Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ddmrp/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
19 changes: 14 additions & 5 deletions ddmrp/models/purchase_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
5 changes: 5 additions & 0 deletions ddmrp/models/stock_buffer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions ddmrp/models/stock_move.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions ddmrp/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -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
63 changes: 63 additions & 0 deletions ddmrp/tests/test_exclude_mto_demand.py
Original file line number Diff line number Diff line change
@@ -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)
Loading