Skip to content

Commit 17b9447

Browse files
committed
[FIX] sale_mrp_bom: Handle phantom BoM (kit) procurements linked to SO lines
1 parent 47cdf1e commit 17b9447

5 files changed

Lines changed: 320 additions & 10 deletions

File tree

sale_mrp_bom/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
22

3+
from . import procurement_group
34
from . import sale_order_line
45
from . import stock_move
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Copyright 2025 360ERP (<https://www.360erp.com>)
2+
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html).
3+
4+
from odoo import api, models
5+
6+
7+
class ProcurementGroup(models.Model):
8+
_inherit = "procurement.group"
9+
10+
@api.model
11+
def run(self, procurements, raise_user_error=True):
12+
"""
13+
Handle phantom BoM (kit) procurements linked to sale order lines.
14+
15+
If a procurement is for a kit product associated with a sale order line,
16+
this method explodes the kit into its components and generates new
17+
procurements for those components. Original procurements that are not
18+
kits, or not linked to a sale order line with a phantom BoM, are passed through.
19+
20+
:param procurements: A list of Procurement namedtuples
21+
:param raise_user_error: Whether to raise UserError on failure
22+
:return: Result of the original run method
23+
"""
24+
# Collect unique sale_line_ids from procurements that have them
25+
sale_line_ids = list(
26+
set(
27+
p.values.get("sale_line_id")
28+
for p in procurements
29+
if p.values.get("sale_line_id")
30+
)
31+
)
32+
33+
# Pre-fetch sale lines and create a mapping for quick access
34+
sale_lines = self.env["sale.order.line"].browse(sale_line_ids)
35+
sale_lines_map = {sl.id: sl for sl in sale_lines}
36+
37+
procurements_without_kit = []
38+
for procurement in procurements:
39+
sale_line_id = procurement.values.get("sale_line_id")
40+
sale_line = sale_lines_map.get(sale_line_id)
41+
42+
bom_kit = (
43+
sale_line.bom_id.filtered(
44+
lambda bm, pr=procurement: bm.type == "phantom"
45+
and (
46+
# If BoM has product_id, match the procurement's product_id
47+
(bm.product_id and bm.product_id == pr.product_id)
48+
or
49+
# Otherwise (if BoM has no product_id), match the template_id
50+
(
51+
not bm.product_id
52+
and bm.product_tmpl_id == pr.product_id.product_tmpl_id
53+
)
54+
)
55+
)
56+
if sale_line
57+
else False
58+
)
59+
if bom_kit:
60+
order_qty = procurement.product_uom._compute_quantity(
61+
procurement.product_qty, bom_kit.product_uom_id, round=False
62+
)
63+
qty_to_produce = order_qty / bom_kit.product_qty
64+
_dummy, bom_sub_lines = bom_kit.explode(
65+
procurement.product_id,
66+
qty_to_produce,
67+
never_attribute_values=procurement.values.get(
68+
"never_product_template_attribute_value_ids"
69+
),
70+
)
71+
for bom_line, bom_line_data in bom_sub_lines:
72+
bom_line_uom = bom_line.product_uom_id
73+
quant_uom = bom_line.product_id.uom_id
74+
# recreate dict of values since each child has its own bom_line_id
75+
values = dict(procurement.values, bom_line_id=bom_line.id)
76+
component_qty, procurement_uom = (
77+
bom_line_uom._adjust_uom_quantities(
78+
bom_line_data["qty"], quant_uom
79+
)
80+
)
81+
procurements_without_kit.append(
82+
self.env["procurement.group"].Procurement(
83+
bom_line.product_id,
84+
component_qty,
85+
procurement_uom,
86+
procurement.location_id,
87+
procurement.name,
88+
procurement.origin,
89+
procurement.company_id,
90+
values,
91+
)
92+
)
93+
else:
94+
procurements_without_kit.append(procurement)
95+
return super().run(procurements_without_kit, raise_user_error=raise_user_error)

sale_mrp_bom/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
22

33
from . import test_sale_mrp_bom
4+
from . import test_sale_mrp_bom_multi_line

sale_mrp_bom/tests/test_sale_mrp_bom.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -59,28 +59,30 @@ def _prepare_so(self):
5959

6060
def _create_bom(self, template):
6161
return self.env["mrp.bom"].create(
62-
{"product_tmpl_id": template.id, "type": "normal"}
62+
[{"product_tmpl_id": template.id, "type": "normal"}]
6363
)
6464

