1241 lines
63 KiB
Python
1241 lines
63 KiB
Python
# 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.fields import Datetime, Domain
|
|
from odoo.tools.date_utils import add, subtract
|
|
from odoo.tools.float_utils import float_compare, float_round
|
|
from collections import OrderedDict
|
|
|
|
|
|
class MrpProductionSchedule(models.Model):
|
|
_name = 'mrp.production.schedule'
|
|
_inherit = ['stock.replenish.mixin']
|
|
_order = 'warehouse_id, mps_sequence, product_id'
|
|
_description = 'Schedule the production of Product in a warehouse'
|
|
|
|
@api.model
|
|
def _default_warehouse_id(self):
|
|
warehouse = self.env['stock.warehouse'].search(self.env['stock.warehouse']._check_company_domain(self.env.company), limit=1)
|
|
if not warehouse:
|
|
self.env['stock.warehouse']._warehouse_redirect_warning()
|
|
return warehouse
|
|
|
|
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')
|
|
warehouse_id = fields.Many2one('stock.warehouse', 'Production Warehouse',
|
|
required=True, default=lambda self: self._default_warehouse_id())
|
|
route_id = fields.Many2one(compute='_compute_route_and_supplier', store=True, readonly=False, help="Route to replenish your product.")
|
|
is_manufacture_route = fields.Boolean(compute='_compute_is_manufacture_route')
|
|
supplier_id = fields.Many2one(compute='_compute_route_and_supplier', store=True, readonly=False)
|
|
bom_id = fields.Many2one(
|
|
'mrp.bom', "Bill of Materials",
|
|
domain="[('product_tmpl_id', '=', product_tmpl_id), '|', ('product_id', '=', product_id), ('product_id', '=', False)]",
|
|
help="If set, the bill of materials components will also be imported.",
|
|
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.")
|
|
replenish_trigger = fields.Selection([
|
|
('manual', "Manual"),
|
|
('automated', "Automatic"),
|
|
('never', "Never")], string='Replenishment Trigger', default='manual', required=True,
|
|
help="Manual: Product to be replenished manually from MPS.\n"
|
|
"Automatic: Product replenished automatically via scheduled action.\n"
|
|
"Never: Product is not replenished from MPS.")
|
|
replenish_state = fields.Selection([
|
|
('to_replenish', 'To Replenish'),
|
|
('under_replenishment', 'Under Replenishment'),
|
|
('excessive_replenishment', 'Excessive Replenishment'),
|
|
('low_forecast', 'Forecast Too Low')], store=False, search='_search_replenish_state',
|
|
help="Technical field to support filtering by replenish state")
|
|
mps_sequence = fields.Integer('Sequence', default=10)
|
|
is_indirect = fields.Boolean('Indirect demand product', default=False,
|
|
help="When checked, this product will not appear in the 'To Forecast' filter.")
|
|
|
|
_warehouse_product_ref_uniq = models.Constraint(
|
|
'unique (warehouse_id, product_id)',
|
|
"The combination of warehouse and product must be unique!",
|
|
)
|
|
|
|
# TODO: move logic to stock.replenish.mixin
|
|
@api.depends('product_id', 'product_id.route_ids')
|
|
def _compute_route_and_supplier(self):
|
|
for mps in self:
|
|
if not mps.product_id:
|
|
continue
|
|
mps.route_id = next((r for r in mps.product_id.route_ids if r.id in mps.allowed_route_ids.ids), False)
|
|
mps.supplier_id = mps.product_id.seller_ids[:1]
|
|
|
|
@api.depends('route_id', 'route_id.rule_ids')
|
|
def _compute_is_manufacture_route(self):
|
|
for mps in self:
|
|
mps.is_manufacture_route = mps.route_id and mps.route_id.rule_ids and 'manufacture' in mps.route_id.rule_ids.mapped('action')
|
|
|
|
def _search_replenish_state(self, operator, value):
|
|
if operator != 'in':
|
|
return NotImplemented
|
|
states = set(self._fields['replenish_state'].get_values(self.env))
|
|
states.add(False)
|
|
states.intersection_update(value)
|
|
if not states:
|
|
return Domain.FALSE
|
|
|
|
productions_schedules = self.search([])
|
|
productions_schedules_states = productions_schedules.get_production_schedule_view_state()
|
|
|
|
def filter_forecasts(forecasts):
|
|
forecast_state = set()
|
|
for f in forecasts:
|
|
if f['state'] == 'to_launch':
|
|
if f['to_replenish']:
|
|
forecast_state.add('to_replenish')
|
|
elif f['state'] == 'to_relaunch':
|
|
forecast_state.add('under_replenishment')
|
|
forecast_state.add('to_replenish')
|
|
elif f['state'] == 'to_correct':
|
|
forecast_state.add('excessive_replenishment')
|
|
|
|
if f['outgoing_qty'] > f['forecast_qty'] or f['indirect_outgoing_qty'] > f['indirect_demand_qty']:
|
|
forecast_state.add('low_forecast')
|
|
|
|
if not forecast_state:
|
|
return False in states
|
|
return states.intersection(forecast_state)
|
|
|
|
ids = [
|
|
state['id']
|
|
for state in productions_schedules_states
|
|
if filter_forecasts(state['forecast_ids'])
|
|
]
|
|
return Domain('id', 'in', 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_str: select incoming moves after this date
|
|
:param date_stop_str: 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 %(product)s %(date)s (%(date_start)s - %(date_end)s)', product=self.product_id.display_name, date=date_str, date_start=date_start_str, date_end=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_str: select incoming moves and RFQ after this date
|
|
:param date_stop_str: 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 %(product)s %(date)s (%(date_start)s - %(date_end)s)',
|
|
product=self.product_id.display_name,
|
|
date=date_str,
|
|
date_start=date_start_str,
|
|
date_end=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_schedules_to_replenish = self.filtered(lambda p: p.replenish_trigger != 'never')
|
|
production_schedule_states = production_schedules_to_replenish.get_production_schedule_view_state(use_all_schedules=True)
|
|
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 production_schedules_to_replenish:
|
|
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[1]['qty'] / bom.product_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['stock.rule'].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['stock.rule'].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['stock.rule'].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 action_cron_replenish(self):
|
|
self.search([('replenish_trigger', '=', 'automated')]).action_replenish(based_on_lead_time=True)
|
|
|
|
def action_toggle_is_indirect(self):
|
|
for record in self:
|
|
record.is_indirect = not record.is_indirect
|
|
|
|
@api.model
|
|
def get_mps_view_state(self, domain=False, offset=0, limit=False, period_scale=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(period_scale)
|
|
company_groups = self.env.company.read([
|
|
'mrp_mps_show_starting_inventory',
|
|
'mrp_mps_show_demand_forecast',
|
|
'mrp_mps_show_indirect_demand',
|
|
'mrp_mps_show_indirect_actual_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(period_scale),
|
|
'production_schedule_ids': productions_schedules_states,
|
|
'manufacturing_period': period_scale or self.env.company.manufacturing_period,
|
|
'default_period': self.env.company.manufacturing_period,
|
|
'manufacturing_period_types': [s[0] for s in self.env.company._fields['manufacturing_period'].selection],
|
|
'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)
|
|
|
|
mps._assign_mps_sequence()
|
|
|
|
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.is_storable:
|
|
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_count([
|
|
('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],
|
|
'is_indirect': True,
|
|
})
|
|
if components_vals:
|
|
self.env['mrp.production.schedule'].create(components_vals)
|
|
return mps
|
|
|
|
def _assign_mps_sequence(self):
|
|
""" Determine the sequence of the new MPS records as well as any existing record they impact.
|
|
Will parse the indirect_demand_trees for each record and see at what level the corresponding product is found.
|
|
The final order will be as follow:
|
|
- final product 1
|
|
- final product 2
|
|
- final product 3
|
|
- semi-finished product 1
|
|
- semi-finished product 2
|
|
- semi-finished product 3
|
|
- component 1
|
|
- component 2
|
|
- component 3
|
|
"""
|
|
schedules_to_compute = self._get_impacted_schedule() | self
|
|
indirect_demand_trees = schedules_to_compute._get_indirect_demand_tree()
|
|
|
|
def _get_level_dict(nodes_to_check, level_dict=False, current_level=0):
|
|
if not level_dict:
|
|
level_dict = {}
|
|
next_nodes = []
|
|
for node in nodes_to_check:
|
|
if node.product not in level_dict:
|
|
level_dict[node.product] = current_level
|
|
next_nodes += node.children
|
|
if next_nodes:
|
|
return _get_level_dict(next_nodes, level_dict, current_level + 1)
|
|
return level_dict
|
|
|
|
level_by_product = _get_level_dict(indirect_demand_trees)
|
|
|
|
for mps in schedules_to_compute:
|
|
mps.mps_sequence = 10 + level_by_product[mps.product_id]
|
|
|
|
def get_production_schedule_view_state(self, period_scale=False, use_all_schedules=False):
|
|
""" 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 direct outgoing moves quantity (i.e. from SOs).
|
|
- indirect_outgoing_qty: The indirect outgoing moves quantity (i.e. from MOs and
|
|
moves going to subcontracting locations).
|
|
- 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(force_period=period_scale)
|
|
date_range_year_minus_1 = company_id._get_date_range(years=1, force_period=period_scale)
|
|
date_range_year_minus_2 = company_id._get_date_range(years=2, force_period=period_scale)
|
|
|
|
# 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._get_impacted_schedule()
|
|
if use_all_schedules
|
|
else self._get_impacted_parent_schedules()) | 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)
|
|
demand_qty_dict = defaultdict(lambda: defaultdict(float))
|
|
incoming_qty, incoming_qty_done = self._get_incoming_qty(date_range)
|
|
outgoing_qty, outgoing_qty_done, indirect_outgoing_qty, indirect_outgoing_qty_done = self._get_outgoing_qty(date_range)
|
|
__, outgoing_qty_year_minus_1, __, __ = self._get_outgoing_qty(date_range_year_minus_1)
|
|
__, outgoing_qty_year_minus_2, __, __ = self._get_outgoing_qty(date_range_year_minus_2)
|
|
read_fields = [
|
|
'forecast_target_qty',
|
|
'min_to_replenish_qty',
|
|
'product_id',
|
|
'replenish_trigger',
|
|
]
|
|
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_id=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)
|
|
starting_inventory_qty += indirect_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 and
|
|
(p.forecast_qty or p.replenish_qty or p.procurement_launched or p.replenish_qty_updated))
|
|
if production_schedule in self:
|
|
forecast_values['date_start'] = date_start
|
|
forecast_values['date_stop'] = date_stop
|
|
forecast_values['outgoing_qty'] = float_round(outgoing_qty.get(key, 0.0) + outgoing_qty_done.get(key, 0.0), precision_rounding=rounding)
|
|
forecast_values['indirect_outgoing_qty'] = float_round(indirect_outgoing_qty.get(key, 0.0) + indirect_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)
|
|
|
|
indirect_qty_value = sum(demand_qty_dict.get(key, {0: 0}).values())
|
|
forecast_values['indirect_demand_qty'] = float_round(indirect_qty_value, precision_rounding=rounding, rounding_method='UP')
|
|
forecast_values['forecast_qty'] = float_round(sum(existing_forecasts.mapped('forecast_qty')), precision_rounding=rounding) # will be 0 if no existing forecast
|
|
forecast_values['replenish_qty_updated'] = any(existing_forecasts.mapped('replenish_qty_updated')) # will be False if no existing forecast
|
|
forecast_values['incoming_qty'] = float_round(incoming_qty.get(key, 0.0) + incoming_qty_done.get(key, 0.0), precision_rounding=rounding)
|
|
|
|
if forecast_values['replenish_qty_updated']:
|
|
forecast_values['replenish_qty'] = float_round(sum(existing_forecasts.mapped('replenish_qty')), precision_rounding=rounding)
|
|
else:
|
|
after_forecast_qty = starting_inventory_qty - forecast_values['forecast_qty'] - forecast_values['indirect_demand_qty']
|
|
replenish_qty = production_schedule._get_replenish_qty(after_forecast_qty=after_forecast_qty)
|
|
forecast_values['replenish_qty'] = float_round(replenish_qty, precision_rounding=rounding)
|
|
for forecast in existing_forecasts:
|
|
demand_qty_dict[key][forecast.date] += forecast.forecast_qty
|
|
|
|
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'] + max(forecast_values['incoming_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.
|
|
product_key = ((date_start, date_stop), production_schedule.product_id, production_schedule.warehouse_id)
|
|
for (product, ratio) in indirect_ratio_mps[(production_schedule.warehouse_id, production_schedule.product_id)].items():
|
|
subproduct_indirect_demand = 0
|
|
if demand_qty_dict.get(product_key, False):
|
|
for (parent_date, parent_quantity) in demand_qty_dict[product_key].items():
|
|
related_date = max(subtract(parent_date, days=lead_time_ignore_components), fields.Date.today())
|
|
index = next(i for i, (dstart, dstop) in enumerate(date_range) if related_date <= dstop)
|
|
related_key = (date_range[index], product, production_schedule.warehouse_id)
|
|
demand_qty_dict[related_key][related_date] += ratio * (parent_quantity - forecast_values['starting_inventory_qty'])
|
|
subproduct_indirect_demand += ratio * (parent_quantity - forecast_values['starting_inventory_qty'])
|
|
if float_compare((ratio * forecast_values['replenish_qty']), subproduct_indirect_demand, precision_rounding=rounding) != 0:
|
|
date_first_demand = min(demand_qty_dict.get(product_key, {}), default=date_start)
|
|
related_date = max(subtract(date_first_demand, days=lead_time_ignore_components), fields.Date.today())
|
|
index = next(i for i, (dstart, dstop) in enumerate(date_range) if related_date <= dstop)
|
|
related_key = (date_range[index], product, production_schedule.warehouse_id)
|
|
demand_qty_dict[related_key][related_date] += (ratio * forecast_values['replenish_qty']) - subproduct_indirect_demand
|
|
|
|
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'])
|
|
has_indirect_actual_demand = any(forecast['indirect_outgoing_qty'] != 0 for forecast in production_schedule_state['forecast_ids'])
|
|
production_schedule_state['has_indirect_demand'] = has_indirect_demand
|
|
production_schedule_state['has_indirect_actual_demand'] = has_indirect_actual_demand
|
|
return [production_schedule_states_by_id[_id] for _id in self.ids if _id in production_schedule_states_by_id]
|
|
|
|
def _get_impacted_parent_schedules(self, domain=None):
|
|
"""
|
|
Return all the parent (supplying) schedules of self, i.e.,
|
|
all the schedules where the product needs a product of self as (sub-)component.
|
|
"""
|
|
if domain is None:
|
|
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(
|
|
Domain.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)
|
|
]]))
|
|
|
|
return supplying_mps
|
|
|
|
def _get_impacted_child_schedules(self, domain=None):
|
|
"""
|
|
Return all the children (supplied) schedules of self, i.e.,
|
|
all the schedules where the product is (sub-)component of a product of self.
|
|
"""
|
|
if domain is None:
|
|
domain = []
|
|
|
|
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(
|
|
Domain.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 supplied_mps
|
|
|
|
def get_impacted_schedule(self, domain=False):
|
|
"""
|
|
Like `_get_impacted_schedule`, but returns a list of ids instead
|
|
to be used by the web client.
|
|
|
|
See `_get_impacted_schedule` for more details.
|
|
|
|
:param domain: Filter supplied and supplying schedules with the domain
|
|
:return: ids of supplied and supplying schedules
|
|
:rtype: list[int]
|
|
"""
|
|
return self._get_impacted_schedule(domain).ids
|
|
|
|
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: Records of supplied and supplying schedules
|
|
"""
|
|
if not domain:
|
|
domain = []
|
|
|
|
supplying_mps = self._get_impacted_parent_schedules(domain)
|
|
supplied_mps = self._get_impacted_child_schedules(domain)
|
|
return supplying_mps | supplied_mps
|
|
|
|
def remove_replenish_qty(self, date_index, period_scale=False):
|
|
""" 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(force_period=period_scale)[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, period_scale=False):
|
|
""" 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(force_period=period_scale)[date_index]
|
|
existing_forecasts = 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_forecasts.mapped('forecast_qty'))
|
|
first_forecast = existing_forecasts.sorted(key='date')[:1]
|
|
|
|
if quantity_to_add > 0:
|
|
if first_forecast.date != max(date_start, fields.Date.today()):
|
|
existing_forecasts.create({
|
|
'forecast_qty': quantity_to_add,
|
|
'date': max(date_start, fields.Date.today()),
|
|
'replenish_qty': 0,
|
|
'production_schedule_id': self.id
|
|
})
|
|
else:
|
|
new_qty = first_forecast.forecast_qty + quantity_to_add
|
|
new_qty = float_round(new_qty, precision_rounding=self.product_uom_id.rounding)
|
|
first_forecast.write({'forecast_qty': new_qty})
|
|
else:
|
|
for forecast in existing_forecasts.sorted(key='date', reverse=True):
|
|
new_qty = forecast.forecast_qty + quantity_to_add
|
|
quantity_to_add += forecast.forecast_qty
|
|
if new_qty >= 0:
|
|
new_qty = float_round(new_qty, precision_rounding=self.product_uom_id.rounding)
|
|
forecast.write({'forecast_qty': new_qty})
|
|
break
|
|
forecast.write({'forecast_qty': 0})
|
|
if quantity_to_add < 0:
|
|
new_qty = float_round(quantity_to_add, precision_rounding=self.product_uom_id.rounding)
|
|
first_forecast.write({'forecast_qty': new_qty})
|
|
return True
|
|
|
|
def set_replenish_qty(self, date_index, quantity, period_scale=False):
|
|
""" 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(force_period=period_scale)[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])
|
|
|
|
@api.model
|
|
def get_import_templates(self):
|
|
return [{
|
|
'label': _('Import Template for Master Production Schedule'),
|
|
'template': '/mrp_mps/static/xls/mps_import_template.xlsx',
|
|
}]
|
|
|
|
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
|
|
"""
|
|
values = {
|
|
'date_planned': Datetime.to_datetime(forecast_values['date_start']),
|
|
'warehouse_id': self.warehouse_id,
|
|
}
|
|
if self.route_id:
|
|
values['route_ids'] = self.route_id
|
|
if self.supplier_id and self.show_vendor:
|
|
values['supplierinfo_id'] = self.supplier_id
|
|
if self.bom_id and self.is_manufacture_route:
|
|
values['bom_id'] = self.bom_id
|
|
return values
|
|
|
|
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 to do. Either the cell is in the lead time range but
|
|
the forecast match the actual replenishment. Or a forced 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 production_schedule.replenish_trigger == 'never':
|
|
state = 'to_launch'
|
|
elif 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 and replenish_qty != 0)):
|
|
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 and production_schedule.replenish_trigger != 'never':
|
|
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, route_ids=self.route_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 minimum 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
|
|
bom_id = self.bom_id or self.product_id.bom_ids and self.product_id.bom_ids[0]
|
|
if bom_id and bom_id.enable_batch_size:
|
|
batch_size = bom_id.product_uom_id._compute_quantity(bom_id.batch_size, self.product_uom_id)
|
|
optimal_qty = float_round(optimal_qty / batch_size, precision_digits=0, rounding_method='UP') * batch_size
|
|
|
|
if 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_id._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)
|
|
bom_by_schedule = {schedule.product_id: schedule.bom_id for schedule in self if schedule.bom_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, [])
|
|
|
|
if product in bom_by_schedule:
|
|
product_bom = bom_by_schedule[product]
|
|
elif product in bom_by_product:
|
|
product_bom = bom_by_product[product]
|
|
else:
|
|
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'
|
|
|
|
# In case of delivery in multi-steps, the real outgoing move does not exist
|
|
# before confirming the previous moves (pick and/or pack), so we must use
|
|
# that/those move(s) instead but only once.
|
|
warehouse_view_location_ids = self.mapped('warehouse_id.view_location_id').ids
|
|
if type == 'outgoing':
|
|
sub_dest_domain = [
|
|
'|',
|
|
'!', (location_dest, 'child_of', warehouse_view_location_ids),
|
|
'&', '&',
|
|
('location_final_id', '!=', False),
|
|
('location_final_id.usage', 'not in', ('internal', 'inventory')),
|
|
('move_dest_ids', '=', False)
|
|
]
|
|
else:
|
|
sub_dest_domain = ['!',(location_dest, 'child_of', warehouse_view_location_ids)]
|
|
|
|
common_domain = [
|
|
('is_inventory', '=', False),
|
|
('date', '<=', date_stop),
|
|
('state', 'not in', ['cancel', 'draft']),
|
|
(location + '.usage', '!=', 'inventory'),
|
|
'|',
|
|
(location_dest + '.usage', 'not in', ('internal', 'inventory')),
|
|
'&',
|
|
(location_dest + '.usage', '=', 'internal'),
|
|
*sub_dest_domain,
|
|
]
|
|
|
|
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))
|
|
|
|
specific_domains = []
|
|
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_domains.append([
|
|
(location, 'child_of', warehouses.mapped('view_location_id').ids),
|
|
('product_id', 'in', products.ids),
|
|
('date', '>=', date_start - relativedelta(days=delay)),
|
|
])
|
|
|
|
if specific_domains:
|
|
return Domain(common_domain) & Domain.OR(specific_domains)
|
|
return Domain.TRUE
|
|
|
|
@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 = []
|
|
moves.fetch(['move_dest_ids', 'company_id', 'state', 'date', 'rule_id', 'product_id', 'location_id', 'origin_returned_move_id', 'product_qty'])
|
|
for move in moves.with_context(prefetch_fields=False):
|
|
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.
|
|
|
|
param: date_range: list of time slots used in order to group outgoing quantity.
|
|
return:
|
|
outgoing_qty: {(date_range, product, warehouse): qty} for direct and not done moves
|
|
outgoing_qty_done: {(date_range, product, warehouse): qty} for direct and done moves
|
|
indirect_outgoing_qty: {(date_range, product, warehouse): qty} for indirect and not done moves
|
|
indirect_outgoing_qty_done: {(date_range, product, warehouse): qty} for indirect and done moves
|
|
"""
|
|
outgoing_qty = defaultdict(float)
|
|
outgoing_qty_done = defaultdict(float)
|
|
indirect_outgoing_qty = defaultdict(float)
|
|
indirect_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')
|
|
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)
|
|
is_indirect_demand = move.location_dest_id.usage == 'production' or 'is_subcontracting_location' in move.location_dest_id._fields and move.location_dest_id.is_subcontracting_location
|
|
if move.state == 'done':
|
|
if is_indirect_demand:
|
|
indirect_outgoing_qty_done[key] += move.product_qty
|
|
else:
|
|
outgoing_qty_done[key] += move.product_qty
|
|
else:
|
|
if is_indirect_demand:
|
|
indirect_outgoing_qty[key] += move.product_qty
|
|
else:
|
|
outgoing_qty[key] += move.product_qty
|
|
|
|
return outgoing_qty, outgoing_qty_done, indirect_outgoing_qty, indirect_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 Domain.FALSE
|
|
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))
|
|
|
|
if not groupby_delay:
|
|
return Domain.FALSE
|
|
domain = Domain.FALSE
|
|
common_domain = Domain([
|
|
('state', 'in', ('draft', 'sent', 'to approve')),
|
|
('date_planned', '<=', date_stop)
|
|
])
|
|
|
|
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)
|
|
domain |= 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)),
|
|
])
|
|
return common_domain & 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, index=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')
|