From 135cfcdab5dd83d339425fc23e44cbe02c232b1b Mon Sep 17 00:00:00 2001 From: BernatPForgeFlow Date: Thu, 3 Aug 2023 09:32:40 +0200 Subject: [PATCH] [IMP] ddmrp: Reformulate incoming quantities calculation Split incoming quantities between inside or outside DLT. Make it more heritable, to facilitate consideration of other data sources. Other changes: - A change is made to the DLT date limit calculation so that refreshing a buffer prior to 12:00:00 UCT, take into account DLT date limit supplies. - In the calculation of the arrival quantities, the case of receptions in more than one step is taken into account. In these cases there was the possibility of double counting the RFQ and the Stock Move of arrival. --- ddmrp/models/stock_buffer.py | 234 +++++++++++++++++++----------- ddmrp/tests/test_ddmrp.py | 126 +++++++++++++++- ddmrp/views/stock_buffer_view.xml | 86 ++++++++--- 3 files changed, 340 insertions(+), 106 deletions(-) diff --git a/ddmrp/models/stock_buffer.py b/ddmrp/models/stock_buffer.py index 389dee6e8..ebda43de5 100644 --- a/ddmrp/models/stock_buffer.py +++ b/ddmrp/models/stock_buffer.py @@ -6,7 +6,7 @@ import operator as py_operator import threading from collections import defaultdict -from datetime import datetime, timedelta +from datetime import datetime, time, timedelta from math import pi from odoo import _, api, exceptions, fields, models @@ -1167,12 +1167,56 @@ def _compute_product_vendor_code(self): string="Incoming (Outside DLT)", readonly=True, ) + stock_moves_inside_dlt_ids = fields.Many2many( + comodel_name="stock.move", + relation="stock_moves_inside_dlt", + ) + stock_moves_inside_dlt_qty = fields.Float( + string="Stock Moves Qty (Within DLT)", + readonly=True, + ) + stock_moves_outside_dlt_ids = fields.Many2many( + comodel_name="stock.move", + relation="stock_moves_outside_dlt", + ) + stock_moves_outside_dlt_qty = fields.Float( + string="Stock Moves Qty (Outside DLT)", + readonly=True, + ) + rfq_inside_dlt_ids = fields.Many2many( + comodel_name="purchase.order.line", + relation="rfq_inside_dlt", + ) + 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_outside_dlt_ids = fields.Many2many( + comodel_name="purchase.order.line", + relation="rfq_outside_dlt", + ) 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.", ) + stock_moves_inside_dlt_qty_to_subtract = fields.Float( + string="Stock Moves Qty (Within DLT) To Subtract", + help="If a Stock Move contains quantities of open RFQs and confirmed " + "Purchase Orders, we will maintain the Stock Move information but " + "save in this field the units that need to be subtracted.", + readonly=True, + ) + stock_moves_outside_dlt_qty_to_subtract = fields.Float( + string="Stock Moves Qty (Outside DLT) To Subtract", + help="If a Stock Move contains quantities of open RFQs and confirmed " + "Purchase Orders, we will maintain the Stock Move information but " + "save in this field the units that need to be subtracted.", + readonly=True, + ) net_flow_position = fields.Float( string="Net flow position", digits="Product Unit of Measure", @@ -1362,17 +1406,6 @@ def _stock_move_tree_view(self, lines): "domain": str([("id", "in", lines.ids)]), } - def open_moves(self): - self.ensure_one() - # Utility method used to add an "Open Moves" button in the buffer - # planning view - domain = self._search_open_stock_moves_domain() - moves = self.env["stock.move"].search(domain) - moves = moves.filtered( - lambda move: move.location_dest_id.is_sublocation_of(self.location_id) - ) - return self._stock_move_tree_view(moves) - def _get_horizon_adu_past_demand(self): return self.adu_calculation_method.horizon_past or 0 @@ -1553,7 +1586,13 @@ 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) + datetime_planned = self._get_date_planned(force_lt=horizon) + target_time = time(12, 0, 0) + if datetime_planned.time() < target_time: + datetime_planned = datetime_planned.replace( + hour=12, minute=0, second=0, microsecond=0 + ) + return datetime_planned def _search_stock_moves_incoming_domain(self, outside_dlt=False): date_to = self._get_incoming_supply_date_limit() @@ -1568,6 +1607,12 @@ def _search_stock_moves_incoming_domain(self, outside_dlt=False): ("date", date_operator, date_to), ] + def _get_all_upstream_moves(self, moves): + upstream_moves = moves + while upstream_moves.mapped("move_orig_ids"): + upstream_moves = upstream_moves.mapped("move_orig_ids") + return upstream_moves.filtered(lambda x: x.state != "cancel") + 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) @@ -1575,15 +1620,28 @@ def _search_stock_moves_incoming(self, outside_dlt=False): lambda move: not move.location_id.is_sublocation_of(self.location_id) and move.location_dest_id.is_sublocation_of(self.location_id) ) + if self.warehouse_id.reception_steps != "one_step": + attr = f'stock_moves_{"outside_" if outside_dlt else "inside_"}dlt_qty_to_subtract' + upstream_moves = self._get_all_upstream_moves(moves) + qty_to_subtract = sum(moves.mapped("product_uom_qty")) - sum( + upstream_moves.mapped("product_uom_qty") + ) + setattr(self, attr, qty_to_subtract) return moves def _get_incoming_by_days(self): self.ensure_one() - moves = self._search_stock_moves_incoming() incoming_by_days = {} + pols = self.rfq_inside_dlt_ids + pol_dates = [dt.date() for dt in pols.mapped("date_planned")] + moves = self.stock_moves_inside_dlt_ids move_dates = [dt.date() for dt in moves.mapped("date")] - for move_date in move_dates: - incoming_by_days[move_date] = 0.0 + dates = list(set(pol_dates) | set(move_dates)) + for date in dates: + incoming_by_days[date] = 0.0 + for pol in pols: + date = pol.date_planned.date() + incoming_by_days[date] += pol.product_qty for move in moves: date = move.date.date() incoming_by_days[date] += move.product_qty @@ -1664,24 +1722,49 @@ def _calc_qualified_demand(self, current_date=False): 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": - cut_date = rec._get_incoming_supply_date_limit() - # FIXME: filter using order_id.state while - # https://github.com/odoo/odoo/pull/58966 is not merged. - # Can be changed in v14. - pols = rec.purchase_line_ids.filtered( - lambda l: l.date_planned > fields.Datetime.to_datetime(cut_date) - and l.order_id.state in ("draft", "sent") + def _get_rfq_dlt(self, outside_dlt=False): + self.ensure_one() + if self.item_type == "purchased": + cut_date = self._get_incoming_supply_date_limit() + if not outside_dlt: + pols = self.purchase_line_ids.filtered( + lambda l: l.date_planned <= fields.Datetime.to_datetime(cut_date) + and l.state in ("draft", "sent") ) - rec.rfq_outside_dlt_qty = sum(pols.mapped("product_qty")) else: - rec.rfq_outside_dlt_qty = 0.0 + pols = self.purchase_line_ids.filtered( + lambda l: l.date_planned > fields.Datetime.to_datetime(cut_date) + and l.state in ("draft", "sent") + ) + return pols + return self.env["purchase.order.line"] + + def _calc_incoming_dlt_qty(self): + for rec in self: + rec.rfq_inside_dlt_ids = rec._get_rfq_dlt() + rec.rfq_outside_dlt_ids = rec._get_rfq_dlt(outside_dlt=True) + rec.rfq_inside_dlt_qty = sum(rec.rfq_inside_dlt_ids.mapped("product_qty")) + rec.rfq_outside_dlt_qty = sum(rec.rfq_outside_dlt_ids.mapped("product_qty")) + + rec.stock_moves_inside_dlt_ids = rec._search_stock_moves_incoming() + rec.stock_moves_outside_dlt_ids = rec._search_stock_moves_incoming( + outside_dlt=True + ) + rec.stock_moves_inside_dlt_qty = ( + sum(rec.stock_moves_inside_dlt_ids.mapped("product_qty")) + - rec.stock_moves_inside_dlt_qty_to_subtract + ) + rec.stock_moves_outside_dlt_qty = ( + sum(rec.stock_moves_outside_dlt_ids.mapped("product_qty")) + - rec.stock_moves_outside_dlt_qty_to_subtract + ) + + rec.incoming_dlt_qty = ( + rec.rfq_inside_dlt_qty + rec.stock_moves_inside_dlt_qty + ) + rec.incoming_outside_dlt_qty = ( + rec.rfq_outside_dlt_qty + rec.stock_moves_outside_dlt_qty + ) rec.incoming_total_qty = rec.incoming_dlt_qty + rec.incoming_outside_dlt_qty return True @@ -1810,72 +1893,57 @@ def do_auto_procure(self): wizard.make_procurement() return True - def _search_purchase_order_lines_incoming(self, outside_dlt=False): - cut_date = self._get_incoming_supply_date_limit() - if not outside_dlt: - pols = self.purchase_line_ids.filtered( - lambda l: l.date_planned <= fields.Datetime.to_datetime(cut_date) - and l.order_id.state in ("draft", "sent") - ) - else: - pols = self.purchase_line_ids.filtered( - lambda l: l.date_planned > fields.Datetime.to_datetime(cut_date) - and l.order_id.state in ("draft", "sent") - ) - return pols + def action_view_supply_moves(self): + result = self.env["ir.actions.actions"]._for_xml_id( + "ddmrp.stock_move_action_ddmrp" + ) + result["context"] = {} + moves = self.stock_moves_inside_dlt_ids + self.stock_moves_outside_dlt_ids + result["domain"] = [("id", "in", moves.ids)] + return result - def action_view_supply(self, outside_dlt=False): - if self.item_type == "purchased": - pols = self._search_purchase_order_lines_incoming(outside_dlt) - moves = self._search_stock_moves_incoming(outside_dlt) - while moves.mapped("move_orig_ids"): - moves = moves.mapped("move_orig_ids") - pos = pols.mapped("order_id") + moves.mapped("purchase_line_id.order_id") - result = self.env["ir.actions.actions"]._for_xml_id("purchase.purchase_rfq") - # Remove the context since the action display RFQ and not PO. - result["context"] = {} - result["domain"] = [("id", "in", pos.ids)] - elif self.item_type == "manufactured": - moves = self._search_stock_moves_incoming(outside_dlt) - mos = moves.mapped("production_id") - result = self.env["ir.actions.actions"]._for_xml_id( - "mrp.mrp_production_action" - ) - result["context"] = {} - result["domain"] = [("id", "in", mos.ids)] - else: - moves = self._search_stock_moves_incoming(outside_dlt) - picks = moves.mapped("picking_id") - result = self.env["ir.actions.actions"]._for_xml_id( - "stock.action_picking_tree_all" - ) - result["context"] = {} - result["domain"] = [("id", "in", picks.ids)] + def action_view_supply_moves_inside_dlt_window(self): + result = self.env["ir.actions.actions"]._for_xml_id( + "ddmrp.stock_move_action_ddmrp" + ) + result["context"] = {} + result["domain"] = [("id", "in", self.stock_moves_inside_dlt_ids.ids)] + return result + + def action_view_supply_moves_outside_dlt_window(self): + result = self.env["ir.actions.actions"]._for_xml_id( + "ddmrp.stock_move_action_ddmrp" + ) + result["context"] = {} + result["domain"] = [("id", "in", self.stock_moves_outside_dlt_ids.ids)] return result - def action_view_supply_inside_dlt_window(self): - return self.action_view_supply() + def action_view_supply_rfq_inside_dlt_window(self): + result = self.env["ir.actions.actions"]._for_xml_id("purchase.purchase_rfq") + result["context"] = {} + result["domain"] = [("id", "in", self.rfq_inside_dlt_ids.order_id.ids)] + return result - def action_view_supply_outside_dlt_window(self): - return self.action_view_supply(outside_dlt=True) + def action_view_supply_rfq_outside_dlt_window(self): + result = self.env["ir.actions.actions"]._for_xml_id("purchase.purchase_rfq") + result["context"] = {} + result["domain"] = [("id", "in", self.rfq_outside_dlt_ids.order_id.ids)] + return result - def action_view_qualified_demand_pickings(self): - moves = self.qualified_demand_stock_move_ids - picks = moves.mapped("picking_id") + def action_view_qualified_demand_moves(self): result = self.env["ir.actions.actions"]._for_xml_id( - "stock.action_picking_tree_all" + "ddmrp.stock_move_action_ddmrp" ) result["context"] = {} - result["domain"] = [("id", "in", picks.ids)] + result["domain"] = [("id", "in", self.qualified_demand_stock_move_ids.ids)] return result def action_view_qualified_demand_mrp(self): - mrp_moves = self.qualified_demand_mrp_move_ids result = self.env["ir.actions.actions"]._for_xml_id( "mrp_multi_level.mrp_move_action" ) result["context"] = {} - result["domain"] = [("id", "in", mrp_moves.ids)] + result["domain"] = [("id", "in", self.qualified_demand_mrp_move_ids.ids)] return result def action_view_past_adu_direct_demand(self): diff --git a/ddmrp/tests/test_ddmrp.py b/ddmrp/tests/test_ddmrp.py index 8823810e8..1769ca131 100644 --- a/ddmrp/tests/test_ddmrp.py +++ b/ddmrp/tests/test_ddmrp.py @@ -1247,7 +1247,6 @@ def test_45_adu_calculation_blended_120_days_estimated_mrp(self): self.bufferModel.cron_ddmrp_adu() to_assert_value = 2 * 0.5 + 2 * 0.5 self.assertEqual(self.buffer_a.adu, to_assert_value) - def test_46_disable_auto_create_orderpoint(self): """If a product has a buffer, do not create a new orderpoint for same location""" op_a = self.orderpoint_model.search( @@ -1288,3 +1287,128 @@ def test_46_disable_auto_create_orderpoint(self): ] ) self.assertFalse(op_a) + + def test_47_action_view_supply_buffer_purchase(self): + """ + Verify that the view incoming quantities action for a purchased buffer + displays the correct results. + """ + buffer = self.buffer_purchase + buffer.auto_procure = True + buffer.auto_procure_option = "standard" + buffer.do_auto_procure() + buffer._calc_incoming_dlt_qty() + pol = self.pol_model.search([("product_id", "=", self.product_purchased.id)]) + # Check that RFQs are correctly computed + self.assertEqual(buffer.rfq_inside_dlt_ids.ids, pol.ids) + self.assertEqual(len(buffer.rfq_outside_dlt_ids.ids), 0) + pol.date_planned += timedelta(days=1) + buffer._calc_incoming_dlt_qty() + self.assertEqual(len(buffer.rfq_inside_dlt_ids.ids), 0) + self.assertEqual(buffer.rfq_outside_dlt_ids.ids, pol.ids) + # Check that incoming quantities are correctly computed + pol.order_id.button_confirm() + buffer._calc_incoming_dlt_qty() + self.assertEqual(len(buffer.rfq_inside_dlt_ids.ids), 0) + self.assertEqual(len(buffer.rfq_outside_dlt_ids.ids), 0) + self.assertEqual(len(buffer.stock_moves_inside_dlt_ids.ids), 0) + self.assertEqual(buffer.stock_moves_outside_dlt_ids.ids, pol.move_ids.ids) + pol.mapped("move_ids.picking_id").scheduled_date -= timedelta(days=1) + buffer._calc_incoming_dlt_qty() + self.assertEqual(len(buffer.rfq_inside_dlt_ids.ids), 0) + self.assertEqual(len(buffer.rfq_outside_dlt_ids.ids), 0) + self.assertEqual(buffer.stock_moves_inside_dlt_ids.ids, pol.move_ids.ids) + self.assertEqual(len(buffer.stock_moves_outside_dlt_ids.ids), 0) + + def test_48_action_view_supply_buffer_manufacture(self): + """ + Verify that the view incoming quantities action for a manufactured buffer + displays the correct results. + """ + buffer = self.buffer_a + self.quant.quantity = 0 + buffer.buffer_profile_id = self.buffer_profile_mmm.id + buffer.auto_procure = True + buffer.auto_procure_option = "standard" + buffer.cron_actions() + buffer.do_auto_procure() + buffer._calc_incoming_dlt_qty() + mo = self.env["mrp.production"].search([("product_id", "=", self.productA.id)]) + # Check that MOs are correctly computed + self.assertEqual( + buffer.stock_moves_inside_dlt_ids.mapped("production_id.id"), mo.ids + ) + self.assertEqual( + len(buffer.stock_moves_outside_dlt_ids.mapped("production_id.id")), 0 + ) + mo.date_planned_finished += timedelta(days=1) + buffer._calc_incoming_dlt_qty() + self.assertEqual( + len(buffer.stock_moves_inside_dlt_ids.mapped("production_id.id")), 0 + ) + self.assertEqual( + buffer.stock_moves_outside_dlt_ids.mapped("production_id.id"), mo.ids + ) + + def test_49_action_view_supply_buffer_purchase_3_steps(self): + """ + Verify that the view incoming quantities action for a purchased buffer displays + the correct results with a 3-step configuration. + """ + buffer = self.buffer_purchase + self.warehouse.reception_steps = "three_steps" + buffer.auto_procure = True + buffer.auto_procure_option = "standard" + buffer.do_auto_procure() + buffer._calc_incoming_dlt_qty() + pol = self.pol_model.search([("product_id", "=", self.product_purchased.id)]) + # Check that RFQs are correctly computed + self.assertEqual(buffer.rfq_inside_dlt_ids.ids, pol.ids) + self.assertEqual(len(buffer.rfq_outside_dlt_ids.ids), 0) + # Check that incoming quantities are correctly computed + pol.order_id.button_confirm() + buffer._calc_incoming_dlt_qty() + moves = pol.mapped("move_ids") + while moves.mapped("move_dest_ids"): + moves = moves.mapped("move_dest_ids") + self.assertEqual(len(buffer.rfq_inside_dlt_ids.ids), 0) + self.assertEqual(len(buffer.rfq_outside_dlt_ids.ids), 0) + self.assertEqual(buffer.stock_moves_inside_dlt_ids.ids, moves.ids) + self.assertEqual(len(buffer.stock_moves_outside_dlt_ids.ids), 0) + moves.mapped("picking_id").scheduled_date += timedelta(days=1) + buffer._calc_incoming_dlt_qty() + self.assertEqual(len(buffer.rfq_inside_dlt_ids.ids), 0) + self.assertEqual(len(buffer.rfq_outside_dlt_ids.ids), 0) + self.assertEqual(len(buffer.stock_moves_inside_dlt_ids.ids), 0) + self.assertEqual(buffer.stock_moves_outside_dlt_ids.ids, moves.ids) + + def test_50_action_view_supply_buffer_manufacture_3_steps(self): + """ + Verify that the view incoming quantities action for a manufactured buffer displays + the correct results with a 3-step configuration. + """ + buffer = self.buffer_a + self.warehouse.manufacture_steps = "pbm_sam" + self.quant.quantity = 0 + buffer.buffer_profile_id = self.buffer_profile_mmm.id + buffer.auto_procure = True + buffer.auto_procure_option = "standard" + buffer.cron_actions() + buffer.do_auto_procure() + buffer._calc_incoming_dlt_qty() + mo = self.env["mrp.production"].search([("product_id", "=", self.productA.id)]) + # Check that MOs are correctly computed + moves = buffer.stock_moves_inside_dlt_ids + while moves.mapped("move_orig_ids"): + moves = moves.mapped("move_orig_ids") + self.assertEqual(moves.mapped("production_id.id"), mo.ids) + self.assertEqual( + len(buffer.stock_moves_outside_dlt_ids.mapped("production_id.id")), 0 + ) + for picking in moves.mapped("picking_id"): + picking.scheduled_date += timedelta(days=1) + buffer._calc_incoming_dlt_qty() + self.assertEqual( + len(buffer.stock_moves_inside_dlt_ids.mapped("production_id.id")), 0 + ) + self.assertEqual(moves.mapped("production_id.id"), mo.ids) diff --git a/ddmrp/views/stock_buffer_view.xml b/ddmrp/views/stock_buffer_view.xml index 5b8966758..bf1fd299b 100644 --- a/ddmrp/views/stock_buffer_view.xml +++ b/ddmrp/views/stock_buffer_view.xml @@ -38,24 +38,19 @@ type="object" /> -