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.
This commit is contained in:
Mihir Kandoi
2026-08-02 19:47:31 +05:30
parent 3b0d35410b
commit cffc1bc7af

View File

@@ -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":