Odoo18-Base/enterprise-17.0/mrp_mps/models/mrp_mps.py
2025-01-06 10:57:38 +07:00

1019 lines
51 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict, namedtuple
from dateutil.relativedelta import relativedelta
from math import log10
from odoo import api, fields, models, _
from odoo.tools.date_utils import add, subtract
from odoo.tools.float_utils import float_round
from odoo.osv.expression import OR, AND
from collections import OrderedDict
class MrpProductionSchedule(models.Model):
_name = 'mrp.production.schedule'
_order = 'warehouse_id, product_id'
_description = 'Schedule the production of Product in a warehouse'
@api.model
def _default_warehouse_id(self):
return self.env['stock.warehouse'].search(self.env['stock.warehouse']._check_company_domain(self.env.company), limit=1)
forecast_ids = fields.One2many('mrp.product.forecast', 'production_schedule_id',
'Forecasted quantity at date')
company_id = fields.Many2one('res.company', 'Company',
default=lambda self: self.env.company)
product_id = fields.Many2one('product.product', string='Product', required=True, index=True)
product_tmpl_id = fields.Many2one('product.template', related="product_id.product_tmpl_id", readonly=True)
product_category_id = fields.Many2one('product.category', related="product_id.product_tmpl_id.categ_id", readonly=True)
product_uom_id = fields.Many2one('uom.uom', string='Product UoM',
related='product_id.uom_id')
# TODO remove master: the `sequence` field was used for _order but not anymore.
sequence = fields.Integer(related='product_id.sequence', store=True)
warehouse_id = fields.Many2one('stock.warehouse', 'Production Warehouse',
required=True, default=lambda self: self._default_warehouse_id())
bom_id = fields.Many2one(
'mrp.bom', "Bill of Materials",
domain="[('product_tmpl_id', '=', product_tmpl_id), '|', ('product_id', '=', product_id), ('product_id', '=', False)]", check_company=True)
forecast_target_qty = fields.Float(
'Safety Stock Target',
help="This is the minimum free stock you want to keep for that product at all times.")
min_to_replenish_qty = fields.Float(
'Minimum to Replenish',
help="Unless the demand is 0, Odoo will always at least replenish this quantity.")
max_to_replenish_qty = fields.Float(
'Maximum to Replenish', default=1000,
help="The maximum replenishment you would like to launch for each period in the MPS. Note that if the demand is higher than that amount, the remaining quantity will be transferred to the next period automatically.")
replenish_state = fields.Selection([
('to_replenish', 'To Replenish'),
('under_replenishment', 'Under Replenishment'),
('excessive_replenishment', 'Excessive Replenishment')], store=False, search='_search_replenish_state',
help="Technical field to support filtering by replenish state")
_sql_constraints = [
('warehouse_product_ref_uniq', 'unique (warehouse_id, product_id)', 'The combination of warehouse and product must be unique!'),
]
def _search_replenish_state(self, operator, value):
productions_schedules = self.search([])
productions_schedules_states = productions_schedules.get_production_schedule_view_state()
def filter_function(f):
if not value:
return not (f['state'] == 'to_launch' and f['to_replenish'] or \
f['state'] == 'to_relaunch' or f['state'] == 'to_correct')
return value == "to_replenish" and f['state'] == 'to_launch' and f['to_replenish'] or \
value == "under_replenishment" and f['state'] == 'to_relaunch' or \
value == "excessive_replenishment" and f['state'] == 'to_correct'
ids = []
for state in productions_schedules_states:
if value:
if any(map(filter_function, state['forecast_ids'])):
ids.append(state['id'])
else:
if all(map(filter_function, state['forecast_ids'])):
ids.append(state['id'])
if operator == '=':
operator = 'in'
else:
operator = 'not in'
return [('id', operator, ids)]
def action_open_actual_demand_details(self, date_str, date_start_str, date_stop_str):
""" Open the picking list view for the actual demand for the current
schedule.
:param date_str: period name for the forecast sellected
:param date_start: select incoming moves after this date
:param date_stop: select incoming moves before this date
:return: action values that open the picking list
:rtype: dict
"""
self.ensure_one()
date_start = fields.Date.from_string(date_start_str)
date_stop = fields.Date.from_string(date_stop_str)
domain_moves = self._get_moves_domain(date_start, date_stop, 'outgoing')
moves_by_date = self._get_moves_and_date(domain_moves)
picking_ids = self._filter_moves(moves_by_date, date_start, date_stop).mapped('picking_id').ids
return {
'type': 'ir.actions.act_window',
'res_model': 'stock.picking',
'views': [(False, 'list'), (False, 'form')],
'view_mode': 'list,form',
'name': _('Actual Demand %s %s (%s - %s)', self.product_id.display_name, date_str, date_start_str, date_stop_str),
'target': 'current',
'domain': [('id', 'in', picking_ids)],
}
def action_open_actual_replenishment_details(self, date_str, date_start_str, date_stop_str):
""" Open the actual replenishment details.
:param date_str: period name for the forecast sellected
:param date_start: select incoming moves and RFQ after this date
:param date_stop: select incoming moves and RFQ before this date
:return: action values that open the forecast details wizard
:rtype: dict
"""
date_start = fields.Date.from_string(date_start_str)
date_stop = fields.Date.from_string(date_stop_str)
domain_moves = self._get_moves_domain(date_start, date_stop, 'incoming')
moves_by_date = self._get_moves_and_date(domain_moves)
move_ids = self._filter_moves(moves_by_date, date_start, date_stop).ids
rfq_domain = self._get_rfq_domain(date_start, date_stop)
purchase_order_by_date = self._get_rfq_and_planned_date(rfq_domain)
purchase_order_line_ids = self._filter_rfq(purchase_order_by_date, date_start, date_stop).ids
name = _('Actual Replenishment %s %s (%s - %s)', self.product_id.display_name, date_str, date_start_str, date_stop_str)
context = {
'default_move_ids': move_ids,
'default_purchase_order_line_ids': purchase_order_line_ids,
'action_name': name,
}
return {
'type': 'ir.actions.act_window',
'name': name,
'view_mode': 'form',
'res_model': 'mrp.mps.forecast.details',
'views': [(False, 'form')],
'target': 'new',
'context': context
}
def action_replenish(self, based_on_lead_time=False):
""" Run the procurement for production schedule in self. Once the
procurements are launched, mark the forecast as launched (only used
for state 'to_relaunch')
:param based_on_lead_time: 2 replenishment options exists in MPS.
based_on_lead_time means that the procurement for self will be launched
based on lead times.
e.g. period are daily and the product have a manufacturing period
of 5 days, then it will try to run the procurements for the 5 first
period of the schedule.
If based_on_lead_time is False then it will run the procurement for the
first period that need a replenishment
"""
production_schedule_states = self.get_production_schedule_view_state()
production_schedule_states = {mps['id']: mps for mps in production_schedule_states}
procurements = []
forecasts_values = []
forecasts_to_set_as_launched = self.env['mrp.product.forecast']
for production_schedule in self:
production_schedule_state = production_schedule_states[production_schedule.id]
# Check for kit. If a kit and its component are both in the MPS we want to skip the
# the kit procurement but instead only refill the components not in MPS
bom = self.env['mrp.bom']._bom_find(
production_schedule.product_id,
company_id=production_schedule.company_id.id,
bom_type='phantom')[production_schedule.product_id]
product_ratio = []
if bom:
dummy, bom_lines = bom.explode(production_schedule.product_id, 1)
product_ids = [l[0].product_id.id for l in bom_lines]
product_ids_with_forecast = self.env['mrp.production.schedule'].search([
('company_id', '=', production_schedule.company_id.id),
('warehouse_id', '=', production_schedule.warehouse_id.id),
('product_id', 'in', product_ids)
]).product_id.ids
product_ratio += [
(l[0], l[0].product_qty * l[1]['qty'])
for l in bom_lines if l[0].product_id.id not in product_ids_with_forecast
]
# Cells with values 'to_replenish' means that they are based on
# lead times. There is at maximum one forecast by schedule with
# 'forced_replenish', it's the cell that need a modification with
# the smallest start date.
replenishment_field = based_on_lead_time and 'to_replenish' or 'forced_replenish'
forecasts_to_replenish = filter(lambda f: f[replenishment_field], production_schedule_state['forecast_ids'])
for forecast in forecasts_to_replenish:
existing_forecasts = production_schedule.forecast_ids.filtered(lambda p:
p.date >= forecast['date_start'] and p.date <= forecast['date_stop']
)
extra_values = production_schedule._get_procurement_extra_values(forecast)
quantity = forecast['replenish_qty'] - forecast['incoming_qty']
if not bom:
procurements.append(self.env['procurement.group'].Procurement(
production_schedule.product_id,
quantity,
production_schedule.product_uom_id,
production_schedule.warehouse_id.lot_stock_id,
production_schedule.product_id.name,
'MPS', production_schedule.company_id, extra_values
))
else:
for bom_line, qty_ratio in product_ratio:
procurements.append(self.env['procurement.group'].Procurement(
bom_line.product_id,
quantity * qty_ratio,
bom_line.product_uom_id,
production_schedule.warehouse_id.lot_stock_id,
bom_line.product_id.name,
'MPS', production_schedule.company_id, extra_values
))
if existing_forecasts:
forecasts_to_set_as_launched |= existing_forecasts
else:
forecasts_values.append({
'forecast_qty': 0,
'date': forecast['date_stop'],
'procurement_launched': True,
'production_schedule_id': production_schedule.id
})
if procurements:
self.env['procurement.group'].with_context(skip_lead_time=True).run(procurements)
forecasts_to_set_as_launched.write({
'procurement_launched': True,
})
if forecasts_values:
self.env['mrp.product.forecast'].create(forecasts_values)
@api.model
def get_mps_view_state(self, domain=False, offset=0, limit=False):
""" Return the global information about MPS and a list of production
schedules values with the domain.
:param domain: domain for mrp.production.schedule
:return: values used by the client action in order to render the MPS.
- dates: list of period name
- production_schedule_ids: list of production schedules values
- manufacturing_period: list of periods (days, months or years)
- company_id: user current company
- groups: company settings that hide/display different rows
:rtype: dict
"""
productions_schedules = self.env['mrp.production.schedule'].search(domain or [], offset=offset, limit=limit)
count = self.env['mrp.production.schedule'].search_count(domain or [])
productions_schedules_states = productions_schedules.get_production_schedule_view_state()
company_groups = self.env.company.read([
'mrp_mps_show_starting_inventory',
'mrp_mps_show_demand_forecast',
'mrp_mps_show_indirect_demand',
'mrp_mps_show_actual_demand',
'mrp_mps_show_to_replenish',
'mrp_mps_show_actual_replenishment',
'mrp_mps_show_safety_stock',
'mrp_mps_show_available_to_promise',
'mrp_mps_show_actual_demand_year_minus_1',
'mrp_mps_show_actual_demand_year_minus_2',
])
return {
'dates': self.env.company._date_range_to_str(),
'production_schedule_ids': productions_schedules_states,
'manufacturing_period': self.env.company.manufacturing_period,
'company_id': self.env.company.id,
'groups': company_groups,
'count': count,
}
@api.model_create_multi
def create(self, vals_list):
""" If the BoM is pass at the creation, create MPS for its components """
existing_mps = []
for i, vals in enumerate(vals_list):
# Allow to add components of a BoM for MPS already created
if vals.get('bom_id'):
mps = self.search([
('product_id', '=', vals['product_id']),
('warehouse_id', '=', vals.get('warehouse_id', self._default_warehouse_id().id)),
('company_id', '=', vals.get('company_id', self.env.company.id)),
], limit=1)
if mps:
mps.bom_id = vals.get('bom_id')
existing_mps.append((i, mps.id))
for i_remove, __ in reversed(existing_mps):
del vals_list[i_remove]
mps = super().create(vals_list)
mps_ids = mps.ids
for i, mps_id in existing_mps:
mps_ids.insert(i, mps_id)
mps = self.browse(mps_ids)
components_list = set()
components_vals = []
for record in mps:
bom = record.bom_id
if not bom:
continue
dummy, components = bom.explode(record.product_id, 1)
for component in components:
if component[0].product_id.type != 'consu':
components_list.add((component[0].product_id.id, record.warehouse_id.id, record.company_id.id))
for component in components_list:
if self.env['mrp.production.schedule'].search([
('product_id', '=', component[0]),
('warehouse_id', '=', component[1]),
('company_id', '=', component[2]),
], limit=1):
continue
components_vals.append({
'product_id': component[0],
'warehouse_id': component[1],
'company_id': component[2]
})
if components_vals:
self.env['mrp.production.schedule'].create(components_vals)
return mps
def get_production_schedule_view_state(self):
""" Prepare and returns the fields used by the MPS client action.
For each schedule returns the fields on the model. And prepare the cells
for each period depending the manufacturing period set on the company.
The forecast cells contains the following information:
- forecast_qty: Demand forecast set by the user
- date_start: First day of the current period
- date_stop: Last day of the current period
- replenish_qty: The quantity to replenish for the current period. It
could be computed or set by the user.
- replenish_qty_updated: The quantity to replenish has been set manually
by the user.
- starting_inventory_qty: During the first period, the quantity
available. After, the safety stock from previous period.
- incoming_qty: The incoming moves and RFQ for the specified product and
warehouse during the current period.
- outgoing_qty: The outgoing moves quantity.
- indirect_demand_qty: On manufacturing a quantity to replenish could
require a need for a component in another schedule. e.g. 2 product A in
order to create 1 product B. If the replenish quantity for product B is
10, it will need 20 product A.
- safety_stock_qty:
starting_inventory_qty - forecast_qty - indirect_demand_qty + replenish_qty
"""
company_id = self.env.company
date_range = company_id._get_date_range()
date_range_year_minus_1 = company_id._get_date_range(years=1)
date_range_year_minus_2 = company_id._get_date_range(years=2)
# We need to get the schedule that impact the schedules in self. Since
# the state is not saved, it needs to recompute the quantity to
# replenish of finished products. It will modify the indirect
# demand and replenish_qty of schedules in self.
schedules_to_compute = self.env['mrp.production.schedule'].browse(self.get_impacted_schedule()) | self
# Dependencies between schedules
indirect_demand_trees = schedules_to_compute._get_indirect_demand_tree()
indirect_ratio_mps = schedules_to_compute._get_indirect_demand_ratio_mps(indirect_demand_trees)
# Get the schedules that do not depends from other in first position in
# order to compute the schedule state only once.
indirect_demand_order = schedules_to_compute._get_indirect_demand_order(indirect_demand_trees)
indirect_demand_qty = defaultdict(float)
incoming_qty, incoming_qty_done = self._get_incoming_qty(date_range)
outgoing_qty, outgoing_qty_done = self._get_outgoing_qty(date_range)
dummy, outgoing_qty_year_minus_1 = self._get_outgoing_qty(date_range_year_minus_1)
dummy, outgoing_qty_year_minus_2 = self._get_outgoing_qty(date_range_year_minus_2)
read_fields = [
'forecast_target_qty',
'min_to_replenish_qty',
'max_to_replenish_qty',
'product_id',
]
if self.env.user.has_group('stock.group_stock_multi_warehouses'):
read_fields.append('warehouse_id')
if self.env.user.has_group('uom.group_uom'):
read_fields.append('product_uom_id')
production_schedule_states = schedules_to_compute.read(read_fields)
production_schedule_states_by_id = {mps['id']: mps for mps in production_schedule_states}
for production_schedule in indirect_demand_order:
# Bypass if the schedule is only used in order to compute indirect
# demand.
rounding = production_schedule.product_id.uom_id.rounding
lead_time = production_schedule._get_lead_times()
# Ignore "Days to Supply Components" when set demand for components since it's normally taken care by the
# components themselves
lead_time_ignore_components = lead_time - production_schedule.bom_id.days_to_prepare_mo
production_schedule_state = production_schedule_states_by_id[production_schedule['id']]
if production_schedule in self:
procurement_date = add(fields.Date.today(), days=lead_time)
precision_digits = max(0, int(-(log10(production_schedule.product_uom_id.rounding))))
production_schedule_state['precision_digits'] = precision_digits
production_schedule_state['forecast_ids'] = []
starting_inventory_qty = production_schedule.product_id.with_context(warehouse=production_schedule.warehouse_id.id).qty_available
if len(date_range):
starting_inventory_qty -= incoming_qty_done.get((date_range[0], production_schedule.product_id, production_schedule.warehouse_id), 0.0)
starting_inventory_qty += outgoing_qty_done.get((date_range[0], production_schedule.product_id, production_schedule.warehouse_id), 0.0)
for index, (date_start, date_stop) in enumerate(date_range):
forecast_values = {}
key = ((date_start, date_stop), production_schedule.product_id, production_schedule.warehouse_id)
key_y_1 = (date_range_year_minus_1[index], *key[1:])
key_y_2 = (date_range_year_minus_2[index], *key[1:])
existing_forecasts = production_schedule.forecast_ids.filtered(lambda p: p.date >= date_start and p.date <= date_stop)
if production_schedule in self:
forecast_values['date_start'] = date_start
forecast_values['date_stop'] = date_stop
forecast_values['incoming_qty'] = float_round(incoming_qty.get(key, 0.0) + incoming_qty_done.get(key, 0.0), precision_rounding=rounding)
forecast_values['outgoing_qty'] = float_round(outgoing_qty.get(key, 0.0) + outgoing_qty_done.get(key, 0.0), precision_rounding=rounding)
forecast_values['outgoing_qty_year_minus_1'] = float_round(outgoing_qty_year_minus_1.get(key_y_1, 0.0), precision_rounding=rounding)
forecast_values['outgoing_qty_year_minus_2'] = float_round(outgoing_qty_year_minus_2.get(key_y_2, 0.0), precision_rounding=rounding)
forecast_values['indirect_demand_qty'] = float_round(indirect_demand_qty.get(key, 0.0), precision_rounding=rounding, rounding_method='UP')
replenish_qty_updated = False
if existing_forecasts:
forecast_values['forecast_qty'] = float_round(sum(existing_forecasts.mapped('forecast_qty')), precision_rounding=rounding)
forecast_values['replenish_qty'] = float_round(sum(existing_forecasts.mapped('replenish_qty')), precision_rounding=rounding)
# Check if the to replenish quantity has been manually set or
# if it needs to be computed.
replenish_qty_updated = any(existing_forecasts.mapped('replenish_qty_updated'))
forecast_values['replenish_qty_updated'] = replenish_qty_updated
else:
forecast_values['forecast_qty'] = 0.0
if not replenish_qty_updated:
replenish_qty = production_schedule._get_replenish_qty(starting_inventory_qty - forecast_values['forecast_qty'] - forecast_values['indirect_demand_qty'])
forecast_values['replenish_qty'] = float_round(replenish_qty, precision_rounding=rounding)
forecast_values['replenish_qty_updated'] = False
forecast_values['starting_inventory_qty'] = float_round(starting_inventory_qty, precision_rounding=rounding)
forecast_values['safety_stock_qty'] = float_round(starting_inventory_qty - forecast_values['forecast_qty'] - forecast_values['indirect_demand_qty'] + forecast_values['replenish_qty'], precision_rounding=rounding)
if production_schedule in self:
production_schedule_state['forecast_ids'].append(forecast_values)
starting_inventory_qty = forecast_values['safety_stock_qty']
if not forecast_values['replenish_qty']:
continue
# Set the indirect demand qty for children schedules.
for (product, ratio) in indirect_ratio_mps[(production_schedule.warehouse_id, production_schedule.product_id)].items():
related_date = max(subtract(date_start, days=lead_time_ignore_components), fields.Date.today())
index = next(i for i, (dstart, dstop) in enumerate(date_range) if related_date <= dstart or (related_date >= dstart and related_date <= dstop))
related_key = (date_range[index], product, production_schedule.warehouse_id)
indirect_demand_qty[related_key] += ratio * forecast_values['replenish_qty']
if production_schedule in self:
# The state is computed after all because it needs the final
# quantity to replenish.
forecasts_state = production_schedule._get_forecasts_state(production_schedule_states_by_id, date_range, procurement_date)
forecasts_state = forecasts_state[production_schedule.id]
for index, forecast_state in enumerate(forecasts_state):
production_schedule_state['forecast_ids'][index].update(forecast_state)
# The purpose is to hide indirect demand row if the schedule do not
# depends from another.
has_indirect_demand = any(forecast['indirect_demand_qty'] != 0 for forecast in production_schedule_state['forecast_ids'])
production_schedule_state['has_indirect_demand'] = has_indirect_demand
return [production_schedule_states_by_id[_id] for _id in self.ids if _id in production_schedule_states_by_id]
def get_impacted_schedule(self, domain=False):
""" When the user modify the demand forecast on a schedule. The new
replenish quantity is computed from schedules that use the product in
self as component (no matter at which BoM level). It will also modify
the replenish quantity on self that will impact the schedule that use
the product in self as a finished product.
:param domain: filter supplied and supplying schedules with the domain
:return ids of supplied and supplying schedules
:rtype list
"""
if not domain:
domain = []
def _used_in_bom(products, related_products):
""" Bottom up from bom line to finished products in order to get
all the finished products that use 'products' as component.
"""
if not products:
return related_products
boms = products.bom_line_ids.mapped('bom_id')
products = boms.mapped('product_id') | boms.mapped('product_tmpl_id.product_variant_ids')
products -= related_products
related_products |= products
return _used_in_bom(products, related_products)
supplying_mps = self.env['mrp.production.schedule'].search(
AND([domain, [
('warehouse_id', 'in', self.mapped('warehouse_id').ids),
('product_id', 'in', _used_in_bom(self.mapped('product_id'), self.env['product.product']).ids)
]]))
def _use_boms(products, related_products):
""" Explore bom line from products's BoMs in order to get components
used.
"""
if not products:
return related_products
components = products.mapped(lambda product: product.bom_ids.bom_line_ids.filtered(lambda line: not line._skip_bom_line(product)).mapped('product_id'))
components -= related_products
related_products |= components
return _use_boms(components, related_products)
supplied_mps = self.env['mrp.production.schedule'].search(
AND([domain, [
('warehouse_id', 'in', self.mapped('warehouse_id').ids),
('product_id', 'in', _use_boms(self.mapped('product_id'), self.env['product.product']).ids)
]]))
return (supplying_mps | supplied_mps).ids
def remove_replenish_qty(self, date_index):
""" Remove the quantity to replenish on the forecast cell.
param date_index: index of the period used to find start and stop date
where the manual replenish quantity should be remove.
"""
date_start, date_stop = self.company_id._get_date_range()[date_index]
forecast_ids = self.forecast_ids.filtered(lambda f:
f.date >= date_start and f.date <= date_stop)
forecast_ids.write({
'replenish_qty': 0.0,
'replenish_qty_updated': False,
})
return True
def set_forecast_qty(self, date_index, quantity):
""" Save the forecast quantity:
params quantity: The new total forecasted quantity
params date_index: The manufacturing period
"""
# Get the last date of current period
self.ensure_one()
date_start, date_stop = self.company_id._get_date_range()[date_index]
existing_forecast = self.forecast_ids.filtered(lambda f:
f.date >= date_start and f.date <= date_stop)
quantity = float_round(float(quantity), precision_rounding=self.product_uom_id.rounding)
quantity_to_add = quantity - sum(existing_forecast.mapped('forecast_qty'))
if existing_forecast:
new_qty = existing_forecast[0].forecast_qty + quantity_to_add
new_qty = float_round(new_qty, precision_rounding=self.product_uom_id.rounding)
existing_forecast[0].write({'forecast_qty': new_qty})
else:
existing_forecast.create({
'forecast_qty': quantity,
'date': date_stop,
'replenish_qty': 0,
'production_schedule_id': self.id
})
return True
def set_replenish_qty(self, date_index, quantity):
""" Save the replenish quantity and mark the cells as manually updated.
params quantity: The new quantity to replenish
params date_index: The manufacturing period
"""
# Get the last date of current period
self.ensure_one()
date_start, date_stop = self.company_id._get_date_range()[date_index]
existing_forecast = self.forecast_ids.filtered(lambda f:
f.date >= date_start and f.date <= date_stop)
quantity = float_round(float(quantity), precision_rounding=self.product_uom_id.rounding)
quantity_to_add = quantity - sum(existing_forecast.mapped('replenish_qty'))
if existing_forecast:
new_qty = existing_forecast[0].replenish_qty + quantity_to_add
new_qty = float_round(new_qty, precision_rounding=self.product_uom_id.rounding)
existing_forecast[0].write({
'replenish_qty': new_qty,
'replenish_qty_updated': True
})
else:
existing_forecast.create({
'forecast_qty': 0,
'date': date_stop,
'replenish_qty': quantity,
'replenish_qty_updated': True,
'production_schedule_id': self.id
})
return True
def _filter_moves(self, moves_by_date, date_start, date_stop):
return self.env['stock.move'].concat(*[m[0] for m in moves_by_date if m[1] >= date_start and m[1] <= date_stop])
def _filter_rfq(self, rfq_by_date_planned, date_start, date_stop):
return self.env['purchase.order.line'].concat(*[pl[0] for pl in rfq_by_date_planned if pl[1] >= date_start and pl[1] <= date_stop])
def _get_procurement_extra_values(self, forecast_values):
""" Extra values that could be added in the vals for procurement.
return values pass to the procurement run method.
rtype dict
"""
return {
'date_planned': forecast_values['date_start'],
'warehouse_id': self.warehouse_id,
}
def _get_forecasts_state(self, production_schedule_states, date_range, procurement_date):
""" Return the state for each forecast cells.
- to_relaunch: A procurement has been launched for the same date range
but a replenish modification require a new procurement.
- to_correct: The actual replenishment is greater than planned, the MPS
should be updated in order to match reality.
- launched: Nothing todo. Either the cell is in the lead time range but
the forecast match the actual replenishment. Or a foreced replenishment
happens but the forecast and the actual demand still the same.
- to_launch: The actual replenishment is lower than forecasted.
It also add a tag on cell in order to:
- to_replenish: The cell is to launch and it needs to be runned in order
to arrive on time due to lead times.
- forced_replenish: Cell to_launch or to_relaunch with the smallest
period
param production_schedule_states: schedules with a state to compute
param date_range: list of period where a state should be computed
param procurement_date: today + lead times for products in self
return: the state for each time slot in date_range for each schedule in
production_schedule_states
rtype: dict
"""
forecasts_state = defaultdict(list)
for production_schedule in self:
forecast_values = production_schedule_states[production_schedule.id]['forecast_ids']
forced_replenish = True
for index, (date_start, date_stop) in enumerate(date_range):
forecast_state = {}
forecast_value = forecast_values[index]
existing_forecasts = production_schedule.forecast_ids.filtered(lambda p: p.date >= date_start and p.date <= date_stop)
procurement_launched = any(existing_forecasts.mapped('procurement_launched'))
replenish_qty = forecast_value['replenish_qty']
incoming_qty = forecast_value['incoming_qty']
if incoming_qty < replenish_qty and procurement_launched:
state = 'to_relaunch'
elif incoming_qty > replenish_qty:
state = 'to_correct'
elif incoming_qty == replenish_qty and (date_start <= procurement_date or procurement_launched):
state = 'launched'
else:
state = 'to_launch'
forecast_state['state'] = state
forecast_state['forced_replenish'] = False
forecast_state['to_replenish'] = False
procurement_qty = replenish_qty - incoming_qty
if forecast_state['state'] not in ('launched', 'to_correct') and procurement_qty > 0:
if date_start <= procurement_date:
forecast_state['to_replenish'] = True
if forced_replenish:
forecast_state['forced_replenish'] = True
forced_replenish = False
forecasts_state[production_schedule.id].append(forecast_state)
return forecasts_state
def _get_lead_times(self):
""" Get the lead time for each product in self. The lead times are
based on rules lead times + produce delay or supplier info delay.
"""
rules = self.product_id._get_rules_from_location(self.warehouse_id.lot_stock_id)
return rules._get_lead_days(self.product_id, bom=self.bom_id)[0]['total_delay']
def _get_replenish_qty(self, after_forecast_qty):
""" Modify the quantity to replenish depending the min/max and targeted
quantity for safety stock.
param after_forecast_qty: The quantity to replenish in order to reach a
safety stock of 0.
return: quantity to replenish
rtype: float
"""
optimal_qty = self.forecast_target_qty - after_forecast_qty
if optimal_qty > self.max_to_replenish_qty:
replenish_qty = self.max_to_replenish_qty
elif optimal_qty <= 0:
replenish_qty = 0
elif optimal_qty < self.min_to_replenish_qty:
replenish_qty = self.min_to_replenish_qty
else:
replenish_qty = optimal_qty
return replenish_qty
def _get_incoming_qty(self, date_range):
""" Get the incoming quantity from RFQ and existing moves.
param: list of time slots used in order to group incoming quantity.
return: a dict with as key a production schedule and as values a list
of incoming quantity for each date range.
"""
incoming_qty = defaultdict(float)
incoming_qty_done = defaultdict(float)
after_date = date_range[0][0]
before_date = date_range[-1][1]
# Get quantity in RFQ
rfq_domain = self._get_rfq_domain(after_date, before_date)
rfq_lines_date_planned = self._get_rfq_and_planned_date(rfq_domain, order='date_planned')
rfq_lines_date_planned = sorted(rfq_lines_date_planned, key=lambda i: i[1])
index = 0
for (line, date_planned) in rfq_lines_date_planned:
# There are cases when we want to consider rfq_lines where their date_planned occurs before the after_date
# if lead times make their stock arrive at a relevant time. Therefore we need to ignore the lines that have
# date_planned + lead time < after_date
if date_planned < after_date:
continue
# Skip to the next time range if the planned date is not in the
# current time interval.
while not (date_range[index][0] <= date_planned and
date_range[index][1] >= date_planned):
index += 1
quantity = line.product_uom._compute_quantity(line.product_qty, line.product_id.uom_id)
incoming_qty[date_range[index], line.product_id, line.order_id.picking_type_id.warehouse_id] += quantity
# Get quantity on incoming moves
# TODO: issue since it will use one search by move. Should use a
# read_group with a group by location.
domain_moves = self._get_moves_domain(after_date, before_date, 'incoming')
stock_moves_and_date = self._get_moves_and_date(domain_moves)
stock_moves_and_date = sorted(stock_moves_and_date, key=lambda m: m[1])
index = 0
for (move, date) in stock_moves_and_date:
if date < after_date or date > before_date:
continue
# Skip to the next time range if the planned date is not in the
# current time interval.
while not (date_range[index][0] <= date and date_range[index][1] >= date):
index += 1
key = (date_range[index], move.product_id, move.location_dest_id.warehouse_id)
if move.state == 'done':
incoming_qty_done[key] += move.product_qty
else:
incoming_qty[key] += move.product_qty
return incoming_qty, incoming_qty_done
def _get_indirect_demand_order(self, indirect_demand_trees):
""" return a new order for record in self. The order returned ensure
that the indirect demand from a record in the set could only be modified
by a record before it. The purpose of this function is to define the
states of multiple schedules only once by schedule and avoid to
recompute a state because its indirect demand was a depend from another
schedule.
"""
product_ids = self.mapped('product_id')
def _get_pre_order(node):
order_list = []
if node.product in product_ids:
order_list.append(node.product)
for child in node.children:
order_list += _get_pre_order(child)
return order_list
product_order_by_tree = []
for node in indirect_demand_trees:
product_order_by_tree += _get_pre_order(node)
product_order = OrderedDict()
for product in reversed(product_order_by_tree):
if product not in product_order:
product_order[product] = True
mps_order_by_product = defaultdict(lambda: self.env['mrp.production.schedule'])
for mps in self:
mps_order_by_product[mps.product_id] |= mps
mps_order = self.env['mrp.production.schedule']
for product in reversed(product_order.keys()):
mps_order |= mps_order_by_product[product]
return mps_order
def _get_indirect_demand_ratio_mps(self, indirect_demand_trees):
""" Return {(warehouse, product): {product: ratio}} dict containing the indirect ratio
between two products.
"""
by_warehouse_mps = defaultdict(lambda: self.env['mrp.production.schedule'])
for mps in self:
by_warehouse_mps[mps.warehouse_id] |= mps
result = defaultdict(lambda: defaultdict(float))
for warehouse_id, other_mps in by_warehouse_mps.items():
other_mps_product_ids = other_mps.mapped('product_id')
subtree_visited = set()
def _dfs_ratio_search(current_node, ratio, node_indirect=False):
for child in current_node.children:
if child.product in other_mps_product_ids:
result[(warehouse_id, node_indirect and node_indirect.product or current_node.product)][child.product] += ratio * child.ratio
if child.product in subtree_visited: # Don't visit the same subtree twice
continue
subtree_visited.add(child.product)
_dfs_ratio_search(child, 1.0, node_indirect=False)
else: # Hidden Bom => continue DFS and set node_indirect
_dfs_ratio_search(child, child.ratio * ratio, node_indirect=current_node)
for tree in indirect_demand_trees:
_dfs_ratio_search(tree, tree.ratio)
return result
def _get_indirect_demand_tree(self):
""" Get the tree architecture for all the BoM and BoM line that are
related to production schedules in self. The purpose of the tree:
- Easier traversal than with BoM and BoM lines.
- Allow to determine the schedules evaluation order. (compute the
schedule without indirect demand first)
It also made the link between schedules even if some intermediate BoM
levels are hidden. (e.g. B1 -1-> B2 -1-> B3, schedule for B1 and B3
are linked even if the schedule for B2 does not exist.)
Return a list of namedtuple that represent on top the schedules without
indirect demand and on lowest leaves the schedules that are the most
influenced by the others.
"""
bom_by_product = self.env['mrp.bom']._bom_find(self.product_id)
Node = namedtuple('Node', ['product', 'ratio', 'children'])
indirect_demand_trees = {}
product_visited = {}
def _get_product_tree(product, ratio):
product_tree = product_visited.get(product)
if product_tree:
return Node(product_tree.product, ratio, product_tree.children)
product_tree = Node(product, ratio, [])
product_bom = bom_by_product.get(product)
if product not in bom_by_product and not product_bom:
product_bom = self.env['mrp.bom']._bom_find(product)[product]
for line in product_bom.bom_line_ids:
if line._skip_bom_line(product):
continue
line_qty = line.product_uom_id._compute_quantity(line.product_qty, line.product_id.uom_id)
bom_qty = line.bom_id.product_uom_id._compute_quantity(line.bom_id.product_qty, line.bom_id.product_tmpl_id.uom_id)
ratio = line_qty / bom_qty
tree = _get_product_tree(line.product_id, ratio)
product_tree.children.append(tree)
if line.product_id in indirect_demand_trees:
del indirect_demand_trees[line.product_id]
product_visited[product] = product_tree
return product_tree
for product in self.mapped('product_id'):
if product in product_visited:
continue
indirect_demand_trees[product] = _get_product_tree(product, 1.0)
return [tree for tree in indirect_demand_trees.values()]
def _get_moves_domain(self, date_start, date_stop, type):
""" Return domain for incoming or outgoing moves """
if not self:
return [('id', '=', False)]
location = type == 'incoming' and 'location_dest_id' or 'location_id'
location_dest = type == 'incoming' and 'location_id' or 'location_dest_id'
domain = []
common_domain = [
('state', 'not in', ['cancel', 'draft']),
(location + '.usage', '!=', 'inventory'),
'|',
(location_dest + '.usage', 'not in', ('internal', 'inventory')),
'&',
(location_dest + '.usage', '=', 'internal'),
'!',
(location_dest, 'child_of', self.mapped('warehouse_id.view_location_id').ids),
('is_inventory', '=', False),
('date', '<=', date_stop),
]
groupby_delay = defaultdict(list)
for schedule in self:
rules = schedule.product_id._get_rules_from_location(schedule.warehouse_id.lot_stock_id)
lead_days, dummy = rules.filtered(lambda r: r.action not in ['buy', 'manufacture'])._get_lead_days(schedule.product_id)
delay = lead_days['total_delay']
groupby_delay[delay].append((schedule.product_id, schedule.warehouse_id))
for delay in groupby_delay:
products, warehouses = zip(*groupby_delay[delay])
warehouses = self.env['stock.warehouse'].concat(*warehouses)
products = self.env['product.product'].concat(*products)
specific_domain = [
(location, 'child_of', warehouses.mapped('view_location_id').ids),
('product_id', 'in', products.ids),
('date', '>=', date_start - relativedelta(days=delay)),
]
domain = OR([domain, AND([common_domain, specific_domain])])
return domain
@api.model
def _get_dest_moves_delay(self, move, delay=0):
if move.origin_returned_move_id:
return delay
elif not move.move_dest_ids:
return delay + move.rule_id.delay
else:
delays = []
additional_delay = move.rule_id.delay
for move_dest in move.move_dest_ids:
delays.append(self._get_dest_moves_delay(
move_dest, delay=delay + additional_delay))
return max(delays)
def _get_moves_and_date(self, moves_domain, order=False):
moves = self.env['stock.move'].search(moves_domain, order=order)
res_moves = []
for move in moves:
delay = self._get_dest_moves_delay(move)
date = fields.Date.to_date(move.date) + relativedelta(days=delay)
res_moves.append((move, date))
return res_moves
def _get_outgoing_qty(self, date_range):
""" Get the outgoing quantity from existing moves.
return a dict with as key a production schedule and as values a list
of outgoing quantity for each date range.
"""
outgoing_qty = defaultdict(float)
outgoing_qty_done = defaultdict(float)
after_date = date_range[0][0]
before_date = date_range[-1][1]
# Get quantity on incoming moves
domain_moves = self._get_moves_domain(after_date, before_date, 'outgoing')
domain_moves = AND([domain_moves, [('raw_material_production_id', '=', False)]])
stock_moves_by_date = self._get_moves_and_date(domain_moves)
stock_moves_by_date = sorted(stock_moves_by_date, key=lambda m: m[1])
index = 0
for (move, date) in stock_moves_by_date:
# There are cases when we want to consider moves where their (scheduled) date occurs before the after_date
# if lead times make their stock delivery at a relevant time. Therefore we need to ignore the lines that have
# date + lead time < after_date. Similar logic with before_date
if date < after_date or date > before_date:
continue
# Skip to the next time range if the planned date is not in the
# current time interval.
while not (date_range[index][0] <= date and date_range[index][1] >= date):
index += 1
key = (date_range[index], move.product_id, move.location_id.warehouse_id)
if move.state == 'done':
outgoing_qty_done[key] += move.product_uom_qty
else:
outgoing_qty[key] += move.product_uom_qty
return outgoing_qty, outgoing_qty_done
def _get_rfq_domain(self, date_start, date_stop):
""" Return a domain used to compute the incoming quantity for a given
product/warehouse/company.
:param date_start: start date of the forecast domain
:param date_stop: end date of the forecast domain
"""
if not self:
return [('id', '=', False)]
domain = []
common_domain = [
('state', 'in', ('draft', 'sent', 'to approve')),
('date_planned', '<=', date_stop)
]
groupby_delay = defaultdict(list)
for schedule in self:
rules = schedule.product_id._get_rules_from_location(schedule.warehouse_id.lot_stock_id)
lead_days, dummy = rules._get_lead_days(schedule.product_id)
delay = lead_days['total_delay']
groupby_delay[delay].append((schedule.product_id, schedule.warehouse_id))
for delay in groupby_delay:
products, warehouses = zip(*groupby_delay[delay])
warehouses = self.env['stock.warehouse'].concat(*warehouses)
products = self.env['product.product'].concat(*products)
specific_domain = [
('order_id.picking_type_id.default_location_dest_id', 'child_of', warehouses.mapped('view_location_id').ids),
('product_id', 'in', products.ids),
('date_planned', '>=', date_start - relativedelta(days=delay)),
]
domain = OR([domain, AND([common_domain, specific_domain])])
return domain
def _get_rfq_and_planned_date(self, rfq_domain, order=False):
purchase_lines = self.env['purchase.order.line'].search(rfq_domain, order=order)
res_purchase_lines = []
for line in purchase_lines:
if not line.move_dest_ids:
res_purchase_lines.append((line, fields.Date.to_date(line.date_planned)))
continue
delay = max(map(self._get_dest_moves_delay, line.move_dest_ids))
date = fields.Date.to_date(line.date_planned) + relativedelta(days=delay)
res_purchase_lines.append((line, date))
return res_purchase_lines
class MrpProductForecast(models.Model):
_name = 'mrp.product.forecast'
_order = 'date'
_description = 'Product Forecast at Date'
production_schedule_id = fields.Many2one('mrp.production.schedule',
required=True, ondelete='cascade')
date = fields.Date('Date', required=True)
forecast_qty = fields.Float('Demand Forecast')
replenish_qty = fields.Float('To Replenish')
replenish_qty_updated = fields.Boolean('Replenish_qty has been manually updated')
procurement_launched = fields.Boolean('Procurement has been run for this forecast')