From 90affa1301d97e376cdbd29d26d234d8bb90c817 Mon Sep 17 00:00:00 2001 From: Arnold Moyaux Date: Wed, 3 Mar 2021 13:29:33 +0000 Subject: [PATCH] [FIX] stock: replenishment flow Usecase to reproduce: - create a product with 0 stock - Sale order 500 units - a RFQ is created for 500 units - in replenishments, you have 0 units to order, OK - RFQ --> confirm PO - in replenishments, now the quantity to order is 500 --> should still be 0, it leads to confusions It happens because the replenish action check the missing quantity for today. It result by -500 units since the delivery is planned for the next day (default supplier lead days = 1). But since the orderpoint exist, the quantity forecasted is already computed correctly with the right lead time. But the action will remove the 500 missing today from the quantity forecasted (The idea is to add the missing quantity on the main location. e.g. I have -10 units in WH/Stock/Shelf1 -20 units in WH/Stock The orderpoint will only look for its own location -> 20 to reorder The replenishment report look by warehouse and not by location -> 30 missing. So 30 missing + 20 incomming -> 10 to add (by default on WH/Stock) The fix is to add a step when a product is missing then compute its virtual available with lead days (to avoid missing confirmed moves during the lead time). opw-2456978 closes odoo/odoo#69527 X-original-commit: f939c7b2c4143d685c25d155a164d63070d36ed2 Signed-off-by: Arnold Moyaux --- addons/stock/models/stock_orderpoint.py | 21 +++++ .../stock/tests/test_report_stock_quantity.py | 79 ++++++++++++++++++- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/addons/stock/models/stock_orderpoint.py b/addons/stock/models/stock_orderpoint.py index 27e634dcd63..6918a6b5d89 100644 --- a/addons/stock/models/stock_orderpoint.py +++ b/addons/stock/models/stock_orderpoint.py @@ -330,6 +330,27 @@ class StockWarehouseOrderpoint(models.Model): if not to_refill: return action + # Recompute the forecasted quantity for missing product today but at this time + # with their real lead days. + key_to_remove = [] + for (product, warehouse) in to_refill.keys(): + product = self.env['product.product'].browse(product) + warehouse = self.env['stock.warehouse'].browse(warehouse) + rules = product._get_rules_from_location(warehouse.lot_stock_id) + lead_days = rules._get_lead_days(product)[0] + virtual_available = product.with_context( + warehouse=warehouse.id, + to_date=fields.datetime.now() + relativedelta.relativedelta(days=lead_days) + ).virtual_available + if float_compare(virtual_available, 0, precision_rounding=product.uom_id.rounding) >= 0: + key_to_remove.append((product.id, warehouse.id)) + else: + to_refill[(product.id, warehouse.id)] = virtual_available + for key in key_to_remove: + del to_refill[key] + if not to_refill: + return action + # Remove incoming quantity from other origin than moves (e.g RFQ) product_ids, warehouse_ids = zip(*to_refill) dummy, qty_by_product_wh = self.env['product.product'].browse(product_ids)._get_quantity_in_progress(warehouse_ids=warehouse_ids) diff --git a/addons/stock/tests/test_report_stock_quantity.py b/addons/stock/tests/test_report_stock_quantity.py index bce00974312..cdf0e8d596b 100644 --- a/addons/stock/tests/test_report_stock_quantity.py +++ b/addons/stock/tests/test_report_stock_quantity.py @@ -1,6 +1,10 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import timedelta + from odoo import fields, tests +from odoo.tests.common import Form class TestReportStockQuantity(tests.TransactionCase): @@ -14,7 +18,7 @@ class TestReportStockQuantity(tests.TransactionCase): 'tracking': 'lot', 'barcode': 'scan_me' }) - wh = self.env['stock.warehouse'].create({ + self.wh = self.env['stock.warehouse'].create({ 'name': 'Base Warehouse', 'code': 'TESTWH' }) @@ -26,7 +30,7 @@ class TestReportStockQuantity(tests.TransactionCase): self.move1 = self.env['stock.move'].create({ 'name': 'test_in_1', 'location_id': self.supplier_location.id, - 'location_dest_id': wh.lot_stock_id.id, + 'location_dest_id': self.wh.lot_stock_id.id, 'product_id': self.product1.id, 'product_uom': self.uom_unit.id, 'product_uom_qty': 100.0, @@ -35,13 +39,13 @@ class TestReportStockQuantity(tests.TransactionCase): }) self.quant1 = self.env['stock.quant'].create({ 'product_id': self.product1.id, - 'location_id': wh.lot_stock_id.id, + 'location_id': self.wh.lot_stock_id.id, 'quantity': 100.0, }) # ship self.move2 = self.env['stock.move'].create({ 'name': 'test_out_1', - 'location_id': wh.lot_stock_id.id, + 'location_id': self.wh.lot_stock_id.id, 'location_dest_id': self.customer_location.id, 'product_id': self.product1.id, 'product_uom': self.uom_unit.id, @@ -73,3 +77,70 @@ class TestReportStockQuantity(tests.TransactionCase): lazy=False) forecast_report = [x['product_qty'] for x in report if x['state'] == 'forecast'] self.assertEqual(forecast_report, [-20, -20]) + + def test_replenishment_report_1(self): + self.product_replenished = self.env['product.product'].create({ + 'name': 'Security razor', + 'type': 'product', + 'categ_id': self.env.ref('product.product_category_all').id, + }) + # get auto-created pull rule from when warehouse is created + self.wh.reception_route_id.rule_ids.unlink() + self.env['stock.rule'].create({ + 'name': 'Rule Supplier', + 'route_id': self.wh.reception_route_id.id, + 'location_id': self.wh.lot_stock_id.id, + 'location_src_id': self.env.ref('stock.stock_location_suppliers').id, + 'action': 'pull', + 'delay': 1.0, + 'procure_method': 'make_to_stock', + 'picking_type_id': self.wh.in_type_id.id, + }) + delivery_picking = self.env['stock.picking'].create({ + 'location_id': self.wh.lot_stock_id.id, + 'location_dest_id': self.ref('stock.stock_location_customers'), + 'picking_type_id': self.ref('stock.picking_type_out'), + }) + self.env['stock.move'].create({ + 'name': 'Delivery', + 'product_id': self.product_replenished.id, + 'product_uom_qty': 500.0, + 'product_uom': self.uom_unit.id, + 'location_id': self.wh.lot_stock_id.id, + 'location_dest_id': self.ref('stock.stock_location_customers'), + 'picking_id': delivery_picking.id, + }) + delivery_picking.action_confirm() + + # Trigger the manual orderpoint creation for missing product + self.env['stock.move'].flush() + self.env['stock.warehouse.orderpoint'].action_open_orderpoints() + + orderpoint = self.env['stock.warehouse.orderpoint'].search([ + ('product_id', '=', self.product_replenished.id) + ]) + self.assertTrue(orderpoint) + self.assertEqual(orderpoint.location_id, self.wh.lot_stock_id) + self.assertEqual(orderpoint.qty_to_order, 500.0) + orderpoint.action_replenish() + self.env['stock.warehouse.orderpoint'].action_open_orderpoints() + + move = self.env['stock.move'].search([ + ('product_id', '=', self.product_replenished.id), + ('location_dest_id', '=', self.wh.lot_stock_id.id) + ]) + # Simulate a supplier delay + move.date = fields.datetime.now() + timedelta(days=1) + orderpoint = self.env['stock.warehouse.orderpoint'].search([ + ('product_id', '=', self.product_replenished.id) + ]) + self.assertFalse(orderpoint) + + orderpoint_form = Form(self.env['stock.warehouse.orderpoint']) + orderpoint_form.product_id = self.product_replenished + orderpoint_form.location_id = self.wh.lot_stock_id + orderpoint = orderpoint_form.save() + + self.assertEqual(orderpoint.qty_to_order, 0.0) + self.env['stock.warehouse.orderpoint'].action_open_orderpoints() + self.assertEqual(orderpoint.qty_to_order, 0.0)