diff --git a/mrp_multi_level/wizards/mrp_multi_level.py b/mrp_multi_level/wizards/mrp_multi_level.py index 4698fcbb01c..aa8adab2b1e 100644 --- a/mrp_multi_level/wizards/mrp_multi_level.py +++ b/mrp_multi_level/wizards/mrp_multi_level.py @@ -670,19 +670,25 @@ def _init_mrp_move_non_grouped_demand(self, product_mrp_area): for move in product_mrp_area.mrp_move_ids: if self._exclude_move(move): continue - # 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: + safety_stock_target_date = self._get_safety_stock_target_date( + product_mrp_area + ) + onhand_before_safety_stock_date = onhand + if move.mrp_qty < 0 or move.mrp_date <= safety_stock_target_date: + # This works because mrp moves are ordered by: + # product_mrp_area_id, mrp_date, mrp_type desc, id + onhand_before_safety_stock_date += move.mrp_qty + if onhand_before_safety_stock_date < product_mrp_area.mrp_minimum_stock: qtytoorder = self._get_qty_to_order( product_mrp_area, - self._get_safety_stock_target_date(product_mrp_area), + safety_stock_target_date, 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_date=safety_stock_target_date, mrp_qty=qtytoorder, name=name, values=dict(origin=name), diff --git a/mrp_multi_level_consume_safety_stock/README.rst b/mrp_multi_level_consume_safety_stock/README.rst new file mode 100644 index 00000000000..f738d5eec7b --- /dev/null +++ b/mrp_multi_level_consume_safety_stock/README.rst @@ -0,0 +1,103 @@ +==================================== +MRP Multi Level Consume Safety Stock +==================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:ad2f169fa6d149c34adaf973e45bd63b040820bcf4909aaf906ed0be7b0cb8c1 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png + :target: https://odoo-community.org/page/development-status + :alt: Production/Stable +.. |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/18.0/mrp_multi_level_consume_safety_stock + :alt: OCA/manufacture +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/manufacture-18-0/manufacture-18-0-mrp_multi_level_consume_safety_stock + :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=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module complements the mrp_multi_level module by allowing to set a +date on the mrp.area records until which no attempt to rebuild safety +stock will be attempted: the safety stock will be used, if the running +stock get below zero, then a resupply will be created to bring back the +stock to zero. + +The idea is that your area may be under tension at a given moment (maybe +some workers are off, maybe there is high demand from customers) and you +can barely keep up with the demand. In this case, you can set Safety +stock rebuild lead date to a date in the future at which you anticipate +that the situation will have been fixed. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +When this module is installed, you get an additional parameter on the +mrp.area records: + +- Safety stock lead date + +Until that date, the MRP Multi Level planner will consume the safety +stock without attempting to rebuild it, and only resupply if the +forecasted stock goes below zero. + +If the date is in the paste, nothing special happens. + +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 +------- + +* Camptocamp + +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-gurneyalex| image:: https://github.com/gurneyalex.png?size=40px + :target: https://github.com/gurneyalex + :alt: gurneyalex + +Current `maintainer `__: + +|maintainer-gurneyalex| + +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_consume_safety_stock/__init__.py b/mrp_multi_level_consume_safety_stock/__init__.py new file mode 100644 index 00000000000..aee8895e7a3 --- /dev/null +++ b/mrp_multi_level_consume_safety_stock/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/mrp_multi_level_consume_safety_stock/__manifest__.py b/mrp_multi_level_consume_safety_stock/__manifest__.py new file mode 100644 index 00000000000..cd3370e165d --- /dev/null +++ b/mrp_multi_level_consume_safety_stock/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2023 Camptocamp (https://www.camptocamp.com) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +{ + "name": "MRP Multi Level Consume Safety Stock", + "version": "18.0.1.0.0", + "development_status": "Production/Stable", + "license": "LGPL-3", + "author": "Camptocamp, Odoo Community Association (OCA)", + "maintainers": ["gurneyalex"], + "summary": "MRP scheduler: use safety stock during stress periods", + "website": "https://github.com/OCA/manufacture", + "category": "Manufacturing", + "depends": ["mrp_multi_level"], + "data": ["views/mrp_area_views.xml"], + "installable": True, + "application": True, +} diff --git a/mrp_multi_level_consume_safety_stock/models/__init__.py b/mrp_multi_level_consume_safety_stock/models/__init__.py new file mode 100644 index 00000000000..0a55c4b109c --- /dev/null +++ b/mrp_multi_level_consume_safety_stock/models/__init__.py @@ -0,0 +1 @@ +from . import mrp_area, product_mrp_area diff --git a/mrp_multi_level_consume_safety_stock/models/mrp_area.py b/mrp_multi_level_consume_safety_stock/models/mrp_area.py new file mode 100644 index 00000000000..44e36b36a29 --- /dev/null +++ b/mrp_multi_level_consume_safety_stock/models/mrp_area.py @@ -0,0 +1,14 @@ +# Copyright 2023 Camptocamp (https://www.camptocamp.com) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +from odoo import fields, models + + +class MrpArea(models.Model): + _inherit = "mrp.area" + + safety_stock_target_date = fields.Date( + string="Safety stock lead date", + help="We will start rebuilding safety stock on that date", + default=fields.Date.today, + ) diff --git a/mrp_multi_level_consume_safety_stock/models/product_mrp_area.py b/mrp_multi_level_consume_safety_stock/models/product_mrp_area.py new file mode 100644 index 00000000000..e8411dd9719 --- /dev/null +++ b/mrp_multi_level_consume_safety_stock/models/product_mrp_area.py @@ -0,0 +1,26 @@ +# Copyright 2023 Camptocamp (https://www.camptocamp.com) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models + + +class ProductMrpArea(models.Model): + _inherit = "product.mrp.area" + + safety_stock_target_date = fields.Date( + string="Safety stock lead date", + compute="_compute_safety_stock_target_date", + help="The date when we can restart supplying for this product", + ) + + @api.depends( + "mrp_lead_time", + "mrp_area_id.safety_stock_target_date", + ) + def _compute_safety_stock_target_date(self): + today = fields.Date.context_today(self) + for rec in self: + delta = relativedelta(days=rec.mrp_lead_time) + area_target_date = rec.mrp_area_id.safety_stock_target_date or today + rec.safety_stock_target_date = max(today, area_target_date + delta) diff --git a/mrp_multi_level_consume_safety_stock/pyproject.toml b/mrp_multi_level_consume_safety_stock/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/mrp_multi_level_consume_safety_stock/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/mrp_multi_level_consume_safety_stock/readme/DESCRIPTION.md b/mrp_multi_level_consume_safety_stock/readme/DESCRIPTION.md new file mode 100644 index 00000000000..4eebe4f3e06 --- /dev/null +++ b/mrp_multi_level_consume_safety_stock/readme/DESCRIPTION.md @@ -0,0 +1,11 @@ +This module complements the mrp_multi_level module by allowing to set a +date on the mrp.area records until which no attempt to rebuild safety +stock will be attempted: the safety stock will be used, if the running +stock get below zero, then a resupply will be created to bring back the +stock to zero. + +The idea is that your area may be under tension at a given moment (maybe +some workers are off, maybe there is high demand from customers) and you +can barely keep up with the demand. In this case, you can set Safety +stock rebuild lead date to a date in the future at which you anticipate +that the situation will have been fixed. diff --git a/mrp_multi_level_consume_safety_stock/readme/USAGE.md b/mrp_multi_level_consume_safety_stock/readme/USAGE.md new file mode 100644 index 00000000000..3996e49dc8d --- /dev/null +++ b/mrp_multi_level_consume_safety_stock/readme/USAGE.md @@ -0,0 +1,10 @@ +When this module is installed, you get an additional parameter on the +mrp.area records: + +- Safety stock lead date + +Until that date, the MRP Multi Level planner will consume the safety +stock without attempting to rebuild it, and only resupply if the +forecasted stock goes below zero. + +If the date is in the paste, nothing special happens. diff --git a/mrp_multi_level_consume_safety_stock/static/description/index.html b/mrp_multi_level_consume_safety_stock/static/description/index.html new file mode 100644 index 00000000000..d546ae5082e --- /dev/null +++ b/mrp_multi_level_consume_safety_stock/static/description/index.html @@ -0,0 +1,440 @@ + + + + + +MRP Multi Level Consume Safety Stock + + + +
+

