From 1407ea6663e3ec0cce38a9caa821dd78c6656bae Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 3 Aug 2026 15:49:09 +0530 Subject: [PATCH] fix(stock): stop treating a Repack secondary item as a finished good (#57735) * fix(stock): stop treating a Repack secondary item as a finished good mark_finished_and_secondary_items flagged every incoming Repack row as a finished item, secondary rows included. Two things followed from that. The row never reached the secondary-item branch in _set_incoming_item_rate, so its own cost_allocation_per was never applied, and the BOM's finished-good percentage was applied to every incoming row rather than to the finished good alone. Value was destroyed as a result. Repacking 1000 of raw material under a BOM that allocates 75% to the finished good and 25% to scrap booked 500 to the finished good and 250 to the scrap: 750 in against 1000 out. Leave secondary rows unflagged so each side takes the share the BOM declares. * test(stock): cover cost allocation for a Repack secondary item A BOM allocating 75% to the finished good and 25% to scrap must split 1000 of raw material into 750 and 250, leaving no difference. (cherry picked from commit 7886bd2cab5169878e465eb986c4df34a77b74ec) # Conflicts: # erpnext/stock/doctype/stock_entry/test_stock_entry.py --- .../stock/doctype/stock_entry/stock_entry.py | 4 +- .../doctype/stock_entry/test_stock_entry.py | 138 ++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index ad22e5c3c08..8646f47c5ae 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2031,7 +2031,9 @@ class StockEntry(StockController, SubcontractingInwardController): for d in self.items: if d.t_warehouse and not d.s_warehouse: - if self.purpose == "Repack" or d.item_code == finished_item: + if d.secondary_item_type or d.is_legacy_scrap_item: + d.is_finished_item = 0 + elif self.purpose == "Repack" or d.item_code == finished_item: d.is_finished_item = 1 else: d.is_finished_item = 0 diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index d91ec89d7dd..82be0f8fa54 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -2737,6 +2737,144 @@ class TestStockEntry(ERPNextTestSuite): self.assertEqual(fg_sle.incoming_rate, 0) self.assertEqual(fg_sle.stock_value_difference, 0) +<<<<<<< HEAD +======= + def test_secondary_item_type_does_not_waive_inspection_outside_manufacturing(self): + """A stray secondary item type must not let a QI-required item through a receipt.""" + item = make_item( + properties={ + "is_stock_item": 1, + "valuation_rate": 50, + "inspection_required_before_purchase": 1, + } + ).name + + def receipt(secondary_item_type): + se = frappe.new_doc("Stock Entry") + se.purpose = se.stock_entry_type = "Material Receipt" + se.company = "_Test Company" + se.inspection_required = 1 + se.append( + "items", + { + "item_code": item, + "t_warehouse": "_Test Warehouse - _TC", + "qty": 10, + "conversion_factor": 1, + "secondary_item_type": secondary_item_type, + }, + ) + return se + + 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 test_repack_allocates_cost_to_secondary_item(self): + """A Repack secondary item takes its own BOM share, not the finished good's.""" + rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}).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" + + bom = frappe.get_doc( + { + "doctype": "BOM", + "item": fg_item, + "currency": "INR", + "quantity": 10, + "company": "_Test Company", + } + ) + bom.append("items", {"item_code": rm_item, "qty": 10}) + bom.append( + "secondary_items", + { + "secondary_item_type": "Scrap", + "item_code": scrap_item, + "item_name": scrap_item, + "qty": 5, + "cost_allocation_per": 25, + "process_loss_per": 0, + }, + ) + bom.insert() + bom.submit() + self.assertEqual(flt(bom.cost_allocation_per), 75.0) + + make_stock_entry(item_code=rm_item, target=warehouse, qty=100, basic_rate=100) + + se = frappe.new_doc("Stock Entry") + se.purpose = se.stock_entry_type = "Repack" + se.company = "_Test Company" + se.from_bom = 1 + se.bom_no = bom.name + se.fg_completed_qty = 10 + se.from_warehouse = warehouse + se.to_warehouse = warehouse + se.get_items() + se.save() + + fg_row = next(d for d in se.items if d.is_finished_item) + scrap_row = next(d for d in se.items if d.secondary_item_type) + + self.assertFalse(scrap_row.is_finished_item) + self.assertEqual(flt(scrap_row.basic_amount), 250.0) + self.assertEqual(flt(fg_row.basic_amount), 750.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) + +>>>>>>> 7886bd2cab (fix(stock): stop treating a Repack secondary item as a finished good (#57735)) 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 (