From 9d0b11ddbe9110db5f844d1edfd2beead41827f3 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Mon, 6 Nov 2023 15:32:24 +0100 Subject: [PATCH 1/6] [IMP] mrp_multi_level: safety stock When a product has a safety stock in an area, and the current stock is below safety, but there are moves in the future, mrp_multi_level does not compute an immediate action to get back to the safety stock. This PR changes this behavior: when the mrp_moves are considered, before processing the first move in the future, we insert a resupply action to rebuild the safety stock. We also add some refactoring in the process: * add extension point on the wizard to compute the quantity to reorder (so we can choose in a separate module whether to rebuild safety stock or not) * add extension point on the wizard to get the date at which the safety stock must be rebuilt (defaults to today) * make the code of the wizard symetric between the groupes and non grouped configuration --- mrp_multi_level/README.rst | 6 +- mrp_multi_level/models/product_mrp_area.py | 1 - mrp_multi_level/readme/CONFIGURE.rst | 2 + mrp_multi_level/readme/CONTRIBUTORS.rst | 2 + mrp_multi_level/static/description/index.html | 5 +- mrp_multi_level/tests/common.py | 1 + mrp_multi_level/tests/test_mrp_multi_level.py | 78 ++++++++++- mrp_multi_level/wizards/mrp_multi_level.py | 132 ++++++++++++++---- 8 files changed, 198 insertions(+), 29 deletions(-) diff --git a/mrp_multi_level/README.rst b/mrp_multi_level/README.rst index fa24be346..43860a725 100644 --- a/mrp_multi_level/README.rst +++ b/mrp_multi_level/README.rst @@ -7,7 +7,7 @@ MRP Multi Level !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:6be0420cb90de94af20fe0c8f65391d1b24738d2b1135fde9200b5f6702c5b22 + !! source digest: sha256:ec517bd88092825314f02e061fee665bbf6c106491161601a02197e6d9beff36 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png @@ -60,6 +60,8 @@ MRP Areas * Go to *Manufacturing > Configuration > MRP Areas* and define or edit any existing area. You can specify the working hours for every area. + + Product MRP Area Parameters ~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -229,6 +231,8 @@ Contributors * Jordi Ballester * Lois Rilo * Héctor Villarreal +* Christopher Ormaza +* Alexandre Fayolle Maintainers ~~~~~~~~~~~ diff --git a/mrp_multi_level/models/product_mrp_area.py b/mrp_multi_level/models/product_mrp_area.py index 08b4edf3e..6b8fb7bb6 100644 --- a/mrp_multi_level/models/product_mrp_area.py +++ b/mrp_multi_level/models/product_mrp_area.py @@ -3,7 +3,6 @@ # - Jordi Ballester Alomar # - Lois Rilo Antelo # License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). - from math import ceil from odoo import _, api, fields, models diff --git a/mrp_multi_level/readme/CONFIGURE.rst b/mrp_multi_level/readme/CONFIGURE.rst index 3825da541..9fec29243 100644 --- a/mrp_multi_level/readme/CONFIGURE.rst +++ b/mrp_multi_level/readme/CONFIGURE.rst @@ -4,6 +4,8 @@ MRP Areas * Go to *Manufacturing > Configuration > MRP Areas* and define or edit any existing area. You can specify the working hours for every area. + + Product MRP Area Parameters ~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/mrp_multi_level/readme/CONTRIBUTORS.rst b/mrp_multi_level/readme/CONTRIBUTORS.rst index 8c327e8ca..24af598a2 100644 --- a/mrp_multi_level/readme/CONTRIBUTORS.rst +++ b/mrp_multi_level/readme/CONTRIBUTORS.rst @@ -2,3 +2,5 @@ * Jordi Ballester * Lois Rilo * Héctor Villarreal +* Christopher Ormaza +* Alexandre Fayolle diff --git a/mrp_multi_level/static/description/index.html b/mrp_multi_level/static/description/index.html index b6c108ae5..e88333a6b 100644 --- a/mrp_multi_level/static/description/index.html +++ b/mrp_multi_level/static/description/index.html @@ -1,3 +1,4 @@ + @@ -366,7 +367,7 @@ ul.auto-toc { !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:6be0420cb90de94af20fe0c8f65391d1b24738d2b1135fde9200b5f6702c5b22 +!! source digest: sha256:ec517bd88092825314f02e061fee665bbf6c106491161601a02197e6d9beff36 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Production/Stable License: LGPL-3 OCA/manufacture Translate me on Weblate Try me on Runboat