MRP Multi Level Consume Safety Stock

+ + +

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

+

This module complements the mrp_multi_level module by allowing to set a +date on the mrp.area records until which no attempt to rebuild safety +stock will be attempted: the safety stock will be used, if the running +stock get below zero, then a resupply will be created to bring back the +stock to zero.

+

The idea is that your area may be under tension at a given moment (maybe +some workers are off, maybe there is high demand from customers) and you +can barely keep up with the demand. In this case, you can set Safety +stock rebuild lead date to a date in the future at which you anticipate +that the situation will have been fixed.

+

Table of contents

+ +
+

Usage

+

When this module is installed, you get an additional parameter on the +mrp.area records:

+
    +
  • Safety stock lead date
  • +
+

Until that date, the MRP Multi Level planner will consume the safety +stock without attempting to rebuild it, and only resupply if the +forecasted stock goes below zero.

+

If the date is in the paste, nothing special happens.

+
+
+

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

+
    +
  • Camptocamp
  • +
+
+
+

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 maintainer:

+

gurneyalex

+

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_consume_safety_stock/tests/__init__.py b/mrp_multi_level_consume_safety_stock/tests/__init__.py new file mode 100644 index 00000000000..805daedb37e --- /dev/null +++ b/mrp_multi_level_consume_safety_stock/tests/__init__.py @@ -0,0 +1 @@ +from . import test_mrp_multi_level_consume_safety_stock diff --git a/mrp_multi_level_consume_safety_stock/tests/test_mrp_multi_level_consume_safety_stock.py b/mrp_multi_level_consume_safety_stock/tests/test_mrp_multi_level_consume_safety_stock.py new file mode 100644 index 00000000000..30fa436b558 --- /dev/null +++ b/mrp_multi_level_consume_safety_stock/tests/test_mrp_multi_level_consume_safety_stock.py @@ -0,0 +1,90 @@ +from datetime import datetime, timedelta + +from odoo.addons.mrp_multi_level.tests.common import TestMrpMultiLevelCommon + + +class TestMrpMultiLevel(TestMrpMultiLevelCommon): + def test_01_consume_safety_stock(self): + now = datetime.now() + self.cases_area.safety_stock_target_date = now.date() + timedelta(days=5) + product = self.prod_test # has Buy route + product.seller_ids[0].delay = 2 # set a purchase lead time + # current stock is 5 + self.quant_obj._update_available_quantity(product, self.cases_loc, 5) + # safety stock is 15 + 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 = [ + { + # only procure to get back to 0 while in the stress period + "date": now.date() + timedelta(days=3), + "demand_qty": 6.0, + "final_on_hand_qty": -1.0, + "initial_on_hand_qty": 5.0, + "running_availability": -1.0, + "supply_qty": 0.0, + "to_procure": 0.0, + }, + { + # after stress period, rebuild safety stock + "date": now.date() + timedelta(days=5), + "demand_qty": 0.0, + "final_on_hand_qty": -1.0, + "initial_on_hand_qty": -1.0, + "running_availability": 15.0, + "supply_qty": 0.0, + "to_procure": 16.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, strict=False): + 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_consume_safety_stock/views/mrp_area_views.xml b/mrp_multi_level_consume_safety_stock/views/mrp_area_views.xml new file mode 100644 index 00000000000..ac738530aa0 --- /dev/null +++ b/mrp_multi_level_consume_safety_stock/views/mrp_area_views.xml @@ -0,0 +1,24 @@ + + + + mrp area form + mrp.area + + + + + + + + + + mrp area list + mrp.area + + + + + + + + diff --git a/mrp_multi_level_consume_safety_stock/wizards/__init__.py b/mrp_multi_level_consume_safety_stock/wizards/__init__.py new file mode 100644 index 00000000000..869fb19c78f --- /dev/null +++ b/mrp_multi_level_consume_safety_stock/wizards/__init__.py @@ -0,0 +1 @@ +from . import mrp_multi_level diff --git a/mrp_multi_level_consume_safety_stock/wizards/mrp_multi_level.py b/mrp_multi_level_consume_safety_stock/wizards/mrp_multi_level.py new file mode 100644 index 00000000000..cf8ba7d81f1 --- /dev/null +++ b/mrp_multi_level_consume_safety_stock/wizards/mrp_multi_level.py @@ -0,0 +1,21 @@ +# Copyright 2023 Camptocamp (https://www.camptocamp.com) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +from odoo import models + + +class MrpMultiLevel(models.TransientModel): + _inherit = "mrp.multi.level" + + def _get_safety_stock_target_date(self, product_mrp_area): + """get the date at which the safety stock rebuild should be targeted, + taking the stress period into account""" + target = super()._get_safety_stock_target_date(product_mrp_area) + return max(product_mrp_area.safety_stock_target_date, target) + + def _get_qty_to_order(self, product_mrp_area, date, move_qty, onhand): + # when in the stress period, don't reconstruct the safety stock + qty = super()._get_qty_to_order(product_mrp_area, date, move_qty, onhand) + if date < self._get_safety_stock_target_date(product_mrp_area): + qty -= product_mrp_area.mrp_minimum_stock + return qty