From 5eabd176f5debe30477e4098d5decd13993a06ef Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 3 Aug 2026 00:52:23 +0530 Subject: [PATCH] fix(manufacturing): compute BOM item amount per line (#57708) * fix(manufacturing): compute BOM item amount per line get_bom_items_as_dict groups BOM lines by item_code, so a BOM listing the same item on more than one line collapses to a single row. The amount column multiplied the summed quantity by a single line's rate: Sum(stock_qty / bom.quantity) * Max(rate) * qty That is neither line's amount and not their total. The Max() was added to satisfy Postgres' strict GROUP BY on the assumption that rate is constant per item, but rate is editable per line. Fold the rate into the sum so every line contributes its own: Sum(stock_qty / bom.quantity * rate) * qty Identical for the common single-line item, correct for duplicates, and valid on both engines. Same class as the fix applied to budget_controller's requested amount. * test(manufacturing): cover BOM item amount across duplicate lines A BOM listing the same item twice, once in the stock UOM and once in a UOM with a conversion factor, gives the two lines different rates (rate is the valuation rate scaled by the conversion factor). The two lines collapse into one row in get_bom_items_as_dict, so amount must be the sum of each line's own qty x rate. Guards the fixture with an assertion that the two rates actually differ, so the test cannot pass vacuously. Fails on the previous Sum(stock_qty) * Max(rate) expression. * fix(manufacturing): use matching UOM quantity for BOM amount --- erpnext/manufacturing/doctype/bom/bom.py | 14 +++++----- erpnext/manufacturing/doctype/bom/test_bom.py | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 6583e28889a..a1e154d1333 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -1264,16 +1264,16 @@ def _build_base_bom_items_query(bom, company, qty, t): def _add_bom_item_columns(query, t, bom, opts, track_semi_finished_goods): is_stock_item = cint(not opts.include_non_stock_items) stock_item_condition = t.item_doc.is_stock_item.isin([1, is_stock_item]) - # rate is constant per grouped item -> Max() keeps it out of the Sum (preserving the original - # Sum(...) * rate * qty arithmetic) while making the expression postgres-valid under GROUP BY. - amount_col = ( - Sum(t.bom_item.stock_qty / IfNull(t.bom_doc.quantity, 1)) * Max(t.bom_item.rate) * opts.qty - ).as_("amount") + if opts.fetch_secondary_items: + return _add_secondary_item_columns(query, t, stock_item_condition) + + # BOM Item rate is per row UOM, while BOM Explosion Item rate is per stock UOM. Select the + # matching quantity so a normal BOM row's conversion factor is not applied twice. + qty_col = t.bom_item.stock_qty if cint(opts.fetch_exploded) else t.bom_item.qty + amount_col = (Sum(qty_col / IfNull(t.bom_doc.quantity, 1) * t.bom_item.rate) * opts.qty).as_("amount") if cint(opts.fetch_exploded): return _add_exploded_item_columns(query, t, bom, amount_col, stock_item_condition) - if opts.fetch_secondary_items: - return _add_secondary_item_columns(query, t, stock_item_condition) return _add_normal_item_columns(query, t, amount_col, stock_item_condition, track_semi_finished_goods) diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index 6d45e7452b2..9ff57dae852 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -101,6 +101,33 @@ class TestBOM(ERPNextTestSuite): self.assertEqual(flt(items_dict[component].qty), 1.0) self.assertNotIn(rm_normal, items_dict) + @timeout + def test_get_items_amount_uses_each_lines_own_rate(self): + from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + + rm = make_item(properties={"is_stock_item": 1, "valuation_rate": 10, "stock_uom": "Nos"}) + if not any(row.uom == "Box" for row in rm.uoms): + rm.append("uoms", {"uom": "Box", "conversion_factor": 5}) + rm.save() + + fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name + bom = make_bom(item=fg_item, raw_materials=[rm.name], rm_qty=2, do_not_save=True) + bom.append("items", {"item_code": rm.name, "qty": 3, "uom": "Box", "stock_uom": "Nos"}) + bom.save() + bom.submit() + + lines = [row for row in bom.items if row.item_code == rm.name] + self.assertEqual(len(lines), 2) + self.assertEqual(len({flt(row.rate) for row in lines}), 2) + + requested_qty = 2 + expected = sum(flt(row.qty) * flt(row.rate) for row in lines) / flt(bom.quantity) * requested_qty + items_dict = get_bom_items_as_dict(bom.name, "_Test Company", qty=requested_qty, fetch_exploded=0) + + self.assertEqual(len([row for row in items_dict if row == rm.name]), 1) + self.assertAlmostEqual(flt(items_dict[rm.name].amount), expected, places=2) + @timeout def test_default_bom(self): def _get_default_bom_in_item():