This module allows you to calculate, based in known inventory, demand, and @@ -617,6 +618,8 @@ If you spotted it first, help us to smash it by providing a detailed and welcome

  • Jordi Ballester <jordi.ballester@forgeflow.com>
  • Lois Rilo <lois.rilo@forgeflow.com>
  • Héctor Villarreal <hector.villarreal@forgeflow.com>
  • +
  • Christopher Ormaza <chris.ormaza@forgeflow.com>
  • +
  • Alexandre Fayolle <alexandre.fayolle@camptocamp.com>
  • diff --git a/mrp_multi_level/tests/common.py b/mrp_multi_level/tests/common.py index dce257246..73e313c42 100644 --- a/mrp_multi_level/tests/common.py +++ b/mrp_multi_level/tests/common.py @@ -15,6 +15,7 @@ class TestMrpMultiLevelCommon(SavepointCase): cls.po_obj = cls.env["purchase.order"] cls.product_obj = cls.env["product.product"] cls.loc_obj = cls.env["stock.location"] + cls.quant_obj = cls.env["stock.quant"] cls.mrp_area_obj = cls.env["mrp.area"] cls.product_mrp_area_obj = cls.env["product.mrp.area"] cls.partner_obj = cls.env["res.partner"] diff --git a/mrp_multi_level/tests/test_mrp_multi_level.py b/mrp_multi_level/tests/test_mrp_multi_level.py index 934471d7e..74f9f37b5 100644 --- a/mrp_multi_level/tests/test_mrp_multi_level.py +++ b/mrp_multi_level/tests/test_mrp_multi_level.py @@ -1,7 +1,7 @@ # Copyright 2018-19 ForgeFlow S.L. (https://www.forgeflow.com) # License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). -from datetime import date, datetime +from datetime import date, datetime, timedelta from odoo import fields @@ -447,3 +447,79 @@ class TestMrpMultiLevel(TestMrpMultiLevelCommon): self.fp_4.route_ids = [(4, self.env.ref("mrp.route_warehouse0_manufacture").id)] product_mrp_area._compute_supply_method() self.assertEqual(product_mrp_area.supply_method, "manufacture") + + def test_18_priorize_safety_stock(self): + 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_applicable": True, # needed? + } + ) + 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}", + ) diff --git a/mrp_multi_level/wizards/mrp_multi_level.py b/mrp_multi_level/wizards/mrp_multi_level.py index a8c40f8c4..330dd1344 100644 --- a/mrp_multi_level/wizards/mrp_multi_level.py +++ b/mrp_multi_level/wizards/mrp_multi_level.py @@ -538,6 +538,16 @@ class MultiLevelMrp(models.TransientModel): self._init_mrp_move(product_mrp_area) logger.info("End MRP initialisation") + def _get_qty_to_order(self, product_mrp_area, date, move_qty, onhand): + """Compute the qty to order at a given date, for a product MRP area, given an + mrp.move quantity and an onhand quantity. + + This method is an extension point, allowing a new module to change the way this + quantity should be computed. + """ + # The default rule is to resupply to rebuild the safety stock + return product_mrp_area.mrp_minimum_stock - onhand - move_qty + @api.model def _init_mrp_move_grouped_demand(self, nbr_create, product_mrp_area): last_date = None @@ -567,7 +577,9 @@ class MultiLevelMrp(models.TransientModel): delta_days=grouping_delta, ) origin = ",".join(list({x for x in demand_origin if x})) - qtytoorder = product_mrp_area.mrp_minimum_stock - onhand - last_qty + qtytoorder = self._get_qty_to_order( + product_mrp_area, last_date, last_qty, onhand + ) cm = self.create_action( product_mrp_area_id=product_mrp_area, mrp_date=last_date, @@ -605,7 +617,9 @@ class MultiLevelMrp(models.TransientModel): delta_days=grouping_delta, ) origin = ",".join(list({x for x in demand_origin if x})) - qtytoorder = product_mrp_area.mrp_minimum_stock - onhand - last_qty + qtytoorder = self._get_qty_to_order( + product_mrp_area, last_date, last_qty, onhand + ) cm = self.create_action( product_mrp_area_id=product_mrp_area, mrp_date=last_date, @@ -616,6 +630,88 @@ class MultiLevelMrp(models.TransientModel): qty_ordered = cm.get("qty_ordered", 0.0) onhand += qty_ordered nbr_create += 1 + + if onhand < 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") + cm = self.create_action( + product_mrp_area_id=product_mrp_area, + mrp_date=mrp_date, + mrp_qty=qtytoorder, + name=name, + values=dict(origin=name), + ) + qty_ordered = cm["qty_ordered"] + onhand += qty_ordered + nbr_create += 1 + + return nbr_create + + def _get_safety_stock_target_date(self, product_mrp_area): + """Get the date at which the safety stock rebuild should be targeted + + This method is an extension point for modules who need to cusomize that date.""" + return date.today() + + @api.model + def _init_mrp_move_non_grouped_demand(self, nbr_create, product_mrp_area): + onhand = product_mrp_area.qty_available + for move in product_mrp_area.mrp_move_ids: + if self._exclude_move(move): + continue + if onhand < product_mrp_area.mrp_minimum_stock: + qtytoorder = self._get_qty_to_order( + product_mrp_area, + self._get_safety_stock_target_date(product_mrp_area), + 0, + onhand, + ) + name = _("Safety Stock") + cm = self.create_action( + product_mrp_area_id=product_mrp_area, + mrp_date=self._get_safety_stock_target_date(product_mrp_area), + mrp_qty=qtytoorder, + name=name, + values=dict(origin=name), + ) + qty_ordered = cm["qty_ordered"] + onhand += qty_ordered + nbr_create += 1 + + qtytoorder = self._get_qty_to_order( + product_mrp_area, move.mrp_date, move.mrp_qty, onhand + ) + if qtytoorder > 0.0: + cm = self.create_action( + product_mrp_area_id=product_mrp_area, + mrp_date=move.mrp_date, + mrp_qty=qtytoorder, + name=move.name or "", + values=dict(origin=move.origin or ""), + ) + qty_ordered = cm["qty_ordered"] + onhand += move.mrp_qty + qty_ordered + nbr_create += 1 + else: + onhand += move.mrp_qty + if onhand < 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, move.mrp_date, 0, onhand + ) + name = _("Safety Stock") + cm = self.create_action( + product_mrp_area_id=product_mrp_area, + mrp_date=mrp_date, + mrp_qty=qtytoorder, + name=name, + values=dict(origin=name), + ) + qty_ordered = cm["qty_ordered"] + onhand += qty_ordered + nbr_create += 1 + return nbr_create @api.model @@ -641,39 +737,25 @@ class MultiLevelMrp(models.TransientModel): for product_mrp_area in product_mrp_areas: nbr_create = 0 onhand = product_mrp_area.qty_available + if product_mrp_area.mrp_nbr_days == 0: - for move in product_mrp_area.mrp_move_ids: - if self._exclude_move(move): - continue - qtytoorder = ( - product_mrp_area.mrp_minimum_stock - - onhand - - move.mrp_qty - ) - if qtytoorder > 0.0: - cm = self.create_action( - product_mrp_area_id=product_mrp_area, - mrp_date=move.mrp_date, - mrp_qty=qtytoorder, - name=move.name or "", - values=dict(origin=move.origin or ""), - ) - qty_ordered = cm["qty_ordered"] - onhand += move.mrp_qty + qty_ordered - nbr_create += 1 - else: - onhand += move.mrp_qty + nbr_create = self._init_mrp_move_non_grouped_demand( + nbr_create, product_mrp_area + ) else: nbr_create = self._init_mrp_move_grouped_demand( nbr_create, product_mrp_area ) if onhand < product_mrp_area.mrp_minimum_stock and nbr_create == 0: - qtytoorder = product_mrp_area.mrp_minimum_stock - onhand + mrp_date = date.today() + qtytoorder = self._get_qty_to_order( + product_mrp_area, mrp_date, 0, onhand + ) name = _("Safety Stock") cm = self.create_action( product_mrp_area_id=product_mrp_area, - mrp_date=date.today(), + mrp_date=mrp_date, mrp_qty=qtytoorder, name=name, values=dict(origin=name), From 11b3f934e7d8375792b3487d7dc6558a42d3b706 Mon Sep 17 00:00:00 2001 From: Lois Rilo Date: Thu, 25 Jan 2024 18:31:05 -0700 Subject: [PATCH 2/6] [FIX] mrp_multi_level: ariable 'move' referenced before assignment --- mrp_multi_level/wizards/mrp_multi_level.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mrp_multi_level/wizards/mrp_multi_level.py b/mrp_multi_level/wizards/mrp_multi_level.py index 330dd1344..cafaed733 100644 --- a/mrp_multi_level/wizards/mrp_multi_level.py +++ b/mrp_multi_level/wizards/mrp_multi_level.py @@ -697,9 +697,7 @@ class MultiLevelMrp(models.TransientModel): onhand += move.mrp_qty if onhand < 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, move.mrp_date, 0, onhand - ) + qtytoorder = self._get_qty_to_order(product_mrp_area, mrp_date, 0, onhand) name = _("Safety Stock") cm = self.create_action( product_mrp_area_id=product_mrp_area, From fc8ffcd5944427c45c4dadcfd4a07cfc6a559489 Mon Sep 17 00:00:00 2001 From: Lois Rilo Date: Tue, 6 Feb 2024 11:34:35 +0100 Subject: [PATCH 3/6] [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") From 6ed19deb8c3a96ec44dce31b7248be69e6094863 Mon Sep 17 00:00:00 2001 From: Lois Rilo Date: Mon, 22 Jan 2024 15:53:58 -0700 Subject: [PATCH 4/6] [FIX] mrp_multi_level: starting qty on hand wrong when using lots Unify the way to get the starting on hand whenever needed in MRP calculations. --- mrp_multi_level/tests/common.py | 52 ++++++++++++++++++++++ mrp_multi_level/wizards/mrp_multi_level.py | 21 +-------- 2 files changed, 53 insertions(+), 20 deletions(-) diff --git a/mrp_multi_level/tests/common.py b/mrp_multi_level/tests/common.py index 73e313c42..4cb1c8f8f 100644 --- a/mrp_multi_level/tests/common.py +++ b/mrp_multi_level/tests/common.py @@ -26,6 +26,8 @@ class TestMrpMultiLevelCommon(SavepointCase): cls.mrp_inventory_obj = cls.env["mrp.inventory"] cls.mrp_move_obj = cls.env["mrp.move"] cls.planned_order_obj = cls.env["mrp.planned.order"] + cls.lot_model = cls.env["stock.production.lot"] + cls.quant_model = cls.env["stock.quant"] cls.fp_1 = cls.env.ref("mrp_multi_level.product_product_fp_1") cls.fp_2 = cls.env.ref("mrp_multi_level.product_product_fp_2") @@ -221,6 +223,53 @@ class TestMrpMultiLevelCommon(SavepointCase): cls.product_mrp_area_obj.create( {"product_id": cls.prod_uom_test.id, "mrp_area_id": cls.mrp_area.id} ) + # Product to test lots + cls.product_lots = cls.product_obj.create( + { + "name": "Product Tracked by Lots", + "type": "product", + "tracking": "lot", + "uom_id": cls.env.ref("uom.product_uom_unit").id, + "list_price": 100.0, + "produce_delay": 5.0, + "route_ids": [(6, 0, [route_buy])], + "seller_ids": [(0, 0, {"name": vendor1.id, "price": 25.0})], + } + ) + cls.product_mrp_area_obj.create( + {"product_id": cls.product_lots.id, "mrp_area_id": cls.mrp_area.id} + ) + cls.lot_1 = cls.lot_model.create( + { + "product_id": cls.product_lots.id, + "name": "Lot 1", + "company_id": cls.company.id, + } + ) + cls.lot_2 = cls.lot_model.create( + { + "product_id": cls.product_lots.id, + "name": "Lot 2", + "company_id": cls.company.id, + } + ) + cls.quant_model.sudo().create( + { + "product_id": cls.product_lots.id, + "lot_id": cls.lot_1.id, + "quantity": 100.0, + "location_id": cls.stock_location.id, + } + ) + cls.quant_model.sudo().create( + { + "product_id": cls.product_lots.id, + "lot_id": cls.lot_2.id, + "quantity": 110.0, + "location_id": cls.stock_location.id, + } + ) + # Product MRP Parameter to test supply method computation cls.env.ref("stock.route_warehouse0_mto").active = True cls.env["stock.rule"].create( @@ -447,6 +496,9 @@ class TestMrpMultiLevelCommon(SavepointCase): cls.create_demand_sec_loc(cls.date_20, 46.0) cls.create_demand_sec_loc(cls.date_22, 33.0) + # Create pickings: + cls._create_picking_out(cls.product_lots, 25, today) + cls.mrp_multi_level_wiz.create({}).run_mrp_multi_level() @classmethod diff --git a/mrp_multi_level/wizards/mrp_multi_level.py b/mrp_multi_level/wizards/mrp_multi_level.py index d7cc22d04..1b2c6725a 100644 --- a/mrp_multi_level/wizards/mrp_multi_level.py +++ b/mrp_multi_level/wizards/mrp_multi_level.py @@ -23,23 +23,6 @@ class MultiLevelMrp(models.TransientModel): help="If empty, all areas will be computed.", ) - @api.model - def _prepare_product_mrp_area_data(self, product_mrp_area): - qty_available = 0.0 - product_obj = self.env["product.product"] - location_ids = product_mrp_area._get_locations() - for location in location_ids: - product_l = product_obj.with_context({"location": location.id}).browse( - product_mrp_area.product_id.id - ) - qty_available += product_l.qty_available - - return { - "product_mrp_area_id": product_mrp_area.id, - "mrp_qty_available": qty_available, - "mrp_llc": product_mrp_area.product_id.llc, - } - @api.model def _prepare_mrp_move_data_from_stock_move( self, product_mrp_area, move, direction="in" @@ -893,9 +876,7 @@ class MultiLevelMrp(models.TransientModel): [("product_mrp_area_id", "=", product_mrp_area.id)], order="due_date" ).mapped("due_date") mrp_dates = set(moves_dates + action_dates) - on_hand_qty = product_mrp_area.product_id.with_context( - location=product_mrp_area._get_locations().ids - )._product_available()[product_mrp_area.product_id.id]["qty_available"] + on_hand_qty = product_mrp_area.qty_available running_availability = on_hand_qty mrp_inventory_vals = [] for mdt in sorted(mrp_dates): From 8f0be86313c5ba30360ab2e59c2581b32e50bb6f Mon Sep 17 00:00:00 2001 From: Lois Rilo Date: Wed, 24 Jan 2024 09:35:24 -0700 Subject: [PATCH 5/6] [IMP] mrp_multi_level: add date to default grouping filters It is the default colum for pivot view. --- mrp_multi_level/views/mrp_inventory_views.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mrp_multi_level/views/mrp_inventory_views.xml b/mrp_multi_level/views/mrp_inventory_views.xml index d368f235c..f6f07d15b 100644 --- a/mrp_multi_level/views/mrp_inventory_views.xml +++ b/mrp_multi_level/views/mrp_inventory_views.xml @@ -141,6 +141,11 @@ string="Main Supplier" context="{'group_by':'main_supplier_id'}" /> + Date: Wed, 14 Feb 2024 13:07:35 +0100 Subject: [PATCH 6/6] [FIX] mrp_multi_level: Prioritize safety stock with mrp moves today If I have 0 units, my safety stock is 5 units and today I have a supply for 10 units, the procurement recommendation should be 0 units --- mrp_multi_level/tests/test_mrp_multi_level.py | 85 +++++++++++++++++++ mrp_multi_level/wizards/mrp_multi_level.py | 46 ++-------- 2 files changed, 92 insertions(+), 39 deletions(-) diff --git a/mrp_multi_level/tests/test_mrp_multi_level.py b/mrp_multi_level/tests/test_mrp_multi_level.py index 5ffd134c5..6095aec3f 100644 --- a/mrp_multi_level/tests/test_mrp_multi_level.py +++ b/mrp_multi_level/tests/test_mrp_multi_level.py @@ -757,3 +757,88 @@ class TestMrpMultiLevel(TestMrpMultiLevelCommon): f"unexpected value for {key}: {inv[key]} " f"(expected {test_vals[key]} on {inv.date})", ) + + def test_23_prioritize_safety_stock_with_mrp_moves_today(self): + """Test MRP but with moves today. 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, + } + ) + self._create_picking_out(product, 10.0, now, location=self.cases_loc) + self._create_picking_in(product, 20.0, now, 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": 10.0, + "final_on_hand_qty": 15.0, + "initial_on_hand_qty": 5.0, + "running_availability": 15.0, + "supply_qty": 20.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_24_prioritize_safety_stock_with_mrp_moves_today_grouped(self): + """Test grouped demand MRP but with moves today. 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": 2, + } + ) + self._create_picking_out(product, 10.0, now, location=self.cases_loc) + self._create_picking_in(product, 20.0, now, 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": 10.0, + "final_on_hand_qty": 15.0, + "initial_on_hand_qty": 5.0, + "running_availability": 15.0, + "supply_qty": 20.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 1b2c6725a..36dfd96d5 100644 --- a/mrp_multi_level/wizards/mrp_multi_level.py +++ b/mrp_multi_level/wizards/mrp_multi_level.py @@ -532,7 +532,7 @@ class MultiLevelMrp(models.TransientModel): return product_mrp_area.mrp_minimum_stock - onhand - move_qty @api.model - def _init_mrp_move_grouped_demand(self, nbr_create, product_mrp_area): + def _init_mrp_move_grouped_demand(self, product_mrp_area): last_date = None last_qty = 0.00 onhand = product_mrp_area.qty_available @@ -566,7 +566,6 @@ class MultiLevelMrp(models.TransientModel): 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: @@ -605,7 +604,6 @@ class MultiLevelMrp(models.TransientModel): onhand = onhand + last_qty + qty_ordered last_date = None last_qty = 0.00 - nbr_create += 1 demand_origin = [] if ( onhand + last_qty + move.mrp_qty @@ -644,7 +642,6 @@ class MultiLevelMrp(models.TransientModel): qty_ordered = cm.get("qty_ordered", 0.0) onhand += qty_ordered last_qty -= qty_ordered - nbr_create += 1 if (onhand + last_qty) < product_mrp_area.mrp_minimum_stock: mrp_date = self._get_safety_stock_target_date(product_mrp_area) @@ -659,9 +656,6 @@ class MultiLevelMrp(models.TransientModel): ) qty_ordered = cm["qty_ordered"] onhand += qty_ordered - nbr_create += 1 - - return nbr_create def _get_safety_stock_target_date(self, product_mrp_area): """Get the date at which the safety stock rebuild should be targeted @@ -670,12 +664,14 @@ class MultiLevelMrp(models.TransientModel): return date.today() @api.model - def _init_mrp_move_non_grouped_demand(self, nbr_create, product_mrp_area): + def _init_mrp_move_non_grouped_demand(self, product_mrp_area): onhand = product_mrp_area.qty_available for move in product_mrp_area.mrp_move_ids: if self._exclude_move(move): continue - if onhand < product_mrp_area.mrp_minimum_stock: + # This works because mrp moves are ordered by: + # product_mrp_area_id, mrp_date, mrp_type desc, id + if onhand + move.mrp_qty < product_mrp_area.mrp_minimum_stock: qtytoorder = self._get_qty_to_order( product_mrp_area, self._get_safety_stock_target_date(product_mrp_area), @@ -692,7 +688,6 @@ class MultiLevelMrp(models.TransientModel): ) qty_ordered = cm["qty_ordered"] onhand += qty_ordered - nbr_create += 1 qtytoorder = self._get_qty_to_order( product_mrp_area, move.mrp_date, move.mrp_qty, onhand @@ -707,7 +702,6 @@ class MultiLevelMrp(models.TransientModel): ) qty_ordered = cm["qty_ordered"] onhand += move.mrp_qty + qty_ordered - nbr_create += 1 else: onhand += move.mrp_qty if onhand < product_mrp_area.mrp_minimum_stock: @@ -723,9 +717,6 @@ class MultiLevelMrp(models.TransientModel): ) qty_ordered = cm["qty_ordered"] onhand += qty_ordered - nbr_create += 1 - - return nbr_create @api.model def _exclude_move(self, move): @@ -748,33 +739,10 @@ class MultiLevelMrp(models.TransientModel): llc += 1 for product_mrp_area in product_mrp_areas: - nbr_create = 0 - onhand = product_mrp_area.qty_available - if product_mrp_area.mrp_nbr_days == 0: - nbr_create = self._init_mrp_move_non_grouped_demand( - nbr_create, product_mrp_area - ) + self._init_mrp_move_non_grouped_demand(product_mrp_area) else: - nbr_create = self._init_mrp_move_grouped_demand( - nbr_create, product_mrp_area - ) - - if onhand < product_mrp_area.mrp_minimum_stock and nbr_create == 0: - mrp_date = date.today() - qtytoorder = self._get_qty_to_order( - product_mrp_area, mrp_date, 0, onhand - ) - name = _("Safety Stock") - cm = self.create_action( - product_mrp_area_id=product_mrp_area, - mrp_date=mrp_date, - mrp_qty=qtytoorder, - name=name, - values=dict(origin=name), - ) - qty_ordered = cm["qty_ordered"] - onhand += qty_ordered + self._init_mrp_move_grouped_demand(product_mrp_area) counter += 1 log_msg = "MRP Calculation LLC {} Finished - Nbr. products: {}".format(