From cffc1bc7af897d2f11ac20dd495985c4ee7b7c5f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 2 Aug 2026 19:47:31 +0530 Subject: [PATCH] fix(manufacturing): stop BOM Stock Analysis inflating both its sums get_bom_data left-joined Bin on item_code alone and then summed over the result. Bin holds one row per warehouse and BOM Item one row per line, so the join is a cross product and each SUM counts the other side's rows: Sum(qty_consumed_per_unit) x (number of warehouses holding the item) Sum(bin.actual_qty) x (number of BOM lines carrying the item) A component on two BOM lines, stocked in two warehouses, reported a per-unit requirement of 10 instead of 5 and available stock of 20 instead of 10 -- wrong on both engines, and wrong in the single-line case too as soon as the item sits in more than one warehouse. Aggregate Bin to one row per item_code before joining, so neither sum can see the other's duplicates. The warehouse filter moves into that subquery; it previously sat in the outer WHERE against a left-joined column, which silently made the join inner, so the join is now made inner explicitly when a warehouse is given to keep items with no bin there excluded as before. --- .../bom_stock_analysis/bom_stock_analysis.py | 50 ++++++++++++------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py index e787451e57b..037f9176685 100644 --- a/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py +++ b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py @@ -190,27 +190,14 @@ def batch_fetch_purchase_rates(bom_data): } -def get_bom_data(filters): - bom_item_table = "BOM Explosion Item" if filters.get("show_exploded_view") else "BOM Item" - - bom_item = frappe.qb.DocType(bom_item_table) +def get_stock_qty_by_item(filters): + """One row per item_code, so joining it to BOM Item cannot multiply either side's sum.""" bin = frappe.qb.DocType("Bin") query = ( - frappe.qb.from_(bom_item) - .left_join(bin) - .on(bom_item.item_code == bin.item_code) - .select( - bom_item.item_code, - # non-grouped columns are constant per grouped item_code -> Max() keeps the GROUP BY valid - Max(bom_item.description).as_("description"), - Max(bom_item.parent).as_("from_bom_no"), - Sum(bom_item.qty_consumed_per_unit).as_("qty_per_unit"), - IfNull(Sum(bin.actual_qty), 0).as_("actual_qty"), - ) - .where((bom_item.parent == filters.get("bom")) & (bom_item.parenttype == "BOM")) - .groupby(bom_item.item_code) - .orderby(Min(bom_item.idx)) + frappe.qb.from_(bin) + .select(bin.item_code, Sum(bin.actual_qty).as_("actual_qty")) + .groupby(bin.item_code) ) if filters.get("warehouse"): @@ -233,6 +220,33 @@ def get_bom_data(filters): else: query = query.where(bin.warehouse == filters.get("warehouse")) + return query + + +def get_bom_data(filters): + bom_item_table = "BOM Explosion Item" if filters.get("show_exploded_view") else "BOM Item" + + bom_item = frappe.qb.DocType(bom_item_table) + stock_qty = get_stock_qty_by_item(filters).as_("stock_qty") + + base = frappe.qb.from_(bom_item) + base = base.join(stock_qty) if filters.get("warehouse") else base.left_join(stock_qty) + + query = ( + base.on(bom_item.item_code == stock_qty.item_code) + .select( + bom_item.item_code, + # non-grouped columns are constant per grouped item_code -> Max() keeps the GROUP BY valid + Max(bom_item.description).as_("description"), + Max(bom_item.parent).as_("from_bom_no"), + Sum(bom_item.qty_consumed_per_unit).as_("qty_per_unit"), + IfNull(Max(stock_qty.actual_qty), 0).as_("actual_qty"), + ) + .where((bom_item.parent == filters.get("bom")) & (bom_item.parenttype == "BOM")) + .groupby(bom_item.item_code) + .orderby(Min(bom_item.idx)) + ) + data = query.run(as_dict=True) if bom_item_table == "BOM Item":