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

View File

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

View File

@@ -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
~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

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

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">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
@@ -366,7 +367,7 @@ ul.auto-toc {
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:6be0420cb90de94af20fe0c8f65391d1b24738d2b1135fde9200b5f6702c5b22
!! source digest: sha256:ec517bd88092825314f02e061fee665bbf6c106491161601a02197e6d9beff36
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<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
@@ -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>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>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>
</div>
<div class="section" id="maintainers">

View File

@@ -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"]

View File

@@ -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}",
)

View File

@@ -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
nbr_create = self._init_mrp_move_non_grouped_demand(
nbr_create, product_mrp_area
)
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:
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),