From b91af5b2b987e04da90c9376261d155b2a665a88 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Wed, 25 Mar 2026 10:43:28 +0530 Subject: [PATCH 01/26] fix: create source_stock_entry to refer to original manufacturing entry (cherry picked from commit d4baa9a74af097a47ffdb267e5a0073f4c5d6721) --- erpnext/stock/doctype/stock_entry/stock_entry.json | 10 ++++++++++ erpnext/stock/doctype/stock_entry/stock_entry.py | 1 + 2 files changed, 11 insertions(+) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json index 7c9dadb9a55..81cbad37c24 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.json +++ b/erpnext/stock/doctype/stock_entry/stock_entry.json @@ -24,6 +24,7 @@ "work_order", "subcontracting_order", "outgoing_stock_entry", + "source_stock_entry", "bom_info_section", "from_bom", "use_multi_level_bom", @@ -125,6 +126,15 @@ "options": "Stock Entry", "read_only": 1 }, + { + "depends_on": "eval:doc.purpose == 'Disassemble'", + "fieldname": "source_stock_entry", + "fieldtype": "Link", + "label": "Source Stock Entry (Manufacture)", + "no_copy": 1, + "options": "Stock Entry", + "print_hide": 1 + }, { "bold": 1, "fetch_from": "stock_entry_type.purpose", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 8078abb2848..8d5c1fe60dc 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -151,6 +151,7 @@ class StockEntry(StockController, SubcontractingInwardController): select_print_heading: DF.Link | None set_posting_time: DF.Check source_address_display: DF.TextEditor | None + source_stock_entry: DF.Link | None source_warehouse_address: DF.Link | None stock_entry_type: DF.Link subcontracting_inward_order: DF.Link | None From c9d03d049c57fdd41af00ea28fe292f511d41ec5 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Wed, 25 Mar 2026 16:49:58 +0530 Subject: [PATCH 02/26] fix: disassembly prompt with source stock entry field (cherry picked from commit 68e97808c566cbb34716f1b9dee4820f8d9c28a9) # Conflicts: # erpnext/manufacturing/doctype/work_order/work_order.py --- .../doctype/work_order/work_order.js | 63 ++++++++++++++++++- .../doctype/work_order/work_order.py | 28 +++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 7a964a76231..0e8729bf4ba 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -441,7 +441,7 @@ frappe.ui.form.on("Work Order", { make_disassembly_order(frm) { erpnext.work_order - .show_prompt_for_qty_input(frm, "Disassemble") + .show_disassembly_prompt(frm) .then((data) => { if (flt(data.qty) <= 0) { frappe.msgprint(__("Disassemble Qty cannot be less than or equal to 0.")); @@ -451,11 +451,14 @@ frappe.ui.form.on("Work Order", { work_order_id: frm.doc.name, purpose: "Disassemble", qty: data.qty, + source_stock_entry: data.source_stock_entry, }); }) .then((stock_entry) => { - frappe.model.sync(stock_entry); - frappe.set_route("Form", stock_entry.doctype, stock_entry.name); + if (stock_entry) { + frappe.model.sync(stock_entry); + frappe.set_route("Form", stock_entry.doctype, stock_entry.name); + } }); }, @@ -1002,6 +1005,60 @@ erpnext.work_order = { return flt(max, precision("qty")); }, + show_disassembly_prompt: function (frm) { + let max_qty = flt(frm.doc.produced_qty - frm.doc.disassembled_qty); + + let fields = [ + { + fieldtype: "Link", + label: __("Source Manufacture Entry"), + fieldname: "source_stock_entry", + options: "Stock Entry", + description: __("Optional. Select a specific manufacture entry to reverse."), + get_query: () => { + return { + filters: { + work_order: frm.doc.name, + purpose: "Manufacture", + docstatus: 1, + }, + }; + }, + onchange: async function () { + if (!frm.disassembly_prompt) return; + + let se_name = this.value; + let qty = max_qty; + if (se_name) { + qty = await frappe.xcall( + "erpnext.manufacturing.doctype.work_order.work_order.get_disassembly_available_qty", + { stock_entry_name: se_name } + ); + } + + frm.disassembly_prompt.set_value("qty", qty); + frm.disassembly_prompt.fields_dict.qty.set_description(__("Max: {0}", [qty])); + }, + }, + { + fieldtype: "Float", + label: __("Qty for {0}", [__("Disassemble")]), + fieldname: "qty", + description: __("Max: {0}", [max_qty]), + default: max_qty, + }, + ]; + + return new Promise((resolve, reject) => { + frm.disassembly_prompt = frappe.prompt( + fields, + (data) => resolve(data), + __("Disassemble"), + __("Create") + ); + }); + }, + show_prompt_for_qty_input: function (frm, purpose, qty, additional_transfer_entry) { let max = !additional_transfer_entry ? this.get_max_transferable_qty(frm, purpose) : qty; diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 72fafa03edd..9b6f95f25df 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -2376,6 +2376,7 @@ def make_stock_entry( qty: float | None = None, target_warehouse: str | None = None, is_additional_transfer_entry: bool = False, + source_stock_entry: str | None = None, ): work_order = frappe.get_doc("Work Order", work_order_id) if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"): @@ -2416,6 +2417,8 @@ def make_stock_entry( if purpose == "Disassemble": stock_entry.from_warehouse = work_order.fg_warehouse stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse + if source_stock_entry: + stock_entry.source_stock_entry = source_stock_entry stock_entry.set_stock_entry_type() stock_entry.is_additional_transfer_entry = is_additional_transfer_entry @@ -2429,7 +2432,32 @@ def make_stock_entry( @frappe.whitelist() +<<<<<<< HEAD def get_default_warehouse(company): +======= +def get_disassembly_available_qty(stock_entry_name: str) -> float: + se = frappe.db.get_value("Stock Entry", stock_entry_name, ["fg_completed_qty"], as_dict=True) + if not se: + return 0.0 + + already_disassembled = flt( + frappe.db.get_value( + "Stock Entry", + { + "source_stock_entry": stock_entry_name, + "purpose": "Disassemble", + "docstatus": 1, + }, + [{"SUM": "fg_completed_qty"}], + ) + ) + + return flt(se.fg_completed_qty) - already_disassembled + + +@frappe.whitelist() +def get_default_warehouse(company: str): +>>>>>>> 68e97808c5 (fix: disassembly prompt with source stock entry field) wip, fg, scrap = frappe.get_cached_value( "Company", company, ["default_wip_warehouse", "default_fg_warehouse", "default_scrap_warehouse"] ) From 5f67ef70bbc97c98b2ca3d9c220fa98270778371 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Wed, 25 Mar 2026 17:04:45 +0530 Subject: [PATCH 03/26] fix: set_query for source stock entry (cherry picked from commit b47dfacb3e10461b6cffff470391ce2fbe4624d0) --- erpnext/stock/doctype/stock_entry/stock_entry.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index dbfad27be26..13e38465681 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -36,6 +36,16 @@ frappe.ui.form.on("Stock Entry", { }; }); + frm.set_query("source_stock_entry", function () { + return { + filters: { + purpose: "Manufacture", + docstatus: 1, + work_order: frm.doc.work_order || undefined, + }, + }; + }); + frm.set_query("source_warehouse_address", function () { return { query: "erpnext.controllers.queries.get_warehouse_address", From 84a063a9bf5876a5b4f395262318fd6acb9f604f Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Wed, 25 Mar 2026 17:11:11 +0530 Subject: [PATCH 04/26] fix: custom button to disassemble manufactured stock entry with work order (cherry picked from commit b64f86148cc326541709e057684f4ab967a5050f) --- .../stock/doctype/stock_entry/stock_entry.js | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index 13e38465681..efaaf475570 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -340,6 +340,55 @@ frappe.ui.form.on("Stock Entry", { __("View") ); } + + if (frm.doc.purpose === "Manufacture" && frm.doc.work_order) { + frm.add_custom_button( + __("Disassemble"), + async function () { + let available_qty = await frappe.xcall( + "erpnext.manufacturing.doctype.work_order.work_order.get_disassembly_available_qty", + { stock_entry_name: frm.doc.name } + ); + frappe.prompt( + // fields + { + fieldtype: "Float", + label: __("Qty to Disassemble"), + fieldname: "qty", + default: available_qty, + description: __("Max: {0}", [available_qty]), + }, + // callback + async (data) => { + if (data.qty > available_qty) { + frappe.throw( + __("Cannot disassemble more than available quantity ({0})", [ + available_qty, + ]) + ); + } + + let stock_entry = await frappe.xcall( + "erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry", + { + work_order_id: frm.doc.work_order, + purpose: "Disassemble", + qty: data.qty, + source_stock_entry: frm.doc.name, + } + ); + if (stock_entry) { + frappe.model.sync(stock_entry); + frappe.set_route("Form", stock_entry.doctype, stock_entry.name); + } + }, + __("Disassemble"), + __("Create") + ); + }, + __("Create") + ); + } } if (frm.doc.docstatus === 0 && !frm.doc.subcontracting_inward_order) { From 1c4b2a7148527d7212c96be3b5eb3680d8571010 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Wed, 25 Mar 2026 18:27:50 +0530 Subject: [PATCH 05/26] fix: support creating disassembly (without link of WO) (cherry picked from commit dba82720b6ae5849034a1fbe510f71b2e203a3a7) --- .../stock/doctype/stock_entry/stock_entry.js | 77 +++++++++++++------ 1 file changed, 54 insertions(+), 23 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index efaaf475570..e4c1ffa4d26 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -242,6 +242,30 @@ frappe.ui.form.on("Stock Entry", { }); }, + source_stock_entry: async function (frm) { + if (!frm.doc.source_stock_entry || frm.doc.purpose !== "Disassemble") return; + + if (frm._via_source_stock_entry) { + frm.call({ + doc: frm.doc, + method: "get_items", + callback: function (r) { + if (!r.exc) refresh_field("items"); + }, + }); + frm._via_source_stock_entry = false; + return; + } + + let available_qty = await frappe.xcall( + "erpnext.manufacturing.doctype.work_order.work_order.get_disassembly_available_qty", + { stock_entry_name: frm.doc.source_stock_entry } + ); + + // triggers get_items() via its onchange + await frm.set_value("fg_completed_qty", available_qty); + }, + outgoing_stock_entry: function (frm) { frappe.call({ doc: frm.doc, @@ -341,7 +365,7 @@ frappe.ui.form.on("Stock Entry", { ); } - if (frm.doc.purpose === "Manufacture" && frm.doc.work_order) { + if (frm.doc.purpose === "Manufacture") { frm.add_custom_button( __("Disassemble"), async function () { @@ -350,7 +374,6 @@ frappe.ui.form.on("Stock Entry", { { stock_entry_name: frm.doc.name } ); frappe.prompt( - // fields { fieldtype: "Float", label: __("Qty to Disassemble"), @@ -358,28 +381,33 @@ frappe.ui.form.on("Stock Entry", { default: available_qty, description: __("Max: {0}", [available_qty]), }, - // callback async (data) => { - if (data.qty > available_qty) { - frappe.throw( - __("Cannot disassemble more than available quantity ({0})", [ - available_qty, - ]) + if (frm.doc.work_order) { + let stock_entry = await frappe.xcall( + "erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry", + { + work_order_id: frm.doc.work_order, + purpose: "Disassemble", + qty: data.qty, + source_stock_entry: frm.doc.name, + } ); - } - - let stock_entry = await frappe.xcall( - "erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry", - { - work_order_id: frm.doc.work_order, - purpose: "Disassemble", - qty: data.qty, - source_stock_entry: frm.doc.name, + if (stock_entry) { + frappe.model.sync(stock_entry); + frappe.set_route("Form", stock_entry.doctype, stock_entry.name); } - ); - if (stock_entry) { - frappe.model.sync(stock_entry); - frappe.set_route("Form", stock_entry.doctype, stock_entry.name); + } else { + let se = frappe.model.get_new_doc("Stock Entry"); + se.company = frm.doc.company; + se.stock_entry_type = "Disassemble"; + se.purpose = "Disassemble"; + se.source_stock_entry = frm.doc.name; + se.from_bom = frm.doc.from_bom; + se.bom_no = frm.doc.bom_no; + se.fg_completed_qty = data.qty; + frm._via_source_stock_entry = true; + + frappe.set_route("Form", "Stock Entry", se.name); } }, __("Disassemble"), @@ -1401,8 +1429,11 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle get_items() { var me = this; - if (this.frm.doc.work_order || this.frm.doc.bom_no) { - // if work order / bom is mentioned, get items + if ( + this.frm.doc.work_order || + this.frm.doc.bom_no || + (this.frm.doc.purpose === "Disassemble" && this.frm.doc.source_stock_entry) + ) { return this.frm.call({ doc: me.frm.doc, freeze: true, From 1237f9a0b17881f2f686bb715d1b7415094a4bf0 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Mon, 30 Mar 2026 15:46:37 +0530 Subject: [PATCH 06/26] fix: validate qty that can be disassembled from source stock entry. (cherry picked from commit 6394dead724b346deae30a6f2b8088d68cac0176) # Conflicts: # erpnext/manufacturing/doctype/work_order/work_order.py --- .../doctype/work_order/work_order.py | 25 +++++++++++-------- .../stock/doctype/stock_entry/stock_entry.py | 21 ++++++++++++++++ 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 9b6f95f25df..268918eca00 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -2433,24 +2433,27 @@ def make_stock_entry( @frappe.whitelist() <<<<<<< HEAD +<<<<<<< HEAD def get_default_warehouse(company): ======= def get_disassembly_available_qty(stock_entry_name: str) -> float: +======= +def get_disassembly_available_qty(stock_entry_name: str, current_se_name: str | None = None) -> float: +>>>>>>> 6394dead72 (fix: validate qty that can be disassembled from source stock entry.) se = frappe.db.get_value("Stock Entry", stock_entry_name, ["fg_completed_qty"], as_dict=True) if not se: return 0.0 - already_disassembled = flt( - frappe.db.get_value( - "Stock Entry", - { - "source_stock_entry": stock_entry_name, - "purpose": "Disassemble", - "docstatus": 1, - }, - [{"SUM": "fg_completed_qty"}], - ) - ) + filters = { + "source_stock_entry": stock_entry_name, + "purpose": "Disassemble", + "docstatus": 1, + } + + if current_se_name: + filters["name"] = ("!=", current_se_name) + + already_disassembled = flt(frappe.db.get_value("Stock Entry", filters, [{"SUM": "fg_completed_qty"}])) return flt(se.fg_completed_qty) - already_disassembled diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 8d5c1fe60dc..c1eeec3eaf4 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -247,6 +247,7 @@ class StockEntry(StockController, SubcontractingInwardController): self.validate_warehouse() self.validate_warehouse_of_sabb() self.validate_work_order() + self.validate_source_stock_entry() self.validate_bom() self.set_process_loss_qty() self.validate_purchase_order() @@ -847,6 +848,26 @@ class StockEntry(StockController, SubcontractingInwardController): elif self.purpose != "Material Transfer": self.work_order = None + def validate_source_stock_entry(self): + if not self.get("source_stock_entry"): + return + + from erpnext.manufacturing.doctype.work_order.work_order import get_disassembly_available_qty + + available_qty = get_disassembly_available_qty(self.source_stock_entry, self.name) + + if flt(self.fg_completed_qty) > available_qty: + frappe.throw( + _( + "Cannot disassemble {0} qty against Stock Entry {1}. Only {2} qty available to disassemble." + ).format( + self.fg_completed_qty, + self.source_stock_entry, + available_qty, + ), + title=_("Excess Disassembly"), + ) + def check_if_operations_completed(self): """Check if Time Sheets are completed against before manufacturing to capture operating costs.""" prod_order = frappe.get_doc("Work Order", self.work_order) From 4232640a8b2670d04ac585f6a7eb4dc53b32ddf8 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Mon, 30 Mar 2026 18:19:19 +0530 Subject: [PATCH 07/26] fix: add support to fetch items based on manufacture stock entry; fix how it's done from work order (cherry picked from commit 1ed0124ad7668cffe2aa858edaadf9a52faab313) --- .../stock/doctype/stock_entry/stock_entry.py | 178 ++++++++++++------ 1 file changed, 121 insertions(+), 57 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index c1eeec3eaf4..7284b50a3e7 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -29,7 +29,6 @@ from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals from erpnext.manufacturing.doctype.bom.bom import ( add_additional_cost, - get_bom_items_as_dict, get_op_cost_from_sub_assemblies, get_secondary_items_from_sub_assemblies, validate_bom_no, @@ -2269,45 +2268,108 @@ class StockEntry(StockController, SubcontractingInwardController): ) def get_items_for_disassembly(self): - """Get items for Disassembly Order""" + """Get items for Disassembly Order. + + Priority: + 1. From a specific Manufacture Stock Entry (exact reversal) + 2. From Work Order required_items (reflects WO changes) + 3. From BOM (standalone disassembly) + """ + + if self.get("source_stock_entry"): + return self._add_items_for_disassembly_from_stock_entry() 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_stock_entry(self): + source_fg_qty = frappe.db.get_value("Stock Entry", self.source_stock_entry, "fg_completed_qty") + if not source_fg_qty: + frappe.throw( + _("Source Stock Entry {0} has no finished goods quantity").format(self.source_stock_entry) + ) + + scale_factor = flt(self.fg_completed_qty) / flt(source_fg_qty) + + for source_row in self.get_items_from_manufacture_stock_entry(self.source_stock_entry): + if source_row.is_finished_item: + qty = flt(self.fg_completed_qty) + s_warehouse = self.from_warehouse or source_row.t_warehouse + t_warehouse = "" + else: + qty = flt(source_row.qty * scale_factor) + s_warehouse = "" + t_warehouse = self.to_warehouse or source_row.s_warehouse + + use_serial_batch_fields = 1 if (source_row.batch_no or source_row.serial_no) else 0 + + self.append( + "items", + { + "item_code": source_row.item_code, + "item_name": source_row.item_name, + "description": source_row.description, + "stock_uom": source_row.stock_uom, + "uom": source_row.uom, + "conversion_factor": source_row.conversion_factor, + "basic_rate": source_row.basic_rate, + "qty": qty, + "s_warehouse": s_warehouse, + "t_warehouse": t_warehouse, + "is_finished_item": source_row.is_finished_item, + "against_stock_entry": self.source_stock_entry, + "ste_detail": source_row.name, + "batch_no": source_row.batch_no, + "serial_no": source_row.serial_no, + "use_serial_batch_fields": use_serial_batch_fields, + }, + ) + def _add_items_for_disassembly_from_work_order(self): - items = self.get_items_from_manufacture_entry() + wo = frappe.get_doc("Work Order", self.work_order) - s_warehouse = frappe.db.get_value("Work Order", self.work_order, "fg_warehouse") + if not wo.required_items: + return self._add_items_for_disassembly_from_bom() - items_dict = get_bom_items_as_dict( - self.bom_no, - self.company, - self.fg_completed_qty, - fetch_exploded=self.use_multi_level_bom, - fetch_qty_in_stock_uom=False, + scale_factor = flt(self.fg_completed_qty) / flt(wo.qty) if flt(wo.qty) else 0 + + # RMs + for ri in wo.required_items: + self.append( + "items", + { + "item_code": ri.item_code, + "item_name": ri.item_name, + "description": ri.description, + "qty": flt(ri.required_qty * scale_factor), + "stock_uom": ri.stock_uom, + "uom": ri.stock_uom, + "conversion_factor": 1, + "t_warehouse": ri.source_warehouse or wo.source_warehouse or self.to_warehouse, + "s_warehouse": "", + "is_finished_item": 0, + }, + ) + + # FG + self.append( + "items", + { + "item_code": wo.production_item, + "item_name": wo.item_name, + "description": wo.description, + "qty": flt(self.fg_completed_qty), + "stock_uom": wo.stock_uom, + "uom": wo.stock_uom, + "conversion_factor": 1, + "s_warehouse": self.from_warehouse or wo.fg_warehouse, + "t_warehouse": "", + "is_finished_item": 1, + }, ) - for row in items: - child_row = self.append("items", {}) - for field, value in row.items(): - if value is not None: - child_row.set(field, value) - - # update qty and amount from BOM items - bom_items = items_dict.get(row.item_code) - if bom_items: - child_row.qty = bom_items.get("qty", child_row.qty) - child_row.amount = bom_items.get("amount", child_row.amount) - - if row.is_finished_item: - child_row.qty = self.fg_completed_qty - - child_row.s_warehouse = (self.from_warehouse or s_warehouse) if row.is_finished_item else "" - 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")) @@ -2325,34 +2387,36 @@ class StockEntry(StockController, SubcontractingInwardController): # Finished goods self.load_items_from_bom() - def get_items_from_manufacture_entry(self): - return frappe.get_all( - "Stock Entry", - fields=[ - "`tabStock Entry Detail`.`item_code`", - "`tabStock Entry Detail`.`item_name`", - "`tabStock Entry Detail`.`description`", - {"SUM": "`tabStock Entry Detail`.`qty`", "as": "qty"}, - {"SUM": "`tabStock Entry Detail`.`transfer_qty`", "as": "transfer_qty"}, - "`tabStock Entry Detail`.`stock_uom`", - "`tabStock Entry Detail`.`uom`", - "`tabStock Entry Detail`.`basic_rate`", - "`tabStock Entry Detail`.`conversion_factor`", - "`tabStock Entry Detail`.`is_finished_item`", - "`tabStock Entry Detail`.`batch_no`", - "`tabStock Entry Detail`.`serial_no`", - "`tabStock Entry Detail`.`s_warehouse`", - "`tabStock Entry Detail`.`t_warehouse`", - "`tabStock Entry Detail`.`use_serial_batch_fields`", - ], - filters=[ - ["Stock Entry", "purpose", "=", "Manufacture"], - ["Stock Entry", "work_order", "=", self.work_order], - ["Stock Entry", "docstatus", "=", 1], - ["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`", + def get_items_from_manufacture_stock_entry(self, stock_entry): + SE = frappe.qb.DocType("Stock Entry") + SED = frappe.qb.DocType("Stock Entry Detail") + + return ( + frappe.qb.from_(SED) + .join(SE) + .on(SED.parent == SE.name) + .select( + SED.name, + SED.item_code, + SED.item_name, + SED.description, + SED.qty, + SED.transfer_qty, + SED.stock_uom, + SED.uom, + SED.basic_rate, + SED.conversion_factor, + SED.is_finished_item, + SED.batch_no, + SED.serial_no, + SED.use_serial_batch_fields, + SED.s_warehouse, + SED.t_warehouse, + ) + .where(SE.name == stock_entry) + .where(SE.docstatus == 1) + .orderby(SED.idx) + .run(as_dict=True) ) @frappe.whitelist() From eead8d6d8c219daa21c5742efe9255fb80833a9e Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Mon, 30 Mar 2026 18:19:55 +0530 Subject: [PATCH 08/26] fix: auto-set source_stock_entry (cherry picked from commit 2e4e8bcaa7566283fe8d9db3bf9a50cfb1f1b68e) --- erpnext/stock/doctype/stock_entry/stock_entry.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 7284b50a3e7..b878f097e60 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2276,6 +2276,21 @@ class StockEntry(StockController, SubcontractingInwardController): 3. From BOM (standalone disassembly) """ + # Auto-set source_stock_entry if WO has exactly one manufacture entry + if not self.get("source_stock_entry") and self.work_order: + manufacture_entries = frappe.get_all( + "Stock Entry", + filters={ + "work_order": self.work_order, + "purpose": "Manufacture", + "docstatus": 1, + }, + pluck="name", + limit_page_length=2, + ) + if len(manufacture_entries) == 1: + self.source_stock_entry = manufacture_entries[0] + if self.get("source_stock_entry"): return self._add_items_for_disassembly_from_stock_entry() From 919cbd5c0229fdde896afad5dbf58fbe2da65556 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 31 Mar 2026 07:44:25 +0530 Subject: [PATCH 09/26] fix: correct warehouse preference for disassemble (cherry picked from commit d3d6b5c6608b9a21db381160987c2b5fa17f2229) --- erpnext/stock/doctype/stock_entry/stock_entry.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index b878f097e60..2068b0cf514 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2362,7 +2362,8 @@ class StockEntry(StockController, SubcontractingInwardController): "stock_uom": ri.stock_uom, "uom": ri.stock_uom, "conversion_factor": 1, - "t_warehouse": ri.source_warehouse or wo.source_warehouse or self.to_warehouse, + # manufacture transfers RMs from WIP (not source warehouse) + "t_warehouse": self.to_warehouse or wo.wip_warehouse, "s_warehouse": "", "is_finished_item": 0, }, From ff104edf1289dbf9876db11d8bdc95ecd9fb8d3b Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 31 Mar 2026 09:27:19 +0530 Subject: [PATCH 10/26] fix: set serial and batch from source stock entry - on disassemble (cherry picked from commit 13b019ab8efe75cff787b400cbbcb9b5c9677bfb) --- .../stock/doctype/stock_entry/stock_entry.py | 107 +++++++++++++----- 1 file changed, 81 insertions(+), 26 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 2068b0cf514..3aee2be095a 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -331,6 +331,58 @@ class StockEntry(StockController, SubcontractingInwardController): if self.purpose != "Disassemble": return + if self.get("source_stock_entry"): + self._set_serial_batch_for_disassembly_from_stock_entry() + else: + self._set_serial_batch_for_disassembly_from_available_materials() + + def _set_serial_batch_for_disassembly_from_stock_entry(self): + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + get_voucher_wise_serial_batch_from_bundle, + ) + + source_fg_qty = flt(frappe.db.get_value("Stock Entry", self.source_stock_entry, "fg_completed_qty")) + scale_factor = flt(self.fg_completed_qty) / source_fg_qty if source_fg_qty else 0 + + bundle_data = get_voucher_wise_serial_batch_from_bundle(voucher_no=[self.source_stock_entry]) + source_rows_by_name = { + r.name: r for r in self.get_items_from_manufacture_stock_entry(self.source_stock_entry) + } + + for row in self.items: + if not row.ste_detail: + continue + + source_row = source_rows_by_name.get(row.ste_detail) + if not source_row: + continue + + source_warehouse = source_row.s_warehouse or source_row.t_warehouse + key = (source_row.item_code, source_warehouse, self.source_stock_entry) + source_bundle = bundle_data.get(key, {}) + + batches = defaultdict(float) + serial_nos = [] + + if source_bundle.get("batch_nos"): + qty_remaining = row.transfer_qty + for batch_no, batch_qty in source_bundle["batch_nos"].items(): + if qty_remaining <= 0: + break + alloc = min(flt(batch_qty) * scale_factor, qty_remaining) + batches[batch_no] = alloc + qty_remaining -= alloc + elif source_row.batch_no: + batches[source_row.batch_no] = row.transfer_qty + + if source_bundle.get("serial_nos"): + serial_nos = get_serial_nos(source_bundle["serial_nos"])[: int(row.transfer_qty)] + elif source_row.serial_no: + serial_nos = get_serial_nos(source_row.serial_no)[: int(row.transfer_qty)] + + self._set_serial_batch_bundle_for_disassembly_row(row, serial_nos, batches) + + def _set_serial_batch_for_disassembly_from_available_materials(self): available_materials = get_available_materials(self.work_order, self) for row in self.items: warehouse = row.s_warehouse or row.t_warehouse @@ -356,33 +408,37 @@ class StockEntry(StockController, SubcontractingInwardController): if materials.serial_nos: serial_nos = materials.serial_nos[: int(row.transfer_qty)] - if not serial_nos and not batches: - continue + self._set_serial_batch_bundle_for_disassembly_row(row, serial_nos, batches) - bundle_doc = SerialBatchCreation( - { - "item_code": row.item_code, - "warehouse": warehouse, - "posting_datetime": get_combine_datetime(self.posting_date, self.posting_time), - "voucher_type": self.doctype, - "voucher_no": self.name, - "voucher_detail_no": row.name, - "qty": row.transfer_qty, - "type_of_transaction": "Inward" if row.t_warehouse else "Outward", - "company": self.company, - "do_not_submit": True, - } - ).make_serial_and_batch_bundle(serial_nos=serial_nos, batch_nos=batches) + def _set_serial_batch_bundle_for_disassembly_row(self, row, serial_nos, batches): + if not serial_nos and not batches: + return - row.serial_and_batch_bundle = bundle_doc.name - row.use_serial_batch_fields = 0 + warehouse = row.s_warehouse or row.t_warehouse + bundle_doc = SerialBatchCreation( + { + "item_code": row.item_code, + "warehouse": warehouse, + "posting_datetime": get_combine_datetime(self.posting_date, self.posting_time), + "voucher_type": self.doctype, + "voucher_no": self.name, + "voucher_detail_no": row.name, + "qty": row.transfer_qty, + "type_of_transaction": "Inward" if row.t_warehouse else "Outward", + "company": self.company, + "do_not_submit": True, + } + ).make_serial_and_batch_bundle(serial_nos=serial_nos, batch_nos=batches) - row.db_set( - { - "serial_and_batch_bundle": bundle_doc.name, - "use_serial_batch_fields": 0, - } - ) + row.serial_and_batch_bundle = bundle_doc.name + row.use_serial_batch_fields = 0 + + row.db_set( + { + "serial_and_batch_bundle": bundle_doc.name, + "use_serial_batch_fields": 0, + } + ) def on_submit(self): self.set_serial_batch_for_disassembly() @@ -2336,8 +2392,7 @@ class StockEntry(StockController, SubcontractingInwardController): "is_finished_item": source_row.is_finished_item, "against_stock_entry": self.source_stock_entry, "ste_detail": source_row.name, - "batch_no": source_row.batch_no, - "serial_no": source_row.serial_no, + # batch and serial bundles built on submit "use_serial_batch_fields": use_serial_batch_fields, }, ) From 195a10efb3577a1de72daa25cd4f72d7f3d4d306 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 31 Mar 2026 15:01:46 +0530 Subject: [PATCH 11/26] test: disassembly from wo (cherry picked from commit 342a14d3403de36a09f5df3e3739489e9a1ab879) --- .../doctype/work_order/test_work_order.py | 24 +++---------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 81ee66ecb4f..7fec4314bca 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2419,7 +2419,7 @@ class TestWorkOrder(ERPNextTestSuite): stock_entry.submit() - def test_disassembly_order_with_qty_behavior(self): + def test_disassembly_order_with_qty_from_wo_behavior(self): # Create raw material and FG item raw_item = make_item("Test Raw for Disassembly", {"is_stock_item": 1}).name fg_item = make_item("Test FG for Disassembly", {"is_stock_item": 1}).name @@ -2459,27 +2459,9 @@ class TestWorkOrder(ERPNextTestSuite): se_for_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty)) se_for_manufacture.submit() - # Simulate a disassembly stock entry + # Disassembly via WO required_items path (no source_stock_entry) disassemble_qty = 4 stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty)) - stock_entry.append( - "items", - { - "item_code": fg_item, - "qty": disassemble_qty, - "s_warehouse": wo.fg_warehouse, - }, - ) - - for bom_item in bom.items: - stock_entry.append( - "items", - { - "item_code": bom_item.item_code, - "qty": (bom_item.qty / bom.quantity) * disassemble_qty, - "t_warehouse": wo.source_warehouse, - }, - ) wo.reload() stock_entry.save() @@ -2494,7 +2476,7 @@ class TestWorkOrder(ERPNextTestSuite): f"Expected FG qty {disassemble_qty}, found {finished_good_entry.qty}", ) - # Assert raw materials + # Assert raw materials - qty scaled from WO required_items for item in stock_entry.items: if item.item_code == fg_item: continue From 4c0ebee15be6bc8d4ac131eb725f097017f5a2dd Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 31 Mar 2026 15:14:56 +0530 Subject: [PATCH 12/26] test: disassemble with source stock entry reference (cherry picked from commit 6988e2cbbc00221c117a07c0a45edac5d97b34ab) --- .../doctype/work_order/test_work_order.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 7fec4314bca..b62b4194f72 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2500,6 +2500,30 @@ class TestWorkOrder(ERPNextTestSuite): f"Work Order disassembled_qty mismatch: expected {disassemble_qty}, got {wo.disassembled_qty}", ) + # Second disassembly: explicitly linked to manufacture SE — verifies SE-linked path + # (first disassembly auto-set source_stock_entry since there's only one manufacture entry) + disassemble_qty_2 = 2 + stock_entry_2 = frappe.get_doc( + make_stock_entry( + wo.name, "Disassemble", disassemble_qty_2, source_stock_entry=se_for_manufacture.name + ) + ) + stock_entry_2.save() + stock_entry_2.submit() + + # All rows must trace back to se_for_manufacture + for item in stock_entry_2.items: + self.assertEqual(item.against_stock_entry, se_for_manufacture.name) + self.assertTrue(item.ste_detail) + + # RM qty scaled from the manufacture SE rows + rm_row = next((i for i in stock_entry_2.items if i.item_code == raw_item), None) + expected_rm_qty = (bom.items[0].qty / bom.quantity) * disassemble_qty_2 + self.assertAlmostEqual(rm_row.qty, expected_rm_qty, places=3) + + wo.reload() + self.assertEqual(wo.disassembled_qty, disassemble_qty + disassemble_qty_2) + def test_disassembly_with_multiple_manufacture_entries(self): """ Test that disassembly does not create duplicate items when manufacturing From 8444778f74e38c10eedb80afb7832b56a57cab99 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 31 Mar 2026 15:18:42 +0530 Subject: [PATCH 13/26] test: additional items in stock entry considered with disassembly (cherry picked from commit d32977e3a9d0eea3813a3a9368146df8c69ba995) --- .../doctype/work_order/test_work_order.py | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index b62b4194f72..7ff5b0fee82 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2639,17 +2639,16 @@ class TestWorkOrder(ERPNextTestSuite): def test_disassembly_with_additional_rm_not_in_bom(self): """ - Test that disassembly correctly handles additional raw materials that were - manually added during manufacturing (not part of the BOM). + Test that SE-linked disassembly includes additional raw materials + that were manually added during manufacturing (not part of the BOM). Scenario: 1. Create Work Order for 10 units with 2 raw materials in BOM 2. Transfer raw materials for manufacture 3. Manufacture in 2 parts (3 units, then 7 units) 4. In each manufacture entry, manually add an extra consumable item - (not in BOM) in proportion to the manufactured qty - 5. Create Disassembly for 4 units - 6. Verify that the additional RM is included in disassembly with proportional qty + 5. Disassemble 3 units linked to first manufacture entry + 6. Verify additional RM is included with correct proportional qty from SE1 """ from erpnext.stock.doctype.stock_entry.test_stock_entry import ( make_stock_entry as make_stock_entry_test_record, @@ -2685,9 +2684,8 @@ class TestWorkOrder(ERPNextTestSuite): se_for_material_transfer.save() se_for_material_transfer.submit() - # First Manufacture Entry - 3 units + # First Manufacture Entry - 3 units with additional RM se_manufacture1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3)) - # Additional RM se_manufacture1.append( "items", { @@ -2700,9 +2698,8 @@ class TestWorkOrder(ERPNextTestSuite): se_manufacture1.save() se_manufacture1.submit() - # Second Manufacture Entry - 7 units + # Second Manufacture Entry - 7 units with additional RM se_manufacture2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 7)) - # AAdditional RM se_manufacture2.append( "items", { @@ -2718,13 +2715,15 @@ class TestWorkOrder(ERPNextTestSuite): wo.reload() self.assertEqual(wo.produced_qty, 10) - # Disassembly for 4 units - disassemble_qty = 4 - stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty)) + # Disassemble 3 units linked to first manufacture entry + disassemble_qty = 3 + stock_entry = frappe.get_doc( + make_stock_entry(wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture1.name) + ) stock_entry.save() stock_entry.submit() - # No duplicate + # No duplicates item_counts = {} for item in stock_entry.items: item_code = item.item_code @@ -2737,16 +2736,15 @@ class TestWorkOrder(ERPNextTestSuite): f"Found duplicate items in disassembly stock entry: {duplicates}", ) - # Additional RM qty + # Additional RM should be included — qty proportional to SE1 (3 units -> 3 additional RM) additional_rm_row = next((i for i in stock_entry.items if i.item_code == additional_rm), None) self.assertIsNotNone( additional_rm_row, f"Additional raw material {additional_rm} not found in disassembly", ) - # intentional full reversal as not part of BOM - # eg: dies or consumables used during manufacturing - expected_additional_rm_qty = 3 + 7 + # SE1 had 3 additional RM for 3 manufactured units, disassembling all 3 + expected_additional_rm_qty = 3 self.assertAlmostEqual( additional_rm_row.qty, expected_additional_rm_qty, @@ -2754,7 +2752,7 @@ class TestWorkOrder(ERPNextTestSuite): msg=f"Additional RM qty mismatch: expected {expected_additional_rm_qty}, got {additional_rm_row.qty}", ) - # RM qty + # BOM RM qty — scaled from SE1's rows 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) @@ -2770,6 +2768,7 @@ class TestWorkOrder(ERPNextTestSuite): 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) + # FG + 2 BOM RM + 1 additional RM = 4 items expected_items = 4 self.assertEqual( len(stock_entry.items), @@ -2777,6 +2776,11 @@ class TestWorkOrder(ERPNextTestSuite): f"Expected {expected_items} items, found {len(stock_entry.items)}", ) + # Verify traceability + for item in stock_entry.items: + self.assertEqual(item.against_stock_entry, se_manufacture1.name) + self.assertTrue(item.ste_detail) + 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) From e1a4d9fab44b84a2c4a85763b10b36b7adff62a0 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 31 Mar 2026 17:52:58 +0530 Subject: [PATCH 14/26] test: disassembly of items with batch and serial numbers (cherry picked from commit 1693698fed085fdc5a20b9bdd23a0d5ae96af195) --- .../doctype/work_order/test_work_order.py | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 7ff5b0fee82..ac9ce2091a9 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2781,6 +2781,206 @@ class TestWorkOrder(ERPNextTestSuite): self.assertEqual(item.against_stock_entry, se_manufacture1.name) self.assertTrue(item.ste_detail) + def test_disassembly_auto_sets_source_stock_entry(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import ( + make_stock_entry as make_stock_entry_test_record, + ) + + raw_item = make_item("Test Raw Auto Set Disassembly", {"is_stock_item": 1}).name + fg_item = make_item("Test FG Auto Set Disassembly", {"is_stock_item": 1}).name + bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item], rm_qty=2) + + wo = make_wo_order_test_record(production_item=fg_item, qty=5, bom_no=bom.name, status="Not Started") + + make_stock_entry_test_record( + item_code=raw_item, purpose="Material Receipt", target=wo.wip_warehouse, qty=50, basic_rate=100 + ) + + se_transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty)) + for item in se_transfer.items: + item.s_warehouse = wo.wip_warehouse + se_transfer.save() + se_transfer.submit() + + se_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty)) + se_manufacture.submit() + + # Disassemble without specifying source_stock_entry + stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", 3)) + stock_entry.save() + + # source_stock_entry should be auto-set since only one manufacture entry + self.assertEqual(stock_entry.source_stock_entry, se_manufacture.name) + + # All items should have against_stock_entry linked + for item in stock_entry.items: + self.assertEqual(item.against_stock_entry, se_manufacture.name) + self.assertTrue(item.ste_detail) + + stock_entry.submit() + + def test_disassembly_batch_tracked_items(self): + from erpnext.stock.doctype.batch.batch import make_batch + from erpnext.stock.doctype.stock_entry.test_stock_entry import ( + make_stock_entry as make_stock_entry_test_record, + ) + + wip_wh = "_Test Warehouse - _TC" + + rm_item = make_item( + "Test Batch RM for Disassembly SB", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TBRD-RM-.###", + }, + ).name + fg_item = make_item( + "Test Batch FG for Disassembly SB", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TBRD-FG-.###", + }, + ).name + + bom = make_bom(item=fg_item, quantity=1, raw_materials=[rm_item], rm_qty=2) + wo = make_wo_order_test_record( + production_item=fg_item, + qty=6, + bom_no=bom.name, + skip_transfer=1, + source_warehouse=wip_wh, + status="Not Started", + ) + + # Stock up RM — batch auto-created on receipt + rm_receipt = make_stock_entry_test_record( + item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=18, basic_rate=100 + ) + rm_bundle = frappe.db.get_value( + "Stock Entry Detail", {"parent": rm_receipt.name, "item_code": rm_item}, "serial_and_batch_bundle" + ) + rm_batch = get_batch_from_bundle(rm_bundle) + + # Pre-create FG batch so we can assign it to the manufacture row + fg_batch = make_batch(frappe._dict(item=fg_item)) + + # Manufacture 3 units: assign batches explicitly on RM and FG rows + se_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3)) + for row in se_manufacture.items: + if row.item_code == rm_item: + row.batch_no = rm_batch + row.use_serial_batch_fields = 1 + elif row.item_code == fg_item: + row.batch_no = fg_batch + row.use_serial_batch_fields = 1 + se_manufacture.save() + se_manufacture.submit() + + # Disassemble 2 of the 3 manufactured units linked to the manufacture SE + disassemble_qty = 2 + stock_entry = frappe.get_doc( + make_stock_entry(wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture.name) + ) + stock_entry.save() + stock_entry.submit() + + # FG row: consuming batch from FG warehouse — bundle must use FG batch + fg_row = next((i for i in stock_entry.items if i.item_code == fg_item), None) + self.assertIsNotNone(fg_row) + self.assertTrue(fg_row.serial_and_batch_bundle, "FG row must have a serial_and_batch_bundle") + self.assertEqual(get_batch_from_bundle(fg_row.serial_and_batch_bundle), fg_batch) + + # RM row: returning to WIP warehouse — bundle must use RM batch + rm_row = next((i for i in stock_entry.items if i.item_code == rm_item), None) + self.assertIsNotNone(rm_row) + self.assertTrue(rm_row.serial_and_batch_bundle, "RM row must have a serial_and_batch_bundle") + self.assertEqual(get_batch_from_bundle(rm_row.serial_and_batch_bundle), rm_batch) + + # RM qty: 2 FG disassembled x 2 RM per FG = 4 + self.assertAlmostEqual(rm_row.qty, 4.0, places=3) + + def test_disassembly_serial_tracked_items(self): + from frappe.model.naming import make_autoname + + from erpnext.stock.doctype.stock_entry.test_stock_entry import ( + make_stock_entry as make_stock_entry_test_record, + ) + + wip_wh = "_Test Warehouse - _TC" + + rm_item = make_item( + "Test Serial RM for Disassembly SB", + {"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TSRD-RM-.####"}, + ).name + fg_item = make_item( + "Test Serial FG for Disassembly SB", + {"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TSRD-FG-.####"}, + ).name + + bom = make_bom(item=fg_item, quantity=1, raw_materials=[rm_item], rm_qty=2) + wo = make_wo_order_test_record( + production_item=fg_item, + qty=6, + bom_no=bom.name, + skip_transfer=1, + source_warehouse=wip_wh, + status="Not Started", + ) + + # Stock up 6 RM serials — series auto-generates them + rm_receipt = make_stock_entry_test_record( + item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100 + ) + rm_bundle = frappe.db.get_value( + "Stock Entry Detail", {"parent": rm_receipt.name, "item_code": rm_item}, "serial_and_batch_bundle" + ) + all_rm_serials = get_serial_nos_from_bundle(rm_bundle) + self.assertEqual(len(all_rm_serials), 6) + + # Pre-generate 3 FG serial numbers + series = frappe.db.get_value("Item", fg_item, "serial_no_series") + fg_serials = [make_autoname(series) for _ in range(3)] + + # Manufacture 3 units: consume first 6 RM serials, produce 3 FG serials + se_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3)) + for row in se_manufacture.items: + if row.item_code == rm_item: + row.serial_no = "\n".join(all_rm_serials) + row.use_serial_batch_fields = 1 + elif row.item_code == fg_item: + row.serial_no = "\n".join(fg_serials) + row.use_serial_batch_fields = 1 + se_manufacture.save() + se_manufacture.submit() + + # Disassemble 2 of the 3 manufactured units + disassemble_qty = 2 + stock_entry = frappe.get_doc( + make_stock_entry(wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture.name) + ) + stock_entry.save() + stock_entry.submit() + + # FG row: 2 serials consumed — must be a subset of the manufacture FG serials + fg_row = next((i for i in stock_entry.items if i.item_code == fg_item), None) + self.assertIsNotNone(fg_row) + self.assertTrue(fg_row.serial_and_batch_bundle, "FG row must have a serial_and_batch_bundle") + fg_dasm_serials = get_serial_nos_from_bundle(fg_row.serial_and_batch_bundle) + self.assertEqual(len(fg_dasm_serials), disassemble_qty) + self.assertTrue(set(fg_dasm_serials).issubset(set(fg_serials))) + + # RM row: 4 serials returned (2 FG x 2 RM each) — must be a subset of manufacture RM serials + rm_row = next((i for i in stock_entry.items if i.item_code == rm_item), None) + self.assertIsNotNone(rm_row) + self.assertTrue(rm_row.serial_and_batch_bundle, "RM row must have a serial_and_batch_bundle") + rm_dasm_serials = get_serial_nos_from_bundle(rm_row.serial_and_batch_bundle) + self.assertEqual(len(rm_dasm_serials), disassemble_qty * 2) + self.assertTrue(set(rm_dasm_serials).issubset(set(all_rm_serials))) + 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) From d50279b718c195ba6f2735d448bc4736f3d4bfcd Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Wed, 1 Apr 2026 15:48:25 +0530 Subject: [PATCH 15/26] fix: handle disassembly for secondary / scrap items (cherry picked from commit 2be8313819c7811afc3758a037457d46f3cae244) --- .../stock/doctype/stock_entry/stock_entry.py | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 3aee2be095a..046e325375a 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -864,7 +864,7 @@ class StockEntry(StockController, SubcontractingInwardController): if self.purpose == "Disassemble": if has_bom: - if d.is_finished_item: + if d.is_finished_item or d.type or d.is_legacy_scrap_item: d.t_warehouse = None if not d.s_warehouse: frappe.throw(_("Source warehouse is mandatory for row {0}").format(d.idx)) @@ -2369,10 +2369,16 @@ class StockEntry(StockController, SubcontractingInwardController): qty = flt(self.fg_completed_qty) s_warehouse = self.from_warehouse or source_row.t_warehouse t_warehouse = "" - else: + elif source_row.s_warehouse: + # RM: was consumed FROM s_warehouse → return TO s_warehouse qty = flt(source_row.qty * scale_factor) s_warehouse = "" t_warehouse = self.to_warehouse or source_row.s_warehouse + else: + # Scrap/secondary: was produced TO t_warehouse → take FROM t_warehouse + qty = flt(source_row.qty * scale_factor) + s_warehouse = source_row.t_warehouse + t_warehouse = "" use_serial_batch_fields = 1 if (source_row.batch_no or source_row.serial_no) else 0 @@ -2390,6 +2396,9 @@ class StockEntry(StockController, SubcontractingInwardController): "s_warehouse": s_warehouse, "t_warehouse": t_warehouse, "is_finished_item": source_row.is_finished_item, + "type": source_row.type, + "is_legacy_scrap_item": source_row.is_legacy_scrap_item, + "bom_secondary_item": source_row.bom_secondary_item, "against_stock_entry": self.source_stock_entry, "ste_detail": source_row.name, # batch and serial bundles built on submit @@ -2424,6 +2433,16 @@ class StockEntry(StockController, SubcontractingInwardController): }, ) + # Secondary/Scrap items + secondary_items = self.get_secondary_items(self.fg_completed_qty) + if secondary_items: + scrap_warehouse = wo.scrap_warehouse or self.from_warehouse or wo.fg_warehouse + for item in secondary_items.values(): + item["from_warehouse"] = scrap_warehouse + item["to_warehouse"] = "" + item["is_finished_item"] = 0 + self.add_to_stock_entry_detail(secondary_items, bom_no=self.bom_no) + # FG self.append( "items", @@ -2455,6 +2474,23 @@ class StockEntry(StockController, SubcontractingInwardController): self.add_to_stock_entry_detail(item_dict) + # Secondary/Scrap items (reverse of what set_secondary_items does for Manufacture) + secondary_items = self.get_secondary_items(self.fg_completed_qty) + if secondary_items: + scrap_warehouse = self.from_warehouse + if self.work_order: + wo_values = frappe.db.get_value( + "Work Order", self.work_order, ["scrap_warehouse", "fg_warehouse"], as_dict=True + ) + scrap_warehouse = wo_values.scrap_warehouse or scrap_warehouse or wo_values.fg_warehouse + + for item in secondary_items.values(): + item["from_warehouse"] = scrap_warehouse + item["to_warehouse"] = "" + item["is_finished_item"] = 0 + + self.add_to_stock_entry_detail(secondary_items, bom_no=self.bom_no) + # Finished goods self.load_items_from_bom() @@ -2478,6 +2514,9 @@ class StockEntry(StockController, SubcontractingInwardController): SED.basic_rate, SED.conversion_factor, SED.is_finished_item, + SED.type, + SED.is_legacy_scrap_item, + SED.bom_secondary_item, SED.batch_no, SED.serial_no, SED.use_serial_batch_fields, From 901e6267293d3c02a0c98ea21b1047143d063a19 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Wed, 1 Apr 2026 16:17:00 +0530 Subject: [PATCH 16/26] test: disassembly for scrap / secondary item (cherry picked from commit a6d41151ff33e99d8e5c189ee90b4c3707537956) --- .../doctype/work_order/test_work_order.py | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index ac9ce2091a9..77a9acdf02e 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2527,7 +2527,8 @@ class TestWorkOrder(ERPNextTestSuite): 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). + is done in multiple batches (multiple manufacture stock entries), including + secondary/scrap items. Scenario: 1. Create Work Order for 10 units @@ -2536,11 +2537,19 @@ class TestWorkOrder(ERPNextTestSuite): 4. Create Disassembly for 4 units 5. Verify no duplicate items in the disassembly stock entry """ - # Create RM and FG item + # Create RM, scrap 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 + scrap_item = make_item("Test Scrap for Multi Batch Disassembly", {"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) + bom = make_bom( + item=fg_item, + quantity=1, + raw_materials=[raw_item1, raw_item2], + rm_qty=2, + scrap_items=[scrap_item], + scrap_qty=10, + ) # Create WO wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started") @@ -2615,7 +2624,7 @@ class TestWorkOrder(ERPNextTestSuite): f"Found duplicate items in disassembly stock entry: {duplicates}", ) - expected_items = 3 # FG item + 2 raw materials + expected_items = 4 # FG item + 2 raw materials + 1 scrap item self.assertEqual( len(stock_entry.items), expected_items, @@ -2626,6 +2635,15 @@ class TestWorkOrder(ERPNextTestSuite): 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) + # Secondary/Scrap item: should be taken from scrap warehouse in disassembly + scrap_row = next((i for i in stock_entry.items if i.item_code == scrap_item), None) + self.assertIsNotNone(scrap_row) + self.assertEqual(scrap_row.type, "Scrap") + self.assertTrue(scrap_row.s_warehouse) + self.assertFalse(scrap_row.t_warehouse) + self.assertEqual(scrap_row.s_warehouse, wo.scrap_warehouse) + self.assertEqual(scrap_row.qty, 40) + # RM quantities for bom_item in bom.items: expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty From 31ac46ae4c04ce379def08dc2cadb84f5e5a1261 Mon Sep 17 00:00:00 2001 From: vorasmit Date: Wed, 1 Apr 2026 23:42:36 +0530 Subject: [PATCH 17/26] fix: manufacture entry with group_by support (cherry picked from commit 3cf1ce83608b51a0e98663378670ed810bd6305c) --- .../stock/doctype/stock_entry/stock_entry.py | 60 ++++++++++--------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 046e325375a..92ff6d7da83 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2494,37 +2494,43 @@ class StockEntry(StockController, SubcontractingInwardController): # Finished goods self.load_items_from_bom() - def get_items_from_manufacture_stock_entry(self, stock_entry): + def get_items_from_manufacture_stock_entry(self, stock_entry=None): SE = frappe.qb.DocType("Stock Entry") SED = frappe.qb.DocType("Stock Entry Detail") + query = frappe.qb.from_(SED).join(SE).on(SED.parent == SE.name).where(SE.docstatus == 1) + + common_fields = [ + SED.item_code, + SED.item_name, + SED.description, + SED.stock_uom, + SED.uom, + SED.basic_rate, + SED.conversion_factor, + SED.is_finished_item, + SED.type, + SED.is_legacy_scrap_item, + SED.bom_secondary_item, + SED.batch_no, + SED.serial_no, + SED.use_serial_batch_fields, + SED.s_warehouse, + SED.t_warehouse, + ] + + if stock_entry: + return ( + query.select(SED.name, SED.qty, SED.transfer_qty, *common_fields) + .where(SE.name == stock_entry) + .orderby(SED.idx) + .run(as_dict=True) + ) return ( - frappe.qb.from_(SED) - .join(SE) - .on(SED.parent == SE.name) - .select( - SED.name, - SED.item_code, - SED.item_name, - SED.description, - SED.qty, - SED.transfer_qty, - SED.stock_uom, - SED.uom, - SED.basic_rate, - SED.conversion_factor, - SED.is_finished_item, - SED.type, - SED.is_legacy_scrap_item, - SED.bom_secondary_item, - SED.batch_no, - SED.serial_no, - SED.use_serial_batch_fields, - SED.s_warehouse, - SED.t_warehouse, - ) - .where(SE.name == stock_entry) - .where(SE.docstatus == 1) + query.select(Sum(SED.qty).as_("qty"), Sum(SED.transfer_qty).as_("transfer_qty"), *common_fields) + .where(SE.purpose == "Manufacture") + .where(SE.work_order == self.work_order) + .groupby(SED.item_code) .orderby(SED.idx) .run(as_dict=True) ) From 0ceb08410475c82157dbaff6431dcd06ae1031e2 Mon Sep 17 00:00:00 2001 From: vorasmit Date: Wed, 1 Apr 2026 23:56:44 +0530 Subject: [PATCH 18/26] fix: avg stock entries for disassembly from WO (cherry picked from commit 71fd18bdf93630410377c840342f1cd36933e3d6) --- .../doctype/work_order/test_work_order.py | 4 +- .../stock/doctype/stock_entry/stock_entry.py | 153 ++++++++---------- 2 files changed, 67 insertions(+), 90 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 77a9acdf02e..e5ae8ec39ec 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2642,7 +2642,9 @@ class TestWorkOrder(ERPNextTestSuite): self.assertTrue(scrap_row.s_warehouse) self.assertFalse(scrap_row.t_warehouse) self.assertEqual(scrap_row.s_warehouse, wo.scrap_warehouse) - self.assertEqual(scrap_row.qty, 40) + # BOM has scrap_qty=10/FG but also process_loss_per=10%, so actual scrap per FG = 9 + # Total produced = 9*3 + 9*7 = 90, disassemble 4/10 → 36 + self.assertEqual(scrap_row.qty, 36) # RM quantities for bom_item in bom.items: diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 92ff6d7da83..9d73a3f117c 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2328,7 +2328,7 @@ class StockEntry(StockController, SubcontractingInwardController): Priority: 1. From a specific Manufacture Stock Entry (exact reversal) - 2. From Work Order required_items (reflects WO changes) + 2. From Work Order Manufacture Stock Entries (averaged reversal) 3. From BOM (standalone disassembly) """ @@ -2362,104 +2362,79 @@ class StockEntry(StockController, SubcontractingInwardController): _("Source Stock Entry {0} has no finished goods quantity").format(self.source_stock_entry) ) - scale_factor = flt(self.fg_completed_qty) / flt(source_fg_qty) + disassemble_qty = flt(self.fg_completed_qty) + scale_factor = disassemble_qty / flt(source_fg_qty) - for source_row in self.get_items_from_manufacture_stock_entry(self.source_stock_entry): - if source_row.is_finished_item: - qty = flt(self.fg_completed_qty) - s_warehouse = self.from_warehouse or source_row.t_warehouse - t_warehouse = "" - elif source_row.s_warehouse: - # RM: was consumed FROM s_warehouse → return TO s_warehouse - qty = flt(source_row.qty * scale_factor) - s_warehouse = "" - t_warehouse = self.to_warehouse or source_row.s_warehouse - else: - # Scrap/secondary: was produced TO t_warehouse → take FROM t_warehouse - qty = flt(source_row.qty * scale_factor) - s_warehouse = source_row.t_warehouse - t_warehouse = "" - - use_serial_batch_fields = 1 if (source_row.batch_no or source_row.serial_no) else 0 - - self.append( - "items", - { - "item_code": source_row.item_code, - "item_name": source_row.item_name, - "description": source_row.description, - "stock_uom": source_row.stock_uom, - "uom": source_row.uom, - "conversion_factor": source_row.conversion_factor, - "basic_rate": source_row.basic_rate, - "qty": qty, - "s_warehouse": s_warehouse, - "t_warehouse": t_warehouse, - "is_finished_item": source_row.is_finished_item, - "type": source_row.type, - "is_legacy_scrap_item": source_row.is_legacy_scrap_item, - "bom_secondary_item": source_row.bom_secondary_item, - "against_stock_entry": self.source_stock_entry, - "ste_detail": source_row.name, - # batch and serial bundles built on submit - "use_serial_batch_fields": use_serial_batch_fields, - }, - ) + self._append_disassembly_row_from_source( + disassemble_qty=disassemble_qty, + scale_factor=scale_factor, + source_stock_entry=self.source_stock_entry, + ) def _add_items_for_disassembly_from_work_order(self): wo = frappe.get_doc("Work Order", self.work_order) - if not wo.required_items: - return self._add_items_for_disassembly_from_bom() + wo_produced_qty = flt(wo.produced_qty) + if wo_produced_qty <= 0: + frappe.throw(_("Work Order {0} has no produced qty").format(self.work_order)) - scale_factor = flt(self.fg_completed_qty) / flt(wo.qty) if flt(wo.qty) else 0 + disassemble_qty = flt(self.fg_completed_qty) + if disassemble_qty <= 0: + frappe.throw(_("Disassemble Qty cannot be less than or equal to 0.")) - # RMs - for ri in wo.required_items: - self.append( - "items", - { - "item_code": ri.item_code, - "item_name": ri.item_name, - "description": ri.description, - "qty": flt(ri.required_qty * scale_factor), - "stock_uom": ri.stock_uom, - "uom": ri.stock_uom, - "conversion_factor": 1, - # manufacture transfers RMs from WIP (not source warehouse) - "t_warehouse": self.to_warehouse or wo.wip_warehouse, - "s_warehouse": "", - "is_finished_item": 0, - }, - ) + scale_factor = disassemble_qty / wo_produced_qty - # Secondary/Scrap items - secondary_items = self.get_secondary_items(self.fg_completed_qty) - if secondary_items: - scrap_warehouse = wo.scrap_warehouse or self.from_warehouse or wo.fg_warehouse - for item in secondary_items.values(): - item["from_warehouse"] = scrap_warehouse - item["to_warehouse"] = "" - item["is_finished_item"] = 0 - self.add_to_stock_entry_detail(secondary_items, bom_no=self.bom_no) - - # FG - self.append( - "items", - { - "item_code": wo.production_item, - "item_name": wo.item_name, - "description": wo.description, - "qty": flt(self.fg_completed_qty), - "stock_uom": wo.stock_uom, - "uom": wo.stock_uom, - "conversion_factor": 1, - "s_warehouse": self.from_warehouse or wo.fg_warehouse, - "t_warehouse": "", - "is_finished_item": 1, - }, + self._append_disassembly_row_from_source( + disassemble_qty=disassemble_qty, + scale_factor=scale_factor, ) + def _append_disassembly_row_from_source(self, disassemble_qty, scale_factor, source_stock_entry=None): + for source_row in self.get_items_from_manufacture_stock_entry(self.source_stock_entry): + if source_row.is_finished_item: + qty = disassemble_qty + s_warehouse = self.from_warehouse or source_row.t_warehouse + t_warehouse = "" + elif source_row.s_warehouse: + # RM: was consumed FROM s_warehouse -> return TO s_warehouse + qty = flt(source_row.qty * scale_factor) + s_warehouse = "" + t_warehouse = self.to_warehouse or source_row.s_warehouse + else: + # Scrap/secondary: was produced TO t_warehouse -> take FROM t_warehouse + qty = flt(source_row.qty * scale_factor) + s_warehouse = source_row.t_warehouse + t_warehouse = "" + + item = { + "item_code": source_row.item_code, + "item_name": source_row.item_name, + "description": source_row.description, + "stock_uom": source_row.stock_uom, + "uom": source_row.uom, + "conversion_factor": source_row.conversion_factor, + "basic_rate": source_row.basic_rate, + "qty": qty, + "s_warehouse": s_warehouse, + "t_warehouse": t_warehouse, + "is_finished_item": source_row.is_finished_item, + "type": source_row.type, + "is_legacy_scrap_item": source_row.is_legacy_scrap_item, + "bom_secondary_item": source_row.bom_secondary_item, + # batch and serial bundles built on submit + "use_serial_batch_fields": 1 if (source_row.batch_no or source_row.serial_no) else 0, + } + + if source_stock_entry: + item.update( + { + "against_stock_entry": source_stock_entry, + "ste_detail": source_row.name, + } + ) + + self.append("items", item) + 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")) From e4eb88d80b3cdeb4124aa07ffba2868315904248 Mon Sep 17 00:00:00 2001 From: vorasmit Date: Fri, 3 Apr 2026 16:19:43 +0530 Subject: [PATCH 19/26] fix: use get_value (cherry picked from commit a71e8bb116a5ca8eb72d3e7b1815a38245757159) --- erpnext/stock/doctype/stock_entry/stock_entry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 9d73a3f117c..be184de0e20 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2372,9 +2372,9 @@ class StockEntry(StockController, SubcontractingInwardController): ) def _add_items_for_disassembly_from_work_order(self): - wo = frappe.get_doc("Work Order", self.work_order) + wo_produced_qty = frappe.db.get_value("Work Order", self.work_order, "produced_qty") - wo_produced_qty = flt(wo.produced_qty) + wo_produced_qty = flt(wo_produced_qty) if wo_produced_qty <= 0: frappe.throw(_("Work Order {0} has no produced qty").format(self.work_order)) From b030eeafb8d920f32f84f7f4e62e84b607297c94 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Mon, 6 Apr 2026 20:08:38 +0530 Subject: [PATCH 20/26] fix: validate work order consistency in stock entry (cherry picked from commit ea392b2009a478eb06307aa3d63ca863488eecef) --- erpnext/stock/doctype/stock_entry/stock_entry.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index be184de0e20..3cbd6a5b4db 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -907,6 +907,16 @@ class StockEntry(StockController, SubcontractingInwardController): if not self.get("source_stock_entry"): return + if self.work_order: + source_wo = frappe.db.get_value("Stock Entry", self.source_stock_entry, "work_order") + if source_wo and source_wo != self.work_order: + frappe.throw( + _( + "Source Stock Entry {0} belongs to Work Order {1}, not {2}. Please use a manufacture entry from the same Work Order." + ).format(self.source_stock_entry, source_wo, self.work_order), + title=_("Work Order Mismatch"), + ) + from erpnext.manufacturing.doctype.work_order.work_order import get_disassembly_available_qty available_qty = get_disassembly_available_qty(self.source_stock_entry, self.name) @@ -2704,7 +2714,7 @@ class StockEntry(StockController, SubcontractingInwardController): sorted_items = sorted(self.items, key=lambda x: x.item_code) if self.purpose == "Manufacture": # ensure finished item at last - sorted_items = sorted(sorted_items, key=lambda x: (x.t_warehouse)) + sorted_items = sorted(sorted_items, key=lambda x: x.t_warehouse) idx = 0 for row in sorted_items: From 0a257ea63d6820a49b13b713f1ffa6a4185ad479 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 7 Apr 2026 09:59:54 +0530 Subject: [PATCH 21/26] fix: process loss with bom path disassembly (cherry picked from commit 93ad48bc1bf7f21e280c0fc068c8e04ab8c422ec) --- .../doctype/work_order/test_work_order.py | 32 ++++++++++++++++++- .../stock/doctype/stock_entry/stock_entry.py | 6 ++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index e5ae8ec39ec..8c135d297d9 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -6,7 +6,7 @@ from collections import defaultdict import frappe from frappe.tests import timeout -from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, today +from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, nowdate, nowtime, today from erpnext.manufacturing.doctype.job_card.job_card import JobCardCancelError from erpnext.manufacturing.doctype.job_card.job_card import make_stock_entry as make_stock_entry_from_jc @@ -2657,6 +2657,36 @@ class TestWorkOrder(ERPNextTestSuite): msg=f"Raw material {bom_item.item_code} qty mismatch", ) + # -- BOM-path disassembly (no source_stock_entry, no work_order) -- + bom_disassemble_qty = 2 + bom_se = frappe.get_doc( + { + "doctype": "Stock Entry", + "stock_entry_type": "Disassemble", + "purpose": "Disassemble", + "from_bom": 1, + "bom_no": bom.name, + "fg_completed_qty": bom_disassemble_qty, + "from_warehouse": wo.fg_warehouse, + "to_warehouse": wo.wip_warehouse, + "company": wo.company, + "posting_date": nowdate(), + "posting_time": nowtime(), + } + ) + bom_se.get_items() + bom_se.save() + bom_se.submit() + + bom_scrap_row = next((i for i in bom_se.items if i.item_code == scrap_item), None) + self.assertIsNotNone(bom_scrap_row, "Scrap item must appear in BOM-path disassembly") + # Without fix 3: qty = 10 * 2 = 20; with fix 3 (process_loss_per=10%): qty = 9 * 2 = 18 + self.assertEqual( + bom_scrap_row.qty, + 18, + f"BOM-path disassembly must apply process_loss_per; expected 18, got {bom_scrap_row.qty}", + ) + def test_disassembly_with_additional_rm_not_in_bom(self): """ Test that SE-linked disassembly includes additional raw materials diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 3cbd6a5b4db..0f954c88493 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2474,6 +2474,12 @@ class StockEntry(StockController, SubcontractingInwardController): item["to_warehouse"] = "" item["is_finished_item"] = 0 + if item.get("process_loss_per"): + item["qty"] -= flt( + item["qty"] * (item["process_loss_per"] / 100), + self.precision("fg_completed_qty"), + ) + self.add_to_stock_entry_detail(secondary_items, bom_no=self.bom_no) # Finished goods From fb1d865e9b6b0f47e09ab9521bc7bf6b8c0efd9b Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 7 Apr 2026 10:00:30 +0530 Subject: [PATCH 22/26] fix: set bom details on disassembly; abs batch qty (cherry picked from commit ab1fc2243141d69739bbe4467e2e2584171c199b) --- erpnext/stock/doctype/stock_entry/stock_entry.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 0f954c88493..5d24abaee98 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -369,7 +369,7 @@ class StockEntry(StockController, SubcontractingInwardController): for batch_no, batch_qty in source_bundle["batch_nos"].items(): if qty_remaining <= 0: break - alloc = min(flt(batch_qty) * scale_factor, qty_remaining) + alloc = min(abs(flt(batch_qty)) * scale_factor, qty_remaining) batches[batch_no] = alloc qty_remaining -= alloc elif source_row.batch_no: @@ -2431,6 +2431,7 @@ class StockEntry(StockController, SubcontractingInwardController): "type": source_row.type, "is_legacy_scrap_item": source_row.is_legacy_scrap_item, "bom_secondary_item": source_row.bom_secondary_item, + "bom_no": source_row.bom_no, # batch and serial bundles built on submit "use_serial_batch_fields": 1 if (source_row.batch_no or source_row.serial_no) else 0, } @@ -2507,6 +2508,7 @@ class StockEntry(StockController, SubcontractingInwardController): SED.use_serial_batch_fields, SED.s_warehouse, SED.t_warehouse, + SED.bom_no, ] if stock_entry: From d4fde552f42d623c293bb1ee5e36e31cc05ece84 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 7 Apr 2026 10:27:41 +0530 Subject: [PATCH 23/26] test: maintain sufficient stock for scrap item (cherry picked from commit b892139342ea8c2aa7a1c1a65b2db80992f7a8aa) --- .../manufacturing/doctype/work_order/test_work_order.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 8c135d297d9..4acf3fe0d23 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2658,6 +2658,15 @@ class TestWorkOrder(ERPNextTestSuite): ) # -- BOM-path disassembly (no source_stock_entry, no work_order) -- + + make_stock_entry_test_record( + item_code=scrap_item, + purpose="Material Receipt", + target=wo.fg_warehouse, + qty=50, + basic_rate=10, + ) + bom_disassemble_qty = 2 bom_se = frappe.get_doc( { From 6cebea314d0b13ff5076d91e79196688e2047d1d Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 7 Apr 2026 10:54:34 +0530 Subject: [PATCH 24/26] test: enhance tests as per review comments (cherry picked from commit f13d37fbf93a7a3f18cf430f3a36515de8bb0a3a) --- .../doctype/work_order/test_work_order.py | 155 +++++++++++++----- 1 file changed, 113 insertions(+), 42 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 4acf3fe0d23..8a13ed11fe2 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2915,49 +2915,81 @@ class TestWorkOrder(ERPNextTestSuite): status="Not Started", ) - # Stock up RM — batch auto-created on receipt - rm_receipt = make_stock_entry_test_record( - item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=18, basic_rate=100 + # Two separate RM receipts → two distinct batches (batch_1, batch_2) + rm_receipt_1 = make_stock_entry_test_record( + item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100 ) - rm_bundle = frappe.db.get_value( - "Stock Entry Detail", {"parent": rm_receipt.name, "item_code": rm_item}, "serial_and_batch_bundle" + rm_batch_1 = get_batch_from_bundle( + frappe.db.get_value( + "Stock Entry Detail", + {"parent": rm_receipt_1.name, "item_code": rm_item}, + "serial_and_batch_bundle", + ) ) - rm_batch = get_batch_from_bundle(rm_bundle) - # Pre-create FG batch so we can assign it to the manufacture row - fg_batch = make_batch(frappe._dict(item=fg_item)) + rm_receipt_2 = make_stock_entry_test_record( + item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100 + ) + rm_batch_2 = get_batch_from_bundle( + frappe.db.get_value( + "Stock Entry Detail", + {"parent": rm_receipt_2.name, "item_code": rm_item}, + "serial_and_batch_bundle", + ) + ) - # Manufacture 3 units: assign batches explicitly on RM and FG rows - se_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3)) - for row in se_manufacture.items: + self.assertNotEqual(rm_batch_1, rm_batch_2, "Two receipts must create two distinct RM batches") + + fg_batch_1 = make_batch(frappe._dict(item=fg_item)) + fg_batch_2 = make_batch(frappe._dict(item=fg_item)) + + # Manufacture entry 1 — 3 FG using batch_1 RM/FG + se_manufacture_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3)) + for row in se_manufacture_1.items: if row.item_code == rm_item: - row.batch_no = rm_batch + row.batch_no = rm_batch_1 row.use_serial_batch_fields = 1 elif row.item_code == fg_item: - row.batch_no = fg_batch + row.batch_no = fg_batch_1 row.use_serial_batch_fields = 1 - se_manufacture.save() - se_manufacture.submit() + se_manufacture_1.save() + se_manufacture_1.submit() - # Disassemble 2 of the 3 manufactured units linked to the manufacture SE + # Manufacture entry 2 — 3 FG using batch_2 RM/FG + se_manufacture_2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3)) + for row in se_manufacture_2.items: + if row.item_code == rm_item: + row.batch_no = rm_batch_2 + row.use_serial_batch_fields = 1 + elif row.item_code == fg_item: + row.batch_no = fg_batch_2 + row.use_serial_batch_fields = 1 + se_manufacture_2.save() + se_manufacture_2.submit() + + # Disassemble 2 units from SE_1 only — must use SE_1's batches, not SE_2's disassemble_qty = 2 stock_entry = frappe.get_doc( - make_stock_entry(wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture.name) + make_stock_entry( + wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture_1.name + ) ) stock_entry.save() stock_entry.submit() - # FG row: consuming batch from FG warehouse — bundle must use FG batch + # FG row: must use fg_batch_1 exclusively (fg_batch_2 must not appear) fg_row = next((i for i in stock_entry.items if i.item_code == fg_item), None) self.assertIsNotNone(fg_row) self.assertTrue(fg_row.serial_and_batch_bundle, "FG row must have a serial_and_batch_bundle") - self.assertEqual(get_batch_from_bundle(fg_row.serial_and_batch_bundle), fg_batch) + self.assertEqual(get_batch_from_bundle(fg_row.serial_and_batch_bundle), fg_batch_1) + self.assertNotEqual(get_batch_from_bundle(fg_row.serial_and_batch_bundle), fg_batch_2) - # RM row: returning to WIP warehouse — bundle must use RM batch + # RM row: must use rm_batch_1 exclusively (rm_batch_2 must not appear) rm_row = next((i for i in stock_entry.items if i.item_code == rm_item), None) self.assertIsNotNone(rm_row) self.assertTrue(rm_row.serial_and_batch_bundle, "RM row must have a serial_and_batch_bundle") - self.assertEqual(get_batch_from_bundle(rm_row.serial_and_batch_bundle), rm_batch) + self.assertEqual(get_batch_from_bundle(rm_row.serial_and_batch_bundle), rm_batch_1) + self.assertNotEqual(get_batch_from_bundle(rm_row.serial_and_batch_bundle), rm_batch_2) # RM qty: 2 FG disassembled x 2 RM per FG = 4 self.assertAlmostEqual(rm_row.qty, 4.0, places=3) @@ -2990,55 +3022,94 @@ class TestWorkOrder(ERPNextTestSuite): status="Not Started", ) - # Stock up 6 RM serials — series auto-generates them - rm_receipt = make_stock_entry_test_record( + # Two separate RM receipts → two disjoint sets of serial numbers + rm_receipt_1 = make_stock_entry_test_record( item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100 ) - rm_bundle = frappe.db.get_value( - "Stock Entry Detail", {"parent": rm_receipt.name, "item_code": rm_item}, "serial_and_batch_bundle" + rm_serials_1 = get_serial_nos_from_bundle( + frappe.db.get_value( + "Stock Entry Detail", + {"parent": rm_receipt_1.name, "item_code": rm_item}, + "serial_and_batch_bundle", + ) ) - all_rm_serials = get_serial_nos_from_bundle(rm_bundle) - self.assertEqual(len(all_rm_serials), 6) + self.assertEqual(len(rm_serials_1), 6) - # Pre-generate 3 FG serial numbers + rm_receipt_2 = make_stock_entry_test_record( + item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100 + ) + rm_serials_2 = get_serial_nos_from_bundle( + frappe.db.get_value( + "Stock Entry Detail", + {"parent": rm_receipt_2.name, "item_code": rm_item}, + "serial_and_batch_bundle", + ) + ) + self.assertEqual(len(rm_serials_2), 6) + self.assertFalse( + set(rm_serials_1) & set(rm_serials_2), "Two receipts must produce disjoint RM serial sets" + ) + + # Pre-generate two sets of FG serial numbers series = frappe.db.get_value("Item", fg_item, "serial_no_series") - fg_serials = [make_autoname(series) for _ in range(3)] + fg_serials_1 = [make_autoname(series) for _ in range(3)] + fg_serials_2 = [make_autoname(series) for _ in range(3)] - # Manufacture 3 units: consume first 6 RM serials, produce 3 FG serials - se_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3)) - for row in se_manufacture.items: + # Manufacture entry 1 — consumes rm_serials_1, produces fg_serials_1 + se_manufacture_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3)) + for row in se_manufacture_1.items: if row.item_code == rm_item: - row.serial_no = "\n".join(all_rm_serials) + row.serial_no = "\n".join(rm_serials_1) row.use_serial_batch_fields = 1 elif row.item_code == fg_item: - row.serial_no = "\n".join(fg_serials) + row.serial_no = "\n".join(fg_serials_1) row.use_serial_batch_fields = 1 - se_manufacture.save() - se_manufacture.submit() + se_manufacture_1.save() + se_manufacture_1.submit() - # Disassemble 2 of the 3 manufactured units + # Manufacture entry 2 — consumes rm_serials_2, produces fg_serials_2 + se_manufacture_2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3)) + for row in se_manufacture_2.items: + if row.item_code == rm_item: + row.serial_no = "\n".join(rm_serials_2) + row.use_serial_batch_fields = 1 + elif row.item_code == fg_item: + row.serial_no = "\n".join(fg_serials_2) + row.use_serial_batch_fields = 1 + se_manufacture_2.save() + se_manufacture_2.submit() + + # Disassemble 2 units from SE_1 only — must use SE_1's serials, not SE_2's disassemble_qty = 2 stock_entry = frappe.get_doc( - make_stock_entry(wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture.name) + make_stock_entry( + wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture_1.name + ) ) stock_entry.save() stock_entry.submit() - # FG row: 2 serials consumed — must be a subset of the manufacture FG serials + # FG row: 2 serials consumed — must be subset of fg_serials_1, disjoint from fg_serials_2 fg_row = next((i for i in stock_entry.items if i.item_code == fg_item), None) self.assertIsNotNone(fg_row) self.assertTrue(fg_row.serial_and_batch_bundle, "FG row must have a serial_and_batch_bundle") fg_dasm_serials = get_serial_nos_from_bundle(fg_row.serial_and_batch_bundle) self.assertEqual(len(fg_dasm_serials), disassemble_qty) - self.assertTrue(set(fg_dasm_serials).issubset(set(fg_serials))) + self.assertTrue(set(fg_dasm_serials).issubset(set(fg_serials_1))) + self.assertFalse( + set(fg_dasm_serials) & set(fg_serials_2), "Disassembly must not use SE_2's FG serials" + ) - # RM row: 4 serials returned (2 FG x 2 RM each) — must be a subset of manufacture RM serials + # RM row: 4 serials returned (2 FG x 2 RM each) — subset of rm_serials_1, disjoint from rm_serials_2 rm_row = next((i for i in stock_entry.items if i.item_code == rm_item), None) self.assertIsNotNone(rm_row) self.assertTrue(rm_row.serial_and_batch_bundle, "RM row must have a serial_and_batch_bundle") rm_dasm_serials = get_serial_nos_from_bundle(rm_row.serial_and_batch_bundle) self.assertEqual(len(rm_dasm_serials), disassemble_qty * 2) - self.assertTrue(set(rm_dasm_serials).issubset(set(all_rm_serials))) + self.assertTrue(set(rm_dasm_serials).issubset(set(rm_serials_1))) + self.assertFalse( + set(rm_dasm_serials) & set(rm_serials_2), "Disassembly must not use SE_2's RM serials" + ) def test_components_alternate_item_for_bom_based_manufacture_entry(self): frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM") From 7bef9542d420fb89ed1dcf90345cbe409c08563e Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 7 Apr 2026 13:17:22 +0530 Subject: [PATCH 25/26] fix: remove unnecessary param, and use value from self (cherry picked from commit 98dfd64f6326cefc8882756eb0d30b26863a0556) --- .../stock/doctype/stock_entry/stock_entry.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 5d24abaee98..ae5c390fdae 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -345,9 +345,7 @@ class StockEntry(StockController, SubcontractingInwardController): scale_factor = flt(self.fg_completed_qty) / source_fg_qty if source_fg_qty else 0 bundle_data = get_voucher_wise_serial_batch_from_bundle(voucher_no=[self.source_stock_entry]) - source_rows_by_name = { - r.name: r for r in self.get_items_from_manufacture_stock_entry(self.source_stock_entry) - } + source_rows_by_name = {r.name: r for r in self.get_items_from_manufacture_stock_entry()} for row in self.items: if not row.ste_detail: @@ -2378,7 +2376,6 @@ class StockEntry(StockController, SubcontractingInwardController): self._append_disassembly_row_from_source( disassemble_qty=disassemble_qty, scale_factor=scale_factor, - source_stock_entry=self.source_stock_entry, ) def _add_items_for_disassembly_from_work_order(self): @@ -2399,8 +2396,8 @@ class StockEntry(StockController, SubcontractingInwardController): scale_factor=scale_factor, ) - def _append_disassembly_row_from_source(self, disassemble_qty, scale_factor, source_stock_entry=None): - for source_row in self.get_items_from_manufacture_stock_entry(self.source_stock_entry): + def _append_disassembly_row_from_source(self, disassemble_qty, scale_factor): + for source_row in self.get_items_from_manufacture_stock_entry(): if source_row.is_finished_item: qty = disassemble_qty s_warehouse = self.from_warehouse or source_row.t_warehouse @@ -2436,10 +2433,10 @@ class StockEntry(StockController, SubcontractingInwardController): "use_serial_batch_fields": 1 if (source_row.batch_no or source_row.serial_no) else 0, } - if source_stock_entry: + if self.source_stock_entry: item.update( { - "against_stock_entry": source_stock_entry, + "against_stock_entry": self.source_stock_entry, "ste_detail": source_row.name, } ) @@ -2486,7 +2483,7 @@ class StockEntry(StockController, SubcontractingInwardController): # Finished goods self.load_items_from_bom() - def get_items_from_manufacture_stock_entry(self, stock_entry=None): + def get_items_from_manufacture_stock_entry(self): SE = frappe.qb.DocType("Stock Entry") SED = frappe.qb.DocType("Stock Entry Detail") query = frappe.qb.from_(SED).join(SE).on(SED.parent == SE.name).where(SE.docstatus == 1) @@ -2511,10 +2508,10 @@ class StockEntry(StockController, SubcontractingInwardController): SED.bom_no, ] - if stock_entry: + if self.source_stock_entry: return ( query.select(SED.name, SED.qty, SED.transfer_qty, *common_fields) - .where(SE.name == stock_entry) + .where(SE.name == self.source_stock_entry) .orderby(SED.idx) .run(as_dict=True) ) From 9e83badbf5d100a01e94beb47ff71354ea0e1dab Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 7 Apr 2026 14:57:56 +0530 Subject: [PATCH 26/26] chore: resolve conflicts --- erpnext/manufacturing/doctype/work_order/work_order.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 268918eca00..5e18f68e8c0 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -2432,14 +2432,7 @@ def make_stock_entry( @frappe.whitelist() -<<<<<<< HEAD -<<<<<<< HEAD -def get_default_warehouse(company): -======= -def get_disassembly_available_qty(stock_entry_name: str) -> float: -======= def get_disassembly_available_qty(stock_entry_name: str, current_se_name: str | None = None) -> float: ->>>>>>> 6394dead72 (fix: validate qty that can be disassembled from source stock entry.) se = frappe.db.get_value("Stock Entry", stock_entry_name, ["fg_completed_qty"], as_dict=True) if not se: return 0.0 @@ -2459,8 +2452,7 @@ def get_disassembly_available_qty(stock_entry_name: str, current_se_name: str | @frappe.whitelist() -def get_default_warehouse(company: str): ->>>>>>> 68e97808c5 (fix: disassembly prompt with source stock entry field) +def get_default_warehouse(company): wip, fg, scrap = frappe.get_cached_value( "Company", company, ["default_wip_warehouse", "default_fg_warehouse", "default_scrap_warehouse"] )