From b733ed96577efe3c12c2e3b6d0f4cd180a8d9b2c 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 | 259 ------- 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 | 638 ------------------ 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 | 136 +++- 8 files changed, 191 insertions(+), 926 deletions(-) delete mode 100644 mrp_multi_level/README.rst delete mode 100644 mrp_multi_level/static/description/index.html diff --git a/mrp_multi_level/README.rst b/mrp_multi_level/README.rst deleted file mode 100644 index f8bdd9855..000000000 --- a/mrp_multi_level/README.rst +++ /dev/null @@ -1,259 +0,0 @@ -=============== -MRP Multi Level -=============== - -.. - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! This file is generated by oca-gen-addon-readme !! - !! changes will be overwritten. !! - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:ade6783d411926bfe80610b33ea8aff7a931f7b8c3336b0b44c9d78eea162757 - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - -.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png - :target: https://odoo-community.org/page/development-status - :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png - :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html - :alt: License: LGPL-3 -.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fmanufacture-lightgray.png?logo=github - :target: https://github.com/OCA/manufacture/tree/13.0/mrp_multi_level - :alt: OCA/manufacture -.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/manufacture-13-0/manufacture-13-0-mrp_multi_level - :alt: Translate me on Weblate -.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png - :target: https://runboat.odoo-community.org/builds?repo=OCA/manufacture&target_branch=13.0 - :alt: Try me on Runboat - -|badge1| |badge2| |badge3| |badge4| |badge5| - -This module allows you to calculate, based in known inventory, demand, and -supply, and based on parameters set at product variant level, the new -procurements for each product. - -To do this, the calculation starts at top level of the bill of material -and explodes this down to the lowest level. - -Key Features ------------- - -* MRP parameters set by product variant MRP area pairs. -* Cron job to calculate the MRP demand. -* Manually calculate the MRP demand. -* Confirm the calculated MRP demand and create PO's, or MO's. -* Able to see the products for which action is needed throught Planned Orders. -* Integration with `Stock Demand Estimates `_ system. - Note: You need to install `mrp_multi_level_estimate module `_. - -**Table of contents** - -.. contents:: - :local: - -Configuration -============= - -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 -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -* Go to *Manufacturing > Master Data > Product MRP Area Parameters* and set - the MRP parameters for a given product and area. - -Usage -===== - -To manually run the MRP scheduler: - -#. Go to *Manufacturing > Operations > Run MRP Multi Level*. -#. On the wizard click *Run MRP*. - -To launch replenishment orders (moves, purchases, production orders...): - -#. Go to *Manufacturing > Operations > MRP Inventory*. -#. Filter with *To procure*. -#. Select multiple records and click on *Action > Procure* or click the right - hand side gears in any record. -#. On the wizard, check everything is ok and click *Execute*. - -Changelog -========= - -13.0.1.5.0 (2020-04-09) -~~~~~~~~~~~~~~~~~~~~~~~ - -**Features** - -- Show *Run MRP Multi Level* menu only to a specific new security group *Run MRP Manually*. (`#492 `_) - - -13.0.1.4.0 (2020-03-26) -~~~~~~~~~~~~~~~~~~~~~~~ - * Add menu entry for planned orders - * Add button to navigate from planned orders to linked manufacturing orders - * Add action to convert planned orders to fixed - * When changing the due date in a planned order the release date is recomputed - -13.0.1.3.0 (2020-03-02) -~~~~~~~~~~~~~~~~~~~~~~~ - -* [IMP] Minor changes" - (`#470 `_). - - * Planned Order release and due date become required. - * Add button to Product MRP Area to update MOQ from Supplier Info. - * Link Manufacturing Orders with Planned Orders. - * Allow Mrp Inventory Procure Wizard to be used from other models. - * Make MRP Inventory creation more extensible. - * Main Supplier computation (v13 requires explicit False definitions) - -13.0.1.2.0 (2020-02-20) -~~~~~~~~~~~~~~~~~~~~~~~ - -* [IMP] Minor changes - (`#468 `_). - - * Planned Orders become fixed on manual creation by default - * Released Quantity becomes readonly - * Add product reference if Planned Order name is not defined on bom explosion - -13.0.1.1.0 (2020-02-21) -~~~~~~~~~~~~~~~~~~~~~~~ - -* [FIX] Minor changes - (`#469 `_). - - * Fix Main supplier computation in multi company - * Drop Triplicated field in search view - - -* [IMP] Minor changes - (`#463 `_). - - * Show supply method on MRP Inventory - * Allow no-MRP users to look into Products - -13.0.1.0.0 (2019-12-18) -~~~~~~~~~~~~~~~~~~~~~~~ - -* [MIG] Migration to v13. - -12.0.1.0.0 (2019-08-05) -~~~~~~~~~~~~~~~~~~~~~~~ - -* [MIG] Migration to v12: - - * Estimates as a forecasting mechanism is moved to a new module - (mrp_multi_level_estimate). - -11.0.3.0.0 (2019-05-22) -~~~~~~~~~~~~~~~~~~~~~~~ - -* [REW/IMP] Rework to include Planned Orders. - (`#365 `_). -* [IMP] Able to procure from a different location than the area's location. - -11.0.2.2.0 (2019-05-02) -~~~~~~~~~~~~~~~~~~~~~~~ - -* [IMP] Able to run MRP only for selected areas. - (`#360 `_). - -11.0.2.1.0 (2019-04-02) -~~~~~~~~~~~~~~~~~~~~~~~ - -* [IMP] Implement *Nbr. Days* functionality to be able to group demand when - generating supply proposals. - (`#345 `_). - -11.0.2.0.0 (2018-11-20) -~~~~~~~~~~~~~~~~~~~~~~~ - -* [REW] Refactor MRP Area. - (`#322 `_): - - * MRP product concept dropped in favor of *Product MRP Area Parameters*. - This allow to set different MRP parameters for the same product in - different areas. - * Menu items reordering. - -11.0.1.1.0 (2018-08-30) -~~~~~~~~~~~~~~~~~~~~~~~ - -* [FIX] Consider *Qty Multiple* on product to propose the quantity to procure. - (`#297 `_) - -11.0.1.0.1 (2018-08-03) -~~~~~~~~~~~~~~~~~~~~~~~ - -* [FIX] User and system locales doesn't break MRP calculation. - (`#290 `_) -* [FIX] Working Hours are now defined only at Warehouse level and displayed - as a related on MRP Areas. - (`#290 `__) - -11.0.1.0.0 (2018-07-09) -~~~~~~~~~~~~~~~~~~~~~~~ - -* Start of the history. - -Bug Tracker -=========== - -Bugs are tracked on `GitHub Issues `_. -In case of trouble, please check there if your issue has already been reported. -If you spotted it first, help us to smash it by providing a detailed and welcomed -`feedback `_. - -Do not contact contributors directly about support or help with technical issues. - -Credits -======= - -Authors -~~~~~~~ - -* Ucamco -* ForgeFlow - -Contributors -~~~~~~~~~~~~ - -* Wim Audenaert -* Jordi Ballester -* Lois Rilo -* Héctor Villarreal - -Maintainers -~~~~~~~~~~~ - -This module is maintained by the OCA. - -.. image:: https://odoo-community.org/logo.png - :alt: Odoo Community Association - :target: https://odoo-community.org - -OCA, or the Odoo Community Association, is a nonprofit organization whose -mission is to support the collaborative development of Odoo features and -promote its widespread use. - -.. |maintainer-JordiBForgeFlow| image:: https://github.com/JordiBForgeFlow.png?size=40px - :target: https://github.com/JordiBForgeFlow - :alt: JordiBForgeFlow -.. |maintainer-LoisRForgeFlow| image:: https://github.com/LoisRForgeFlow.png?size=40px - :target: https://github.com/LoisRForgeFlow - :alt: LoisRForgeFlow - -Current `maintainers `__: - -|maintainer-JordiBForgeFlow| |maintainer-LoisRForgeFlow| - -This module is part of the `OCA/manufacture `_ project on GitHub. - -You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/mrp_multi_level/models/product_mrp_area.py b/mrp_multi_level/models/product_mrp_area.py index 7a27345f3..d9525ed11 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 deleted file mode 100644 index 833710ad4..000000000 --- a/mrp_multi_level/static/description/index.html +++ /dev/null @@ -1,638 +0,0 @@ - - - - - -MRP Multi Level - - - -
-

