From 099c5a594e72101923881ad1aa26ec3d85ee8473 Mon Sep 17 00:00:00 2001 From: Alexandre Fayolle Date: Mon, 6 Nov 2023 15:32:24 +0100 Subject: [PATCH] [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 | 3 +- mrp_multi_level/models/product_mrp_area.py | 1 - mrp_multi_level/readme/CONTRIBUTORS.rst | 1 + mrp_multi_level/static/description/index.html | 4 +- mrp_multi_level/tests/common.py | 1 + mrp_multi_level/tests/test_mrp_multi_level.py | 81 ++++++++++- mrp_multi_level/wizards/mrp_multi_level.py | 132 ++++++++++++++---- 7 files changed, 194 insertions(+), 29 deletions(-) diff --git a/mrp_multi_level/README.rst b/mrp_multi_level/README.rst index efa74f787..8504944e7 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:ab86a0f0bdde79f5b087dc2279034143e68492fc72d50e41f6aba8c459e3cda0 + !! source digest: sha256:16854b8ceefe95939168a06e911b981bf26a5c916735aa975382ba488b1291d6 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png @@ -231,6 +231,7 @@ Contributors * Héctor Villarreal * Christopher Ormaza * Joan Sisquella +* Alexandre Fayolle Maintainers ~~~~~~~~~~~ diff --git a/mrp_multi_level/models/product_mrp_area.py b/mrp_multi_level/models/product_mrp_area.py index ef62c12ef..e6114d394 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/CONTRIBUTORS.rst b/mrp_multi_level/readme/CONTRIBUTORS.rst index 6f4590cee..76b6b5572 100644 --- a/mrp_multi_level/readme/CONTRIBUTORS.rst +++ b/mrp_multi_level/readme/CONTRIBUTORS.rst @@ -4,3 +4,4 @@ * Héctor Villarreal * Christopher Ormaza * Joan Sisquella +* Alexandre Fayolle diff --git a/mrp_multi_level/static/description/index.html b/mrp_multi_level/static/description/index.html index 3c5e51511..8190a5ed8 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:ab86a0f0bdde79f5b087dc2279034143e68492fc72d50e41f6aba8c459e3cda0 +!! source digest: sha256:16854b8ceefe95939168a06e911b981bf26a5c916735aa975382ba488b1291d6 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

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 @@ -619,6 +620,7 @@ If you spotted it first, help us to smash it by providing a detailed and welcome

  • Héctor Villarreal <hector.villarreal@forgeflow.com>
  • Christopher Ormaza <chris.ormaza@forgeflow.com>
  • Joan Sisquella <joan.sisquella@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 73d74f3b0..ad03b3049 100644 --- a/mrp_multi_level/tests/common.py +++ b/mrp_multi_level/tests/common.py @@ -15,6 +15,7 @@ class TestMrpMultiLevelCommon(TransactionCase): 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 8bcf81f7e..9dcebdaa2 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 @@ -445,3 +445,82 @@ class TestMrpMultiLevel(TestMrpMultiLevelCommon): ] product_mrp_area._compute_supply_method() self.assertEqual(product_mrp_area.supply_method, "buy") + + 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 d44d725f6..79bc10550 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),