From fc8ffcd5944427c45c4dadcfd4a07cfc6a559489 Mon Sep 17 00:00:00 2001 From: Lois Rilo Date: Tue, 6 Feb 2024 11:34:35 +0100 Subject: [PATCH] [FIX] mrp_multi_level: wrong safety stock intial order in grouped demand mode Three tests cases modelize the issues being solved. --- mrp_multi_level/tests/test_mrp_multi_level.py | 236 +++++++++++++++++- mrp_multi_level/wizards/mrp_multi_level.py | 36 ++- 2 files changed, 269 insertions(+), 3 deletions(-) diff --git a/mrp_multi_level/tests/test_mrp_multi_level.py b/mrp_multi_level/tests/test_mrp_multi_level.py index 74f9f37b5..5ffd134c5 100644 --- a/mrp_multi_level/tests/test_mrp_multi_level.py +++ b/mrp_multi_level/tests/test_mrp_multi_level.py @@ -521,5 +521,239 @@ class TestMrpMultiLevel(TestMrpMultiLevelCommon): test_vals[key], inv[key], f"unexpected value for {key}: {inv[key]} " - f"(expected {test_vals[key]} on {inv.date}", + f"(expected {test_vals[key]} on {inv.date})", + ) + + def test_19_on_hand_with_lots(self): + """Check that on-hand is correctly computed when tracking by lots.""" + lots_line_1 = self.mrp_inventory_obj.search( + [("product_mrp_area_id.product_id", "=", self.product_lots.id)] + ) + self.assertEqual(len(lots_line_1), 1) + self.assertEqual(lots_line_1.initial_on_hand_qty, 210) + self.assertEqual(lots_line_1.final_on_hand_qty, 185) + + def test_20_prioritize_safety_stock_grouped_1(self): + """Test grouped demand MRP but with a short nbr days. + Safety stock should be ordered.""" + now = datetime.now() + product = self.prod_test # has Buy route + product.seller_ids[0].delay = 2 # set a purchase lead time + self.quant_obj._update_available_quantity(product, self.cases_loc, 5) + self.product_mrp_area_obj.create( + { + "product_id": product.id, + "mrp_area_id": self.cases_area.id, + "mrp_minimum_stock": 15, + "mrp_nbr_days": 2, + } + ) + self._create_picking_out( + product, 6.0, now + timedelta(days=3), location=self.cases_loc + ) + self._create_picking_in( + product, 10.0, now + timedelta(days=7), location=self.cases_loc + ) + self._create_picking_out( + product, 12.0, now + timedelta(days=14), location=self.cases_loc + ) + self.mrp_multi_level_wiz.create( + {"mrp_area_ids": [(6, 0, self.cases_area.ids)]} + ).run_mrp_multi_level() + inventory = self.mrp_inventory_obj.search( + [("mrp_area_id", "=", self.cases_area.id), ("product_id", "=", product.id)] + ) + expected = [ + { + "date": now.date(), + "demand_qty": 0.0, + "final_on_hand_qty": 5.0, + "initial_on_hand_qty": 5.0, + "running_availability": 15.0, + "supply_qty": 0.0, + "to_procure": 10.0, + }, + { + "date": now.date() + timedelta(days=3), + "demand_qty": 6.0, + "final_on_hand_qty": -1.0, + "initial_on_hand_qty": 5.0, + "running_availability": 15.0, + "supply_qty": 0.0, + "to_procure": 6.0, + }, + { + "date": now.date() + timedelta(days=7), + "demand_qty": 0.0, + "final_on_hand_qty": 9.0, + "initial_on_hand_qty": -1.0, + "running_availability": 25.0, + "supply_qty": 10.0, + "to_procure": 0.0, + }, + { + "date": now.date() + timedelta(days=14), + "demand_qty": 12.0, + "final_on_hand_qty": -3.0, + "initial_on_hand_qty": 9.0, + "running_availability": 15.0, + "supply_qty": 0.0, + "to_procure": 2.0, + }, + ] + self.assertEqual(len(expected), len(inventory)) + for test_vals, inv in zip(expected, inventory): + for key in test_vals: + self.assertEqual( + test_vals[key], + inv[key], + f"unexpected value for {key}: {inv[key]} " + f"(expected {test_vals[key]} on {inv.date})", + ) + + def test_21_prioritize_safety_stock_grouped_2(self): + """Test grouped demand MRP but with a longer nbr days. + Safety stock should be ordered.""" + now = datetime.now() + product = self.prod_test # has Buy route + product.seller_ids[0].delay = 2 # set a purchase lead time + self.quant_obj._update_available_quantity(product, self.cases_loc, 5) + self.product_mrp_area_obj.create( + { + "product_id": product.id, + "mrp_area_id": self.cases_area.id, + "mrp_minimum_stock": 15, + "mrp_nbr_days": 7, + } + ) + self._create_picking_out( + product, 6.0, now + timedelta(days=3), location=self.cases_loc + ) + self._create_picking_in( + product, 10.0, now + timedelta(days=7), location=self.cases_loc + ) + self._create_picking_out( + product, 12.0, now + timedelta(days=12), location=self.cases_loc + ) + self.mrp_multi_level_wiz.create( + {"mrp_area_ids": [(6, 0, self.cases_area.ids)]} + ).run_mrp_multi_level() + inventory = self.mrp_inventory_obj.search( + [("mrp_area_id", "=", self.cases_area.id), ("product_id", "=", product.id)] + ) + expected = [ + { + "date": now.date(), + "demand_qty": 0.0, + "final_on_hand_qty": 5.0, + "initial_on_hand_qty": 5.0, + "running_availability": 21.0, + "supply_qty": 0.0, + "to_procure": 16.0, + }, + { + "date": now.date() + timedelta(days=3), + "demand_qty": 6.0, + "final_on_hand_qty": -1.0, + "initial_on_hand_qty": 5.0, + "running_availability": 15.0, + "supply_qty": 0.0, + "to_procure": 0.0, + }, + { + "date": now.date() + timedelta(days=7), + "demand_qty": 0.0, + "final_on_hand_qty": 9.0, + "initial_on_hand_qty": -1.0, + "running_availability": 27.0, + "supply_qty": 10.0, + "to_procure": 2.0, + }, + { + "date": now.date() + timedelta(days=12), + "demand_qty": 12.0, + "final_on_hand_qty": -3.0, + "initial_on_hand_qty": 9.0, + "running_availability": 15.0, + "supply_qty": 0.0, + "to_procure": 0.0, + }, + ] + self.assertEqual(len(expected), len(inventory)) + for test_vals, inv in zip(expected, inventory): + for key in test_vals: + self.assertEqual( + test_vals[key], + inv[key], + f"unexpected value for {key}: {inv[key]} " + f"(expected {test_vals[key]} on {inv.date})", + ) + + def test_22_prioritize_safety_stock_grouped_3(self): + """Test grouped demand MRP but with an existing incoming supply + Safety stock should NOT be ordered.""" + now = datetime.now() + product = self.prod_test # has Buy route + product.seller_ids[0].delay = 2 # set a purchase lead time + self.quant_obj._update_available_quantity(product, self.cases_loc, 5) + self.product_mrp_area_obj.create( + { + "product_id": product.id, + "mrp_area_id": self.cases_area.id, + "mrp_minimum_stock": 15, + "mrp_nbr_days": 7, + } + ) + self._create_picking_in( + product, 30.0, now + timedelta(days=3), location=self.cases_loc + ) + self._create_picking_out( + product, 6.0, now + timedelta(days=7), location=self.cases_loc + ) + self._create_picking_out( + product, 12.0, now + timedelta(days=12), location=self.cases_loc + ) + self.mrp_multi_level_wiz.create( + {"mrp_area_ids": [(6, 0, self.cases_area.ids)]} + ).run_mrp_multi_level() + inventory = self.mrp_inventory_obj.search( + [("mrp_area_id", "=", self.cases_area.id), ("product_id", "=", product.id)] + ) + expected = [ + { + "date": now.date() + timedelta(days=3), + "demand_qty": 0.0, + "initial_on_hand_qty": 5.0, + "final_on_hand_qty": 35.0, + "running_availability": 35.0, + "supply_qty": 30.0, + "to_procure": 0.0, + }, + { + "date": now.date() + timedelta(days=7), + "demand_qty": 6.0, + "initial_on_hand_qty": 35.0, + "final_on_hand_qty": 29.0, + "running_availability": 29.0, + "supply_qty": 0.0, + "to_procure": 0.0, + }, + { + "date": now.date() + timedelta(days=12), + "demand_qty": 12.0, + "initial_on_hand_qty": 29.0, + "final_on_hand_qty": 17.0, + "running_availability": 17.0, + "supply_qty": 0.0, + "to_procure": 0.0, + }, + ] + self.assertEqual(len(expected), len(inventory)) + for test_vals, inv in zip(expected, inventory): + for key in test_vals: + self.assertEqual( + test_vals[key], + inv[key], + f"unexpected value for {key}: {inv[key]} " + f"(expected {test_vals[key]} on {inv.date})", ) diff --git a/mrp_multi_level/wizards/mrp_multi_level.py b/mrp_multi_level/wizards/mrp_multi_level.py index cafaed733..d7cc22d04 100644 --- a/mrp_multi_level/wizards/mrp_multi_level.py +++ b/mrp_multi_level/wizards/mrp_multi_level.py @@ -555,6 +555,37 @@ class MultiLevelMrp(models.TransientModel): onhand = product_mrp_area.qty_available grouping_delta = product_mrp_area.mrp_nbr_days demand_origin = [] + + if ( + product_mrp_area.mrp_move_ids + and onhand < product_mrp_area.mrp_minimum_stock + ): + last_date = self._get_safety_stock_target_date(product_mrp_area) + demand_origin.append("Safety Stock") + move = fields.first(product_mrp_area.mrp_move_ids) + if last_date and ( + fields.Date.from_string(move.mrp_date) + >= last_date + timedelta(days=grouping_delta) + ): + name = _("Safety Stock") + origin = ",".join(list({x for x in demand_origin if x})) + qtytoorder = self._get_qty_to_order( + product_mrp_area, last_date, 0, onhand + ) + cm = self.create_action( + product_mrp_area_id=product_mrp_area, + mrp_date=last_date, + mrp_qty=qtytoorder, + name=name, + values=dict(origin=origin), + ) + qty_ordered = cm.get("qty_ordered", 0.0) + onhand = onhand + qty_ordered + last_date = None + last_qty = 0.00 + nbr_create += 1 + demand_origin = [] + for move in product_mrp_area.mrp_move_ids: if self._exclude_move(move): continue @@ -598,7 +629,7 @@ class MultiLevelMrp(models.TransientModel): ) < product_mrp_area.mrp_minimum_stock or ( onhand + last_qty ) < product_mrp_area.mrp_minimum_stock: - if not last_date or last_qty == 0.0: + if not last_date: last_date = fields.Date.from_string(move.mrp_date) last_qty = move.mrp_qty else: @@ -629,9 +660,10 @@ class MultiLevelMrp(models.TransientModel): ) qty_ordered = cm.get("qty_ordered", 0.0) onhand += qty_ordered + last_qty -= qty_ordered nbr_create += 1 - if onhand < product_mrp_area.mrp_minimum_stock: + if (onhand + last_qty) < product_mrp_area.mrp_minimum_stock: mrp_date = self._get_safety_stock_target_date(product_mrp_area) qtytoorder = self._get_qty_to_order(product_mrp_area, mrp_date, 0, onhand) name = _("Safety Stock")