MRP Multi Level

- - -

Beta 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 -supply, and based on parameters set at product variant level, the new -procurements for each product.

-

To do this, the calculation starts at top level of the bill of material -and explodes this down to the lowest level.

-
-

Key Features

-
    -
  • MRP parameters set by product variant MRP area pairs.
  • -
  • Cron job to calculate the MRP demand.
  • -
  • Manually calculate the MRP demand.
  • -
  • Confirm the calculated MRP demand and create PO’s, or MO’s.
  • -
  • Able to see the products for which action is needed throught Planned Orders.
  • -
  • Integration with Stock Demand Estimates system. -Note: You need to install mrp_multi_level_estimate module.
  • -
-

Table of contents

- -
-

Configuration

-
-

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

-
    -
  • Go to Manufacturing > Master Data > Product MRP Area Parameters and set -the MRP parameters for a given product and area.
  • -
-
-
-
-

Usage

-

To manually run the MRP scheduler:

-
    -
  1. Go to Manufacturing > Operations > Run MRP Multi Level.
  2. -
  3. On the wizard click Run MRP.
  4. -
-

To launch replenishment orders (moves, purchases, production orders…):

-
    -
  1. Go to Manufacturing > Operations > MRP Inventory.
  2. -
  3. Filter with To procure.
  4. -
  5. Select multiple records and click on Action > Procure or click the right -hand side gears in any record.
  6. -
  7. On the wizard, check everything is ok and click Execute.
  8. -
