From 55ee1dcd04309c40091a492e0e6f6b7dbecd0d7e Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Wed, 25 Mar 2026 10:43:28 +0530 Subject: [PATCH 01/28] fix: create source_stock_entry to refer to original manufacturing entry (cherry picked from commit d4baa9a74af097a47ffdb267e5a0073f4c5d6721) # Conflicts: # erpnext/stock/doctype/stock_entry/stock_entry.json # erpnext/stock/doctype/stock_entry/stock_entry.py --- .../stock/doctype/stock_entry/stock_entry.json | 17 +++++++++++++++++ .../stock/doctype/stock_entry/stock_entry.py | 5 +++++ 2 files changed, 22 insertions(+) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json index f37a2785252..8559e1339e2 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.json +++ b/erpnext/stock/doctype/stock_entry/stock_entry.json @@ -28,7 +28,15 @@ "column_break_eaoa", "set_posting_time", "inspection_required", +<<<<<<< HEAD "apply_putaway_rule", +======= + "column_break_jabv", + "work_order", + "subcontracting_order", + "outgoing_stock_entry", + "source_stock_entry", +>>>>>>> d4baa9a74a (fix: create source_stock_entry to refer to original manufacturing entry) "bom_info_section", "from_bom", "use_multi_level_bom", @@ -120,6 +128,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 fec92256108..39ee125f676 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -142,7 +142,12 @@ class StockEntry(StockController): scan_barcode: DF.Data | None select_print_heading: DF.Link | None set_posting_time: DF.Check +<<<<<<< HEAD source_address_display: DF.SmallText | None +======= + source_address_display: DF.TextEditor | None + source_stock_entry: DF.Link | None +>>>>>>> d4baa9a74a (fix: create source_stock_entry to refer to original manufacturing entry) source_warehouse_address: DF.Link | None stock_entry_type: DF.Link subcontracting_order: DF.Link | None From 44f2e9480d8ef49a0b12562268e9111040e4ea91 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Wed, 25 Mar 2026 16:49:58 +0530 Subject: [PATCH 02/28] fix: disassembly prompt with source stock entry field (cherry picked from commit 68e97808c566cbb34716f1b9dee4820f8d9c28a9) # Conflicts: # erpnext/manufacturing/doctype/work_order/work_order.js # erpnext/manufacturing/doctype/work_order/work_order.py --- .../doctype/work_order/work_order.js | 68 ++++++++++++++++++- .../doctype/work_order/work_order.py | 41 +++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index b6206cefcbb..b6f3f740e55 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -401,7 +401,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.")); @@ -411,11 +411,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); + } }); }, @@ -865,8 +868,67 @@ erpnext.work_order = { return flt(max, precision("qty")); }, +<<<<<<< HEAD show_prompt_for_qty_input: function (frm, purpose) { let max = this.get_max_transferable_qty(frm, purpose); +======= + 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; +>>>>>>> 68e97808c5 (fix: disassembly prompt with source stock entry field) let fields = [ { diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index bc3def1186f..7f8afa49ab3 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1480,7 +1480,18 @@ def set_work_order_ops(name): @frappe.whitelist() +<<<<<<< HEAD def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None): +======= +def make_stock_entry( + work_order_id: str, + purpose: str, + qty: float | None = None, + target_warehouse: str | None = None, + is_additional_transfer_entry: bool = False, + source_stock_entry: str | None = None, +): +>>>>>>> 68e97808c5 (fix: disassembly prompt with source stock entry field) work_order = frappe.get_doc("Work Order", work_order_id) if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"): wip_warehouse = work_order.wip_warehouse @@ -1517,6 +1528,8 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None): 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.get_items() @@ -1528,9 +1541,37 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None): @frappe.whitelist() +<<<<<<< HEAD def get_default_warehouse(): doc = frappe.get_cached_doc("Manufacturing Settings") +======= +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): + wip, fg, scrap = frappe.get_cached_value( + "Company", company, ["default_wip_warehouse", "default_fg_warehouse", "default_scrap_warehouse"] + ) +>>>>>>> 68e97808c5 (fix: disassembly prompt with source stock entry field) return { "wip_warehouse": doc.default_wip_warehouse, "fg_warehouse": doc.default_fg_warehouse, From 849b2e6ebf977204643109e83e60a8ee68bbc557 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Wed, 25 Mar 2026 17:04:45 +0530 Subject: [PATCH 03/28] 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 fd2860a6f58..4e22c74b622 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 835ae27b38d21abf468562abdd7d5988e5595597 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Wed, 25 Mar 2026 17:11:11 +0530 Subject: [PATCH 04/28] 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 4e22c74b622..96ad2418445 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -325,6 +325,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) { From ef15c0581dc75ebdbd7b1ed19eaf2729e57997c6 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Wed, 25 Mar 2026 18:27:50 +0530 Subject: [PATCH 05/28] 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 96ad2418445..3e17748bb8f 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -229,6 +229,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, @@ -326,7 +350,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 () { @@ -335,7 +359,6 @@ frappe.ui.form.on("Stock Entry", { { stock_entry_name: frm.doc.name } ); frappe.prompt( - // fields { fieldtype: "Float", label: __("Qty to Disassemble"), @@ -343,28 +366,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"), @@ -1338,8 +1366,11 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle if (!this.frm.doc.fg_completed_qty || !this.frm.doc.bom_no) frappe.throw(__("BOM and Manufacturing Quantity are required")); - 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 583c7b9819597df4695ff07f3b1dc97c32c9feb9 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Mon, 30 Mar 2026 15:46:37 +0530 Subject: [PATCH 06/28] 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 7f8afa49ab3..7ae844d6a67 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1542,26 +1542,29 @@ def make_stock_entry( @frappe.whitelist() <<<<<<< HEAD +<<<<<<< HEAD def get_default_warehouse(): doc = frappe.get_cached_doc("Manufacturing Settings") ======= 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 39ee125f676..1b9af6e3edf 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -222,6 +222,7 @@ class StockEntry(StockController): 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() @@ -771,6 +772,26 @@ class StockEntry(StockController): 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 e9ce0a41e6ac9a8739b0f93d390473f798b1f3d2 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Mon, 30 Mar 2026 18:19:19 +0530 Subject: [PATCH 07/28] fix: add support to fetch items based on manufacture stock entry; fix how it's done from work order (cherry picked from commit 1ed0124ad7668cffe2aa858edaadf9a52faab313) # Conflicts: # erpnext/stock/doctype/stock_entry/stock_entry.py --- .../stock/doctype/stock_entry/stock_entry.py | 153 ++++++++++++++---- 1 file changed, 124 insertions(+), 29 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 1b9af6e3edf..ae5bb77e667 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -28,7 +28,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_scrap_items_from_sub_assemblies, validate_bom_no, @@ -1984,45 +1983,108 @@ class StockEntry(StockController): ) 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")) @@ -2040,6 +2102,7 @@ class StockEntry(StockController): # Finished goods self.load_items_from_bom() +<<<<<<< HEAD def get_items_from_manufacture_entry(self): return frappe.get_all( "Stock Entry", @@ -2068,6 +2131,38 @@ class StockEntry(StockController): ], 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) +>>>>>>> 1ed0124ad7 (fix: add support to fetch items based on manufacture stock entry; fix how it's done from work order) ) @frappe.whitelist() From b87b445802ec96f1848013c0b28d524306bd8bcd Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Mon, 30 Mar 2026 18:19:55 +0530 Subject: [PATCH 08/28] 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 ae5bb77e667..5f750e90b4f 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1991,6 +1991,21 @@ class StockEntry(StockController): 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 b8ddc2f2b9c9910f1c65be5b1bef0d29a806a3e4 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 31 Mar 2026 07:44:25 +0530 Subject: [PATCH 09/28] 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 5f750e90b4f..029b624260e 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2077,7 +2077,8 @@ class StockEntry(StockController): "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 df049cd27705ef0805c8df92d79de01829fc439b Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 31 Mar 2026 09:27:19 +0530 Subject: [PATCH 10/28] fix: set serial and batch from source stock entry - on disassemble (cherry picked from commit 13b019ab8efe75cff787b400cbbcb9b5c9677bfb) # Conflicts: # erpnext/stock/doctype/stock_entry/stock_entry.py --- .../stock/doctype/stock_entry/stock_entry.py | 96 ++++++++++++++++--- 1 file changed, 84 insertions(+), 12 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 029b624260e..f39c1d910ad 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -298,6 +298,58 @@ class StockEntry(StockController): 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 @@ -323,9 +375,9 @@ class StockEntry(StockController): 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) +<<<<<<< HEAD bundle_doc = SerialBatchCreation( { "item_code": row.item_code, @@ -341,16 +393,37 @@ class StockEntry(StockController): "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 +>>>>>>> 13b019ab8e (fix: set serial and batch from source stock entry - on disassemble) - 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() @@ -2051,8 +2124,7 @@ class StockEntry(StockController): "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 43c507570b7729f6f04cd75c748f1f3e1d9686f9 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 31 Mar 2026 15:01:46 +0530 Subject: [PATCH 11/28] 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 c23db9aa682..80115c152fc 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2395,7 +2395,7 @@ class TestWorkOrder(FrappeTestCase): 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 @@ -2435,27 +2435,9 @@ class TestWorkOrder(FrappeTestCase): 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() @@ -2470,7 +2452,7 @@ class TestWorkOrder(FrappeTestCase): 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 75eb5ad5845eff10b1c11c579b9cc0ebf4e7b125 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 31 Mar 2026 15:14:56 +0530 Subject: [PATCH 12/28] 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 80115c152fc..de5f713c76f 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2476,6 +2476,30 @@ class TestWorkOrder(FrappeTestCase): 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 1063a56251b154ec98d3e0b879e0d8e724f85a25 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 31 Mar 2026 15:18:42 +0530 Subject: [PATCH 13/28] 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 de5f713c76f..628e8b985fa 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2615,17 +2615,16 @@ class TestWorkOrder(FrappeTestCase): 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, @@ -2661,9 +2660,8 @@ class TestWorkOrder(FrappeTestCase): 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", { @@ -2676,9 +2674,8 @@ class TestWorkOrder(FrappeTestCase): 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", { @@ -2694,13 +2691,15 @@ class TestWorkOrder(FrappeTestCase): 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 @@ -2713,16 +2712,15 @@ class TestWorkOrder(FrappeTestCase): 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, @@ -2730,7 +2728,7 @@ class TestWorkOrder(FrappeTestCase): 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) @@ -2746,6 +2744,7 @@ class TestWorkOrder(FrappeTestCase): 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), @@ -2753,6 +2752,11 @@ class TestWorkOrder(FrappeTestCase): 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 f9b1df3572cbd5aca72826dea2d6d3774ef484c4 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 31 Mar 2026 17:52:58 +0530 Subject: [PATCH 14/28] 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 628e8b985fa..4afe1e5de6e 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2757,6 +2757,206 @@ class TestWorkOrder(FrappeTestCase): 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 229dc23f974d7c7bf95d837aad16ba1301f82780 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Wed, 1 Apr 2026 15:48:25 +0530 Subject: [PATCH 15/28] 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 f39c1d910ad..0d55f177e8f 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -807,7 +807,7 @@ class StockEntry(StockController): 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)) @@ -2101,10 +2101,16 @@ class StockEntry(StockController): 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 @@ -2122,6 +2128,9 @@ class StockEntry(StockController): "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 @@ -2156,6 +2165,16 @@ class StockEntry(StockController): }, ) + # 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", @@ -2187,6 +2206,23 @@ class StockEntry(StockController): 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() @@ -2240,6 +2276,9 @@ class StockEntry(StockController): 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 20f81516cf64a263e618e991b34feab452f244eb Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Wed, 1 Apr 2026 16:17:00 +0530 Subject: [PATCH 16/28] 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 4afe1e5de6e..cc3fde2e173 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2503,7 +2503,8 @@ class TestWorkOrder(FrappeTestCase): 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 @@ -2512,11 +2513,19 @@ class TestWorkOrder(FrappeTestCase): 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") @@ -2591,7 +2600,7 @@ class TestWorkOrder(FrappeTestCase): 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, @@ -2602,6 +2611,15 @@ class TestWorkOrder(FrappeTestCase): 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 841b507502c63ff10c7baf55627f5bcbca89e3d9 Mon Sep 17 00:00:00 2001 From: vorasmit Date: Wed, 1 Apr 2026 23:42:36 +0530 Subject: [PATCH 17/28] fix: manufacture entry with group_by support (cherry picked from commit 3cf1ce83608b51a0e98663378670ed810bd6305c) # Conflicts: # erpnext/stock/doctype/stock_entry/stock_entry.py --- .../stock/doctype/stock_entry/stock_entry.py | 62 +++++++++++-------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 0d55f177e8f..5b3999bba95 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2226,6 +2226,7 @@ class StockEntry(StockController): # Finished goods self.load_items_from_bom() +<<<<<<< HEAD <<<<<<< HEAD def get_items_from_manufacture_entry(self): return frappe.get_all( @@ -2257,36 +2258,45 @@ class StockEntry(StockController): group_by="`tabStock Entry Detail`.`item_code`", ======= def get_items_from_manufacture_stock_entry(self, stock_entry): +======= + def get_items_from_manufacture_stock_entry(self, stock_entry=None): +>>>>>>> 3cf1ce8360 (fix: manufacture entry with group_by support) 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) >>>>>>> 1ed0124ad7 (fix: add support to fetch items based on manufacture stock entry; fix how it's done from work order) From 44d40795df70cead2956ffc70de298e335ada2fd Mon Sep 17 00:00:00 2001 From: vorasmit Date: Wed, 1 Apr 2026 23:56:44 +0530 Subject: [PATCH 18/28] 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 cc3fde2e173..f8223fa65be 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2618,7 +2618,9 @@ class TestWorkOrder(FrappeTestCase): 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 5b3999bba95..7320d3448fc 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2060,7 +2060,7 @@ class StockEntry(StockController): 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) """ @@ -2094,104 +2094,79 @@ class StockEntry(StockController): _("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 8f01d12b5eb4207d7838ce9ee027de06a6fbf311 Mon Sep 17 00:00:00 2001 From: vorasmit Date: Fri, 3 Apr 2026 16:19:43 +0530 Subject: [PATCH 19/28] 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 7320d3448fc..fd616eb4cf8 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2104,9 +2104,9 @@ class StockEntry(StockController): ) 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 d690a0c6bd3124ed65e1049f84fe96c3401a6fe5 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Mon, 6 Apr 2026 20:08:38 +0530 Subject: [PATCH 20/28] fix: validate work order consistency in stock entry (cherry picked from commit ea392b2009a478eb06307aa3d63ca863488eecef) # Conflicts: # erpnext/stock/doctype/stock_entry/stock_entry.py --- .../stock/doctype/stock_entry/stock_entry.py | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index fd616eb4cf8..3c9f0e46fb3 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -848,6 +848,16 @@ class StockEntry(StockController): 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) @@ -2401,7 +2411,161 @@ class StockEntry(StockController): if self.pro_doc and self.pro_doc.scrap_warehouse: item["to_warehouse"] = self.pro_doc.scrap_warehouse +<<<<<<< HEAD self.add_to_stock_entry_detail(scrap_item_dict, bom_no=self.bom_no) +======= + if ( + self.purpose not in ["Material Transfer for Manufacture"] + and frappe.db.get_single_value("Manufacturing Settings", "backflush_raw_materials_based_on") + != "BOM" + and not skip_transfer + ): + return + + reservation_entries = self.get_available_reserved_materials() + if not reservation_entries: + return + + new_items_to_add = [] + for d in self.items: + if d.serial_and_batch_bundle or d.serial_no or d.batch_no: + continue + + key = (d.item_code, d.s_warehouse) + if details := reservation_entries.get(key): + original_qty = d.qty + if batches := details.get("batch_no"): + for batch_no, qty in batches.items(): + if original_qty <= 0: + break + + if qty <= 0: + continue + + if d.batch_no and original_qty > 0: + new_row = frappe.copy_doc(d) + new_row.name = None + new_row.batch_no = batch_no + new_row.qty = qty + new_row.idx = d.idx + 1 + if new_row.batch_no and details.get("batchwise_sn"): + new_row.serial_no = "\n".join( + details.get("batchwise_sn")[new_row.batch_no][: cint(new_row.qty)] + ) + + new_items_to_add.append(new_row) + original_qty -= qty + batches[batch_no] -= qty + + if qty >= d.qty and not d.batch_no: + d.batch_no = batch_no + batches[batch_no] -= d.qty + if d.batch_no and details.get("batchwise_sn"): + d.serial_no = "\n".join( + details.get("batchwise_sn")[d.batch_no][: cint(d.qty)] + ) + elif not d.batch_no: + d.batch_no = batch_no + d.qty = qty + original_qty -= qty + batches[batch_no] = 0 + + if d.batch_no and details.get("batchwise_sn"): + d.serial_no = "\n".join( + details.get("batchwise_sn")[d.batch_no][: cint(d.qty)] + ) + + if details.get("serial_no"): + d.serial_no = "\n".join(details.get("serial_no")[: cint(d.qty)]) + + d.use_serial_batch_fields = 1 + + for new_row in new_items_to_add: + self.append("items", new_row) + + 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) + + idx = 0 + for row in sorted_items: + idx += 1 + row.idx = idx + self.set("items", sorted_items) + + def get_available_reserved_materials(self): + reserved_entries = self.get_reserved_materials() + if not reserved_entries: + return {} + + itemwise_serial_batch_qty = frappe._dict() + + for d in reserved_entries: + key = (d.item_code, d.warehouse) + if key not in itemwise_serial_batch_qty: + itemwise_serial_batch_qty[key] = frappe._dict( + { + "serial_no": [], + "batch_no": defaultdict(float), + "batchwise_sn": defaultdict(list), + } + ) + + details = itemwise_serial_batch_qty[key] + if d.batch_no: + details.batch_no[d.batch_no] += d.qty + if d.serial_no: + details.batchwise_sn[d.batch_no].extend(d.serial_no.split("\n")) + elif d.serial_no: + details.serial_no.append(d.serial_no) + + return itemwise_serial_batch_qty + + def get_reserved_materials(self): + doctype = frappe.qb.DocType("Stock Reservation Entry") + serial_batch_doc = frappe.qb.DocType("Serial and Batch Entry") + + query = ( + frappe.qb.from_(doctype) + .inner_join(serial_batch_doc) + .on(doctype.name == serial_batch_doc.parent) + .select( + serial_batch_doc.serial_no, + serial_batch_doc.batch_no, + serial_batch_doc.qty, + doctype.item_code, + doctype.warehouse, + doctype.name, + doctype.transferred_qty, + doctype.consumed_qty, + ) + .where( + (doctype.docstatus == 1) + & (doctype.voucher_no == (self.work_order or self.subcontracting_order)) + & (serial_batch_doc.delivered_qty < serial_batch_doc.qty) + ) + .orderby(serial_batch_doc.idx) + ) + + return query.run(as_dict=True) + + def set_secondary_items(self): + if self.purpose in ["Manufacture", "Repack"]: + secondary_items_dict = self.get_secondary_items(self.fg_completed_qty) + for item in secondary_items_dict.values(): + if self.pro_doc and item.type: + if self.pro_doc.scrap_warehouse and item.type == "Scrap": + item["to_warehouse"] = self.pro_doc.scrap_warehouse + + if item.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_dict, bom_no=self.bom_no) +>>>>>>> ea392b2009 (fix: validate work order consistency in stock entry) def set_process_loss_qty(self): if self.purpose not in ("Manufacture", "Repack"): From eee6d7e56684182f4aab51d787541d1650a9938e Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 7 Apr 2026 09:59:54 +0530 Subject: [PATCH 21/28] fix: process loss with bom path disassembly (cherry picked from commit 93ad48bc1bf7f21e280c0fc068c8e04ab8c422ec) # Conflicts: # erpnext/manufacturing/doctype/work_order/test_work_order.py --- .../doctype/work_order/test_work_order.py | 35 +++++++++++++++++++ .../stock/doctype/stock_entry/stock_entry.py | 6 ++++ 2 files changed, 41 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index f8223fa65be..fd4d590b140 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -3,8 +3,13 @@ import frappe +<<<<<<< HEAD from frappe.tests.utils import FrappeTestCase, change_settings, timeout from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, today +======= +from frappe.tests import timeout +from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, nowdate, nowtime, today +>>>>>>> 93ad48bc1b (fix: process loss with bom path disassembly) 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 @@ -2633,6 +2638,36 @@ class TestWorkOrder(FrappeTestCase): 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 3c9f0e46fb3..f6d8ca01c66 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2206,6 +2206,12 @@ class StockEntry(StockController): 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 84d5b524832b6dd31e1f1f190d5dd6f9a3b8a05e Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 7 Apr 2026 10:00:30 +0530 Subject: [PATCH 22/28] 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 f6d8ca01c66..dc85ace8545 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -336,7 +336,7 @@ class StockEntry(StockController): 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: @@ -2163,6 +2163,7 @@ class StockEntry(StockController): "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, } @@ -2273,6 +2274,7 @@ class StockEntry(StockController): SED.use_serial_batch_fields, SED.s_warehouse, SED.t_warehouse, + SED.bom_no, ] if stock_entry: From 7767659b87c5492578df43712e9ff1d5ec7779d8 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 7 Apr 2026 10:27:41 +0530 Subject: [PATCH 23/28] 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 fd4d590b140..3d84070748f 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2639,6 +2639,15 @@ class TestWorkOrder(FrappeTestCase): ) # -- 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 99df61a0d8f13fadb78a1e328dc36cbd29d03b0c Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 7 Apr 2026 10:54:34 +0530 Subject: [PATCH 24/28] 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 3d84070748f..acf095d1000 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2896,49 +2896,81 @@ class TestWorkOrder(FrappeTestCase): 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) @@ -2971,55 +3003,94 @@ class TestWorkOrder(FrappeTestCase): 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 0b0dccd294aa1595d72774b9b9241e6931e42b4a Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 7 Apr 2026 13:17:22 +0530 Subject: [PATCH 25/28] fix: remove unnecessary param, and use value from self (cherry picked from commit 98dfd64f6326cefc8882756eb0d30b26863a0556) # Conflicts: # erpnext/stock/doctype/stock_entry/stock_entry.py --- .../stock/doctype/stock_entry/stock_entry.py | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index dc85ace8545..faec439909c 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -312,9 +312,7 @@ class StockEntry(StockController): 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: @@ -2110,7 +2108,6 @@ class StockEntry(StockController): 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): @@ -2131,8 +2128,8 @@ class StockEntry(StockController): 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 @@ -2168,10 +2165,10 @@ class StockEntry(StockController): "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, } ) @@ -2218,6 +2215,7 @@ class StockEntry(StockController): # Finished goods self.load_items_from_bom() +<<<<<<< HEAD <<<<<<< HEAD <<<<<<< HEAD def get_items_from_manufacture_entry(self): @@ -2253,6 +2251,9 @@ class StockEntry(StockController): ======= def get_items_from_manufacture_stock_entry(self, stock_entry=None): >>>>>>> 3cf1ce8360 (fix: manufacture entry with group_by support) +======= + def get_items_from_manufacture_stock_entry(self): +>>>>>>> 98dfd64f63 (fix: remove unnecessary param, and use value from 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) @@ -2277,10 +2278,10 @@ class StockEntry(StockController): 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 904ac628302f205b2af104e51aab21e6fe2ac6a0 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 7 Apr 2026 14:51:35 +0530 Subject: [PATCH 26/28] chore: resolve conflicts --- .../doctype/work_order/test_work_order.py | 5 - .../doctype/work_order/work_order.js | 9 +- .../doctype/work_order/work_order.py | 22 +- .../doctype/stock_entry/stock_entry.json | 9 +- .../stock/doctype/stock_entry/stock_entry.py | 218 +----------------- 5 files changed, 8 insertions(+), 255 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index acf095d1000..631baa4229c 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -3,13 +3,8 @@ import frappe -<<<<<<< HEAD from frappe.tests.utils import FrappeTestCase, change_settings, timeout -from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, today -======= -from frappe.tests import timeout from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, nowdate, nowtime, today ->>>>>>> 93ad48bc1b (fix: process loss with bom path disassembly) 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 diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index b6f3f740e55..20ece137c50 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -868,10 +868,6 @@ erpnext.work_order = { return flt(max, precision("qty")); }, -<<<<<<< HEAD - show_prompt_for_qty_input: function (frm, purpose) { - let max = this.get_max_transferable_qty(frm, purpose); -======= show_disassembly_prompt: function (frm) { let max_qty = flt(frm.doc.produced_qty - frm.doc.disassembled_qty); @@ -926,9 +922,8 @@ erpnext.work_order = { }); }, - 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; ->>>>>>> 68e97808c5 (fix: disassembly prompt with source stock entry field) + show_prompt_for_qty_input: function (frm, purpose) { + let max = this.get_max_transferable_qty(frm, purpose); let fields = [ { diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 7ae844d6a67..3a1dc2c6360 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1480,18 +1480,13 @@ def set_work_order_ops(name): @frappe.whitelist() -<<<<<<< HEAD -def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None): -======= def make_stock_entry( work_order_id: str, purpose: str, qty: float | None = None, target_warehouse: str | None = None, - is_additional_transfer_entry: bool = False, source_stock_entry: str | None = None, ): ->>>>>>> 68e97808c5 (fix: disassembly prompt with source stock entry field) work_order = frappe.get_doc("Work Order", work_order_id) if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"): wip_warehouse = work_order.wip_warehouse @@ -1541,16 +1536,7 @@ def make_stock_entry( @frappe.whitelist() -<<<<<<< HEAD -<<<<<<< HEAD -def get_default_warehouse(): - doc = frappe.get_cached_doc("Manufacturing Settings") - -======= -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 @@ -1570,11 +1556,9 @@ def get_disassembly_available_qty(stock_entry_name: str, current_se_name: str | @frappe.whitelist() -def get_default_warehouse(company: str): - wip, fg, scrap = frappe.get_cached_value( - "Company", company, ["default_wip_warehouse", "default_fg_warehouse", "default_scrap_warehouse"] - ) ->>>>>>> 68e97808c5 (fix: disassembly prompt with source stock entry field) +def get_default_warehouse(): + doc = frappe.get_cached_doc("Manufacturing Settings") + return { "wip_warehouse": doc.default_wip_warehouse, "fg_warehouse": doc.default_fg_warehouse, diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json index 8559e1339e2..797bb8ac5dd 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.json +++ b/erpnext/stock/doctype/stock_entry/stock_entry.json @@ -11,6 +11,7 @@ "naming_series", "stock_entry_type", "outgoing_stock_entry", + "source_stock_entry", "purpose", "add_to_transit", "work_order", @@ -28,15 +29,7 @@ "column_break_eaoa", "set_posting_time", "inspection_required", -<<<<<<< HEAD "apply_putaway_rule", -======= - "column_break_jabv", - "work_order", - "subcontracting_order", - "outgoing_stock_entry", - "source_stock_entry", ->>>>>>> d4baa9a74a (fix: create source_stock_entry to refer to original manufacturing entry) "bom_info_section", "from_bom", "use_multi_level_bom", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index faec439909c..e2b4309cd9a 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -141,12 +141,8 @@ class StockEntry(StockController): scan_barcode: DF.Data | None select_print_heading: DF.Link | None set_posting_time: DF.Check -<<<<<<< HEAD source_address_display: DF.SmallText | None -======= - source_address_display: DF.TextEditor | None source_stock_entry: DF.Link | None ->>>>>>> d4baa9a74a (fix: create source_stock_entry to refer to original manufacturing entry) source_warehouse_address: DF.Link | None stock_entry_type: DF.Link subcontracting_order: DF.Link | None @@ -375,34 +371,17 @@ class StockEntry(StockController): self._set_serial_batch_bundle_for_disassembly_row(row, serial_nos, batches) -<<<<<<< HEAD - bundle_doc = SerialBatchCreation( - { - "item_code": row.item_code, - "warehouse": warehouse, - "posting_date": self.posting_date, - "posting_time": 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 ->>>>>>> 13b019ab8e (fix: set serial and batch from source stock entry - on disassemble) 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), + "posting_date": self.posting_date, + "posting_time": self.posting_time, "voucher_type": self.doctype, "voucher_no": self.name, "voucher_detail_no": row.name, @@ -2215,45 +2194,7 @@ class StockEntry(StockController): # Finished goods self.load_items_from_bom() -<<<<<<< HEAD -<<<<<<< HEAD -<<<<<<< HEAD - 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): -======= - def get_items_from_manufacture_stock_entry(self, stock_entry=None): ->>>>>>> 3cf1ce8360 (fix: manufacture entry with group_by support) -======= def get_items_from_manufacture_stock_entry(self): ->>>>>>> 98dfd64f63 (fix: remove unnecessary param, and use value from 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) @@ -2293,7 +2234,6 @@ class StockEntry(StockController): .groupby(SED.item_code) .orderby(SED.idx) .run(as_dict=True) ->>>>>>> 1ed0124ad7 (fix: add support to fetch items based on manufacture stock entry; fix how it's done from work order) ) @frappe.whitelist() @@ -2420,161 +2360,7 @@ class StockEntry(StockController): if self.pro_doc and self.pro_doc.scrap_warehouse: item["to_warehouse"] = self.pro_doc.scrap_warehouse -<<<<<<< HEAD self.add_to_stock_entry_detail(scrap_item_dict, bom_no=self.bom_no) -======= - if ( - self.purpose not in ["Material Transfer for Manufacture"] - and frappe.db.get_single_value("Manufacturing Settings", "backflush_raw_materials_based_on") - != "BOM" - and not skip_transfer - ): - return - - reservation_entries = self.get_available_reserved_materials() - if not reservation_entries: - return - - new_items_to_add = [] - for d in self.items: - if d.serial_and_batch_bundle or d.serial_no or d.batch_no: - continue - - key = (d.item_code, d.s_warehouse) - if details := reservation_entries.get(key): - original_qty = d.qty - if batches := details.get("batch_no"): - for batch_no, qty in batches.items(): - if original_qty <= 0: - break - - if qty <= 0: - continue - - if d.batch_no and original_qty > 0: - new_row = frappe.copy_doc(d) - new_row.name = None - new_row.batch_no = batch_no - new_row.qty = qty - new_row.idx = d.idx + 1 - if new_row.batch_no and details.get("batchwise_sn"): - new_row.serial_no = "\n".join( - details.get("batchwise_sn")[new_row.batch_no][: cint(new_row.qty)] - ) - - new_items_to_add.append(new_row) - original_qty -= qty - batches[batch_no] -= qty - - if qty >= d.qty and not d.batch_no: - d.batch_no = batch_no - batches[batch_no] -= d.qty - if d.batch_no and details.get("batchwise_sn"): - d.serial_no = "\n".join( - details.get("batchwise_sn")[d.batch_no][: cint(d.qty)] - ) - elif not d.batch_no: - d.batch_no = batch_no - d.qty = qty - original_qty -= qty - batches[batch_no] = 0 - - if d.batch_no and details.get("batchwise_sn"): - d.serial_no = "\n".join( - details.get("batchwise_sn")[d.batch_no][: cint(d.qty)] - ) - - if details.get("serial_no"): - d.serial_no = "\n".join(details.get("serial_no")[: cint(d.qty)]) - - d.use_serial_batch_fields = 1 - - for new_row in new_items_to_add: - self.append("items", new_row) - - 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) - - idx = 0 - for row in sorted_items: - idx += 1 - row.idx = idx - self.set("items", sorted_items) - - def get_available_reserved_materials(self): - reserved_entries = self.get_reserved_materials() - if not reserved_entries: - return {} - - itemwise_serial_batch_qty = frappe._dict() - - for d in reserved_entries: - key = (d.item_code, d.warehouse) - if key not in itemwise_serial_batch_qty: - itemwise_serial_batch_qty[key] = frappe._dict( - { - "serial_no": [], - "batch_no": defaultdict(float), - "batchwise_sn": defaultdict(list), - } - ) - - details = itemwise_serial_batch_qty[key] - if d.batch_no: - details.batch_no[d.batch_no] += d.qty - if d.serial_no: - details.batchwise_sn[d.batch_no].extend(d.serial_no.split("\n")) - elif d.serial_no: - details.serial_no.append(d.serial_no) - - return itemwise_serial_batch_qty - - def get_reserved_materials(self): - doctype = frappe.qb.DocType("Stock Reservation Entry") - serial_batch_doc = frappe.qb.DocType("Serial and Batch Entry") - - query = ( - frappe.qb.from_(doctype) - .inner_join(serial_batch_doc) - .on(doctype.name == serial_batch_doc.parent) - .select( - serial_batch_doc.serial_no, - serial_batch_doc.batch_no, - serial_batch_doc.qty, - doctype.item_code, - doctype.warehouse, - doctype.name, - doctype.transferred_qty, - doctype.consumed_qty, - ) - .where( - (doctype.docstatus == 1) - & (doctype.voucher_no == (self.work_order or self.subcontracting_order)) - & (serial_batch_doc.delivered_qty < serial_batch_doc.qty) - ) - .orderby(serial_batch_doc.idx) - ) - - return query.run(as_dict=True) - - def set_secondary_items(self): - if self.purpose in ["Manufacture", "Repack"]: - secondary_items_dict = self.get_secondary_items(self.fg_completed_qty) - for item in secondary_items_dict.values(): - if self.pro_doc and item.type: - if self.pro_doc.scrap_warehouse and item.type == "Scrap": - item["to_warehouse"] = self.pro_doc.scrap_warehouse - - if item.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_dict, bom_no=self.bom_no) ->>>>>>> ea392b2009 (fix: validate work order consistency in stock entry) def set_process_loss_qty(self): if self.purpose not in ("Manufacture", "Repack"): From 652bd396d4cbe0e96e2bc47de4e641d51965575f Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 7 Apr 2026 15:43:43 +0530 Subject: [PATCH 27/28] fix: add v15 compatibility for scrap item --- .../doctype/work_order/test_work_order.py | 25 ++++++++---------- .../stock/doctype/stock_entry/stock_entry.py | 26 ++++++------------- 2 files changed, 19 insertions(+), 32 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 631baa4229c..374caf369ea 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2519,13 +2519,11 @@ class TestWorkOrder(FrappeTestCase): 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, - scrap_items=[scrap_item], - scrap_qty=10, + item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2, do_not_submit=True ) + # add scrap item + bom.append("scrap_items", {"item_code": scrap_item, "stock_qty": 10}) + bom.submit() # Create WO wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started") @@ -2611,16 +2609,15 @@ class TestWorkOrder(FrappeTestCase): 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 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.assertEqual(scrap_row.is_scrap_item, 1) self.assertTrue(scrap_row.s_warehouse) self.assertFalse(scrap_row.t_warehouse) self.assertEqual(scrap_row.s_warehouse, wo.scrap_warehouse) - # 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) + # BOM has scrap_qty=10/FG, total produced = 10*10 = 100, disassemble 4/10 → 40 + self.assertEqual(scrap_row.qty, 40) # RM quantities for bom_item in bom.items: @@ -2665,11 +2662,11 @@ class TestWorkOrder(FrappeTestCase): 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 + # v15: BOM scrap_qty=10/FG, no process_loss_per field → qty = 10 * 2 = 20 self.assertEqual( bom_scrap_row.qty, - 18, - f"BOM-path disassembly must apply process_loss_per; expected 18, got {bom_scrap_row.qty}", + 20, + f"BOM-path disassembly scrap qty mismatch; expected 20, got {bom_scrap_row.qty}", ) def test_disassembly_with_additional_rm_not_in_bom(self): diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index e2b4309cd9a..f7b49839fdc 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -784,7 +784,7 @@ class StockEntry(StockController): if self.purpose == "Disassemble": if has_bom: - if d.is_finished_item or d.type or d.is_legacy_scrap_item: + if d.is_finished_item or d.is_scrap_item: d.t_warehouse = None if not d.s_warehouse: frappe.throw(_("Source warehouse is mandatory for row {0}").format(d.idx)) @@ -2136,9 +2136,7 @@ class StockEntry(StockController): "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, + "is_scrap_item": source_row.is_scrap_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, @@ -2168,9 +2166,9 @@ class StockEntry(StockController): 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 items (reverse: take scrap FROM scrap warehouse instead of producing TO it) + scrap_items = self.get_bom_scrap_material(self.fg_completed_qty) + if scrap_items: scrap_warehouse = self.from_warehouse if self.work_order: wo_values = frappe.db.get_value( @@ -2178,18 +2176,12 @@ class StockEntry(StockController): ) scrap_warehouse = wo_values.scrap_warehouse or scrap_warehouse or wo_values.fg_warehouse - for item in secondary_items.values(): + for item in scrap_items.values(): item["from_warehouse"] = scrap_warehouse 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) + self.add_to_stock_entry_detail(scrap_items, bom_no=self.bom_no) # Finished goods self.load_items_from_bom() @@ -2208,9 +2200,7 @@ class StockEntry(StockController): SED.basic_rate, SED.conversion_factor, SED.is_finished_item, - SED.type, - SED.is_legacy_scrap_item, - SED.bom_secondary_item, + SED.is_scrap_item, SED.batch_no, SED.serial_no, SED.use_serial_batch_fields, From 8b42fcf274bd56a65f2e59cda69c1a3c06ca7c93 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 7 Apr 2026 19:09:51 +0530 Subject: [PATCH 28/28] fix: ensure compatibility with v15 --- erpnext/manufacturing/doctype/work_order/work_order.py | 4 +++- erpnext/stock/doctype/stock_entry/stock_entry.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 3a1dc2c6360..a0877fcce35 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1550,7 +1550,9 @@ def get_disassembly_available_qty(stock_entry_name: str, current_se_name: str | if current_se_name: filters["name"] = ("!=", current_se_name) - already_disassembled = flt(frappe.db.get_value("Stock Entry", filters, [{"SUM": "fg_completed_qty"}])) + already_disassembled = flt( + frappe.db.get_value("Stock Entry", filters, "sum(fg_completed_qty)", order_by=None) + ) 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 f7b49839fdc..5f9e55cee50 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -2585,7 +2585,7 @@ class StockEntry(StockController): return item_dict def get_scrap_items_from_job_card(self): - if not self.pro_doc: + if not getattr(self, "pro_doc", None): self.set_work_order_details() if not self.pro_doc.operations: