From 68eeba41c1eb3a503f5fe0acd6f1d48d320c216f Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Fri, 19 Dec 2025 14:07:09 +0530 Subject: [PATCH] fix: de-duplicate rows on disassembly with multiple manufacture entries (cherry picked from commit a091e47bd7589bda083c0d411f5e518ca12ac0fd) --- .../doctype/work_order/test_work_order.py | 111 ++++++++++++++++++ .../stock/doctype/stock_entry/stock_entry.py | 1 + 2 files changed, 112 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 0ece122fe0e..4dbe95fb238 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2467,6 +2467,117 @@ class TestWorkOrder(FrappeTestCase): f"Work Order disassembled_qty mismatch: expected {disassemble_qty}, got {wo.disassembled_qty}", ) + def test_disassembly_with_multiple_manufacture_entries(self): + """ + Test that disassembly does not create duplicate items when manufacturing + is done in multiple batches (multiple manufacture stock entries). + + Scenario: + 1. Create Work Order for 10 units + 2. Transfer raw materials + 3. Manufacture in 2 parts (3 units, then 7 units) - creates 2 stock entries + 4. Create Disassembly for 4 units + 5. Verify no duplicate items in the disassembly stock entry + """ + # Create RM and FG item + raw_item1 = make_item("Test Raw for Multi Batch Disassembly 1", {"is_stock_item": 1}).name + raw_item2 = make_item("Test Raw for Multi Batch Disassembly 2", {"is_stock_item": 1}).name + fg_item = make_item("Test FG for Multi Batch Disassembly", {"is_stock_item": 1}).name + bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2) + + # Create WO + wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started") + + # Ensure enough stock + from erpnext.stock.doctype.stock_entry.test_stock_entry import ( + make_stock_entry as make_stock_entry_test_record, + ) + + make_stock_entry_test_record( + item_code=raw_item1, + purpose="Material Receipt", + target=wo.wip_warehouse, + qty=50, + basic_rate=100, + ) + make_stock_entry_test_record( + item_code=raw_item2, + purpose="Material Receipt", + target=wo.wip_warehouse, + qty=50, + basic_rate=100, + ) + + # Transfer for manufacture + se_for_material_transfer = frappe.get_doc( + make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty) + ) + for item in se_for_material_transfer.items: + item.s_warehouse = wo.wip_warehouse + se_for_material_transfer.save() + se_for_material_transfer.submit() + + # First Manufacture Entry - 3 units + se_manufacture1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3)) + se_manufacture1.submit() + + # Second Manufacture Entry - 7 units + se_manufacture2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 7)) + se_manufacture2.submit() + + wo.reload() + self.assertEqual(wo.produced_qty, 10) + + # Count manufacture entries + manufacture_entries = frappe.get_all( + "Stock Entry", + filters={ + "work_order": wo.name, + "purpose": "Manufacture", + "docstatus": 1, + }, + ) + self.assertEqual(len(manufacture_entries), 2, "Expected 2 manufacture entries") + + # Disassembly for 4 units + disassemble_qty = 4 + stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty)) + + item_counts = {} + for item in stock_entry.items: + item_code = item.item_code + item_counts[item_code] = item_counts.get(item_code, 0) + 1 + + # No duplicates + duplicates = {k: v for k, v in item_counts.items() if v > 1} + self.assertEqual( + len(duplicates), + 0, + f"Found duplicate items in disassembly stock entry: {duplicates}", + ) + + expected_items = 3 # FG item + 2 raw materials + self.assertEqual( + len(stock_entry.items), + expected_items, + f"Expected {expected_items} items, found {len(stock_entry.items)}", + ) + + # FG item qty + fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None) + self.assertEqual(fg_item_row.qty, disassemble_qty) + + # RM quantities + for bom_item in bom.items: + expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty + rm_row = next((i for i in stock_entry.items if i.item_code == bom_item.item_code), None) + self.assertAlmostEqual( + rm_row.qty, + expected_qty, + places=3, + msg=f"Raw material {bom_item.item_code} qty mismatch", + ) + def test_components_alternate_item_for_bom_based_manufacture_entry(self): frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM") frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index a441664ab9c..85c0e313047 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1970,6 +1970,7 @@ class StockEntry(StockController): ["Stock Entry Detail", "docstatus", "=", 1], ], order_by="`tabStock Entry Detail`.`idx` desc, `tabStock Entry Detail`.`is_finished_item` desc", + group_by="`tabStock Entry Detail`.`item_code`", ) @frappe.whitelist()