# 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')