Merge pull request #53396 from vorasmit/same-phantom-different-fg

This commit is contained in:
Smit Vora
2026-03-13 09:44:42 +05:30
committed by GitHub
2 changed files with 95 additions and 8 deletions

View File

@@ -2104,16 +2104,16 @@ def get_raw_materials_of_sub_assembly_items(
for item in query.run(as_dict=True):
key = (item.item_code, item.bom_no)
if item.is_phantom_item:
sub_assembly_items[key] += item.get("qty")
existing_key = (item.item_code, item.bom_no or item.main_bom)
if (item.bom_no and key not in sub_assembly_items) or (
(item.item_code, item.bom_no or item.main_bom) in existing_sub_assembly_items
):
if item.bom_no and not item.is_phantom_item and key not in sub_assembly_items:
continue
if not item.is_phantom_item and existing_key in existing_sub_assembly_items:
continue
if item.bom_no:
planned_qty = flt(sub_assembly_items[key])
recursion_qty = flt(item.get("qty")) if item.is_phantom_item else flt(sub_assembly_items[key])
get_raw_materials_of_sub_assembly_items(
existing_sub_assembly_items,
item_details,
@@ -2121,9 +2121,10 @@ def get_raw_materials_of_sub_assembly_items(
item.bom_no,
include_non_stock_items,
sub_assembly_items,
planned_qty=planned_qty,
planned_qty=recursion_qty,
)
existing_sub_assembly_items.add((item.item_code, item.bom_no or item.main_bom))
if not item.is_phantom_item:
existing_sub_assembly_items.add(existing_key)
else:
if not item.conversion_factor and item.purchase_uom:
item.conversion_factor = get_uom_conversion_factor(item.item_code, item.purchase_uom)

View File

@@ -2710,6 +2710,92 @@ class TestProductionPlan(IntegrationTestCase):
[item.item_code for item in plan.mr_items], ["Item Level 1-3", "Item Level 2-3", "Item Level 3-1"]
)
def test_phantom_bom_explosion_across_multiple_po_items(self):
"""
Regression: when the same phantom item (BOM) is referenced inside sub-assemblies
of two different production plan items, its raw materials must be fully exploded
for *both* plan items.
"""
# Setup items
fg_a = make_item("FG for Cross-PO Phantom Test A")
fg_b = make_item("FG for Cross-PO Phantom Test B")
sa_a = make_item("SA for Cross-PO Phantom Test A")
sa_b = make_item("SA for Cross-PO Phantom Test B")
phantom = make_item("Phantom for Cross-PO Test")
rm = make_item("RM for Cross-PO Phantom Test")
# Create the shared phantom BOM
phantom_bom = make_bom(item=phantom.name, raw_materials=[rm.name], do_not_save=True)
phantom_bom.is_phantom_bom = 1
phantom_bom.save()
phantom_bom.submit()
# Create SA-A BOM with phantom
sa_a_bom = make_bom(item=sa_a.name, raw_materials=[phantom.name], do_not_save=True)
sa_a_bom.items[0].bom_no = phantom_bom.name
sa_a_bom.save()
sa_a_bom.submit()
# Create SA-B BOM with the SAME phantom
sa_b_bom = make_bom(item=sa_b.name, raw_materials=[phantom.name], do_not_save=True)
sa_b_bom.items[0].bom_no = phantom_bom.name
sa_b_bom.save()
sa_b_bom.submit()
# Create FG-A BOM with SA-A
fg_a_bom = make_bom(item=fg_a.name, raw_materials=[sa_a.name], do_not_save=True)
fg_a_bom.items[0].bom_no = sa_a_bom.name
fg_a_bom.save()
fg_a_bom.submit()
# Create FG-B BOM with SA-B
fg_b_bom = make_bom(item=fg_b.name, raw_materials=[sa_b.name], do_not_save=True)
fg_b_bom.items[0].bom_no = sa_b_bom.name
fg_b_bom.save()
fg_b_bom.submit()
# Build Production Plan with both FGs
plan = frappe.new_doc("Production Plan")
plan.company = "_Test Company"
plan.posting_date = nowdate()
plan.ignore_existing_ordered_qty = 1
plan.skip_available_sub_assembly_item = 1
plan.sub_assembly_warehouse = "_Test Warehouse - _TC"
for fg_item, bom in [(fg_a.name, fg_a_bom.name), (fg_b.name, fg_b_bom.name)]:
plan.append(
"po_items",
{
"use_multi_level_bom": 1,
"item_code": fg_item,
"bom_no": bom,
"planned_qty": 1,
"planned_start_date": now_datetime(),
"stock_uom": "Nos",
},
)
plan.insert()
plan.get_sub_assembly_items()
# Verify both sub-assemblies are present
sa_items = {row.production_item for row in plan.sub_assembly_items}
self.assertIn(sa_a.name, sa_items)
self.assertIn(sa_b.name, sa_items)
plan.submit()
mr_items = get_items_for_material_requests(plan.as_dict())
# Phantom raw material should be counted twice (once per FG → SA → shared phantom)
rm_total_qty = sum(flt(d["quantity"]) for d in mr_items if d["item_code"] == rm.name)
self.assertEqual(
rm_total_qty,
2.0,
f"Expected RM qty=2 (1 per FG via shared phantom BOM), got {rm_total_qty}. "
"The phantom BOM was not re-exploded for the second po_item.",
)
def create_production_plan(**args):
"""