From c97be8abe1b6ca1115130776b52065d6eddf914c Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 24 Jun 2026 12:57:31 +0530 Subject: [PATCH 01/40] feat(accounts): add configurable job timeout for Process Period Closing Voucher Adds a `pcv_job_timeout` Int field (default 3600s) to Accounts Settings so admins can tune the enqueue timeout for PCV background jobs without a code change. All three `frappe.enqueue` calls in `process_period_closing_voucher.py` now read this value at runtime. Co-Authored-By: Claude Sonnet 4.6 (cherry picked from commit 13b6c4a165db29d95d05cdcd01d79cdeaca0accc) # Conflicts: # erpnext/accounts/doctype/accounts_settings/accounts_settings.json --- .../accounts_settings/accounts_settings.json | 16 ++++++++++++++++ .../process_period_closing_voucher.py | 10 +++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 18a0a86fd52..bfde962fe62 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -79,6 +79,7 @@ "acc_frozen_upto", "ignore_account_closing_balance", "use_legacy_controller_for_pcv", + "pcv_job_timeout", "column_break_25", "frozen_accounts_modifier", "tab_break_dpet", @@ -635,6 +636,7 @@ "label": "Use Legacy Controller For Period Closing Voucher" }, { +<<<<<<< HEAD "fieldname": "payment_entry_settings", "fieldtype": "Section Break", "label": "Payment Entry Settings" @@ -650,6 +652,20 @@ "fieldname": "show_party_balance", "fieldtype": "Check", "label": "Show Party Balance" +======= + "default": "3600", + "description": "Timeout (in seconds) for each background job enqueued by Process Period Closing Voucher", + "fieldname": "pcv_job_timeout", + "fieldtype": "Int", + "label": "PCV Job Timeout (seconds)" + }, + { + "description": "Users with this role will be notified if the asset depreciation gets failed", + "fieldname": "role_to_notify_on_depreciation_failure", + "fieldtype": "Link", + "label": "Role to Notify on Depreciation Failure", + "options": "Role" +>>>>>>> 13b6c4a165 (feat(accounts): add configurable job timeout for Process Period Closing Voucher) }, { "default": "30, 60, 90, 120", diff --git a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py index e6a330a4d14..096b085cf0e 100644 --- a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py +++ b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py @@ -92,6 +92,8 @@ def start_pcv_processing(docname: str): frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True) frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running") + timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600 + ppcvd = qb.DocType("Process Period Closing Voucher Detail") if normal_balances := ( qb.from_(ppcvd) @@ -118,7 +120,7 @@ def start_pcv_processing(docname: str): frappe.enqueue( method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date", queue="long", - timeout="3600", + timeout=timeout, is_async=True, enqueue_after_commit=True, docname=docname, @@ -244,6 +246,8 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions): @frappe.whitelist() def schedule_next_date(docname: str): + timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600 + ppcvd = qb.DocType("Process Period Closing Voucher Detail") if to_process := ( qb.from_(ppcvd) @@ -269,7 +273,7 @@ def schedule_next_date(docname: str): frappe.enqueue( method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date", queue="long", - timeout="3600", + timeout=timeout, is_async=True, enqueue_after_commit=True, docname=docname, @@ -299,7 +303,7 @@ def schedule_next_date(docname: str): frappe.enqueue( method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.summarize_and_post_ledger_entries", queue="long", - timeout="3600", + timeout=timeout, is_async=True, job_name=job_name, enqueue_after_commit=True, From c33d7e5d7be12f103ccae69354ab75d309af3d2d Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 24 Jun 2026 13:01:42 +0530 Subject: [PATCH 02/40] refactor: patch, display depends on and json changes (cherry picked from commit 3da7eefebb37bb1f1e6cc4c7c0c2add735e0e2c5) # Conflicts: # erpnext/accounts/doctype/accounts_settings/accounts_settings.json # erpnext/accounts/doctype/accounts_settings/accounts_settings.py # erpnext/patches.txt --- .../doctype/accounts_settings/accounts_settings.json | 5 +++++ .../doctype/accounts_settings/accounts_settings.py | 5 +++++ erpnext/patches.txt | 10 ++++++++++ 3 files changed, 20 insertions(+) diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index bfde962fe62..6dd3c5fcc6e 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -654,6 +654,7 @@ "label": "Show Party Balance" ======= "default": "3600", + "depends_on": "eval: !doc.use_legacy_controller_for_pcv", "description": "Timeout (in seconds) for each background job enqueued by Process Period Closing Voucher", "fieldname": "pcv_job_timeout", "fieldtype": "Int", @@ -679,7 +680,11 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], +<<<<<<< HEAD "modified": "2026-05-18 12:16:33.679345", +======= + "modified": "2026-06-24 12:59:41.868865", +>>>>>>> 3da7eefebb (refactor: patch, display depends on and json changes) "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py index 5cd4955cdd7..69e3dde73af 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py @@ -59,7 +59,12 @@ class AccountsSettings(Document): make_payment_via_journal_entry: DF.Check merge_similar_account_heads: DF.Check over_billing_allowance: DF.Currency +<<<<<<< HEAD post_change_gl_entries: DF.Check +======= + pcv_job_timeout: DF.Int + preview_mode: DF.Check +>>>>>>> 3da7eefebb (refactor: patch, display depends on and json changes) receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"] receivable_payable_remarks_length: DF.Int reconciliation_queue_size: DF.Int diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 3b0ac5cf552..fb3279255e2 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -435,5 +435,15 @@ erpnext.patches.v16_0.update_order_qty_and_requested_qty_based_on_mr_and_po erpnext.patches.v16_0.depends_on_inv_dimensions erpnext.patches.v16_0.clear_procedures_from_receivable_report erpnext.patches.v16_0.migrate_address_contact_custom_fields +<<<<<<< HEAD erpnext.patches.v15_0.set_main_item_code_in_material_request_plan_item erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes +======= +erpnext.patches.v16_0.rename_secondary_item_type_field +erpnext.patches.v16_0.submit_existing_product_bundles #1 +erpnext.patches.v16_0.migrate_subscription_generate_invoice_at +erpnext.patches.v16_0.rename_subscription_billing_period_fields +erpnext.patches.v16_0.drop_redundant_serial_no_index_from_sabb +erpnext.patches.v16_0.set_default_close_opportunity_after_days +execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600) +>>>>>>> 3da7eefebb (refactor: patch, display depends on and json changes) From df3c821f98045f9ac038b1bafbd35113949db783 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 24 Jun 2026 15:05:41 +0530 Subject: [PATCH 03/40] chore: resolve conflicts --- .../accounts_settings/accounts_settings.json | 16 ++-------------- .../accounts_settings/accounts_settings.py | 4 ---- erpnext/patches.txt | 9 --------- 3 files changed, 2 insertions(+), 27 deletions(-) diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json index 6dd3c5fcc6e..62b6d5c0b6a 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json @@ -636,7 +636,6 @@ "label": "Use Legacy Controller For Period Closing Voucher" }, { -<<<<<<< HEAD "fieldname": "payment_entry_settings", "fieldtype": "Section Break", "label": "Payment Entry Settings" @@ -652,7 +651,8 @@ "fieldname": "show_party_balance", "fieldtype": "Check", "label": "Show Party Balance" -======= + }, + { "default": "3600", "depends_on": "eval: !doc.use_legacy_controller_for_pcv", "description": "Timeout (in seconds) for each background job enqueued by Process Period Closing Voucher", @@ -660,14 +660,6 @@ "fieldtype": "Int", "label": "PCV Job Timeout (seconds)" }, - { - "description": "Users with this role will be notified if the asset depreciation gets failed", - "fieldname": "role_to_notify_on_depreciation_failure", - "fieldtype": "Link", - "label": "Role to Notify on Depreciation Failure", - "options": "Role" ->>>>>>> 13b6c4a165 (feat(accounts): add configurable job timeout for Process Period Closing Voucher) - }, { "default": "30, 60, 90, 120", "fieldname": "default_ageing_range", @@ -680,11 +672,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], -<<<<<<< HEAD - "modified": "2026-05-18 12:16:33.679345", -======= "modified": "2026-06-24 12:59:41.868865", ->>>>>>> 3da7eefebb (refactor: patch, display depends on and json changes) "modified_by": "Administrator", "module": "Accounts", "name": "Accounts Settings", diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py index 69e3dde73af..83ece261895 100644 --- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.py +++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.py @@ -59,12 +59,8 @@ class AccountsSettings(Document): make_payment_via_journal_entry: DF.Check merge_similar_account_heads: DF.Check over_billing_allowance: DF.Currency -<<<<<<< HEAD post_change_gl_entries: DF.Check -======= pcv_job_timeout: DF.Int - preview_mode: DF.Check ->>>>>>> 3da7eefebb (refactor: patch, display depends on and json changes) receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"] receivable_payable_remarks_length: DF.Int reconciliation_queue_size: DF.Int diff --git a/erpnext/patches.txt b/erpnext/patches.txt index fb3279255e2..b48f16a7550 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -435,15 +435,6 @@ erpnext.patches.v16_0.update_order_qty_and_requested_qty_based_on_mr_and_po erpnext.patches.v16_0.depends_on_inv_dimensions erpnext.patches.v16_0.clear_procedures_from_receivable_report erpnext.patches.v16_0.migrate_address_contact_custom_fields -<<<<<<< HEAD erpnext.patches.v15_0.set_main_item_code_in_material_request_plan_item erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes -======= -erpnext.patches.v16_0.rename_secondary_item_type_field -erpnext.patches.v16_0.submit_existing_product_bundles #1 -erpnext.patches.v16_0.migrate_subscription_generate_invoice_at -erpnext.patches.v16_0.rename_subscription_billing_period_fields -erpnext.patches.v16_0.drop_redundant_serial_no_index_from_sabb -erpnext.patches.v16_0.set_default_close_opportunity_after_days execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600) ->>>>>>> 3da7eefebb (refactor: patch, display depends on and json changes) From 25857125007861e121e505eef841f5b8e0233f90 Mon Sep 17 00:00:00 2001 From: Loic Oberle Date: Sat, 23 May 2026 07:59:09 +0200 Subject: [PATCH 04/40] =?UTF-8?q?refactor(sales=5Fperson=5Fwise=5Ftransact?= =?UTF-8?q?ion=5Fsummary):=20Replace=20SQL=20with=20que=E2=80=A6=20(#55191?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit df3d0859a13b00d9aa9377dac48cfc365e557b20) --- .../sales_person_wise_transaction_summary.py | 94 ++++++++++--------- 1 file changed, 52 insertions(+), 42 deletions(-) diff --git a/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py b/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py index 405159215cd..23ed83cca84 100644 --- a/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py +++ b/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py @@ -4,7 +4,7 @@ import frappe from frappe import _, msgprint, qb -from frappe.query_builder import Criterion +from frappe.query_builder import Case, Criterion from erpnext import get_company_currency @@ -155,50 +155,60 @@ def get_columns(filters): def get_entries(filters): - date_field = filters["doc_type"] == "Sales Order" and "transaction_date" or "posting_date" - if filters["doc_type"] == "Sales Order": - qty_field = "delivered_qty" - else: - qty_field = "qty" - conditions, values = get_conditions(filters, date_field) + doc_type = filters["doc_type"] - entries = frappe.db.sql( - """ - SELECT - dt.name, dt.customer, dt.territory, dt.{} as posting_date, dt_item.item_code, - st.sales_person, st.allocated_percentage, dt_item.warehouse, - CASE - WHEN dt.status = "Closed" THEN dt_item.{} * dt_item.conversion_factor - ELSE dt_item.stock_qty - END as stock_qty, - CASE - WHEN dt.status = "Closed" THEN (dt_item.base_net_rate * dt_item.{} * dt_item.conversion_factor) - ELSE dt_item.base_net_amount - END as base_net_amount, - CASE - WHEN dt.status = "Closed" THEN ((dt_item.base_net_rate * dt_item.{} * dt_item.conversion_factor) * st.allocated_percentage/100) - ELSE dt_item.base_net_amount * st.allocated_percentage/100 - END as contribution_amt - FROM - `tab{}` dt, `tab{} Item` dt_item, `tabSales Team` st - WHERE - st.parent = dt.name and dt.name = dt_item.parent and st.parenttype = {} - and dt.docstatus = 1 {} order by st.sales_person, dt.name desc - """.format( - date_field, - qty_field, - qty_field, - qty_field, - filters["doc_type"], - filters["doc_type"], - "%s", - conditions, - ), - tuple([filters["doc_type"], *values]), - as_dict=1, + date_field = "transaction_date" if doc_type == "Sales Order" else "posting_date" + qty_field = "delivered_qty" if doc_type == "Sales Order" else "qty" + + dt = frappe.qb.DocType(doc_type) + dt_item = frappe.qb.DocType(f"{doc_type} Item") + st = frappe.qb.DocType("Sales Team") + + calc_qty = dt_item[qty_field] * dt_item.conversion_factor + calc_net_amount = dt_item.base_net_rate * calc_qty + + stock_qty_case = Case().when(dt.status == "Closed", calc_qty).else_(dt_item.stock_qty).as_("stock_qty") + + base_net_amount_case = ( + Case() + .when(dt.status == "Closed", calc_net_amount) + .else_(dt_item.base_net_amount) + .as_("base_net_amount") ) - return entries + contribution_amt_case = ( + Case() + .when(dt.status == "Closed", (calc_net_amount * st.allocated_percentage / 100)) + .else_(dt_item.base_net_amount * st.allocated_percentage / 100) + .as_("contribution_amt") + ) + + query = ( + frappe.get_query(dt, filters=filters, ignore_permissions=False) + .join(dt_item) + .on(dt.name == dt_item.parent) + .join(st) + .on(dt.name == st.parent) + .select( + dt.name, + dt.customer, + dt.territory, + dt[date_field].as_("posting_date"), + dt_item.item_code, + st.sales_person, + st.allocated_percentage, + dt_item.warehouse, + stock_qty_case, + base_net_amount_case, + contribution_amt_case, + ) + .where(st.parenttype == doc_type) + .where(dt.docstatus == 1) + ) + + query = query.orderby(st.sales_person).orderby(dt.name, order=frappe.qb.desc) + + return query.run(as_dict=True) def get_conditions(filters, date_field): From db8a26a3afe971355df746c12d9377e853e6731a Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:30:14 +0530 Subject: [PATCH 05/40] fix: precision issue causing COGS in inter transfer PR (backport #56420) (#56424) fix: precision issue causing COGS in inter transfer PR (#56420) (cherry picked from commit 9b0e1b61f202e33beaff8b8a146d3336001dc861) Co-authored-by: rohitwaghchaure --- erpnext/controllers/taxes_and_totals.py | 2 +- .../doctype/repost_item_valuation/repost_item_valuation.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 1fc8128d6ea..e218e9a44cb 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -185,7 +185,7 @@ class calculate_taxes_and_totals: return if not self.discount_amount_applied: - do_not_round_fields = ["valuation_rate", "incoming_rate"] + do_not_round_fields = ["valuation_rate", "incoming_rate", "sales_incoming_rate"] for item in self.doc.items: self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields) diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index ef42da67668..98a2720e6d2 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -306,6 +306,9 @@ class RepostItemValuation(Document): def _recalculate_valuation_rate(self): doc = frappe.get_doc(self.voucher_type, self.voucher_no) + if doc.get("is_internal_supplier"): + doc.set_sales_incoming_rate_for_internal_transfer() + doc.update_valuation_rate() for item in doc.items: item.db_set("valuation_rate", item.valuation_rate) From 414b3665c1cc444e66eb3a3cbb09682d4bf04f78 Mon Sep 17 00:00:00 2001 From: Diptanil Saha Date: Thu, 25 Jun 2026 00:49:27 +0530 Subject: [PATCH 06/40] fix(company): using `String.prototype.bold` method instead of `frappe.utils.bold` on company deletion prompt (#56441) --- erpnext/setup/doctype/company/company.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/erpnext/setup/doctype/company/company.js b/erpnext/setup/doctype/company/company.js index 33a7ecc8714..799686fbbb1 100644 --- a/erpnext/setup/doctype/company/company.js +++ b/erpnext/setup/doctype/company/company.js @@ -208,7 +208,7 @@ frappe.ui.form.on("Company", { reqd: 1, description: __( "Please make sure you really want to delete all the transactions for {0}. Your master data will remain as it is. This action cannot be undone.", - [frappe.utils.bold(frm.doc.name)] + [frappe.utils.escape_html(frm.doc.name).bold()] ), }, function (data) { @@ -228,7 +228,9 @@ frappe.ui.form.on("Company", { }, }); }, - __("Delete all the Transactions for {0}", [frappe.utils.bold(frm.doc.name)]), + __("Delete all the Transactions for {0}", [ + frappe.utils.escape_html(frm.doc.name).bold(), + ]), __("Delete") ); d.get_primary_btn().addClass("btn-danger"); From b176fb292b8d0ef3a9f14ec0007578881a1f45ff Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Thu, 25 Jun 2026 14:55:47 +0530 Subject: [PATCH 07/40] fix: remove frappe.utils from jinja context in process statement of accounts (cherry picked from commit 37ec2d0edd439a6dd49c4506d889b0977dcfb183) --- .../process_statement_of_accounts.py | 1 - 1 file changed, 1 deletion(-) diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py index bf130db8e22..48349a4bd99 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py @@ -390,7 +390,6 @@ def get_context(customer, doc): return { "doc": template_doc, "customer": frappe.get_doc("Customer", customer), - "frappe": frappe.utils, } From 28e4bca4f2ee9394dc8b4a7266b641d0ad58cec9 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:41:41 +0000 Subject: [PATCH 08/40] fix(crm): using `get_list` instead of `get_all` in `get_opportunities` (backport #56463) (#56465) Co-authored-by: Diptanil Saha fix(crm): using `get_list` instead of `get_all` in `get_opportunities` (#56463) --- erpnext/crm/doctype/prospect/prospect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/crm/doctype/prospect/prospect.py b/erpnext/crm/doctype/prospect/prospect.py index 7ecbe637f04..02bd2a33be4 100644 --- a/erpnext/crm/doctype/prospect/prospect.py +++ b/erpnext/crm/doctype/prospect/prospect.py @@ -136,7 +136,7 @@ def make_opportunity(source_name, target_doc=None): @frappe.whitelist() def get_opportunities(prospect): - return frappe.get_all( + return frappe.get_list( "Opportunity", filters={"opportunity_from": "Prospect", "party_name": prospect}, fields=[ From 9adfab19dc8c67a682e6df79be867c9a4ca01ea0 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Thu, 25 Jun 2026 17:32:46 +0530 Subject: [PATCH 09/40] fix: handle missing serial and batch bundle in print format (cherry picked from commit 548d90df4f58cd0a7a885cdd3adfd85af2e81de7) --- erpnext/stock/serial_batch_bundle.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 1b030dbe2fa..d0cf769100f 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -588,10 +588,16 @@ def get_serial_nos_from_bundle(serial_and_batch_bundle, serial_nos=None): def get_serial_or_batch_nos(bundle): # For print format + if not bundle: + return "" + bundle_data = frappe.get_cached_value( "Serial and Batch Bundle", bundle, ["has_serial_no", "has_batch_no"], as_dict=True ) + if not bundle_data: + return bundle + fields = [] if bundle_data.has_serial_no: fields.append("serial_no") From 3365373ec9e2c030cf72e76f77d7841993958721 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 26 Jun 2026 10:08:10 +0530 Subject: [PATCH 10/40] Revert "fix: handle missing serial and batch bundle in print format (backport #56497)" --- erpnext/stock/serial_batch_bundle.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index d0cf769100f..1b030dbe2fa 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -588,16 +588,10 @@ def get_serial_nos_from_bundle(serial_and_batch_bundle, serial_nos=None): def get_serial_or_batch_nos(bundle): # For print format - if not bundle: - return "" - bundle_data = frappe.get_cached_value( "Serial and Batch Bundle", bundle, ["has_serial_no", "has_batch_no"], as_dict=True ) - if not bundle_data: - return bundle - fields = [] if bundle_data.has_serial_no: fields.append("serial_no") From 9e6edea818ad3a82fdf7cf66c562cfd6f32dfd3b Mon Sep 17 00:00:00 2001 From: Mohd Haris Date: Fri, 19 Jun 2026 10:51:36 +0530 Subject: [PATCH 11/40] fix: allow rename for Quality Inspection Parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Quality Inspection Parameter DocType did not have `allow_rename` enabled, so the "Rename" action was hidden from the form's menu (the 3-dots / ⋮ options). Since the DocType is auto-named from the `parameter` field (`autoname: field:parameter`), users had no way to correct or change a parameter's name once created. Enable `allow_rename` so users can rename a Quality Inspection Parameter from the form menu. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit adfef48a65cbbe8019e97ac850d873f843d9f923) # Conflicts: # erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json --- .../quality_inspection_parameter.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json b/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json index 418b4825f2f..8915a580777 100644 --- a/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json +++ b/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_rename": 1, "autoname": "field:parameter", "creation": "2020-12-28 17:06:00.254129", "doctype": "DocType", @@ -34,7 +35,11 @@ ], "index_web_pages_for_search": 1, "links": [], +<<<<<<< HEAD "modified": "2021-02-19 20:33:30.657406", +======= + "modified": "2026-06-19 10:55:00.000000", +>>>>>>> adfef48a65 (fix: allow rename for Quality Inspection Parameter) "modified_by": "Administrator", "module": "Stock", "name": "Quality Inspection Parameter", From f2b7319462db6d6a137db0e16255fa1502ed538e Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 26 Jun 2026 13:46:30 +0530 Subject: [PATCH 12/40] chore: resolve conflicts --- .../quality_inspection_parameter.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json b/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json index 8915a580777..f95c4a69482 100644 --- a/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json +++ b/erpnext/stock/doctype/quality_inspection_parameter/quality_inspection_parameter.json @@ -35,11 +35,7 @@ ], "index_web_pages_for_search": 1, "links": [], -<<<<<<< HEAD - "modified": "2021-02-19 20:33:30.657406", -======= "modified": "2026-06-19 10:55:00.000000", ->>>>>>> adfef48a65 (fix: allow rename for Quality Inspection Parameter) "modified_by": "Administrator", "module": "Stock", "name": "Quality Inspection Parameter", @@ -98,4 +94,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} From 0f9429fd3dca54021f184c400b6397d92f830724 Mon Sep 17 00:00:00 2001 From: Shllokkk Date: Fri, 26 Jun 2026 15:00:54 +0530 Subject: [PATCH 13/40] fix: remove dead bundle helper call from purchase receipt print format (cherry picked from commit b9f5a77fa7ae7e151a667a38fc5569b4e2cecedd) --- .../purchase_receipt_serial_and_batch_bundle_print.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/print_format/purchase_receipt_serial_and_batch_bundle_print/purchase_receipt_serial_and_batch_bundle_print.json b/erpnext/stock/print_format/purchase_receipt_serial_and_batch_bundle_print/purchase_receipt_serial_and_batch_bundle_print.json index a8ab8f6ac7d..9b5a71aae02 100644 --- a/erpnext/stock/print_format/purchase_receipt_serial_and_batch_bundle_print/purchase_receipt_serial_and_batch_bundle_print.json +++ b/erpnext/stock/print_format/purchase_receipt_serial_and_batch_bundle_print/purchase_receipt_serial_and_batch_bundle_print.json @@ -8,7 +8,7 @@ "docstatus": 0, "doctype": "Print Format", "font_size": 14, - "format_data": "[{\"fieldname\": \"print_heading_template\", \"fieldtype\": \"Custom HTML\", \"options\": \"
\\t\\t\\t\\t

Purchase Receipt

{{ doc.name }}\\t\\t\\t\\t

\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"supplier_name\", \"print_hide\": 0, \"label\": \"Supplier Name\"}, {\"fieldname\": \"supplier_delivery_note\", \"print_hide\": 0, \"label\": \"Supplier Delivery Note\"}, {\"fieldname\": \"rack\", \"print_hide\": 0, \"label\": \"Rack\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"posting_date\", \"print_hide\": 0, \"label\": \"Date\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"apply_putaway_rule\", \"print_hide\": 0, \"label\": \"Apply Putaway Rule\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Accounting Dimensions\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"region\", \"print_hide\": 0, \"label\": \"Region\"}, {\"fieldname\": \"function\", \"print_hide\": 0, \"label\": \"Function\"}, {\"fieldname\": \"depot\", \"print_hide\": 0, \"label\": \"Depot\"}, {\"fieldname\": \"cost_center\", \"print_hide\": 0, \"label\": \"Cost Center\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"location\", \"print_hide\": 0, \"label\": \"Location\"}, {\"fieldname\": \"country\", \"print_hide\": 0, \"label\": \"Country\"}, {\"fieldname\": \"project\", \"print_hide\": 0, \"label\": \"Project\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Items\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"scan_barcode\", \"print_hide\": 0, \"label\": \"Scan Barcode\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"set_from_warehouse\", \"print_hide\": 0, \"label\": \"Set From Warehouse\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"\\n\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t{%- for row in doc.items -%}\\n\\t\\t\\n\\t\\t {% set bundle_data = get_serial_or_batch_nos(row.serial_and_batch_bundle) %}\\n\\t\\t {% set serial_nos = [] %}\\n {% set batches = {} %}\\n\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t{%- endfor -%}\\n\\t\\n
SrItem NameDescriptionQtyRateAmount
{{ row.idx }}\\n\\t\\t\\t\\t{{ row.item_name }}\\n\\t\\t\\t\\t{% if row.item_code != row.item_name -%}\\n\\t\\t\\t\\t
Item Code: {{ row.item_code}}\\n\\t\\t\\t\\t{%- endif %}\\n\\t\\t\\t
\\n\\t\\t\\t\\t
{{ row.description }}
{{ row.qty }} {{ row.uom or row.stock_uom }}{{\\n\\t\\t\\t\\trow.get_formatted(\\\"rate\\\", doc) }}{{\\n\\t\\t\\t\\trow.get_formatted(\\\"amount\\\", doc) }}
\\n\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"total_qty\", \"print_hide\": 0, \"label\": \"Total Quantity\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"total\", \"print_hide\": 0, \"label\": \"Total\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"taxes\", \"print_hide\": 0, \"label\": \"Purchase Taxes and Charges\", \"visible_columns\": [{\"fieldname\": \"category\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"add_deduct_tax\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"charge_type\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"row_id\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"included_in_print_rate\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"included_in_paid_amount\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"account_head\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"description\", \"print_width\": \"300px\", \"print_hide\": 0}, {\"fieldname\": \"rate\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"region\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"function\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"location\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"cost_center\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"depot\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"country\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"account_currency\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"tax_amount\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"total\", \"print_width\": \"\", \"print_hide\": 0}]}, {\"fieldtype\": \"Section Break\", \"label\": \"Totals\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"grand_total\", \"print_hide\": 0, \"label\": \"Grand Total\"}, {\"fieldname\": \"rounded_total\", \"print_hide\": 0, \"label\": \"Rounded Total\"}, {\"fieldname\": \"in_words\", \"print_hide\": 0, \"label\": \"In Words\"}, {\"fieldname\": \"disable_rounded_total\", \"print_hide\": 0, \"label\": \"Disable Rounded Total\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Supplier Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"address_display\", \"print_hide\": 0, \"label\": \"Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"contact_display\", \"print_hide\": 0, \"label\": \"Contact\"}, {\"fieldname\": \"contact_mobile\", \"print_hide\": 0, \"label\": \"Mobile No\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Company Billing Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"billing_address\", \"print_hide\": 0, \"label\": \"Billing Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"billing_address_display\", \"print_hide\": 0, \"label\": \"Billing Address\"}, {\"fieldname\": \"terms\", \"print_hide\": 0, \"label\": \"Terms and Conditions\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"\\n\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t{%- for row in doc.items -%}\\n\\t\\t\\n\\t\\t {% set bundle_data = frappe.get_all(\\\"Serial and Batch Entry\\\", \\n\\t\\t fields=[\\\"serial_no\\\", \\\"batch_no\\\", \\\"qty\\\"], \\n\\t\\t filters={\\\"parent\\\": row.serial_and_batch_bundle}) %}\\n\\t\\t {% set serial_nos = [] %}\\n {% set batches = {} %}\\n \\n {% if bundle_data %}\\n\\t\\t\\t {% for data in bundle_data %}\\n\\t\\t\\t {% if data.serial_no %}\\n\\t\\t\\t {{ serial_nos.append(data.serial_no) or \\\"\\\" }}\\n\\t\\t\\t {% endif %}\\n\\t\\t\\t \\n\\t\\t\\t {% if data.batch_no %}\\n\\t\\t\\t {{ batches.update({data.batch_no: data.qty}) or \\\"\\\" }}\\n\\t\\t\\t {% endif %}\\n\\t\\t\\t {% endfor %}\\n\\t\\t\\t{% endif %}\\n\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t{%- endfor -%}\\n\\t\\n
SrItem NameQtySerial NosBatch Nos (Qty)
{{ row.idx }}\\n\\t\\t\\t\\t{{ row.item_name }}\\n\\t\\t\\t\\t{% if row.item_code != row.item_name -%}\\n\\t\\t\\t\\t
Item Code: {{ row.item_code}}\\n\\t\\t\\t\\t{%- endif %}\\n\\t\\t\\t
{{ row.qty }} {{ row.uom or row.stock_uom }}{{ serial_nos|join(',') }}\\n\\t\\t\\t {% if batches %}\\n {% for batch_no, qty in batches.items() %}\\n

{{batch_no}} : {{qty}} {{ row.uom or row.stock_uom }}

\\n {% endfor %}\\n {% endif %}\\n\\t\\t\\t
\\n\"}]", + "format_data": "[{\"fieldname\": \"print_heading_template\", \"fieldtype\": \"Custom HTML\", \"options\": \"
\\t\\t\\t\\t

Purchase Receipt

{{ doc.name }}\\t\\t\\t\\t

\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"supplier_name\", \"print_hide\": 0, \"label\": \"Supplier Name\"}, {\"fieldname\": \"supplier_delivery_note\", \"print_hide\": 0, \"label\": \"Supplier Delivery Note\"}, {\"fieldname\": \"rack\", \"print_hide\": 0, \"label\": \"Rack\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"posting_date\", \"print_hide\": 0, \"label\": \"Date\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"apply_putaway_rule\", \"print_hide\": 0, \"label\": \"Apply Putaway Rule\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Accounting Dimensions\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"region\", \"print_hide\": 0, \"label\": \"Region\"}, {\"fieldname\": \"function\", \"print_hide\": 0, \"label\": \"Function\"}, {\"fieldname\": \"depot\", \"print_hide\": 0, \"label\": \"Depot\"}, {\"fieldname\": \"cost_center\", \"print_hide\": 0, \"label\": \"Cost Center\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"location\", \"print_hide\": 0, \"label\": \"Location\"}, {\"fieldname\": \"country\", \"print_hide\": 0, \"label\": \"Country\"}, {\"fieldname\": \"project\", \"print_hide\": 0, \"label\": \"Project\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Items\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"scan_barcode\", \"print_hide\": 0, \"label\": \"Scan Barcode\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"set_from_warehouse\", \"print_hide\": 0, \"label\": \"Set From Warehouse\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"\\n\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t{%- for row in doc.items -%}\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t{%- endfor -%}\\n\\t\\n
SrItem NameDescriptionQtyRateAmount
{{ row.idx }}\\n\\t\\t\\t\\t{{ row.item_name }}\\n\\t\\t\\t\\t{% if row.item_code != row.item_name -%}\\n\\t\\t\\t\\t
Item Code: {{ row.item_code}}\\n\\t\\t\\t\\t{%- endif %}\\n\\t\\t\\t
\\n\\t\\t\\t\\t
{{ row.description }}
{{ row.qty }} {{ row.uom or row.stock_uom }}{{\\n\\t\\t\\t\\trow.get_formatted(\\\"rate\\\", doc) }}{{\\n\\t\\t\\t\\trow.get_formatted(\\\"amount\\\", doc) }}
\\n\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"total_qty\", \"print_hide\": 0, \"label\": \"Total Quantity\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"total\", \"print_hide\": 0, \"label\": \"Total\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"taxes\", \"print_hide\": 0, \"label\": \"Purchase Taxes and Charges\", \"visible_columns\": [{\"fieldname\": \"category\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"add_deduct_tax\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"charge_type\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"row_id\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"included_in_print_rate\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"included_in_paid_amount\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"account_head\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"description\", \"print_width\": \"300px\", \"print_hide\": 0}, {\"fieldname\": \"rate\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"region\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"function\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"location\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"cost_center\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"depot\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"country\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"account_currency\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"tax_amount\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"total\", \"print_width\": \"\", \"print_hide\": 0}]}, {\"fieldtype\": \"Section Break\", \"label\": \"Totals\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"grand_total\", \"print_hide\": 0, \"label\": \"Grand Total\"}, {\"fieldname\": \"rounded_total\", \"print_hide\": 0, \"label\": \"Rounded Total\"}, {\"fieldname\": \"in_words\", \"print_hide\": 0, \"label\": \"In Words\"}, {\"fieldname\": \"disable_rounded_total\", \"print_hide\": 0, \"label\": \"Disable Rounded Total\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Supplier Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"address_display\", \"print_hide\": 0, \"label\": \"Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"contact_display\", \"print_hide\": 0, \"label\": \"Contact\"}, {\"fieldname\": \"contact_mobile\", \"print_hide\": 0, \"label\": \"Mobile No\"}, {\"fieldtype\": \"Section Break\", \"label\": \"Company Billing Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"billing_address\", \"print_hide\": 0, \"label\": \"Billing Address\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"billing_address_display\", \"print_hide\": 0, \"label\": \"Billing Address\"}, {\"fieldname\": \"terms\", \"print_hide\": 0, \"label\": \"Terms and Conditions\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"\\n\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t{%- for row in doc.items -%}\\n\\t\\t\\n\\t\\t {% set bundle_data = frappe.get_all(\\\"Serial and Batch Entry\\\", \\n\\t\\t fields=[\\\"serial_no\\\", \\\"batch_no\\\", \\\"qty\\\"], \\n\\t\\t filters={\\\"parent\\\": row.serial_and_batch_bundle}) %}\\n\\t\\t {% set serial_nos = [] %}\\n {% set batches = {} %}\\n \\n {% if bundle_data %}\\n\\t\\t\\t {% for data in bundle_data %}\\n\\t\\t\\t {% if data.serial_no %}\\n\\t\\t\\t {{ serial_nos.append(data.serial_no) or \\\"\\\" }}\\n\\t\\t\\t {% endif %}\\n\\t\\t\\t \\n\\t\\t\\t {% if data.batch_no %}\\n\\t\\t\\t {{ batches.update({data.batch_no: data.qty}) or \\\"\\\" }}\\n\\t\\t\\t {% endif %}\\n\\t\\t\\t {% endfor %}\\n\\t\\t\\t{% endif %}\\n\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t{%- endfor -%}\\n\\t\\n
SrItem NameQtySerial NosBatch Nos (Qty)
{{ row.idx }}\\n\\t\\t\\t\\t{{ row.item_name }}\\n\\t\\t\\t\\t{% if row.item_code != row.item_name -%}\\n\\t\\t\\t\\t
Item Code: {{ row.item_code}}\\n\\t\\t\\t\\t{%- endif %}\\n\\t\\t\\t
{{ row.qty }} {{ row.uom or row.stock_uom }}{{ serial_nos|join(',') }}\\n\\t\\t\\t {% if batches %}\\n {% for batch_no, qty in batches.items() %}\\n

{{batch_no}} : {{qty}} {{ row.uom or row.stock_uom }}

\\n {% endfor %}\\n {% endif %}\\n\\t\\t\\t
\\n\"}]", "idx": 0, "line_breaks": 0, "margin_bottom": 15.0, @@ -27,4 +27,4 @@ "raw_printing": 0, "show_section_headings": 0, "standard": "Yes" -} \ No newline at end of file +} From 4aedf98c7ccff4ead45947219e2e5825f5432e8f Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:06:34 +0530 Subject: [PATCH 14/40] ci: bump pre-commit actions to v3.0.1 (backport #56562) (#56566) Co-authored-by: Diptanil Saha --- .github/workflows/linters.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index 94b76b12ce7..f55d6bf844e 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -18,7 +18,7 @@ jobs: cache: pip - name: Install and Run Pre-commit - uses: pre-commit/action@v3.0.0 + uses: pre-commit/action@v3.0.1 - name: Download Semgrep rules run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules From 5ed946b3b96c888dc8d66b5595b6b75523abe279 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 19:42:28 +0530 Subject: [PATCH 15/40] fix: ignored posting time 00:00:00 in RIV (backport #56571) (#56572) fix: ignored posting time 00:00:00 in RIV (#56571) (cherry picked from commit 31f89b72b48110a2245791b85994b491c6fda2e5) Co-authored-by: rohitwaghchaure --- .../doctype/repost_item_valuation/repost_item_valuation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index 98a2720e6d2..f7cc4b90c36 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -86,7 +86,7 @@ class RepostItemValuation(Document): self.validate_recreate_stock_ledgers() def set_default_posting_time(self): - if not self.posting_time: + if self.posting_time is None: self.posting_time = nowtime() if not self.posting_date: From af05b8a30dba71ca964f90167db24015bd1ca6d5 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 02:31:22 +0530 Subject: [PATCH 16/40] chore: removing `controllers` from pre-commit eslint hooks exclude list (backport #56575) (#56578) Co-authored-by: Diptanil Saha --- .pre-commit-config.yaml | 1 - erpnext/public/js/controllers/accounts.js | 14 ++++++----- .../public/js/controllers/taxes_and_totals.js | 12 +++++---- erpnext/public/js/controllers/transaction.js | 25 +++++++++---------- 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 13cbf66a5af..c09e5cdedb9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -50,7 +50,6 @@ repos: cypress/.*| .*node_modules.*| .*boilerplate.*| - erpnext/public/js/controllers/.*| erpnext/templates/pages/order.js| erpnext/templates/includes/.* )$ diff --git a/erpnext/public/js/controllers/accounts.js b/erpnext/public/js/controllers/accounts.js index dec0f1c024d..1b1c45e38ea 100644 --- a/erpnext/public/js/controllers/accounts.js +++ b/erpnext/public/js/controllers/accounts.js @@ -16,13 +16,15 @@ erpnext.accounts.taxes = { } }); }, - onload: function(frm) { - if(frm.get_field("taxes")) { - frm.set_query("account_head", "taxes", function(doc) { - if(frm.cscript.tax_table == "Sales Taxes and Charges") { - var account_type = ["Tax", "Chargeable", "Expense Account"]; + onload: function (frm) { + if (frm.get_field("taxes")) { + frm.set_query("account_head", "taxes", function (doc) { + let account_type = ["Tax", "Chargeable"]; + + if (frm.cscript.tax_table == "Sales Taxes and Charges") { + account_type.push("Expense Account"); } else { - var account_type = ["Tax", "Chargeable", "Income Account", "Expenses Included In Valuation"]; + account_type.push("Income Account", "Expenses Included In Valuation"); } return { diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 0df4cabfb4f..1f091f3934d 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -498,7 +498,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } else if(tax.charge_type == "On Net Total") { if (tax.account_head in item_tax_map) { current_net_amount = item.net_amount - }; + } current_tax_amount = (tax_rate / 100.0) * item.net_amount; } else if(tax.charge_type == "On Previous Row Amount") { current_net_amount = this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_amount_for_current_item @@ -862,12 +862,13 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { if(["Sales Invoice", "POS Invoice", "Purchase Invoice"].includes(this.frm.doc.doctype)) { let grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total; let base_grand_total = this.frm.doc.base_rounded_total || this.frm.doc.base_grand_total; + let total_amount_to_pay; if(this.frm.doc.party_account_currency == this.frm.doc.currency) { - var total_amount_to_pay = flt((grand_total - this.frm.doc.total_advance + total_amount_to_pay = flt((grand_total - this.frm.doc.total_advance - this.frm.doc.write_off_amount), precision("grand_total")); } else { - var total_amount_to_pay = flt( + total_amount_to_pay = flt( (flt(base_grand_total, precision("base_grand_total")) - this.frm.doc.total_advance - this.frm.doc.base_write_off_amount), precision("base_grand_total") @@ -901,14 +902,15 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { async set_total_amount_to_default_mop() { let grand_total = this.frm.doc.rounded_total || this.frm.doc.grand_total; let base_grand_total = this.frm.doc.base_rounded_total || this.frm.doc.base_grand_total; + let total_amount_to_pay; if (this.frm.doc.party_account_currency == this.frm.doc.currency) { - var total_amount_to_pay = flt( + total_amount_to_pay = flt( grand_total - this.frm.doc.total_advance - this.frm.doc.write_off_amount, precision("grand_total") ); } else { - var total_amount_to_pay = flt( + total_amount_to_pay = flt( ( flt( base_grand_total, diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 5fce7783c38..88dc01d5845 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -1007,13 +1007,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe var set_party_account = function(set_pricing) { if (["Sales Invoice", "Purchase Invoice"].includes(me.frm.doc.doctype)) { - if(me.frm.doc.doctype=="Sales Invoice") { - var party_type = "Customer"; - var party_account_field = 'debit_to'; - } else { - var party_type = "Supplier"; - var party_account_field = 'credit_to'; - } + let party_type = me.frm.doc.doctype == "Sales Invoice" ? "Customer" : "Supplier"; + let party_account_field = me.frm.doc.doctype == "Sales Invoice" ? "debit_to" : "credit_to"; var party = me.frm.doc[frappe.model.scrub(party_type)]; if(party && me.frm.doc.company && (!me.frm.doc.__onload?.load_after_mapping || !me.frm.doc[party_account_field])) { @@ -1427,7 +1422,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe let first_row = this.frm.doc.items[0]; if (!first_row) { return false - }; + } let mapped_rows = mappped_fields.filter(d => first_row[d]) @@ -1599,7 +1594,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe this.frm.set_currency_labels(["operating_cost", "hour_rate"], this.frm.doc.currency, "operations"); this.frm.set_currency_labels(["base_operating_cost", "base_hour_rate"], company_currency, "operations"); - var item_grid = this.frm.fields_dict["operations"].grid; + let item_grid = this.frm.fields_dict["operations"].grid; $.each(["base_operating_cost", "base_hour_rate"], function(i, fname) { if(frappe.meta.get_docfield(item_grid.doctype, fname)) item_grid.set_column_disp(fname, me.frm.doc.currency != company_currency); @@ -1610,7 +1605,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe this.frm.set_currency_labels(["rate", "amount"], this.frm.doc.currency, "scrap_items"); this.frm.set_currency_labels(["base_rate", "base_amount"], company_currency, "scrap_items"); - var item_grid = this.frm.fields_dict["scrap_items"].grid; + let item_grid = this.frm.fields_dict["scrap_items"].grid; $.each(["base_rate", "base_amount"], function(i, fname) { if(frappe.meta.get_docfield(item_grid.doctype, fname)) item_grid.set_column_disp(fname, me.frm.doc.currency != company_currency); @@ -2005,7 +2000,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe row_to_modify[key] = pr_row[key]; } - if (this.frm.doc.hasOwnProperty("is_pos") && this.frm.doc.is_pos) { + if (Object.prototype.hasOwnProperty.call(this.frm.doc, "is_pos") && this.frm.doc.is_pos) { let r = await frappe.db.get_value("POS Profile", this.frm.doc.pos_profile, "cost_center"); if (r.message.cost_center) { row_to_modify["cost_center"] = r.message.cost_center; @@ -2237,8 +2232,12 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe }, callback: function(r) { if (!r.exc) { - $.each(me.frm.doc.items || [], function(i, item) { - if (item.name && r.message.hasOwnProperty(item.name) && r.message[item.name].item_tax_template) { + $.each(me.frm.doc.items || [], function (i, item) { + if ( + item.name && + Object.prototype.hasOwnProperty.call(r.message, item.name) && + r.message[item.name].item_tax_template + ) { item.item_tax_template = r.message[item.name].item_tax_template; item.item_tax_rate = r.message[item.name].item_tax_rate; me.add_taxes_from_item_tax_template(item.item_tax_rate); From 257b4225ecfe531cc88c5b4a0c41c17d9045f482 Mon Sep 17 00:00:00 2001 From: Diptanil Saha Date: Sat, 27 Jun 2026 02:34:24 +0530 Subject: [PATCH 17/40] ci: separate job for semgrep (#56580) --- .github/workflows/linters.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index f55d6bf844e..3b9d953db1c 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -20,6 +20,18 @@ jobs: - name: Install and Run Pre-commit uses: pre-commit/action@v3.0.1 + semgrep: + name: semgrep + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Python 3.10 + uses: actions/setup-python@v4 + with: + python-version: '3.10' + cache: pip + - name: Download Semgrep rules run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules From afddf709053183030a3e26bd27e00531b3f0939e Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 02:37:22 +0530 Subject: [PATCH 18/40] fix(lead): added missing read permission check on `get_lead_details` (backport #56272) (#56273) Co-authored-by: Diptanil Saha fix(lead): added missing read permission check on `get_lead_details` (#56272) --- erpnext/crm/doctype/lead/lead.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index 94e99a612e8..42b82395719 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -438,6 +438,7 @@ def get_lead_details(lead, posting_date=None, company=None, doctype=None): out = frappe._dict() lead_doc = frappe.get_doc("Lead", lead) + lead_doc.check_permission() lead = lead_doc out.update( From 54fcd09995469f89e114402eebbbe95455f0468b Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:04:38 +0530 Subject: [PATCH 19/40] fix: do not allow closing the accounting period for future dates (backport #56551) (#56576) fix: do not allow closing the accounting period for future dates (#56551) (cherry picked from commit 5e60e4faa7a7a2e5238746275995fc1bb7625a48) Co-authored-by: rohitwaghchaure --- .../doctype/accounting_period/accounting_period.py | 13 +++++++++++++ .../accounting_period/test_accounting_period.py | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/accounting_period/accounting_period.py b/erpnext/accounts/doctype/accounting_period/accounting_period.py index 300d216618e..426a4d57064 100644 --- a/erpnext/accounts/doctype/accounting_period/accounting_period.py +++ b/erpnext/accounts/doctype/accounting_period/accounting_period.py @@ -5,6 +5,7 @@ import frappe from frappe import _ from frappe.model.document import Document +from frappe.utils import getdate, nowdate class OverlapError(frappe.ValidationError): @@ -34,8 +35,20 @@ class AccountingPeriod(Document): # end: auto-generated types def validate(self): + self.validate_dates() self.validate_overlap() + def validate_dates(self): + if getdate(self.start_date) > getdate(self.end_date): + frappe.throw(_("Start Date cannot be after End Date")) + + if getdate(self.end_date) > getdate(nowdate()): + frappe.throw( + _( + "Accounting Period cannot be created for a future date. End Date {0} is after today." + ).format(frappe.bold(frappe.format(self.end_date, "Date"))) + ) + def before_insert(self): self.bootstrap_doctypes_for_closing() diff --git a/erpnext/accounts/doctype/accounting_period/test_accounting_period.py b/erpnext/accounts/doctype/accounting_period/test_accounting_period.py index 16cae9683f9..671a28e3956 100644 --- a/erpnext/accounts/doctype/accounting_period/test_accounting_period.py +++ b/erpnext/accounts/doctype/accounting_period/test_accounting_period.py @@ -4,7 +4,7 @@ import unittest import frappe -from frappe.utils import add_months, nowdate +from frappe.utils import nowdate from erpnext.accounts.doctype.accounting_period.accounting_period import ( ClosedAccountingPeriod, @@ -47,7 +47,7 @@ def create_accounting_period(**args): accounting_period = frappe.new_doc("Accounting Period") accounting_period.start_date = args.start_date or nowdate() - accounting_period.end_date = args.end_date or add_months(nowdate(), 1) + accounting_period.end_date = args.end_date or nowdate() accounting_period.company = args.company or "_Test Company" accounting_period.period_name = args.period_name or "_Test_Period_Name_1" accounting_period.append("closed_documents", {"document_type": "Sales Invoice", "closed": 1}) From d992f6f5c8ca5676d7fc9365729a195fa50d22ac Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 11:13:07 +0000 Subject: [PATCH 20/40] chore(crm_settings): remove unused `delete_custom_fields` import (backport #56558) (#56582) Co-authored-by: Diptanil Saha --- erpnext/crm/doctype/crm_settings/crm_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/crm/doctype/crm_settings/crm_settings.py b/erpnext/crm/doctype/crm_settings/crm_settings.py index 04e5a402add..7ca341adb77 100644 --- a/erpnext/crm/doctype/crm_settings/crm_settings.py +++ b/erpnext/crm/doctype/crm_settings/crm_settings.py @@ -3,7 +3,7 @@ import frappe from frappe import _ -from frappe.custom.doctype.custom_field.custom_field import create_custom_fields, delete_custom_fields +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields from frappe.model.document import Document From d3df0bf387c452d831e2d2cd7acf7074380307a3 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Sat, 27 Jun 2026 23:20:11 +0530 Subject: [PATCH 21/40] fix: reserve projected stock for production plan based on BOM qty --- .../doctype/production_plan/production_plan.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 67323d42d40..cb8f24fc9f1 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -9,6 +9,7 @@ from collections import defaultdict import frappe from frappe import _, msgprint from frappe.model.document import Document +from frappe.query_builder import Case from frappe.query_builder.functions import IfNull, Sum from frappe.utils import ( add_days, @@ -1375,7 +1376,7 @@ def get_material_request_items( get_conversion_factor(row.item_code, item_details.purchase_uom).get("conversion_factor") or 1.0 ) - if required_qty > 0: + if flt(row.get("qty")) > 0: return { "item_code": row.item_code, "item_name": row.item_name, @@ -1880,7 +1881,12 @@ def get_reserved_qty_for_production_plan(item_code, warehouse): frappe.qb.from_(table) .inner_join(child) .on(table.name == child.parent) - .select(Sum(child.quantity * child.conversion_factor)) + .select( + Sum( + (Case().when(child.quantity == 0, child.required_bom_qty).else_(child.quantity)) + * child.conversion_factor + ) + ) .where( (table.docstatus == 1) & (child.item_code == item_code) From b39024e0e3380c67e9c2a48d4b77dbeb3c6adc32 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Sat, 27 Jun 2026 23:20:11 +0530 Subject: [PATCH 22/40] test: update cascading test to assert zero-qty reservation for stock-covered items --- .../production_plan/test_production_plan.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 5af1fdb36b5..e64a8c16b84 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -212,13 +212,15 @@ class TestProductionPlan(FrappeTestCase): quantities = [d["quantity"] for d in mr_items] rm_qty = sum(quantities) - # 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)") + # 3 MR items: SO1's requirement is covered by stock (qty=0 but reserved), SO2 and SO3 need 1 each + self.assertEqual(len(mr_items), 3) + self.assertEqual( + rm_qty, 2, "Cascading failed: total purchase qty should be 2 (3 needed - 1 in stock)" + ) self.assertEqual( quantities, - [1, 1], - "Cascading failed: only second and third SO should need procurement (qty=1) since first SO consumed stock", + [0, 1, 1], + "SO1 stock-covered item should appear with qty=0 for reservation; SO2 and SO3 need qty=1", ) sr.cancel() @@ -251,11 +253,13 @@ class TestProductionPlan(FrappeTestCase): pln = create_production_plan( item_code="Test Production Item 1", use_multi_level_bom=0, ignore_existing_ordered_qty=0 ) - self.assertFalse(len(pln.mr_items)) + items_needing_purchase = [row.item_code for row in pln.mr_items if row.quantity > 0] + self.assertFalse(len(items_needing_purchase)) + + pln.cancel() sr1.cancel() sr2.cancel() - pln.cancel() def test_production_plan_sales_orders(self): "Test if previously fulfilled SO (with WO) is pulled into Prod Plan." From bba7fe98313c44c1478d5dca72aadc9ba555aa7e Mon Sep 17 00:00:00 2001 From: pandiyan Date: Wed, 24 Jun 2026 18:43:33 +0530 Subject: [PATCH 23/40] fix: skip qty over-allowance check for non-stock items only --- erpnext/controllers/status_updater.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 21e6d3ea8b9..50d272d7efb 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -275,6 +275,12 @@ class StatusUpdater(Document): item["idx"] = d.idx item["target_ref_field"] = args["target_ref_field"].replace("_", " ") + # skip qty over-allowance check for non-stock items + if "qty" in args.get("target_ref_field", "") and not frappe.get_cached_value( + "Item", item["item_code"], "is_stock_item" + ): + continue + # if not item[args['target_ref_field']]: # msgprint(_("Note: System will not check over-delivery and over-booking for Item {0} as quantity or amount is 0").format(item.item_code)) if args.get("no_allowance"): From 3f00a0ffa9c03e891643f17b86a9ca9182c6e7f6 Mon Sep 17 00:00:00 2001 From: pandiyan Date: Wed, 24 Jun 2026 18:43:39 +0530 Subject: [PATCH 24/40] test: add tests for non stock item over billing against so/po --- .../purchase_invoice/test_purchase_invoice.py | 18 ++++++++ .../sales_invoice/test_sales_invoice.py | 45 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index bccd29c822a..5aa2faed1a1 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -2924,6 +2924,24 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin): # Test 4 - Since this PI is overbilled by 130% and only 120% is allowed, it will fail self.assertRaises(frappe.ValidationError, pi.submit) + @change_settings("Accounts Settings", {"over_billing_allowance": 0}) + def test_non_stock_item_over_billing_against_po_is_blocked(self): + service_item = create_item( + "_Test Service Item Non Stock PI", + is_stock_item=0, + is_purchase_item=1, + ).name + + po = create_purchase_order(item_code=service_item, qty=5, rate=100, do_not_save=False) + po.submit() + + pi = make_pi_from_po(po.name) + pi.items[0].qty = 10 # overbill by 100 % + pi.save() + + with self.assertRaises(frappe.ValidationError): + pi.submit() + def test_discount_percentage_not_set_when_amount_is_manually_set(self): pi = make_purchase_invoice(do_not_save=True) discount_amount = 7 diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 6f51e27f532..0b1f1e922bf 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -3700,6 +3700,51 @@ class TestSalesInvoice(FrappeTestCase): self.assertTrue("cannot overbill" in str(err.exception).lower()) dn.cancel() + @change_settings("Accounts Settings", {"over_billing_allowance": 0}) + def test_non_stock_item_over_billing_against_so_is_blocked(self): + from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so + from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + + service_item = create_item( + "_Test Service Item Non Stock SI", + is_stock_item=0, + ).name + + so = make_sales_order(item_code=service_item, qty=5, rate=100) + so.submit() + + si = make_si_from_so(so.name) + si.items[0].qty = 10 # overbill by 100 % + si.save() + + with self.assertRaises(frappe.ValidationError): + si.submit() + + @change_settings("Accounts Settings", {"over_billing_allowance": 0}) + def test_non_stock_item_over_billing_against_so_from_quotation_is_blocked(self): + from erpnext.selling.doctype.quotation.quotation import make_sales_order as make_so_from_quotation + from erpnext.selling.doctype.quotation.test_quotation import make_quotation + from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so + + service_item = create_item( + "_Test Service Item Non Stock SI Quot", + is_stock_item=0, + ).name + + quotation = make_quotation(item_code=service_item, qty=5, rate=100) + + so = make_so_from_quotation(quotation.name) + so.delivery_date = frappe.utils.add_days(frappe.utils.today(), 7) + so.insert() + so.submit() + + si = make_si_from_so(so.name) + si.items[0].qty = 10 # overbill by 100 % + si.save() + + with self.assertRaises(frappe.ValidationError): + si.submit() + @change_settings( "Accounts Settings", { From 562563553c3dafc5f5e97c3995db0ac22dcd1fab Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:16:40 +0530 Subject: [PATCH 25/40] fix: sync Stock Reconciliation difference amount with GL after reposting (backport #56574) (#56584) * fix: sync Stock Reconciliation difference amount with GL after reposting (#56574) * fix: sync Stock Reconciliation difference amount with GL after reposting * fix: placement of recalculate differece amount function (cherry picked from commit c7ef42ef98b9e1da549f67e73291fb25431aa46e) # Conflicts: # erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py # erpnext/stock/stock_ledger.py * chore: fix conflicts Removed unused imports and fixed import structure. * chore: fix conflicts Refactor update_rate_on_stock_reconciliation method to use recalculation from ledger instead of manual updates for stock reconciliation items. * chore: fix conflicts --------- Co-authored-by: rohitwaghchaure --- .../stock_reconciliation.py | 113 ++++++++++++ .../test_stock_reconciliation.py | 166 ++++++++++++++++++ erpnext/stock/stock_ledger.py | 45 ++--- 3 files changed, 291 insertions(+), 33 deletions(-) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 67f9b57c172..ab1358e8293 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -993,6 +993,102 @@ class StockReconciliation(StockController): d.quantity_difference = flt(d.qty) - flt(d.current_qty) d.amount_difference = flt(d.amount) - flt(d.current_amount) + def recalculate_difference_amount_from_ledger(self): + """Sync the displayed current qty/rate and difference amount with the (reposted) ledger. + + Submitted reconciliations freeze ``difference_amount`` and the per-row current values at + submit time, but reposting/backdated transactions recompute the reconciliation's Stock Ledger + Entries and rebuild the GL from them. Without this sync the document keeps showing stale figures + that no longer match the GL entries. Anchoring ``amount_difference`` to the row's summed + ``stock_value_difference`` keeps the document and the GL consistent by construction. + """ + difference_amount = 0.0 + + for row in self.items: + stock_value_difference = flt(get_row_stock_value_difference(self.doctype, self.name, row.name)) + + amount = flt(flt(row.qty) * flt(row.valuation_rate), row.precision("amount")) + amount_difference = flt(stock_value_difference, row.precision("amount_difference")) + current_amount = flt(amount - amount_difference, row.precision("current_amount")) + + current_qty = self.get_current_qty_from_ledger(row) + current_valuation_rate = ( + flt(current_amount / current_qty, row.precision("current_valuation_rate")) + if current_qty + else 0.0 + ) + + row.db_set( + { + "amount": amount, + "current_qty": current_qty, + "current_valuation_rate": current_valuation_rate, + "current_amount": current_amount, + "quantity_difference": flt(row.qty) - current_qty, + "amount_difference": amount_difference, + }, + update_modified=False, + ) + + difference_amount += amount_difference + + self.db_set( + "difference_amount", + flt(difference_amount, self.precision("difference_amount")), + update_modified=False, + ) + + def get_current_qty_from_ledger(self, row): + """Current (pre-reconciliation) qty for a row, recomputed from the ledger after reposting. + + Serial/batch rows cannot have backdated qty changes inserted before a future reconciliation + (blocked by ``check_future_entries_exists``), so their current qty is frozen and read straight + from the current bundle. Non-serial rows can float, so read the ledger balance just before the + reconciliation, excluding the reconciliation's own entries. + """ + if row.current_serial_and_batch_bundle: + total_qty = frappe.db.get_value( + "Serial and Batch Bundle", row.current_serial_and_batch_bundle, "total_qty" + ) + return abs(flt(total_qty, row.precision("current_qty"))) + + reco_sle = frappe.db.get_value( + "Stock Ledger Entry", + { + "voucher_type": self.doctype, + "voucher_no": self.name, + "voucher_detail_no": row.name, + "is_cancelled": 0, + }, + ["posting_datetime", "creation"], + as_dict=True, + ) + if not reco_sle: + return flt(row.current_qty, row.precision("current_qty")) + + sle = frappe.qb.DocType("Stock Ledger Entry") + previous_sle = ( + frappe.qb.from_(sle) + .select(sle.qty_after_transaction) + .where( + (sle.item_code == row.item_code) + & (sle.warehouse == row.warehouse) + & (sle.is_cancelled == 0) + & ( + (sle.posting_datetime < reco_sle.posting_datetime) + | ( + (sle.posting_datetime == reco_sle.posting_datetime) + & (sle.creation < reco_sle.creation) + ) + ) + ) + .orderby(sle.posting_datetime, order=frappe.qb.desc) + .orderby(sle.creation, order=frappe.qb.desc) + .limit(1) + ).run() + + return flt(previous_sle[0][0], row.precision("current_qty")) if previous_sle else 0.0 + def submit(self): if len(self.items) > 100: msgprint( @@ -1179,6 +1275,23 @@ def get_itemwise_batch(warehouse, posting_date, company, item_code=None): return itemwise_batch_data +def get_row_stock_value_difference(voucher_type: str, voucher_no: str, voucher_detail_no: str): + """Net stock value change posted to the GL by a reconciliation row (sum of its SLEs).""" + sle = frappe.qb.DocType("Stock Ledger Entry") + result = ( + frappe.qb.from_(sle) + .select(Sum(sle.stock_value_difference)) + .where( + (sle.voucher_type == voucher_type) + & (sle.voucher_no == voucher_no) + & (sle.voucher_detail_no == voucher_detail_no) + & (sle.is_cancelled == 0) + ) + ).run() + + return flt(result[0][0]) if result and result[0][0] else 0.0 + + @frappe.whitelist() def get_stock_balance_for( item_code: str, diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 795ea870cf1..11d7850913e 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -782,6 +782,172 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin): sr1.load_from_db() self.assertEqual(sr1.difference_amount, 10000) + def assert_reco_difference_matches_gl(self, reco_name): + """The displayed Difference Amount (doc and per-row) must equal the reposted GL impact, + i.e. the sum of the reconciliation's Stock Ledger Entry ``stock_value_difference``.""" + from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( + get_row_stock_value_difference, + ) + + reco = frappe.get_doc("Stock Reconciliation", reco_name) + total_difference = 0.0 + + for row in reco.items: + row_difference = flt( + get_row_stock_value_difference("Stock Reconciliation", reco_name, row.name), + row.precision("amount_difference"), + ) + + self.assertEqual(flt(row.amount_difference), row_difference) + total_difference += row_difference + + self.assertEqual( + flt(reco.difference_amount, reco.precision("difference_amount")), + flt(total_difference, reco.precision("difference_amount")), + ) + + def test_difference_amount_synced_with_gl_after_repost_non_serialized(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item_code = self.make_item().name + warehouse = "_Test Warehouse - _TC" + + # Opening stock => 100 * 100 = 10000 + make_stock_entry( + item_code=item_code, + target=warehouse, + qty=100, + basic_rate=100, + posting_date=add_days(nowdate(), -5), + posting_time="10:00:00", + ) + + # Reconcile to 100 @ 200 => difference 20000 - 10000 = 10000 + reco = create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=100, + rate=200, + posting_date=add_days(nowdate(), -2), + ) + self.assertEqual(reco.difference_amount, 10000) + self.assert_reco_difference_matches_gl(reco.name) + + # Backdated reconciliation lowers the pre-reco stock value to 50 * 50 = 2500 + create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=50, + rate=50, + posting_date=add_days(nowdate(), -3), + ) + + reco.load_from_db() + # Current is now 2500 => difference 20000 - 2500 = 17500 + self.assertEqual(reco.difference_amount, 17500) + self.assert_reco_difference_matches_gl(reco.name) + + def test_difference_amount_synced_with_gl_after_repost_batched(self): + from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import ( + make_landed_cost_voucher, + ) + + item_code = self.make_item( + "Test Batch Item Reco Difference Sync", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TEST-BATCH-DIFFSYNC-.###", + }, + ).name + warehouse = "_Test Warehouse - _TC" + + # Receive 10 @ 100 (batch value 1000) + pr = make_purchase_receipt( + item_code=item_code, + warehouse=warehouse, + qty=10, + rate=100, + posting_date=add_days(nowdate(), -5), + ) + batch_no = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle) + + # Reconcile the batch to 10 @ 500 => difference 5000 - 1000 = 4000 + reco = create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=10, + rate=500, + batch_no=batch_no, + use_serial_batch_fields=1, + posting_date=add_days(nowdate(), -2), + ) + difference_on_submit = reco.difference_amount + self.assert_reco_difference_matches_gl(reco.name) + + # Landed cost retroactively raises the receipt (and batch) valuation, reposting the reco + make_landed_cost_voucher( + receipt_document_type="Purchase Receipt", + receipt_document=pr.name, + charges=1000, + company="_Test Company", + ) + + reco.load_from_db() + self.assertNotEqual(reco.difference_amount, difference_on_submit) + self.assert_reco_difference_matches_gl(reco.name) + + def test_difference_amount_synced_with_gl_after_repost_serialized(self): + from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import ( + make_landed_cost_voucher, + ) + + item_code = self.make_item( + "Test Serial Item Reco Difference Sync", + { + "is_stock_item": 1, + "has_serial_no": 1, + "serial_no_series": "TSIRDS.####", + }, + ).name + warehouse = "_Test Warehouse - _TC" + + # Receive 5 serial nos @ 100 (value 500) + pr = make_purchase_receipt( + item_code=item_code, + warehouse=warehouse, + qty=5, + rate=100, + posting_date=add_days(nowdate(), -5), + ) + serial_nos = get_serial_nos_from_bundle(pr.items[0].serial_and_batch_bundle) + + # Reconcile the serial nos to 5 @ 500 => difference 2500 - 500 = 2000 + reco = create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=5, + rate=500, + serial_no="\n".join(serial_nos), + use_serial_batch_fields=1, + posting_date=add_days(nowdate(), -2), + ) + difference_on_submit = reco.difference_amount + self.assert_reco_difference_matches_gl(reco.name) + + # Landed cost retroactively raises the receipt (and serial) valuation, reposting the reco + make_landed_cost_voucher( + receipt_document_type="Purchase Receipt", + receipt_document=pr.name, + charges=1000, + company="_Test Company", + ) + + reco.load_from_db() + self.assertNotEqual(reco.difference_amount, difference_on_submit) + self.assert_reco_difference_matches_gl(reco.name) + def test_make_stock_zero_for_serial_batch_item(self): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 9ee458ce9c7..26dcb5f024b 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1329,6 +1329,11 @@ class update_entries_after: Update outgoing rate in Stock Entry, Delivery Note, Sales Invoice and Sales Return In case of Stock Entry, also calculate FG Item rate and total incoming/outgoing amount """ + if sle.voucher_type == "Stock Reconciliation": + if flt(sle.actual_qty) <= 0 and not self.args.get("sle_id"): + self.update_rate_on_stock_reconciliation(sle) + return + if sle.actual_qty and sle.voucher_detail_no: outgoing_rate = abs(flt(sle.stock_value_difference)) / abs(sle.actual_qty) @@ -1340,8 +1345,6 @@ class update_entries_after: self.update_rate_on_purchase_receipt(sle, outgoing_rate) elif flt(sle.actual_qty) < 0 and sle.voucher_type == "Subcontracting Receipt": self.update_rate_on_subcontracting_receipt(sle, outgoing_rate) - elif sle.voucher_type == "Stock Reconciliation": - self.update_rate_on_stock_reconciliation(sle) def update_rate_on_stock_entry(self, sle, outgoing_rate): frappe.db.set_value("Stock Entry Detail", sle.voucher_detail_no, "basic_rate", outgoing_rate) @@ -1435,37 +1438,13 @@ class update_entries_after: d.db_update() def update_rate_on_stock_reconciliation(self, sle): - if not sle.serial_no and not sle.batch_no: - sr = frappe.get_doc("Stock Reconciliation", sle.voucher_no, for_update=True) - - for item in sr.items: - # Skip for Serial and Batch Items - if item.name != sle.voucher_detail_no or item.serial_no or item.batch_no: - continue - - previous_sle = get_previous_sle( - { - "item_code": item.item_code, - "warehouse": item.warehouse, - "posting_date": sr.posting_date, - "posting_time": sr.posting_time, - "sle": sle.name, - } - ) - - item.current_qty = previous_sle.get("qty_after_transaction") or 0.0 - item.current_valuation_rate = previous_sle.get("valuation_rate") or 0.0 - item.current_amount = flt(item.current_qty) * flt(item.current_valuation_rate) - - item.amount = flt(item.qty) * flt(item.valuation_rate) - item.quantity_difference = item.qty - item.current_qty - item.amount_difference = item.amount - item.current_amount - else: - sr.difference_amount = sum([item.amount_difference for item in sr.items]) - sr.db_update() - - for item in sr.items: - item.db_update() + # Refresh the reconciliation's difference amount and per-row current qty/rate from the reposted + # ledger so the document keeps matching the GL entries. Handles serialized, batched and + # non-serialized items uniformly (the document method reads the current bundle for serial/batch + # rows and the pre-reconciliation ledger balance for non-serial rows). + frappe.get_lazy_doc( + "Stock Reconciliation", sle.voucher_no, for_update=True + ).recalculate_difference_amount_from_ledger() def get_incoming_value_for_serial_nos(self, sle, serial_nos): # get rate from serial nos within same company From 8abc27863ab0653a3af9c02dd034c2049b084d39 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 29 Jun 2026 15:06:21 +0530 Subject: [PATCH 26/40] fix: extract stock ageing data helper in stock balance (backport #55774) The v15 backport (#55776) of #55774 dropped the stock_balance.py changes. A separate backport left an inline version that sorts/filters the FIFO queue by itemgetter(1) *before* normalizing, so for batchwise valuation slots itemgetter(1) reads the count flag instead of the posting date. The queue is then ordered wrong and earliest_age/latest_age come out swapped. Extract get_stock_ageing_data, which normalizes before sorting/filtering so itemgetter(1) is always the posting date, and add the regression test. Co-Authored-By: Claude Opus 4.8 --- .../report/stock_balance/stock_balance.py | 28 +++++++++++-------- .../stock_balance/test_stock_balance.py | 18 +++++++++++- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index 6bceb0483f8..2c25b2c5b2b 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -100,8 +100,6 @@ class StockBalanceReport: self.filters["show_warehouse_wise_stock"] = True item_wise_fifo_queue = FIFOSlots(self.filters, self.sle_entries).generate() - _func = itemgetter(1) - del self.sle_entries sre_details = self.get_sre_reserved_qty_details() @@ -126,16 +124,7 @@ class StockBalanceReport: stock_ageing_data = {"average_age": 0, "earliest_age": 0, "latest_age": 0} if opening_fifo_queue: - fifo_queue = sorted(filter(_func, opening_fifo_queue), key=_func) - fifo_queue = normalize_fifo_queue(fifo_queue) - if not fifo_queue: - continue - - to_date = self.to_date - stock_ageing_data["average_age"] = get_average_age(fifo_queue, to_date) - stock_ageing_data["earliest_age"] = date_diff(to_date, fifo_queue[0][1]) - stock_ageing_data["latest_age"] = date_diff(to_date, fifo_queue[-1][1]) - stock_ageing_data["fifo_queue"] = fifo_queue + stock_ageing_data.update(get_stock_ageing_data(opening_fifo_queue, self.to_date)) report_data.update(stock_ageing_data) @@ -694,6 +683,21 @@ class StockBalanceReport: return opening_fifo_queue +def get_stock_ageing_data(fifo_queue: list, to_date: str) -> dict: + stock_ageing_data = {"average_age": 0, "earliest_age": 0, "latest_age": 0} + fifo_queue = sorted(filter(itemgetter(1), normalize_fifo_queue(fifo_queue)), key=itemgetter(1)) + + if not fifo_queue: + return stock_ageing_data + + stock_ageing_data["average_age"] = get_average_age(fifo_queue, to_date) + stock_ageing_data["earliest_age"] = date_diff(to_date, fifo_queue[0][1]) + stock_ageing_data["latest_age"] = date_diff(to_date, fifo_queue[-1][1]) + stock_ageing_data["fifo_queue"] = fifo_queue + + return stock_ageing_data + + def filter_items_with_no_transactions( iwb_map, float_precision: float, inventory_dimensions: list | None = None ): diff --git a/erpnext/stock/report/stock_balance/test_stock_balance.py b/erpnext/stock/report/stock_balance/test_stock_balance.py index 0985e4783c3..347c14c7eb0 100644 --- a/erpnext/stock/report/stock_balance/test_stock_balance.py +++ b/erpnext/stock/report/stock_balance/test_stock_balance.py @@ -7,7 +7,7 @@ from frappe.utils import today from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry -from erpnext.stock.report.stock_balance.stock_balance import execute +from erpnext.stock.report.stock_balance.stock_balance import execute, get_stock_ageing_data def stock_balance(filters): @@ -168,3 +168,19 @@ class TestStockBalance(FrappeTestCase): rows = stock_balance(self.filters.update({"show_variant_attributes": 1, "item_code": [variant.name]})) self.assertPartialDictEq(attributes, rows[0]) self.assertInvariants(rows) + + def test_stock_ageing_data_accepts_batchwise_valuation_slots(self): + fifo_queue = [ + ["SA-BATCH-NEWER", 1, 2.0, "2021-12-05", 20.0], + ["SA-BATCH-OLDER", 1, 3.0, "2021-12-01", 30.0], + ] + + stock_ageing_data = get_stock_ageing_data(fifo_queue, "2021-12-10") + + self.assertEqual(stock_ageing_data["average_age"], 7.4) + self.assertEqual(stock_ageing_data["earliest_age"], 9) + self.assertEqual(stock_ageing_data["latest_age"], 5) + self.assertEqual( + stock_ageing_data["fifo_queue"], + [[3.0, "2021-12-01", 30.0], [2.0, "2021-12-05", 20.0]], + ) From ab98d19f26f82f34f89a2dcddfa285e8c55cff74 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 29 Jun 2026 17:20:12 +0530 Subject: [PATCH 27/40] fix: negative stock issue (#56616) --- erpnext/stock/stock_ledger.py | 1 + 1 file changed, 1 insertion(+) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 26dcb5f024b..06159345577 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -134,6 +134,7 @@ def repost_current_voucher(args, allow_negative_stock=False, via_landed_cost_vou "sle_id": args.get("name"), "creation": args.get("creation"), "reserved_stock": args.get("reserved_stock"), + "cancelled": args.get("is_cancelled"), }, allow_negative_stock=allow_negative_stock, via_landed_cost_voucher=via_landed_cost_voucher, From f50e529f8ad6bcc267925fd550ab3f5baa9a4134 Mon Sep 17 00:00:00 2001 From: rohitwaghchaure Date: Mon, 29 Jun 2026 17:21:17 +0530 Subject: [PATCH 28/40] fix: update_qty_in_future_sle skips SLEs with same posting datetime (#56615) --- .../test_stock_ledger_entry.py | 101 ++++++++++++++++++ erpnext/stock/stock_ledger.py | 80 ++++++++------ 2 files changed, 149 insertions(+), 32 deletions(-) diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index 917aba9803e..ac3d4a01d11 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -1257,6 +1257,107 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin): self.assertEqual(sle[0].qty_after_transaction, 105) self.assertEqual(sle[0].actual_qty, 100) + def test_update_qty_in_future_sle_shifts_same_timestamp_later_entry(self): + # update_qty_in_future_sle treats "future" as strictly after the current entry in the + # (posting_datetime, creation) order. An entry sharing the exact posting timestamp but created + # later must still have its running balance shifted; comparing posting_datetime alone would skip + # it. The current entry itself (same timestamp, same creation) must not be shifted. + from erpnext.stock.stock_ledger import update_qty_in_future_sle + + item = make_item().name + warehouse = "_Test Warehouse - _TC" + + receipt1 = make_purchase_receipt( + item_code=item, + warehouse=warehouse, + qty=10, + rate=10, + posting_date="2021-01-01", + posting_time="02:00:00", + ) + time.sleep(1) + receipt2 = make_purchase_receipt( + item_code=item, + warehouse=warehouse, + qty=20, + rate=10, + posting_date="2021-01-01", + posting_time="02:00:00", # identical timestamp, later creation + ) + + def sle(voucher): + return frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": voucher.name, "is_cancelled": 0}, + ["name", "posting_date", "posting_time", "creation", "qty_after_transaction"], + as_dict=True, + ) + + sle1, sle2 = sle(receipt1), sle(receipt2) + self.assertEqual(sle1.qty_after_transaction, 10) + self.assertEqual(sle2.qty_after_transaction, 30) + + # Simulate a +5 qty shift originating at receipt1's ledger position. + args = frappe._dict( + { + "item_code": item, + "warehouse": warehouse, + "voucher_type": "Purchase Receipt", + "voucher_no": receipt1.name, + "posting_date": sle1.posting_date, + "posting_time": sle1.posting_time, + "creation": sle1.creation, + "actual_qty": 5, + } + ) + update_qty_in_future_sle(args, allow_negative_stock=True) + + # receipt2 (same timestamp, later creation) is shifted; receipt1 (the current entry) is not. + self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle2.name, "qty_after_transaction"), 35) + self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle1.name, "qty_after_transaction"), 10) + + def test_get_next_stock_reco_respects_creation_order(self): + # A stock reco sharing the exact posting timestamp of the current entry must only count as the + # "next" reco when it was created after that entry. A reco created before it actually precedes + # the entry and must not bound (truncate) the qty-shift range. + from erpnext.stock.stock_ledger import get_next_stock_reco + + item = make_item().name + warehouse = "_Test Warehouse - _TC" + + reco = create_stock_reconciliation( + item_code=item, + warehouse=warehouse, + qty=10, + rate=100, + posting_date="2021-01-01", + posting_time="02:00:00", + ) + reco_sle = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": reco.name, "is_cancelled": 0}, + ["posting_date", "posting_time", "creation"], + as_dict=True, + ) + + base_kwargs = { + "item_code": item, + "warehouse": warehouse, + "voucher_no": "SOME-OTHER-VOUCHER", + "posting_date": reco_sle.posting_date, + "posting_time": reco_sle.posting_time, + } + + # Current entry created AFTER the reco at the same timestamp -> reco precedes it -> not returned. + after = {**base_kwargs, "creation": add_to_date(reco_sle.creation, seconds=5)} + self.assertFalse(get_next_stock_reco(after)) + + # Current entry created BEFORE the reco at the same timestamp -> reco follows it -> returned. + before = {**base_kwargs, "creation": add_to_date(reco_sle.creation, seconds=-5)} + result = get_next_stock_reco(before) + self.assertTrue(result) + self.assertEqual(result[0].voucher_no, reco.name) + @change_settings("System Settings", {"float_precision": 3, "currency_precision": 2}) def test_transfer_invariants(self): """Extact stock value should be transferred.""" diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 06159345577..0d83119a0fc 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -2042,36 +2042,44 @@ def get_valuation_rate( def update_qty_in_future_sle(args, allow_negative_stock=False): """Recalculate Qty after Transaction in future SLEs based on current SLE.""" - datetime_limit_condition = "" qty_shift = args.actual_qty - args["posting_datetime"] = get_combine_datetime(args["posting_date"], args["posting_time"]) + posting_datetime = get_combine_datetime(args["posting_date"], args["posting_time"]) + args["posting_datetime"] = posting_datetime # find difference/shift in qty caused by stock reconciliation if args.voucher_type == "Stock Reconciliation": qty_shift = get_stock_reco_qty_shift(args) + sle = frappe.qb.DocType("Stock Ledger Entry") + + # SLEs are ordered by (posting_datetime, creation). "Future" therefore means strictly after the + # current entry in that tuple order: a later posting_datetime, or the same posting_datetime with a + # later creation. Comparing posting_datetime alone would skip same-timestamp entries created after + # this one (e.g. the same item repeated in a voucher, or another voucher posted in the same second). + future_condition = sle.posting_datetime > posting_datetime + if args.get("creation"): + future_condition = future_condition | ( + (sle.posting_datetime == posting_datetime) & (sle.creation > args.get("creation")) + ) + + query = ( + frappe.qb.update(sle) + .set(sle.qty_after_transaction, sle.qty_after_transaction + qty_shift) + .where( + (sle.item_code == args.get("item_code")) + & (sle.warehouse == args.get("warehouse")) + & (sle.is_cancelled == 0) + & future_condition + ) + ) + # find the next nearest stock reco so that we only recalculate SLEs till that point next_stock_reco_detail = get_next_stock_reco(args) if next_stock_reco_detail: - detail = next_stock_reco_detail[0] - datetime_limit_condition = get_datetime_limit_condition(detail) + query = query.where(get_datetime_limit_condition(sle, next_stock_reco_detail[0])) - frappe.db.sql( # nosemgrep - f""" - update `tabStock Ledger Entry` - set qty_after_transaction = qty_after_transaction + {qty_shift} - where - item_code = %(item_code)s - and warehouse = %(warehouse)s - and is_cancelled = 0 - and ( - posting_datetime > %(posting_datetime)s - ) - {datetime_limit_condition} - """, - args, - ) + query.run() validate_negative_qty_in_future_sle(args, allow_negative_stock) @@ -2106,6 +2114,22 @@ def get_stock_reco_qty_shift(args): return stock_reco_qty_shift +def get_next_reco_datetime_condition(sle, kwargs): + """Match stock recos that come strictly after the current entry in (posting_datetime, creation) + order. Using posting_datetime alone (>=) could pick a reco sharing this exact timestamp but created + earlier — i.e. one that actually precedes this entry — and wrongly truncate the qty-shift range.""" + current_datetime = get_combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time")) + + creation = kwargs.get("creation") + if not creation: + # No creation tiebreaker available; fall back to the posting_datetime-only bound. + return sle.posting_datetime >= current_datetime + + return (sle.posting_datetime > current_datetime) | ( + (sle.posting_datetime == current_datetime) & (sle.creation > creation) + ) + + def get_next_stock_reco(kwargs): """Returns next nearest stock reconciliaton's details.""" @@ -2131,10 +2155,7 @@ def get_next_stock_reco(kwargs): & (sle.voucher_type == "Stock Reconciliation") & (sle.voucher_no != kwargs.get("voucher_no")) & (sle.is_cancelled == 0) - & ( - sle.posting_datetime - >= get_combine_datetime(kwargs.get("posting_date"), kwargs.get("posting_time")) - ) + & get_next_reco_datetime_condition(sle, kwargs) ) .orderby(sle.posting_datetime) .orderby(sle.creation) @@ -2147,17 +2168,12 @@ def get_next_stock_reco(kwargs): return query.run(as_dict=True) -def get_datetime_limit_condition(detail): +def get_datetime_limit_condition(sle, detail): posting_datetime = get_combine_datetime(detail.posting_date, detail.posting_time) - return f""" - and - (posting_datetime < '{posting_datetime}' - or ( - posting_datetime = '{posting_datetime}' - and creation < '{detail.creation}' - ) - )""" + return (sle.posting_datetime < posting_datetime) | ( + (sle.posting_datetime == posting_datetime) & (sle.creation < detail.creation) + ) def validate_negative_qty_in_future_sle(args, allow_negative_stock=False): From 52d04ad8341fa5653de593feb7c0dbb4f1ecf6b5 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:04:44 +0530 Subject: [PATCH 29/40] fix: Use correct doctype name for PCV perm-check (backport #56606) (#56610) fix: Use correct doctype name for PCV perm-check (#56606) closes https://github.com/frappe/erpnext/issues/56593 (cherry picked from commit 747374e767a1b790d1cd2d83cab8ae3bdb3580ae) Co-authored-by: Ankush Menat --- .../process_period_closing_voucher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py index 096b085cf0e..b5ca3331b71 100644 --- a/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py +++ b/erpnext/accounts/doctype/process_period_closing_voucher/process_period_closing_voucher.py @@ -89,7 +89,7 @@ class ProcessPeriodClosingVoucher(Document): @frappe.whitelist() def start_pcv_processing(docname: str): if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]: - frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True) + frappe.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True) frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running") timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600 From 83e8d1eb2f74c34e555b149c61f2e660387601a9 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 23:12:20 +0530 Subject: [PATCH 30/40] fix: carry item-level project to Purchase Receipt GL entries (backport #56568) (#56618) fix: carry item-level project to Purchase Receipt GL entries (#56568) Purchase Receipt stock and asset GL lines used the item row's cost center but always fell back to the document-level project, unlike Purchase Invoice which uses the item-level project. add_gl_entry accepted a project argument but never wrote it to the GL dict, so the inward, Stock Received But Not Billed, landed cost, divisional loss, sub-contracting and exchange rate lines dropped the row's project. Write project into the GL dict and pass project=item.project on the entries that were missing it, so project behaves like cost center and matches Purchase Invoice. Ticket: 72523 (cherry picked from commit 6f97c7199c4f65e5e2fda31d99905077f2d2a2bc) Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com> --- erpnext/controllers/stock_controller.py | 3 +++ erpnext/stock/doctype/purchase_receipt/purchase_receipt.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 32968952fc6..f58c831922a 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1490,6 +1490,9 @@ class StockController(AccountsController): "remarks": remarks, } + if project: + gl_entry.update({"project": project}) + if voucher_detail_no: gl_entry.update({"voucher_detail_no": voucher_detail_no}) diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index c27e2e40f30..10099631a75 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -493,6 +493,7 @@ class PurchaseReceipt(BuyingController): remarks=remarks, against_account=stock_asset_rbnb, account_currency=account_currency, + project=item.project, item=item, ) @@ -535,6 +536,7 @@ class PurchaseReceipt(BuyingController): against_account=stock_asset_account_name, debit_in_account_currency=-1 * flt(outgoing_amount, item.precision("base_net_amount")), account_currency=account_currency, + project=item.project, item=item, ) @@ -559,6 +561,7 @@ class PurchaseReceipt(BuyingController): against_account=self.supplier, debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference, account_currency=account_currency, + project=item.project, item=item, ) @@ -572,6 +575,7 @@ class PurchaseReceipt(BuyingController): against_account=self.supplier, debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference, account_currency=account_currency, + project=item.project, item=item, ) @@ -634,6 +638,7 @@ class PurchaseReceipt(BuyingController): remarks=remarks, against_account=stock_asset_account_name, account_currency=supplier_warehouse_account_currency, + project=item.project, item=item, ) From 9125ab6c7734fb2a97060a717851e3a7c9051284 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 21 Jun 2026 10:14:42 +0530 Subject: [PATCH 31/40] fix(controllers): fix supplier-RFQ portal list query (wrong column + Postgres DISTINCT) rfq_transaction_list had two defects introduced when it was converted to the query builder: 1. `party.supplier == party[0]` compared supplier to a column literally named "0" (a stray index on the DocType, not the intended `parties[0]` value). This renders as `supplier = \`0\`` / `supplier = "0"` and errors on BOTH engines (MariaDB: Unknown column '0'; Postgres: column "0" does not exist), so the supplier portal RFQ list was completely broken. 2. SELECT DISTINCT ordered by `creation`, which is not in the select list. Postgres rejects this ("for SELECT DISTINCT, ORDER BY expressions must appear in select list"). Compare against `parties[0]` and add `creation` to the select list. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit a7d9078bf450141a4e10fcb2f406f6ba272fe803) # Conflicts: # erpnext/controllers/tests/test_website_list_for_contact.py --- .../tests/test_website_list_for_contact.py | 36 +++++++++++++++++++ .../controllers/website_list_for_contact.py | 5 +-- 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 erpnext/controllers/tests/test_website_list_for_contact.py diff --git a/erpnext/controllers/tests/test_website_list_for_contact.py b/erpnext/controllers/tests/test_website_list_for_contact.py new file mode 100644 index 00000000000..d62254d8a0c --- /dev/null +++ b/erpnext/controllers/tests/test_website_list_for_contact.py @@ -0,0 +1,36 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import json + +from frappe.tests.utils import FrappeTestCase + + +class TestWebsiteListForContact(FrappeTestCase): + def test_get_list_context_currency_symbols(self): + # get_list_context builds the enabled-currency symbol map via frappe.get_all (converted from + # raw SQL). Exercises that query and asserts a known enabled currency is present. + from erpnext.controllers.website_list_for_contact import get_list_context + + context = get_list_context() + + symbols = json.loads(context["currency_symbols"]) + self.assertIsInstance(symbols, dict) + self.assertIn("USD", symbols) + + def test_rfq_transaction_list_returns_supplier_rfq(self): + # rfq_transaction_list filters RFQs by the supplier (parties[0]) and uses SELECT DISTINCT with + # ORDER BY creation -- both must be valid on Postgres, and the supplier filter must compare to the + # party value (not a stray `party[0]` column reference). + from erpnext.buying.doctype.request_for_quotation.test_request_for_quotation import ( + make_request_for_quotation, + ) + from erpnext.controllers.website_list_for_contact import rfq_transaction_list + + rfq = make_request_for_quotation() + supplier = rfq.suppliers[0].supplier + + rows = rfq_transaction_list( + "Request for Quotation Supplier", "Request for Quotation", [supplier], 0, 20 + ) + self.assertIn(rfq.name, [row.name for row in rows]) diff --git a/erpnext/controllers/website_list_for_contact.py b/erpnext/controllers/website_list_for_contact.py index ea7b47bd487..a62fccc752c 100644 --- a/erpnext/controllers/website_list_for_contact.py +++ b/erpnext/controllers/website_list_for_contact.py @@ -181,9 +181,10 @@ def rfq_transaction_list(parties_doctype, doctype, parties, limit_start, limit_p party = frappe.qb.DocType(parties_doctype) data = ( frappe.qb.from_(party) - .select(party.parent.as_("name"), party.supplier) + # creation must be selected: Postgres requires SELECT DISTINCT order-by exprs in the select list + .select(party.parent.as_("name"), party.supplier, party.creation) .distinct() - .where((party.supplier == party[0]) & (party.docstatus == 1)) + .where((party.supplier == parties[0]) & (party.docstatus == 1)) .orderby(party.creation, order=frappe.qb.desc) .limit(limit_page_length) .offset(limit_start) From 25764520c2981460ffa713c8ff5770246667b695 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:47:33 +0530 Subject: [PATCH 32/40] fix: update qty in future SLEs when cancelling documents (backport #56638) (#56644) fix: update qty in future SLEs when cancelling documents (#56638) (cherry picked from commit 01374db8dab4979309b4d1fa446c4af0a008ddfc) Co-authored-by: rohitwaghchaure --- .../test_stock_ledger_entry.py | 41 +++++++++++++++++++ erpnext/stock/stock_ledger.py | 5 ++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index ac3d4a01d11..6aea0ef5337 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -1316,6 +1316,47 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin): self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle2.name, "qty_after_transaction"), 35) self.assertEqual(frappe.db.get_value("Stock Ledger Entry", sle1.name, "qty_after_transaction"), 10) + def test_cancel_first_of_two_same_timestamp_entries(self): + # Two receipts of the same item+warehouse at the exact same posting timestamp: balances 10 -> 20. + # Cancelling the first must leave the second standing alone on a zero base (qty 10), not + # double-decremented. The same-timestamp sibling is corrected by the cancellation reprocessing, + # so update_qty_in_future_sle must not shift it again. + item = make_item().name + warehouse = "_Test Warehouse - _TC" + + receipt1 = make_purchase_receipt( + item_code=item, + warehouse=warehouse, + qty=10, + rate=10, + posting_date="2026-06-01", + posting_time="10:00:00", + ) + time.sleep(1) + receipt2 = make_purchase_receipt( + item_code=item, + warehouse=warehouse, + qty=10, + rate=10, + posting_date="2026-06-01", + posting_time="10:00:00", # identical timestamp, later creation + ) + + def qty_after(voucher): + return frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": voucher.name, "is_cancelled": 0}, + "qty_after_transaction", + ) + + self.assertEqual(qty_after(receipt1), 10) + self.assertEqual(qty_after(receipt2), 20) + + receipt1.cancel() + + # receipt2 now sits on a zero base -> 10 (not 0 from a double shift, nor a negative-stock error). + self.assertEqual(qty_after(receipt2), 10) + def test_get_next_stock_reco_respects_creation_order(self): # A stock reco sharing the exact posting timestamp of the current entry must only count as the # "next" reco when it was created after that entry. A reco created before it actually precedes diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 0d83119a0fc..93ee2eaa651 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -2057,8 +2057,11 @@ def update_qty_in_future_sle(args, allow_negative_stock=False): # current entry in that tuple order: a later posting_datetime, or the same posting_datetime with a # later creation. Comparing posting_datetime alone would skip same-timestamp entries created after # this one (e.g. the same item repeated in a voucher, or another voucher posted in the same second). + # On cancellation `args` is a freshly inserted reversal entry, so its `creation` is the cancel time + # (not the original entry's position) and same-timestamp siblings are already recomputed by the + # cancelled path in update_entries_after; applying the tiebreaker here would double-shift them. future_condition = sle.posting_datetime > posting_datetime - if args.get("creation"): + if args.get("creation") and not args.get("is_cancelled"): future_condition = future_condition | ( (sle.posting_datetime == posting_datetime) & (sle.creation > args.get("creation")) ) From 3479d65bd4fe0b5efcebad0e0c6eb0bf94033f00 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:33:00 +0530 Subject: [PATCH 33/40] fix(stock): value batch/serial return from ledger when original receipt has no bundle (backport #56631) (#56645) fix(stock): value batch/serial return from ledger when original receipt has no bundle (#56631) * fix(stock): value batch/serial return from ledger when original receipt has no bundle * test(stock): add test to validate the valuation of serial/batch for return when original receipt has no bundle (cherry picked from commit 6184c057dbb4547f6cbb30f08a16a811fe811b0c) Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com> --- .../serial_and_batch_bundle.py | 52 +++++++++++- .../test_serial_and_batch_bundle.py | 85 +++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index f8facea5f78..4fa630fb8a8 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 @@ -513,10 +513,12 @@ class SerialandBatchBundle(Document): ] # Added to handle rejected warehouse case + return_warehouse = None if self.voucher_type in ["Purchase Receipt", "Purchase Invoice"]: warehouses = get_warehouses_for_return(self.voucher_type, return_against_voucher_detail_no) if self.warehouse in warehouses: - filters.append(["Serial and Batch Entry", "warehouse", "=", self.warehouse]) + return_warehouse = self.warehouse + filters.append(["Serial and Batch Entry", "warehouse", "=", return_warehouse]) bundle_data = frappe.get_all( "Serial and Batch Bundle", @@ -529,6 +531,11 @@ class SerialandBatchBundle(Document): order_by="`tabSerial and Batch Bundle`.`creation`, `tabSerial and Batch Entry`.`idx`", ) + if not bundle_data: + bundle_data = self.get_legacy_valuation_rate_for_return_entry( + return_against, return_against_voucher_detail_no, return_warehouse + ) + if not bundle_data: return {} @@ -540,6 +547,49 @@ class SerialandBatchBundle(Document): return valuation_details + def get_legacy_valuation_rate_for_return_entry( + self, return_against, return_against_voucher_detail_no, return_warehouse=None + ): + """Return the original line's incoming rate per serial no / batch from the SLE, for legacy receipts with no bundle.""" + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + + if not (self.has_serial_no or self.has_batch_no): + return [] + + sle = frappe.qb.DocType("Stock Ledger Entry") + query = ( + frappe.qb.from_(sle) + .select(sle.serial_no, sle.batch_no, sle.incoming_rate) + .where( + (sle.voucher_no == return_against) + & (sle.voucher_detail_no == return_against_voucher_detail_no) + & (sle.item_code == self.item_code) + & (sle.is_cancelled == 0) + & (sle.serial_and_batch_bundle.isnull()) + ) + ) + + if return_warehouse: + query = query.where(sle.warehouse == return_warehouse) + + data = [] + for d in query.run(as_dict=True): + if d.serial_no: + for serial_no in get_serial_nos(d.serial_no): + data.append( + frappe._dict( + {"serial_no": serial_no, "batch_no": d.batch_no, "incoming_rate": d.incoming_rate} + ) + ) + elif d.batch_no: + data.append( + frappe._dict( + {"serial_no": None, "batch_no": d.batch_no, "incoming_rate": d.incoming_rate} + ) + ) + + return data + def calculate_total_qty(self, save=True): self.total_qty = 0.0 for d in self.entries: diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index 2cee2bb6f0a..100e62deafb 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -1246,6 +1246,91 @@ class TestSerialandBatchBundle(FrappeTestCase): self.assertEqual(frappe.get_value("Serial No", serial_no, "purchase_document_no"), se1.name) + def _assert_legacy_return_valuation(self, item_code, props, batch_no=None): + """Return against a legacy serial/batch receipt (no Serial and Batch Bundle) must value outgoing stock from the original ledger rate.""" + from erpnext.controllers.sales_and_purchase_return import make_return_doc + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + + make_item(item_code, props) + if batch_no and not frappe.db.exists("Batch", batch_no): + frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert() + + pr = make_purchase_receipt( + item_code=item_code, qty=10, rate=100, batch_no=batch_no, use_serial_batch_fields=True + ) + + # Simulate a receipt migrated from an older version: serial nos / batch tracked via the + # deprecated fields on the Stock Ledger Entry, with no Serial and Batch Bundle. + serial_nos = [] + for row in pr.items: + if row.serial_and_batch_bundle: + serial_nos = frappe.get_all( + "Serial and Batch Entry", + filters={"parent": row.serial_and_batch_bundle}, + pluck="serial_no", + ) + frappe.db.delete("Serial and Batch Bundle", {"name": row.serial_and_batch_bundle}) + frappe.db.set_value("Purchase Receipt Item", row.name, "serial_and_batch_bundle", None) + + serial_nos = [sn for sn in serial_nos if sn] + legacy = {"serial_and_batch_bundle": None} + if batch_no: + legacy["batch_no"] = batch_no + if serial_nos: + legacy["serial_no"] = "\n".join(serial_nos) + for sle in frappe.get_all("Stock Ledger Entry", filters={"voucher_no": pr.name}, pluck="name"): + frappe.db.set_value("Stock Ledger Entry", sle, legacy) + + rt = make_return_doc("Purchase Receipt", pr.name) + rt.items[0].qty = -4 + rt.items[0].received_qty = -4 + rt.items[0].use_serial_batch_fields = 1 + if batch_no: + rt.items[0].batch_no = batch_no + if serial_nos: + rt.items[0].serial_no = "\n".join(serial_nos[:4]) + rt.submit() + + difference_in_stock_value = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": rt.name, "is_cancelled": 0, "voucher_type": "Purchase Receipt"}, + "stock_value_difference", + ) + # 4 units returned at the original ledger rate of 100 -> -400 (must not be zero) + self.assertEqual(flt(difference_in_stock_value, 2), -400.0) + + def test_return_valuation_for_legacy_batch_without_bundle(self): + self._assert_legacy_return_valuation( + "Test Legacy Batch Return Valuation", + { + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "LBRV-.#####", + "is_stock_item": 1, + }, + batch_no="LBRV-BATCH-0001", + ) + + def test_return_valuation_for_legacy_serial_without_bundle(self): + self._assert_legacy_return_valuation( + "Test Legacy Serial Return Valuation", + {"has_serial_no": 1, "serial_no_series": "LSRV-.#####", "is_stock_item": 1}, + ) + + def test_return_valuation_for_legacy_serial_and_batch_without_bundle(self): + self._assert_legacy_return_valuation( + "Test Legacy Serial Batch Return Valuation", + { + "has_serial_no": 1, + "serial_no_series": "LSBRV-.#####", + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "LSBRVB-.#####", + "is_stock_item": 1, + }, + batch_no="LSBRV-BATCH-0001", + ) + def get_batch_from_bundle(bundle): from erpnext.stock.serial_batch_bundle import get_batch_nos From 53bdccee5f79df7cf2112a70e2ec0472b6102fbc Mon Sep 17 00:00:00 2001 From: pandiyan Date: Tue, 30 Jun 2026 16:09:00 +0530 Subject: [PATCH 34/40] fix: set mr status to received when per_received is 100 even if per_ordered < 100 --- erpnext/controllers/status_updater.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 50d272d7efb..c695d17e80f 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -135,7 +135,7 @@ status_map = { ], [ "Partially Ordered", - "eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.docstatus == 1 and self.material_request_type != 'Material Transfer'", + "eval:self.status != 'Stopped' and self.per_ordered < 100 and self.per_ordered > 0 and self.per_received < 100 and self.docstatus == 1 and self.material_request_type not in ['Material Transfer', 'Customer Provided']", ], ], "POS Opening Entry": [ From c2b77184554c2d6bf959cb59f7807377f372c06b Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 30 Jun 2026 20:23:14 +0530 Subject: [PATCH 35/40] fix: use correct variable to fetch valuation method (cherry picked from commit 1492c9fbc3fb7c0e8b2434d4ffeb7320838a70c6) # Conflicts: # erpnext/controllers/sales_and_purchase_return.py --- erpnext/controllers/sales_and_purchase_return.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index e1e3ba3e84e..afa4f5c6d00 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -143,7 +143,11 @@ def validate_returned_items(doc): ref.rate and flt(d.rate) > ref.rate and doc.doctype in ("Delivery Note", "Sales Invoice") +<<<<<<< HEAD and get_valuation_method(ref.item_code) != "Moving Average" +======= + and get_valuation_method(d.item_code, doc.company) != "Moving Average" +>>>>>>> 1492c9fbc3 (fix: use correct variable to fetch valuation method) ): frappe.throw( _("Row # {0}: Rate cannot be greater than the rate used in {1} {2}").format( From 04a6831645f511edb574b140d0cf91dd0aba972d Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 30 Jun 2026 20:35:44 +0530 Subject: [PATCH 36/40] chore: resolve conflicts --- erpnext/controllers/sales_and_purchase_return.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index afa4f5c6d00..c58580739e3 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -143,11 +143,7 @@ def validate_returned_items(doc): ref.rate and flt(d.rate) > ref.rate and doc.doctype in ("Delivery Note", "Sales Invoice") -<<<<<<< HEAD - and get_valuation_method(ref.item_code) != "Moving Average" -======= - and get_valuation_method(d.item_code, doc.company) != "Moving Average" ->>>>>>> 1492c9fbc3 (fix: use correct variable to fetch valuation method) + and get_valuation_method(d.item_code) != "Moving Average" ): frappe.throw( _("Row # {0}: Rate cannot be greater than the rate used in {1} {2}").format( From b5735531c15521c411d441e72f8ca08e1e23e344 Mon Sep 17 00:00:00 2001 From: Diptanil Saha Date: Tue, 30 Jun 2026 21:46:03 +0530 Subject: [PATCH 37/40] refactor: port bulk_transaction_log existence check to the query builder (#56667) Co-authored-by: Mihir Kandoi Co-authored-by: Claude Opus 4.8 (1M context) --- .../doctype/bulk_transaction_log/bulk_transaction_log.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py index fbe9d7fcf7d..6ffb23659c8 100644 --- a/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py +++ b/erpnext/bulk_transaction/doctype/bulk_transaction_log/bulk_transaction_log.py @@ -30,10 +30,7 @@ class BulkTransactionLog(Document): def load_from_db(self): log_detail = qb.DocType("Bulk Transaction Log Detail") - has_records = frappe.db.sql( - "select exists (select * from `tabBulk Transaction Log Detail` where date = %s);", - (self.name,), - )[0][0] + has_records = frappe.db.exists("Bulk Transaction Log Detail", {"date": self.name}) if not has_records: raise frappe.DoesNotExistError From 345587754f8b1b4906fe47884ee3b47a5468b97b Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:43:26 +0000 Subject: [PATCH 38/40] fix: add permission checks in whitelisted functions (backport #53103) (#56668) * fix: add permission checks in whitelisted functions (#53103) (cherry picked from commit a6e78c2eea71df011fbb59ca1cafeda15f96bd78) # Conflicts: # erpnext/stock/report/stock_qty_vs_batch_qty/stock_qty_vs_batch_qty.py * chore: resolve conflict --------- Co-authored-by: Priyal Rawal <135015851+Priyal208@users.noreply.github.com> Co-authored-by: Diptanil Saha --- .../bank_reconciliation_tool.py | 3 +- .../payment_request/payment_request.py | 1 + .../process_statement_of_accounts.py | 3 ++ .../stock_qty_vs_batch_qty.js | 44 ++++++++++--------- .../stock_qty_vs_batch_qty.py | 1 + 5 files changed, 30 insertions(+), 22 deletions(-) diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py index 9ea87ef0ae7..f249cf9c19d 100644 --- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py +++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py @@ -57,7 +57,7 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None): filters.append(["date", "<=", to_date]) if from_date: filters.append(["date", ">=", from_date]) - transactions = frappe.get_all( + transactions = frappe.get_list( "Bank Transaction", fields=[ "date", @@ -82,6 +82,7 @@ def get_bank_transactions(bank_account, from_date=None, to_date=None): @frappe.whitelist() def get_account_balance(bank_account, till_date, company): # returns account balance till the specified date + frappe.has_permission("Bank Account", "read", bank_account, throw=True) account = frappe.db.get_value("Bank Account", bank_account, "account") filters = frappe._dict( { diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index e341490ed7f..f13569e0d9b 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -834,6 +834,7 @@ def resend_payment_email(docname): @frappe.whitelist() def make_payment_entry(docname): doc = frappe.get_doc("Payment Request", docname) + doc.check_permission("read") return doc.create_payment_entry(submit=False).as_dict() diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py index 48349a4bd99..9f0680de3ee 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py @@ -439,6 +439,8 @@ def get_customer_emails(customer_name, primary_mandatory, billing_and_primary=Tr when Is Billing Contact checked and Primary email- email with Is Primary checked""" + frappe.has_permission("Customer", "read", customer_name, throw=True) + billing_email = frappe.db.sql( """ SELECT @@ -482,6 +484,7 @@ def get_customer_emails(customer_name, primary_mandatory, billing_and_primary=Tr @frappe.whitelist() def download_statements(document_name): doc = frappe.get_doc("Process Statement Of Accounts", document_name) + doc.check_permission("read") report = get_report_pdf(doc) if report: frappe.local.response.filename = doc.name + ".pdf" 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 f80126bcb0a..40ad8843871 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 @@ -28,28 +28,30 @@ frappe.query_reports["Stock Qty vs Batch Qty"] = { }, ], 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 (frappe.model.can_write("Batch")) { + 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: { - selected_batches: selected_rows, - }, - callback: function (r) { - if (!r.exc) { - report.refresh(); - } - }, - }); - } else { - frappe.msgprint(__("Please select at least one row with difference value")); - } - }); + if (selected_rows.length) { + frappe.call({ + method: "erpnext.stock.report.stock_qty_vs_batch_qty.stock_qty_vs_batch_qty.update_batch_qty", + args: { + selected_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) { 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 87c5e1419cc..e9ccde483ae 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 @@ -101,6 +101,7 @@ def get_data(filters=None): @frappe.whitelist() def update_batch_qty(selected_batches=None): + frappe.has_permission("Batch", "write", throw=True, ignore_share_permissions=True) if not selected_batches: return From 17733a564159ddf220fc58ba91ac53e48d6a0841 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Wed, 1 Jul 2026 08:02:45 +0530 Subject: [PATCH 39/40] fix: gross profit calculation with rate adjustment entries (cherry picked from commit b9f330a1589baa03acc60ba08fbda9874e5bc9b5) --- erpnext/accounts/report/gross_profit/gross_profit.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index 21f999197c2..8ecfe51d244 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -562,7 +562,12 @@ class GrossProfitGenerator: row.base_amount = packed_item.base_amount # get buying amount - if row.item_code in product_bundles: + if row.is_debit_note: + # Rate adjustment debit notes have no stock movement, so buying amount is zero + if not grouped_by_invoice: + row.qty = 0 + row.buying_amount = 0 + elif row.item_code in product_bundles: row.buying_amount = flt( self.get_buying_amount_from_product_bundle(row, product_bundles[row.item_code]), self.currency_precision, @@ -925,6 +930,7 @@ class GrossProfitGenerator: SalesInvoice.customer_group, SalesInvoice.customer_name, SalesInvoice.territory, + SalesInvoice.is_debit_note, SalesInvoiceItem.item_code, SalesInvoice.base_net_total.as_("invoice_base_net_total"), SalesInvoiceItem.item_name, @@ -1104,6 +1110,7 @@ class GrossProfitGenerator: "posting_time": row.posting_time, "project": row.project, "update_stock": row.update_stock, + "is_debit_note": row.is_debit_note, "customer": row.customer, "customer_group": row.customer_group, "customer_name": row.customer_name, @@ -1142,6 +1149,7 @@ class GrossProfitGenerator: "description": item.description, "warehouse": item.warehouse or row.warehouse, "update_stock": row.update_stock, + "is_debit_note": row.is_debit_note, "item_group": "", "brand": "", "dn_detail": row.dn_detail, From fa82da509ba3a9572d31a0b68cbd005cc06d9ca2 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Wed, 1 Jul 2026 08:17:42 +0530 Subject: [PATCH 40/40] test(gross_profit): added test cases for rate adjustment entry (cherry picked from commit 17ef5d6034b5dd2191813b3e57201e02fd65b0dd) --- .../report/gross_profit/test_gross_profit.py | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py index 9a0a9cc5174..d24d472710d 100644 --- a/erpnext/accounts/report/gross_profit/test_gross_profit.py +++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py @@ -727,6 +727,160 @@ class TestGrossProfit(FrappeTestCase): self.assertEqual(total[7], 1000.0) self.assertEqual(total[8], 100.0) + def create_rate_adjustment_debit_note(self, against_invoice, adjustment_rate, item_code=None): + """Create a rate adjustment debit note with no stock movement.""" + dn = self.create_sales_invoice(qty=1, rate=adjustment_rate, do_not_save=True, do_not_submit=True) + if item_code: + dn.items[0].item_code = item_code + dn.items[0].item_name = item_code + dn.is_debit_note = 1 + dn.return_against = against_invoice.name + dn.items[0].allow_zero_valuation_rate = 1 + return dn.save().submit() + + def test_debit_note_has_zero_buying_amount_and_full_gross_profit(self): + """ + Rate adjustment debit note (is_debit_note=1) should show buying_amount=0 + since there is no stock movement. Gross profit equals the adjustment amount + and gross profit % equals 100%. + """ + make_stock_entry( + company=self.company, + item_code=self.item, + target=self.warehouse, + qty=1, + basic_rate=100, + ) + + sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True) + sinv.update_stock = 1 + sinv = sinv.save().submit() + + debit_note = self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20) + + filters = frappe._dict( + company=self.company, + from_date=nowdate(), + to_date=nowdate(), + group_by="Invoice", + ) + + columns, data = execute(filters=filters) + + dn_item_rows = [ + x for x in data if x.get("parent_invoice") == debit_note.name and x.get("indent") == 1.0 + ] + self.assertEqual(len(dn_item_rows), 1) + + dn_row = dn_item_rows[0] + self.assertEqual(dn_row.buying_amount, 0.0) + self.assertEqual(dn_row.selling_amount, 20.0) + self.assertEqual(dn_row.gross_profit, 20.0) + self.assertEqual(dn_row["gross_profit_%"], 100.0) + + def test_original_invoice_unaffected_by_rate_adjustment_debit_note(self): + """ + The original invoice's GP should be derived solely from its own selling + amount and COGS — the rate adjustment debit note must not alter it. + """ + make_stock_entry( + company=self.company, + item_code=self.item, + target=self.warehouse, + qty=1, + basic_rate=100, + ) + + sinv = self.create_sales_invoice(qty=1, rate=200, do_not_submit=True) + sinv.update_stock = 1 + sinv = sinv.save().submit() + + self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20) + + filters = frappe._dict( + company=self.company, + from_date=nowdate(), + to_date=nowdate(), + group_by="Invoice", + ) + + columns, data = execute(filters=filters) + + sinv_item_rows = [x for x in data if x.get("parent_invoice") == sinv.name and x.get("indent") == 1.0] + self.assertEqual(len(sinv_item_rows), 1) + + sinv_row = sinv_item_rows[0] + self.assertEqual(sinv_row.selling_amount, 200.0) + self.assertEqual(sinv_row.buying_amount, 100.0) + self.assertEqual(sinv_row.gross_profit, 100.0) + self.assertEqual(sinv_row["gross_profit_%"], 50.0) + + def test_debit_note_qty_not_inflated_in_grouped_report(self): + """ + When grouped by Item Code, the debit note (qty=0) must not inflate + the group's qty or buying_amount. The selling amount and average + selling rate correctly reflect the rate adjustment. + """ + item = create_item("_Test Rate Adjustment Debit Note Item") + + make_stock_entry( + company=self.company, + item_code=item.item_code, + target=self.warehouse, + qty=1, + basic_rate=100, + ) + + sinv = create_sales_invoice( + qty=1, + rate=200, + company=self.company, + customer=self.customer, + item_code=item.item_code, + item_name=item.item_code, + cost_center=self.cost_center, + warehouse=self.warehouse, + debit_to=self.debit_to, + parent_cost_center=self.cost_center, + update_stock=1, + currency="INR", + income_account=self.income_account, + expense_account=self.expense_account, + ) + + self.create_rate_adjustment_debit_note(sinv, adjustment_rate=20, item_code=item.item_code) + + filters = frappe._dict( + company=self.company, + from_date=nowdate(), + to_date=nowdate(), + group_by="Item Code", + ) + + columns, data = execute(filters=filters) + + # group_by="Item Code" column order: + # [item_code, item_name, brand, description, qty, base_rate, + # buying_rate, base_amount, buying_amount, gross_profit, gross_profit_percent, currency] + item_row = next((row for row in data if row[0] == item.item_code), None) + self.assertIsNotNone(item_row) + + qty, base_rate, buying_amount, base_amount, gross_profit, gp_percent = ( + item_row[4], + item_row[5], + item_row[8], + item_row[7], + item_row[9], + item_row[10], + ) + + self.assertEqual(qty, 1.0) # debit note adds qty=0, not inflated + self.assertEqual(buying_amount, 100.0) # only original invoice COGS + self.assertEqual(base_amount, 220.0) # 200 (original) + 20 (adjustment) + self.assertEqual(base_rate, 220.0) # avg selling rate = 220/1 + self.assertEqual(gross_profit, 120.0) # 220 - 100 + self.assertAlmostEqual(gp_percent, 54.545, places=2) # 120/220 * 100 + def make_sales_person(sales_person_name="_Test Sales Person"): if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}):