-
-
-

Changelog

-
-

13.0.1.5.0 (2020-04-09)

-

Features

-
    -
  • Show Run MRP Multi Level menu only to a specific new security group Run MRP Manually. (#492)
  • -
-
-
-

13.0.1.4.0 (2020-03-26)

-
-
    -
  • Add menu entry for planned orders
  • -
  • Add button to navigate from planned orders to linked manufacturing orders
  • -
  • Add action to convert planned orders to fixed
  • -
  • When changing the due date in a planned order the release date is recomputed
  • -
-
-
-
-

13.0.1.3.0 (2020-03-02)

-
    -
  • [IMP] Minor changes” -(#470).
      -
    • Planned Order release and due date become required.
    • -
    • Add button to Product MRP Area to update MOQ from Supplier Info.
    • -
    • Link Manufacturing Orders with Planned Orders.
    • -
    • Allow Mrp Inventory Procure Wizard to be used from other models.
    • -
    • Make MRP Inventory creation more extensible.
    • -
    • Main Supplier computation (v13 requires explicit False definitions)
    • -
    -
  • -
-
-
-

13.0.1.2.0 (2020-02-20)

-
    -
  • [IMP] Minor changes -(#468).
      -
    • Planned Orders become fixed on manual creation by default
    • -
    • Released Quantity becomes readonly
    • -
    • Add product reference if Planned Order name is not defined on bom explosion
    • -
    -
  • -
-
-
-

13.0.1.1.0 (2020-02-21)

-
    -
  • [FIX] Minor changes -(#469).
      -
    • Fix Main supplier computation in multi company
    • -
    • Drop Triplicated field in search view
    • -
    -
  • -
  • [IMP] Minor changes -(#463).
      -
    • Show supply method on MRP Inventory
    • -
    • Allow no-MRP users to look into Products
    • -
    -
  • -
-
-
-

13.0.1.0.0 (2019-12-18)

-
    -
  • [MIG] Migration to v13.
  • -
-
-
-

12.0.1.0.0 (2019-08-05)

-
    -
  • [MIG] Migration to v12:
      -
    • Estimates as a forecasting mechanism is moved to a new module -(mrp_multi_level_estimate).
    • -
    -
  • -
-
-
-

11.0.3.0.0 (2019-05-22)

-
    -
  • [REW/IMP] Rework to include Planned Orders. -(#365).
  • -
  • [IMP] Able to procure from a different location than the area’s location.
  • -
-
-
-

11.0.2.2.0 (2019-05-02)

-
    -
  • [IMP] Able to run MRP only for selected areas. -(#360).
  • -
-
-
-

11.0.2.1.0 (2019-04-02)

-
    -
  • [IMP] Implement Nbr. Days functionality to be able to group demand when -generating supply proposals. -(#345).
  • -
-
-
-

11.0.2.0.0 (2018-11-20)

-
    -
  • [REW] Refactor MRP Area. -(#322):
      -
    • MRP product concept dropped in favor of Product MRP Area Parameters. -This allow to set different MRP parameters for the same product in -different areas.
    • -
    • Menu items reordering.
    • -
    -
  • -
-
-
-

11.0.1.1.0 (2018-08-30)

-
    -
  • [FIX] Consider Qty Multiple on product to propose the quantity to procure. -(#297)
  • -
-
-
-

11.0.1.0.1 (2018-08-03)

-
    -
  • [FIX] User and system locales doesn’t break MRP calculation. -(#290)
  • -
  • [FIX] Working Hours are now defined only at Warehouse level and displayed -as a related on MRP Areas. -(#290)
  • -
-
-
-

11.0.1.0.0 (2018-07-09)

-
    -
  • Start of the history.
  • -
-
-
-
-

Bug Tracker

-

Bugs are tracked on GitHub Issues. -In case of trouble, please check there if your issue has already been reported. -If you spotted it first, help us to smash it by providing a detailed and welcomed -feedback.

-

Do not contact contributors directly about support or help with technical issues.

-
-
-

Credits

-
-

Authors

-
    -
  • Ucamco
  • -
  • ForgeFlow
  • -
-
-
-

Contributors

- -
-
-

Maintainers

-

This module is maintained by the OCA.

-Odoo Community Association -

OCA, or the Odoo Community Association, is a nonprofit organization whose -mission is to support the collaborative development of Odoo features and -promote its widespread use.

-

Current maintainers:

-

JordiBForgeFlow LoisRForgeFlow

-

This module is part of the OCA/manufacture project on GitHub.

-

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

-
-
-
-
- - diff --git a/mrp_multi_level/tests/common.py b/mrp_multi_level/tests/common.py index 32a18961e..7658041d4 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 84731de09..6e2e7bf24 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 d6cb03652..97bd2b84b 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 @@ -566,8 +576,10 @@ class MultiLevelMrp(models.TransientModel): product_name=product_mrp_area.product_id.display_name, delta_days=grouping_delta, ) - origin = ",".join(list(set(demand_origin))) - qtytoorder = product_mrp_area.mrp_minimum_stock - onhand - last_qty + origin = ",".join(list({x for x in demand_origin if x})) + 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, @@ -603,8 +615,10 @@ class MultiLevelMrp(models.TransientModel): product_name=product_mrp_area.product_id.display_name, delta_days=grouping_delta, ) - origin = ",".join(list(set(demand_origin))) - qtytoorder = product_mrp_area.mrp_minimum_stock - onhand - last_qty + origin = ",".join(list({x for x in demand_origin if x})) + 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, @@ -615,6 +629,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 @@ -640,39 +736,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, - values=dict(origin=move.origin), - ) - 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 9512488e1dbc9c5d52d733f41451772b3bdc86a9 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 97bd2b84b..bccda1abe 100644 --- a/mrp_multi_level/wizards/mrp_multi_level.py +++ b/mrp_multi_level/wizards/mrp_multi_level.py @@ -696,9 +696,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 2c2338fbd96034df5320bc5affabb45dce7c470e 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 6e2e7bf24..9e32019f3 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 bccda1abe..f7a40e502 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 @@ -597,7 +628,7 @@ class MultiLevelMrp(models.TransientModel): (onhand + last_qty + move.mrp_qty) < 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: @@ -628,9 +659,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 468d3f3a2bb3f428076338142f20729991a8b858 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 7658041d4..726c4db73 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( @@ -454,6 +503,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 f7a40e502..064f5a0ec 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" @@ -892,9 +875,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 70d77d79daed00473f3b18f09d22037051c916fe 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 5c696efc6..8bc245464 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 9e32019f3..2580133da 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 064f5a0ec..e28000bde 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) < product_mrp_area.mrp_minimum_stock @@ -643,7 +641,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) @@ -658,9 +655,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 @@ -669,12 +663,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), @@ -691,7 +687,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 @@ -706,7 +701,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: @@ -722,9 +716,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): @@ -747,33 +738,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(