From efdb004f0bcce1612c158a3376d806596a303707 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:33:06 +0530 Subject: [PATCH 01/61] fix(warehouse_capacity_dashboard): removed `escape` from template (backport #53907) (#53908) Co-authored-by: diptanilsaha fix(warehouse_capacity_dashboard): removed `escape` from template (#53907) --- .../warehouse_capacity_summary.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.html b/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.html index adab4786403..5a69c405364 100644 --- a/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.html +++ b/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.html @@ -32,8 +32,8 @@ class="btn btn-default btn-xs btn-edit" style="margin: 4px 0; float: left;" data-warehouse="{{ d.warehouse }}" - data-item="{{ escape(d.item_code) }}" - data-company="{{ escape(d.company) }}"> + data-item="{{ d.item_code }}" + data-company="{{ d.company }}"> {{ __("Edit Capacity") }} From 94fe32f18927c32f6bb42a122acaa08ae56b36d6 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 06:08:02 +0000 Subject: [PATCH 02/61] chore: remove inter warehouse transfer settings (backport #53860) (#53940) * chore: remove inter warehouse transfer settings (#53860) (cherry picked from commit 0696bd2082765f7943192433a926031b745fdf3c) # Conflicts: # erpnext/stock/doctype/stock_settings/stock_settings.json * chore: resolve conflicts --------- Co-authored-by: Nishka Gosalia <58264710+nishkagosalia@users.noreply.github.com> Co-authored-by: Mihir Kandoi --- .../stock_settings/stock_settings.json | 27 +------------ .../doctype/stock_settings/stock_settings.py | 39 ------------------- 2 files changed, 1 insertion(+), 65 deletions(-) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index a9a3fcbfae4..58ea8083087 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -73,10 +73,6 @@ "auto_indent", "column_break_27", "reorder_email_notify", - "inter_warehouse_transfer_settings_section", - "allow_from_dn", - "column_break_31", - "allow_from_pr", "stock_closing_tab", "control_historical_stock_transactions_section", "stock_frozen_upto", @@ -223,23 +219,6 @@ "fieldtype": "Data", "label": "Naming Series Prefix" }, - { - "fieldname": "inter_warehouse_transfer_settings_section", - "fieldtype": "Section Break", - "label": "Inter Warehouse Transfer Settings" - }, - { - "default": "0", - "fieldname": "allow_from_dn", - "fieldtype": "Check", - "label": "Allow Material Transfer from Delivery Note to Sales Invoice" - }, - { - "default": "0", - "fieldname": "allow_from_pr", - "fieldtype": "Check", - "label": "Allow Material Transfer from Purchase Receipt to Purchase Invoice" - }, { "description": "If mentioned, the system will allow only the users with this Role to create or modify any stock transaction earlier than the latest stock transaction for a specific item and warehouse. If set as blank, it allows all users to create/edit back-dated transactions.", "fieldname": "role_allowed_to_create_edit_back_dated_transactions", @@ -287,10 +266,6 @@ "fieldname": "column_break_27", "fieldtype": "Column Break" }, - { - "fieldname": "column_break_31", - "fieldtype": "Column Break" - }, { "fieldname": "quality_inspection_settings_section", "fieldtype": "Section Break", @@ -553,7 +528,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-02-25 09:56:34.105949", + "modified": "2026-03-27 22:39:16.812184", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index 69e626db1ba..c6c6bd49488 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -26,8 +26,6 @@ class StockSettings(Document): action_if_quality_inspection_is_not_submitted: DF.Literal["Stop", "Warn"] action_if_quality_inspection_is_rejected: DF.Literal["Stop", "Warn"] allow_existing_serial_no: DF.Check - allow_from_dn: DF.Check - allow_from_pr: DF.Check allow_internal_transfer_at_arms_length_price: DF.Check allow_negative_stock: DF.Check allow_negative_stock_for_batch: DF.Check @@ -235,9 +233,6 @@ class StockSettings(Document): ) ) - def on_update(self): - self.toggle_warehouse_field_for_inter_warehouse_transfer() - def change_precision_for_for_sales(self): doc_before_save = self.get_doc_before_save() if doc_before_save and ( @@ -288,40 +283,6 @@ class StockSettings(Document): validate_fields_for_doctype=False, ) - def toggle_warehouse_field_for_inter_warehouse_transfer(self): - make_property_setter( - "Sales Invoice Item", - "target_warehouse", - "hidden", - 1 - cint(self.allow_from_dn), - "Check", - validate_fields_for_doctype=False, - ) - make_property_setter( - "Delivery Note Item", - "target_warehouse", - "hidden", - 1 - cint(self.allow_from_dn), - "Check", - validate_fields_for_doctype=False, - ) - make_property_setter( - "Purchase Invoice Item", - "from_warehouse", - "hidden", - 1 - cint(self.allow_from_pr), - "Check", - validate_fields_for_doctype=False, - ) - make_property_setter( - "Purchase Receipt Item", - "from_warehouse", - "hidden", - 1 - cint(self.allow_from_pr), - "Check", - validate_fields_for_doctype=False, - ) - def clean_all_descriptions(): for item in frappe.get_all("Item", ["name", "description"]): From e159c797667d679aa5fb20ce6abdca23f4a07d21 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:29:24 +0530 Subject: [PATCH 03/61] fix: do not show inv dimension unnecessarily in stock entry (backport #53946) (#53950) Co-authored-by: Mihir Kandoi fix: do not show inv dimension unnecessarily in stock entry (#53946) --- .../stock/doctype/inventory_dimension/inventory_dimension.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py index 7349838e816..fbef891b745 100644 --- a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py @@ -181,6 +181,7 @@ class InventoryDimension(Document): insert_after="inventory_dimension", options=self.reference_document, label=_(label), + depends_on="eval:doc.s_warehouse" if doctype == "Stock Entry Detail" else "", search_index=1, reqd=self.reqd, mandatory_depends_on=self.mandatory_depends_on, @@ -273,7 +274,7 @@ class InventoryDimension(Document): elif doctype != "Stock Entry Detail": display_depends_on = "eval:parent.is_internal_customer == 1" elif doctype == "Stock Entry Detail": - display_depends_on = "eval:parent.purpose != 'Material Issue'" + display_depends_on = "eval:doc.t_warehouse" fieldname = f"{fieldname_start_with}_{self.source_fieldname}" label = f"{label_start_with} {self.dimension_name}" From 22774fdf877db3eccf15d9783f4054373ba04ced Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 31 Mar 2026 19:22:02 +0530 Subject: [PATCH 04/61] revert: botched backport (#53967) fix(manufacturing): apply work order status filter in job card (#53776)" fix(manufacturing): apply work order status filter in job card (backport #53766) (#53767)" --- .../doctype/job_card/job_card.js | 36 +++++-------------- 1 file changed, 8 insertions(+), 28 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index d911456f602..e096c73cc61 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -31,34 +31,6 @@ frappe.ui.form.on("Job Card", { }; }); - frm.set_query("operation", "time_logs", () => { - let operations = (frm.doc.sub_operations || []).map((d) => d.sub_operation); - return { - filters: { - name: ["in", operations], - }, - }; - }); - - frm.set_query("work_order", function () { - return { - filters: { - status: ["not in", ["Cancelled", "Closed", "Stopped"]], - }, - }; - }); - - frm.events.set_company_filters(frm, "target_warehouse"); - frm.events.set_company_filters(frm, "source_warehouse"); - frm.events.set_company_filters(frm, "wip_warehouse"); - frm.set_query("source_warehouse", "items", () => { - return { - filters: { - company: frm.doc.company, - }, - }; - }); - frm.set_indicator_formatter("sub_operation", function (doc) { if (doc.status == "Pending") { return "red"; @@ -75,6 +47,14 @@ frappe.ui.form.on("Job Card", { }, }; }); + + frm.set_query("work_order", function () { + return { + filters: { + status: ["not in", ["Cancelled", "Closed", "Stopped"]], + }, + }; + }); }, refresh: function (frm) { From 3fbfad1b9b4fb00de1cf5b2c4fa238ab356aa9a0 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:15:42 +0000 Subject: [PATCH 05/61] fix: include rejected qty in tax (purchase receipt) (backport #53624) (#53971) Co-authored-by: Mihir Kandoi fix: include rejected qty in tax (purchase receipt) (#53624) --- erpnext/controllers/buying_controller.py | 2 +- erpnext/controllers/taxes_and_totals.py | 20 +++++++++++-- .../purchase_receipt/purchase_receipt.py | 2 +- .../purchase_receipt/test_purchase_receipt.py | 29 ++++++++++++++++++- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index dea76428d90..eedb0ce6eba 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -364,7 +364,7 @@ class BuyingController(SubcontractingController): get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0 ) - net_rate = item.base_net_amount + net_rate = item.qty * item.base_net_rate if item.sales_incoming_rate: # for internal transfer net_rate = item.qty * item.sales_incoming_rate diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 337ffbfeb0c..0e8effb9287 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -183,6 +183,9 @@ class calculate_taxes_and_totals: return if not self.discount_amount_applied: + bill_for_rejected_quantity_in_purchase_invoice = frappe.get_single_value( + "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice" + ) for item in self.doc.items: self.doc.round_floats_in(item) @@ -238,7 +241,13 @@ class calculate_taxes_and_totals: elif not item.qty and self.doc.get("is_debit_note"): item.amount = flt(item.rate, item.precision("amount")) else: - item.amount = flt(item.rate * item.qty, item.precision("amount")) + qty = ( + (item.qty + item.rejected_qty) + if bill_for_rejected_quantity_in_purchase_invoice + and self.doc.doctype == "Purchase Receipt" + else item.qty + ) + item.amount = flt(item.rate * qty, item.precision("amount")) item.net_amount = item.amount @@ -370,9 +379,16 @@ class calculate_taxes_and_totals: self.doc.total ) = self.doc.base_total = self.doc.net_total = self.doc.base_net_total = 0.0 + bill_for_rejected_quantity_in_purchase_invoice = frappe.get_single_value( + "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice" + ) for item in self._items: self.doc.total += item.amount - self.doc.total_qty += item.qty + self.doc.total_qty += ( + (item.qty + item.rejected_qty) + if bill_for_rejected_quantity_in_purchase_invoice and self.doc.doctype == "Purchase Receipt" + else item.qty + ) self.doc.base_total += item.base_amount self.doc.net_total += item.net_amount self.doc.base_net_total += item.base_net_amount diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index e9291e10baf..b27a2fc6549 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -510,7 +510,7 @@ class PurchaseReceipt(BuyingController): else flt(item.net_amount, item.precision("net_amount")) ) - outgoing_amount = item.base_net_amount + outgoing_amount = item.qty * item.base_net_rate if self.is_internal_transfer() and item.valuation_rate: outgoing_amount = abs(get_stock_value_difference(self.name, item.name, item.from_warehouse)) credit_amount = outgoing_amount diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 8508031578d..59ebbf681ea 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -4539,7 +4539,7 @@ class TestPurchaseReceipt(FrappeTestCase): self.assertEqual(srbnb_cost, 1500) - def test_valuation_rate_for_rejected_materials_withoout_accepted_materials(self): + def test_valuation_rate_for_rejected_materials_without_accepted_materials(self): item = make_item("Test Item with Rej Material Valuation WO Accepted", {"is_stock_item": 1}) company = "_Test Company with perpetual inventory" @@ -5106,6 +5106,33 @@ class TestPurchaseReceipt(FrappeTestCase): self.assertEqual(row.warehouse, "_Test Warehouse 1 - _TC") self.assertEqual(row.incoming_rate, 100) + def test_bill_for_rejected_quantity_in_purchase_invoice(self): + item_code = make_item("Test Rejected Qty", {"is_stock_item": 1}).name + + frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 0) + pr = make_purchase_receipt( + item_code=item_code, + qty=10, + rejected_qty=2, + rate=10, + warehouse="_Test Warehouse - _TC", + ) + + self.assertEqual(pr.total_qty, 10) + self.assertEqual(pr.total, 100) + + frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 1) + pr = make_purchase_receipt( + item_code=item_code, + qty=10, + rejected_qty=2, + rate=10, + warehouse="_Test Warehouse - _TC", + ) + + self.assertEqual(pr.total_qty, 12) + self.assertEqual(pr.total, 120) + def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier From 1d36cb55cdc8ce9f59a82a89b84f44c7d3c0f0c0 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Fri, 7 Nov 2025 15:25:47 +0530 Subject: [PATCH 06/61] feat: Allow Editing of Items and Quantities in Work Order --- .../manufacturing_settings/manufacturing_settings.json | 10 +++++++++- .../manufacturing_settings/manufacturing_settings.py | 1 + erpnext/manufacturing/doctype/work_order/work_order.js | 5 +++++ erpnext/manufacturing/doctype/work_order/work_order.py | 7 ++++++- 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json index 277200af310..64a1c870ce4 100644 --- a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json +++ b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json @@ -15,6 +15,7 @@ "bom_section", "update_bom_costs_automatically", "column_break_lhyt", + "allow_editing_of_items_and_quantities_in_work_order", "section_break_6", "default_wip_warehouse", "default_fg_warehouse", @@ -243,13 +244,20 @@ "fieldname": "enforce_time_logs", "fieldtype": "Check", "label": "Enforce Time Logs" + }, + { + "default": "0", + "description": "If enabled, the system will allow users to edit the raw materials and their quantities in the Work Order. The system will not reset the quantities as per the BOM, if the user has changed them.", + "fieldname": "allow_editing_of_items_and_quantities_in_work_order", + "fieldtype": "Check", + "label": "Allow Editing of Items and Quantities in Work Order" } ], "icon": "icon-wrench", "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-05-16 11:23:16.916512", + "modified": "2025-11-07 14:52:56.241459", "modified_by": "Administrator", "module": "Manufacturing", "name": "Manufacturing Settings", diff --git a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py index e9f011f78ba..44d42cccae0 100644 --- a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py +++ b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py @@ -18,6 +18,7 @@ class ManufacturingSettings(Document): from frappe.types import DF add_corrective_operation_cost_in_finished_good_valuation: DF.Check + allow_editing_of_items_and_quantities_in_work_order: DF.Check allow_overtime: DF.Check allow_production_on_holidays: DF.Check backflush_raw_materials_based_on: DF.Literal["BOM", "Material Transferred for Manufacture"] diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index b6206cefcbb..8e8d359430f 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -243,6 +243,11 @@ frappe.ui.form.on("Work Order", { frm.trigger("add_custom_button_to_return_components"); frm.trigger("allow_alternative_item"); + frm.trigger("toggle_items_editable"); + }, + + toggle_items_editable(frm) { + frm.toggle_enable("required_items", frm.doc.__onload?.allow_editing_items === 1 ? 1 : 0); }, add_custom_button_to_return_components: function (frm) { diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index bc3def1186f..e1a561958ed 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -141,6 +141,7 @@ class WorkOrder(Document): def onload(self): ms = frappe.get_doc("Manufacturing Settings") + self.set_onload("allow_editing_items", ms.allow_editing_of_items_and_quantities_in_work_order) self.set_onload("material_consumption", ms.material_consumption) self.set_onload("backflush_raw_materials_based_on", ms.backflush_raw_materials_based_on) self.set_onload("overproduction_percentage", ms.overproduction_percentage_for_work_order) @@ -167,7 +168,11 @@ class WorkOrder(Document): validate_uom_is_integer(self, "stock_uom", ["required_qty"]) - self.set_required_items(reset_only_qty=len(self.get("required_items"))) + if not len(self.get("required_items")) or not frappe.db.get_single_value( + "Manufacturing Settings", "allow_editing_of_items_and_quantities_in_work_order" + ): + self.set_required_items(reset_only_qty=len(self.get("required_items"))) + self.validate_operations_sequence() def validate_operations_sequence(self): From 62d58702a03f016893a9e7537425ce37a9cb729e Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Tue, 2 Dec 2025 11:18:07 +0530 Subject: [PATCH 07/61] fix: not able to set operation in work order --- erpnext/manufacturing/doctype/work_order/work_order.js | 8 +++++++- .../doctype/work_order_item/work_order_item.json | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 8e8d359430f..4e2ba94e308 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -247,7 +247,13 @@ frappe.ui.form.on("Work Order", { }, toggle_items_editable(frm) { - frm.toggle_enable("required_items", frm.doc.__onload?.allow_editing_items === 1 ? 1 : 0); + if (!frm.doc.__onload?.allow_editing_items) { + frm.set_df_property("required_items", "cannot_delete_rows", true); + frm.set_df_property("required_items", "cannot_add_rows", true); + frm.fields_dict["required_items"].grid.update_docfield_property("item_code", "read_only", 1); + frm.fields_dict["required_items"].grid.update_docfield_property("required_qty", "read_only", 1); + frm.fields_dict["required_items"].grid.refresh(); + } }, add_custom_button_to_return_components: function (frm) { diff --git a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json index 580168180a7..98ee0a63d53 100644 --- a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json +++ b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json @@ -151,7 +151,7 @@ ], "istable": 1, "links": [], - "modified": "2024-11-19 15:48:16.823384", + "modified": "2025-12-02 11:16:05.081613", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order Item", From 3c327d5225849b84db403a7901fe3e0fd48eceb6 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Wed, 1 Apr 2026 08:43:56 +0530 Subject: [PATCH 08/61] fix(ux): refresh grid to correctly persist the state of fields --- .../doctype/work_order/work_order.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 4e2ba94e308..f8bbab0ff95 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -247,13 +247,16 @@ frappe.ui.form.on("Work Order", { }, toggle_items_editable(frm) { - if (!frm.doc.__onload?.allow_editing_items) { - frm.set_df_property("required_items", "cannot_delete_rows", true); - frm.set_df_property("required_items", "cannot_add_rows", true); - frm.fields_dict["required_items"].grid.update_docfield_property("item_code", "read_only", 1); - frm.fields_dict["required_items"].grid.update_docfield_property("required_qty", "read_only", 1); - frm.fields_dict["required_items"].grid.refresh(); - } + let allow_edit = true; + if (!frm.doc.__onload?.allow_editing_items) allow_edit = false; + + frm.set_df_property("required_items", "cannot_delete_rows", !allow_edit); + frm.set_df_property("required_items", "cannot_add_rows", !allow_edit); + + const grid = frm.fields_dict["required_items"].grid; + grid.update_docfield_property("item_code", "read_only", !allow_edit); + grid.update_docfield_property("required_qty", "read_only", !allow_edit); + grid.refresh(); }, add_custom_button_to_return_components: function (frm) { From 7f721896657f2706ab445b4d68f1b033eaef2280 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Fri, 3 Apr 2026 00:33:17 +0530 Subject: [PATCH 09/61] fix(test): pin posting date in test_depreciation_on_cancel_invoice --- erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index c515322348a..a8fb4ab93a0 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -3230,7 +3230,7 @@ class TestSalesInvoice(FrappeTestCase): calculate_depreciation=1, submit=1, ) - post_depreciation_entries() + post_depreciation_entries(date="2025-04-01") si = create_sales_invoice( item_code="Macbook Pro", asset=asset.name, qty=1, rate=10000, posting_date=getdate("2025-05-01") From 05d6cf5c9a4f0b3de830709b3dfc87038f846ae7 Mon Sep 17 00:00:00 2001 From: kavin-114 Date: Thu, 2 Apr 2026 23:27:24 +0530 Subject: [PATCH 10/61] fix(stock): update stock queue in SABE for return entries (cherry picked from commit 0af8077bcc828422593dfa51b99bcac249a8bbed) --- .../serial_and_batch_bundle.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 4de6ebc6a00..f1457e1a10b 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -400,6 +400,25 @@ class SerialandBatchBundle(Document): def set_valuation_rate_for_return_entry(self, return_against, row, save=False, prev_sle=None): if valuation_details := self.get_valuation_rate_for_return_entry(return_against): + from erpnext.stock.utils import get_valuation_method + + valuation_method = get_valuation_method(self.item_code, self.company) + + stock_queue = [] + non_batchwise_batches = [] + if not self.has_serial_no and valuation_method == "FIFO": + non_batchwise_batches = frappe.get_all( + "Batch", + filters={ + "name": ("in", [d.batch_no for d in self.entries if d.batch_no]), + "use_batchwise_valuation": 0, + }, + pluck="name", + ) + + if non_batchwise_batches and prev_sle and prev_sle.stock_queue: + stock_queue = parse_json(prev_sle.stock_queue) + for row in self.entries: if valuation_details: self.validate_returned_serial_batch_no(return_against, row, valuation_details) @@ -421,11 +440,25 @@ class SerialandBatchBundle(Document): row.incoming_rate = flt(valuation_rate) row.stock_value_difference = flt(row.qty) * flt(row.incoming_rate) + if ( + non_batchwise_batches + and row.batch_no in non_batchwise_batches + and row.incoming_rate is not None + ): + if flt(row.qty) > 0: + stock_queue.append([row.qty, row.incoming_rate]) + elif flt(row.qty) < 0: + stock_queue = FIFOValuation(stock_queue) + stock_queue.remove_stock(qty=abs(row.qty)) + stock_queue = stock_queue.state + row.stock_queue = json.dumps(stock_queue) + if save: row.db_set( { "incoming_rate": row.incoming_rate, "stock_value_difference": row.stock_value_difference, + "stock_queue": row.get("stock_queue"), } ) From b57db06100b1abe489399a2ad984ed81700228b4 Mon Sep 17 00:00:00 2001 From: kavin-114 Date: Fri, 3 Apr 2026 00:02:42 +0530 Subject: [PATCH 11/61] test(stock): add unit test to update stock queue for return (cherry picked from commit e537896df882f81fcabd999a9aa74f1cd1aa7462) # Conflicts: # erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py --- .../test_serial_and_batch_bundle.py | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index 51b939c343d..90b91bd148d 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -1071,6 +1071,208 @@ class TestSerialandBatchBundle(FrappeTestCase): self.assertTrue(bundle_doc.docstatus == 0) self.assertRaises(frappe.ValidationError, bundle_doc.submit) +<<<<<<< HEAD +======= + def test_reference_voucher_on_cancel(self): + """ + When a source document is cancelled, the reference voucher field + in the respective serial or batch document should be nullified. + """ + + item_code = make_item( + "Serial Item", + properties={ + "is_stock_item": 1, + "has_serial_no": 1, + "serial_no_series": "SERIAL.#####", + }, + ).name + + se = make_stock_entry( + item_code=item_code, + qty=1, + target="_Test Warehouse - _TC", + ) + serial_no = get_serial_nos_from_bundle(se.items[0].serial_and_batch_bundle)[0] + self.assertEqual(frappe.get_value("Serial No", serial_no, "reference_name"), se.name) + + se.cancel() + self.assertIsNone(frappe.get_value("Serial No", serial_no, "reference_name")) + + se1 = frappe.copy_doc(se, ignore_no_copy=False) + se1.items[0].serial_no = serial_no + se1.submit() + + self.assertEqual(frappe.get_value("Serial No", serial_no, "reference_name"), se1.name) + + def test_stock_queue_for_return_entry_with_non_batchwise_valuation(self): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + + batch_item_code = "Old Batch Return Queue Test" + make_item( + batch_item_code, + { + "has_batch_no": 1, + "batch_number_series": "TEST-RET-Q-.#####", + "create_new_batch": 1, + "is_stock_item": 1, + "valuation_method": "FIFO", + }, + ) + + batch_id = "Old Batch Return Queue 1" + if not frappe.db.exists("Batch", batch_id): + batch_doc = frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_id, + "item": batch_item_code, + "use_batchwise_valuation": 0, + } + ).insert(ignore_permissions=True) + + batch_doc.db_set( + { + "use_batchwise_valuation": 0, + "batch_qty": 0, + } + ) + + # Create initial stock with FIFO queue: [[10, 100], [20, 200]] + make_stock_entry( + item_code=batch_item_code, + target="_Test Warehouse - _TC", + qty=10, + rate=100, + batch_no=batch_id, + use_serial_batch_fields=True, + ) + + make_stock_entry( + item_code=batch_item_code, + target="_Test Warehouse - _TC", + qty=20, + rate=200, + batch_no=batch_id, + use_serial_batch_fields=True, + ) + + # Purchase Receipt: inward 5 @ 300 + pr = make_purchase_receipt( + item_code=batch_item_code, + warehouse="_Test Warehouse - _TC", + qty=5, + rate=300, + batch_no=batch_id, + use_serial_batch_fields=True, + ) + + sle = frappe.db.get_value( + "Stock Ledger Entry", + {"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": pr.name}, + ["stock_queue"], + as_dict=True, + ) + + # Stock queue should now be [[10, 100], [20, 200], [5, 300]] + self.assertEqual(json.loads(sle.stock_queue), [[10, 100], [20, 200], [5, 300]]) + + # Purchase Return: return 5 against the PR + return_pr = make_return_doc("Purchase Receipt", pr.name) + return_pr.submit() + + return_sle = frappe.db.get_value( + "Stock Ledger Entry", + {"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": return_pr.name}, + ["stock_queue"], + as_dict=True, + ) + + # Stock queue should have 5 removed via FIFO from [[10, 100], [20, 200], [5, 300]] + # FIFO removes from front: [10, 100] -> [5, 100], rest unchanged + self.assertEqual(json.loads(return_sle.stock_queue), [[5, 100], [20, 200], [5, 300]]) + + def test_stock_queue_for_return_entry_with_empty_fifo_queue(self): + """Credit note (sales return) against empty FIFO queue should still rebuild stock_queue.""" + from erpnext.controllers.sales_and_purchase_return import make_return_doc + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + + batch_item_code = "Old Batch Empty Queue Test" + make_item( + batch_item_code, + { + "has_batch_no": 1, + "batch_number_series": "TEST-EQ-.#####", + "create_new_batch": 1, + "is_stock_item": 1, + "valuation_method": "FIFO", + }, + ) + + batch_id = "Old Batch Empty Queue 1" + if not frappe.db.exists("Batch", batch_id): + batch_doc = frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_id, + "item": batch_item_code, + "use_batchwise_valuation": 0, + } + ).insert(ignore_permissions=True) + + batch_doc.db_set( + { + "use_batchwise_valuation": 0, + "batch_qty": 0, + } + ) + + # Inward 10 @ 100, then outward all 10 to empty the queue + make_stock_entry( + item_code=batch_item_code, + target="_Test Warehouse - _TC", + qty=10, + rate=100, + batch_no=batch_id, + use_serial_batch_fields=True, + ) + + dn = create_delivery_note( + item_code=batch_item_code, + warehouse="_Test Warehouse - _TC", + qty=10, + rate=150, + batch_no=batch_id, + use_serial_batch_fields=True, + ) + + # Verify queue is empty after full outward + sle = frappe.db.get_value( + "Stock Ledger Entry", + {"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": dn.name}, + ["stock_queue"], + as_dict=True, + ) + self.assertFalse(json.loads(sle.stock_queue or "[]")) + + # Sales return (credit note): 5 items come back at original rate 100 + return_dn = make_return_doc("Delivery Note", dn.name) + for row in return_dn.items: + row.qty = -5 + return_dn.save().submit() + + return_sle = frappe.db.get_value( + "Stock Ledger Entry", + {"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": return_dn.name}, + ["stock_queue"], + as_dict=True, + ) + + # Stock queue should have the returned stock: [[5, 100]] + self.assertEqual(json.loads(return_sle.stock_queue), [[5, 100]]) + +>>>>>>> e537896df8 (test(stock): add unit test to update stock queue for return) def get_batch_from_bundle(bundle): from erpnext.stock.serial_batch_bundle import get_batch_nos From cb0a548a95c87d55d8fd9eef3eab7d10ad8b67f1 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 12:48:16 +0530 Subject: [PATCH 12/61] fix(manufacturing): handle null cur_dialog in BOM work order dialog (backport #54011) (#54014) --- erpnext/manufacturing/doctype/bom/bom.js | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index 525b6aecee7..ed1628ded5f 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -264,6 +264,7 @@ frappe.ui.form.on("BOM", { reqd: 1, default: 1, onchange: () => { + if (!cur_dialog) return; const { quantity, items: rm } = frm.doc; const variant_items_map = rm.reduce((acc, item) => { acc[item.item_code] = item.qty; From e33abeef7f7a42e185d6818974906f6c4370e91a Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 15:39:20 +0000 Subject: [PATCH 13/61] fix: remove reference in serial/batch when document is cancelled (backport #53979) (#53988) --- .../serial_and_batch_bundle.py | 14 ++++++++ .../test_serial_and_batch_bundle.py | 32 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 4de6ebc6a00..84a2649e190 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -1446,6 +1446,7 @@ class SerialandBatchBundle(Document): def on_cancel(self): self.validate_voucher_no_docstatus() self.validate_batch_quantity() + self.remove_source_document_no() def validate_batch_quantity(self): if not self.has_batch_no: @@ -1464,6 +1465,19 @@ class SerialandBatchBundle(Document): if flt(available_qty, precision) < 0: self.throw_negative_batch(d.batch_no, available_qty, precision) + def remove_source_document_no(self): + if not self.has_serial_no: + return + + if self.total_qty > 0: + serial_nos = [d.serial_no for d in self.entries if d.serial_no] + sn_table = frappe.qb.DocType("Serial No") + ( + frappe.qb.update(sn_table) + .set(sn_table.purchase_document_no, None) + .where((sn_table.name.isin(serial_nos)) & (sn_table.purchase_document_no == self.voucher_no)) + ).run() + def throw_negative_batch(self, batch_no, available_qty, precision, posting_datetime=None): from erpnext.stock.stock_ledger import NegativeStockError diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index 51b939c343d..7b910f58e73 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -1071,6 +1071,38 @@ class TestSerialandBatchBundle(FrappeTestCase): self.assertTrue(bundle_doc.docstatus == 0) self.assertRaises(frappe.ValidationError, bundle_doc.submit) + def test_reference_voucher_on_cancel(self): + """ + When a source document is cancelled, the reference voucher field + in the respective serial or batch document should be nullified. + """ + + item_code = make_item( + "Serial Item", + properties={ + "is_stock_item": 1, + "has_serial_no": 1, + "serial_no_series": "SERIAL.#####", + }, + ).name + + se = make_stock_entry( + item_code=item_code, + qty=1, + target="_Test Warehouse - _TC", + ) + serial_no = get_serial_nos_from_bundle(se.items[0].serial_and_batch_bundle)[0] + self.assertEqual(frappe.get_value("Serial No", serial_no, "purchase_document_no"), se.name) + + se.cancel() + self.assertIsNone(frappe.get_value("Serial No", serial_no, "purchase_document_no")) + + se1 = frappe.copy_doc(se, ignore_no_copy=False) + se1.items[0].serial_no = serial_no + se1.submit() + + self.assertEqual(frappe.get_value("Serial No", serial_no, "purchase_document_no"), se1.name) + def get_batch_from_bundle(bundle): from erpnext.stock.serial_batch_bundle import get_batch_nos From a71d32e668efdac630c1a5faa4a3596e2a957fbb Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sun, 5 Apr 2026 21:16:48 +0530 Subject: [PATCH 14/61] fix: update min date based on transaction_date (backport #53803) (#54024) Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com> fix: update min date based on transaction_date (#53803) --- erpnext/selling/doctype/sales_order/sales_order.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 38334cc29bc..1316252005f 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -63,6 +63,13 @@ frappe.ui.form.on("Sales Order", { }); } }, + transaction_date(frm) { + prevent_past_delivery_dates(frm); + frm.set_value("delivery_date", ""); + frm.doc.items.forEach((d) => { + frappe.model.set_value(d.doctype, d.name, "delivery_date", ""); + }); + }, refresh: function (frm) { if (frm.doc.docstatus === 1) { From af0116cdc57359c17be8baf6ab6fc8bf91dfa9ee Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 05:36:09 +0000 Subject: [PATCH 15/61] fix: show current stock qty in Stock Entry PDF (backport #53761) (#54031) --- erpnext/stock/doctype/stock_entry/stock_entry.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 24e704e07b2..fec92256108 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -183,6 +183,13 @@ class StockEntry(StockController): ) def onload(self): + self.update_items_from_bin_details() + + def before_print(self, settings=None): + super().before_print(settings) + self.update_items_from_bin_details() + + def update_items_from_bin_details(self): for item in self.get("items"): item.update(get_bin_details(item.item_code, item.s_warehouse)) From 7a227e048ee15f450cbf0b000d5226194144b1a9 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Thu, 26 Mar 2026 16:23:14 +0530 Subject: [PATCH 16/61] fix: prevent selection of group type customer group in customer master (cherry picked from commit 6068dc959f5c0594749f8f6bb3406c77c08e131d) --- erpnext/selling/doctype/customer/customer.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 1ce480fbd2e..8b5761f5930 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -145,6 +145,7 @@ class Customer(TransactionBase): def validate(self): self.flags.is_new_doc = self.is_new() self.flags.old_lead = self.lead_name + self.validate_customer_group() validate_party_accounts(self) self.validate_credit_limit_on_change() self.set_loyalty_program() @@ -324,6 +325,17 @@ class Customer(TransactionBase): frappe.NameError, ) + def validate_customer_group(self): + if not self.customer_group: + return + + is_group = frappe.db.get_value("Customer Group", self.customer_group, "is_group") + if is_group: + frappe.throw( + _("Cannot select a Group type Customer Group. Please select a non-group Customer Group."), + title=_("Invalid Customer Group"), + ) + def validate_credit_limit_on_change(self): if self.get("__islocal") or not self.credit_limits: return From 97684d3daed4da01de7dc24f8fcef6ae426d3af4 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Tue, 31 Mar 2026 15:29:22 +0530 Subject: [PATCH 17/61] fix(test): do not use is_group enabled customer group in test (cherry picked from commit 75fa2b227711eb4e90d5c868db80cc42b25fe2f7) --- .../doctype/bank_transaction/test_bank_transaction.py | 4 ++-- .../test_opening_invoice_creation_tool.py | 2 +- erpnext/controllers/tests/test_qty_based_taxes.py | 2 +- erpnext/stock/doctype/shipment/test_shipment.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py index 303633ac4bb..4294c4462b1 100644 --- a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py @@ -398,7 +398,7 @@ def add_vouchers(gl_account="_Test Bank - _TC"): frappe.get_doc( { "doctype": "Customer", - "customer_group": "All Customer Groups", + "customer_group": "Individual", "customer_type": "Company", "customer_name": "Poore Simon's", } @@ -429,7 +429,7 @@ def add_vouchers(gl_account="_Test Bank - _TC"): frappe.get_doc( { "doctype": "Customer", - "customer_group": "All Customer Groups", + "customer_group": "Individual", "customer_type": "Company", "customer_name": "Fayva", } diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py index 378fbded863..bfab823f495 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py @@ -209,7 +209,7 @@ def make_customer(customer=None): { "doctype": "Customer", "customer_name": customer_name, - "customer_group": "All Customer Groups", + "customer_group": "Individual", "customer_type": "Company", "territory": "All Territories", } diff --git a/erpnext/controllers/tests/test_qty_based_taxes.py b/erpnext/controllers/tests/test_qty_based_taxes.py index e7896b57f23..6233f6af9f4 100644 --- a/erpnext/controllers/tests/test_qty_based_taxes.py +++ b/erpnext/controllers/tests/test_qty_based_taxes.py @@ -66,7 +66,7 @@ class TestTaxes(unittest.TestCase): { "doctype": "Customer", "customer_name": uuid4(), - "customer_group": "All Customer Groups", + "customer_group": "Individual", } ).insert() self.supplier = frappe.get_doc( diff --git a/erpnext/stock/doctype/shipment/test_shipment.py b/erpnext/stock/doctype/shipment/test_shipment.py index 1c91a054ebc..41ddc6ea52e 100644 --- a/erpnext/stock/doctype/shipment/test_shipment.py +++ b/erpnext/stock/doctype/shipment/test_shipment.py @@ -177,7 +177,7 @@ def create_shipment_customer(customer_name): customer = frappe.new_doc("Customer") customer.customer_name = customer_name customer.customer_type = "Company" - customer.customer_group = "All Customer Groups" + customer.customer_group = "Individual" customer.territory = "All Territories" customer.insert() return customer From 13eab9f993c7037ab235d76838bb415cda7fa7ef Mon Sep 17 00:00:00 2001 From: Sagar Vora <16315650+sagarvora@users.noreply.github.com> Date: Mon, 6 Apr 2026 12:33:49 +0530 Subject: [PATCH 18/61] fix: skip discount amount validation when not saving (cherry picked from commit 09755833881742fe6f14bddd3c215f5029ab1d28) --- erpnext/controllers/taxes_and_totals.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 0e8effb9287..e75dd3dacd2 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -740,7 +740,8 @@ class calculate_taxes_and_totals: discount_amount += total_return_discount # validate that discount amount cannot exceed the total before discount - if ( + # only during save (i.e. when `_action` is set) + if self.doc.get("_action") and ( (grand_total >= 0 and discount_amount > grand_total) or (grand_total < 0 and discount_amount < grand_total) # returns ): From 1ffbc399e17c9c6b9f0a8ac9fc09b5f7e0533654 Mon Sep 17 00:00:00 2001 From: Sagar Vora <16315650+sagarvora@users.noreply.github.com> Date: Mon, 6 Apr 2026 12:37:32 +0530 Subject: [PATCH 19/61] test: add test for discount amount on partial purchase receipt Co-authored-by: ravibharathi656 <131471282+ravibharathi656@users.noreply.github.com> (cherry picked from commit 135cb5fd670ddd2aa1642282fd5aad300fd006ff) --- .../purchase_order/test_purchase_order.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index ae7898f9a07..c4394c066e0 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -289,6 +289,30 @@ class TestPurchaseOrder(FrappeTestCase): # ordered qty should decrease (back to initial) on row deletion self.assertEqual(get_ordered_qty(), existing_ordered_qty) + def test_discount_amount_partial_purchase_receipt(self): + po = create_purchase_order(qty=4, rate=100, do_not_save=1) + po.apply_discount_on = "Grand Total" + po.discount_amount = 120 + po.save() + po.submit() + + self.assertEqual(po.grand_total, 280) + + pr1 = make_purchase_receipt(po.name) + pr1.items[0].qty = 3 + pr1.save() + pr1.submit() + + self.assertEqual(pr1.discount_amount, 120) + self.assertEqual(pr1.grand_total, 180) + + pr2 = make_purchase_receipt(po.name) + pr2.save() + pr2.submit() + + self.assertEqual(pr2.discount_amount, 0) + self.assertEqual(pr2.grand_total, 100) + def test_update_child_perm(self): po = create_purchase_order(item_code="_Test Item", qty=4) From f855cc89c9d77a868f1b325d28b8fdcfd4bd3d01 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 6 Apr 2026 13:12:03 +0530 Subject: [PATCH 20/61] chore: fix conflicts --- .../test_serial_and_batch_bundle.py | 114 ------------------ 1 file changed, 114 deletions(-) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index 90b91bd148d..d05c0716648 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -1071,40 +1071,6 @@ class TestSerialandBatchBundle(FrappeTestCase): self.assertTrue(bundle_doc.docstatus == 0) self.assertRaises(frappe.ValidationError, bundle_doc.submit) -<<<<<<< HEAD -======= - def test_reference_voucher_on_cancel(self): - """ - When a source document is cancelled, the reference voucher field - in the respective serial or batch document should be nullified. - """ - - item_code = make_item( - "Serial Item", - properties={ - "is_stock_item": 1, - "has_serial_no": 1, - "serial_no_series": "SERIAL.#####", - }, - ).name - - se = make_stock_entry( - item_code=item_code, - qty=1, - target="_Test Warehouse - _TC", - ) - serial_no = get_serial_nos_from_bundle(se.items[0].serial_and_batch_bundle)[0] - self.assertEqual(frappe.get_value("Serial No", serial_no, "reference_name"), se.name) - - se.cancel() - self.assertIsNone(frappe.get_value("Serial No", serial_no, "reference_name")) - - se1 = frappe.copy_doc(se, ignore_no_copy=False) - se1.items[0].serial_no = serial_no - se1.submit() - - self.assertEqual(frappe.get_value("Serial No", serial_no, "reference_name"), se1.name) - def test_stock_queue_for_return_entry_with_non_batchwise_valuation(self): from erpnext.controllers.sales_and_purchase_return import make_return_doc from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt @@ -1193,86 +1159,6 @@ class TestSerialandBatchBundle(FrappeTestCase): # FIFO removes from front: [10, 100] -> [5, 100], rest unchanged self.assertEqual(json.loads(return_sle.stock_queue), [[5, 100], [20, 200], [5, 300]]) - def test_stock_queue_for_return_entry_with_empty_fifo_queue(self): - """Credit note (sales return) against empty FIFO queue should still rebuild stock_queue.""" - from erpnext.controllers.sales_and_purchase_return import make_return_doc - from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note - - batch_item_code = "Old Batch Empty Queue Test" - make_item( - batch_item_code, - { - "has_batch_no": 1, - "batch_number_series": "TEST-EQ-.#####", - "create_new_batch": 1, - "is_stock_item": 1, - "valuation_method": "FIFO", - }, - ) - - batch_id = "Old Batch Empty Queue 1" - if not frappe.db.exists("Batch", batch_id): - batch_doc = frappe.get_doc( - { - "doctype": "Batch", - "batch_id": batch_id, - "item": batch_item_code, - "use_batchwise_valuation": 0, - } - ).insert(ignore_permissions=True) - - batch_doc.db_set( - { - "use_batchwise_valuation": 0, - "batch_qty": 0, - } - ) - - # Inward 10 @ 100, then outward all 10 to empty the queue - make_stock_entry( - item_code=batch_item_code, - target="_Test Warehouse - _TC", - qty=10, - rate=100, - batch_no=batch_id, - use_serial_batch_fields=True, - ) - - dn = create_delivery_note( - item_code=batch_item_code, - warehouse="_Test Warehouse - _TC", - qty=10, - rate=150, - batch_no=batch_id, - use_serial_batch_fields=True, - ) - - # Verify queue is empty after full outward - sle = frappe.db.get_value( - "Stock Ledger Entry", - {"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": dn.name}, - ["stock_queue"], - as_dict=True, - ) - self.assertFalse(json.loads(sle.stock_queue or "[]")) - - # Sales return (credit note): 5 items come back at original rate 100 - return_dn = make_return_doc("Delivery Note", dn.name) - for row in return_dn.items: - row.qty = -5 - return_dn.save().submit() - - return_sle = frappe.db.get_value( - "Stock Ledger Entry", - {"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": return_dn.name}, - ["stock_queue"], - as_dict=True, - ) - - # Stock queue should have the returned stock: [[5, 100]] - self.assertEqual(json.loads(return_sle.stock_queue), [[5, 100]]) - ->>>>>>> e537896df8 (test(stock): add unit test to update stock queue for return) def get_batch_from_bundle(bundle): from erpnext.stock.serial_batch_bundle import get_batch_nos From ea3fcc214b83c97d6b1faed48e76a9c7ed42c270 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Mon, 6 Apr 2026 12:38:22 +0530 Subject: [PATCH 21/61] fix(test): use non-group customer group in test setup --- .../accounts/doctype/payment_entry/test_payment_entry.py | 1 + .../payment_ledger_entry/test_payment_ledger_entry.py | 1 + .../payment_reconciliation/test_payment_reconciliation.py | 1 + erpnext/accounts/doctype/subscription/test_subscription.py | 3 +++ .../report/accounts_receivable/test_accounts_receivable.py | 2 ++ erpnext/accounts/report/gross_profit/test_gross_profit.py | 1 + erpnext/accounts/test/accounts_mixin.py | 2 ++ erpnext/accounts/test_party.py | 6 +----- erpnext/controllers/tests/test_accounts_controller.py | 1 + erpnext/crm/doctype/lead/lead.py | 2 +- .../test_sales_pipeline_analytics.py | 1 + erpnext/regional/report/uae_vat_201/test_uae_vat_201.py | 1 + .../report/vat_audit_report/test_vat_audit_report.py | 1 + erpnext/selling/doctype/customer/test_customer.py | 1 + 14 files changed, 18 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index da6c2eefae4..bec4a8391e3 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -2043,6 +2043,7 @@ def create_customer(name="_Test Customer 2 USD", currency="USD"): customer.customer_name = name customer.default_currency = currency customer.type = "Individual" + customer.customer_group = "Individual" customer.save() customer = customer.name return customer diff --git a/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py b/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py index 9a33a7ccf6d..dd3a936f220 100644 --- a/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py +++ b/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py @@ -80,6 +80,7 @@ class TestPaymentLedgerEntry(FrappeTestCase): customer = frappe.new_doc("Customer") customer.customer_name = name customer.type = "Individual" + customer.customer_group = "Individual" customer.save() self.customer = customer.name diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py index 59c385855fa..b7d8fb44853 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py @@ -2546,6 +2546,7 @@ def make_customer(customer_name, currency=None): customer = frappe.new_doc("Customer") customer.customer_name = customer_name customer.type = "Individual" + customer.customer_group = "Individual" if currency: customer.default_currency = currency diff --git a/erpnext/accounts/doctype/subscription/test_subscription.py b/erpnext/accounts/doctype/subscription/test_subscription.py index 1d906fb9276..aba51ac5c6a 100644 --- a/erpnext/accounts/doctype/subscription/test_subscription.py +++ b/erpnext/accounts/doctype/subscription/test_subscription.py @@ -629,18 +629,21 @@ def create_parties(): customer.customer_name = "_Test Subscription Customer" customer.default_currency = "USD" customer.append("accounts", {"company": "_Test Company", "account": "_Test Receivable USD - _TC"}) + customer.customer_group = "Individual" customer.insert() if not frappe.db.exists("Customer", "_Test Subscription Customer Multi Currency"): customer = frappe.new_doc("Customer") customer.customer_name = "Test Subscription Customer Multi Currency" customer.default_currency = "USD" + customer.customer_group = "Individual" customer.insert() if not frappe.db.exists("Customer", "_Test Subscription Customer John Doe"): customer = frappe.new_doc("Customer") customer.customer_name = "_Test Subscription Customer John Doe" customer.append("accounts", {"company": "_Test Company", "account": "_Test Receivable - _TC"}) + customer.customer_group = "Individual" customer.insert() diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py index 4dfcd3bf259..88a3b818196 100644 --- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py @@ -779,6 +779,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase): "customer_name": "Jane Doe", "type": "Individual", "default_currency": "USD", + "customer_group": "Individual", } ) .insert() @@ -1002,6 +1003,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase): "customer_name": "Jane Doe", "type": "Individual", "default_currency": "USD", + "customer_group": "Individual", } ) .insert() diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py index 35f24df015f..9a0a9cc5174 100644 --- a/erpnext/accounts/report/gross_profit/test_gross_profit.py +++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py @@ -82,6 +82,7 @@ class TestGrossProfit(FrappeTestCase): customer = frappe.new_doc("Customer") customer.customer_name = name customer.type = "Individual" + customer.customer_group = "Individual" customer.save() self.customer = customer.name diff --git a/erpnext/accounts/test/accounts_mixin.py b/erpnext/accounts/test/accounts_mixin.py index 3cad657553e..2bce9b46835 100644 --- a/erpnext/accounts/test/accounts_mixin.py +++ b/erpnext/accounts/test/accounts_mixin.py @@ -12,6 +12,7 @@ class AccountsTestMixin: customer = frappe.new_doc("Customer") customer.customer_name = customer_name customer.type = "Individual" + customer.customer_group = "Individual" if currency: customer.default_currency = currency @@ -36,6 +37,7 @@ class AccountsTestMixin: "account": default_account, }, ) + customer.customer_group = "Individual" customer.save() self.customer = customer_name diff --git a/erpnext/accounts/test_party.py b/erpnext/accounts/test_party.py index 9d3de5e8282..8b4a52603bf 100644 --- a/erpnext/accounts/test_party.py +++ b/erpnext/accounts/test_party.py @@ -7,12 +7,8 @@ from erpnext.accounts.party import get_default_price_list class PartyTestCase(FrappeTestCase): def test_get_default_price_list_should_return_none_for_invalid_group(self): customer = frappe.get_doc( - { - "doctype": "Customer", - "customer_name": "test customer", - } + {"doctype": "Customer", "customer_name": "test customer", "customer_group": "Individual"} ).insert(ignore_permissions=True, ignore_mandatory=True) - customer.customer_group = None customer.save() price_list = get_default_price_list(customer) assert price_list is None diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py index a120da6f852..3ecf3d8b0af 100644 --- a/erpnext/controllers/tests/test_accounts_controller.py +++ b/erpnext/controllers/tests/test_accounts_controller.py @@ -29,6 +29,7 @@ def make_customer(customer_name, currency=None): customer = frappe.new_doc("Customer") customer.customer_name = customer_name customer.customer_type = "Individual" + customer.customer_group = "Individual" if currency: customer.default_currency = currency diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index f0f492191fb..db909534cdf 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -324,7 +324,7 @@ def _make_customer(source_name, target_doc=None, ignore_permissions=False): target.customer_name = source.lead_name if not target.customer_group: - target.customer_group = frappe.db.get_default("Customer Group") + target.customer_group = "Individual" doclist = get_mapped_doc( "Lead", diff --git a/erpnext/crm/report/sales_pipeline_analytics/test_sales_pipeline_analytics.py b/erpnext/crm/report/sales_pipeline_analytics/test_sales_pipeline_analytics.py index bf3f946d6ab..7622c38f94c 100644 --- a/erpnext/crm/report/sales_pipeline_analytics/test_sales_pipeline_analytics.py +++ b/erpnext/crm/report/sales_pipeline_analytics/test_sales_pipeline_analytics.py @@ -196,6 +196,7 @@ def create_customer(): if not doc: doc = frappe.new_doc("Customer") doc.customer_name = "_Test NC" + doc.customer_group = "Individual" doc.insert() diff --git a/erpnext/regional/report/uae_vat_201/test_uae_vat_201.py b/erpnext/regional/report/uae_vat_201/test_uae_vat_201.py index cab84024417..8c2b559fe52 100644 --- a/erpnext/regional/report/uae_vat_201/test_uae_vat_201.py +++ b/erpnext/regional/report/uae_vat_201/test_uae_vat_201.py @@ -148,6 +148,7 @@ def make_customer(): "doctype": "Customer", "customer_name": "_Test UAE Customer", "customer_type": "Company", + "customer_group": "Individual", } ) customer.insert() diff --git a/erpnext/regional/report/vat_audit_report/test_vat_audit_report.py b/erpnext/regional/report/vat_audit_report/test_vat_audit_report.py index a898a251043..26b2969e2c5 100644 --- a/erpnext/regional/report/vat_audit_report/test_vat_audit_report.py +++ b/erpnext/regional/report/vat_audit_report/test_vat_audit_report.py @@ -115,6 +115,7 @@ def make_customer(): "doctype": "Customer", "customer_name": "_Test SA Customer", "customer_type": "Company", + "customer_group": "Individual", } ).insert() diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py index dfb4a5b4445..a8fd5ed76ca 100644 --- a/erpnext/selling/doctype/customer/test_customer.py +++ b/erpnext/selling/doctype/customer/test_customer.py @@ -450,6 +450,7 @@ def make_customer(customer_name): customer = frappe.new_doc("Customer") customer.customer_name = customer_name customer.customer_type = "Individual" + customer.customer_group = "Individual" customer.insert() return customer.name else: From def62cf3fe0c30a406b93382312b9270e643c9b5 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Mon, 6 Apr 2026 14:15:03 +0530 Subject: [PATCH 22/61] fix: GL entries for different exchange rate in the purchase invoice (cherry picked from commit a953709640259ab53c2c009afe86189884eff9b8) # Conflicts: # erpnext/stock/doctype/purchase_receipt/purchase_receipt.py --- .../purchase_invoice/purchase_invoice.py | 11 +- .../purchase_invoice/test_purchase_invoice.py | 11 ++ .../buying/doctype/supplier/test_supplier.py | 9 ++ .../purchase_receipt/purchase_receipt.py | 123 +++++++++++++++++- .../purchase_receipt/test_purchase_receipt.py | 67 ++++++++++ 5 files changed, 214 insertions(+), 7 deletions(-) diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index ea513e0cfd7..b80eadc7ae0 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -978,6 +978,10 @@ class PurchaseInvoice(BuyingController): if provisional_accounting_for_non_stock_items: self.get_provisional_accounts() + adjust_incoming_rate = frappe.db.get_single_value( + "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate" + ) + for item in self.get("items"): if flt(item.base_net_amount) or (self.get("update_stock") and item.valuation_rate): if item.item_code: @@ -1146,7 +1150,11 @@ class PurchaseInvoice(BuyingController): ) # check if the exchange rate has changed - if item.get("purchase_receipt") and self.auto_accounting_for_stock: + if ( + not adjust_incoming_rate + and item.get("purchase_receipt") + and self.auto_accounting_for_stock + ): if ( exchange_rate_map[item.purchase_receipt] and self.conversion_rate != exchange_rate_map[item.purchase_receipt] @@ -1183,6 +1191,7 @@ class PurchaseInvoice(BuyingController): item=item, ) ) + if ( self.auto_accounting_for_stock and self.is_opening == "No" diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index f8e12eda182..bccd29c822a 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -356,6 +356,12 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin): make_purchase_invoice as create_purchase_invoice, ) + original_value = frappe.db.get_single_value( + "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate" + ) + + frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0) + pr = make_purchase_receipt( company="_Test Company with perpetual inventory", warehouse="Stores - TCP1", @@ -376,12 +382,17 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin): amount = frappe.db.get_value( "GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "debit" ) + discrepancy_caused_by_exchange_rate_diff = abs( pi.items[0].base_net_amount - pr.items[0].base_net_amount ) self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount) + frappe.db.set_single_value( + "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value + ) + def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self): from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( make_purchase_invoice as create_purchase_invoice, diff --git a/erpnext/buying/doctype/supplier/test_supplier.py b/erpnext/buying/doctype/supplier/test_supplier.py index e4475c7ee38..04983721a64 100644 --- a/erpnext/buying/doctype/supplier/test_supplier.py +++ b/erpnext/buying/doctype/supplier/test_supplier.py @@ -175,6 +175,15 @@ def create_supplier(**args): if not args.without_supplier_group: doc.supplier_group = args.supplier_group or "Services" + if args.get("party_account"): + doc.append( + "accounts", + { + "company": frappe.db.get_value("Account", args.get("party_account"), "company"), + "account": args.get("party_account"), + }, + ) + doc.insert() return doc diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index b27a2fc6549..e2f12310182 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -1135,9 +1135,15 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate total_amount, total_billed_amount, pi_landed_cost_amount = 0, 0, 0 item_wise_returned_qty = get_item_wise_returned_qty(pr_doc) + billed_qty_amt = frappe._dict() if adjust_incoming_rate: +<<<<<<< HEAD item_wise_billed_qty = get_billed_qty_against_purchase_receipt(pr_doc) +======= + billed_qty_amt = get_billed_qty_amount_against_purchase_receipt(pr_doc) + billed_qty_amt_based_on_po = get_billed_qty_amount_against_purchase_order(pr_doc) +>>>>>>> a953709640 (fix: GL entries for different exchange rate in the purchase invoice) for item in pr_doc.items: returned_qty = flt(item_wise_returned_qty.get(item.name)) @@ -1166,13 +1172,55 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate if ( item.billed_amt is not None and item.amount is not None +<<<<<<< HEAD and item_wise_billed_qty.get(item.name) ): adjusted_amt = ( flt(item.billed_amt / item_wise_billed_qty.get(item.name)) - flt(item.rate) ) * item.qty +======= + and ( + billed_qty_amt.get(item.name) or billed_qty_amt_based_on_po.get(item.purchase_order_item) + ) + ): + qty = None + if billed_qty_amt.get(item.name): + qty = billed_qty_amt.get(item.name).get("qty") - adjusted_amt = flt(adjusted_amt * flt(pr_doc.conversion_rate), item.precision("amount")) + if not qty and billed_qty_amt_based_on_po.get(item.purchase_order_item): + if item.qty < billed_qty_amt_based_on_po.get(item.purchase_order_item)["qty"]: + qty = item.qty + else: + qty = billed_qty_amt_based_on_po.get(item.purchase_order_item)["qty"] + + billed_qty_amt_based_on_po[item.purchase_order_item]["qty"] -= qty + + billed_amt = item.billed_amt + if billed_qty_amt.get(item.name): + billed_amt = flt(billed_qty_amt.get(item.name).get("amount")) + elif billed_qty_amt_based_on_po.get(item.purchase_order_item): + total_billed_qty = ( + billed_qty_amt_based_on_po.get(item.purchase_order_item).get("qty") + qty + ) +>>>>>>> a953709640 (fix: GL entries for different exchange rate in the purchase invoice) + + if total_billed_qty: + billed_amt = flt( + flt(billed_qty_amt_based_on_po.get(item.purchase_order_item).get("amount")) + * (qty / total_billed_qty) + ) + else: + billed_amt = 0.0 + + # Reduce billed amount based on PO for next iterations + billed_qty_amt_based_on_po[item.purchase_order_item]["amount"] -= billed_amt + + if qty: + adjusted_amt = ( + flt(billed_amt / qty) - (flt(item.rate) * flt(pr_doc.conversion_rate)) + ) * item.qty + + adjusted_amt = flt(adjusted_amt, item.precision("amount")) pi_landed_cost_amount += adjusted_amt item.db_set("amount_difference_with_purchase_invoice", adjusted_amt, update_modified=False) elif amount and item.billed_amt > amount: @@ -1201,22 +1249,85 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate adjust_incoming_rate_for_pr(pr_doc) -def get_billed_qty_against_purchase_receipt(pr_doc): +def get_billed_qty_amount_against_purchase_receipt(pr_doc): pr_names = [d.name for d in pr_doc.items] + parent_table = frappe.qb.DocType("Purchase Invoice") table = frappe.qb.DocType("Purchase Invoice Item") query = ( - frappe.qb.from_(table) - .select(table.pr_detail, fn.Sum(table.qty).as_("qty")) + frappe.qb.from_(parent_table) + .inner_join(table) + .on(parent_table.name == table.parent) + .select( + table.pr_detail, + fn.Sum(table.amount * parent_table.conversion_rate).as_("amount"), + fn.Sum(table.qty).as_("qty"), + ) .where((table.pr_detail.isin(pr_names)) & (table.docstatus == 1)) .groupby(table.pr_detail) ) - invoice_data = query.run(as_list=1) + invoice_data = query.run(as_dict=1) if not invoice_data: return frappe._dict() - return frappe._dict(invoice_data) + + billed_qty_amt = frappe._dict() + + for row in invoice_data: + if row.pr_detail not in billed_qty_amt: + billed_qty_amt[row.pr_detail] = {"amount": 0, "qty": 0} + + billed_qty_amt[row.pr_detail]["amount"] += flt(row.amount) + billed_qty_amt[row.pr_detail]["qty"] += flt(row.qty) + + return billed_qty_amt +<<<<<<< HEAD +======= +def get_billed_qty_amount_against_purchase_order(pr_doc): + po_names = list( + set( + [ + d.purchase_order_item + for d in pr_doc.items + if d.purchase_order_item and not d.purchase_invoice_item + ] + ) + ) + + invoice_data_po_based = frappe._dict() + if po_names: + parent_table = frappe.qb.DocType("Purchase Invoice") + table = frappe.qb.DocType("Purchase Invoice Item") + + query = ( + frappe.qb.from_(parent_table) + .inner_join(table) + .on(parent_table.name == table.parent) + .select( + table.po_detail, + fn.Sum(table.qty).as_("qty"), + fn.Sum(table.amount * parent_table.conversion_rate).as_("amount"), + ) + .where((table.po_detail.isin(po_names)) & (table.docstatus == 1) & (table.pr_detail.isnull())) + .groupby(table.po_detail) + ) + + invoice_data = query.run(as_dict=1) + if not invoice_data: + return frappe._dict() + + for row in invoice_data: + if row.po_detail not in invoice_data_po_based: + invoice_data_po_based[row.po_detail] = {"amount": 0, "qty": 0} + + invoice_data_po_based[row.po_detail]["amount"] += flt(row.amount) + invoice_data_po_based[row.po_detail]["qty"] += flt(row.qty) + + return invoice_data_po_based + + +>>>>>>> a953709640 (fix: GL entries for different exchange rate in the purchase invoice) def adjust_incoming_rate_for_pr(doc): doc.update_valuation_rate(reset_outgoing_rate=False) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 59ebbf681ea..2d2e68bb0ea 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -5133,6 +5133,70 @@ class TestPurchaseReceipt(FrappeTestCase): self.assertEqual(pr.total_qty, 12) self.assertEqual(pr.total, 120) + def test_different_exchange_rate_in_pr_and_pi(self): + from erpnext.accounts.doctype.account.test_account import create_account + + original_value = frappe.db.get_single_value( + "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate" + ) + + frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 1) + + party_account = create_account( + account_name="USD Party Account Creditors", + parent_account="Accounts Payable - TCP1", + account_type="Payable", + company="_Test Company with perpetual inventory", + account_currency="USD", + ) + + supplier = create_supplier( + supplier_name="_Test USD Supplier New 1", default_currency="USD", party_account=party_account + ).name + item_code = make_item("Test Item for Different Exchange Rate", {"is_stock_item": 1}).name + + pr = make_purchase_receipt( + item_code=item_code, + qty=1, + currency="USD", + conversion_rate=80, + rate=100, + company="_Test Company with perpetual inventory", + warehouse=frappe.get_value( + "Warehouse", {"company": "_Test Company with perpetual inventory"}, "name" + ), + supplier=supplier, + ) + + self.assertEqual(pr.currency, "USD") + self.assertEqual(pr.conversion_rate, 80) + + gl_entries = get_gl_entries(pr.doctype, pr.name) + self.assertTrue(len(gl_entries) == 2) + for row in gl_entries: + amount = row.credit or row.debit + self.assertEqual(amount, 8000.0) + + pi = make_purchase_invoice(pr.name) + pi.conversion_rate = 90 + pi.currency = "USD" + + pi.save() + pi.submit() + + gl_entries = get_gl_entries(pi.doctype, pi.name) + self.assertTrue(len(gl_entries) == 2) + + accounts = ["USD Party Account Creditors - TCP1", "Stock Received But Not Billed - TCP1"] + for row in gl_entries: + amount = row.credit or row.debit + self.assertEqual(amount, 9000.0) + self.assertTrue(row.account in accounts) + + frappe.db.set_single_value( + "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value + ) + def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier @@ -5303,6 +5367,9 @@ def make_purchase_receipt(**args): pr.return_against = args.return_against pr.apply_putaway_rule = args.apply_putaway_rule + if args.get("conversion_rate") is not None: + pr.conversion_rate = args.conversion_rate + qty = args.qty if args.qty is not None else 5 rejected_qty = args.rejected_qty or 0 received_qty = args.received_qty or flt(rejected_qty) + flt(qty) From 93bfd62725d84b89fe7f81c04267b9aa7382170e Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 6 Apr 2026 17:44:36 +0530 Subject: [PATCH 23/61] chore: fix conflicts Removed redundant calculation of billed quantity and adjusted logic for billed amount based on purchase order. --- .../doctype/purchase_receipt/purchase_receipt.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index e2f12310182..f200294a777 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -1138,12 +1138,8 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate billed_qty_amt = frappe._dict() if adjust_incoming_rate: -<<<<<<< HEAD - item_wise_billed_qty = get_billed_qty_against_purchase_receipt(pr_doc) -======= billed_qty_amt = get_billed_qty_amount_against_purchase_receipt(pr_doc) billed_qty_amt_based_on_po = get_billed_qty_amount_against_purchase_order(pr_doc) ->>>>>>> a953709640 (fix: GL entries for different exchange rate in the purchase invoice) for item in pr_doc.items: returned_qty = flt(item_wise_returned_qty.get(item.name)) @@ -1172,13 +1168,6 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate if ( item.billed_amt is not None and item.amount is not None -<<<<<<< HEAD - and item_wise_billed_qty.get(item.name) - ): - adjusted_amt = ( - flt(item.billed_amt / item_wise_billed_qty.get(item.name)) - flt(item.rate) - ) * item.qty -======= and ( billed_qty_amt.get(item.name) or billed_qty_amt_based_on_po.get(item.purchase_order_item) ) @@ -1202,7 +1191,6 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate total_billed_qty = ( billed_qty_amt_based_on_po.get(item.purchase_order_item).get("qty") + qty ) ->>>>>>> a953709640 (fix: GL entries for different exchange rate in the purchase invoice) if total_billed_qty: billed_amt = flt( @@ -1282,8 +1270,6 @@ def get_billed_qty_amount_against_purchase_receipt(pr_doc): return billed_qty_amt -<<<<<<< HEAD -======= def get_billed_qty_amount_against_purchase_order(pr_doc): po_names = list( set( @@ -1327,7 +1313,6 @@ def get_billed_qty_amount_against_purchase_order(pr_doc): return invoice_data_po_based ->>>>>>> a953709640 (fix: GL entries for different exchange rate in the purchase invoice) def adjust_incoming_rate_for_pr(doc): doc.update_valuation_rate(reset_outgoing_rate=False) From 21805bde1fad3c244165fccd57bf306571149773 Mon Sep 17 00:00:00 2001 From: Poovitha Palanivelu Date: Fri, 3 Apr 2026 12:51:46 +0530 Subject: [PATCH 24/61] feat(timesheet): allow partial billing and handled return --- .../doctype/sales_invoice/sales_invoice.json | 8 +-- .../doctype/sales_invoice/sales_invoice.py | 63 ++++++++++++++++--- .../sales_invoice_timesheet.json | 3 +- .../doctype/timesheet/test_timesheet.py | 57 ++++++++++++++++- .../projects/doctype/timesheet/timesheet.json | 4 +- .../projects/doctype/timesheet/timesheet.py | 13 +++- .../doctype/timesheet/timesheet_list.js | 3 + 7 files changed, 128 insertions(+), 23 deletions(-) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index f207b2079ab..a5abf3dd32f 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -777,8 +777,7 @@ }, { "collapsible": 1, - "collapsible_depends_on": "eval:doc.total_billing_amount > 0", - "depends_on": "eval:!doc.is_return", + "collapsible_depends_on": "eval:doc.total_billing_amount > 0 || doc.total_billing_hours > 0", "fieldname": "time_sheet_list", "fieldtype": "Section Break", "hide_border": 1, @@ -792,7 +791,6 @@ "hide_days": 1, "hide_seconds": 1, "label": "Time Sheets", - "no_copy": 1, "options": "Sales Invoice Timesheet", "print_hide": 1 }, @@ -2112,7 +2110,7 @@ "fieldtype": "Column Break" }, { - "depends_on": "eval:(!doc.is_return && doc.total_billing_amount > 0)", + "depends_on": "eval:doc.total_billing_amount > 0 || doc.total_billing_hours > 0", "fieldname": "section_break_104", "fieldtype": "Section Break" }, @@ -2200,7 +2198,7 @@ "link_fieldname": "consolidated_invoice" } ], - "modified": "2026-02-05 20:43:44.732805", + "modified": "2026-04-06 22:30:28.513139", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 6bc24633ca9..3e16c503e69 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -323,10 +323,22 @@ class SalesInvoice(SellingController): ) self.set_against_income_account() - self.validate_time_sheets_are_submitted() + + if self.is_return and not self.return_against and self.timesheets: + frappe.throw(_("Direct return is not allowed for Timesheet.")) + + if not self.is_return: + self.validate_time_sheets_are_submitted() + self.validate_multiple_billing("Delivery Note", "dn_detail", "amount") - if self.is_return: - self.timesheets = [] + + if self.is_return and self.return_against: + for row in self.timesheets: + if row.billing_hours: + row.billing_hours = -abs(row.billing_hours) + if row.billing_amount: + row.billing_amount = -abs(row.billing_amount) + self.update_packing_list() self.set_billing_hours_and_amount() self.update_timesheet_billing_for_project() @@ -494,7 +506,7 @@ class SalesInvoice(SellingController): if not cint(self.is_pos) == 1 and not self.is_return: self.update_against_document_in_jv() - self.update_time_sheet(self.name) + self.update_time_sheet(None if (self.is_return and self.return_against) else self.name) if frappe.db.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction": update_company_current_month_sales(self.company) @@ -550,7 +562,7 @@ class SalesInvoice(SellingController): self.check_if_consolidated_invoice() super().before_cancel() - self.update_time_sheet(None) + self.update_time_sheet(self.return_against if (self.is_return and self.return_against) else None) def on_cancel(self): check_if_return_invoice_linked_with_payment_entry(self) @@ -735,8 +747,20 @@ class SalesInvoice(SellingController): for data in timesheet.time_logs: if ( (self.project and args.timesheet_detail == data.name) - or (not self.project and not data.sales_invoice) - or (not sales_invoice and data.sales_invoice == self.name) + or (not self.project and not data.sales_invoice and args.timesheet_detail == data.name) + or ( + not sales_invoice + and data.sales_invoice == self.name + and args.timesheet_detail == data.name + ) + or ( + self.is_return + and self.return_against + and data.sales_invoice + and data.sales_invoice == self.return_against + and not sales_invoice + and args.timesheet_detail == data.name + ) ): data.sales_invoice = sales_invoice @@ -776,11 +800,25 @@ class SalesInvoice(SellingController): payment.account = get_bank_cash_account(payment.mode_of_payment, self.company).get("account") def validate_time_sheets_are_submitted(self): + # Note: This validation is skipped for return invoices + # to allow returns to reference already-billed timesheet details for data in self.timesheets: + # Handle invoice duplication + if data.time_sheet and data.timesheet_detail: + if sales_invoice := frappe.db.get_value( + "Timesheet Detail", data.timesheet_detail, "sales_invoice" + ): + frappe.throw( + _("Row {0}: Sales Invoice {1} is already created for {2}").format( + data.idx, frappe.bold(sales_invoice), frappe.bold(data.time_sheet) + ) + ) if data.time_sheet: status = frappe.db.get_value("Timesheet", data.time_sheet, "status") - if status not in ["Submitted", "Payslip"]: - frappe.throw(_("Timesheet {0} is already completed or cancelled").format(data.time_sheet)) + if status not in ["Submitted", "Payslip", "Partially Billed"]: + frappe.throw( + _("Timesheet {0} cannot be invoiced in its current state").format(data.time_sheet) + ) def set_pos_fields(self, for_validate=False): """Set retail related fields from POS Profiles""" @@ -1112,7 +1150,12 @@ class SalesInvoice(SellingController): timesheet.billing_amount = ts_doc.total_billable_amount def update_timesheet_billing_for_project(self): - if not self.timesheets and self.project and self.is_auto_fetch_timesheet_enabled(): + if ( + not self.is_return + and not self.timesheets + and self.project + and self.is_auto_fetch_timesheet_enabled() + ): self.add_timesheet_data() else: self.calculate_billing_amount_for_timesheet() diff --git a/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json b/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json index 69b7c129f09..f959054f4a2 100644 --- a/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json +++ b/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json @@ -52,7 +52,6 @@ "fieldtype": "Data", "hidden": 1, "label": "Timesheet Detail", - "no_copy": 1, "print_hide": 1, "read_only": 1 }, @@ -117,7 +116,7 @@ ], "istable": 1, "links": [], - "modified": "2021-10-02 03:48:44.979777", + "modified": "2026-04-06 22:30:28.513139", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Timesheet", diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py index e17fa3d622c..cd20cd340c2 100644 --- a/erpnext/projects/doctype/timesheet/test_timesheet.py +++ b/erpnext/projects/doctype/timesheet/test_timesheet.py @@ -8,6 +8,7 @@ import frappe from frappe.tests.utils import change_settings from frappe.utils import add_to_date, now_datetime, nowdate +from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.projects.doctype.timesheet.timesheet import OverlapError, make_sales_invoice from erpnext.setup.doctype.employee.test_employee import make_employee @@ -202,6 +203,58 @@ class TestTimesheet(unittest.TestCase): ts.calculate_percentage_billed() self.assertEqual(ts.per_billed, 100) + def test_partial_billing_and_return(self): + """ + Test Timesheet status transitions during partial billing, full billing, + sales return, and return cancellation. + Scenario: + 1. Create a Timesheet with two billable time logs. + 2. Create a Sales Invoice billing only one time log → Timesheet becomes Partially Billed. + 3. Create another Sales Invoice billing the remaining time log → Timesheet becomes Billed. + 4. Create a Sales Return against the second invoice → Timesheet reverts to Partially Billed. + 5. Cancel the Sales Return → Timesheet returns to Billed status. + This test ensures Timesheet status is recalculated correctly + across billing and return lifecycle events. + """ + emp = make_employee("test_employee_6@salary.com") + + timesheet = make_timesheet(emp, simulate=True, is_billable=1, do_not_submit=True) + timesheet_detail = timesheet.append("time_logs", {}) + timesheet_detail.is_billable = 1 + timesheet_detail.activity_type = "_Test Activity Type" + timesheet_detail.from_time = timesheet.time_logs[0].to_time + datetime.timedelta(minutes=1) + timesheet_detail.hours = 2 + timesheet_detail.to_time = timesheet_detail.from_time + datetime.timedelta( + hours=timesheet_detail.hours + ) + timesheet.save().submit() + + sales_invoice = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer", currency="INR") + sales_invoice.due_date = nowdate() + sales_invoice.timesheets.pop() + sales_invoice.submit() + + timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status") + self.assertEqual(timesheet_status, "Partially Billed") + + sales_invoice2 = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer", currency="INR") + sales_invoice2.due_date = nowdate() + sales_invoice2.submit() + + timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status") + self.assertEqual(timesheet_status, "Billed") + + sales_return = make_sales_return(sales_invoice2.name).submit() + timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status") + self.assertEqual(timesheet_status, "Partially Billed") + + sales_return.load_from_db() + sales_return.cancel() + + timesheet.load_from_db() + self.assertEqual(timesheet.time_logs[1].sales_invoice, sales_invoice2.name) + self.assertEqual(timesheet.status, "Billed") + def make_timesheet( employee, @@ -211,6 +264,7 @@ def make_timesheet( project=None, task=None, company=None, + do_not_submit=False, ): update_activity_type(activity_type) timesheet = frappe.new_doc("Timesheet") @@ -237,7 +291,8 @@ def make_timesheet( else: timesheet.save(ignore_permissions=True) - timesheet.submit() + if not do_not_submit: + timesheet.submit() return timesheet diff --git a/erpnext/projects/doctype/timesheet/timesheet.json b/erpnext/projects/doctype/timesheet/timesheet.json index ba6262dc3de..6f16266e0f3 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.json +++ b/erpnext/projects/doctype/timesheet/timesheet.json @@ -91,7 +91,7 @@ "in_standard_filter": 1, "label": "Status", "no_copy": 1, - "options": "Draft\nSubmitted\nBilled\nPayslip\nCompleted\nCancelled", + "options": "Draft\nSubmitted\nPartially Billed\nBilled\nPayslip\nCompleted\nCancelled", "print_hide": 1, "read_only": 1 }, @@ -310,7 +310,7 @@ "idx": 1, "is_submittable": 1, "links": [], - "modified": "2023-04-20 15:59:11.107831", + "modified": "2026-04-06 22:30:28.513139", "modified_by": "Administrator", "module": "Projects", "name": "Timesheet", diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index ec58c55f020..bf3116cb409 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -50,7 +50,9 @@ class Timesheet(Document): per_billed: DF.Percent sales_invoice: DF.Link | None start_date: DF.Date | None - status: DF.Literal["Draft", "Submitted", "Billed", "Payslip", "Completed", "Cancelled"] + status: DF.Literal[ + "Draft", "Submitted", "Partially Billed", "Billed", "Payslip", "Completed", "Cancelled" + ] time_logs: DF.Table[TimesheetDetail] title: DF.Data | None total_billable_amount: DF.Currency @@ -126,6 +128,9 @@ class Timesheet(Document): if flt(self.per_billed, self.precision("per_billed")) >= 100.0: self.status = "Billed" + if 0.0 < flt(self.per_billed, self.precision("per_billed")) < 100.0: + self.status = "Partially Billed" + if self.sales_invoice: self.status = "Completed" @@ -423,7 +428,9 @@ def get_timesheet_data(name, project): @frappe.whitelist() -def make_sales_invoice(source_name, item_code=None, customer=None, currency=None): +def make_sales_invoice( + source_name: str, item_code: str | None = None, customer: str | None = None, currency: str | None = None +): target = frappe.new_doc("Sales Invoice") timesheet = frappe.get_doc("Timesheet", source_name) @@ -452,7 +459,7 @@ def make_sales_invoice(source_name, item_code=None, customer=None, currency=None target.append("items", {"item_code": item_code, "qty": hours, "rate": billing_rate}) for time_log in timesheet.time_logs: - if time_log.is_billable: + if time_log.is_billable and not time_log.sales_invoice: target.append( "timesheets", { diff --git a/erpnext/projects/doctype/timesheet/timesheet_list.js b/erpnext/projects/doctype/timesheet/timesheet_list.js index 0de568ce589..ceca47209e1 100644 --- a/erpnext/projects/doctype/timesheet/timesheet_list.js +++ b/erpnext/projects/doctype/timesheet/timesheet_list.js @@ -1,6 +1,9 @@ frappe.listview_settings["Timesheet"] = { add_fields: ["status", "total_hours", "start_date", "end_date"], get_indicator: function (doc) { + if (doc.status == "Partially Billed") { + return [__("Partially Billed"), "orange", "status,=," + "Partially Billed"]; + } if (doc.status == "Billed") { return [__("Billed"), "green", "status,=," + "Billed"]; } From 7794f3033ed524f2e6334f4fc2f110091622b362 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Tue, 7 Apr 2026 02:12:17 +0530 Subject: [PATCH 25/61] fix: validation test for customer group --- erpnext/crm/doctype/lead/lead.py | 2 +- erpnext/crm/doctype/opportunity/test_opportunity.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index db909534cdf..f0f492191fb 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -324,7 +324,7 @@ def _make_customer(source_name, target_doc=None, ignore_permissions=False): target.customer_name = source.lead_name if not target.customer_group: - target.customer_group = "Individual" + target.customer_group = frappe.db.get_default("Customer Group") doclist = get_mapped_doc( "Lead", diff --git a/erpnext/crm/doctype/opportunity/test_opportunity.py b/erpnext/crm/doctype/opportunity/test_opportunity.py index 6ec3ca4a6c1..f346946568e 100644 --- a/erpnext/crm/doctype/opportunity/test_opportunity.py +++ b/erpnext/crm/doctype/opportunity/test_opportunity.py @@ -35,7 +35,9 @@ class TestOpportunity(unittest.TestCase): self.assertEqual(frappe.db.get_value("Lead", opp_doc.party_name, "email_id"), opp_doc.contact_email) # create new customer and create new contact against 'new.opportunity@example.com' - customer = make_customer(opp_doc.party_name).insert(ignore_permissions=True) + customer = make_customer(opp_doc.party_name) + customer.customer_group = "Individual" + customer.insert(ignore_permissions=True) contact = frappe.get_doc( { "doctype": "Contact", From 14085de332abb9628cb3224bd8f46f9314e11bd2 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:25:32 +0530 Subject: [PATCH 26/61] =?UTF-8?q?fix:=20resolve=20user=20permission=20erro?= =?UTF-8?q?r=20on=20status=20change=20by=20updating=20user=20=E2=80=A6=20(?= =?UTF-8?q?backport=20#54033)=20(#54059)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Krishna Shirsath --- erpnext/setup/doctype/employee/employee.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/setup/doctype/employee/employee.py b/erpnext/setup/doctype/employee/employee.py index 543a8a194b8..db4446cce77 100755 --- a/erpnext/setup/doctype/employee/employee.py +++ b/erpnext/setup/doctype/employee/employee.py @@ -189,7 +189,7 @@ class Employee(NestedSet): frappe.throw(_("User {0} does not exist").format(self.user_id)) if self.status != "Active" and enabled or self.status == "Active" and enabled == 0: - frappe.set_value("User", self.user_id, "enabled", not enabled) + frappe.db.set_value("User", self.user_id, "enabled", not enabled) def validate_duplicate_user_id(self): Employee = frappe.qb.DocType("Employee") From ee812687e6f8c1d57e3c1d4c542e22de898d3343 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:26:03 +0530 Subject: [PATCH 27/61] feat: croatian_address_template (backport #53888) (#54057) Co-authored-by: mahsem <137205921+mahsem@users.noreply.github.com> --- erpnext/regional/address_template/templates/croatia.html | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 erpnext/regional/address_template/templates/croatia.html diff --git a/erpnext/regional/address_template/templates/croatia.html b/erpnext/regional/address_template/templates/croatia.html new file mode 100644 index 00000000000..0c2ed73f0ae --- /dev/null +++ b/erpnext/regional/address_template/templates/croatia.html @@ -0,0 +1,4 @@ +{{ address_line1 }}
+{% if address_line2 %}{{ address_line2 }}
{% endif -%} +{{ pincode }} {{ city | upper }}
+{{ country | upper }} \ No newline at end of file From bcf59e71717455842a7124044175191f8da3f8c2 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 06:31:51 +0000 Subject: [PATCH 28/61] fix: transactions where update stock is 0 should not create SLEs (backport #54035) (#54076) * fix: transactions where update stock is 0 should not create SLEs (#54035) (cherry picked from commit 66780543bdbc11f44055e3a2a8f8f735c4be61f0) # Conflicts: # erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py * chore: resolve conflicts * chore: resolve conflicts --------- Co-authored-by: Nishka Gosalia <58264710+nishkagosalia@users.noreply.github.com> Co-authored-by: Mihir Kandoi --- .../repost_item_valuation/repost_item_valuation.py | 10 ++++++++++ .../test_repost_item_valuation.py | 5 ----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index e3b1b330fad..78ae87f06ee 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -74,6 +74,7 @@ class RepostItemValuation(Document): def validate(self): self.set_company() + self.validate_update_stock() self.validate_period_closing_voucher() self.set_status(write=False) self.reset_field_values() @@ -81,6 +82,15 @@ class RepostItemValuation(Document): self.reset_recreate_stock_ledgers() self.validate_recreate_stock_ledgers() + def validate_update_stock(self): + if self.voucher_type in ["Sales Invoice", "Purchase Invoice"]: + update_stock = frappe.get_value(self.voucher_type, self.voucher_no, "update_stock") + if not update_stock: + msg = _( + "Since {0} has 'Update Stock' disabled, you cannot create repost item valuation against it" + ).format(get_link_to_form(self.voucher_type, self.voucher_no)) + frappe.throw(msg) + def validate_recreate_stock_ledgers(self): if not self.recreate_stock_ledgers: return diff --git a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py index c36d799ba37..5cc3736fb0f 100644 --- a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py @@ -137,19 +137,14 @@ class TestRepostItemValuation(FrappeTestCase, StockTestMixin): item_code="_Test Item", warehouse="_Test Warehouse - _TC", based_on="Item and Warehouse", - voucher_type="Sales Invoice", - voucher_no="SI-1", posting_date="2021-01-02", posting_time="00:01:00", ) - # new repost without any duplicates riv1 = frappe.get_doc(riv_args) riv1.flags.dont_run_in_test = True riv1.submit() _assert_status(riv1, "Queued") - self.assertEqual(riv1.voucher_type, "Sales Invoice") # traceability - self.assertEqual(riv1.voucher_no, "SI-1") # newer than existing duplicate - riv1 riv2 = frappe.get_doc(riv_args.update({"posting_date": "2021-01-03"})) From c81c1ea8693519d13be249238de1c1d6a278d8bf Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Tue, 7 Apr 2026 13:11:44 +0530 Subject: [PATCH 29/61] chore: fix test case --- .../doctype/serial_and_batch_bundle/serial_and_batch_bundle.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 49a50204f42..b0b8c221f8e 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -402,7 +402,7 @@ class SerialandBatchBundle(Document): if valuation_details := self.get_valuation_rate_for_return_entry(return_against): from erpnext.stock.utils import get_valuation_method - valuation_method = get_valuation_method(self.item_code, self.company) + valuation_method = get_valuation_method(self.item_code) stock_queue = [] non_batchwise_batches = [] From 55ee1dcd04309c40091a492e0e6f6b7dbecd0d7e Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Wed, 25 Mar 2026 10:43:28 +0530 Subject: [PATCH 30/61] 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 31/61] 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 32/61] 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 33/61] 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 34/61] 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 35/61] 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 36/61] 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 37/61] 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 38/61] 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 39/61] 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 40/61] 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 41/61] 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 42/61] 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 43/61] 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 44/61] 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 45/61] 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 46/61] 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 47/61] 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 48/61] 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 49/61] 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 50/61] 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 51/61] 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 52/61] 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 53/61] 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 54/61] 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 55/61] 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 56/61] 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 0505684d229d8e47371d6a49aeb46178459838ed Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:06:02 +0000 Subject: [PATCH 57/61] fix: sync paid and received amount (backport #53039) (#54107) Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com> fix: sync paid and received amount (#53039) --- erpnext/accounts/doctype/payment_entry/payment_entry.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 60c8e47f8f0..42fdc5124bf 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -839,7 +839,7 @@ frappe.ui.form.on("Payment Entry", { paid_amount: function (frm) { frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate)); let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; - if (!frm.doc.received_amount) { + if (frm.doc.paid_amount) { if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) { frm.set_value("received_amount", frm.doc.paid_amount); } else if (company_currency == frm.doc.paid_to_account_currency) { @@ -860,7 +860,7 @@ frappe.ui.form.on("Payment Entry", { flt(frm.doc.received_amount) * flt(frm.doc.target_exchange_rate) ); - if (!frm.doc.paid_amount) { + if (frm.doc.received_amount) { if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) { frm.set_value("paid_amount", frm.doc.received_amount); if (frm.doc.target_exchange_rate) { From 2f9643d44d7119fa90e84c5d357b70081535413b Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Tue, 27 Jan 2026 23:35:35 +0530 Subject: [PATCH 58/61] refactor: reposting for better peformance (cherry picked from commit 20787ef5da3a71e3b4a9970470ef035d7c225786) --- .../repost_item_valuation.js | 40 +- .../repost_item_valuation.json | 69 +- .../repost_item_valuation.py | 12 +- .../stock_ledger_variance.py | 7 +- erpnext/stock/stock_ledger.py | 712 ++++++++++-------- 5 files changed, 461 insertions(+), 379 deletions(-) diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js index e6547ad6f35..c514b25c8ef 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js @@ -67,9 +67,15 @@ frappe.ui.form.on("Repost Item Valuation", { } if (frm.doc.status == "In Progress") { - frm.doc.current_index = data.current_index; - frm.doc.items_to_be_repost = data.items_to_be_repost; - frm.doc.total_reposting_count = data.total_reposting_count; + if (data.current_index) { + frm.doc.current_index = data.current_index; + frm.doc.items_to_be_repost = data.items_to_be_repost; + } + + if (data.vouchers_posted) { + frm.doc.total_vouchers = data.total_vouchers; + frm.doc.vouchers_posted = data.vouchers_posted; + } frm.dashboard.reset(); frm.trigger("show_reposting_progress"); @@ -104,15 +110,31 @@ frappe.ui.form.on("Repost Item Valuation", { show_reposting_progress: function (frm) { var bars = []; - + let title = ""; + let progress = 0.0; let total_count = frm.doc.items_to_be_repost ? JSON.parse(frm.doc.items_to_be_repost).length : 0; - if (frm.doc?.total_reposting_count) { - total_count = frm.doc.total_reposting_count; + if (total_count > 1) { + progress = flt((cint(frm.doc.current_index) / total_count) * 100, 2) || 0.5; + title = __("Reposting for Item-Wh Completed {0}%", [progress]); + + bars.push({ + title: title, + width: progress + "%", + progress_class: "progress-bar-success", + }); + + frm.dashboard.add_progress(__("Reposting Progress"), bars); } - let progress = flt((cint(frm.doc.current_index) / total_count) * 100, 2) || 0.5; - var title = __("Reposting Completed {0}%", [progress]); + if (!frm.doc.vouchers_posted) { + return; + } + + // Show voucher posting progress if vouchers are being reposted + bars = []; + progress = flt((cint(frm.doc.vouchers_posted) / cint(frm.doc.total_vouchers)) * 100, 2) || 0.5; + title = __("Reposting for Vouchers Completed {0}%", [progress]); bars.push({ title: title, @@ -120,7 +142,7 @@ frappe.ui.form.on("Repost Item Valuation", { progress_class: "progress-bar-success", }); - frm.dashboard.add_progress(__("Reposting Progress"), bars); + frm.dashboard.add_progress(__("Reposting Vouchers Progress"), bars); }, restart_reposting: function (frm) { diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json index bd70072e4bd..3affd1e4be9 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json @@ -24,14 +24,16 @@ "error_section", "error_log", "reposting_info_section", - "reposting_data_file", "items_to_be_repost", - "distinct_item_and_warehouse", "column_break_o1sj", "total_reposting_count", "current_index", "gl_reposting_index", - "affected_transactions" + "reposting_data_file", + "vouchers_based_on_item_and_warehouse_section", + "total_vouchers", + "column_break_yqwo", + "vouchers_posted" ], "fields": [ { @@ -164,15 +166,6 @@ "print_hide": 1, "read_only": 1 }, - { - "fieldname": "distinct_item_and_warehouse", - "fieldtype": "Code", - "hidden": 1, - "label": "Distinct Item and Warehouse", - "no_copy": 1, - "print_hide": 1, - "read_only": 1 - }, { "fieldname": "current_index", "fieldtype": "Int", @@ -182,14 +175,6 @@ "print_hide": 1, "read_only": 1 }, - { - "fieldname": "affected_transactions", - "fieldtype": "Code", - "hidden": 1, - "label": "Affected Transactions", - "no_copy": 1, - "read_only": 1 - }, { "default": "0", "fieldname": "gl_reposting_index", @@ -202,7 +187,7 @@ { "fieldname": "reposting_info_section", "fieldtype": "Section Break", - "label": "Reposting Info" + "label": "Reposting Item and Warehouse" }, { "fieldname": "column_break_o1sj", @@ -211,14 +196,7 @@ { "fieldname": "total_reposting_count", "fieldtype": "Int", - "label": "Total Reposting Count", - "no_copy": 1, - "read_only": 1 - }, - { - "fieldname": "reposting_data_file", - "fieldtype": "Attach", - "label": "Reposting Data File", + "label": "No of Items to Repost", "no_copy": 1, "read_only": 1 }, @@ -228,13 +206,44 @@ "fieldname": "recreate_stock_ledgers", "fieldtype": "Check", "label": "Recreate Stock Ledgers" + }, + { + "fieldname": "vouchers_based_on_item_and_warehouse_section", + "fieldtype": "Section Break", + "hidden": 1, + "label": "Reposting Vouchers" + }, + { + "fieldname": "total_vouchers", + "fieldtype": "Int", + "label": "Total Ledgers", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "column_break_yqwo", + "fieldtype": "Column Break" + }, + { + "fieldname": "vouchers_posted", + "fieldtype": "Int", + "label": "Ledgers Posted", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "reposting_data_file", + "fieldtype": "Attach", + "label": "Reposting Data File", + "no_copy": 1, + "read_only": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-03-31 12:38:20.566196", + "modified": "2026-03-27 19:59:58.637964", "modified_by": "Administrator", "module": "Stock", "name": "Repost Item Valuation", diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index e3b1b330fad..16e0cac3b87 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -33,14 +33,12 @@ class RepostItemValuation(Document): if TYPE_CHECKING: from frappe.types import DF - affected_transactions: DF.Code | None allow_negative_stock: DF.Check allow_zero_rate: DF.Check amended_from: DF.Link | None based_on: DF.Literal["Transaction", "Item and Warehouse"] company: DF.Link | None current_index: DF.Int - distinct_item_and_warehouse: DF.Code | None error_log: DF.LongText | None gl_reposting_index: DF.Int item_code: DF.Link | None @@ -49,11 +47,14 @@ class RepostItemValuation(Document): posting_time: DF.Time | None recreate_stock_ledgers: DF.Check reposting_data_file: DF.Attach | None - status: DF.Literal["Queued", "In Progress", "Completed", "Skipped", "Failed"] + reposting_reference: DF.Data | None + status: DF.Literal["Queued", "In Progress", "Completed", "Skipped", "Failed", "Cancelled"] total_reposting_count: DF.Int + total_vouchers: DF.Int via_landed_cost_voucher: DF.Check voucher_no: DF.DynamicLink | None voucher_type: DF.Link | None + vouchers_posted: DF.Int warehouse: DF.Link | None # end: auto-generated types @@ -250,6 +251,9 @@ class RepostItemValuation(Document): self.distinct_item_and_warehouse = None self.items_to_be_repost = None self.gl_reposting_index = 0 + self.total_reposting_count = 0 + self.total_vouchers = 0 + self.vouchers_posted = 0 self.clear_attachment() self.db_update() @@ -381,7 +385,7 @@ def repost_sl_entries(doc): ) else: repost_future_sle( - args=[ + items_to_be_repost=[ frappe._dict( { "item_code": doc.item_code, diff --git a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py index 808afadd05a..327f158e3f6 100644 --- a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py +++ b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py @@ -248,12 +248,7 @@ def get_item_warehouse_combinations(filters: dict | None = None) -> dict: bin.warehouse, item.valuation_method, ) - .where( - (item.is_stock_item == 1) - & (item.has_serial_no == 0) - & (warehouse.is_group == 0) - & (warehouse.company == filters.company) - ) + .where((item.is_stock_item == 1) & (warehouse.is_group == 0) & (warehouse.company == filters.company)) ) if filters.item_code: diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index d50d7fc5dba..89e572e6be3 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -4,16 +4,19 @@ import copy import gzip import json +from collections import deque import frappe from frappe import _, bold, scrub from frappe.model.meta import get_field_precision +from frappe.query_builder import Order from frappe.query_builder.functions import Sum from frappe.utils import ( cint, cstr, flt, format_date, + get_datetime, get_link_to_form, getdate, now, @@ -66,8 +69,8 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc from erpnext.controllers.stock_controller import future_sle_exists if sl_entries: - cancel = sl_entries[0].get("is_cancelled") - if cancel: + cancelled = sl_entries[0].get("is_cancelled") + if cancelled: validate_cancellation(sl_entries) set_as_cancel(sl_entries[0].get("voucher_type"), sl_entries[0].get("voucher_no")) @@ -75,10 +78,7 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc future_sle_exists(args, sl_entries) for sle in sl_entries: - if sle.serial_no and not via_landed_cost_voucher: - validate_serial_no(sle) - - if cancel: + if cancelled: sle["actual_qty"] = -flt(sle.get("actual_qty")) if sle["actual_qty"] < 0 and not sle.get("outgoing_rate"): @@ -155,35 +155,6 @@ def get_args_for_future_sle(row): ) -def validate_serial_no(sle): - from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos - - for sn in get_serial_nos(sle.serial_no): - args = copy.deepcopy(sle) - args.serial_no = sn - args.warehouse = "" - - vouchers = [] - for row in get_stock_ledger_entries(args, ">"): - voucher_type = frappe.bold(row.voucher_type) - voucher_no = frappe.bold(get_link_to_form(row.voucher_type, row.voucher_no)) - vouchers.append(f"{voucher_type} {voucher_no}") - - if vouchers: - serial_no = frappe.bold(sn) - msg = ( - f"""The serial no {serial_no} has been used in the future transactions so you need to cancel them first. - The list of the transactions are as below.""" - + "

  • " - ) - - msg += "
  • ".join(vouchers) - msg += "
" - - title = "Cannot Submit" if not sle.get("is_cancelled") else "Cannot Cancel" - frappe.throw(_(msg), title=_(title), exc=SerialNoExistsInFutureTransaction) - - def validate_cancellation(kargs): if kargs[0].get("is_cancelled"): repost_entry = frappe.db.get_value( @@ -237,146 +208,96 @@ def make_entry(args, allow_negative_stock=False, via_landed_cost_voucher=False): def repost_future_sle( - args=None, + items_to_be_repost=None, voucher_type=None, voucher_no=None, allow_negative_stock=None, via_landed_cost_voucher=False, doc=None, ): - if not args: - args = [] # set args to empty list if None to avoid enumerate error - reposting_data = {} + if not items_to_be_repost: + items_to_be_repost = get_items_to_be_repost( + voucher_type=voucher_type, voucher_no=voucher_no, doc=doc, reposting_data=reposting_data + ) + if doc and doc.reposting_data_file: reposting_data = get_reposting_data(doc.reposting_data_file) - items_to_be_repost = get_items_to_be_repost( - voucher_type=voucher_type, voucher_no=voucher_no, doc=doc, reposting_data=reposting_data + repost_affected_transaction = get_affected_transactions(doc, reposting_data) or set() + resume_item_wh_wise_last_posted_sle = ( + get_item_wh_wise_last_posted_sle_from_reposting_data(doc, reposting_data) or {} ) - if items_to_be_repost: - args = items_to_be_repost - - distinct_item_warehouses = get_distinct_item_warehouse(args, doc, reposting_data=reposting_data) - affected_transactions = get_affected_transactions(doc, reposting_data=reposting_data) - - i = get_current_index(doc) or 0 - while i < len(args): - validate_item_warehouse(args[i]) + if not items_to_be_repost: + return + index = get_current_index(doc) or 0 + while index < len(items_to_be_repost): obj = update_entries_after( { - "item_code": args[i].get("item_code"), - "warehouse": args[i].get("warehouse"), - "posting_date": args[i].get("posting_date"), - "posting_time": args[i].get("posting_time"), - "creation": args[i].get("creation"), - "distinct_item_warehouses": distinct_item_warehouses, - "items_to_be_repost": args, - "current_index": i, + "item_code": items_to_be_repost[index].get("item_code"), + "warehouse": items_to_be_repost[index].get("warehouse"), + "posting_date": items_to_be_repost[index].get("posting_date"), + "posting_time": items_to_be_repost[index].get("posting_time"), + "creation": items_to_be_repost[index].get("creation"), + "current_idx": index, + "items_to_be_repost": items_to_be_repost, + "repost_doc": doc, + "repost_affected_transaction": repost_affected_transaction, + "item_wh_wise_last_posted_sle": resume_item_wh_wise_last_posted_sle, }, allow_negative_stock=allow_negative_stock, via_landed_cost_voucher=via_landed_cost_voucher, ) - affected_transactions.update(obj.affected_transactions) - key = (args[i].get("item_code"), args[i].get("warehouse")) - if distinct_item_warehouses.get(key): - distinct_item_warehouses[key].reposting_status = True + index += 1 - if obj.new_items_found: - for _item_wh, data in distinct_item_warehouses.items(): - if ("args_idx" not in data and not data.reposting_status) or ( - data.sle_changed and data.reposting_status - ): - data.args_idx = len(args) - args.append(data.sle) - elif data.sle_changed and not data.reposting_status: - args[data.args_idx] = data.sle - - data.sle_changed = False - i += 1 - - if doc: - update_args_in_repost_item_valuation( - doc, i, args, distinct_item_warehouses, affected_transactions - ) + resume_item_wh_wise_last_posted_sle = {} + repost_affected_transaction.update(obj.repost_affected_transaction) + update_args_in_repost_item_valuation(doc, index, items_to_be_repost, repost_affected_transaction) -def get_reposting_data(file_path) -> dict: - file_name = frappe.db.get_value( - "File", +def update_args_in_repost_item_valuation( + doc, + index, + items_to_be_repost, + repost_affected_transaction, + item_wh_wise_last_posted_sle=None, + only_affected_transaction=False, +): + file_name = "" + has_file = False + + if not item_wh_wise_last_posted_sle: + item_wh_wise_last_posted_sle = {} + + if doc.reposting_data_file: + has_file = True + + if doc.reposting_data_file: + file_name = get_reposting_file_name(doc.doctype, doc.name) + # frappe.delete_doc("File", file_name, ignore_permissions=True, delete_permanently=True) + + doc.reposting_data_file = create_json_gz_file( { - "file_url": file_path, - "attached_to_field": "reposting_data_file", + "repost_affected_transaction": repost_affected_transaction, + "item_wh_wise_last_posted_sle": {str(k): v for k, v in item_wh_wise_last_posted_sle.items()} + or {}, }, - "name", + doc, + file_name, ) - if not file_name: - return frappe._dict() - - attached_file = frappe.get_doc("File", file_name) - - content = attached_file.get_content() - if isinstance(content, str): - content = content.encode("utf-8") - - try: - data = gzip.decompress(content) - except Exception: - return frappe._dict() - - if data := json.loads(data.decode("utf-8")): - data = data - - return parse_json(data) - - -def validate_item_warehouse(args): - for field in ["item_code", "warehouse", "posting_date", "posting_time"]: - if args.get(field) in [None, ""]: - validation_msg = f"The field {frappe.unscrub(field)} is required for the reposting" - frappe.throw(_(validation_msg)) - - -def update_args_in_repost_item_valuation(doc, index, args, distinct_item_warehouses, affected_transactions): - if not doc.items_to_be_repost: - file_name = "" - if doc.reposting_data_file: - file_name = get_reposting_file_name(doc.doctype, doc.name) - # frappe.delete_doc("File", file_name, ignore_permissions=True, delete_permanently=True) - - doc.reposting_data_file = create_json_gz_file( - { - "items_to_be_repost": args, - "distinct_item_and_warehouse": {str(k): v for k, v in distinct_item_warehouses.items()}, - "affected_transactions": affected_transactions, - }, - doc, - file_name, - ) - + if not only_affected_transaction or not has_file: doc.db_set( { "current_index": index, - "total_reposting_count": len(args), + "items_to_be_repost": frappe.as_json(items_to_be_repost), + "total_reposting_count": len(items_to_be_repost), "reposting_data_file": doc.reposting_data_file, } ) - else: - doc.db_set( - { - "items_to_be_repost": json.dumps(args, default=str), - "distinct_item_and_warehouse": json.dumps( - {str(k): v for k, v in distinct_item_warehouses.items()}, default=str - ), - "current_index": index, - "affected_transactions": frappe.as_json(affected_transactions), - } - ) - if not frappe.flags.in_test: frappe.db.commit() @@ -384,9 +305,8 @@ def update_args_in_repost_item_valuation(doc, index, args, distinct_item_warehou "item_reposting_progress", { "name": doc.name, - "items_to_be_repost": json.dumps(args, default=str), "current_index": index, - "total_reposting_count": len(args), + "total_reposting_count": len(items_to_be_repost), }, doctype=doc.doctype, docname=doc.name, @@ -443,23 +363,27 @@ def create_file(doc, compressed_content): return _file.file_url -def get_items_to_be_repost(voucher_type=None, voucher_no=None, doc=None, reposting_data=None): - if not reposting_data and doc and doc.reposting_data_file: - reposting_data = get_reposting_data(doc.reposting_data_file) +def validate_item_warehouse(args): + for field in ["item_code", "warehouse", "posting_date", "posting_time"]: + if args.get(field) in [None, ""]: + validation_msg = f"The field {frappe.unscrub(field)} is required for the reposting" + frappe.throw(_(validation_msg)) + +def get_items_to_be_repost(voucher_type=None, voucher_no=None, doc=None, reposting_data=None): if reposting_data and reposting_data.items_to_be_repost: return reposting_data.items_to_be_repost items_to_be_repost = [] if doc and doc.items_to_be_repost: - items_to_be_repost = json.loads(doc.items_to_be_repost) or [] + items_to_be_repost = json.loads(doc.items_to_be_repost) if not items_to_be_repost and voucher_type and voucher_no: items_to_be_repost = frappe.db.get_all( "Stock Ledger Entry", filters={"voucher_type": voucher_type, "voucher_no": voucher_no}, - fields=["item_code", "warehouse", "posting_date", "posting_time", "creation"], + fields=["item_code", "warehouse", "posting_date", "posting_time", "creation", "posting_datetime"], order_by="creation asc", group_by="item_code, warehouse", ) @@ -467,51 +391,54 @@ def get_items_to_be_repost(voucher_type=None, voucher_no=None, doc=None, reposti return items_to_be_repost or [] -def get_distinct_item_warehouse(args=None, doc=None, reposting_data=None): - if not reposting_data and doc and doc.reposting_data_file: - reposting_data = get_reposting_data(doc.reposting_data_file) - - if reposting_data and reposting_data.distinct_item_and_warehouse: - return parse_distinct_items_and_warehouses(reposting_data.distinct_item_and_warehouse) - - distinct_item_warehouses = {} - - if doc and doc.distinct_item_and_warehouse: - distinct_item_warehouses = json.loads(doc.distinct_item_and_warehouse) - distinct_item_warehouses = { - frappe.safe_eval(k): frappe._dict(v) for k, v in distinct_item_warehouses.items() - } - else: - for i, d in enumerate(args): - distinct_item_warehouses.setdefault( - (d.item_code, d.warehouse), frappe._dict({"reposting_status": False, "sle": d, "args_idx": i}) - ) - - return distinct_item_warehouses - - -def parse_distinct_items_and_warehouses(distinct_items_and_warehouses): - new_dict = frappe._dict({}) - - # convert string keys to tuple - for k, v in distinct_items_and_warehouses.items(): - new_dict[frappe.safe_eval(k)] = frappe._dict(v) - - return new_dict - - def get_affected_transactions(doc, reposting_data=None) -> set[tuple[str, str]]: if not reposting_data and doc and doc.reposting_data_file: reposting_data = get_reposting_data(doc.reposting_data_file) - if reposting_data and reposting_data.affected_transactions: - return {tuple(transaction) for transaction in reposting_data.affected_transactions} + if reposting_data and reposting_data.repost_affected_transaction: + return {tuple(transaction) for transaction in reposting_data.repost_affected_transaction} - if not doc.affected_transactions: - return set() + return set() - transactions = frappe.parse_json(doc.affected_transactions) - return {tuple(transaction) for transaction in transactions} + +def get_item_wh_wise_last_posted_sle_from_reposting_data(doc, reposting_data=None): + if not reposting_data and doc and doc.reposting_data_file: + reposting_data = get_reposting_data(doc.reposting_data_file) + + if reposting_data and reposting_data.item_wh_wise_last_posted_sle: + return frappe._dict(reposting_data.item_wh_wise_last_posted_sle) + + return frappe._dict() + + +def get_reposting_data(file_path) -> dict: + file_name = frappe.db.get_value( + "File", + { + "file_url": file_path, + "attached_to_field": "reposting_data_file", + }, + "name", + ) + + if not file_name: + return frappe._dict() + + attached_file = frappe.get_doc("File", file_name) + + content = attached_file.get_content() + if isinstance(content, str): + content = content.encode("utf-8") + + try: + data = gzip.decompress(content) + except Exception: + return frappe._dict() + + if data := json.loads(data.decode("utf-8")): + data = data + + return parse_json(data) def get_current_index(doc=None): @@ -547,6 +474,10 @@ class update_entries_after: self.allow_zero_rate = allow_zero_rate self.via_landed_cost_voucher = via_landed_cost_voucher self.item_code = args.get("item_code") + self.stock_ledgers_to_repost = [] + self.current_idx = args.get("current_idx", 0) + self.repost_doc = args.get("repost_doc") or None + self.items_to_be_repost = args.get("items_to_be_repost") or None self.allow_negative_stock = allow_negative_stock or is_negative_stock_allowed( item_code=self.item_code @@ -556,17 +487,20 @@ class update_entries_after: if self.args.sle_id: self.args["name"] = self.args.sle_id + self.prev_sle_dict = frappe._dict({}) self.company = frappe.get_cached_value("Warehouse", self.args.warehouse, "company") self.set_precision() self.valuation_method = get_valuation_method(self.item_code) + self.repost_affected_transaction = args.get("repost_affected_transaction") or set() self.new_items_found = False - self.distinct_item_warehouses = args.get("distinct_item_warehouses", frappe._dict()) - self.affected_transactions: set[tuple[str, str]] = set() self.reserved_stock = self.get_reserved_stock() self.data = frappe._dict() - self.initialize_previous_data(self.args) + + if not self.repost_doc or not self.args.get("item_wh_wise_last_posted_sle"): + self.initialize_previous_data(self.args) + self.build() def get_reserved_stock(self): @@ -613,7 +547,14 @@ class update_entries_after: """ self.data.setdefault(args.warehouse, frappe._dict()) warehouse_dict = self.data[args.warehouse] + + if self.stock_ledgers_to_repost: + return + previous_sle = get_previous_sle_of_current_voucher(args) + if previous_sle: + self.prev_sle_dict[(args.get("item_code"), args.get("warehouse"))] = previous_sle + warehouse_dict.previous_sle = previous_sle for key in ("qty_after_transaction", "valuation_rate", "stock_value"): @@ -635,27 +576,185 @@ class update_entries_after: if not future_sle_exists(self.args): self.update_bin() else: - entries_to_fix = self.get_future_entries_to_fix() + self.item_wh_wise_last_posted_sle = self.get_item_wh_wise_last_posted_sle() + _item_wh_sle = self.sort_sles(self.item_wh_wise_last_posted_sle.values()) - i = 0 - while i < len(entries_to_fix): - sle = entries_to_fix[i] - i += 1 + while _item_wh_sle: + self.initialize_reposting() + sle_dict = _item_wh_sle.pop(0) + self.repost_stock_ledgers(sle_dict) - self.process_sle(sle) - self.update_bin_data(sle) - - if sle.dependant_sle_voucher_detail_no: - entries_to_fix = self.get_dependent_entries_to_fix(entries_to_fix, sle) - if sle.voucher_type == "Stock Entry" and is_repack_entry(sle.voucher_no): - # for repack entries, we need to repost both source and target warehouses - self.update_distinct_item_warehouses_for_repack(sle) + self.update_bin() + self.reset_vouchers_and_idx() + self.update_data_in_repost() if self.exceptions: self.raise_exceptions() - def update_distinct_item_warehouses_for_repack(self, sle): - sles = ( + def initialize_reposting(self): + self._sles = [] + self.distinct_sles = set() + self.distinct_dependant_item_wh = set() + self.prev_sle_dict = frappe._dict({}) + + def get_item_wh_wise_last_posted_sle(self): + if self.args and self.args.get("item_wh_wise_last_posted_sle"): + _sles = {} + for key, sle in self.args.get("item_wh_wise_last_posted_sle").items(): + _sles[frappe.safe_eval(key)] = frappe._dict(sle) + + return _sles + + return { + (self.args.item_code, self.args.warehouse): frappe._dict( + { + "item_code": self.args.item_code, + "warehouse": self.args.warehouse, + "posting_datetime": get_combine_datetime(self.args.posting_date, self.args.posting_time), + "posting_date": self.args.posting_date, + "posting_time": self.args.posting_time, + "creation": self.args.creation, + } + ) + } + + def repost_stock_ledgers(self, sle_dict=None): + self._sles = self.get_future_entries_to_repost(sle_dict) + + if not isinstance(self._sles, deque): + self._sles = deque(self._sles) + + i = 0 + while self._sles: + sle = self._sles.popleft() + i += 1 + if sle.name in self.distinct_sles: + continue + + item_wh_key = (sle.item_code, sle.warehouse) + if item_wh_key not in self.prev_sle_dict: + self.prev_sle_dict[item_wh_key] = get_previous_sle_of_current_voucher(sle) + + self.repost_stock_ledger_entry(sle) + + # To avoid duplicate reposting of same sle in case of multiple dependant sle + self.distinct_sles.add(sle.name) + + if sle.dependant_sle_voucher_detail_no: + self.include_dependant_sle_in_reposting(sle) + self.update_item_wh_wise_last_posted_sle(sle) + + if i % 1000 == 0: + self.update_data_in_repost(len(self._sles), i) + + def sort_sles(self, sles): + return sorted( + sles, + key=lambda d: ( + get_datetime(d.posting_datetime), + get_datetime(d.creation), + ), + ) + + def include_dependant_sle_in_reposting(self, sle): + repost_dependant_sle = False + if sle.voucher_type == "Stock Entry" and is_repack_entry(sle.voucher_no): + repack_sles = self.get_sles_for_repack(sle) + for repack_sle in repack_sles: + if (repack_sle.item_code, repack_sle.warehouse) in self.distinct_dependant_item_wh: + continue + + repost_dependant_sle = True + self.distinct_dependant_item_wh.add((repack_sle.item_code, repack_sle.warehouse)) + self._sles.extend(self.get_future_entries_to_repost(repack_sle)) + else: + dependant_sles = get_sle_by_voucher_detail_no(sle.dependant_sle_voucher_detail_no) + for depend_sle in dependant_sles: + if (depend_sle.item_code, depend_sle.warehouse) in self.distinct_dependant_item_wh: + continue + + repost_dependant_sle = True + self.distinct_dependant_item_wh.add((depend_sle.item_code, depend_sle.warehouse)) + self._sles.extend(self.get_future_entries_to_repost(depend_sle)) + + if repost_dependant_sle: + self._sles = deque(self.sort_sles(self._sles)) + + def repost_stock_ledger_entry(self, sle): + if self.args.item_code != sle.item_code or self.args.warehouse != sle.warehouse: + self.repost_affected_transaction.add((sle.voucher_type, sle.voucher_no)) + + if isinstance(sle, dict): + sle = frappe._dict(sle) + + self.process_sle(sle) + self.update_item_wh_wise_last_posted_sle(sle) + + def update_item_wh_wise_last_posted_sle(self, sle): + if not self._sles: + self.item_wh_wise_last_posted_sle = frappe._dict() + return + + self.item_wh_wise_last_posted_sle[(sle.item_code, sle.warehouse)] = frappe._dict( + { + "item_code": sle.item_code, + "warehouse": sle.warehouse, + "posting_date": sle.posting_date, + "posting_time": sle.posting_time, + "posting_datetime": sle.posting_datetime + or get_combine_datetime(sle.posting_date, sle.posting_time), + "creation": sle.creation, + } + ) + + def reset_vouchers_and_idx(self): + self.stock_ledgers_to_repost = [] + self.prev_sle_dict = frappe._dict() + self.item_wh_wise_last_posted_sle = frappe._dict() + + def update_data_in_repost(self, total_sles=None, index=None): + if not self.repost_doc: + return + + values_to_update = { + "total_vouchers": cint(total_sles) + cint(index), + "vouchers_posted": index or 0, + } + + self.repost_doc.db_set(values_to_update) + + update_args_in_repost_item_valuation( + self.repost_doc, + self.current_idx, + self.items_to_be_repost, + self.repost_affected_transaction, + self.item_wh_wise_last_posted_sle, + only_affected_transaction=True, + ) + + if not frappe.flags.in_test: + # To maintain the state of the reposting, so if timeout happens, it can be resumed from the last posted voucher + frappe.db.commit() # nosemgrep + + self.publish_real_time_progress(total_sles=total_sles, index=index) + + def publish_real_time_progress(self, total_sles=None, index=None): + frappe.publish_realtime( + "item_reposting_progress", + { + "name": self.repost_doc.name, + "total_vouchers": cint(total_sles) + cint(index), + "vouchers_posted": index or 0, + }, + doctype=self.repost_doc.doctype, + docname=self.repost_doc.name, + ) + + def get_future_entries_to_repost(self, kwargs): + return get_stock_ledger_entries(kwargs, ">=", "asc", for_update=True, check_serial_no=False) + + def get_sles_for_repack(self, sle): + return ( frappe.get_all( "Stock Ledger Entry", filters={ @@ -663,16 +762,20 @@ class update_entries_after: "voucher_no": sle.voucher_no, "actual_qty": (">", 0), "is_cancelled": 0, - "voucher_detail_no": ("!=", sle.dependant_sle_voucher_detail_no), + "dependant_sle_voucher_detail_no": ("!=", sle.dependant_sle_voucher_detail_no), }, - fields=["*"], + fields=[ + "item_code", + "warehouse", + "posting_date", + "posting_time", + "posting_datetime", + "creation", + ], ) or [] ) - for dependant_sle in sles: - self.update_distinct_item_warehouses(dependant_sle) - def has_stock_reco_with_serial_batch(self, sle): if ( sle.voucher_type == "Stock Reconciliation" @@ -683,35 +786,10 @@ class update_entries_after: return False def process_sle_against_current_timestamp(self): - sl_entries = self.get_sle_against_current_voucher() + sl_entries = get_sle_against_current_voucher(self.args) for sle in sl_entries: self.process_sle(sle) - def get_sle_against_current_voucher(self): - self.args["posting_datetime"] = get_combine_datetime(self.args.posting_date, self.args.posting_time) - - return frappe.db.sql( - """ - select - *, posting_datetime as "timestamp" - from - `tabStock Ledger Entry` - where - item_code = %(item_code)s - and warehouse = %(warehouse)s - and is_cancelled = 0 - and ( - posting_datetime = %(posting_datetime)s - ) - and creation = %(creation)s - order by - creation ASC - for update - """, - self.args, - as_dict=1, - ) - def get_future_entries_to_fix(self): # includes current entry! args = self.data[self.args.warehouse].previous_sle or frappe._dict( @@ -720,78 +798,8 @@ class update_entries_after: return list(self.get_sle_after_datetime(args)) - def get_dependent_entries_to_fix(self, entries_to_fix, sle): - dependant_sle = get_sle_by_voucher_detail_no( - sle.dependant_sle_voucher_detail_no, excluded_sle=sle.name - ) - - if not dependant_sle: - return entries_to_fix - elif dependant_sle.item_code == self.item_code and dependant_sle.warehouse == self.args.warehouse: - return entries_to_fix - elif dependant_sle.item_code != self.item_code: - self.update_distinct_item_warehouses(dependant_sle) - return entries_to_fix - elif dependant_sle.item_code == self.item_code and dependant_sle.warehouse in self.data: - return entries_to_fix - else: - self.initialize_previous_data(dependant_sle) - self.update_distinct_item_warehouses(dependant_sle) - return entries_to_fix - - def update_distinct_item_warehouses(self, dependant_sle): - key = (dependant_sle.item_code, dependant_sle.warehouse) - val = frappe._dict({"sle": dependant_sle}) - - if key not in self.distinct_item_warehouses: - self.distinct_item_warehouses[key] = val - self.new_items_found = True - else: - existing_sle = self.distinct_item_warehouses[key].get("sle", {}) - if getdate(existing_sle.get("posting_date")) > getdate(dependant_sle.posting_date): - self.distinct_item_warehouses[key] = val - self.new_items_found = True - elif ( - dependant_sle.actual_qty > 0 - and dependant_sle.voucher_type == "Stock Entry" - and is_transfer_stock_entry(dependant_sle.voucher_no) - ): - if self.distinct_item_warehouses[key].get("transfer_entry_to_repost"): - return - - val["transfer_entry_to_repost"] = True - self.distinct_item_warehouses[key] = val - self.new_items_found = True - - def is_dependent_voucher_reposted(self, dependant_sle) -> bool: - # Return False if the dependent voucher is not reposted - - if self.args.items_to_be_repost and self.args.current_index: - index = self.args.current_index - while index < len(self.args.items_to_be_repost): - if ( - self.args.items_to_be_repost[index].get("item_code") == dependant_sle.item_code - and self.args.items_to_be_repost[index].get("warehouse") == dependant_sle.warehouse - ): - if getdate(self.args.items_to_be_repost[index].get("posting_date")) > getdate( - dependant_sle.posting_date - ): - self.args.items_to_be_repost[index]["posting_date"] = dependant_sle.posting_date - - return False - - index += 1 - - return True - - def get_dependent_voucher_detail_nos(self, key): - if "dependent_voucher_detail_nos" not in self.distinct_item_warehouses[key]: - self.distinct_item_warehouses[key].dependent_voucher_detail_nos = [] - - return self.distinct_item_warehouses[key].dependent_voucher_detail_nos - def validate_previous_sle_qty(self, sle): - previous_sle = self.data[sle.warehouse].previous_sle + previous_sle = self.prev_sle_dict.get((sle.item_code, sle.warehouse)) if previous_sle and previous_sle.get("qty_after_transaction") < 0 and sle.get("actual_qty") > 0: frappe.msgprint( _( @@ -810,10 +818,32 @@ class update_entries_after: def process_sle(self, sle): # previous sle data for this warehouse - self.wh_data = self.data[sle.warehouse] + key = (sle.item_code, sle.warehouse) + if key not in self.prev_sle_dict: + prev_sle = get_previous_sle_of_current_voucher(sle) + if prev_sle: + self.prev_sle_dict[key] = prev_sle + + if not self.prev_sle_dict.get(key): + self.prev_sle_dict[key] = frappe._dict( + { + "qty_after_transaction": 0.0, + "valuation_rate": 0.0, + "stock_value": 0.0, + "prev_stock_value": 0.0, + "stock_queue": [], + } + ) + + self.wh_data = self.prev_sle_dict.get(key) + + if self.wh_data.stock_queue and isinstance(self.wh_data.stock_queue, str): + self.wh_data.stock_queue = json.loads(self.wh_data.stock_queue) + + if not self.wh_data.prev_stock_value: + self.wh_data.prev_stock_value = self.wh_data.stock_value self.validate_previous_sle_qty(sle) - self.affected_transactions.add((sle.voucher_type, sle.voucher_no)) if (sle.serial_no and not self.via_landed_cost_voucher) or not cint(self.allow_negative_stock): # validate negative stock for serialized items, fifo valuation @@ -916,6 +946,7 @@ class update_entries_after: sle.stock_queue = json.dumps(self.wh_data.stock_queue) sle.stock_value_difference = stock_value_difference + if ( sle.is_adjustment_entry and flt(sle.qty_after_transaction, self.flt_precision) == 0 @@ -940,6 +971,8 @@ class update_entries_after: sle.modified = now() frappe.get_doc(sle).db_update() + self.prev_sle_dict[key] = sle + if not self.args.get("sle_id") or ( sle.serial_and_batch_bundle and sle.auto_created_serial_and_batch_bundle ): @@ -1713,15 +1746,42 @@ class update_entries_after: def update_bin(self): # update bin for each warehouse - for warehouse, data in self.data.items(): - bin_name = get_or_make_bin(self.item_code, warehouse) + for (item_code, warehouse), data in self.prev_sle_dict.items(): + bin_name = get_or_make_bin(item_code, warehouse) - updated_values = {"actual_qty": data.qty_after_transaction, "stock_value": data.stock_value} + updated_values = { + "actual_qty": flt(data.qty_after_transaction), + "stock_value": flt(data.stock_value), + } if data.valuation_rate is not None: - updated_values["valuation_rate"] = data.valuation_rate + updated_values["valuation_rate"] = flt(data.valuation_rate) + frappe.db.set_value("Bin", bin_name, updated_values, update_modified=True) +def get_sle_against_current_voucher(kwargs): + kwargs["posting_datetime"] = get_combine_datetime(kwargs.posting_date, kwargs.posting_time) + doctype = frappe.qb.DocType("Stock Ledger Entry") + + query = ( + frappe.qb.from_(doctype) + .select("*") + .where( + (doctype.item_code == kwargs.item_code) + & (doctype.warehouse == kwargs.warehouse) + & (doctype.is_cancelled == 0) + & (doctype.posting_datetime == kwargs.posting_datetime) + ) + .orderby(doctype.creation, order=Order.asc) + .for_update() + ) + + if not kwargs.get("cancelled"): + query = query.where(doctype.creation == kwargs.creation) + + return query.run(as_dict=True) + + def get_previous_sle_of_current_voucher(args, operator="<", exclude_current_voucher=False): """get stock ledger entries filtered by specific posting datetime conditions""" @@ -1874,23 +1934,15 @@ def get_stock_ledger_entries( ) -def get_sle_by_voucher_detail_no(voucher_detail_no, excluded_sle=None): - return frappe.db.get_value( +def get_sle_by_voucher_detail_no(voucher_detail_no): + return frappe.get_all( "Stock Ledger Entry", - {"voucher_detail_no": voucher_detail_no, "name": ["!=", excluded_sle], "is_cancelled": 0}, - [ - "item_code", - "warehouse", - "actual_qty", - "qty_after_transaction", - "posting_date", - "posting_time", - "voucher_detail_no", - "posting_datetime as timestamp", - "voucher_type", - "voucher_no", - ], - as_dict=1, + filters={ + "voucher_detail_no": voucher_detail_no, + "is_cancelled": 0, + "dependant_sle_voucher_detail_no": ("is", "not set"), + }, + fields=["item_code", "warehouse", "posting_date", "posting_time", "posting_datetime", "creation"], ) From 0063201818a8085665f4c1e58b08790ec20e217e Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Sat, 4 Apr 2026 12:47:43 +0530 Subject: [PATCH 59/61] fix: do not repost GL if no change in valuation --- erpnext/stock/stock_ledger.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 89e572e6be3..ccc65a1e329 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -681,9 +681,6 @@ class update_entries_after: self._sles = deque(self.sort_sles(self._sles)) def repost_stock_ledger_entry(self, sle): - if self.args.item_code != sle.item_code or self.args.warehouse != sle.warehouse: - self.repost_affected_transaction.add((sle.voucher_type, sle.voucher_no)) - if isinstance(sle, dict): sle = frappe._dict(sle) @@ -945,6 +942,8 @@ class update_entries_after: sle.stock_value = self.wh_data.stock_value sle.stock_queue = json.dumps(self.wh_data.stock_queue) + old_stock_value_difference = sle.stock_value_difference + sle.stock_value_difference = stock_value_difference if ( @@ -978,6 +977,14 @@ class update_entries_after: ): self.update_outgoing_rate_on_transaction(sle) + if flt(old_stock_value_difference, self.currency_precision) == flt( + sle.stock_value_difference, self.currency_precision + ): + return + + if self.args.item_code != sle.item_code or self.args.warehouse != sle.warehouse: + self.repost_affected_transaction.add((sle.voucher_type, sle.voucher_no)) + def get_serialized_values(self, sle): from erpnext.stock.serial_batch_bundle import SerialNoValuation From 8b42fcf274bd56a65f2e59cda69c1a3c06ca7c93 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 7 Apr 2026 19:09:51 +0530 Subject: [PATCH 60/61] 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: From 5b7e6eb83178effd5d253a7f132c64c7641eb406 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:55:06 +0530 Subject: [PATCH 61/61] =?UTF-8?q?fix(promotional=5Fscheme):=20toggle=20ena?= =?UTF-8?q?ble=20state=20between=20Buying=20and=20Selli=E2=80=A6=20(backpo?= =?UTF-8?q?rt=20#54110)=20(#54111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ahmed AbuKhatwa <82771130+AhmedAbokhatwa@users.noreply.github.com> Co-authored-by: AhmedAbukhatwa fix(promotional_scheme): toggle enable state between Buying and Selli… (#54110) --- .../accounts/doctype/promotional_scheme/promotional_scheme.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.js b/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.js index 43261e4080a..88e5b5216b5 100644 --- a/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.js +++ b/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.js @@ -21,10 +21,12 @@ frappe.ui.form.on("Promotional Scheme", { selling: function (frm) { frm.trigger("set_options_for_applicable_for"); + frm.toggle_enable("buying", !frm.doc.selling); }, buying: function (frm) { frm.trigger("set_options_for_applicable_for"); + frm.toggle_enable("selling", !frm.doc.buying); }, set_options_for_applicable_for: function (frm) {