[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
This commit is contained in:
Alexandre Fayolle
2023-11-06 15:32:24 +01:00
committed by BernatPForgeFlow
parent e2c92f9d5a
commit 9d0b11ddbe
8 changed files with 198 additions and 29 deletions

View File

@@ -7,7 +7,7 @@ MRP Multi Level
!! This file is generated by oca-gen-addon-readme !! !! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !! !! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:6be0420cb90de94af20fe0c8f65391d1b24738d2b1135fde9200b5f6702c5b22 !! source digest: sha256:ec517bd88092825314f02e061fee665bbf6c106491161601a02197e6d9beff36
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Production%2FStable-green.png .. |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 * Go to *Manufacturing > Configuration > MRP Areas* and define or edit
any existing area. You can specify the working hours for every area. any existing area. You can specify the working hours for every area.
Product MRP Area Parameters Product MRP Area Parameters
~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -229,6 +231,8 @@ Contributors
* Jordi Ballester <jordi.ballester@forgeflow.com> * Jordi Ballester <jordi.ballester@forgeflow.com>
* Lois Rilo <lois.rilo@forgeflow.com> * Lois Rilo <lois.rilo@forgeflow.com>
* Héctor Villarreal <hector.villarreal@forgeflow.com> * Héctor Villarreal <hector.villarreal@forgeflow.com>
* Christopher Ormaza <chris.ormaza@forgeflow.com>
* Alexandre Fayolle <alexandre.fayolle@camptocamp.com>
Maintainers Maintainers
~~~~~~~~~~~ ~~~~~~~~~~~

View File

@@ -3,7 +3,6 @@
# - Jordi Ballester Alomar <jordi.ballester@forgeflow.com> # - Jordi Ballester Alomar <jordi.ballester@forgeflow.com>
# - Lois Rilo Antelo <lois.rilo@forgeflow.com> # - Lois Rilo Antelo <lois.rilo@forgeflow.com>
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). # License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html).
from math import ceil from math import ceil
from odoo import _, api, fields, models from odoo import _, api, fields, models

View File

@@ -4,6 +4,8 @@ MRP Areas
* Go to *Manufacturing > Configuration > MRP Areas* and define or edit * Go to *Manufacturing > Configuration > MRP Areas* and define or edit
any existing area. You can specify the working hours for every area. any existing area. You can specify the working hours for every area.
Product MRP Area Parameters Product MRP Area Parameters
~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@@ -2,3 +2,5 @@
* Jordi Ballester <jordi.ballester@forgeflow.com> * Jordi Ballester <jordi.ballester@forgeflow.com>
* Lois Rilo <lois.rilo@forgeflow.com> * Lois Rilo <lois.rilo@forgeflow.com>
* Héctor Villarreal <hector.villarreal@forgeflow.com> * Héctor Villarreal <hector.villarreal@forgeflow.com>
* Christopher Ormaza <chris.ormaza@forgeflow.com>
* Alexandre Fayolle <alexandre.fayolle@camptocamp.com>

View File

