[IMP] mrp, purchase_stock: visibility days and days to prepare MO

- Add the concept of 'Days to Prepare MO' in  mrp, similar to 'Days to
Purchase' but defined at the product level. Both concepts are merged into
Days to Order at the orderpoint level, taking the value of Days to Purchase
for orderpoints with a buy route and Days to Prepare MO for orderpoint with a
manufacturing route.
 - Add the concept of 'Visibility Days' on orderpoints. The idea is to
avoid to create through reordering rules multiple small orders for  the
same product on a small time frame but rather to order directly a bigger
quantity. When Visibility Days are defined, the quantity of the orders
created by a RR is not the quantity forecasted at Today + Lead times
(quantity that triggered the order according to the RR), but the
quantity forecasted at Today + Lead Times + Visibility days.
The Visibility Days are different for purchase and manufacturing.
Changing the route of the orderpoint will change its value. They
can be defined at the company level and further fine-tuned on the
orderpoint. In case of change of the parameters at the company level,
only orderpoints without a specific value of Visibility Days  will
be changed.
 - Alignement between Manufacturing and Purchase Security Lead Time
(previously the Manufacturing Security Lead time didn't impact the
procurement date, instead the manufacturing time was made longer).

closes odoo/odoo#86682

Task-id: 2738838
Pr: https://github.com/odoo/odoo/pull/86682
Related: odoo/upgrade#3415
Signed-off-by: William Henrotin (whe) <whe@odoo.com>
This commit is contained in:
Nicolas Pierre
2022-05-31 19:16:55 +02:00
parent ab9bc656da
commit 87d33596d1
16 changed files with 118 additions and 47 deletions
+1 -2
View File
@@ -658,7 +658,7 @@ class MrpProduction(models.Model):
for production in self:
if not production.date_planned_start or production.is_planned:
continue
days_delay = production.product_id.produce_delay + production.company_id.manufacturing_lead
days_delay = production.product_id.produce_delay
date_planned_finished = production.date_planned_start + relativedelta(days=days_delay)
if date_planned_finished == production.date_planned_start:
date_planned_finished = date_planned_finished + relativedelta(hours=1)
@@ -904,7 +904,6 @@ class MrpProduction(models.Model):
def _get_date_planned_finished(self):
date_planned_finished = self.date_planned_start + relativedelta(days=self.product_id.produce_delay)
date_planned_finished = date_planned_finished + relativedelta(days=self.company_id.manufacturing_lead)
if date_planned_finished == self.date_planned_start:
date_planned_finished = date_planned_finished + relativedelta(hours=1)
return date_planned_finished
+4
View File
@@ -33,6 +33,10 @@ class ProductTemplate(models.Model):
'Manufacturing Lead Time', default=0.0,
help="Average lead time in days to manufacture this product. In the case of multi-level BOM, the manufacturing lead times of the components will be added. In case the product is subcontracted, this can be used to determine the date at which components should be sent to the subcontractor.")
is_kits = fields.Boolean(compute='_compute_is_kits', compute_sudo=False)
days_to_prepare_mo = fields.Float(
string="Days to prepare Manufacturing Order", default=0.0,
help="Create and confirm Manufacturing Orders these many days in advance, to have enough time to replenish components or manufacture semi-finished products.\n"
"Note that this does not affect the MO scheduled date, which still respects the just-in-time mechanism.")
def _compute_bom_count(self):
for product in self:
+7
View File
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from odoo import api, Command, fields, models
from odoo.osv import expression
from odoo.tools import float_compare, float_round, float_is_zero, OrderedSet
@@ -547,3 +548,9 @@ class StockMove(models.Model):
res = super()._prepare_procurement_values()
res['bom_line_id'] = self.bom_line_id.id
return res
def _get_mto_procurement_date(self):
date = super()._get_mto_procurement_date()
if 'manufacture' in self.product_id._get_rules_from_location(self.location_id).mapped('action'):
date -= relativedelta(days=self.company_id.manufacturing_lead)
return date
+29
View File
@@ -4,6 +4,7 @@
from odoo import _, api, fields, models
from odoo.tools.float_utils import float_is_zero
from odoo.osv.expression import AND
from dateutil.relativedelta import relativedelta
class StockWarehouseOrderpoint(models.Model):
@@ -13,6 +14,7 @@ class StockWarehouseOrderpoint(models.Model):
bom_id = fields.Many2one(
'mrp.bom', string='Bill of Materials', check_company=True,
domain="[('type', '=', 'normal'), '&', '|', ('company_id', '=', company_id), ('company_id', '=', False), '|', ('product_id', '=', product_id), '&', ('product_id', '=', False), ('product_tmpl_id', '=', product_tmpl_id)]")
manufacturing_visibility_days = fields.Float(default=0.0, help="Visibility Days applied on the manufacturing routes.")
def _get_replenishment_order_notification(self):
self.ensure_one()
@@ -45,6 +47,27 @@ class StockWarehouseOrderpoint(models.Model):
for orderpoint in self:
orderpoint.show_bom = orderpoint.route_id.id in manufacture_route
def _compute_visibility_days(self):
res = super()._compute_visibility_days()
for orderpoint in self:
if 'manufacture' in orderpoint.rule_ids.mapped('action'):
orderpoint.visibility_days = orderpoint.manufacturing_visibility_days
return res
def _set_visibility_days(self):
res = super()._set_visibility_days()
for orderpoint in self:
if 'manufacture' in orderpoint.rule_ids.mapped('action'):
orderpoint.manufacturing_visibility_days = orderpoint.visibility_days
return res
def _compute_days_to_order(self):
res = super()._compute_days_to_order()
for orderpoint in self:
if 'manufacture' in orderpoint.rule_ids.mapped('action'):
orderpoint.days_to_order = orderpoint.product_id.days_to_prepare_mo
return res
def _quantity_in_progress(self):
bom_kits = self.env['mrp.bom']._bom_find(self.product_id, bom_type='phantom')
bom_kit_orderpoints = {
@@ -109,6 +132,12 @@ class StockWarehouseOrderpoint(models.Model):
orderpoint_wh_bom.route_id = route_id[0].id
return super()._set_default_route_id()
def _get_orderpoint_procurement_date(self):
date = super()._get_orderpoint_procurement_date()
if any(rule.action == 'manufacture' for rule in self.rule_ids):
date -= relativedelta(days=self.company_id.manufacturing_lead)
return date
def _prepare_procurement_values(self, date=False, group=False):
values = super()._prepare_procurement_values(date=date, group=group)
values['bom_id'] = self.bom_id
+1 -2
View File
@@ -109,7 +109,7 @@ class StockRule(models.Model):
def _prepare_mo_vals(self, product_id, product_qty, product_uom, location_dest_id, name, origin, company_id, values, bom):
date_planned = self._get_date_planned(product_id, company_id, values)
date_deadline = values.get('date_deadline') or date_planned + relativedelta(days=company_id.manufacturing_lead) + relativedelta(days=product_id.produce_delay)
date_deadline = values.get('date_deadline') or date_planned + relativedelta(days=product_id.produce_delay)
mo_values = {
'origin': origin,
'product_id': product_id.id,
@@ -144,7 +144,6 @@ class StockRule(models.Model):
def _get_date_planned(self, product_id, company_id, values):
format_date_planned = fields.Datetime.from_string(values['date_planned'])
date_planned = format_date_planned - relativedelta(days=product_id.produce_delay)
date_planned = date_planned - relativedelta(days=company_id.manufacturing_lead)
if date_planned == format_date_planned:
date_planned = date_planned - relativedelta(hours=1)
return date_planned
+4
View File
@@ -12,6 +12,10 @@
<div attrs="{'invisible':[('type','=','service')]}">
<field name="produce_delay" class="oe_inline"/> days
</div>
<label for="days_to_prepare_mo" attrs="{'invisible':[('type','=','service')]}"/>
<div attrs="{'invisible':[('type','=','service')]}">
<field name="days_to_prepare_mo" class="oe_inline"/> days
</div>
</xpath>
<xpath expr="//field[@name='product_variant_count']" position="after">
<field name="is_kits" invisible="1"/>
+7
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from dateutil.relativedelta import relativedelta
from odoo import _, models
from odoo.tools.float_utils import float_is_zero
from odoo.exceptions import UserError
@@ -30,3 +31,9 @@ class StockMove(models.Model):
if float_is_zero(valuation_total_qty, precision_rounding=related_aml.product_uom_id.rounding or related_aml.product_id.uom_id.rounding):
raise UserError(_('Odoo is not able to generate the anglo saxon entries. The total valuation of %s is zero.') % related_aml.product_id.display_name)
return valuation_price_unit_total, valuation_total_qty
def _get_mto_procurement_date(self):
date = super()._get_mto_procurement_date()
if 'buy' in self.product_id._get_rules_from_location(self.location_id).mapped('action'):
date -= relativedelta(days=self.company_id.po_lead)
return date
+1 -1
View File
@@ -4,8 +4,8 @@
from . import account_invoice
from . import product
from . import purchase
from . import res_company
from . import res_config_settings
from . import res_partner
from . import res_company
from . import stock
from . import stock_rule
@@ -10,7 +10,6 @@ class ResConfigSettings(models.TransientModel):
module_stock_dropshipping = fields.Boolean("Dropshipping")
days_to_purchase = fields.Float(
related='company_id.days_to_purchase', readonly=False)
is_installed_sale = fields.Boolean(string="Is the Sale Module Installed")
def get_values(self):
+21
View File
@@ -215,6 +215,7 @@ class Orderpoint(models.Model):
'product.supplierinfo', string='Product Supplier', check_company=True,
domain="['|', ('product_id', '=', product_id), '&', ('product_id', '=', False), ('product_tmpl_id', '=', product_tmpl_id)]")
vendor_id = fields.Many2one(related='supplier_id.partner_id', string="Vendor", store=True)
purchase_visibility_days = fields.Float(default=0.0, help="Visibility Days applied on the purchase routes.")
@api.depends('product_id.purchase_order_line_ids', 'product_id.purchase_order_line_ids.state')
def _compute_qty(self):
@@ -225,6 +226,26 @@ class Orderpoint(models.Model):
def _compute_lead_days(self):
return super()._compute_lead_days()
def _compute_visibility_days(self):
res = super()._compute_visibility_days()
for orderpoint in self:
if 'buy' in orderpoint.rule_ids.mapped('action'):
orderpoint.visibility_days = orderpoint.purchase_visibility_days
return res
def _set_visibility_days(self):
res = super()._set_visibility_days()
for orderpoint in self:
if 'buy' in orderpoint.rule_ids.mapped('action'):
orderpoint.purchase_visibility_days = orderpoint.visibility_days
return res
def _compute_days_to_order(self):
res = super()._compute_days_to_order()
for orderpoint in self:
if 'buy' in orderpoint.rule_ids.mapped('action'):
orderpoint.days_to_order = orderpoint.company_id.days_to_purchase
return res
@api.depends('route_id')
def _compute_show_suppplier(self):
buy_route = []
+3 -7
View File
@@ -152,9 +152,8 @@ class StockRule(models.Model):
self.env['purchase.order.line'].sudo().create(po_line_values)
def _get_lead_days(self, product, **values):
"""Add the company security lead time, days to purchase and the supplier
delay to the cumulative delay and cumulative description. The days to
purchase and company lead time are always displayed for onboarding
"""Add the company security lead time and the supplier delay to the cumulative delay
and cumulative description. The company lead time is always displayed for onboarding
purpose in order to indicate that those options are available.
"""
delay, delay_description = super()._get_lead_days(product, **values)
@@ -170,10 +169,7 @@ class StockRule(models.Model):
security_delay = buy_rule.picking_type_id.company_id.po_lead
if not bypass_delay_description:
delay_description.append((_('Purchase Security Lead Time'), _('+ %d day(s)', security_delay)))
days_to_purchase = buy_rule.company_id.days_to_purchase
if not bypass_delay_description:
delay_description.append((_('Days to Purchase'), _('+ %d day(s)', days_to_purchase)))
return delay + supplier_delay + security_delay + days_to_purchase, delay_description
return delay + supplier_delay + security_delay, delay_description
@api.model
def _get_procurements_to_merge_groupby(self, procurement):
@@ -296,6 +296,7 @@ class TestPurchaseLeadTime(PurchaseTestCommon):
orderpoint_form = Form(self.env['stock.warehouse.orderpoint'])
orderpoint_form.product_id = product
orderpoint_form.product_min_qty = 0.0
orderpoint_form.visibility_days = 1.0
orderpoint = orderpoint_form.save()
orderpoint_form = Form(self.env['stock.warehouse.orderpoint'].with_company(company2))
@@ -320,12 +321,12 @@ class TestPurchaseLeadTime(PurchaseTestCommon):
po_line = self.env['purchase.order.line'].search([('product_id', '=', product.id)])
self.assertEqual(fields.Date.to_date(po_line.order_id.date_order), fields.Date.today() + timedelta(days=2))
self.assertEqual(len(po_line), 1)
self.assertEqual(po_line.product_uom_qty, 20.0)
self.assertEqual(po_line.product_uom_qty, 25.0)
self.assertEqual(len(po_line.order_id), 1)
orderpoint_form = Form(orderpoint)
orderpoint_form.save()
self.mock_date.today.return_value = fields.Date.today() + timedelta(days=1)
self.mock_date.today.return_value = fields.Date.today() + timedelta(days=2)
orderpoint._compute_qty()
self.env['procurement.group'].run_scheduler()
po_line = self.env['purchase.order.line'].search([('product_id', '=', product.id)])
+4 -1
View File
@@ -1283,7 +1283,7 @@ class StockMove(models.Model):
product_id = self.product_id.with_context(lang=self._get_lang())
return {
'product_description_variants': self.description_picking and self.description_picking.replace(product_id._get_description(self.picking_type_id), ''),
'date_planned': self.date,
'date_planned': self._get_mto_procurement_date(),
'date_deadline': self.date_deadline,
'move_dest_ids': self,
'group_id': group_id,
@@ -1294,6 +1294,9 @@ class StockMove(models.Model):
'product_packaging_id': self.product_packaging_id,
}
def _get_mto_procurement_date(self):
return self.date
def _prepare_move_line_vals(self, quantity=None, reserved_quant=None):
self.ensure_one()
vals = {
+27 -6
View File
@@ -97,6 +97,12 @@ class StockWarehouseOrderpoint(models.Model):
qty_forecast = fields.Float('Forecast', readonly=True, compute='_compute_qty')
qty_to_order = fields.Float('To Order', compute='_compute_qty_to_order', store=True, readonly=False)
days_to_order = fields.Float(compute='_compute_days_to_order', help="Numbers of days in advance that replenishments demands are created.")
visibility_days = fields.Float(
compute='_compute_visibility_days', inverse='_set_visibility_days', readonly=False,
help="Consider product forecast these many days in the future upon product replenishment, set to 0 for just-in-time.\n"
"The value depends on the type of the route (Buy or Manufacture)")
_sql_constraints = [
('qty_multiple_check', 'CHECK( qty_multiple >= 0 )', 'Qty Multiple must be greater than or equal to zero.'),
('product_location_check', 'unique (product_id, location_id, company_id)', 'A replenishment rule already exists for this product on this location.'),
@@ -134,6 +140,17 @@ class StockWarehouseOrderpoint(models.Model):
continue
orderpoint.rule_ids = orderpoint.product_id._get_rules_from_location(orderpoint.location_id, route_ids=orderpoint.route_id)
@api.depends('route_id', 'product_id')
def _compute_visibility_days(self):
self.visibility_days = 0
def _set_visibility_days(self):
return True
@api.depends('route_id', 'product_id')
def _compute_days_to_order(self):
self.days_to_order = 0
@api.constrains('product_id')
def _check_product_uom(self):
''' Check if the UoM has the same category as the product standard UoM '''
@@ -251,7 +268,7 @@ class StockWarehouseOrderpoint(models.Model):
orderpoint.qty_on_hand = products_qty[orderpoint.product_id.id]['qty_available']
orderpoint.qty_forecast = products_qty[orderpoint.product_id.id]['virtual_available'] + products_qty_in_progress[orderpoint.id]
@api.depends('qty_multiple', 'qty_forecast', 'product_min_qty', 'product_max_qty')
@api.depends('qty_multiple', 'qty_forecast', 'product_min_qty', 'product_max_qty', 'visibility_days')
def _compute_qty_to_order(self):
for orderpoint in self:
if not orderpoint.product_id or not orderpoint.location_id:
@@ -260,8 +277,10 @@ class StockWarehouseOrderpoint(models.Model):
qty_to_order = 0.0
rounding = orderpoint.product_uom.rounding
if float_compare(orderpoint.qty_forecast, orderpoint.product_min_qty, precision_rounding=rounding) < 0:
qty_to_order = max(orderpoint.product_min_qty, orderpoint.product_max_qty) - orderpoint.qty_forecast
# We want to know how much we should order to also satisfy the needs that gonna appear in the next (visibility) days
product_context = orderpoint._get_product_context(visibility_days=orderpoint.visibility_days)
qty_forecast_with_visibility = orderpoint.product_id.with_context(product_context).read(['virtual_available'])[0]['virtual_available'] + orderpoint._quantity_in_progress()[orderpoint.id]
qty_to_order = max(orderpoint.product_min_qty, orderpoint.product_max_qty) - qty_forecast_with_visibility
remainder = orderpoint.qty_multiple > 0 and qty_to_order % orderpoint.qty_multiple or 0.0
if float_compare(remainder, 0.0, precision_rounding=rounding) > 0:
qty_to_order += orderpoint.qty_multiple - remainder
@@ -291,14 +310,16 @@ class StockWarehouseOrderpoint(models.Model):
def _get_lead_days_values(self):
self.ensure_one()
return dict()
return {
'days_to_order': self.days_to_order,
}
def _get_product_context(self):
def _get_product_context(self, visibility_days=0):
"""Used to call `virtual_available` when running an orderpoint."""
self.ensure_one()
return {
'location': self.location_id.id,
'to_date': datetime.combine(self.lead_days_date, time.max)
'to_date': datetime.combine(self.lead_days_date + relativedelta.relativedelta(days=visibility_days), time.max)
}
def _get_orderpoint_action(self):
+3 -1
View File
@@ -350,6 +350,7 @@ class StockRule(models.Model):
:rtype: tuple[int, list[str, str]]
"""
delay = sum(self.filtered(lambda r: r.action in ['pull', 'pull_push']).mapped('delay'))
days_to_order = values.get('days_to_order', 0)
if self.env.context.get('bypass_delay_description'):
delay_description = []
else:
@@ -358,7 +359,8 @@ class StockRule(models.Model):
for rule in self
if rule.action in ['pull', 'pull_push'] and rule.delay
]
return delay, delay_description
delay_description.append((_('Days to Order'), _('+ %d day(s)', days_to_order)))
return delay + days_to_order, delay_description
class ProcurementGroup(models.Model):
+3 -24
View File
@@ -48,6 +48,7 @@
<field name="qty_on_hand" force_save="1"/>
<field name="qty_forecast" force_save="1"/>
<button name="action_product_forecast_report" type="object" icon="fa-area-chart" title="Forecast Report" attrs="{'invisible': [('id', '=', False)]}"/>
<field name="visibility_days" optional="hidden"/>
<field name="route_id" options="{'no_create': True, 'no_open': True}"/>
<button name="action_stock_replenishment_info" type="object" icon="fa-info-circle" title="Resplenishment Information" attrs="{'invisible': [('id', '=', False)]}"/>
<field name="trigger" optional="hide"/>
@@ -68,29 +69,6 @@
</field>
</record>
<record id="view_warehouse_orderpoint_tree_editable_config" model="ir.ui.view">
<field name="name">stock.warehouse.orderpoint.tree.editable.config</field>
<field name="model">stock.warehouse.orderpoint</field>
<field name="arch" type="xml">
<tree string="Reordering Rules" editable="bottom">
<field name="active" invisible="1"/>
<field name="warehouse_id" invisible="1"/>
<field name="product_tmpl_id" invisible="1"/>
<field name="product_id" invisible="context.get('default_product_id')" force_save="1"/>
<field name="location_id" options="{'no_create': True}" groups="stock.group_stock_multi_locations"/>
<field name="route_id" optional="hide"/>
<field name="trigger" optional="hide"/>
<field name="group_id" optional="hide" groups="stock.group_adv_location"/>
<field name="product_min_qty" optional="show"/>
<field name="product_max_qty" optional="show"/>
<field name="qty_multiple" optional="show"/>
<button name="stock.action_stock_replenishment_info" string="Forecast Description" type="action" icon="fa-area-chart" attrs="{'invisible': [('id', '=', False)]}"/>
<field name="product_uom_name" string="UoM" groups="uom.group_uom"/>
<field name="company_id" optional="hide" readonly="1" groups="base.group_multi_company"/>
</tree>
</field>
</record>
<record model="ir.ui.view" id="stock_reorder_report_search">
<field name="name">stock.warehouse.orderpoint.reorder.search</field>
<field name="model">stock.warehouse.orderpoint</field>
@@ -182,6 +160,7 @@
<field name="group_id" groups="stock.group_adv_location"/>
</div>
<field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/>
<field name="visibility_days"/>
</group>
</group>
</sheet>
@@ -212,7 +191,7 @@
<field name="res_model">stock.warehouse.orderpoint</field>
<field name="type">ir.actions.act_window</field>
<field name="view_mode">tree,kanban,form</field>
<field name="view_id" ref="view_warehouse_orderpoint_tree_editable_config"/>
<field name="view_id" ref="view_warehouse_orderpoint_tree_editable"/>
<field name="search_view_id" ref="warehouse_orderpoint_search"/>
<field name="context">{'search_default_trigger': 'auto'}</field>
<field name="help" type="html">