From dffd5d9cdd8e2f1d30a69472687de1230575f811 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Fri, 28 Nov 2025 11:37:25 +0530 Subject: [PATCH 01/44] fix: cascade projected quantity across multiple items in material requests (cherry picked from commit d344be32a0d4a1de0225df0f6f9ddcf7949e900c) # Conflicts: # erpnext/manufacturing/doctype/production_plan/production_plan.py --- .../production_plan/production_plan.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 4aa723a2ac4..587988eb3ec 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -1303,14 +1303,33 @@ def get_material_request_items( include_safety_stock, warehouse, bin_dict, +<<<<<<< HEAD +======= + consumed_qty, +>>>>>>> d344be32a0 (fix: cascade projected quantity across multiple items in material requests) ): total_qty = row["qty"] required_qty = 0 +<<<<<<< HEAD if ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0: required_qty = total_qty elif total_qty > bin_dict.get("projected_qty", 0): required_qty = total_qty - bin_dict.get("projected_qty", 0) +======= + item_code = row.get("item_code") + + if not ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0: + required_qty = flt(row.get("qty")) + else: + key = (item_code, warehouse) + available_qty = flt(bin_dict.get("projected_qty", 0)) - consumed_qty[key] + if available_qty > 0: + required_qty = max(0, flt(row.get("qty")) - available_qty) + consumed_qty[key] += min(flt(row.get("qty")), available_qty) + else: + required_qty = flt(row.get("qty")) +>>>>>>> d344be32a0 (fix: cascade projected quantity across multiple items in material requests) if doc.get("consider_minimum_order_qty") and required_qty > 0 and required_qty < row["min_order_qty"]: required_qty = row["min_order_qty"] @@ -1648,9 +1667,15 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d so_item_details[sales_order][item_code] = details mr_items = [] + consumed_qty = defaultdict(float) + for sales_order in so_item_details: item_dict = so_item_details[sales_order] for details in item_dict.values(): +<<<<<<< HEAD +======= + warehouse = warehouse or details.get("source_warehouse") or details.get("default_warehouse") +>>>>>>> d344be32a0 (fix: cascade projected quantity across multiple items in material requests) bin_dict = get_bin_details(details, doc.company, warehouse) bin_dict = bin_dict[0] if bin_dict else {} @@ -1664,6 +1689,10 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d include_safety_stock, warehouse, bin_dict, +<<<<<<< HEAD +======= + consumed_qty, +>>>>>>> d344be32a0 (fix: cascade projected quantity across multiple items in material requests) ) if items: mr_items.append(items) From e403dfe73af690e7d4aad3d2c17a60544218b0c2 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Fri, 28 Nov 2025 13:45:20 +0530 Subject: [PATCH 02/44] test: add test for projected quantity cascading across multiple sales orders (cherry picked from commit 92fdec9b928a78a67b51dd58803598f3858346df) --- .../production_plan/test_production_plan.py | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 28c9e63bc1f..20ae8ba6763 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -145,6 +145,73 @@ class TestProductionPlan(FrappeTestCase): sr2.cancel() pln.cancel() + def test_projected_qty_cascading_across_multiple_sales_orders(self): + rm_item = make_item( + "_Test RM For Cascading", + {"is_stock_item": 1, "valuation_rate": 100}, + ).name + + fg_item_a = make_item( + "_Test FG A For Cascading", + {"is_stock_item": 1, "valuation_rate": 200}, + ).name + + if not frappe.db.exists("BOM", {"item": fg_item_a, "docstatus": 1}): + make_bom(item=fg_item_a, raw_materials=[rm_item], rm_qty=1) + + # Stock for RM + sr = create_stock_reconciliation(item_code=rm_item, target="_Test Warehouse - _TC", qty=1, rate=100) + + # Sales orders + so1 = make_sales_order(item_code=fg_item_a, qty=1) + so2 = make_sales_order(item_code=fg_item_a, qty=1) + + # Production plan + pln = frappe.get_doc( + { + "doctype": "Production Plan", + "company": "_Test Company", + "posting_date": nowdate(), + "get_items_from": "Sales Order", + "ignore_existing_ordered_qty": 1, + } + ) + pln.append( + "sales_orders", + { + "sales_order": so1.name, + "sales_order_date": so1.transaction_date, + "customer": so1.customer, + "grand_total": so1.grand_total, + }, + ) + pln.append( + "sales_orders", + { + "sales_order": so2.name, + "sales_order_date": so2.transaction_date, + "customer": so2.customer, + "grand_total": so2.grand_total, + }, + ) + + pln.get_items() + pln.insert() + + mr_items = get_items_for_material_requests(pln.as_dict()) + quantities = [d["quantity"] for d in mr_items] + rm_qty = sum(quantities) + + self.assertEqual(len(mr_items), 2) # one for each SO + self.assertEqual(rm_qty, 1, "Cascading failed: total MR qty should be 1 (2 needed - 1 in stock)") + self.assertEqual( + quantities, + [0, 1], + "Cascading failed: first item should consume stock (qty=0), second should need procurement (qty=1)", + ) + + sr.cancel() + def test_production_plan_with_non_stock_item(self): "Test if MR Planning table includes Non Stock RM." pln = create_production_plan(item_code="Test Production Item 1", include_non_stock_items=1) From edcf24afa99326a039ffa5b93649b763d34a7d32 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Mon, 15 Dec 2025 14:59:06 +0530 Subject: [PATCH 03/44] chore: resolve conflicts --- .../production_plan/production_plan.py | 20 +------------------ 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 587988eb3ec..39c21089f3b 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -1303,20 +1303,9 @@ def get_material_request_items( include_safety_stock, warehouse, bin_dict, -<<<<<<< HEAD -======= consumed_qty, ->>>>>>> d344be32a0 (fix: cascade projected quantity across multiple items in material requests) ): - total_qty = row["qty"] - required_qty = 0 -<<<<<<< HEAD - if ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0: - required_qty = total_qty - elif total_qty > bin_dict.get("projected_qty", 0): - required_qty = total_qty - bin_dict.get("projected_qty", 0) -======= item_code = row.get("item_code") if not ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0: @@ -1329,7 +1318,6 @@ def get_material_request_items( consumed_qty[key] += min(flt(row.get("qty")), available_qty) else: required_qty = flt(row.get("qty")) ->>>>>>> d344be32a0 (fix: cascade projected quantity across multiple items in material requests) if doc.get("consider_minimum_order_qty") and required_qty > 0 and required_qty < row["min_order_qty"]: required_qty = row["min_order_qty"] @@ -1373,7 +1361,7 @@ def get_material_request_items( "item_name": row.item_name, "quantity": required_qty / conversion_factor, "conversion_factor": conversion_factor, - "required_bom_qty": total_qty, + "required_bom_qty": row.get("qty"), "stock_uom": row.get("stock_uom"), "warehouse": warehouse or row.get("source_warehouse") @@ -1672,10 +1660,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d for sales_order in so_item_details: item_dict = so_item_details[sales_order] for details in item_dict.values(): -<<<<<<< HEAD -======= warehouse = warehouse or details.get("source_warehouse") or details.get("default_warehouse") ->>>>>>> d344be32a0 (fix: cascade projected quantity across multiple items in material requests) bin_dict = get_bin_details(details, doc.company, warehouse) bin_dict = bin_dict[0] if bin_dict else {} @@ -1689,10 +1674,7 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d include_safety_stock, warehouse, bin_dict, -<<<<<<< HEAD -======= consumed_qty, ->>>>>>> d344be32a0 (fix: cascade projected quantity across multiple items in material requests) ) if items: mr_items.append(items) From 0452b22aa663c89fd954b0c1590a1dc5507ddd52 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Mon, 15 Dec 2025 17:20:57 +0530 Subject: [PATCH 04/44] fix: use original logic for v15 - inverted wrt v16 --- .../manufacturing/doctype/production_plan/production_plan.py | 2 +- .../doctype/production_plan/test_production_plan.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 39c21089f3b..c4b0aa26fce 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -1308,7 +1308,7 @@ def get_material_request_items( required_qty = 0 item_code = row.get("item_code") - if not ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0: + if ignore_existing_ordered_qty or bin_dict.get("projected_qty", 0) < 0: required_qty = flt(row.get("qty")) else: key = (item_code, warehouse) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 20ae8ba6763..5505be3cafa 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -173,7 +173,7 @@ class TestProductionPlan(FrappeTestCase): "company": "_Test Company", "posting_date": nowdate(), "get_items_from": "Sales Order", - "ignore_existing_ordered_qty": 1, + "ignore_existing_ordered_qty": 0, } ) pln.append( From 0b6b73b5004fc61b83cdc0563c41318c7b8ead7a Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 16 Dec 2025 17:58:20 +0530 Subject: [PATCH 05/44] fix(buying): add disabled filter for supplier (cherry picked from commit 6cc2290f6e0939000a4a6f5e903cf4e850bda5e8) # Conflicts: # erpnext/buying/doctype/request_for_quotation/request_for_quotation.js --- .../request_for_quotation/request_for_quotation.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js index e88a98759d0..5782a3960ad 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js @@ -549,10 +549,20 @@ erpnext.buying.RequestforQuotationController = class RequestforQuotationControll callback: load_suppliers, }); } else if (args.supplier_group) { +<<<<<<< HEAD return frappe.call({ method: "frappe.client.get_list", args: { doctype: "Supplier", +======= + frappe.db + .get_list("Supplier", { + filters: { + supplier_group: args.supplier_group, + disabled: 0, + }, + limit: 100, +>>>>>>> 6cc2290f6e (fix(buying): add disabled filter for supplier) order_by: "name", fields: ["name"], filters: [["Supplier", "supplier_group", "=", args.supplier_group]], From 2d42904bfb9c98ef43d776412c7eab48a46dec51 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 18:24:39 +0100 Subject: [PATCH 06/44] fix: use serial and batch bundle to fetch incoming rate (backport #51119) (#51146) Co-authored-by: NaviN <118178330+Navin-S-R@users.noreply.github.com> Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> fix: use serial and batch bundle to fetch incoming rate (#51119) --- .../doctype/asset_capitalization/asset_capitalization.js | 8 ++++++++ .../doctype/asset_capitalization/asset_capitalization.py | 2 ++ 2 files changed, 10 insertions(+) diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js index 5d47cc13e5b..98cbe10693f 100644 --- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js +++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js @@ -197,6 +197,13 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s } } + serial_and_batch_bundle(doc, cdt, cdn) { + var row = frappe.get_doc(cdt, cdn); + if (cdt === "Asset Capitalization Stock Item") { + this.get_warehouse_details(row); + } + } + asset(doc, cdt, cdn) { var row = frappe.get_doc(cdt, cdn); if (cdt === "Asset Capitalization Asset Item") { @@ -410,6 +417,7 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s voucher_type: me.frm.doc.doctype, voucher_no: me.frm.doc.name, allow_zero_valuation: 1, + serial_and_batch_bundle: item.serial_and_batch_bundle, }, }, callback: function (r) { diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py index 4179ceccb59..1f671333cfa 100644 --- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py +++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py @@ -363,6 +363,7 @@ class AssetCapitalization(StockController): "voucher_no": self.name, "company": self.company, "allow_zero_valuation": cint(item.get("allow_zero_valuation_rate")), + "serial_and_batch_bundle": item.serial_and_batch_bundle, } ) @@ -763,6 +764,7 @@ def get_consumed_stock_item_details(args): "company": args.company, "serial_no": args.serial_no, "batch_no": args.batch_no, + "serial_and_batch_bundle": args.serial_and_batch_bundle, } ) out.update(get_warehouse_details(incoming_rate_args)) From 048865811c1ad0ea43f3a7937970c98dd624d3ec Mon Sep 17 00:00:00 2001 From: Yash Chaubey <101944829+yash14023@users.noreply.github.com> Date: Wed, 15 Oct 2025 12:46:02 +0530 Subject: [PATCH 07/44] perf: optimize company monthly sales query using date range (#48942) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf: optimize company monthly sales query using date range instead of DATE_FORMAT * perf: optimize company monthly sales query using date range (cherry picked from commit 4ede97ae2b86e5e6daa68a04608eafa5d4ec4574) --- erpnext/setup/doctype/company/company.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 50fe87ef654..c4544b4e26b 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -11,7 +11,7 @@ from frappe.cache_manager import clear_defaults_cache from frappe.contacts.address_and_contact import load_address_and_contact from frappe.custom.doctype.property_setter.property_setter import make_property_setter from frappe.desk.page.setup_wizard.setup_wizard import make_records -from frappe.utils import cint, formatdate, get_link_to_form, get_timestamp, today +from frappe.utils import add_months, cint, formatdate, get_first_day, get_link_to_form, get_timestamp, today from frappe.utils.nestedset import NestedSet, rebuild_tree from erpnext.accounts.doctype.account.account import get_account_currency @@ -762,27 +762,29 @@ def install_country_fixtures(company, country): def update_company_current_month_sales(company): - current_month_year = formatdate(today(), "MM-yyyy") + from_date = get_first_day(today()) + to_date = get_first_day(add_months(from_date, 1)) results = frappe.db.sql( - f""" + """ SELECT SUM(base_grand_total) AS total, - DATE_FORMAT(`posting_date`, '%m-%Y') AS month_year + DATE_FORMAT(posting_date, '%%m-%%Y') AS month_year FROM `tabSales Invoice` WHERE - DATE_FORMAT(`posting_date`, '%m-%Y') = '{current_month_year}' + posting_date >= %s + AND posting_date < %s AND docstatus = 1 - AND company = {frappe.db.escape(company)} + AND company = %s GROUP BY month_year - """, + """, + (from_date, to_date, company), as_dict=True, ) monthly_total = results[0]["total"] if len(results) > 0 else 0 - frappe.db.set_value("Company", company, "total_monthly_sales", monthly_total) From 89d6a8f02ec7b4ec9886b4b44621da3cb7d5bf70 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:46:31 +0530 Subject: [PATCH 08/44] fix: incorrect current qty in stock reco (backport #51152) (#51158) * fix: incorrect current qty in stock reco (#51152) (cherry picked from commit dec474ef3a48ea1e0daa4dd6d14257bbf30b81bc) * chore: fix conflicts --------- Co-authored-by: rohitwaghchaure --- erpnext/stock/doctype/batch/batch.py | 4 +- .../stock_reconciliation.py | 37 +++++++------------ 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 699b7a9562d..20dd94da906 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -166,7 +166,9 @@ class Batch(Document): for row in batches: batch_qty += row.get("qty") - self.db_set("batch_qty", batch_qty) + if self.batch_qty != batch_qty: + self.db_set("batch_qty", batch_qty) + frappe.msgprint(_("Batch Qty updated to {0}").format(batch_qty), alert=True) def set_batchwise_valuation(self): diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index cc4b08b50f4..2bf4333b14e 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -1227,32 +1227,23 @@ class StockReconciliation(StockController): def get_batch_qty_for_stock_reco( item_code, warehouse, batch_no, posting_date, posting_time, voucher_no, sle_creation ): - ledger = frappe.qb.DocType("Stock Ledger Entry") - posting_datetime = get_combine_datetime(posting_date, posting_time) - - query = ( - frappe.qb.from_(ledger) - .select( - Sum(ledger.actual_qty).as_("batch_qty"), + qty = ( + get_batch_qty( + batch_no, + warehouse, + item_code, + creation=sle_creation, + posting_date=posting_date, + posting_time=posting_time, + ignore_voucher_nos=[voucher_no], + for_stock_levels=True, + consider_negative_batches=True, + do_not_check_future_batches=True, ) - .where( - (ledger.item_code == item_code) - & (ledger.warehouse == warehouse) - & (ledger.docstatus == 1) - & (ledger.is_cancelled == 0) - & (ledger.batch_no == batch_no) - & (ledger.voucher_no != voucher_no) - & ( - (ledger.posting_datetime < posting_datetime) - | ((ledger.posting_datetime == posting_datetime) & (ledger.creation < sle_creation)) - ) - ) - .groupby(ledger.batch_no) + or 0 ) - sle = query.run(as_dict=True) - - return flt(sle[0].batch_qty) if sle else 0 + return flt(qty) @frappe.whitelist() From 5b1795b0a5b180c482c7c70c331109da30f7e7aa Mon Sep 17 00:00:00 2001 From: sudarshan-g Date: Wed, 17 Dec 2025 12:09:22 +0530 Subject: [PATCH 09/44] fix: show company currency in asset depreciation schedule (cherry picked from commit e32f898dd706e8f04bf15636355752d63bdc01b4) --- erpnext/assets/doctype/asset/asset.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index d2a86acc837..c7a564b4d5b 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -274,8 +274,14 @@ frappe.ui.form.on("Asset", { const row = [ sch["idx"], frappe.format(sch["schedule_date"], { fieldtype: "Date" }), - frappe.format(sch["depreciation_amount"], { fieldtype: "Currency" }), - frappe.format(sch["accumulated_depreciation_amount"], { fieldtype: "Currency" }), + frappe.format(sch["depreciation_amount"], { + fieldtype: "Currency", + options: "Company:company:default_currency", + }), + frappe.format(sch["accumulated_depreciation_amount"], { + fieldtype: "Currency", + options: "Company:company:default_currency", + }), sch["journal_entry"] || "", ]; From d7c50cfa7c68209d87643cfcbec59cb86531c4a9 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Wed, 17 Dec 2025 15:12:45 +0530 Subject: [PATCH 10/44] fix(payment entry): set row id for 'On Previous Row Amount' or 'On Previous Row Total' charge type on tax table (cherry picked from commit 848f8d6b1ff7624e917b7914ffaa53630c2892e2) --- erpnext/accounts/doctype/payment_entry/payment_entry.js | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 31f0235d147..4146b4aebb2 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -1520,18 +1520,14 @@ frappe.ui.form.on("Payment Entry", { "Can refer row only if the charge type is 'On Previous Row Amount' or 'Previous Row Total'" ); d.row_id = ""; - } else if ( - (d.charge_type == "On Previous Row Amount" || d.charge_type == "On Previous Row Total") && - d.row_id - ) { + } else if (d.charge_type == "On Previous Row Amount" || d.charge_type == "On Previous Row Total") { if (d.idx == 1) { msg = __( "Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row" ); d.charge_type = ""; } else if (!d.row_id) { - msg = __("Please specify a valid Row ID for row {0} in table {1}", [d.idx, __(d.doctype)]); - d.row_id = ""; + d.row_id = d.idx - 1; } else if (d.row_id && d.row_id >= d.idx) { msg = __( "Cannot refer row number greater than or equal to current row number for this Charge type" From 59aef4fc8c95e4a085f5d55c058d9fdaa77c4959 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Wed, 17 Dec 2025 16:16:54 +0530 Subject: [PATCH 11/44] fix(stock): handle serial and batch nos for disassemble stock entry --- erpnext/stock/serial_batch_bundle.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 2ee85fc2c24..dab6614b514 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -1070,13 +1070,23 @@ class SerialBatchCreation: for d in remove_list: package.remove(d) - def make_serial_and_batch_bundle(self): + def make_serial_and_batch_bundle( + self, serial_nos=None, batch_nos=None + ): # passing None instead of [] due to ruff linter error B006 + serial_nos = serial_nos or [] + batch_nos = batch_nos or [] + doc = frappe.new_doc("Serial and Batch Bundle") valid_columns = doc.meta.get_valid_columns() for key, value in self.__dict__.items(): if key in valid_columns: doc.set(key, value) + if serial_nos: + self.serial_nos = serial_nos + if batch_nos: + self.batches = batch_nos + if self.type_of_transaction == "Outward": self.set_auto_serial_batch_entries_for_outward() elif self.type_of_transaction == "Inward": From 696a0892fa8527077e093c212b6ed96296b7764c Mon Sep 17 00:00:00 2001 From: Navin-S-R Date: Thu, 18 Dec 2025 13:27:06 +0530 Subject: [PATCH 12/44] refactor: improve asset depreciation handling during asset sales --- .../doctype/sales_invoice/sales_invoice.py | 220 +++++++++++------- 1 file changed, 138 insertions(+), 82 deletions(-) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 56472e04430..9e15f8701bb 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -362,21 +362,34 @@ class SalesInvoice(SellingController): validate_docs_for_deferred_accounting([self.name], []) def validate_fixed_asset(self): - for d in self.get("items"): - if d.is_fixed_asset and d.meta.get_field("asset") and d.asset: - asset = frappe.get_doc("Asset", d.asset) - if self.doctype == "Sales Invoice" and self.docstatus == 1: - if self.update_stock: - frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale")) + if self.doctype != "Sales Invoice": + return - elif asset.status in ("Scrapped", "Cancelled", "Capitalized") or ( - asset.status == "Sold" and not self.is_return - ): - frappe.throw( - _("Row #{0}: Asset {1} cannot be submitted, it is already {2}").format( - d.idx, d.asset, asset.status + for d in self.get("items"): + if d.is_fixed_asset: + if d.asset: + if not self.is_return: + asset_status = frappe.db.get_value("Asset", d.asset, "status") + if self.update_stock: + frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale")) + + elif asset_status in ("Scrapped", "Cancelled", "Capitalized"): + frappe.throw( + _("Row #{0}: Asset {1} cannot be sold, it is already {2}").format( + d.idx, d.asset, asset_status + ) ) + elif asset_status == "Sold" and not self.is_return: + frappe.throw(_("Row #{0}: Asset {1} is already sold").format(d.idx, d.asset)) + elif not self.return_against: + frappe.throw( + _("Row #{0}: Return Against is required for returning asset").format(d.idx) ) + else: + frappe.throw( + _("Row #{0}: You must select an Asset for Item {1}.").format(d.idx, d.item_code), + title=_("Missing Asset"), + ) def validate_item_cost_centers(self): for item in self.items: @@ -465,6 +478,8 @@ class SalesInvoice(SellingController): self.update_stock_reservation_entries() self.update_stock_ledger() + self.process_asset_depreciation() + # this sequence because outstanding may get -ve self.make_gl_entries() @@ -561,6 +576,8 @@ class SalesInvoice(SellingController): if self.update_stock == 1: self.update_stock_ledger() + self.process_asset_depreciation() + self.make_gl_entries_on_cancel() if self.update_stock == 1: @@ -1182,6 +1199,91 @@ class SalesInvoice(SellingController): ): throw(_("Delivery Note {0} is not submitted").format(d.delivery_note)) + def process_asset_depreciation(self): + if (self.is_return and self.docstatus == 2) or (not self.is_return and self.docstatus == 1): + self.depreciate_asset_on_sale() + else: + self.restore_asset() + + self.update_asset() + + def depreciate_asset_on_sale(self): + """ + Depreciate asset on sale or cancellation of return sales invoice + """ + disposal_date = self.get_disposal_date() + for d in self.get("items"): + if d.asset: + asset = frappe.get_doc("Asset", d.asset) + if asset.calculate_depreciation and asset.status != "Fully Depreciated": + depreciate_asset(asset, disposal_date, self.get_note_for_asset_sale(asset)) + + def get_note_for_asset_sale(self, asset): + return _("This schedule was created when Asset {0} was {1} through Sales Invoice {2}.").format( + get_link_to_form(asset.doctype, asset.name), + _("returned") if self.is_return else _("sold"), + get_link_to_form(self.doctype, self.get("name")), + ) + + def restore_asset(self): + """ + Restore asset on return or cancellation of original sales invoice + """ + + for d in self.get("items"): + if d.asset: + asset = frappe.get_cached_doc("Asset", d.asset) + if asset.calculate_depreciation: + posting_date = self.get_disposal_date() + reverse_depreciation_entry_made_after_disposal(asset, posting_date) + + note = self.get_note_for_asset_return(asset) + reset_depreciation_schedule(asset, self.posting_date, note) + + def get_note_for_asset_return(self, asset): + asset_link = get_link_to_form(asset.doctype, asset.name) + invoice_link = get_link_to_form(self.doctype, self.get("name")) + if self.is_return: + return _( + "This schedule was created when Asset {0} was returned through Sales Invoice {1}." + ).format(asset_link, invoice_link) + else: + return _( + "This schedule was created when Asset {0} was restored due to Sales Invoice {1} cancellation." + ).format(asset_link, invoice_link) + + def update_asset(self): + """ + Update asset status, disposal date and asset activity on sale or return sales invoice + """ + + def _update_asset(asset, disposal_date, note, asset_status=None): + frappe.db.set_value("Asset", d.asset, "disposal_date", disposal_date) + add_asset_activity(asset.name, note) + asset.set_status(asset_status) + + disposal_date = self.get_disposal_date() + for d in self.get("items"): + if d.asset: + asset = frappe.get_cached_doc("Asset", d.asset) + + if (self.is_return and self.docstatus == 1) or (not self.is_return and self.docstatus == 2): + note = _("Asset returned") if self.is_return else _("Asset sold") + asset_status, disposal_date = None, None + else: + note = _("Asset sold") if not self.is_return else _("Return invoice of asset cancelled") + asset_status = "Sold" + + _update_asset(asset, disposal_date, note, asset_status) + + def get_disposal_date(self): + if self.is_return: + disposal_date = frappe.db.get_value("Sales Invoice", self.return_against, "posting_date") + else: + disposal_date = self.posting_date + + return disposal_date + def make_gl_entries(self, gl_entries=None, from_repost=False): from erpnext.accounts.general_ledger import make_gl_entries, make_reverse_gl_entries @@ -1358,68 +1460,8 @@ class SalesInvoice(SellingController): if self.is_internal_transfer(): continue - if item.is_fixed_asset: - asset = self.get_asset(item) - - if (self.docstatus == 2 and not self.is_return) or ( - self.docstatus == 1 and self.is_return - ): - fixed_asset_gl_entries = get_gl_entries_on_asset_regain( - asset, - item.base_net_amount, - item.finance_book, - self.get("doctype"), - self.get("name"), - self.get("posting_date"), - ) - asset.db_set("disposal_date", None) - add_asset_activity(asset.name, _("Asset returned")) - asset_status = asset.get_status() - - if asset.calculate_depreciation and not asset_status == "Fully Depreciated": - posting_date = ( - frappe.db.get_value("Sales Invoice", self.return_against, "posting_date") - if self.is_return - else self.posting_date - ) - reverse_depreciation_entry_made_after_disposal(asset, posting_date) - notes = _( - "This schedule was created when Asset {0} was returned through Sales Invoice {1}." - ).format( - get_link_to_form(asset.doctype, asset.name), - get_link_to_form(self.doctype, self.get("name")), - ) - reset_depreciation_schedule(asset, self.posting_date, notes) - asset.reload() - - else: - if asset.calculate_depreciation: - if not asset.status == "Fully Depreciated": - notes = _( - "This schedule was created when Asset {0} was sold through Sales Invoice {1}." - ).format( - get_link_to_form(asset.doctype, asset.name), - get_link_to_form(self.doctype, self.get("name")), - ) - depreciate_asset(asset, self.posting_date, notes) - asset.reload() - - fixed_asset_gl_entries = get_gl_entries_on_asset_disposal( - asset, - item.base_net_amount, - item.finance_book, - self.get("doctype"), - self.get("name"), - self.get("posting_date"), - ) - asset.db_set("disposal_date", self.posting_date) - add_asset_activity(asset.name, _("Asset sold")) - - for gle in fixed_asset_gl_entries: - gle["against"] = self.customer - gl_entries.append(self.get_gl_dict(gle, item=item)) - - self.set_asset_status(asset) + if item.is_fixed_asset and item.asset: + self.get_gl_entries_for_fixed_asset(item, gl_entries) else: income_account = ( @@ -1455,17 +1497,31 @@ class SalesInvoice(SellingController): if cint(self.update_stock) and erpnext.is_perpetual_inventory_enabled(self.company): gl_entries += super().get_gl_entries() - def get_asset(self, item): - if item.get("asset"): - asset = frappe.get_doc("Asset", item.asset) + def get_gl_entries_for_fixed_asset(self, item, gl_entries): + asset = frappe.get_cached_doc("Asset", item.asset) + + if self.is_return: + fixed_asset_gl_entries = get_gl_entries_on_asset_regain( + asset, + item.base_net_amount, + item.finance_book, + self.get("doctype"), + self.get("name"), + self.get("posting_date"), + ) else: - frappe.throw( - _("Row #{0}: You must select an Asset for Item {1}.").format(item.idx, item.item_name), - title=_("Missing Asset"), + fixed_asset_gl_entries = get_gl_entries_on_asset_disposal( + asset, + item.base_net_amount, + item.finance_book, + self.get("doctype"), + self.get("name"), + self.get("posting_date"), ) - self.check_finance_books(item, asset) - return asset + for gle in fixed_asset_gl_entries: + gle["against"] = self.customer + gl_entries.append(self.get_gl_dict(gle, item=item)) @property def enable_discount_accounting(self): From 7f91f95f958ae6edf329ff5d752637c8df27962f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Thu, 18 Dec 2025 15:37:23 +0530 Subject: [PATCH 13/44] chore: resolve conflicts --- .../request_for_quotation/request_for_quotation.js | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js index 5782a3960ad..c1c1999a017 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js @@ -549,23 +549,13 @@ erpnext.buying.RequestforQuotationController = class RequestforQuotationControll callback: load_suppliers, }); } else if (args.supplier_group) { -<<<<<<< HEAD return frappe.call({ method: "frappe.client.get_list", args: { doctype: "Supplier", -======= - frappe.db - .get_list("Supplier", { - filters: { - supplier_group: args.supplier_group, - disabled: 0, - }, - limit: 100, ->>>>>>> 6cc2290f6e (fix(buying): add disabled filter for supplier) order_by: "name", fields: ["name"], - filters: [["Supplier", "supplier_group", "=", args.supplier_group]], + filters: [["Supplier", "supplier_group", "=", args.supplier_group], ["disabled", "=", 0]], }, callback: load_suppliers, }); From 8ef09c0dc00db0f21472d7be419a866418600e16 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 18 Dec 2025 17:05:39 +0530 Subject: [PATCH 14/44] fix(pegged currencies): skip adding currencies_to_add items on pegged_currency_item if source_currency or pegged_against currency doc does not exist (backport #51188) (#51203) Co-authored-by: Diptanil Saha fix(pegged currencies): skip adding currencies_to_add items on pegged_currency_item if source_currency or pegged_against currency doc does not exist (#51188) --- erpnext/setup/install.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index ccae5136a9e..fffe9b9bdf0 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -296,8 +296,20 @@ def update_pegged_currencies(): {"source_currency": "SAR", "pegged_against": "USD", "pegged_exchange_rate": 3.75}, ] + # Add items on pegged_currency_item if source_currency and pegged_against currency doc exist. + + currencies_exist = frappe.db.get_list( + "Currency", {"name": ["in", ["AED", "BHD", "JOD", "OMR", "QAR", "SAR", "USD"]]}, pluck="name" + ) + + if "USD" not in currencies_exist: + return + for currency in currencies_to_add: - if currency["source_currency"] not in existing_sources: + if ( + currency["source_currency"] in currencies_exist + and currency["source_currency"] not in existing_sources + ): doc.append("pegged_currency_item", currency) doc.save() From f13db03c9b801508ab0344fc722b05186805abae Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Fri, 19 Dec 2025 10:33:44 +0530 Subject: [PATCH 15/44] test: make corrections to tests based on v15 functionality --- .../production_plan/test_production_plan.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 5505be3cafa..85e175af2da 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -165,6 +165,7 @@ class TestProductionPlan(FrappeTestCase): # Sales orders so1 = make_sales_order(item_code=fg_item_a, qty=1) so2 = make_sales_order(item_code=fg_item_a, qty=1) + so3 = make_sales_order(item_code=fg_item_a, qty=1) # Production plan pln = frappe.get_doc( @@ -194,6 +195,15 @@ class TestProductionPlan(FrappeTestCase): "grand_total": so2.grand_total, }, ) + pln.append( + "sales_orders", + { + "sales_order": so3.name, + "sales_order_date": so3.transaction_date, + "customer": so3.customer, + "grand_total": so3.grand_total, + }, + ) pln.get_items() pln.insert() @@ -202,12 +212,13 @@ class TestProductionPlan(FrappeTestCase): quantities = [d["quantity"] for d in mr_items] rm_qty = sum(quantities) - self.assertEqual(len(mr_items), 2) # one for each SO - self.assertEqual(rm_qty, 1, "Cascading failed: total MR qty should be 1 (2 needed - 1 in stock)") + # Only 2 MR item created - the first SO's requirement is fully covered by stock (v15 behaviour) + self.assertEqual(len(mr_items), 2) + self.assertEqual(rm_qty, 2, "Cascading failed: total MR qty should be 2 (3 needed - 1 in stock)") self.assertEqual( quantities, - [0, 1], - "Cascading failed: first item should consume stock (qty=0), second should need procurement (qty=1)", + [1, 1], + "Cascading failed: only second and third SO should need procurement (qty=1) since first SO consumed stock", ) sr.cancel() From ac2402dd2a6ce89a54e73070c1959eaad464e510 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Fri, 19 Dec 2025 12:11:04 +0530 Subject: [PATCH 16/44] fix(stock): ignore reserved stock while calculating batch qty (cherry picked from commit b23c6e2687af087702409d3cd669519da0565044) # Conflicts: # erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py --- erpnext/stock/doctype/batch/batch.py | 9 ++++++++- .../serial_and_batch_bundle/serial_and_batch_bundle.py | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 20dd94da906..ba9aaa6c48a 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -159,8 +159,13 @@ class Batch(Document): @frappe.whitelist() def recalculate_batch_qty(self): batches = get_batch_qty( - batch_no=self.name, item_code=self.item, for_stock_levels=True, consider_negative_batches=True + batch_no=self.name, + item_code=self.item, + for_stock_levels=True, + consider_negative_batches=True, + ignore_reserved_stock=True, ) + batch_qty = 0.0 if batches: for row in batches: @@ -240,6 +245,7 @@ def get_batch_qty( for_stock_levels=False, consider_negative_batches=False, do_not_check_future_batches=False, + ignore_reserved_stock=False, ): """Returns batch actual qty if warehouse is passed, or returns dict of qty by warehouse if warehouse is None @@ -269,6 +275,7 @@ def get_batch_qty( "for_stock_levels": for_stock_levels, "consider_negative_batches": consider_negative_batches, "do_not_check_future_batches": do_not_check_future_batches, + "ignore_reserved_stock": ignore_reserved_stock, } ) 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 e20363cec3a..238d6600830 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 @@ -2332,6 +2332,12 @@ def get_auto_batch_nos(kwargs): sre_reserved_batches = frappe._dict() if not kwargs.ignore_reserved_stock: sre_reserved_batches = get_reserved_batches_for_sre(kwargs) +<<<<<<< HEAD +======= + + if kwargs.against_sales_order and only_consider_batches: + kwargs.batch_no = kwargs.warehouse = None +>>>>>>> b23c6e2687 (fix(stock): ignore reserved stock while calculating batch qty) picked_batches = frappe._dict() if kwargs.get("is_pick_list"): From b20405dbf23cc52765bcd2c5229142838b85dfb9 Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Fri, 19 Dec 2025 17:25:20 +0530 Subject: [PATCH 17/44] test(stock): add test for ignore reserve stock (cherry picked from commit 4d8ec5f54c0c063cc33990490947dd76fe029f94) --- erpnext/stock/doctype/batch/test_batch.py | 32 +++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/erpnext/stock/doctype/batch/test_batch.py b/erpnext/stock/doctype/batch/test_batch.py index 1d44d19ac81..83efc12a7d8 100644 --- a/erpnext/stock/doctype/batch/test_batch.py +++ b/erpnext/stock/doctype/batch/test_batch.py @@ -324,6 +324,38 @@ class TestBatch(FrappeTestCase): self.assertEqual(get_batch_qty("batch a", "_Test Warehouse - _TC"), 90) + def test_ignore_reserved_qty(self): + from erpnext.selling.doctype.sales_order.sales_order import create_pick_list + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + + batch_item_name = "Reserve Batch Item" + batch_id = "Reserve Batch 1" + # Create Batch Item + self.make_batch_item(batch_item_name) + # Create Batch and Material Receipt Entry with qty 90 + self.make_new_batch_and_entry(batch_item_name, batch_id, "_Test Warehouse - _TC") + + # Enable Stock Reservation + frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1) + + # Create Sales Order with qty 50 + sales_order = make_sales_order( + item_code=batch_item_name, warehouse="_Test Warehouse - _TC", qty=50, rate=20 + ) + + # Create Pick List for the Sales Order + pl = create_pick_list(sales_order.name) + pl.submit() + # Create Stock Reservation Entries + pl.create_stock_reservation_entries(notify=False) + + batch = frappe.get_doc("Batch", batch_id) + # Recalculate Batch Qty + batch.recalculate_batch_qty() + batch.reload() + # Case: Ignore Reserved Qty + self.assertEqual(batch.batch_qty, 90) + def test_total_batch_qty(self): self.make_batch_item("ITEM-BATCH-3") existing_batch_qty = flt(frappe.db.get_value("Batch", "B100", "batch_qty")) From 9ade0725e8eb5f3ecdbc9cc7d6f5f6af6fd1748c Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Fri, 19 Dec 2025 18:07:42 +0530 Subject: [PATCH 18/44] chore: fix conflicts Removed logic for handling reserved stock when calculating batch quantity. --- .../serial_and_batch_bundle/serial_and_batch_bundle.py | 6 ------ 1 file changed, 6 deletions(-) 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 238d6600830..e20363cec3a 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 @@ -2332,12 +2332,6 @@ def get_auto_batch_nos(kwargs): sre_reserved_batches = frappe._dict() if not kwargs.ignore_reserved_stock: sre_reserved_batches = get_reserved_batches_for_sre(kwargs) -<<<<<<< HEAD -======= - - if kwargs.against_sales_order and only_consider_batches: - kwargs.batch_no = kwargs.warehouse = None ->>>>>>> b23c6e2687 (fix(stock): ignore reserved stock while calculating batch qty) picked_batches = frappe._dict() if kwargs.get("is_pick_list"): From 57c356a1cd4cca079c69bd7561d75789332315e9 Mon Sep 17 00:00:00 2001 From: Pugazhendhi Velu Date: Tue, 7 Oct 2025 15:16:19 +0000 Subject: [PATCH 19/44] feat(report): add batch qty update functionality in report (cherry picked from commit f40c492a050e763153efb39eee254016e41f0b52) --- .../report/stock_qty_vs_batch_qty/__init__.py | 0 .../stock_qty_vs_batch_qty.js | 71 +++++++++++++ .../stock_qty_vs_batch_qty.json | 31 ++++++ .../stock_qty_vs_batch_qty.py | 99 +++++++++++++++++++ 4 files changed, 201 insertions(+) create mode 100644 erpnext/stock/report/stock_qty_vs_batch_qty/__init__.py create mode 100644 erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js create mode 100644 erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.json create mode 100644 erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py diff --git a/erpnext/stock/report/stock_qty_vs_batch_qty/__init__.py b/erpnext/stock/report/stock_qty_vs_batch_qty/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js new file mode 100644 index 00000000000..785a18b0c8c --- /dev/null +++ b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js @@ -0,0 +1,71 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.query_reports["Stock Qty vs Batch Qty"] = { + filters: [ + { + fieldname: "item", + label: __("Item"), + fieldtype: "Link", + options: "Item", + get_query: function () { + return { + filters: { has_batch_no: true }, + }; + }, + }, + { + fieldname: "batch", + label: __("Batch"), + fieldtype: "Link", + options: "Batch", + get_query: function () { + const item_code = frappe.query_report.get_filter_value("item"); + return { + filters: { item: item_code }, + }; + }, + }, + ], + onload: function (report) { + report.page.add_inner_button(__("Update Batch Qty"), function () { + let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows(); + let selected_rows = indexes + .map((i) => frappe.query_report.data[i]) + .filter((row) => row.difference != 0); + + if (selected_rows.length) { + frappe.call({ + method: "erpnext.stock.report.stock_qty_vs_batch_qty.stock_qty_vs_batch_qty.update_batch_qty", + args: { + batches: selected_rows, + }, + callback: function (r) { + if (!r.exc) { + report.refresh(); + } + }, + }); + } else { + frappe.msgprint(__("Please select at least one row with difference value")); + } + }); + }, + + formatter: function (value, row, column, data, default_formatter) { + value = default_formatter(value, row, column, data); + if (column.fieldname == "difference" && data) { + if (data.difference > 0) { + value = "" + value + ""; + } else if (data.difference < 0) { + value = "" + value + ""; + } + } + return value; + }, + get_datatable_options(options) { + return Object.assign(options, { + checkboxColumn: true, + }); + }, +}; diff --git a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.json b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.json new file mode 100644 index 00000000000..b1885bb07f4 --- /dev/null +++ b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.json @@ -0,0 +1,31 @@ +{ + "add_total_row": 0, + "add_translate_data": 0, + "columns": [], + "creation": "2025-10-07 20:03:45.952352", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letterhead": null, + "modified": "2025-10-07 20:03:45.952352", + "modified_by": "Administrator", + "module": "Stock", + "name": "Stock Qty vs Batch Qty", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Item", + "report_name": "Stock Qty vs Batch Qty", + "report_type": "Script Report", + "roles": [ + { + "role": "Stock Manager" + }, + { + "role": "Stock User" + } + ], + "timeout": 0 +} diff --git a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py new file mode 100644 index 00000000000..56038111b93 --- /dev/null +++ b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py @@ -0,0 +1,99 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import json + +import frappe +from frappe import _ +from frappe.query_builder import DocType + +from erpnext.stock.doctype.batch.batch import get_batch_qty + + +def execute(filters=None): + if not filters: + filters = {} + + columns = get_columns() + data = get_data(filters) + + return columns, data + + +def get_columns() -> list[dict]: + columns = [ + { + "label": _("Item Code"), + "fieldname": "item_code", + "fieldtype": "Link", + "options": "Item", + "width": 200, + }, + {"label": _("Item Name"), "fieldname": "item_name", "fieldtype": "Data", "width": 200}, + {"label": _("Batch"), "fieldname": "batch", "fieldtype": "Link", "options": "Batch", "width": 200}, + {"label": _("Batch Qty"), "fieldname": "batch_qty", "fieldtype": "Float", "width": 150}, + {"label": _("Stock Qty"), "fieldname": "stock_qty", "fieldtype": "Float", "width": 150}, + {"label": _("Difference"), "fieldname": "difference", "fieldtype": "Float", "width": 150}, + ] + + return columns + + +def get_data(filters): + item_filter = filters.get("item") + batch_filter = filters.get("batch") + + Batch = DocType("Batch") + + query = ( + frappe.qb.from_(Batch) + .select(Batch.item.as_("item_code"), Batch.item_name, Batch.batch_qty, Batch.name.as_("batch_no")) + .where(Batch.disabled == 0) + ) + + if item_filter: + query = query.where(Batch.item == item_filter) + + if batch_filter: + query = query.where(Batch.name == batch_filter) + + batch_list = query.run(as_dict=True) + data = [] + for batch in batch_list: + batches = get_batch_qty(batch_no=batch.batch_no) + + if not batches: + continue + + batch_qty = batch.get("batch_qty", 0) + actual_qty = sum(b.get("qty", 0) for b in batches) + + difference = batch_qty - actual_qty + + row = { + "item_code": batch.item_code, + "item_name": batch.item_name, + "batch": batch.batch_no, + "batch_qty": batch_qty, + "stock_qty": actual_qty, + "difference": difference, + } + + data.append(row) + + return data + + +@frappe.whitelist() +def update_batch_qty(batches=None): + if not batches: + return + + batches = json.loads(batches) + for batch in batches: + batch_name = batch.get("batch") + stock_qty = batch.get("stock_qty") + + frappe.db.set_value("Batch", batch_name, "batch_qty", stock_qty) + + frappe.msgprint(_("Batch Qty updated successfully"), alert=True) From e7fcacbe69a1bf77301325d87b86010ebb8f348d Mon Sep 17 00:00:00 2001 From: Pugazhendhi Velu Date: Wed, 8 Oct 2025 14:16:26 +0000 Subject: [PATCH 20/44] refactor: fetch batch qty difference in a single db query (cherry picked from commit 9cc77934a6ce1f822928d9587242c6944f289c80) --- .../stock_qty_vs_batch_qty.js | 2 +- .../stock_qty_vs_batch_qty.py | 56 ++++++++----------- 2 files changed, 25 insertions(+), 33 deletions(-) diff --git a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js index 785a18b0c8c..bd906665398 100644 --- a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js +++ b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js @@ -22,7 +22,7 @@ frappe.query_reports["Stock Qty vs Batch Qty"] = { get_query: function () { const item_code = frappe.query_report.get_filter_value("item"); return { - filters: { item: item_code }, + filters: { item: item_code, disabled: 0 }, }; }, }, diff --git a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py index 56038111b93..bcd04fb4c45 100644 --- a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py +++ b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py @@ -5,9 +5,7 @@ import json import frappe from frappe import _ -from frappe.query_builder import DocType - -from erpnext.stock.doctype.batch.batch import get_batch_qty +from frappe.query_builder.functions import Sum def execute(filters=None): @@ -43,43 +41,37 @@ def get_data(filters): item_filter = filters.get("item") batch_filter = filters.get("batch") - Batch = DocType("Batch") + stock_ledger_entry = frappe.qb.DocType("Stock Ledger Entry") + batch_ledger = frappe.qb.DocType("Serial and Batch Entry") + batch_table = frappe.qb.DocType("Batch") query = ( - frappe.qb.from_(Batch) - .select(Batch.item.as_("item_code"), Batch.item_name, Batch.batch_qty, Batch.name.as_("batch_no")) - .where(Batch.disabled == 0) + frappe.qb.from_(stock_ledger_entry) + .inner_join(batch_ledger) + .on(stock_ledger_entry.serial_and_batch_bundle == batch_ledger.parent) + .inner_join(batch_table) + .on(batch_ledger.batch_no == batch_table.name) + .select( + batch_table.item.as_("item_code"), + batch_table.item_name.as_("item_name"), + batch_table.name.as_("batch"), + batch_table.batch_qty.as_("batch_qty"), + Sum(batch_ledger.qty).as_("stock_qty"), + (Sum(batch_ledger.qty) - batch_table.batch_qty).as_("difference"), + ) + .where(batch_table.disabled == 0) + .where(stock_ledger_entry.is_cancelled == 0) + .groupby(batch_table.name) + .having((Sum(batch_ledger.qty) - batch_table.batch_qty) != 0) ) if item_filter: - query = query.where(Batch.item == item_filter) + query = query.where(batch_table.item == item_filter) if batch_filter: - query = query.where(Batch.name == batch_filter) + query = query.where(batch_table.name == batch_filter) - batch_list = query.run(as_dict=True) - data = [] - for batch in batch_list: - batches = get_batch_qty(batch_no=batch.batch_no) - - if not batches: - continue - - batch_qty = batch.get("batch_qty", 0) - actual_qty = sum(b.get("qty", 0) for b in batches) - - difference = batch_qty - actual_qty - - row = { - "item_code": batch.item_code, - "item_name": batch.item_name, - "batch": batch.batch_no, - "batch_qty": batch_qty, - "stock_qty": actual_qty, - "difference": difference, - } - - data.append(row) + data = query.run(as_dict=True) return data From 10b0da8bc81fda31aa9fc2047478928c78fb2e2c Mon Sep 17 00:00:00 2001 From: Pugazhendhi Velu Date: Mon, 13 Oct 2025 15:53:19 +0000 Subject: [PATCH 21/44] fix: use get_batch_qty to fetch batch data (cherry picked from commit cf03d0303356dc92c0903b1893850e6f8a7f53e1) --- .../stock_qty_vs_batch_qty.py | 74 +++++++++++-------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py index bcd04fb4c45..4818f36610c 100644 --- a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py +++ b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py @@ -5,7 +5,8 @@ import json import frappe from frappe import _ -from frappe.query_builder.functions import Sum + +from erpnext.stock.doctype.batch.batch import get_batch_qty def execute(filters=None): @@ -37,43 +38,56 @@ def get_columns() -> list[dict]: return columns -def get_data(filters): - item_filter = filters.get("item") - batch_filter = filters.get("batch") +def get_data(filters=None): + filters = filters or {} - stock_ledger_entry = frappe.qb.DocType("Stock Ledger Entry") - batch_ledger = frappe.qb.DocType("Serial and Batch Entry") - batch_table = frappe.qb.DocType("Batch") + item = filters.get("item") + batch_no = filters.get("batch") + + batch_sle_data = get_batch_qty(item_code=item, batch_no=batch_no) or [] + + stock_qty_map = {} + for row in batch_sle_data: + batch = row.get("batch_no") + if not batch: + continue + stock_qty_map[batch] = stock_qty_map.get(batch, 0) + (row.get("qty") or 0) + + batch = frappe.qb.DocType("Batch") query = ( - frappe.qb.from_(stock_ledger_entry) - .inner_join(batch_ledger) - .on(stock_ledger_entry.serial_and_batch_bundle == batch_ledger.parent) - .inner_join(batch_table) - .on(batch_ledger.batch_no == batch_table.name) - .select( - batch_table.item.as_("item_code"), - batch_table.item_name.as_("item_name"), - batch_table.name.as_("batch"), - batch_table.batch_qty.as_("batch_qty"), - Sum(batch_ledger.qty).as_("stock_qty"), - (Sum(batch_ledger.qty) - batch_table.batch_qty).as_("difference"), - ) - .where(batch_table.disabled == 0) - .where(stock_ledger_entry.is_cancelled == 0) - .groupby(batch_table.name) - .having((Sum(batch_ledger.qty) - batch_table.batch_qty) != 0) + frappe.qb.from_(batch) + .select(batch.name, batch.item, batch.item_name, batch.batch_qty) + .where(batch.disabled == 0) ) - if item_filter: - query = query.where(batch_table.item == item_filter) + if item: + query = query.where(batch.item == item) + if batch_no: + query = query.where(batch.name == batch_no) - if batch_filter: - query = query.where(batch_table.name == batch_filter) + batch_records = query.run(as_dict=True) or [] - data = query.run(as_dict=True) + result = [] + for batch_doc in batch_records: + name = batch_doc.get("name") + batch_qty = batch_doc.get("batch_qty") or 0 + stock_qty = stock_qty_map.get(name, 0) + difference = stock_qty - batch_qty - return data + if difference != 0: + result.append( + { + "item_code": batch_doc.get("item"), + "item_name": batch_doc.get("item_name"), + "batch": name, + "batch_qty": batch_qty, + "stock_qty": stock_qty, + "difference": difference, + } + ) + + return result @frappe.whitelist() From ca835c831b7bfd8ecec40251aab487a9c2137fcb Mon Sep 17 00:00:00 2001 From: Pugazhendhi Velu Date: Fri, 19 Dec 2025 14:05:22 +0000 Subject: [PATCH 22/44] fix: update batch_qty using get_batch_qty (cherry picked from commit 15d9d8b7199f74bf1f51d18d7cab24a3e884a713) --- .../stock_qty_vs_batch_qty.js | 2 +- .../stock_qty_vs_batch_qty.json | 7 +--- .../stock_qty_vs_batch_qty.py | 41 +++++++++++++------ 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js index bd906665398..f80126bcb0a 100644 --- a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js +++ b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.js @@ -38,7 +38,7 @@ frappe.query_reports["Stock Qty vs Batch Qty"] = { frappe.call({ method: "erpnext.stock.report.stock_qty_vs_batch_qty.stock_qty_vs_batch_qty.update_batch_qty", args: { - batches: selected_rows, + selected_batches: selected_rows, }, callback: function (r) { if (!r.exc) { diff --git a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.json b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.json index b1885bb07f4..147815be88d 100644 --- a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.json +++ b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.json @@ -10,7 +10,7 @@ "idx": 0, "is_standard": "Yes", "letterhead": null, - "modified": "2025-10-07 20:03:45.952352", + "modified": "2025-11-18 11:35:04.615085", "modified_by": "Administrator", "module": "Stock", "name": "Stock Qty vs Batch Qty", @@ -21,10 +21,7 @@ "report_type": "Script Report", "roles": [ { - "role": "Stock Manager" - }, - { - "role": "Stock User" + "role": "Item Manager" } ], "timeout": 0 diff --git a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py index 4818f36610c..d88d610d23e 100644 --- a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py +++ b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py @@ -44,7 +44,12 @@ def get_data(filters=None): item = filters.get("item") batch_no = filters.get("batch") - batch_sle_data = get_batch_qty(item_code=item, batch_no=batch_no) or [] + batch_sle_data = ( + get_batch_qty( + item_code=item, batch_no=batch_no, for_stock_levels=True, consider_negative_batches=True + ) + or [] + ) stock_qty_map = {} for row in batch_sle_data: @@ -69,17 +74,17 @@ def get_data(filters=None): batch_records = query.run(as_dict=True) or [] result = [] - for batch_doc in batch_records: - name = batch_doc.get("name") - batch_qty = batch_doc.get("batch_qty") or 0 + for row in batch_records: + name = row.get("name") + batch_qty = row.get("batch_qty") or 0 stock_qty = stock_qty_map.get(name, 0) difference = stock_qty - batch_qty if difference != 0: result.append( { - "item_code": batch_doc.get("item"), - "item_name": batch_doc.get("item_name"), + "item_code": row.get("item"), + "item_name": row.get("item_name"), "batch": name, "batch_qty": batch_qty, "stock_qty": stock_qty, @@ -91,15 +96,25 @@ def get_data(filters=None): @frappe.whitelist() -def update_batch_qty(batches=None): - if not batches: +def update_batch_qty(selected_batches=None): + if not selected_batches: return - batches = json.loads(batches) - for batch in batches: - batch_name = batch.get("batch") - stock_qty = batch.get("stock_qty") + selected_batches = json.loads(selected_batches) + for row in selected_batches: + batch_name = row.get("batch") - frappe.db.set_value("Batch", batch_name, "batch_qty", stock_qty) + batches = get_batch_qty( + batch_no=batch_name, + item_code=row.get("item_code"), + for_stock_levels=True, + consider_negative_batches=True, + ) + batch_qty = 0.0 + if batches: + for batch in batches: + batch_qty += batch.get("qty") + + frappe.db.set_value("Batch", batch_name, "batch_qty", batch_qty) frappe.msgprint(_("Batch Qty updated successfully"), alert=True) From 26a36d807e3f5b3d12bf2cacf8ba15a95a0f840e Mon Sep 17 00:00:00 2001 From: Pugazhendhi Velu Date: Sun, 21 Dec 2025 12:22:47 +0000 Subject: [PATCH 23/44] fix(stock-report): ignore reserved stock in batch qty calculation (cherry picked from commit 9a1f551e53c09018ad7049e9eefa93ddfb3cb430) --- .../stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py index d88d610d23e..87c5e1419cc 100644 --- a/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py +++ b/erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py @@ -46,7 +46,11 @@ def get_data(filters=None): batch_sle_data = ( get_batch_qty( - item_code=item, batch_no=batch_no, for_stock_levels=True, consider_negative_batches=True + item_code=item, + batch_no=batch_no, + for_stock_levels=True, + consider_negative_batches=True, + ignore_reserved_stock=True, ) or [] ) @@ -109,6 +113,7 @@ def update_batch_qty(selected_batches=None): item_code=row.get("item_code"), for_stock_levels=True, consider_negative_batches=True, + ignore_reserved_stock=True, ) batch_qty = 0.0 if batches: From dc5faa8b7107e6edec59188f50c854b7013d151e Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Sun, 21 Dec 2025 21:37:13 +0530 Subject: [PATCH 24/44] fix: same serial number was picked in multiple sales invoices (cherry picked from commit 61c31f0cd053c993101d3287e91993ed93faa6cd) # Conflicts: # erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py --- .../serial_and_batch_bundle.py | 75 +++++++++++++++++-- 1 file changed, 68 insertions(+), 7 deletions(-) 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 e20363cec3a..9cf921b8646 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 @@ -1989,8 +1989,35 @@ def get_available_serial_nos(kwargs): if kwargs.warehouse: filters["warehouse"] = kwargs.warehouse +<<<<<<< HEAD # Since SLEs are not present against Reserved Stock [POS invoices, SRE], need to ignore reserved serial nos. ignore_serial_nos = get_reserved_serial_nos(kwargs) +======= + reserved_entries = get_reserved_serial_nos_for_sre(kwargs) + + ignore_serial_nos = [] + if reserved_entries: + if kwargs.get("sabb_voucher_type") == "Delivery Note" and kwargs.get("against_sales_order"): + reserved_voucher_details = [kwargs.get("against_sales_order")] + else: + reserved_voucher_details = get_reserved_voucher_details(kwargs) + + # Check if serial nos are reserved for the current voucher then fetch only those serial nos + if reserved_serial_nos := get_reserved_serial_nos_for_voucher( + kwargs, reserved_entries, reserved_voucher_details + ): + filters["name"] = ("in", reserved_serial_nos) + return get_serial_nos_based_on_filters(filters, fields, order_by, kwargs) + + # Check if serial nos are reserved for other vouchers then ignore those serial nos + elif ignore_reserved_serial_nos := get_other_doc_reserved_serials( + kwargs, reserved_entries, reserved_voucher_details + ): + ignore_serial_nos.extend(ignore_reserved_serial_nos) + + if reserved_for_pos := get_reserved_serial_nos_for_pos(kwargs): + ignore_serial_nos.extend(reserved_for_pos) +>>>>>>> 61c31f0cd0 (fix: same serial number was picked in multiple sales invoices) # To ignore serial nos in the same record for the draft state if kwargs.get("ignore_serial_nos"): @@ -2018,13 +2045,47 @@ def get_available_serial_nos(kwargs): filters["batch_no"] = ("in", batches) - return frappe.get_all( - "Serial No", - fields=fields, - filters=filters, - limit=cint(kwargs.qty) or 10000000, - order_by=order_by, - ) + return get_serial_nos_based_on_filters(filters, fields, order_by, kwargs) + + +def get_serial_nos_based_on_filters(filters, fields, order_by, kwargs): + doctype = frappe.qb.DocType("Serial No") + + order_by_column = getattr(doctype, order_by) + query = frappe.qb.from_(doctype).orderby(order_by_column).limit(cint(kwargs.qty) or 10000000).for_update() + + for key, value in filters.items(): + column = getattr(doctype, key) + + if isinstance(value, tuple): + operator = value[0] + + if operator == "between": + query = query.where(column.between(value[1], value[2])) + + elif operator == "in": + query = query.where(column.isin(value[1])) + + elif operator == "not in": + query = query.where(column.notin(value[1])) + + elif operator == "is": + if value[1] == "set": + query = query.where(column.isnotnull()) + elif value[1] == "not set": + query = query.where(column.isnull()) + else: + query = query.where(column == value) + + for field in fields: + if " as " in field.lower(): + # Split field and alias + field_name, alias = field.split(" as ", 1) + query = query.select(getattr(doctype, field_name).as_(alias)) + else: + query = query.select(getattr(doctype, field)) + + return query.run(as_dict=True) def get_non_expired_batches(batches): From c77c4266525c714dff47a26650c535f50ec1bef0 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 22 Dec 2025 11:37:19 +0530 Subject: [PATCH 25/44] chore: fix conflicts Removed logic for handling reserved serial numbers in sales invoices. --- .../serial_and_batch_bundle.py | 27 ------------------- 1 file changed, 27 deletions(-) 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 9cf921b8646..bb68468619b 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 @@ -1989,35 +1989,8 @@ def get_available_serial_nos(kwargs): if kwargs.warehouse: filters["warehouse"] = kwargs.warehouse -<<<<<<< HEAD # Since SLEs are not present against Reserved Stock [POS invoices, SRE], need to ignore reserved serial nos. ignore_serial_nos = get_reserved_serial_nos(kwargs) -======= - reserved_entries = get_reserved_serial_nos_for_sre(kwargs) - - ignore_serial_nos = [] - if reserved_entries: - if kwargs.get("sabb_voucher_type") == "Delivery Note" and kwargs.get("against_sales_order"): - reserved_voucher_details = [kwargs.get("against_sales_order")] - else: - reserved_voucher_details = get_reserved_voucher_details(kwargs) - - # Check if serial nos are reserved for the current voucher then fetch only those serial nos - if reserved_serial_nos := get_reserved_serial_nos_for_voucher( - kwargs, reserved_entries, reserved_voucher_details - ): - filters["name"] = ("in", reserved_serial_nos) - return get_serial_nos_based_on_filters(filters, fields, order_by, kwargs) - - # Check if serial nos are reserved for other vouchers then ignore those serial nos - elif ignore_reserved_serial_nos := get_other_doc_reserved_serials( - kwargs, reserved_entries, reserved_voucher_details - ): - ignore_serial_nos.extend(ignore_reserved_serial_nos) - - if reserved_for_pos := get_reserved_serial_nos_for_pos(kwargs): - ignore_serial_nos.extend(reserved_for_pos) ->>>>>>> 61c31f0cd0 (fix: same serial number was picked in multiple sales invoices) # To ignore serial nos in the same record for the draft state if kwargs.get("ignore_serial_nos"): From 68eeba41c1eb3a503f5fe0acd6f1d48d320c216f Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Fri, 19 Dec 2025 14:07:09 +0530 Subject: [PATCH 26/44] fix: de-duplicate rows on disassembly with multiple manufacture entries (cherry picked from commit a091e47bd7589bda083c0d411f5e518ca12ac0fd) --- .../doctype/work_order/test_work_order.py | 111 ++++++++++++++++++ .../stock/doctype/stock_entry/stock_entry.py | 1 + 2 files changed, 112 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 0ece122fe0e..4dbe95fb238 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2467,6 +2467,117 @@ class TestWorkOrder(FrappeTestCase): f"Work Order disassembled_qty mismatch: expected {disassemble_qty}, got {wo.disassembled_qty}", ) + def test_disassembly_with_multiple_manufacture_entries(self): + """ + Test that disassembly does not create duplicate items when manufacturing + is done in multiple batches (multiple manufacture stock entries). + + Scenario: + 1. Create Work Order for 10 units + 2. Transfer raw materials + 3. Manufacture in 2 parts (3 units, then 7 units) - creates 2 stock entries + 4. Create Disassembly for 4 units + 5. Verify no duplicate items in the disassembly stock entry + """ + # Create RM and FG item + raw_item1 = make_item("Test Raw for Multi Batch Disassembly 1", {"is_stock_item": 1}).name + raw_item2 = make_item("Test Raw for Multi Batch Disassembly 2", {"is_stock_item": 1}).name + fg_item = make_item("Test FG for Multi Batch Disassembly", {"is_stock_item": 1}).name + bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2) + + # Create WO + wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started") + + # Ensure enough stock + from erpnext.stock.doctype.stock_entry.test_stock_entry import ( + make_stock_entry as make_stock_entry_test_record, + ) + + make_stock_entry_test_record( + item_code=raw_item1, + purpose="Material Receipt", + target=wo.wip_warehouse, + qty=50, + basic_rate=100, + ) + make_stock_entry_test_record( + item_code=raw_item2, + purpose="Material Receipt", + target=wo.wip_warehouse, + qty=50, + basic_rate=100, + ) + + # Transfer for manufacture + se_for_material_transfer = frappe.get_doc( + make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty) + ) + for item in se_for_material_transfer.items: + item.s_warehouse = wo.wip_warehouse + se_for_material_transfer.save() + se_for_material_transfer.submit() + + # First Manufacture Entry - 3 units + se_manufacture1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3)) + se_manufacture1.submit() + + # Second Manufacture Entry - 7 units + se_manufacture2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 7)) + se_manufacture2.submit() + + wo.reload() + self.assertEqual(wo.produced_qty, 10) + + # Count manufacture entries + manufacture_entries = frappe.get_all( + "Stock Entry", + filters={ + "work_order": wo.name, + "purpose": "Manufacture", + "docstatus": 1, + }, + ) + self.assertEqual(len(manufacture_entries), 2, "Expected 2 manufacture entries") + + # Disassembly for 4 units + disassemble_qty = 4 + stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty)) + + item_counts = {} + for item in stock_entry.items: + item_code = item.item_code + item_counts[item_code] = item_counts.get(item_code, 0) + 1 + + # No duplicates + duplicates = {k: v for k, v in item_counts.items() if v > 1} + self.assertEqual( + len(duplicates), + 0, + f"Found duplicate items in disassembly stock entry: {duplicates}", + ) + + expected_items = 3 # FG item + 2 raw materials + self.assertEqual( + len(stock_entry.items), + expected_items, + f"Expected {expected_items} items, found {len(stock_entry.items)}", + ) + + # FG item qty + fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None) + self.assertEqual(fg_item_row.qty, disassemble_qty) + + # RM quantities + for bom_item in bom.items: + expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty + rm_row = next((i for i in stock_entry.items if i.item_code == bom_item.item_code), None) + self.assertAlmostEqual( + rm_row.qty, + expected_qty, + places=3, + msg=f"Raw material {bom_item.item_code} qty mismatch", + ) + def test_components_alternate_item_for_bom_based_manufacture_entry(self): frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM") frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index a441664ab9c..85c0e313047 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1970,6 +1970,7 @@ class StockEntry(StockController): ["Stock Entry Detail", "docstatus", "=", 1], ], order_by="`tabStock Entry Detail`.`idx` desc, `tabStock Entry Detail`.`is_finished_item` desc", + group_by="`tabStock Entry Detail`.`item_code`", ) @frappe.whitelist() From dd19b95113f1a16a33ac1e2051c0ae7fc5b8674b Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Fri, 19 Dec 2025 18:14:06 +0530 Subject: [PATCH 27/44] fix: don't fetch qty as it's unused (cherry picked from commit df13308663db4e6e03953f8737135eb04a186624) --- erpnext/stock/doctype/stock_entry/stock_entry.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 85c0e313047..e86f6e6d122 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1950,8 +1950,6 @@ class StockEntry(StockController): "`tabStock Entry Detail`.`item_code`", "`tabStock Entry Detail`.`item_name`", "`tabStock Entry Detail`.`description`", - "`tabStock Entry Detail`.`qty`", - "`tabStock Entry Detail`.`transfer_qty`", "`tabStock Entry Detail`.`stock_uom`", "`tabStock Entry Detail`.`uom`", "`tabStock Entry Detail`.`basic_rate`", From 16112630ea56b69671abb4f746a42547b2ddc708 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Fri, 19 Dec 2025 18:19:40 +0530 Subject: [PATCH 28/44] test: ensure no regression after save and submit on disassemble (cherry picked from commit 18ac5897960a65b20fa41a1d59d8c853550e3327) --- erpnext/manufacturing/doctype/work_order/test_work_order.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 4dbe95fb238..c254171b990 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2542,6 +2542,8 @@ class TestWorkOrder(FrappeTestCase): # Disassembly for 4 units disassemble_qty = 4 stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty)) + stock_entry.save() + stock_entry.submit() item_counts = {} for item in stock_entry.items: From 72d77a5e99699602f607471a4c4c5301434006d1 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Fri, 19 Dec 2025 19:03:22 +0530 Subject: [PATCH 29/44] fix: support disassemble of RMs other than in BOM (cherry picked from commit ce123f1a89d582280043e55534a6d90de3f9e6c7) --- erpnext/stock/doctype/stock_entry/stock_entry.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index e86f6e6d122..5012b1ad64f 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1950,6 +1950,8 @@ class StockEntry(StockController): "`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`", From bb00bb83f8157d97d75c2ce3bedfcb743a3c8429 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Fri, 19 Dec 2025 19:32:34 +0530 Subject: [PATCH 30/44] test: ensure full qty reversal for items outside of BOM on disassemble (cherry picked from commit 5b3d2c0d02c84fe906c0a0088404cca8a4ca5bf2) --- .../doctype/work_order/test_work_order.py | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index c254171b990..4640f5192dd 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -2580,6 +2580,146 @@ class TestWorkOrder(FrappeTestCase): msg=f"Raw material {bom_item.item_code} qty mismatch", ) + 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). + + 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 + """ + from erpnext.stock.doctype.stock_entry.test_stock_entry import ( + make_stock_entry as make_stock_entry_test_record, + ) + + # Create RM and FG item + raw_item1 = make_item("Test BOM Raw 1 for Additional RM Disassembly", {"is_stock_item": 1}).name + raw_item2 = make_item("Test BOM Raw 2 for Additional RM Disassembly", {"is_stock_item": 1}).name + additional_rm = make_item("Test Additional RM for Disassembly", {"is_stock_item": 1}).name + fg_item = make_item("Test FG for Additional RM Disassembly", {"is_stock_item": 1}).name + + bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2) + + # Create WO + wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started") + + # Ensure enough stock + for item in [raw_item1, raw_item2, additional_rm]: + make_stock_entry_test_record( + item_code=item, + purpose="Material Receipt", + target=wo.wip_warehouse, + qty=100, + basic_rate=100, + ) + + # Transfer for manufacture + se_for_material_transfer = frappe.get_doc( + make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty) + ) + for item in se_for_material_transfer.items: + item.s_warehouse = wo.wip_warehouse + se_for_material_transfer.save() + se_for_material_transfer.submit() + + # First Manufacture Entry - 3 units + se_manufacture1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3)) + # Additional RM + se_manufacture1.append( + "items", + { + "item_code": additional_rm, + "qty": 3, # 1 per unit + "s_warehouse": wo.wip_warehouse, + "is_finished_item": 0, + }, + ) + se_manufacture1.save() + se_manufacture1.submit() + + # Second Manufacture Entry - 7 units + se_manufacture2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 7)) + # AAdditional RM + se_manufacture2.append( + "items", + { + "item_code": additional_rm, + "qty": 7, # 1 per unit + "s_warehouse": wo.wip_warehouse, + "is_finished_item": 0, + }, + ) + se_manufacture2.save() + se_manufacture2.submit() + + 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)) + stock_entry.save() + stock_entry.submit() + + # No duplicate + item_counts = {} + for item in stock_entry.items: + item_code = item.item_code + item_counts[item_code] = item_counts.get(item_code, 0) + 1 + + duplicates = {k: v for k, v in item_counts.items() if v > 1} + self.assertEqual( + len(duplicates), + 0, + f"Found duplicate items in disassembly stock entry: {duplicates}", + ) + + # Additional RM qty + 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 + self.assertAlmostEqual( + additional_rm_row.qty, + expected_additional_rm_qty, + places=3, + msg=f"Additional RM qty mismatch: expected {expected_additional_rm_qty}, got {additional_rm_row.qty}", + ) + + # RM qty + for bom_item in bom.items: + expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty + rm_row = next((i for i in stock_entry.items if i.item_code == bom_item.item_code), None) + self.assertIsNotNone(rm_row, f"BOM raw material {bom_item.item_code} not found") + self.assertAlmostEqual( + rm_row.qty, + expected_qty, + places=3, + msg=f"BOM raw material {bom_item.item_code} qty mismatch", + ) + + # FG qty + fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None) + self.assertEqual(fg_item_row.qty, disassemble_qty) + + expected_items = 4 + self.assertEqual( + len(stock_entry.items), + expected_items, + f"Expected {expected_items} items, found {len(stock_entry.items)}", + ) + 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 73643de61256651e12d545dfef3587ee978f57b2 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 22 Dec 2025 12:02:38 +0530 Subject: [PATCH 31/44] fix: added limit --- erpnext/stock/get_item_details.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 8e71367c663..b0ade4324fa 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -204,6 +204,7 @@ def update_stock(ctx, out, doc=None): "item_code": ctx.item_code, "warehouse": ctx.warehouse, "based_on": frappe.db.get_single_value("Stock Settings", "pick_serial_and_batch_based_on"), + "qty": out.stock_qty, } ) From c89fe9f1ca3c5ab734bbe64d890714671d715c6a Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 22 Dec 2025 16:19:11 +0530 Subject: [PATCH 32/44] Revert "fix: performance of the reposting" (cherry picked from commit 280558efa210c8674caec71f3b01b448573dba9f) --- erpnext/stock/stock_ledger.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 407040c245c..71a7883d644 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -718,13 +718,6 @@ class update_entries_after: {"item_code": self.item_code, "warehouse": self.args.warehouse} ) - key = (self.item_code, self.args.warehouse) - if key in self.distinct_item_warehouses and self.distinct_item_warehouses[key].get( - "transfer_entry_to_repost" - ): - # only repost stock entries - args["filter_voucher_type"] = "Stock Entry" - return list(self.get_sle_after_datetime(args)) def get_dependent_entries_to_fix(self, entries_to_fix, sle): @@ -758,10 +751,8 @@ class update_entries_after: 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) + elif 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 @@ -1848,9 +1839,6 @@ def get_stock_ledger_entries( if operator in (">", "<=") and previous_sle.get("name"): conditions += " and name!=%(name)s" - if previous_sle.get("filter_voucher_type"): - conditions += " and voucher_type = %(filter_voucher_type)s" - if extra_cond: conditions += f"{extra_cond}" From aefde87a0caa110e80ffd6f1ee67b9b7b5f9d59f Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 22 Dec 2025 16:22:44 +0530 Subject: [PATCH 33/44] chore: fix test case (cherry picked from commit d191b8058739d307ea72e98fed37841fc673398b) --- erpnext/stock/stock_ledger.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 71a7883d644..76ad47fb2f2 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -751,8 +751,10 @@ class update_entries_after: 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.voucher_type == "Stock Entry" and is_transfer_stock_entry( - dependant_sle.voucher_no + 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 From c095938e69169dc1e333ec8f5b891d9a5569c72e Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 22 Dec 2025 16:23:15 +0530 Subject: [PATCH 34/44] chore: fix linters issue (cherry picked from commit e9c37642c8f39aa7aece15b44c6f5765a7ea94ab) --- erpnext/stock/stock_ledger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 76ad47fb2f2..d00d092e795 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -753,7 +753,7 @@ class update_entries_after: self.new_items_found = True elif ( dependant_sle.actual_qty > 0 - and dependant_sle.voucher_type == "Stock Entry" + 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"): From 425dcee5bfe2b8a5d18b661c518e9ec8072e8322 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Mon, 22 Dec 2025 12:04:40 +0530 Subject: [PATCH 35/44] fix: limit condition to fetch serial nos (cherry picked from commit da4b78491d5da659c300bacb7bf964204bd8fb15) # Conflicts: # erpnext/stock/get_item_details.py --- .../serial_and_batch_bundle.py | 12 +++++++++--- erpnext/stock/get_item_details.py | 8 ++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) 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 bb68468619b..55facceae13 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 @@ -241,6 +241,7 @@ class SerialandBatchBundle(Document): "check_serial_nos": True, "serial_nos": serial_nos, } + if self.voucher_type == "POS Invoice": kwargs["ignore_voucher_nos"] = [self.voucher_no] @@ -1976,9 +1977,9 @@ def get_available_serial_nos(kwargs): order_by = "creation" if kwargs.based_on == "LIFO": - order_by = "creation desc" + order_by = "creation" elif kwargs.based_on == "Expiry": - order_by = "amc_expiry_date asc" + order_by = "amc_expiry_date" filters = {"item_code": kwargs.item_code} @@ -2025,7 +2026,12 @@ def get_serial_nos_based_on_filters(filters, fields, order_by, kwargs): doctype = frappe.qb.DocType("Serial No") order_by_column = getattr(doctype, order_by) - query = frappe.qb.from_(doctype).orderby(order_by_column).limit(cint(kwargs.qty) or 10000000).for_update() + query = frappe.qb.from_(doctype).limit(cint(kwargs.qty) or 10000000).for_update() + + if kwargs.based_on == "LIFO": + query = query.orderby(order_by_column, order=frappe.query_builder.Order.desc) + else: + query = query.orderby(order_by_column) for key, value in filters.items(): column = getattr(doctype, key) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index b0ade4324fa..ec42b3543e2 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -203,7 +203,15 @@ def update_stock(ctx, out, doc=None): { "item_code": ctx.item_code, "warehouse": ctx.warehouse, +<<<<<<< HEAD "based_on": frappe.db.get_single_value("Stock Settings", "pick_serial_and_batch_based_on"), +======= + "based_on": frappe.get_single_value("Stock Settings", "pick_serial_and_batch_based_on"), + "sabb_voucher_no": doc.get("name") if doc else None, + "sabb_voucher_detail_no": ctx.child_docname, + "sabb_voucher_type": ctx.doctype, + "pick_reserved_items": True, +>>>>>>> da4b78491d (fix: limit condition to fetch serial nos) "qty": out.stock_qty, } ) From 8f52f14505d2d30c709620842b521f64b7aebbe9 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 22 Dec 2025 18:04:47 +0530 Subject: [PATCH 36/44] chore: fix conflicts Refactor based_on retrieval method and remove unused fields. --- erpnext/stock/get_item_details.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index ec42b3543e2..b0ade4324fa 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -203,15 +203,7 @@ def update_stock(ctx, out, doc=None): { "item_code": ctx.item_code, "warehouse": ctx.warehouse, -<<<<<<< HEAD "based_on": frappe.db.get_single_value("Stock Settings", "pick_serial_and_batch_based_on"), -======= - "based_on": frappe.get_single_value("Stock Settings", "pick_serial_and_batch_based_on"), - "sabb_voucher_no": doc.get("name") if doc else None, - "sabb_voucher_detail_no": ctx.child_docname, - "sabb_voucher_type": ctx.doctype, - "pick_reserved_items": True, ->>>>>>> da4b78491d (fix: limit condition to fetch serial nos) "qty": out.stock_qty, } ) From cbcfe6ec36df51a8d232c67a24ce7bd36f5fb7f8 Mon Sep 17 00:00:00 2001 From: khushi8112 Date: Tue, 23 Dec 2025 16:02:50 +0530 Subject: [PATCH 37/44] fix: do not hide primary-action for composite asset --- erpnext/assets/doctype/asset/asset.js | 13 +++++++++---- erpnext/assets/doctype/asset/asset.json | 5 +++-- erpnext/assets/doctype/asset/asset.py | 5 ++++- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index c7a564b4d5b..e2ba7814a3d 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -80,6 +80,12 @@ frappe.ui.form.on("Asset", { } }, + before_submit: function (frm) { + if (frm.doc.is_composite_asset && !frm.has_active_capitalization) { + frappe.throw(__("Please capitalize this asset before submitting.")); + } + }, + refresh: function (frm) { frappe.ui.form.trigger("Asset", "is_existing_asset"); frm.toggle_display("next_depreciation_date", frm.doc.docstatus < 1); @@ -200,9 +206,10 @@ frappe.ui.form.on("Asset", { asset: frm.doc.name, }, callback: function (r) { + frm.has_active_capitalization = r.message; + if (!r.message) { - $(".primary-action").prop("hidden", true); - $(".form-message").text(__("Capitalize this asset to confirm")); + $(".form-message").text(__("Capitalize this asset before submitting.")); frm.add_custom_button(__("Capitalize Asset"), function () { frm.trigger("create_asset_capitalization"); @@ -474,7 +481,6 @@ frappe.ui.form.on("Asset", { is_composite_asset: function (frm) { if (frm.doc.is_composite_asset) { frm.set_value("gross_purchase_amount", 0); - frm.set_df_property("gross_purchase_amount", "read_only", 1); } else { frm.set_df_property("gross_purchase_amount", "read_only", 0); } @@ -542,7 +548,6 @@ frappe.ui.form.on("Asset", { callback: function (r) { var doclist = frappe.model.sync(r.message); frappe.set_route("Form", doclist[0].doctype, doclist[0].name); - $(".primary-action").prop("hidden", false); }, }); }, diff --git a/erpnext/assets/doctype/asset/asset.json b/erpnext/assets/doctype/asset/asset.json index 2e15dd77b62..e797415ee3e 100644 --- a/erpnext/assets/doctype/asset/asset.json +++ b/erpnext/assets/doctype/asset/asset.json @@ -229,7 +229,8 @@ "fieldtype": "Currency", "label": "Net Purchase Amount", "mandatory_depends_on": "eval:(!doc.is_composite_asset || doc.docstatus==1)", - "options": "Company:company:default_currency" + "options": "Company:company:default_currency", + "read_only_depends_on": "eval: doc.is_composite_asset" }, { "fieldname": "available_for_use_date", @@ -596,7 +597,7 @@ "link_fieldname": "target_asset" } ], - "modified": "2025-11-17 18:01:51.417942", + "modified": "2025-12-23 16:01:10.195932", "modified_by": "Administrator", "module": "Assets", "name": "Asset", diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 33c6604f01f..164c9820fe1 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -69,7 +69,6 @@ class Asset(AccountsController): default_finance_book: DF.Link | None department: DF.Link | None depr_entry_posting_status: DF.Literal["", "Successful", "Failed"] - depreciation_completed: DF.Check depreciation_method: DF.Literal["", "Straight Line", "Double Declining Balance", "Manual"] disposal_date: DF.Date | None finance_books: DF.Table[AssetFinanceBook] @@ -159,6 +158,10 @@ class Asset(AccountsController): self.total_asset_cost = self.gross_purchase_amount + self.additional_asset_cost self.status = self.get_status() + def before_submit(self): + if self.is_composite_asset and not has_active_capitalization(self.name): + frappe.throw(_("Please capitalize this asset before submitting.")) + def on_submit(self): self.validate_in_use_date() self.make_asset_movement() From f0aefa4274a7538ccd2e951d2c60f6be799c211c Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 23 Dec 2025 16:22:06 +0530 Subject: [PATCH 38/44] chore: v15 compatible get-all query --- 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 5012b1ad64f..ae41b2eb403 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1950,8 +1950,8 @@ class StockEntry(StockController): "`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"}, + "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`", From 8a01a709a7746c3b0b4891046fdadd4cd54f7878 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Tue, 23 Dec 2025 13:17:30 +0530 Subject: [PATCH 39/44] fix: use stock adjustment if the account has not set (cherry picked from commit 9bbcbe0ac39142220c23f3b3950b1e5d43d3751a) --- erpnext/stock/doctype/stock_entry/stock_entry.py | 4 +--- erpnext/stock/doctype/stock_entry/test_stock_entry.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index a441664ab9c..22eea620411 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1838,9 +1838,7 @@ class StockEntry(StockController): if self.purpose == "Material Issue": ret["expense_account"] = item.get("expense_account") or item_group_defaults.get("expense_account") - if (self.purpose == "Manufacture" and not args.get("is_finished_item")) or not ret.get( - "expense_account" - ): + if not ret.get("expense_account"): ret["expense_account"] = frappe.get_cached_value( "Company", self.company, "stock_adjustment_account" ) diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index aa5f0ae1045..87e018d6683 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -1285,7 +1285,7 @@ class TestStockEntry(FrappeTestCase): self.assertEqual(se.value_difference, 0.0) self.assertEqual(se.total_incoming_value, se.total_outgoing_value) - self.assertEqual(se.items[0].expense_account, "Stock Adjustment - _TC") + self.assertEqual(se.items[0].expense_account, "_Test Account Cost for Goods Sold - _TC") self.assertEqual(se.items[1].expense_account, "_Test Account Cost for Goods Sold - _TC") @change_settings("Stock Settings", {"allow_negative_stock": 0}) From 0f2fb54756e027d8e2b3caf67750219a4235403f Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 18:00:19 +0530 Subject: [PATCH 40/44] Merge pull request #51292 from frappe/mergify/bp/version-15-hotfix/pr-51285 fix(patch): handle currency exchange settings frankfurter api update for older versions (backport #51285) --- .../update_currency_exchange_settings_for_frankfurter.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/erpnext/patches/v14_0/update_currency_exchange_settings_for_frankfurter.py b/erpnext/patches/v14_0/update_currency_exchange_settings_for_frankfurter.py index a67c5a26237..f8abf913e3b 100644 --- a/erpnext/patches/v14_0/update_currency_exchange_settings_for_frankfurter.py +++ b/erpnext/patches/v14_0/update_currency_exchange_settings_for_frankfurter.py @@ -2,6 +2,15 @@ import frappe def execute(): + try: + from erpnext.patches.v16_0.update_currency_exchange_settings_for_frankfurter import execute + + execute() + except ImportError: + update_frankfurter_app_parameter_and_result() + + +def update_frankfurter_app_parameter_and_result(): settings = frappe.get_doc("Currency Exchange Settings") if settings.service_provider != "frankfurter.app": return From fe80d1d0e737b235ff5a3d0ef764de0b91d354d5 Mon Sep 17 00:00:00 2001 From: SowmyaArunachalam Date: Fri, 19 Dec 2025 22:49:34 +0530 Subject: [PATCH 41/44] feat: add redirect button on report (cherry picked from commit c0ac5f94b513ba09cca00735c342ebad01920b51) --- erpnext/stock/report/stock_balance/stock_balance.js | 7 +++++++ erpnext/stock/report/stock_ledger/stock_ledger.js | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/erpnext/stock/report/stock_balance/stock_balance.js b/erpnext/stock/report/stock_balance/stock_balance.js index b59ee57b154..fa3b977a121 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.js +++ b/erpnext/stock/report/stock_balance/stock_balance.js @@ -151,6 +151,13 @@ frappe.query_reports["Stock Balance"] = { return value; }, + + onload: function (report) { + report.page.add_inner_button(__("View Stock Ledger"), function () { + var filters = report.get_values(); + frappe.set_route("query-report", "Stock Ledger", filters); + }); + }, }; erpnext.utils.add_inventory_dimensions("Stock Balance", 8); diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.js b/erpnext/stock/report/stock_ledger/stock_ledger.js index 9b85a83c03b..d56d58db666 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.js +++ b/erpnext/stock/report/stock_ledger/stock_ledger.js @@ -134,6 +134,13 @@ frappe.query_reports["Stock Ledger"] = { return value; }, + + onload: function (report) { + report.page.add_inner_button(__("View Stock Balance"), function () { + var filters = report.get_values(); + frappe.set_route("query-report", "Stock Balance", filters); + }); + }, }; erpnext.utils.add_inventory_dimensions("Stock Ledger", 10); From 5f295c5310d044094c9cad201b4ed765c28dfa7e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 23 Dec 2025 20:47:16 +0530 Subject: [PATCH 42/44] chore: resolve conflicts --- .../request_for_quotation.js | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js index 5782a3960ad..6ab5048082e 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js @@ -549,23 +549,16 @@ erpnext.buying.RequestforQuotationController = class RequestforQuotationControll callback: load_suppliers, }); } else if (args.supplier_group) { -<<<<<<< HEAD return frappe.call({ method: "frappe.client.get_list", args: { doctype: "Supplier", -======= - frappe.db - .get_list("Supplier", { - filters: { - supplier_group: args.supplier_group, - disabled: 0, - }, - limit: 100, ->>>>>>> 6cc2290f6e (fix(buying): add disabled filter for supplier) order_by: "name", fields: ["name"], - filters: [["Supplier", "supplier_group", "=", args.supplier_group]], + filters: [ + ["Supplier", "supplier_group", "=", args.supplier_group], + ["disabled", "=", 0], + ], }, callback: load_suppliers, }); From c01f20da00bec7a29826e1eaa396acc64d87fcab Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Mon, 22 Dec 2025 15:26:33 +0530 Subject: [PATCH 43/44] fix(manufacturing): validate delivered qty in production plan (cherry picked from commit eda8a621c65bc0a250407605782acf7457ca79e9) --- .../production_plan/production_plan.py | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index c4b0aa26fce..74ccdfd02aa 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -369,6 +369,15 @@ class ProductionPlan(Document): pi = frappe.qb.DocType("Packed Item") + pending_qty = ( + frappe.qb.terms.Case() + .when( + (so_item.work_order_qty > so_item.delivered_qty), + (((so_item.qty - so_item.work_order_qty) * pi.qty) / so_item.qty), + ) + .else_(((so_item.qty - so_item.delivered_qty) * pi.qty) / so_item.qty) + ) + packed_items_query = ( frappe.qb.from_(so_item) .from_(pi) @@ -376,7 +385,7 @@ class ProductionPlan(Document): pi.parent, pi.item_code, pi.warehouse.as_("warehouse"), - (((so_item.qty - so_item.work_order_qty) * pi.qty) / so_item.qty).as_("pending_qty"), + pending_qty.as_("pending_qty"), pi.parent_item, pi.description, so_item.name, @@ -387,7 +396,16 @@ class ProductionPlan(Document): & (so_item.docstatus == 1) & (pi.parent_item == so_item.item_code) & (so_item.parent.isin(so_list)) - & (so_item.qty > so_item.work_order_qty) + & ( + ( + (so_item.work_order_qty > so_item.delivered_qty) + & (so_item.qty > so_item.work_order_qty) + ) + | ( + (so_item.work_order_qty <= so_item.delivered_qty) + & (so_item.qty > so_item.delivered_qty) + ) + ) & ( ExistsCriterion( frappe.qb.from_(bom) From 2645bf648db95a90458b9a038eb35c39b5c6b55b Mon Sep 17 00:00:00 2001 From: Sudharsanan11 Date: Tue, 23 Dec 2025 18:26:17 +0530 Subject: [PATCH 44/44] test(manufacturing): add test to validate planned qty (cherry picked from commit 2073cb0106157b4d59511196ca66db21258ad59d) --- .../production_plan/test_production_plan.py | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 85e175af2da..b4b0468a0dd 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -756,6 +756,109 @@ class TestProductionPlan(FrappeTestCase): frappe.db.rollback() + def test_get_sales_order_items_for_product_bundle(self): + """Testing the Planned Qty for Product Bundle Item""" + from erpnext.manufacturing.doctype.work_order.test_work_order import ( + make_stock_entry as create_stock_entry, + ) + from erpnext.manufacturing.doctype.work_order.test_work_order import ( + make_wo_order_test_record, + ) + from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + # 1. Create required items + bundle_item = create_item(item_code="Bundle Item", is_stock_item=0) + bom_item = create_item(item_code="BOM Item") + rm_item = create_item(item_code="RM Item") + + fg_warehouse = "_Test FG Warehouse - _TC" + + # Create warehouse if it doesn't exist + if not frappe.db.exists("Warehouse", fg_warehouse): + create_warehouse(warehouse_name="_Test FG Warehouse") + + # 2. Create initial stock for components + make_stock_entry(item_code=bom_item.name, target="_Test FG Warehouse - _TC", qty=15) + make_stock_entry(item_code=rm_item.name, target="Stores - _TC", qty=25) + + # 3. Create BOM for manufactured item + bom = make_bom( + item=bom_item.name, + raw_materials=[rm_item.name], + set_as_default_bom=1, + ) + + # 4. Create Product Bundle (Bundle Item → contains BOM Item) + make_product_bundle(parent=bundle_item.name, items=[bom_item.name]) + + # 5. Create Sales Order for 50 units of Bundle Item + sales_order = make_sales_order(item_code=bundle_item.name, qty=50, warehouse=fg_warehouse) + + # 6. Create Work Order for partial quantity (25 out of 50) + work_order_qty = 25 + work_order = make_wo_order_test_record( + production_item=bom_item.name, + bom_no=bom.name, + qty=work_order_qty, + sales_order=sales_order.name, + source_warehouse="Stores - _TC", + fg_warehouse=fg_warehouse, + do_not_save=1, + ) + + # Link Work Order to correct Sales Order Item row + work_order.sales_order_item = sales_order.items[0].name + work_order.save() + work_order.submit() + + # 7. Material transfer from Stores → WIP + transfer_entry = frappe.get_doc( + create_stock_entry(work_order.name, "Material Transfer for Manufacture") + ) + for d in transfer_entry.get("items"): + d.s_warehouse = "Stores - _TC" + transfer_entry.insert() + transfer_entry.submit() + + # 8. Complete manufacturing (WIP → Finished Goods) + manufacture_entry = frappe.get_doc(create_stock_entry(work_order.name, "Manufacture")) + manufacture_entry.insert() + manufacture_entry.submit() + + # 9. Verify work order qty is correctly updated in Sales Order + sales_order.reload() + self.assertEqual(sales_order.items[0].work_order_qty, work_order_qty) + + # 10. Create partial Delivery Note (40 out of 50) + dn = make_delivery_note(sales_order.name) + dn.items[0].qty = 40 + dn.save() + dn.submit() + + # 11. Check delivered quantity updated correctly + sales_order.reload() + self.assertEqual(sales_order.items[0].delivered_qty, 40) + + # 12. Create Production Plan from remaining open Sales Order quantity + pln = frappe.new_doc("Production Plan") + pln.company = sales_order.company + pln.get_items_from = "Sales Order" + pln.item_code = bundle_item.name + + # Fetch open sales orders + pln.get_open_sales_orders() + self.assertEqual(pln.sales_orders[0].sales_order, sales_order.name) + + # Pull items → should plan remaining 10 qty + pln.get_so_items() + + """ + Test Case: Production Plan should plan remaining 10 units + (50 ordered - 25 manufactured - 40 delivered = 10 pending) + """ + self.assertEqual(pln.po_items[0].planned_qty, 10) + def test_multiple_work_order_for_production_plan_item(self): "Test producing Prod Plan (making WO) in parts."