mirror of
https://github.com/OCA/manufacture.git
synced 2025-01-28 16:37:15 +02:00
[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:
committed by
BernatPForgeFlow
parent
e2c92f9d5a
commit
9d0b11ddbe
@@ -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
|
||||
~~~~~~~~~~~
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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&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 <<a class="reference external" href="mailto:jordi.ballester@forgeflow.com">jordi.ballester@forgeflow.com</a>></li>
|
||||
<li>Lois Rilo <<a class="reference external" href="mailto:lois.rilo@forgeflow.com">lois.rilo@forgeflow.com</a>></li>
|
||||
<li>Héctor Villarreal <<a class="reference external" href="mailto:hector.villarreal@forgeflow.com">hector.villarreal@forgeflow.com</a>></li>
|
||||
<li>Christopher Ormaza <<a class="reference external" href="mailto:chris.ormaza@forgeflow.com">chris.ormaza@forgeflow.com</a>></li>
|
||||
<li>Alexandre Fayolle <<a class="reference external" href="mailto:alexandre.fayolle@camptocamp.com">alexandre.fayolle@camptocamp.com</a>></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section" id="maintainers">
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user