@@ -1,3 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head> <head>
@@ -366,7 +367,7 @@ ul.auto-toc {
!! This file is generated by oca-gen-addon-readme !! !! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !! !! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:6be0420cb90de94af20fe0c8f65391d1b24738d2b1135fde9200b5f6702c5b22 !! source digest: sha256:ec517bd88092825314f02e061fee665bbf6c106491161601a02197e6d9beff36
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Production/Stable" src="https://img.shields.io/badge/maturity-Production%2FStable-green.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/lgpl-3.0-standalone.html"><img alt="License: LGPL-3" src="https://img.shields.io/badge/licence-LGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/manufacture/tree/14.0/mrp_multi_level"><img alt="OCA/manufacture" src="https://img.shields.io/badge/github-OCA%2Fmanufacture-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/manufacture-14-0/manufacture-14-0-mrp_multi_level"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/manufacture&amp;target_branch=14.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p> <p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Production/Stable" src="https://img.shields.io/badge/maturity-Production%2FStable-green.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/lgpl-3.0-standalone.html"><img alt="License: LGPL-3" src="https://img.shields.io/badge/licence-LGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/manufacture/tree/14.0/mrp_multi_level"><img alt="OCA/manufacture" src="https://img.shields.io/badge/github-OCA%2Fmanufacture-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/manufacture-14-0/manufacture-14-0-mrp_multi_level"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/manufacture&amp;target_branch=14.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>This module allows you to calculate, based in known inventory, demand, and <p>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
<li>Jordi Ballester &lt;<a class="reference external" href="mailto:jordi.ballester&#64;forgeflow.com">jordi.ballester&#64;forgeflow.com</a>&gt;</li> <li>Jordi Ballester &lt;<a class="reference external" href="mailto:jordi.ballester&#64;forgeflow.com">jordi.ballester&#64;forgeflow.com</a>&gt;</li>
<li>Lois Rilo &lt;<a class="reference external" href="mailto:lois.rilo&#64;forgeflow.com">lois.rilo&#64;forgeflow.com</a>&gt;</li> <li>Lois Rilo &lt;<a class="reference external" href="mailto:lois.rilo&#64;forgeflow.com">lois.rilo&#64;forgeflow.com</a>&gt;</li>
<li>Héctor Villarreal &lt;<a class="reference external" href="mailto:hector.villarreal&#64;forgeflow.com">hector.villarreal&#64;forgeflow.com</a>&gt;</li> <li>Héctor Villarreal &lt;<a class="reference external" href="mailto:hector.villarreal&#64;forgeflow.com">hector.villarreal&#64;forgeflow.com</a>&gt;</li>
<li>Christopher Ormaza &lt;<a class="reference external" href="mailto:chris.ormaza&#64;forgeflow.com">chris.ormaza&#64;forgeflow.com</a>&gt;</li>
<li>Alexandre Fayolle &lt;<a class="reference external" href="mailto:alexandre.fayolle&#64;camptocamp.com">alexandre.fayolle&#64;camptocamp.com</a>&gt;</li>
</ul> </ul>
</div> </div>
<div class="section" id="maintainers"> <div class="section" id="maintainers">

View File

@@ -15,6 +15,7 @@ class TestMrpMultiLevelCommon(SavepointCase):
cls.po_obj = cls.env["purchase.order"] cls.po_obj = cls.env["purchase.order"]
cls.product_obj = cls.env["product.product"] cls.product_obj = cls.env["product.product"]
cls.loc_obj = cls.env["stock.location"] cls.loc_obj = cls.env["stock.location"]
cls.quant_obj = cls.env["stock.quant"]
cls.mrp_area_obj = cls.env["mrp.area"] cls.mrp_area_obj = cls.env["mrp.area"]
cls.product_mrp_area_obj = cls.env["product.mrp.area"] cls.product_mrp_area_obj = cls.env["product.mrp.area"]
cls.partner_obj = cls.env["res.partner"] cls.partner_obj = cls.env["res.partner"]

View File

@@ -1,7 +1,7 @@
# Copyright 2018-19 ForgeFlow S.L. (https://www.forgeflow.com) # Copyright 2018-19 ForgeFlow S.L. (https://www.forgeflow.com)
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). # 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 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)] self.fp_4.route_ids = [(4, self.env.ref("mrp.route_warehouse0_manufacture").id)]
product_mrp_area._compute_supply_method() product_mrp_area._compute_supply_method()
self.assertEqual(product_mrp_area.supply_method, "manufacture") 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}",
)

View File

@@ -538,6 +538,16 @@ class MultiLevelMrp(models.TransientModel):
self._init_mrp_move(product_mrp_area) self._init_mrp_move(product_mrp_area)
logger.info("End MRP initialisation") 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 @api.model
def _init_mrp_move_grouped_demand(self, nbr_create, product_mrp_area): def _init_mrp_move_grouped_demand(self, nbr_create, product_mrp_area):
last_date = None last_date = None
@@ -567,7 +577,9 @@ class MultiLevelMrp(models.TransientModel):
delta_days=grouping_delta, delta_days=grouping_delta,
) )
origin = ",".join(list({x for x in demand_origin if x})) 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( cm = self.create_action(
product_mrp_area_id=product_mrp_area, product_mrp_area_id=product_mrp_area,
mrp_date=last_date, mrp_date=last_date,
@@ -605,7 +617,9 @@ class MultiLevelMrp(models.TransientModel):
delta_days=grouping_delta, delta_days=grouping_delta,
) )
origin = ",".join(list({x for x in demand_origin if x})) 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( cm = self.create_action(
product_mrp_area_id=product_mrp_area, product_mrp_area_id=product_mrp_area,
mrp_date=last_date, mrp_date=last_date,
@@ -616,6 +630,88 @@ class MultiLevelMrp(models.TransientModel):
qty_ordered = cm.get("qty_ordered", 0.0) qty_ordered = cm.get("qty_ordered", 0.0)
onhand += qty_ordered onhand += qty_ordered
nbr_create += 1 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 return nbr_create
@api.model @api.model
@@ -641,39 +737,25 @@ class MultiLevelMrp(models.TransientModel):
for product_mrp_area in product_mrp_areas: for product_mrp_area in product_mrp_areas:
nbr_create = 0 nbr_create = 0
onhand = product_mrp_area.qty_available onhand = product_mrp_area.qty_available
if product_mrp_area.mrp_nbr_days == 0: if product_mrp_area.mrp_nbr_days == 0:
for move in product_mrp_area.mrp_move_ids: nbr_create = self._init_mrp_move_non_grouped_demand(
if self._exclude_move(move): nbr_create, product_mrp_area
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
else: else:
nbr_create = self._init_mrp_move_grouped_demand( nbr_create = self._init_mrp_move_grouped_demand(
nbr_create, product_mrp_area nbr_create, product_mrp_area
) )
if onhand < product_mrp_area.mrp_minimum_stock and nbr_create == 0: 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") name = _("Safety Stock")
cm = self.create_action( cm = self.create_action(
product_mrp_area_id=product_mrp_area, product_mrp_area_id=product_mrp_area,
mrp_date=date.today(), mrp_date=mrp_date,
mrp_qty=qtytoorder, mrp_qty=qtytoorder,
name=name, name=name,
values=dict(origin=name), values=dict(origin=name),