6565
def _create_bom_line(self, bom, product, qty):
6666
self.env["mrp.bom.line"].create(
67-
{"bom_id": bom.id, "product_id": product.id, "product_qty": qty}
67+
[{"bom_id": bom.id, "product_id": product.id, "product_qty": qty}]
6868
)
6969

7070
def _create_sale_order(self, partner, client_ref):
7171
return self.env["sale.order"].create(
72-
{"partner_id": partner.id, "client_order_ref": client_ref}
72+
[{"partner_id": partner.id, "client_order_ref": client_ref}]
7373
)
7474

7575
def _create_sale_order_line(self, sale_order, product, qty, price, bom):
7676
self.env["sale.order.line"].create(
77-
{
78-
"order_id": sale_order.id,
79-
"product_id": product.id,
80-
"price_unit": price,
81-
"product_uom_qty": qty,
82-
"bom_id": bom.id,
83-
}
77+
[
78+
{
79+
"order_id": sale_order.id,
80+
"product_id": product.id,
81+
"price_unit": price,
82+
"product_uom_qty": qty,
83+
"bom_id": bom.id,
84+
}
85+
]
8486
)
8587

8688
def test_define_bom_in_sale_line(self):
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
# Copyright 2025 360ERP (https://www.360erp.com)
2+
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
3+
4+
from odoo.tests.common import TransactionCase
5+
6+
7+
class TestSalePhantomBomProcurementMultiLine(TransactionCase):
8+
"""
9+
Tests Phantom BoM explosion for Kits selected on SO Lines.
10+
Focuses on a scenario with multiple lines for the same kit product,
11+
each specifying a different phantom BoM referencing the same component,
12+
to ensure component quantities are correctly exploded and aggregated
13+
within the resulting Stock Picking moves.
14+
"""
15+
16+
@classmethod
17+
def setUpClass(cls):
18+
super().setUpClass()
19+
cls.env = cls.env(
20+
context=dict(
21+
cls.env.context,
22+
mail_create_nolog=True,
23+
mail_create_nosubscribe=True,
24+
mail_notrack=True,
25+
no_reset_password=True,
26+
tracking_disable=True,
27+
)
28+
)
29+
cls.company = cls.env.company
30+
31+
# Required groups
32+
cls.env.user.groups_id += cls.env.ref("stock.group_adv_location")
33+
cls.env.user.groups_id += cls.env.ref("sale_mrp_bom.sale_mrp_bom_group")
34+
35+
# Ensure MTO Route is Active
36+
cls.mto_route = cls.env.ref(
37+
"stock.route_warehouse0_mto", raise_if_not_found=True
38+
)
39+
if not cls.mto_route.active:
40+
cls.mto_route.action_unarchive()
41+
42+
# Products
43+
cls.product_mtokit = cls.env["product.product"].create(
44+
[
45+
{
46+
"name": "MTOKIT",
47+
"type": "consu",
48+
"route_ids": [(6, 0, [cls.mto_route.id])],
49+
"categ_id": cls.env.ref("product.product_category_all").id,
50+
}
51+
]
52+
)
53+
cls.product_mtocomp = cls.env["product.product"].create(
54+
[
55+
{
56+
"name": "MTOCOMP",
57+
"type": "consu",
58+
"route_ids": [],
59+
"categ_id": cls.env.ref("product.product_category_all").id,
60+
}
61+
]
62+
)
63+
64+
# BoMs (Phantom)
65+
cls.bom_kit1 = cls.env["mrp.bom"].create(
66+
[
67+
{
68+
"product_tmpl_id": cls.product_mtokit.product_tmpl_id.id,
69+
"product_qty": 1.0,
70+
"type": "phantom",
71+
"code": "KIT1",
72+
}
73+
]
74+
)
75+
# BoM Line 1: 1 x MTOCOMP
76+
cls.env["mrp.bom.line"].create(
77+
[
78+
{
79+
"bom_id": cls.bom_kit1.id,
80+
"product_id": cls.product_mtocomp.id,
81+
"product_qty": 1,
82+
}
83+
]
84+
)
85+
cls.bom_kit2 = cls.env["mrp.bom"].create(
86+
[
87+
{
88+
"product_tmpl_id": cls.product_mtokit.product_tmpl_id.id,
89+
"product_qty": 1.0,
90+
"type": "phantom",
91+
"code": "KIT2",
92+
}
93+
]
94+
)
95+
# BoM Line 2: 2 x MTOCOMP
96+
cls.env["mrp.bom.line"].create(
97+
[
98+
{
99+
"bom_id": cls.bom_kit2.id,
100+
"product_id": cls.product_mtocomp.id,
101+
"product_qty": 2,
102+
}
103+
]
104+
)
105+
106+
cls.partner = cls.env.ref("base.res_partner_2") # Customer
107+
cls.warehouse = cls.env.ref("stock.warehouse0")
108+
109+
def _create_sale_order(self, partner):
110+
return self.env["sale.order"].create(
111+
[
112+
{
113+
"partner_id": partner.id,
114+
"partner_invoice_id": partner.id,
115+
"partner_shipping_id": partner.id,
116+
"warehouse_id": self.warehouse.id,
117+
}
118+
]
119+
)
120+
121+
def _create_sale_order_line(self, sale_order, product, qty, bom):
122+
sol = self.env["sale.order.line"].create(
123+
[
124+
{
125+
"order_id": sale_order.id,
126+
"product_id": product.id,
127+
"product_uom_qty": qty,
128+
"bom_id": bom.id,
129+
"product_uom": product.uom_id.id,
130+
"price_unit": 1,
131+
}
132+
]
133+
)
134+
return sol
135+
136+
def test_phantom_bom_explosion_multi_line_same_component(self):
137+
"""
138+
Test SO with 2 lines for MTOKIT (phantom): Line 1 uses KIT1 (1 comp),
139+
Line 2 uses KIT2 (2 comps).
140+
Verify that the resulting delivery picking moves contain lines for MTO_COMP
141+
with correctly aggregated quantities based on the phantom BoM explosion.
142+
"""
143+
# Create SO
144+
so = self._create_sale_order(self.partner)
145+
qty_line1 = 5
146+
qty_line2 = 3
147+
148+
# Line 1: 5 x MTOKIT using KIT1 (-> 5 * 1 = 5 MTO_COMP)
149+
self._create_sale_order_line(so, self.product_mtokit, qty_line1, self.bom_kit1)
150+
# Line 2: 3 x MTOKIT using KIT2 (-> 3 * 2 = 6 MTO_COMP)
151+
self._create_sale_order_line(so, self.product_mtokit, qty_line2, self.bom_kit2)
152+
153+
# Confirm the Sale Order - This triggers the delivery order creation
154+
# and the phantom BoM explosion for the delivery moves.
155+
so.action_confirm()
156+
157+
# Find the picking associated with the Sale Order
158+
pickings = so.picking_ids
159+
self.assertEqual(
160+
len(pickings),
161+
1,
162+
f"Expected one picking for {so.name}, found {len(pickings)}",
163+
)
164+
picking = pickings[0]
165+
166+
# Find stock moves within this picking
167+
moves = picking.move_ids
168+
169+
# Verify no moves for the kit itself (it's phantom)
170+
kit_product_moves = moves.filtered(
171+
lambda m: m.product_id == self.product_mtokit
172+
)
173+
self.assertFalse(
174+
kit_product_moves,
175+
"No stock move line should be created for the parent kit product (MTOKIT).",
176+
)
177+
178+
# Find the moves specifically for the component within this picking
179+
comp_product_moves = moves.filtered(
180+
lambda m: m.product_id == self.product_mtocomp
181+
)
182+
self.assertTrue(
183+
comp_product_moves,
184+
f"Stock move lines for {self.product_mtocomp.name} should be created.",
185+
)
186+
187+
# Calculate expected *total* component quantity based on BoMs
188+
expected_comp_qty_line1 = (
189+
qty_line1
190+
* self.bom_kit1.bom_line_ids.filtered(
191+
lambda bl: bl.product_id == self.product_mtocomp
192+
).product_qty
193+
)
194+
expected_comp_qty_line2 = (
195+
qty_line2
196+
* self.bom_kit2.bom_line_ids.filtered(
197+
lambda bl: bl.product_id == self.product_mtocomp
198+
).product_qty
199+
)
200+
expected_total_qty = (
201+
expected_comp_qty_line1 + expected_comp_qty_line2
202+
) # Should be 5 * 1 + 3 * 2 = 11
203+
204+
# Verify the total quantity demanded in the generated stock moves
205+
# Check the initial demand planned for the picking
206+
actual_total_move_qty = sum(comp_product_moves.mapped("product_uom_qty"))
207+
self.assertEqual(
208+
actual_total_move_qty,
209+
expected_total_qty,
210+
f"MTO_COMP: expected {expected_total_qty}, got {actual_total_move_qty}.",
211+
)

0 commit comments

Comments
 (0)