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():