diff --git a/ddmrp/models/stock_buffer.py b/ddmrp/models/stock_buffer.py index 35efc345f..21b4556ae 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", @@ -1542,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() @@ -1557,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) @@ -1564,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 @@ -1653,24 +1722,47 @@ 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.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 @@ -1813,55 +1905,32 @@ def do_auto_procure(self): 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 - ) + moves = self.stock_moves_inside_dlt_ids + self.stock_moves_outside_dlt_ids result["domain"] = [("id", "in", moves.ids)] return result - def _get_rfq_dlt(self, outside_dlt=False): - self.ensure_one() - 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") - ) - else: - 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 - 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)] + 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("stock.stock_move_action") - moves = self._search_stock_moves_incoming(outside_dlt=True) result["context"] = {} - result["domain"] = [("id", "in", moves.ids)] + result["domain"] = [("id", "in", self.stock_moves_outside_dlt_ids.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() - pos = pols.mapped("order_id") result["context"] = {} - result["domain"] = [("id", "in", pos.ids)] + result["domain"] = [("id", "in", self.rfq_inside_dlt_ids.order_id.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(outside_dlt=True) - pos = pols.mapped("order_id") result["context"] = {} - result["domain"] = [("id", "in", pos.ids)] + result["domain"] = [("id", "in", self.rfq_outside_dlt_ids.order_id.ids)] return result def action_view_qualified_demand_moves(self): diff --git a/ddmrp/tests/test_ddmrp.py b/ddmrp/tests/test_ddmrp.py index 8823810e8..e03913050 100644 --- a/ddmrp/tests/test_ddmrp.py +++ b/ddmrp/tests/test_ddmrp.py @@ -1288,3 +1288,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 98d3252bd..bf1fd299b 100644 --- a/ddmrp/views/stock_buffer_view.xml +++ b/ddmrp/views/stock_buffer_view.xml @@ -64,12 +64,22 @@ icon="fa-cogs" type="action" /> +