From 5e81cd1540a370dcdc00c7d0637302ca2efaccbb Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 3 Aug 2026 13:28:09 +0530 Subject: [PATCH 1/2] fix(stock): cost a BOM-less secondary item out of the finished good The legacy scrap checkbox deducted the scrap row's value from the finished good, so a Manufacture entry balanced. Its replacement, the Secondary Item Type dropdown, only balances when the row carries a BOM Secondary Item link, because the cost allocation percentage lives there. A row typed as Scrap in the UI has no such link, so its value was added on top of a finished good that already absorbed the whole raw material cost, and the entry closed with a non-zero difference. Treat a secondary row with no BOM link the way the legacy scrap item was treated: deduct its value from the finished good. The finished good's rate is derived from the other incoming rows, so those rows must be rated first. Previously the finished good was rated in row order, ahead of the secondary rows, and picked up their amounts only on a later validate pass. Rate the finished goods last so a single pass is correct. --- erpnext/stock/doctype/stock_entry/stock_entry.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index b63df53bd32..e9a15a7e40d 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -70,6 +70,15 @@ from erpnext.controllers.subcontracting_inward_controller import SubcontractingI form_grid_templates = {"items": "templates/form_grid/stock_entry_grid.html"} +def is_costed_out_of_finished_item(row) -> bool: + """Whether the row takes its value out of the finished good instead of adding to it. + + A secondary item that is not linked to a BOM has no cost allocation of its own, so it is + valued the way the legacy scrap item was: its cost is deducted from the finished good. + """ + return bool(row.is_legacy_scrap_item or (row.secondary_item_type and not row.bom_secondary_item)) + + class StockEntry(StockController, SubcontractingInwardController): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -564,7 +573,8 @@ class StockEntry(StockController, SubcontractingInwardController): ) zero_valuation_items = [] - for d in self.get("items"): + finished_items_last = sorted(self.get("items"), key=lambda row: cint(row.is_finished_item)) + for d in finished_items_last: if d.s_warehouse or d.set_basic_rate_manually: continue @@ -736,7 +746,9 @@ class StockEntry(StockController, SubcontractingInwardController): self, finished_item_qty, outgoing_items_cost=0, has_consumption_basis=False ) -> float: settings = frappe.get_single("Manufacturing Settings") - scrap_items_cost = sum([flt(d.basic_amount) for d in self.get("items") if d.is_legacy_scrap_item]) + scrap_items_cost = sum( + [flt(d.basic_amount) for d in self.get("items") if is_costed_out_of_finished_item(d)] + ) if settings.material_consumption: outgoing_items_cost = self._get_rm_cost_for_manufacture( From 7f47361ebdfc743509a9b1d92690a4667d513091 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 3 Aug 2026 13:28:20 +0530 Subject: [PATCH 2/2] test(stock): cover a secondary item added without a BOM A Manufacture entry with a raw material worth 1000, a finished good and a Scrap row typed in the UI must value the scrap at its own rate and take that value out of the finished good, leaving no difference. --- .../doctype/stock_entry/test_stock_entry.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index 2cd3fde5f84..af4e0f42c9a 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -2759,6 +2759,55 @@ class TestStockEntry(ERPNextTestSuite): self.assertRaises(QualityInspectionRequiredError, receipt("").submit) self.assertRaises(QualityInspectionRequiredError, receipt("Scrap").submit) + def test_manufacture_balances_secondary_item_added_without_a_bom(self): + """A secondary item with no BOM link is costed out of the finished good, as legacy scrap was.""" + rm_item = make_item(properties={"is_stock_item": 1}).name + fg_item = make_item(properties={"is_stock_item": 1}).name + scrap_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 20}).name + warehouse = "_Test Warehouse - _TC" + + make_stock_entry(item_code=rm_item, target=warehouse, qty=10, basic_rate=100) + + se = frappe.new_doc("Stock Entry") + se.purpose = se.stock_entry_type = "Manufacture" + se.company = "_Test Company" + se.append( + "items", {"item_code": rm_item, "s_warehouse": warehouse, "qty": 10, "conversion_factor": 1} + ) + se.append( + "items", + { + "item_code": fg_item, + "t_warehouse": warehouse, + "qty": 10, + "is_finished_item": 1, + "conversion_factor": 1, + }, + ) + se.append( + "items", + { + "item_code": scrap_item, + "t_warehouse": warehouse, + "qty": 5, + "secondary_item_type": "Scrap", + "conversion_factor": 1, + }, + ) + se.save() + + scrap_row = se.items[2] + self.assertEqual(flt(scrap_row.basic_rate), 20.0) + self.assertEqual(flt(scrap_row.basic_amount), 100.0) + + fg_row = se.items[1] + self.assertEqual(flt(fg_row.basic_rate), 90.0) + self.assertEqual(flt(fg_row.basic_amount), 900.0) + + self.assertEqual(flt(se.total_incoming_value), 1000.0) + self.assertEqual(flt(se.total_outgoing_value), 1000.0) + self.assertEqual(flt(se.value_difference), 0.0) + def _make_wo_for_free_raw_material(self, rm_item, fg_item, bom_no): from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record from erpnext.manufacturing.doctype.work_order.work_order import (