From bf9744e145f6a0a1724653018b37445e4172a3f4 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 10 Aug 2026 13:25:20 +0530 Subject: [PATCH] fix(manufacturing): correct nested BOM quantities --- .../report/bom_explorer/bom_explorer.py | 16 +++++- .../report/bom_explorer/test_bom_explorer.py | 54 ++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py index 1f82ec847b3..426a74d90f9 100644 --- a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py +++ b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py @@ -25,10 +25,12 @@ def fetch_exploded_bom_items(root_bom): recursive CTE -- replaces a query-per-node walk with a single query. UNION keeps it cycle-safe and fetches each sub-BOM's items only once even when it is reused across the tree.""" bom_item = frappe.qb.DocType("BOM Item") + child_bom = frappe.qb.DocType("BOM").as_("child_bom") tree = frappe.qb.Table("exploded_bom") fields = [ bom_item.parent, bom_item.qty, + bom_item.stock_qty, bom_item.bom_no, bom_item.item_code, bom_item.item_name, @@ -46,7 +48,11 @@ def fetch_exploded_bom_items(root_bom): .where(tree.bom_no != "") ) rows = ( - frappe.qb.with_(seed + recursion, "exploded_bom", recursive=True).from_(tree).select(tree.star) + frappe.qb.with_(seed + recursion, "exploded_bom", recursive=True) + .from_(tree) + .left_join(child_bom) + .on(tree.bom_no == child_bom.name) + .select(tree.star, child_bom.quantity.as_("child_bom_qty")) ).run(as_dict=True) children_map = defaultdict(list) @@ -71,7 +77,13 @@ def build_exploded_rows(bom, children_map, data, indent=0, qty=1): } ) if item.bom_no: - build_exploded_rows(item.bom_no, children_map, data, indent + 1, item.qty) + build_exploded_rows( + item.bom_no, + children_map, + data, + indent + 1, + qty * item.stock_qty / item.child_bom_qty, + ) def get_columns(): diff --git a/erpnext/manufacturing/report/bom_explorer/test_bom_explorer.py b/erpnext/manufacturing/report/bom_explorer/test_bom_explorer.py index 54bfae2d6c2..b24467d069e 100644 --- a/erpnext/manufacturing/report/bom_explorer/test_bom_explorer.py +++ b/erpnext/manufacturing/report/bom_explorer/test_bom_explorer.py @@ -4,7 +4,7 @@ import frappe from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom -from erpnext.manufacturing.report.bom_explorer.bom_explorer import execute +from erpnext.manufacturing.report.bom_explorer.bom_explorer import build_exploded_rows, execute from erpnext.tests.utils import ERPNextTestSuite @@ -78,3 +78,55 @@ class TestBOMExplorer(ERPNextTestSuite): # The leaf belongs to the sub-assembly, so it is exploded one level deeper. self.assertEqual(rows_by_item[leaf_item]["indent"], 1) self.assertEqual(rows_by_item[leaf_item]["bom_level"], 1) + + def test_nested_bom_uses_stock_qty_for_output_normalization(self): + parent_bom = create_nested_bom( + {"parent": {"sub": {"leaf": {}}}}, + prefix="_Test explorer converted quantity ", + ) + sub_bom = frappe.get_doc("BOM", parent_bom.items[0].bom_no) + + # The parent needs two boxes (20 units). The child BOM produces five units per batch. + frappe.db.set_value("BOM", sub_bom.name, "quantity", 5) + frappe.db.set_value("BOM Item", sub_bom.items[0].name, {"qty": 3, "stock_qty": 3}) + frappe.db.set_value( + "BOM Item", + parent_bom.items[0].name, + {"qty": 2, "uom": "Box", "conversion_factor": 10, "stock_qty": 20}, + ) + + data = self.run_report(parent_bom.name) + rows_by_item = {row["item_code"]: row for row in data} + + self.assertEqual(rows_by_item["_Test explorer converted quantity sub"]["qty"], 2) + self.assertEqual(rows_by_item["_Test explorer converted quantity leaf"]["qty"], 12) + + def test_nested_bom_multiplies_qty_at_every_level(self): + children_map = { + "root": [ + frappe._dict( + item_code="parent", + idx=1, + bom_no="parent-bom", + child_bom_qty=1, + qty=8, + stock_qty=8, + ) + ], + "parent-bom": [ + frappe._dict( + item_code="child", + idx=1, + bom_no="child-bom", + child_bom_qty=1, + qty=4, + stock_qty=4, + ) + ], + "child-bom": [frappe._dict(item_code="raw-material", idx=1, bom_no="", qty=2, stock_qty=2)], + } + data = [] + + build_exploded_rows("root", children_map, data) + + self.assertEqual([row["qty"] for row in data], [8, 32, 64])