# Copyright 2019-20 ForgeFlow S.L. (http://www.forgeflow.com) # License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). import json import logging import operator as py_operator import threading from collections import defaultdict from datetime import datetime, timedelta from math import pi from odoo import _, api, exceptions, fields, models from odoo.exceptions import ValidationError from odoo.tools import float_compare, float_round from odoo.tools.misc import split_every _logger = logging.getLogger(__name__) try: from bokeh.embed import components from bokeh.models import ( ColumnDataSource, DatetimeTickFormatter, HoverTool, LabelSet, Legend, ) from bokeh.plotting import figure from bokeh.util.serialization import convert_datetime_type except (ImportError, IOError) as err: _logger.debug(err) OPERATORS = { "<": py_operator.lt, ">": py_operator.gt, "<=": py_operator.le, ">=": py_operator.ge, "==": py_operator.eq, "=": py_operator.eq, "!=": py_operator.ne, } _PRIORITY_LEVEL = [("1_red", "Red"), ("2_yellow", "Yellow"), ("3_green", "Green")] DDMRP_COLOR = { "0_dark_red": "#8B0000", "1_red": "#ff0000", "2_yellow": "#ffff00", "3_green": "#33cc33", } class StockBuffer(models.Model): _name = "stock.buffer" _description = "Stock Buffer" _order = "planning_priority_level asc, net_flow_position_percent asc" CRON_DDMRP_CHUNKS = 50 @api.model def default_get(self, fields): res = super().default_get(fields) warehouse = None if "warehouse_id" not in res and res.get("company_id"): warehouse = self.env["stock.warehouse"].search( [("company_id", "=", res["company_id"])], limit=1 ) if warehouse: res["warehouse_id"] = warehouse.id res["location_id"] = warehouse.lot_stock_id.id return res name = fields.Char( copy=False, required=True, default=lambda self: self.env["ir.sequence"].next_by_code("stock.buffer"), ) active = fields.Boolean(default=True) warehouse_id = fields.Many2one( comodel_name="stock.warehouse", string="Warehouse", ondelete="cascade", required=True, ) location_id = fields.Many2one( comodel_name="stock.location", string="Location", ondelete="cascade", required=True, ) product_id = fields.Many2one( comodel_name="product.product", string="Product", domain=[("type", "=", "product")], ondelete="cascade", required=True, ) product_uom = fields.Many2one( related="product_id.uom_id", ) product_categ_id = fields.Many2one( comodel_name="product.category", string="Product Category", related="product_id.categ_id", store=True, readonly=True, ) # TODO: fix in method _compute_procure_recommended_qty. # not sure maybe they are useful for tweak batches like in multi level mrp procure_min_qty = fields.Float( string="Minimum Procure Batch", digits="Product Unit of Measure", help="Minimum qty for a single procurement", ) procure_max_qty = fields.Float( string="Maximum Procure Batch", digits="Product Unit of Measure", help="Maximum qty for a single procurement", ) qty_multiple = fields.Float( digits="Product Unit of Measure", default=1, required=True, help="The procurement quantity will be rounded up to this multiple. " "If it is 0, the exact quantity will be used.", ) group_id = fields.Many2one( comodel_name="procurement.group", string="Procurement Group", copy=False, help="Moves created through this buffer will be put in this " "procurement group. If none is given, the moves generated by " "stock rules will be grouped into one big picking.", ) company_id = fields.Many2one( comodel_name="res.company", string="Company", required=True, default=lambda self: self.env.company, ) # TODO: rename to manual LT ?? lead_days = fields.Integer( "Lead Time (Distributed)", default=1, help="Lead time for distributed products.", ) _sql_constraints = [ ( "qty_multiple_check", "CHECK( qty_multiple >= 0 )", "Qty Multiple must be greater than or equal to zero.", ), ( "stock_buffer_uniq", "unique(product_id, location_id)", "The product/location combination must be unique." "Remember that the buffer could be archived.", ), ] def _quantity_in_progress(self): """Return Quantities that are not yet in virtual stock but should be deduced from buffers (example: purchases created from buffers)""" res = {}.fromkeys(self.ids, 0.0) for buffer in self: polines = buffer._get_rfq_dlt(dlt_interval=None) for line in polines: res[buffer.id] += line.product_uom._compute_quantity( line.product_qty, buffer.product_uom, round=False ) return res def action_view_purchase(self): action = self.env["ir.actions.actions"]._for_xml_id("purchase.purchase_rfq") # Remove the context since the action basically display RFQ and not PO. action["context"] = {} order_line_ids = self.env["purchase.order.line"].search( [("buffer_ids", "in", self.ids)] ) purchase_ids = order_line_ids.mapped("order_id") action["domain"] = [("id", "in", purchase_ids.ids)] return action def action_view_yearly_consumption(self): action = self.env["ir.actions.actions"]._for_xml_id( "ddmrp.stock_move_year_consumption_action" ) locations = ( self.env["stock.location"] .with_context(active_test=False) .search([("id", "child_of", self.location_id.ids)]) ) date_to = fields.Date.today() # We take last five years, even though they will be initially # filtered in the action to show only last year. date_from = date_to - timedelta(days=5 * 365) action["domain"] = self._past_moves_domain(date_from, date_to, locations) return action def _demand_estimate_domain(self, locations, date_from=False, date_to=False): self.ensure_one() domain = [ ("location_id", "in", locations.ids), ("product_id", "=", self.product_id.id), ] if date_to: domain += [("date_from", "<=", date_to)] if date_from: domain += [("date_to", ">=", date_from)] return domain def action_view_stock_demand_estimates(self): result = self.env["ir.actions.actions"]._for_xml_id( "stock_demand_estimate.stock_demand_estimate_action" ) locations = self.env["stock.location"].search( [("id", "child_of", [self.location_id.id])] ) domain = self._demand_estimate_domain(locations) recs = self.env["stock.demand.estimate"].search(domain) result["domain"] = [("id", "in", recs.ids)] return result def action_view_bom(self): action = self.product_id.action_view_bom() boms = self._get_manufactured_bom(limit=100) action["domain"] = [("id", "in", boms.ids)] action["context"] = { "location_id": self.location_id.id, "warehouse_id": self.location_id.warehouse_id.id, } return action @api.constrains("product_id") def _check_product_uom(self): if any( buffer.product_id.uom_id.category_id != buffer.product_uom.category_id for buffer in self ): raise ValidationError( _( "You have to select a product unit of measure that is in" "the same category than the default unit of" "measure of the product" ) ) @api.onchange("warehouse_id") def onchange_warehouse_id(self): if self.warehouse_id: self.location_id = self.warehouse_id.lot_stock_id.id @api.onchange("product_id") def onchange_product_id(self): if self.product_id: self.product_uom = self.product_id.uom_id.id return { "domain": { "product_uom": [ ("category_id", "=", self.product_id.uom_id.category_id.id) ] } } return {"domain": {"product_uom": []}} def _prepare_procurement_values( self, product_qty, date=False, group=False, ): """Prepare specific key for moves or other components that will be created from a stock rule comming from a buffer. This method could be override in order to add other custom key that could be used in move/po creation. """ return { "date_planned": date or self._get_date_planned(), "warehouse_id": self.warehouse_id, "buffer_id": self, "company_id": self.company_id, "group_id": group or self.group_id, } # MANUAL PROCUREMENT AND UOM def _get_date_planned(self, force_lt=None): self.ensure_one() profile = self.buffer_profile_id dlt = int(self.dlt) if force_lt and isinstance(force_lt, (int, float)): dlt = force_lt if profile.item_type == "distributed": max_proc_time = profile.distributed_reschedule_max_proc_time else: max_proc_time = 0 # For purchased items we always consider calendar days, # not work days. if profile.item_type == "purchased": dt_planned = fields.datetime.today() + timedelta(days=dlt) else: if self.warehouse_id.calendar_id: dt_planned = self.warehouse_id.wh_plan_days(fields.datetime.now(), dlt) if max_proc_time: calendar = self.warehouse_id.calendar_id # We found the day with "wh_plan_day", now determine # the first available hour in the day (wh_plan_day returns # the stop hour), and add the procurement time. dt_planned = calendar.plan_hours( # expect hours max_proc_time / 60, # start from the first working hours available dt_planned.replace(hour=0, minute=0, second=0), ) else: dt_planned = ( fields.datetime.now() + timedelta(days=dlt) + timedelta(minutes=max_proc_time) ) return dt_planned procure_recommended_qty = fields.Float( string="Procure Recommendation", compute="_compute_procure_recommended_qty", store=True, ) procure_uom_id = fields.Many2one( comodel_name="uom.uom", string="Procurement UoM", compute="_compute_procure_uom_id", readonly=False, store=True, ) @api.constrains("product_id", "procure_uom_id") def _check_procure_uom(self): if any( buffer.product_uom and buffer.procure_uom_id and buffer.product_uom.category_id != buffer.procure_uom_id.category_id for buffer in self ): raise ValidationError( _( "Error: The product default Unit of Measure and the " "procurement Unit of Measure must be in the same category." ) ) # STOCK INFORMATION: product_location_qty_available_not_res = fields.Float( string="Quantity On Hand (Unreserved)", help="Quantity available in this stock buffer, this is the total " "quantity on hand minus the outgoing reservations.", readonly=True, ) def _get_outgoing_reservation_qty(self): """Return the qty reserved in operations that move products outside of the buffer in the UoM of the product.""" domain = [ ("product_id", "=", self.product_id.id), ("state", "in", ("partially_available", "assigned")), ] lines = self.env["stock.move.line"].search(domain) lines = lines.filtered( lambda line: line.location_id.is_sublocation_of(self.location_id) and not line.location_dest_id.is_sublocation_of(self.location_id) ) return sum(lines.mapped("reserved_qty")) def _update_quantities_dict(self, product): self.ensure_one() reserved_qty = self._get_outgoing_reservation_qty() self.update( { "product_location_qty_available_not_res": product["qty_available"] - reserved_qty, } ) def _calc_product_available_qty(self): operation_by_location = defaultdict(lambda: self.browse()) for rec in self: operation_by_location[rec.location_id] |= rec for location_id, buffer_in_location in operation_by_location.items(): products = ( buffer_in_location.mapped("product_id") .with_context(location=location_id.id) ._compute_quantities_dict( lot_id=self.env.context.get("lot_id"), owner_id=self.env.context.get("owner_id"), package_id=self.env.context.get("package_id"), ) ) for buffer in buffer_in_location: product = products[buffer.product_id.id] buffer._update_quantities_dict(product) # PURCHASES LINK: purchase_line_ids = fields.Many2many( comodel_name="purchase.order.line", string="Purchase Order Lines", copy=False, readonly=True, ) # MRP LINK: def action_view_mrp_productions(self): result = self.env["ir.actions.actions"]._for_xml_id("mrp.mrp_production_action") result["context"] = {} mrp_production_ids = self.env["mrp.production"].search( [("buffer_id", "=", self.id)] ) result["domain"] = [("id", "in", mrp_production_ids.ids)] return result product_type = fields.Selection(related="product_id.type", readonly=True) used_in_bom_count = fields.Integer(related="product_id.used_in_bom_count") def action_used_in_bom(self): self.ensure_one() action = self.env["ir.actions.actions"]._for_xml_id("mrp.mrp_bom_form_action") action["domain"] = [("bom_line_ids.product_id", "=", self.product_id.id)] return action # DDMRP SPECIFIC: @api.depends( "dlt", "extra_lead_time", "adu", "buffer_profile_id.lead_time_id.factor", "red_override", "buffer_profile_id.variability_id.factor", "product_uom.rounding", "lead_days", "product_id.seller_ids.delay", ) def _compute_red_zone(self): for rec in self: if rec.product_id and rec.replenish_method in ["replenish", "min_max"]: dlt = rec.dlt + rec.extra_lead_time rec.red_base_qty = float_round( dlt * rec.adu * rec.buffer_profile_id.lead_time_id.factor, precision_rounding=rec.product_uom.rounding, ) rec.red_safety_qty = float_round( rec.red_base_qty * rec.buffer_profile_id.variability_id.factor, precision_rounding=rec.product_uom.rounding, ) rec.red_zone_qty = rec.red_base_qty + rec.red_safety_qty elif rec.product_id and rec.replenish_method == "replenish_override": rec.red_zone_qty = rec.red_override else: rec.red_zone_qty = 0.0 @api.depends( "dlt", "extra_lead_time", "adu", "buffer_profile_id.lead_time_id.factor", "order_cycle", "minimum_order_quantity", "product_uom.rounding", "green_override", "top_of_yellow", ) def _compute_green_zone(self): for rec in self: if rec.product_id and rec.replenish_method in ["replenish", "min_max"]: # Using imposed or desired minimum order cycle rec.green_zone_oc = float_round( rec.order_cycle * rec.adu, precision_rounding=rec.product_uom.rounding, ) # Using lead time factor dlt = rec.dlt + rec.extra_lead_time rec.green_zone_lt_factor = float_round( dlt * rec.adu * rec.buffer_profile_id.lead_time_id.factor, precision_rounding=rec.product_uom.rounding, ) # Using minimum order quantity rec.green_zone_moq = float_round( rec.minimum_order_quantity, precision_rounding=rec.product_uom.rounding, ) # The biggest option of the above will be used as the green # zone value rec.green_zone_qty = max( rec.green_zone_oc, rec.green_zone_lt_factor, rec.green_zone_moq ) elif rec.product_id and rec.replenish_method == "replenish_override": rec.green_zone_qty = rec.green_override else: rec.green_zone_qty = 0.0 rec.top_of_green = rec.green_zone_qty + rec.top_of_yellow @api.depends( "dlt", "extra_lead_time", "adu", "buffer_profile_id.lead_time_id.factor", "buffer_profile_id.variability_id.factor", "buffer_profile_id.replenish_method", "order_cycle", "minimum_order_quantity", "product_uom.rounding", "yellow_override", "red_zone_qty", ) def _compute_yellow_zone(self): for rec in self: if rec.product_id and rec.replenish_method == "min_max": rec.yellow_zone_qty = 0 elif rec.product_id and rec.replenish_method == "replenish": dlt = rec.dlt + rec.extra_lead_time rec.yellow_zone_qty = float_round( dlt * rec.adu, precision_rounding=rec.product_uom.rounding ) elif rec.product_id and rec.replenish_method == "replenish_override": rec.yellow_zone_qty = rec.yellow_override else: rec.yellow_zone_qty = 0.0 rec.top_of_yellow = rec.yellow_zone_qty + rec.red_zone_qty @api.depends( "net_flow_position", "top_of_green", "qty_multiple", "product_uom", "procure_uom_id", "product_uom.rounding", ) def _compute_procure_recommended_qty(self): subtract_qty = self.sudo()._quantity_in_progress() for rec in self: procure_recommended_qty = 0.0 # uses _origin because onchange uses a NewId with the record wrapped if rec._origin and rec.net_flow_position < rec.top_of_yellow: qty = ( rec.top_of_green - rec.net_flow_position - subtract_qty[rec._origin.id] ) if qty >= 0.0: procure_recommended_qty = qty elif rec._origin: if subtract_qty[rec._origin.id] > 0.0: procure_recommended_qty -= subtract_qty[rec._origin.id] adjusted_qty = 0.0 if procure_recommended_qty > 0.0: adjusted_qty = rec._adjust_procure_qty(procure_recommended_qty) rec.procure_recommended_qty = adjusted_qty @api.depends("product_uom") def _compute_procure_uom_id(self): for rec in self: rec.procure_uom_id = rec.product_uom.id def _adjust_procure_qty(self, qty): self.ensure_one() # If there is a procure UoM we apply it before anything. # This means max, min and multiple quantities are relative to # the procure UoM. if self.procure_uom_id: rounding = self.procure_uom_id.rounding adjusted_qty = self.product_id.uom_id._compute_quantity( qty, self.procure_uom_id ) else: rounding = self.product_uom.rounding adjusted_qty = qty # Apply qty multiple and minimum quantity (maximum quantity # applies on the procure wizard) remainder = self.qty_multiple > 0 and adjusted_qty % self.qty_multiple or 0.0 multiple_tolerance = self.qty_multiple * ( self.company_id.ddmrp_qty_multiple_tolerance / 100 ) if ( float_compare(remainder, multiple_tolerance, precision_rounding=rounding) > 0 ): adjusted_qty += self.qty_multiple - remainder elif float_compare(remainder, 0.0, precision_rounding=rounding) > 0: adjusted_qty -= remainder if ( float_compare( adjusted_qty, self.procure_min_qty, precision_rounding=rounding ) < 0 ): adjusted_qty = self.procure_min_qty return adjusted_qty def _compute_ddmrp_chart_planning(self): """This method use the Bokeh library to create a buffer depiction.""" for rec in self: div, script = rec.get_ddmrp_chart_planning() json_data = json.dumps( { "div": div, "script": script, } ) rec.ddmrp_chart = json_data def _compute_ddmrp_chart_execution(self): for rec in self: div, script = rec.get_ddmrp_chart_execution() json_data = json.dumps( { "div": div, "script": script, } ) rec.ddmrp_chart_execution = json_data def _get_colors_hex_map(self, pallete="planning"): return DDMRP_COLOR def get_ddmrp_chart_planning(self): p = figure(frame_width=300, frame_height=400, y_axis_label="Quantity") p.xaxis.visible = False p.toolbar.logo = None hex_colors = self._get_colors_hex_map(pallete="planning") red = p.vbar( x=1, bottom=0, top=self.top_of_red, width=1, color=hex_colors.get("1_red", "red"), ) yellow = p.vbar( x=1, bottom=self.top_of_red, top=self.top_of_yellow, width=1, color=hex_colors.get("2_yellow", "yellow"), ) green = p.vbar( x=1, bottom=self.top_of_yellow, top=self.top_of_green, width=1, color=hex_colors.get("3_green", "green"), ) net_flow = p.line( [0, 2], [self.net_flow_position, self.net_flow_position], line_width=2 ) on_hand = p.line( [0, 2], [ self.product_location_qty_available_not_res, self.product_location_qty_available_not_res, ], line_width=2, line_dash="dotted", ) legend = Legend( items=[ ("Red zone", [red]), ("Yellow zone", [yellow]), ("Green zone", [green]), ("Net Flow Position", [net_flow]), ("On-Hand Position (Unreserved)", [on_hand]), ], ) labels_source_data = { "height": [ self.net_flow_position, self.product_location_qty_available_not_res, self.top_of_red, self.top_of_yellow, self.top_of_green, ], "weight": [0.25, 1.75, 1, 1, 1], "names": [ str(self.net_flow_position), str(self.product_location_qty_available_not_res), str(self.top_of_red), str(self.top_of_yellow), str(self.top_of_green), ], } source = ColumnDataSource(data=labels_source_data) labels = LabelSet( x="weight", y="height", text="names", y_offset=1, text_font_size="8pt", source=source, text_align="center", ) p.add_layout(labels) p.add_layout(legend, "below") script, div = components(p, wrap_script=False) return div, script def get_ddmrp_chart_execution(self): p = figure(frame_width=300, frame_height=400, y_axis_label="Quantity") p.xaxis.visible = False p.toolbar.logo = None tor_exec = float_round( self.top_of_red / 2, precision_rounding=self.product_uom.rounding, ) toy_exec = self.top_of_red tog_exec = float_round( self.top_of_red + self.green_zone_qty, precision_rounding=self.product_uom.rounding, ) tor2_exec = self.top_of_green toy2_exec = (tor2_exec + tog_exec) / 2 hex_colors = self._get_colors_hex_map(pallete="execution") red = p.vbar( x=1, bottom=0, top=tor_exec, width=1, color=hex_colors.get("1_red", "red"), ) yellow = p.vbar( x=1, bottom=tor_exec, top=toy_exec, width=1, color=hex_colors.get("2_yellow", "yellow"), ) green = p.vbar( x=1, bottom=toy_exec, top=tog_exec, width=1, color=hex_colors.get("3_green", "green"), ) yellow_2 = p.vbar( x=1, bottom=tog_exec, top=toy2_exec, width=1, color=hex_colors.get("2_yellow", "yellow"), ) red_2 = p.vbar( x=1, bottom=toy2_exec, top=tor2_exec, width=1, color=hex_colors.get("1_red", "red"), ) on_hand = p.line( [0, 2], [ self.product_location_qty_available_not_res, self.product_location_qty_available_not_res, ], line_width=2, line_dash="dotted", ) legend = Legend( items=[ ("Red zone (Execution)", [red, red_2]), ("Yellow zone (Execution)", [yellow, yellow_2]), ("Green zone (Execution)", [green]), ("On-Hand Position (Unreserved)", [on_hand]), ] ) labels_source_data = { "height": [ self.product_location_qty_available_not_res, tor_exec, toy_exec, tog_exec, toy2_exec, ], "weight": [0.25, 1, 1, 1, 1], "names": [ str(self.product_location_qty_available_not_res), str(tor_exec), str(toy_exec), str(tog_exec), str(toy2_exec), ], } source = ColumnDataSource(data=labels_source_data) labels = LabelSet( x="weight", y="height", text="names", y_offset=1, text_font_size="8pt", source=source, text_align="center", ) p.add_layout(labels) p.add_layout(legend, "below") script, div = components(p, wrap_script=False) return div, script def _compute_ddmrp_demand_supply_chart(self): for rec in self: if not rec.buffer_profile_id: # Not a buffer, skip. rec.ddmrp_demand_chart = "" rec.ddmrp_supply_chart = "" continue # Prepare data: demand_data = rec._get_demand_by_days(rec.qualified_demand_stock_move_ids) mrp_data = rec._get_qualified_mrp_moves(rec.qualified_demand_mrp_move_ids) supply_data = rec._get_incoming_by_days() width = timedelta(days=0.4) date_format = ( self.env["res.lang"]._lang_get(self.env.lang or "en_US").date_format ) # Plot demand data: if demand_data or mrp_data: x_demand = list(convert_datetime_type(x) for x in demand_data.keys()) y_demand = list(demand_data.values()) x_mrp = list(convert_datetime_type(x) for x in mrp_data.keys()) y_mrp = list(mrp_data.values()) p = figure( frame_width=500, frame_height=400, y_axis_label="Quantity", x_axis_type="datetime", ) p.toolbar.logo = None p.sizing_mode = "stretch_both" # TODO: # p.xaxis.label_text_font = "helvetica" p.xaxis.formatter = DatetimeTickFormatter( hours=date_format, days=date_format, months=date_format, years=date_format, ) p.xaxis.major_label_orientation = pi / 4 if demand_data: p.vbar( x=x_demand, width=width, bottom=0, top=y_demand, color="firebrick", ) if mrp_data: p.vbar( x=x_mrp, width=width, bottom=0, top=y_mrp, color="lightsalmon" ) p.line( [ datetime.today() - timedelta(days=1), datetime.today() + timedelta(days=rec.order_spike_horizon), ], [rec.order_spike_threshold, rec.order_spike_threshold], line_width=2, line_dash="dashed", ) unit = rec.product_uom.name hover = HoverTool( tooltips=[("qty", "$y %s" % unit)], point_policy="follow_mouse" ) p.add_tools(hover) script, div = components(p, wrap_script=False) json_data = json.dumps( { "div": div, "script": script, } ) rec.ddmrp_demand_chart = json_data else: rec.ddmrp_demand_chart = json.dumps( { "div": _("No demand detected."), "script": "", } ) # Plot supply data: if supply_data: x_supply = list(convert_datetime_type(x) for x in supply_data.keys()) y_supply = list(supply_data.values()) p = figure( frame_width=500, frame_height=400, y_axis_label="Quantity", x_axis_type="datetime", ) p.toolbar.logo = None p.sizing_mode = "stretch_both" p.xaxis.formatter = DatetimeTickFormatter( hours=date_format, days=date_format, months=date_format, years=date_format, ) p.xaxis.major_label_orientation = pi / 4 # White line to have similar proportion to demand chart. p.line( [ datetime.today() - timedelta(days=1), datetime.today() + timedelta(days=rec.order_spike_horizon), ], [rec.order_spike_threshold, rec.order_spike_threshold], line_width=2, line_dash="dashed", color="white", ) p.vbar(x=x_supply, width=width, bottom=0, top=y_supply, color="grey") unit = rec.product_uom.name hover = HoverTool( tooltips=[("qty", "$y %s" % unit)], point_policy="follow_mouse" ) p.add_tools(hover) script, div = components(p, wrap_script=False) json_data = json.dumps( { "div": div, "script": script, } ) rec.ddmrp_supply_chart = json_data else: rec.ddmrp_supply_chart = json.dumps( { "div": _("No supply detected."), "script": "", } ) @api.depends("red_zone_qty") def _compute_order_spike_threshold(self): for rec in self: rec.order_spike_threshold = 0.5 * rec.red_zone_qty def _get_manufactured_bom(self, limit=1): return self.env["mrp.bom"].search( [ ("type", "=", "normal"), "|", ("product_id", "=", self.product_id.id), ("product_tmpl_id", "=", self.product_id.product_tmpl_id.id), "|", ("company_id", "=", self.company_id.id), ("company_id", "=", False), ], limit=limit, ) @api.depends("lead_days", "product_id.seller_ids.delay") def _compute_dlt(self): for rec in self: if rec.buffer_profile_id.item_type == "manufactured": bom = rec._get_manufactured_bom() dlt = bom.with_context(location_id=rec.location_id.id).dlt elif rec.buffer_profile_id.item_type == "distributed": dlt = rec.lead_days else: sellers = rec._get_product_sellers() dlt = sellers and fields.first(sellers).delay or rec.lead_days rec.dlt = dlt def _get_product_sellers(self): """:returns the default sellers for a single buffer.""" self.ensure_one() all_sellers = self.product_id.seller_ids.filtered( lambda r: not r.company_id or r.company_id == self.company_id ) today = fields.Date.context_today(self) sellers = all_sellers.filtered( lambda s: ( (s.product_id == self.product_id or not s.product_id) and ( (s.date_start <= today if s.date_start else True) and (s.date_end >= today if s.date_end else True) ) ) ) if not sellers: # fallback to all sellers sellers = all_sellers # When the current transaction changed the sequence, it may happen that # the sellers' recordset is not correctly sorted by default. sellers = sellers.sorted(key=lambda s: (s.sequence, -s.min_qty, s.price)) return sellers @api.depends( "buffer_profile_id", "item_type", "product_id.seller_ids", "product_id.seller_ids.company_id", "product_id.seller_ids.partner_id", "product_id.seller_ids.product_id", "product_id.seller_ids.sequence", "product_id.seller_ids.min_qty", "product_id.seller_ids.price", ) def _compute_main_supplier(self): for rec in self: if rec.item_type == "purchased": suppliers = rec._get_product_sellers() rec.main_supplier_id = suppliers[0].partner_id if suppliers else False else: rec.main_supplier_id = False @api.depends("main_supplier_id", "product_id.seller_ids") def _compute_product_vendor_code(self): for rec in self: if not rec.main_supplier_id: rec.product_vendor_code = False continue supplier_info = rec._get_product_sellers().filtered( lambda r: r.partner_id == rec.main_supplier_id and r.product_id == rec.product_id ) rec.product_vendor_code = fields.first(supplier_info).product_code buffer_profile_id = fields.Many2one( comodel_name="stock.buffer.profile", string="Buffer Profile", required=True, ) replenish_method = fields.Selection( related="buffer_profile_id.replenish_method", ) item_type = fields.Selection( related="buffer_profile_id.item_type", ) main_supplier_id = fields.Many2one( comodel_name="res.partner", string="Main Supplier", help=( "The main supplier is the first listed supplier defined " "on the product that is valid for this product variant. " "Any date restrictions are not taken into account." ), compute="_compute_main_supplier", store=True, index=True, ) green_override = fields.Float( string="Green Zone (Override)", ) yellow_override = fields.Float( string="Yellow Zone (Override)", ) red_override = fields.Float( string="Red Zone (Override)", ) product_vendor_code = fields.Char( compute="_compute_product_vendor_code", string="Vendor Code" ) dlt = fields.Float( string="DLT (days)", compute="_compute_dlt", help="Decoupled Lead Time (days)", ) adu = fields.Float( string="ADU", default=0.0, digits="Average Daily Usage", readonly=True, help="Average Daily Usage", ) adu_calculation_method = fields.Many2one( comodel_name="product.adu.calculation.method", string="ADU calculation method", required=True, ) adu_calculation_method_type = fields.Selection( related="adu_calculation_method.method", ) adu_fixed = fields.Float( string="Fixed ADU", default=1.0, digits="Average Daily Usage", ) order_cycle = fields.Float(string="Minimum Order Cycle (days)") minimum_order_quantity = fields.Float( digits="Product Unit of Measure", ) red_base_qty = fields.Float( compute="_compute_red_zone", digits="Product Unit of Measure", store=True, ) red_safety_qty = fields.Float( compute="_compute_red_zone", digits="Product Unit of Measure", store=True, ) red_zone_qty = fields.Float( compute="_compute_red_zone", digits="Product Unit of Measure", store=True, ) top_of_red = fields.Float( string="Top Of Red", related="red_zone_qty", store=True, ) green_zone_qty = fields.Float( compute="_compute_green_zone", digits="Product Unit of Measure", store=True, ) green_zone_lt_factor = fields.Float( string="Green Zone Lead Time Factor", compute="_compute_green_zone", store=True, help="Green zone Lead Time Factor", ) green_zone_moq = fields.Float( string="Green Zone Minimum Order Quantity", compute="_compute_green_zone", digits="Product Unit of Measure", store=True, help="Green zone qty option considering minimum order quantity", ) green_zone_oc = fields.Float( string="Green Zone Order Cycle", compute="_compute_green_zone", store=True, help="Green zone qty option considering desired Order Cycle", ) yellow_zone_qty = fields.Float( compute="_compute_yellow_zone", digits="Product Unit of Measure", store=True, ) top_of_yellow = fields.Float( compute="_compute_yellow_zone", digits="Product Unit of Measure", store=True, ) top_of_green = fields.Float( compute="_compute_green_zone", digits="Product Unit of Measure", store=True, ) order_spike_horizon = fields.Float() order_spike_threshold = fields.Float( compute="_compute_order_spike_threshold", digits="Product Unit of Measure", store=True, ) qualified_demand = fields.Float( digits="Product Unit of Measure", readonly=True, ) qualified_demand_stock_move_ids = fields.Many2many( comodel_name="stock.move", ) qualified_demand_mrp_move_ids = fields.Many2many( comodel_name="mrp.move", ) incoming_total_qty = fields.Float( string="Total Incoming", readonly=True, ) incoming_dlt_qty = fields.Float( string="Incoming (Within DLT)", readonly=True, ) incoming_outside_dlt_qty = fields.Float( string="Incoming (Outside DLT)", readonly=True, ) rfq_outside_dlt_qty = fields.Float( string="RFQ Qty (Outside DLT)", readonly=True, help="Request for Quotation total quantity that is planned outside of " "the DLT horizon.", ) rfq_inside_dlt_qty = fields.Float( string="RFQ Qty (Inside DLT)", readonly=True, help="Request for Quotation total quantity that is planned inside of " "the DLT horizon.", ) rfq_total_qty = fields.Float( string="RFQ Total Qty", readonly=True, help="Request for Quotation total quantity that is planned", ) net_flow_position = fields.Float( digits="Product Unit of Measure", readonly=True, ) net_flow_position_percent = fields.Float( string="Net flow position (% of TOG)", readonly=True, ) planning_priority_level = fields.Selection( selection=_PRIORITY_LEVEL, readonly=True, ) execution_priority_level = fields.Selection( string="On-Hand Alert Level", selection=_PRIORITY_LEVEL, store=True, readonly=True, ) on_hand_percent = fields.Float( string="On Hand/TOR (%)", store=True, readonly=True, ) on_hand_target_position = fields.Float( string="Avg On Hand Target Position", help="It denotes what the target stock on hand is. The " "computation is: OH Target = TOR + Green Zone / 2 ", compute="_compute_on_hand_target_position", ) on_hand_target_max = fields.Float( string="Target On Hand (Max)", help="It denotes how far you are on average from the target", compute="_compute_on_hand_target_max", ) on_hand_target_min = fields.Float( related="top_of_red", string="On Hand Target Range", help="It denotes what the target stock on hand range is.", ) mrp_production_ids = fields.One2many( string="Manufacturing Orders", comodel_name="mrp.production", inverse_name="buffer_id", ) ddmrp_chart = fields.Text( string="DDMRP Chart", compute=_compute_ddmrp_chart_planning, ) ddmrp_chart_execution = fields.Text( string="DDMRP Execution Chart", compute=_compute_ddmrp_chart_execution ) show_execution_chart = fields.Boolean() ddmrp_demand_chart = fields.Text( string="DDMRP Demand Chart", compute="_compute_ddmrp_demand_supply_chart", ) ddmrp_supply_chart = fields.Text( string="DDMRP Supply Chart", compute="_compute_ddmrp_demand_supply_chart", ) auto_procure = fields.Boolean( default=False, help="Whenever the buffer is recomputed, if this option is set, it " "will procure automatically if needed.", ) auto_procure_option = fields.Selection( selection=[ ("standard", "When recommended (NFP below TOY)"), ("stockout", "When in stockout"), ], default="standard", required=True, ) extra_lead_time = fields.Float( string="Extra lead time (for Sizing)", default=0.0, help="When defined, this lead time will be added to the decoupled " "lead time for the computation of the zones size (it won't affect " "planned date for procurements).\n" "This is particularly useful in situations of infrequent but " "periodic demand. E.g. We receive a large order every 30 days, " "whereas the supplier takes 10 days to supply. \n" "In this case the yellow zone must cover for the " "entire cycle of 30 days of demand.\n" "In situations with infrequent demand the ADU tends to be very" " small, and every new order would be treated as a spike, when \n" "in reality this is not an exceptional situation.", ) distributed_source_location_id = fields.Many2one( string="Replenishment Location", comodel_name="stock.location", readonly=True, index=True, help="Source location from where goods will be replenished. " "Computed when buffer is refreshed from following the Stock Rules.", ) distributed_source_location_qty = fields.Float( string="Source Location Free Quantity (distributed)", compute="_compute_distributed_source_location_qty", search="_search_distributed_source_location_qty", help="Quantity free for distributed buffer in the source location. " "When a procurement is requested, if the option is active on the profile," " it will be limited to this quantity.", ) @api.depends( "top_of_green", "top_of_yellow", "top_of_red", ) def _compute_on_hand_target_position(self): for rec in self: green_zone_size = rec.top_of_green - rec.top_of_yellow rec.on_hand_target_position = rec.top_of_red + green_zone_size / 2 @api.depends( "top_of_green", "top_of_yellow", "top_of_red", ) def _compute_on_hand_target_max(self): for rec in self: green_zone_size = rec.top_of_green - rec.top_of_yellow rec.on_hand_target_max = rec.top_of_red + green_zone_size @api.depends( "distributed_source_location_id", "distributed_source_location_id.quant_ids.quantity", "distributed_source_location_id.quant_ids.reserved_quantity", ) def _compute_distributed_source_location_qty(self): to_compute_per_location = {} for record in self: location = record.distributed_source_location_id if not location: record.distributed_source_location_qty = 0.0 continue to_compute_per_location.setdefault(location.id, set()) to_compute_per_location[location.id].add(record.id) # batch computation per location for location_id, buffer_ids in to_compute_per_location.items(): buffers = self.browse(buffer_ids).with_context(location=location_id) for buf in buffers: buf.distributed_source_location_qty = buf.product_id.free_qty def _search_distributed_source_location_qty(self, operator, value): if operator not in OPERATORS: raise exceptions.UserError(_("Unsupported operator %s") % (operator,)) buffers = self.search([("distributed_source_location_id", "!=", False)]) operator_func = OPERATORS[operator] buffers = buffers.filtered( lambda buf: operator_func(buf.distributed_source_location_qty, value) ) return [("id", "in", buffers.ids)] def _search_open_stock_moves_domain(self): self.ensure_one() return [ ("product_id", "=", self.product_id.id), ( "state", "in", ["draft", "waiting", "confirmed", "partially_available", "assigned"], ), ] @api.model def _stock_move_tree_view(self, lines): views = [] tree_view = self.env.ref("stock.view_move_tree", False) if tree_view: views += [(tree_view.id, "tree")] form_view = self.env.ref("stock.view_move_form", False) if form_view: views += [(form_view.id, "form")] return { "name": _("Non-completed Moves"), "type": "ir.actions.act_window", "res_model": "stock.move", "view_type": "form", "views": views, "view_mode": "tree,form", "domain": str([("id", "in", lines.ids)]), } def _get_horizon_adu_past_demand(self): return self.adu_calculation_method.horizon_past or 0 def _get_dates_adu_past_demand(self, horizon): date_from = fields.Date.to_string( self.warehouse_id.wh_plan_days(datetime.now(), -1 * horizon) ) date_to = fields.Date.to_string( self.warehouse_id.wh_plan_days(datetime.now(), -1) ) return date_from, date_to def _past_mrp_move_domain(self, date_from, date_to, locations): self.ensure_one() return [ ("product_id", "=", self.product_id.id), ("mrp_date", "<=", date_to), ("mrp_date", ">=", date_from), ("mrp_area_id.location_id", "in", locations.ids), ("mrp_type", "=", "d"), ("mrp_origin", "in", ["mrp", "mo"]), ] def _past_moves_domain(self, date_from, date_to, locations): self.ensure_one() domain = [ ("state", "=", "done"), ("location_id", "in", locations.ids), ("location_dest_id", "not in", locations.ids), ("location_dest_id.usage", "!=", "inventory"), ("product_id", "=", self.product_id.id), ("date", ">=", date_from), ("date", "<=", date_to), ] if not self.env.company.ddmrp_adu_calc_include_scrap: domain.append(("location_id.scrap_location", "=", False)) return domain def _calc_adu_past_demand(self): self.ensure_one() horizon = self._get_horizon_adu_past_demand() # today is excluded to be sure that is a past day and all moves # for that day are done (or at least the expected date is in the past). date_from, date_to = self._get_dates_adu_past_demand(horizon) locations = ( self.env["stock.location"] .with_context(active_test=False) .search([("id", "child_of", self.location_id.ids)]) ) qty = 0.0 if self.adu_calculation_method.source_past == "estimates_mrp": domain = self._past_mrp_move_domain(date_from, date_to, locations) for mrp_move in self.env["mrp.move"].search(domain): qty += -mrp_move.mrp_qty if self.adu_calculation_method.source_past in ["estimates", "estimates_mrp"]: domain = self._demand_estimate_domain(locations, date_from, date_to) for estimate in self.env["stock.demand.estimate"].search(domain): qty += estimate.get_quantity_by_date_range( fields.Date.from_string(date_from), fields.Date.from_string(date_to) ) elif self.adu_calculation_method.source_past == "actual": domain = self._past_moves_domain(date_from, date_to, locations) for group in self.env["stock.move"].read_group( domain, ["product_id", "product_qty"], ["product_id"] ): qty += group["product_qty"] return qty / horizon def _get_horizon_adu_future_demand(self): return self.adu_calculation_method.horizon_future or 1 def _get_dates_adu_future_demand(self, horizon): date_from = fields.Datetime.now() date_to = self.warehouse_id.wh_plan_days(date_from, horizon) date_to = date_to.replace( hour=date_from.hour, minute=date_from.minute, second=date_from.second, ) return date_from, date_to def _future_mrp_move_domain(self, date_from, date_to, locations): self.ensure_one() return [ ("product_id", "=", self.product_id.id), ("mrp_date", "<=", date_to), ("mrp_date", ">=", date_from), ("mrp_area_id.location_id", "in", locations.ids), ("mrp_type", "=", "d"), ("mrp_origin", "in", ["mrp", "mo"]), ] def _future_moves_domain(self, date_from, date_to, locations): self.ensure_one() domain = [ ("state", "not in", ["done", "cancel"]), ("location_id", "in", locations.ids), ("location_dest_id", "not in", locations.ids), ("location_dest_id.usage", "!=", "inventory"), ("product_id", "=", self.product_id.id), ("date", ">=", date_from), ("date", "<=", date_to), ] if not self.env.company.ddmrp_adu_calc_include_scrap: domain.append(("location_id.scrap_location", "=", False)) return domain def _calc_adu_future_demand(self): self.ensure_one() horizon = self._get_horizon_adu_future_demand() date_from, date_to = self._get_dates_adu_future_demand(horizon) locations = self.env["stock.location"].search( [("id", "child_of", [self.location_id.id])] ) qty = 0.0 if self.adu_calculation_method.source_future == "estimates_mrp": domain = self._future_mrp_move_domain(date_from, date_to, locations) for mrp_move in self.env["mrp.move"].search(domain): qty += -mrp_move.mrp_qty if self.adu_calculation_method.source_future in ["estimates", "estimates_mrp"]: domain = self._demand_estimate_domain(locations, date_from, date_to) for estimate in self.env["stock.demand.estimate"].search(domain): qty += estimate.get_quantity_by_date_range( fields.Date.from_string(date_from), fields.Date.from_string(date_to) ) elif self.adu_calculation_method.source_future == "actual": domain = self._future_moves_domain(date_from, date_to, locations) for group in self.env["stock.move"].read_group( domain, ["product_id", "product_qty"], ["product_id"] ): qty += group["product_qty"] return qty / horizon def _calc_adu_blended(self): self.ensure_one() past_comp = self._calc_adu_past_demand() fp = self.adu_calculation_method.factor_past future_comp = self._calc_adu_future_demand() ff = self.adu_calculation_method.factor_future return past_comp * fp + future_comp * ff def _calc_adu(self): for rec in self: if rec.adu_calculation_method.method == "fixed": rec.adu = rec.adu_fixed elif rec.adu_calculation_method.method == "past": rec.adu = rec._calc_adu_past_demand() elif rec.adu_calculation_method.method == "future": rec.adu = rec._calc_adu_future_demand() elif rec.adu_calculation_method.method == "blended": rec.adu = rec._calc_adu_blended() return True def _search_stock_moves_qualified_demand_domain(self): self.ensure_one() horizon = self.order_spike_horizon date_to = self.warehouse_id.wh_plan_days(datetime.now(), horizon) return [ ("product_id", "=", self.product_id.id), ( "state", "in", ["waiting", "confirmed", "partially_available", "assigned"], ), ("date", "<=", date_to), ] def _search_stock_moves_qualified_demand(self): domain = self._search_stock_moves_qualified_demand_domain() moves = self.env["stock.move"].search(domain) moves = moves.filtered( lambda move: move.location_id.is_sublocation_of(self.location_id) and not move.location_dest_id.is_sublocation_of(self.location_id) ) return moves def _get_incoming_supply_date_limit(self): # The safety factor allows to control the date limit factor = self.warehouse_id.nfp_incoming_safety_factor or 1 horizon = int(self.dlt) * factor return self._get_date_planned(force_lt=horizon) def _search_stock_moves_incoming_domain(self, outside_dlt=False): date_to = self._get_incoming_supply_date_limit() date_operator = ">" if outside_dlt else "<=" return [ ("product_id", "=", self.product_id.id), ( "state", "in", ["waiting", "confirmed", "partially_available", "assigned"], ), ("date", date_operator, date_to), ] def _search_stock_moves_incoming(self, outside_dlt=False): domain = self._search_stock_moves_incoming_domain(outside_dlt=outside_dlt) moves = self.env["stock.move"].search(domain) moves = moves.filtered( lambda move: not move.location_id.is_sublocation_of(self.location_id) and move.location_dest_id.is_sublocation_of(self.location_id) ) return moves def _get_incoming_by_days(self): self.ensure_one() moves = self._search_stock_moves_incoming() incoming_by_days = {} move_dates = [dt.date() for dt in moves.mapped("date")] for move_date in move_dates: incoming_by_days[move_date] = 0.0 for move in moves: date = move.date.date() incoming_by_days[date] += move.product_qty return incoming_by_days def _get_demand_by_days(self, moves): self.ensure_one() demand_by_days = {} move_dates = [dt.date() for dt in moves.mapped("date")] for move_date in move_dates: demand_by_days[move_date] = 0.0 for move in moves: date = move.date.date() demand_by_days[ date ] += move.product_qty - move.product_uom._compute_quantity( move.reserved_availability, move.product_id.uom_id ) return demand_by_days def _search_mrp_moves_qualified_demand_domain(self): self.ensure_one() horizon = self.order_spike_horizon date_to = self.warehouse_id.wh_plan_days(datetime.now(), horizon) return [ ("product_id", "=", self.product_id.id), ("mrp_type", "=", "d"), ("mrp_date", "<=", date_to), ] def _search_mrp_moves_qualified_demand(self): domain = self._search_mrp_moves_qualified_demand_domain() moves = self.env["mrp.move"].search(domain) moves = moves.filtered( lambda move: move.mrp_area_id.location_id.is_sublocation_of( self.location_id ) ) return moves def _get_qualified_mrp_moves(self, moves): self.ensure_one() mrp_moves_by_days = {} move_dates = [dt for dt in moves.mapped("mrp_date")] for move_date in move_dates: mrp_moves_by_days[move_date] = 0.0 for move in moves: date = move.mrp_date mrp_moves_by_days[date] += abs(move.mrp_qty) return mrp_moves_by_days def _calc_qualified_demand(self, current_date=False): today = current_date or fields.date.today() for rec in self: qualified_demand = 0.0 moves = rec._search_stock_moves_qualified_demand() mrp_moves = rec._search_mrp_moves_qualified_demand() demand_by_days = rec._get_demand_by_days(moves) mrp_moves_by_days = rec._get_qualified_mrp_moves(mrp_moves) dates = list(set(demand_by_days.keys()) | set(mrp_moves_by_days.keys())) for date in dates: if ( demand_by_days.get(date, 0.0) >= rec.order_spike_threshold or date <= today ): qualified_demand += demand_by_days.get(date, 0.0) else: moves = moves.filtered(lambda x: x.date != date) if ( mrp_moves_by_days.get(date, 0.0) >= rec.order_spike_threshold or date <= today ): qualified_demand += mrp_moves_by_days.get(date, 0.0) else: mrp_moves = mrp_moves.filtered(lambda x: x.mrp_date != date) rec.qualified_demand = qualified_demand rec.qualified_demand_stock_move_ids = moves rec.qualified_demand_mrp_move_ids = mrp_moves return True def _calc_incoming_dlt_qty(self): for rec in self: moves = self._search_stock_moves_incoming() rec.incoming_dlt_qty = sum(moves.mapped("product_qty")) outside_dlt_moves = self._search_stock_moves_incoming(outside_dlt=True) rec.incoming_outside_dlt_qty = sum(outside_dlt_moves.mapped("product_qty")) if rec.item_type == "purchased": pols_outside_dlt = rec._get_rfq_dlt(dlt_interval="outside") rec.rfq_outside_dlt_qty = sum(pols_outside_dlt.mapped("product_qty")) pols_inside_dlt = rec._get_rfq_dlt(dlt_interval="inside") rec.rfq_inside_dlt_qty = sum(pols_inside_dlt.mapped("product_qty")) rec.rfq_total_qty = rec.rfq_inside_dlt_qty + rec.rfq_outside_dlt_qty else: rec.rfq_outside_dlt_qty = 0.0 rec.rfq_inside_dlt_qty = 0.0 rec.rfq_total_qty = 0.0 rec.incoming_total_qty = rec.incoming_dlt_qty + rec.incoming_outside_dlt_qty return True def _calc_net_flow_position(self): for rec in self: rec.net_flow_position = ( rec.product_location_qty_available_not_res + rec.incoming_dlt_qty - rec.qualified_demand ) usage = 0.0 if rec.top_of_green: usage = round((rec.net_flow_position / rec.top_of_green * 100), 2) rec.net_flow_position_percent = usage return True def _calc_distributed_source_location(self): """Compute source location used for replenishment of distributed buffer It follows the rules of the default route until it finds a "Take from stock" rule. The source location depends on many factors (route on warehouse, product, category, ...), that's why it is updated only on refresh of the buffer. """ for record in self: if record.item_type != "distributed": record.distributed_source_location_id = self.env[ "stock.location" ].browse() continue source_location = record._source_location_from_route() record.distributed_source_location_id = source_location def _calc_planning_priority(self): for rec in self: if rec.net_flow_position >= rec.top_of_yellow: rec.planning_priority_level = "3_green" elif rec.net_flow_position >= rec.top_of_red: rec.planning_priority_level = "2_yellow" else: rec.planning_priority_level = "1_red" def _calc_execution_priority(self): for rec in self: if rec.product_location_qty_available_not_res >= rec.top_of_red: rec.execution_priority_level = "3_green" elif rec.product_location_qty_available_not_res >= rec.top_of_red * 0.5: rec.execution_priority_level = "2_yellow" else: rec.execution_priority_level = "1_red" if rec.top_of_red: rec.on_hand_percent = round( ( (rec.product_location_qty_available_not_res / rec.top_of_red) * 100 ), 2, ) else: rec.on_hand_percent = 0.0 @api.model_create_multi def create(self, vals_list): records = super().create(vals_list) if not self.env.context.get("skip_adu_calculation", False): records._calc_adu() records._calc_product_available_qty() records._calc_distributed_source_location() return records def write(self, vals): res = super().write(vals) if any(f in vals for f in ("adu_fixed", "adu_calculation_method")): self._calc_adu() return res def _procure_qty_to_order(self): qty_to_order = self.procure_recommended_qty rounding = self.procure_uom_id.rounding or self.product_uom.rounding qty_in_progress = self._quantity_in_progress()[self._origin.id] if ( self.item_type == "distributed" and self.buffer_profile_id.replenish_distributed_limit_to_free_qty ): # If we don't procure more than what we have in stock, we prevent # backorders on the replenishment if ( float_compare( self.distributed_source_location_qty, self.procure_min_qty, precision_rounding=rounding, ) < 0 ): # the free qty is below the minimum we want to move, do not # move anything return 0 else: # move only what we have in stock return min(qty_to_order, self.distributed_source_location_qty) elif ( float_compare(qty_in_progress, 0, precision_rounding=rounding) > 0 and float_compare( qty_to_order, self.green_zone_qty, precision_rounding=rounding ) < 0 ): # When there is qty in progress (e.g. RfQ sent), do not keep # auto-procuring small quantities, wait for the qty to be at least GZ. return 0 return qty_to_order def do_auto_procure(self): if not self.auto_procure: return False rounding = self.product_uom.rounding qty_to_order = self._procure_qty_to_order() if float_compare(qty_to_order, 0.0, precision_rounding=rounding) > 0 and ( ( self.auto_procure_option == "stockout" and float_compare( self.net_flow_position, 0.0, precision_rounding=rounding ) < 0 ) or self.auto_procure_option == "standard" ): wizard = ( self.env["make.procurement.buffer"] .with_context( active_model="stock.buffer", active_ids=self.ids, active_id=self.id ) .create({}) ) wizard.make_procurement() return True def action_view_supply_moves(self): result = self.env["ir.actions.actions"]._for_xml_id("stock.stock_move_action") result["context"] = {} moves = self._search_stock_moves_incoming() + self._search_stock_moves_incoming( outside_dlt=True ) result["domain"] = [("id", "in", moves.ids)] return result def _get_rfq_dlt(self, dlt_interval=None): self.ensure_one() cut_date = self._get_incoming_supply_date_limit() if dlt_interval == "inside": pols = self.purchase_line_ids.filtered( lambda l: l.date_planned <= fields.Datetime.to_datetime(cut_date) and l.state in ("draft", "sent", "to approve") ) elif dlt_interval == "outside": pols = self.purchase_line_ids.filtered( lambda l: l.date_planned > fields.Datetime.to_datetime(cut_date) and l.state in ("draft", "sent", "to approve") ) else: pols = self.purchase_line_ids.filtered( lambda l: l.state in ("draft", "sent", "to approve") ) return pols def action_view_supply_moves_inside_dlt_window(self): result = self.env["ir.actions.actions"]._for_xml_id("stock.stock_move_action") moves = self._search_stock_moves_incoming() result["context"] = {} result["domain"] = [("id", "in", moves.ids)] return result def action_view_supply_moves_outside_dlt_window(self): result = self.env["ir.actions.actions"]._for_xml_id("stock.stock_move_action") moves = self._search_stock_moves_incoming(outside_dlt=True) result["context"] = {} result["domain"] = [("id", "in", moves.ids)] return result def action_view_supply_rfq_inside_dlt_window(self): result = self.env["ir.actions.actions"]._for_xml_id("purchase.purchase_rfq") pols = self._get_rfq_dlt(dlt_interval="inside") pos = pols.mapped("order_id") result["context"] = {} result["domain"] = [("id", "in", pos.ids)] return result def action_view_supply_rfq_outside_dlt_window(self): result = self.env["ir.actions.actions"]._for_xml_id("purchase.purchase_rfq") pols = self._get_rfq_dlt(dlt_interval="outside") pos = pols.mapped("order_id") result["context"] = {} result["domain"] = [("id", "in", pos.ids)] return result def action_view_qualified_demand_moves(self): result = self.env["ir.actions.actions"]._for_xml_id("stock.stock_move_action") result["context"] = {} result["domain"] = [("id", "in", self.qualified_demand_stock_move_ids.ids)] return result def action_view_qualified_demand_mrp(self): result = self.env["ir.actions.actions"]._for_xml_id( "mrp_multi_level.mrp_move_action" ) result["context"] = {} result["domain"] = [("id", "in", self.qualified_demand_mrp_move_ids.ids)] return result def action_view_past_adu_direct_demand(self): horizon = self._get_horizon_adu_past_demand() date_from, date_to = self._get_dates_adu_past_demand(horizon) locations = self.env["stock.location"].search( [("id", "child_of", [self.location_id.id])] ) if self.adu_calculation_method.source_past == "actual": domain = self._past_moves_domain(date_from, date_to, locations) moves = self.env["stock.move"].search(domain) result = self.env["ir.actions.actions"]._for_xml_id( "stock.stock_move_action" ) result["context"] = {} result["domain"] = [("id", "in", moves.ids)] else: domain = self._demand_estimate_domain(locations, date_from, date_to) estimates = self.env["stock.demand.estimate"].search(domain) result = self.env["ir.actions.actions"]._for_xml_id( "stock_demand_estimate.stock_demand_estimate_action" ) result["context"] = {} result["domain"] = [("id", "in", estimates.ids)] return result def action_view_past_adu_indirect_demand(self): horizon = self._get_horizon_adu_past_demand() date_from, date_to = self._get_dates_adu_past_demand(horizon) locations = self.env["stock.location"].search( [("id", "child_of", [self.location_id.id])] ) domain = self._past_mrp_move_domain(date_from, date_to, locations) mrp_moves = self.env["mrp.move"].search(domain) result = self.env["ir.actions.actions"]._for_xml_id( "mrp_multi_level.mrp_move_action" ) result["context"] = {} result["domain"] = [("id", "in", mrp_moves.ids)] return result def action_view_future_adu_direct_demand(self): horizon = self._get_horizon_adu_future_demand() date_from, date_to = self._get_dates_adu_future_demand(horizon) locations = self.env["stock.location"].search( [("id", "child_of", [self.location_id.id])] ) if self.adu_calculation_method.source_future == "actual": domain = self._future_moves_domain(date_from, date_to, locations) moves = self.env["stock.move"].search(domain) result = self.env["ir.actions.actions"]._for_xml_id( "stock.stock_move_action" ) result["context"] = {} result["domain"] = [("id", "in", moves.ids)] else: domain = self._demand_estimate_domain(locations, date_from, date_to) estimates = self.env["stock.demand.estimate"].search(domain) result = self.env["ir.actions.actions"]._for_xml_id( "stock_demand_estimate.stock_demand_estimate_action" ) result["context"] = {} result["domain"] = [("id", "in", estimates.ids)] return result def action_view_future_adu_indirect_demand(self): horizon = self._get_horizon_adu_future_demand() date_from, date_to = self._get_dates_adu_future_demand(horizon) locations = self.env["stock.location"].search( [("id", "child_of", [self.location_id.id])] ) domain = self._future_mrp_move_domain(date_from, date_to, locations) mrp_moves = self.env["mrp.move"].search(domain) result = self.env["ir.actions.actions"]._for_xml_id( "mrp_multi_level.mrp_move_action" ) result["context"] = {} result["domain"] = [("id", "in", mrp_moves.ids)] return result def action_request_procurement(self): action = self.env["ir.actions.actions"]._for_xml_id( "ddmrp.act_make_procurement_from_buffer" ) return action @api.model def cron_ddmrp_adu(self, automatic=False): """calculate ADU for each DDMRP buffer. Called by cronjob.""" auto_commit = not getattr(threading.current_thread(), "testing", False) _logger.info("Start cron_ddmrp_adu.") buffer_ids = self.search([]).ids i = 0 j = len(buffer_ids) for buffer_chunk_ids in split_every(self.CRON_DDMRP_CHUNKS, buffer_ids): for b in self.browse(buffer_chunk_ids).exists(): try: i += 1 _logger.debug("ddmrp cron_adu: {}. ({}/{})".format(b.name, i, j)) if automatic: with self.env.cr.savepoint(): b._calc_adu() else: b._calc_adu() except Exception: _logger.exception("Fail to compute ADU for buffer %s", b.name) if not automatic: raise if auto_commit: self._cr.commit() # pylint: disable=E8102 _logger.info("End cron_ddmrp_adu.") return True def refresh_buffer(self): self.ensure_one() self.cron_actions(only_nfp=False) return True def cron_actions(self, only_nfp=False): """This method is meant to be inherited by other modules in order to enhance extensibility.""" self.ensure_one() self.invalidate_recordset( fnames=[ "product_location_qty_available_not_res", "dlt", "distributed_source_location_qty", "qualified_demand", ], ) self._calc_product_available_qty() if not only_nfp or only_nfp == "out": self._calc_qualified_demand() if not only_nfp or only_nfp == "in": self._calc_incoming_dlt_qty() self._calc_net_flow_position() self._calc_distributed_source_location() self._calc_planning_priority() self._calc_execution_priority() self.mrp_production_ids._calc_execution_priority() self.mapped("purchase_line_ids")._calc_execution_priority() if not only_nfp: # re-compoute red to force in cascade the recalculation of zones. self._compute_red_zone() self.do_auto_procure() return True @api.model def cron_ddmrp(self, automatic=False): """Calculate key DDMRP parameters for each buffer. Called by cronjob.""" auto_commit = not getattr(threading.current_thread(), "testing", False) _logger.info("Start cron_ddmrp.") buffer_ids = self.search([]).ids i = 0 j = len(buffer_ids) for buffer_chunk_ids in split_every(self.CRON_DDMRP_CHUNKS, buffer_ids): for b in self.browse(buffer_chunk_ids).exists(): i += 1 _logger.debug("ddmrp cron: {}. ({}/{})".format(b.name, i, j)) try: if automatic: with self.env.cr.savepoint(): b.cron_actions() else: b.cron_actions() except Exception: _logger.exception("Fail updating buffer %s", b.name) if not automatic: raise if auto_commit: self._cr.commit() # pylint: disable=E8102 _logger.info("End cron_ddmrp.") return True def _values_source_location_from_route(self): return {"warehouse_id": self.warehouse_id} def _source_location_from_route(self, procure_location=None): """Return the replenishment source location for distributed buffers""" current_location = procure_location or self.location_id rule_values = self._values_source_location_from_route() while current_location: rule = self.env["procurement.group"]._get_rule( self.product_id, current_location, rule_values ) if rule.procure_method == "make_to_stock": return rule.location_src_id elif rule.procure_method == "make_to_order": # If resupply from another warehouse, this rule can't be retrieved by # method _get_rule, because that we try to get this rule bases on previous rule pull_rule = self.env["stock.rule"].search( [ ("action", "in", ("pull", "pull_push")), ("route_id", "=", rule.route_id.id), ("location_dest_id", "=", rule.location_src_id.id), ] ) if pull_rule: if pull_rule.procure_method in ("make_to_stock", "mts_else_mto"): return pull_rule.location_src_id elif pull_rule.procure_method == "make_to_order": current_location = pull_rule.location_src_id rule_values.update( { "warehouse_id": pull_rule.location_src_id.warehouse_id, } ) continue current_location = rule.location_src_id def action_dummy(self): # no action, used to show an image in the tree view return