From c830bf6fc71ec40d4b5a6d7fbacf48c3d76ba179 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 12:54:06 +0530 Subject: [PATCH] fix: allow disassemble stock entry without work order (backport #51761) (#51836) fix: allow disassemble stock entry without work order (#51761) * fix: allow disassemble stock entry without work order * fix: use existing functionality to load fg item * chore: better dict update (cherry picked from commit 83919119f8b65a092a4b7cd97ec4833c99aefc51) Co-authored-by: Smit Vora --- .../stock/doctype/stock_entry/stock_entry.py | 49 +++++++++++++++++-- .../doctype/stock_entry/test_stock_entry.py | 45 +++++++++++++++++ 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index fbcc43231dd..5d973cdc3a0 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -724,7 +724,7 @@ class StockEntry(StockController, SubcontractingInwardController): "Subcontracting Return", ] - validate_for_manufacture = any([d.bom_no for d in self.get("items")]) + has_bom = any([d.bom_no for d in self.get("items")]) if self.purpose in source_mandatory and self.purpose not in target_mandatory: self.to_warehouse = None @@ -753,7 +753,7 @@ class StockEntry(StockController, SubcontractingInwardController): frappe.throw(_("Target warehouse is mandatory for row {0}").format(d.idx)) if self.purpose == "Manufacture": - if validate_for_manufacture: + if has_bom: if d.is_finished_item or d.is_scrap_item: d.s_warehouse = None if not d.t_warehouse: @@ -763,6 +763,17 @@ class StockEntry(StockController, SubcontractingInwardController): if not d.s_warehouse: frappe.throw(_("Source warehouse is mandatory for row {0}").format(d.idx)) + if self.purpose == "Disassemble": + if has_bom: + if d.is_finished_item: + d.t_warehouse = None + if not d.s_warehouse: + frappe.throw(_("Source warehouse is mandatory for row {0}").format(d.idx)) + else: + d.s_warehouse = None + if not d.t_warehouse: + frappe.throw(_("Target warehouse is mandatory for row {0}").format(d.idx)) + if cstr(d.s_warehouse) == cstr(d.t_warehouse) and self.purpose not in [ "Material Transfer for Manufacture", "Material Transfer", @@ -2162,9 +2173,12 @@ class StockEntry(StockController, SubcontractingInwardController): def get_items_for_disassembly(self): """Get items for Disassembly Order""" - if not self.work_order: - frappe.throw(_("The Work Order is mandatory for Disassembly Order")) + if self.work_order: + return self._add_items_for_disassembly_from_work_order() + return self._add_items_for_disassembly_from_bom() + + def _add_items_for_disassembly_from_work_order(self): items = self.get_items_from_manufacture_entry() s_warehouse = frappe.db.get_value("Work Order", self.work_order, "fg_warehouse") @@ -2196,6 +2210,23 @@ class StockEntry(StockController, SubcontractingInwardController): child_row.t_warehouse = row.s_warehouse child_row.is_finished_item = 0 if row.is_finished_item else 1 + def _add_items_for_disassembly_from_bom(self): + if not self.bom_no or not self.fg_completed_qty: + frappe.throw(_("BOM and Finished Good Quantity is mandatory for Disassembly")) + + # Raw Materials + item_dict = self.get_bom_raw_materials(self.fg_completed_qty) + + for item_row in item_dict.values(): + item_row["to_warehouse"] = self.to_warehouse + item_row["from_warehouse"] = "" + item_row["is_finished_item"] = 0 + + self.add_to_stock_entry_detail(item_dict) + + # Finished goods + self.load_items_from_bom() + def get_items_from_manufacture_entry(self): return frappe.get_all( "Stock Entry", @@ -2560,6 +2591,7 @@ class StockEntry(StockController, SubcontractingInwardController): expense_account = item.get("expense_account") if not expense_account: expense_account = frappe.get_cached_value("Company", self.company, "stock_adjustment_account") + args = { "to_warehouse": to_warehouse, "from_warehouse": "", @@ -2573,6 +2605,15 @@ class StockEntry(StockController, SubcontractingInwardController): "sample_quantity": item.get("sample_quantity"), } + if self.purpose == "Disassemble": + args.update( + { + "from_warehouse": self.from_warehouse, + "to_warehouse": "", + "qty": flt(self.fg_completed_qty), + } + ) + if ( self.work_order and self.pro_doc.has_batch_no diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index a0ac4f180b2..1bcfc567fda 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -2277,6 +2277,51 @@ class TestStockEntry(IntegrationTestCase): se.save() se.submit() + def test_disassemble_entry_without_wo(self): + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + + fg_item = make_item("_Disassemble Mobile", properties={"is_stock_item": 1}).name + rm_item1 = make_item("_Disassemble Temper Glass", properties={"is_stock_item": 1}).name + rm_item2 = make_item("_Disassemble Battery", properties={"is_stock_item": 1}).name + warehouse = "_Test Warehouse - _TC" + + # Stock up the FG item (what we'll disassemble) + make_stock_entry(item_code=fg_item, target=warehouse, qty=5, purpose="Material Receipt") + + bom_no = make_bom(item=fg_item, raw_materials=[rm_item1, rm_item2]).name + + se = make_stock_entry(item_code=fg_item, qty=1, purpose="Disassemble", do_not_save=True) + se.from_bom = 1 + se.use_multi_level_bom = 1 + se.bom_no = bom_no + se.fg_completed_qty = 1 + se.from_warehouse = warehouse + se.to_warehouse = warehouse + + se.get_items() + + # Verify FG as source (being consumed) + fg_items = [d for d in se.items if d.is_finished_item] + self.assertEqual(len(fg_items), 1) + self.assertEqual(fg_items[0].item_code, fg_item) + self.assertEqual(fg_items[0].qty, 1) + self.assertEqual(fg_items[0].s_warehouse, warehouse) + self.assertFalse(fg_items[0].t_warehouse) + + # Verify RM as target (being received) + rm_items = {d.item_code: d for d in se.items if not d.is_finished_item} + self.assertEqual(len(rm_items), 2) + self.assertIn(rm_item1, rm_items) + self.assertIn(rm_item2, rm_items) + self.assertEqual(rm_items[rm_item1].qty, 1) + self.assertEqual(rm_items[rm_item2].qty, 1) + self.assertEqual(rm_items[rm_item1].t_warehouse, warehouse) + self.assertFalse(rm_items[rm_item1].s_warehouse) + + se.calculate_rate_and_amount() + se.save() + se.submit() + @IntegrationTestCase.change_settings( "Stock Settings", {"sample_retention_warehouse": "_Test Warehouse 1 - _TC"} )