From 652c96c051d32cd2a0704ffa16452a0d46dfc259 Mon Sep 17 00:00:00 2001 From: Jordi Ballester Alomar Date: Fri, 10 Jul 2026 07:17:28 +0200 Subject: [PATCH] [FIX] ddmrp: exclude make-to-order demand from the net flow position Qualified demand counted every outgoing stock move for the product, including make-to-order (pegged) demand. MTO demand is served by its own dedicated supply, not from the buffer, so counting it deflated the net flow position and led to over-procurement of MTS buffers. This was also inconsistent with the supply side, which already keeps MTO purchase lines out of MTS buffers (purchase.order.line._find_buffer_link). Use one shared criterion on both sides, based on the same relation (stock.move.created_purchase_line_ids <-> purchase.order.line.move_dest_ids): - stock.move._ddmrp_is_mto(): the move raised a purchase to satisfy itself. - purchase.order.line._ddmrp_is_mto(): the line was raised for a demand move. _search_stock_moves_qualified_demand now drops MTO moves, and _find_buffer_link is refactored to use the mirrored helper. Because both ends test the same relation, a pegged demand move and the purchase line it created are always excluded together, keeping the net flow position consistent. --- ddmrp/__manifest__.py | 2 +- ddmrp/models/purchase_order.py | 19 ++++++-- ddmrp/models/stock_buffer.py | 5 ++ ddmrp/models/stock_move.py | 14 ++++++ ddmrp/tests/__init__.py | 1 + ddmrp/tests/test_exclude_mto_demand.py | 63 ++++++++++++++++++++++++++ 6 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 ddmrp/tests/test_exclude_mto_demand.py diff --git a/ddmrp/__manifest__.py b/ddmrp/__manifest__.py index 0b52cf8c5..6ba7f1cbe 100644 --- a/ddmrp/__manifest__.py +++ b/ddmrp/__manifest__.py @@ -5,7 +5,7 @@ { "name": "DDMRP", "summary": "Demand Driven Material Requirements Planning", - "version": "19.0.1.1.0", + "version": "19.0.1.1.1", "license": "LGPL-3", "development_status": "Production/Stable", "author": "ForgeFlow, Odoo Community Association (OCA)", diff --git a/ddmrp/models/purchase_order.py b/ddmrp/models/purchase_order.py index 5ec157310..93b17b2de 100644 --- a/ddmrp/models/purchase_order.py +++ b/ddmrp/models/purchase_order.py @@ -76,14 +76,23 @@ def _get_domain_buffer_link(self): ("warehouse_id", "=", self.order_id.picking_type_id.warehouse_id.id), ] + def _ddmrp_is_mto(self): + """Return True when this purchase line was raised to satisfy a + make-to-order demand move. + + ``move_dest_ids`` is the inverse of ``stock.move.created_purchase_line_ids`` + (same relation), so this mirrors ``stock.move._ddmrp_is_mto`` on the + demand side. Keeping the criterion identical on both sides guarantees + that a pegged demand move and the purchase line it created are kept out + of the MTS buffer together, so the net flow position stays consistent. + """ + self.ensure_one() + return bool(self.move_dest_ids) + def _find_buffer_link(self): buffer_model = self.env["stock.buffer"] - move_model = self.env["stock.move"] for rec in self.filtered(lambda r: not r.buffer_ids): - mto_move = move_model.search( - [("created_purchase_line_ids", "in", rec.ids)], limit=1 - ) - if mto_move: + if rec._ddmrp_is_mto(): # MTO lines are not accounted in MTS stock buffers. continue domain = rec._get_domain_buffer_link() diff --git a/ddmrp/models/stock_buffer.py b/ddmrp/models/stock_buffer.py index 1c2f45fe4..44b2e176e 100644 --- a/ddmrp/models/stock_buffer.py +++ b/ddmrp/models/stock_buffer.py @@ -1601,6 +1601,11 @@ def _search_stock_moves_qualified_demand(self): and not move.location_final_id.is_sublocation_of(self.location_id) ) ) + # Make-to-order demand is served by its own pegged supply, not from + # the buffer, so it must not deflate the net flow position. This + # mirrors the supply side, which keeps the MTO purchase line out of + # the buffer (see purchase.order.line._find_buffer_link). + and not move._ddmrp_is_mto() ) return moves diff --git a/ddmrp/models/stock_move.py b/ddmrp/models/stock_move.py index cb3fe16f8..477363f9a 100644 --- a/ddmrp/models/stock_move.py +++ b/ddmrp/models/stock_move.py @@ -19,6 +19,20 @@ class StockMove(models.Model): string="Qualified Demand For Buffers", ) + def _ddmrp_is_mto(self): + """Return True when this move is pegged to a make-to-order supply. + + A move that raised a purchase to satisfy itself references the created + purchase line through ``created_purchase_line_ids``. This is the same + relation the supply side uses to keep MTO purchase lines out of MTS + buffers (``purchase.order.line._ddmrp_is_mto`` / ``_find_buffer_link`` + rely on the inverse ``move_dest_ids``). Using one criterion on both + sides keeps the net flow position consistent: the MTO demand move and + the purchase line it pegs are excluded together. + """ + self.ensure_one() + return bool(self.created_purchase_line_ids) + def _prepare_procurement_values(self): res = super()._prepare_procurement_values() if self.buffer_ids: diff --git a/ddmrp/tests/__init__.py b/ddmrp/tests/__init__.py index 48cc5a4de..377e34d62 100644 --- a/ddmrp/tests/__init__.py +++ b/ddmrp/tests/__init__.py @@ -1,3 +1,4 @@ from . import test_ddmrp from . import test_ddmrp_distributed_source_location from . import test_distributed_max_proc_time +from . import test_exclude_mto_demand diff --git a/ddmrp/tests/test_exclude_mto_demand.py b/ddmrp/tests/test_exclude_mto_demand.py new file mode 100644 index 000000000..da178a6a3 --- /dev/null +++ b/ddmrp/tests/test_exclude_mto_demand.py @@ -0,0 +1,63 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +from datetime import datetime + +from .common import TestDdmrpCommon + + +class TestExcludeMtoDemand(TestDdmrpCommon): + """Make-to-order demand must not deflate the net flow position of an + MTS buffer, mirroring the supply side which keeps MTO purchase lines out + of the buffer.""" + + def test_mto_demand_excluded_from_qualified_demand(self): + buffer = self.buffer_purchase + product = buffer.product_id + qty = 30.0 + date_move = datetime.today() + + # Make-to-stock demand: a plain outgoing delivery from the buffer. + mts_pick = self.create_picking_out( + product, date_move, qty, source_location=self.stock_location + ) + mts_move = mts_pick.move_ids + self.assertFalse(mts_move._ddmrp_is_mto()) + + # Make-to-order demand: an outgoing delivery pegged to a purchase line + # (same shape and date as the MTS one). Creating the purchase line with + # the demand move as move_dest reproduces the real MTO linkage. + mto_pick = self.create_picking_out( + product, date_move, qty, source_location=self.stock_location + ) + mto_move = mto_pick.move_ids + vendor = self.partner_model.create({"name": "MTO Vendor"}) + po = self.env["purchase.order"].create({"partner_id": vendor.id}) + pol = self.pol_model.create( + { + "order_id": po.id, + "product_id": product.id, + "name": product.display_name, + "product_qty": qty, + "product_uom_id": product.uom_id.id, + "price_unit": 1.0, + "date_planned": date_move, + "move_dest_ids": [(6, 0, mto_move.ids)], + } + ) + + # Both sides recognise the peg through the same relation. + self.assertTrue(mto_move._ddmrp_is_mto()) + self.assertTrue(pol._ddmrp_is_mto()) + + # Supply side: the MTO purchase line is kept out of the buffer. + self.assertNotIn(pol, buffer.purchase_line_ids) + + # Demand side: recompute the buffer. + self.bufferModel.cron_ddmrp(domain=[("id", "=", buffer.id)]) + + # Only the MTS demand counts; the MTO demand is excluded, so the net + # flow position is not deflated by demand served by its own supply. + self.assertEqual(buffer.qualified_demand, qty) + self.assertIn(mts_move, buffer.qualified_demand_stock_move_ids) + self.assertNotIn(mto_move, buffer.qualified_demand_stock_move_ids)