From 7630c01e40d0de69822919f4196147ad0cfc0fb3 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Tue, 21 Apr 2026 18:33:25 +0530 Subject: [PATCH 01/28] refactor: use consistent report column names --- .../tax_withholding_details.py | 10 +++++----- .../test_tax_withholding_details.py | 2 +- .../tds_computation_summary.py | 14 +++++++------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py index d93c60b2cf4..8d9dbbbe2de 100644 --- a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py +++ b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py @@ -119,8 +119,8 @@ def get_result(filters, tds_accounts, tax_category_map, net_total_map): row.update( { - "section_code": tax_withholding_category or "", - "entity_type": party_map.get(party, {}).get(party_type), + "tax_withholding_category": tax_withholding_category or "", + "party_entity_type": party_map.get(party, {}).get(party_type), "rate": rate, "total_amount": total_amount, "grand_total": grand_total, @@ -141,7 +141,7 @@ def get_result(filters, tds_accounts, tax_category_map, net_total_map): else: entries[key] = row out = list(entries.values()) - out.sort(key=lambda x: (x["section_code"], x["transaction_date"], x["ref_no"])) + out.sort(key=lambda x: (x["tax_withholding_category"], x["transaction_date"], x["ref_no"])) return out @@ -207,7 +207,7 @@ def get_columns(filters): { "label": _("Section Code"), "options": "Tax Withholding Category", - "fieldname": "section_code", + "fieldname": "tax_withholding_category", "fieldtype": "Link", "width": 90, }, @@ -236,7 +236,7 @@ def get_columns(filters): columns.extend( [ - {"label": _("Entity Type"), "fieldname": "entity_type", "fieldtype": "Data", "width": 100}, + {"label": _("Entity Type"), "fieldname": "party_entity_type", "fieldtype": "Data", "width": 100}, ] ) if filters.party_type == "Supplier": diff --git a/erpnext/accounts/report/tax_withholding_details/test_tax_withholding_details.py b/erpnext/accounts/report/tax_withholding_details/test_tax_withholding_details.py index 56dba9d86d3..a4eaa44e64a 100644 --- a/erpnext/accounts/report/tax_withholding_details/test_tax_withholding_details.py +++ b/erpnext/accounts/report/tax_withholding_details/test_tax_withholding_details.py @@ -118,7 +118,7 @@ class TestTaxWithholdingDetails(AccountsTestMixin, FrappeTestCase): voucher_expected_values = expected_values[i] voucher_actual_values = ( voucher.ref_no, - voucher.section_code, + voucher.tax_withholding_category, voucher.rate, voucher.base_tax_withholding_net_total, voucher.base_total, diff --git a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py index cbceaeed092..71bf90d11df 100644 --- a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py +++ b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py @@ -49,25 +49,25 @@ def group_by_party_and_category(data, filters): for row in data: party_category_wise_map.setdefault( - (row.get("party"), row.get("section_code")), + (row.get("party"), row.get("tax_withholding_category")), { "pan": row.get("pan"), "tax_id": row.get("tax_id"), "party": row.get("party"), "party_name": row.get("party_name"), - "section_code": row.get("section_code"), - "entity_type": row.get("entity_type"), + "tax_withholding_category": row.get("tax_withholding_category"), + "party_entity_type": row.get("party_entity_type"), "rate": row.get("rate"), "total_amount": 0.0, "tax_amount": 0.0, }, ) - party_category_wise_map.get((row.get("party"), row.get("section_code")))["total_amount"] += row.get( + party_category_wise_map.get((row.get("party"), row.get("tax_withholding_category")))["total_amount"] += row.get( "total_amount", 0.0 ) - party_category_wise_map.get((row.get("party"), row.get("section_code")))["tax_amount"] += row.get( + party_category_wise_map.get((row.get("party"), row.get("tax_withholding_category")))["tax_amount"] += row.get( "tax_amount", 0.0 ) @@ -112,11 +112,11 @@ def get_columns(filters): { "label": _("Section Code"), "options": "Tax Withholding Category", - "fieldname": "section_code", + "fieldname": "tax_withholding_category", "fieldtype": "Link", "width": 180, }, - {"label": _("Entity Type"), "fieldname": "entity_type", "fieldtype": "Data", "width": 180}, + {"label": _("Entity Type"), "fieldname": "party_entity_type", "fieldtype": "Data", "width": 180}, { "label": _("TDS Rate %") if filters.get("party_type") == "Supplier" else _("TCS Rate %"), "fieldname": "rate", From 55cce2a11c7c8356b97f332c9ef6eda9e9ea75d6 Mon Sep 17 00:00:00 2001 From: sarathibalamurugan Date: Wed, 15 Apr 2026 16:49:07 +0530 Subject: [PATCH 02/28] fix(accounts): fetch project name from payment entry to journal entry (cherry picked from commit d9b255b952bd7fb3859c5afc7a330ba5c39d5a12) --- erpnext/accounts/utils.py | 3 +++ erpnext/controllers/accounts_controller.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 179126452e7..751acd0d4f4 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -2320,6 +2320,7 @@ def create_gain_loss_journal( ref2_detail_no, cost_center, dimensions, + project=None, ) -> str: journal_entry = frappe.new_doc("Journal Entry") journal_entry.voucher_type = "Exchange Gain Or Loss" @@ -2346,6 +2347,7 @@ def create_gain_loss_journal( "account_currency": party_account_currency, "exchange_rate": 0, "cost_center": cost_center or erpnext.get_default_cost_center(company), + "project": project, "reference_type": ref1_dt, "reference_name": ref1_dn, "reference_detail_no": ref1_detail_no, @@ -2363,6 +2365,7 @@ def create_gain_loss_journal( "account_currency": gain_loss_account_currency, "exchange_rate": 1, "cost_center": cost_center or erpnext.get_default_cost_center(company), + "project": project, "reference_type": ref2_dt, "reference_name": ref2_dn, "reference_detail_no": ref2_detail_no, diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index ae2fae8051d..75bc8e0771e 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1751,6 +1751,7 @@ class AccountsController(TransactionBase): arg.get("referenced_row"), arg.get("cost_center"), dimensions_dict, + arg.get("project"), ) frappe.msgprint( _("Exchange Gain/Loss amount has been booked through {0}").format( @@ -1835,6 +1836,7 @@ class AccountsController(TransactionBase): d.idx, self.cost_center, dimensions_dict, + self.project, ) frappe.msgprint( _("Exchange Gain/Loss amount has been booked through {0}").format( From f9ae22d85e91ca2b6c5521ef0042bcc8682c65ae Mon Sep 17 00:00:00 2001 From: sarathibalamurugan Date: Fri, 17 Apr 2026 18:44:44 +0530 Subject: [PATCH 03/28] test: add test for project name in exchange gain loss entry (cherry picked from commit 9eeb81910663cf002e4e5b7cb389d131480ff58a) # Conflicts: # erpnext/accounts/doctype/payment_entry/test_payment_entry.py --- .../payment_entry/test_payment_entry.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index bec4a8391e3..c38ff11f83c 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -1937,6 +1937,37 @@ class TestPaymentEntry(FrappeTestCase): self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, pe.doctype, pe.name) self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, "Journal Entry", jv[0]) + def test_project_name_in_exchange_gain_loss_entry(self): + si = create_sales_invoice( + customer="_Test Customer USD", + debit_to="_Test Receivable USD - _TC", + currency="USD", + conversion_rate=50, + do_not_submit=True, + ) + from erpnext.projects.doctype.project.test_project import make_project + + si.project = make_project({"project_name": "_Test Project for Exchange Gain Loss Entry"}).name + + si.submit() + + pe = get_payment_entry("Sales Invoice", si.name) + + pe.source_exchange_rate = 100 + + pe.insert() + pe.submit() + + rows = frappe.get_all( + "Journal Entry Account", + or_filters=[{"reference_name": pe.name}, {"reference_name": si.name}], + fields=["project"], + ) + self.assertEqual(len(rows), 2) + + self.assertEqual(rows[0].project, si.project) + self.assertEqual(rows[1].project, si.project) + def create_payment_entry(**args): payment_entry = frappe.new_doc("Payment Entry") From 8e12bda108bc2a354b0e90b4d01be0e166889873 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Wed, 22 Apr 2026 12:11:04 +0530 Subject: [PATCH 04/28] refactor: better label for entity type --- .../tax_withholding_details.py | 9 ++++++-- .../tds_computation_summary.py | 21 ++++++++++++------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py index 8d9dbbbe2de..99ac592097b 100644 --- a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py +++ b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py @@ -205,7 +205,7 @@ def get_columns(filters): pan = "pan" if frappe.db.has_column(filters.party_type, "pan") else "tax_id" columns = [ { - "label": _("Section Code"), + "label": _("Tax Withholding Category"), "options": "Tax Withholding Category", "fieldname": "tax_withholding_category", "fieldtype": "Link", @@ -236,7 +236,12 @@ def get_columns(filters): columns.extend( [ - {"label": _("Entity Type"), "fieldname": "party_entity_type", "fieldtype": "Data", "width": 100}, + { + "label": _(f"{filters.get('party_type', 'Party')} Type"), + "fieldname": "party_entity_type", + "fieldtype": "Data", + "width": 100, + }, ] ) if filters.party_type == "Supplier": diff --git a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py index 71bf90d11df..8916dbb7aa2 100644 --- a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py +++ b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py @@ -63,13 +63,13 @@ def group_by_party_and_category(data, filters): }, ) - party_category_wise_map.get((row.get("party"), row.get("tax_withholding_category")))["total_amount"] += row.get( - "total_amount", 0.0 - ) + party_category_wise_map.get((row.get("party"), row.get("tax_withholding_category")))[ + "total_amount" + ] += row.get("total_amount", 0.0) - party_category_wise_map.get((row.get("party"), row.get("tax_withholding_category")))["tax_amount"] += row.get( - "tax_amount", 0.0 - ) + party_category_wise_map.get((row.get("party"), row.get("tax_withholding_category")))[ + "tax_amount" + ] += row.get("tax_amount", 0.0) final_result = get_final_result(party_category_wise_map) @@ -110,13 +110,18 @@ def get_columns(filters): columns.extend( [ { - "label": _("Section Code"), + "label": _("Tax Withholding Category"), "options": "Tax Withholding Category", "fieldname": "tax_withholding_category", "fieldtype": "Link", "width": 180, }, - {"label": _("Entity Type"), "fieldname": "party_entity_type", "fieldtype": "Data", "width": 180}, + { + "label": _(f"{filters.get('party_type', 'Party')} Type"), + "fieldname": "party_entity_type", + "fieldtype": "Data", + "width": 180, + }, { "label": _("TDS Rate %") if filters.get("party_type") == "Supplier" else _("TCS Rate %"), "fieldname": "rate", From a3ad1fb163d537c0dd66c78c6522d7607604eee2 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Wed, 22 Apr 2026 12:12:18 +0530 Subject: [PATCH 05/28] fix: add party_type for dynamic link and add it to grouping key --- .../report/tds_computation_summary/tds_computation_summary.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py index 8916dbb7aa2..ff8816dba4d 100644 --- a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py +++ b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py @@ -49,11 +49,12 @@ def group_by_party_and_category(data, filters): for row in data: party_category_wise_map.setdefault( - (row.get("party"), row.get("tax_withholding_category")), + (row.get("party_type"), row.get("party"), row.get("tax_withholding_category")), { "pan": row.get("pan"), "tax_id": row.get("tax_id"), "party": row.get("party"), + "party_type": row.get("party_type"), "party_name": row.get("party_name"), "tax_withholding_category": row.get("tax_withholding_category"), "party_entity_type": row.get("party_entity_type"), From 617944903608e7feecc529e3b8023d72c7bceefc Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:32:21 +0000 Subject: [PATCH 06/28] fix: py error on stock ageing report (backport #54467) (#54468) fix: py error on stock ageing report (#54467) (cherry picked from commit f5357c233dfc3df45da332d578cbf388a475ae60) Co-authored-by: Mihir Kandoi --- erpnext/stock/report/stock_ageing/stock_ageing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index 1171dd73eab..30a67324bea 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -612,5 +612,5 @@ class FIFOSlots: sr_item = frappe.db.get_value( "Stock Reconciliation Item", row.voucher_detail_no, ["current_qty", "qty"], as_dict=True ) - if sr_item.qty and sr_item.current_qty: + if sr_item and sr_item.qty and sr_item.current_qty: self.stock_reco_voucher_wise_count[row.voucher_detail_no] = sr_item.current_qty From 9a4c693f2de641d7d9699157866f9c6dc9c52b92 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:05:29 +0000 Subject: [PATCH 07/28] fix: sales order is not valid when creating WO from MR from PP (backport #54435) (#54470) fix: sales order is not valid when creating WO from MR from PP (#54435) (cherry picked from commit e65b9fc2ae96fa10c03ff4d064bad956a8cc13d6) Co-authored-by: Mihir Kandoi --- .../doctype/work_order/work_order.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 8bc2e7c1953..2de6f934a15 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -228,6 +228,18 @@ class WorkOrder(Document): if self.production_plan_sub_assembly_item: return + production_item = self.production_item + + if self.material_request_item and ( + mr_plan_item := frappe.get_value( + "Material Request Item", self.material_request_item, "material_request_plan_item" + ) + ): + if main_item_code := frappe.get_value( + "Material Request Plan Item", mr_plan_item, "main_item_code" + ): + production_item = main_item_code + if self.sales_order: self.check_sales_order_on_hold_or_close() @@ -248,8 +260,8 @@ class WorkOrder(Document): & (SalesOrder.docstatus == 1) & (SalesOrder.name == self.sales_order) & ( - (SalesOrderItem.item_code == self.production_item) - | (ProductBundleItem.item_code == self.production_item) + (SalesOrderItem.item_code == production_item) + | (ProductBundleItem.item_code == production_item) ) ) .run(as_dict=1) @@ -268,7 +280,7 @@ class WorkOrder(Document): & (SalesOrder.skip_delivery_note == 0) & (SalesOrderItem.item_code == PackedItem.parent_item) & (SalesOrder.docstatus == 1) - & (PackedItem.item_code == self.production_item) + & (PackedItem.item_code == production_item) ) .run(as_dict=1) ) From 8f9a5e6c0cf28bdbbad6c9f5fd99bc421033fda0 Mon Sep 17 00:00:00 2001 From: Smit Vora Date: Thu, 23 Apr 2026 15:01:46 +0530 Subject: [PATCH 08/28] fix: use key consistently --- .../tds_computation_summary.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py index ff8816dba4d..a1b9c22f63f 100644 --- a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py +++ b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py @@ -48,8 +48,9 @@ def group_by_party_and_category(data, filters): party_category_wise_map = {} for row in data: + key = (row.get("party_type"), row.get("party"), row.get("tax_withholding_category")) party_category_wise_map.setdefault( - (row.get("party_type"), row.get("party"), row.get("tax_withholding_category")), + key, { "pan": row.get("pan"), "tax_id": row.get("tax_id"), @@ -64,13 +65,8 @@ def group_by_party_and_category(data, filters): }, ) - party_category_wise_map.get((row.get("party"), row.get("tax_withholding_category")))[ - "total_amount" - ] += row.get("total_amount", 0.0) - - party_category_wise_map.get((row.get("party"), row.get("tax_withholding_category")))[ - "tax_amount" - ] += row.get("tax_amount", 0.0) + party_category_wise_map.get(key)["total_amount"] += row.get("total_amount", 0.0) + party_category_wise_map.get(key)["tax_amount"] += row.get("tax_amount", 0.0) final_result = get_final_result(party_category_wise_map) From e0013f7618efacd31a4810e8b1253b4de896b9c7 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:37:39 +0000 Subject: [PATCH 09/28] fix(edi): restrict Code List imports to files and trusted backend URLs (backport #54137) (#54265) Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> fix(edi): restrict Code List imports to files and trusted backend URLs (#54137) fix(edi): hardcode "Code List" DocType in importer (#54488) --- .../edi/doctype/code_list/code_list_import.js | 1 + .../edi/doctype/code_list/code_list_import.py | 166 +++++++++++---- .../code_list/test_code_list_import.py | 200 ++++++++++++++++++ .../edi/doctype/common_code/common_code.py | 14 +- 4 files changed, 329 insertions(+), 52 deletions(-) create mode 100644 erpnext/edi/doctype/code_list/test_code_list_import.py diff --git a/erpnext/edi/doctype/code_list/code_list_import.js b/erpnext/edi/doctype/code_list/code_list_import.js index 4a33f3e2fe6..917e815fc97 100644 --- a/erpnext/edi/doctype/code_list/code_list_import.js +++ b/erpnext/edi/doctype/code_list/code_list_import.js @@ -10,6 +10,7 @@ erpnext.edi.import_genericode = function (listview_or_form) { method: "erpnext.edi.doctype.code_list.code_list_import.import_genericode", doctype: doctype, docname: docname, + allow_web_link: false, allow_toggle_private: false, allow_take_photo: false, on_success: function (_file_doc, r) { diff --git a/erpnext/edi/doctype/code_list/code_list_import.py b/erpnext/edi/doctype/code_list/code_list_import.py index 7368d3c012e..20fa7c453b4 100644 --- a/erpnext/edi/doctype/code_list/code_list_import.py +++ b/erpnext/edi/doctype/code_list/code_list_import.py @@ -1,48 +1,118 @@ import json +from urllib.parse import urlsplit import frappe import requests from frappe import _ from frappe.utils import escape_html +from frappe.utils.file_manager import save_file from lxml import etree -URL_PREFIXES = ("http://", "https://") +GENERICODE_FETCH_TIMEOUT = 15 +LOCAL_FILE_PREFIXES = ("/files/", "/private/files/") + + +class RemoteGenericodeUrlNotAllowedError(Exception): + pass + + +class CodeListSelectionMismatchError(Exception): + pass @frappe.whitelist() def import_genericode(): - doctype = "Code List" - docname = frappe.form_dict.docname - content = frappe.local.uploaded_file - - # recover the content, if it's a link - if (file_url := frappe.local.uploaded_file_url) and file_url.startswith(URL_PREFIXES): - try: - # If it's a URL, fetch the content and make it a local file (for durable audit) - response = requests.get(frappe.local.uploaded_file_url) - response.raise_for_status() - frappe.local.uploaded_file = content = response.content - frappe.local.uploaded_filename = frappe.local.uploaded_file_url.split("/")[-1] - frappe.local.uploaded_file_url = None - except Exception as e: - frappe.throw(f"
{e!s}
", title=_("Fetching Error")) - - if file_url := frappe.local.uploaded_file_url: - file_path = frappe.utils.file_manager.get_file_path(file_url) - with open(file_path.encode(), mode="rb") as f: - content = f.read() - - # Parse the xml content - parser = etree.XMLParser( - remove_blank_text=True, - resolve_entities=False, - load_dtd=False, - no_network=True, - ) try: - root = etree.fromstring(content, parser=parser) - except Exception as e: - frappe.throw(f"
{e!s}
", title=_("Parsing Error")) + content, file_name = get_uploaded_genericode_file() + + return import_genericode_content( + doctype="Code List", + docname=frappe.form_dict.docname, + content=content, + file_name=file_name, + ) + except RemoteGenericodeUrlNotAllowedError: + frappe.throw( + _("Importing Code Lists from remote URLs is not allowed."), + title=_("Invalid Upload"), + ) + except CodeListSelectionMismatchError: + frappe.throw(_("The uploaded file does not match the selected Code List.")) + except etree.XMLSyntaxError: + frappe.throw( + _("The uploaded file could not be parsed as a genericode XML document."), + title=_("Parsing Error"), + ) + + +def import_genericode_from_url( + url: str, + doctype: str = "Code List", + docname: str | None = None, +): + """Import a Code List from a trusted backend URL.""" + content = fetch_genericode_from_url(url) + file_name = urlsplit(url).path.rsplit("/", 1)[-1] or "genericode.xml" + + return import_genericode_content( + doctype=doctype, + docname=docname, + content=content, + file_name=file_name, + ) + + +def get_uploaded_genericode_file() -> tuple[bytes, str | None]: + uploaded_data = frappe.local.uploaded_file + file_name = frappe.local.uploaded_filename + if uploaded_data and file_name: + return uploaded_data, file_name + + file_url = frappe.local.uploaded_file_url + if not file_url: + raise frappe.ValidationError(_("No file uploaded or URL provided.")) + + if not is_local_file_url(file_url): + raise RemoteGenericodeUrlNotAllowedError + + file_doc = frappe.get_doc("File", {"file_url": file_url}) + file_doc.check_permission("read") + return read_file_bytes(file_doc), file_name + + +def read_file_bytes(file_doc) -> bytes: + """Return the raw bytes of a File document. + + v15's `File.get_content` eagerly decodes to utf-8 and returns `str` for text + files, but `lxml.etree.fromstring` needs bytes when the XML declares an encoding. + """ + content = file_doc.get_content() + if isinstance(content, str): + content = content.encode("utf-8") + return content + + +def is_local_file_url(file_url: str | None) -> bool: + if not file_url: + return False + + parsed = urlsplit(file_url.strip()) + return not parsed.scheme and not parsed.netloc and parsed.path.startswith(LOCAL_FILE_PREFIXES) + + +def fetch_genericode_from_url(url: str) -> bytes: + response = requests.get(url, timeout=GENERICODE_FETCH_TIMEOUT) + response.raise_for_status() + return response.content + + +def import_genericode_content( + doctype: str, + docname: str | None, + content: bytes, + file_name: str | None, +): + root = parse_genericode_content(content) # Extract the name (CanonicalVersionUri) from the parsed XML name = root.find(".//CanonicalVersionUri").text @@ -51,7 +121,7 @@ def import_genericode(): if frappe.db.exists(doctype, docname): code_list = frappe.get_doc(doctype, docname) if code_list.name != name: - frappe.throw(_("The uploaded file does not match the selected Code List.")) + raise CodeListSelectionMismatchError else: # Create a new Code List document with the extracted name code_list = frappe.new_doc(doctype) @@ -60,19 +130,13 @@ def import_genericode(): code_list.from_genericode(root) code_list.save() - # Attach the file and provide a recoverable identifier - file_doc = frappe.get_doc( - { - "doctype": "File", - "attached_to_doctype": "Code List", - "attached_to_name": code_list.name, - "folder": frappe.db.get_value("File", {"is_attachments_folder": 1}), - "file_name": frappe.local.uploaded_filename, - "file_url": frappe.local.uploaded_file_url, - "is_private": 1, - "content": content, - } - ).save() + file_doc = save_file( + fname=file_name, + content=content, + dt=doctype, + dn=code_list.name, + is_private=1, + ) # Get available columns and example values columns, example_values, filterable_columns = get_genericode_columns_and_examples(root) @@ -87,6 +151,16 @@ def import_genericode(): } +def parse_genericode_content(content: bytes): + parser = etree.XMLParser( + remove_blank_text=True, + resolve_entities=False, + load_dtd=False, + no_network=True, + ) + return etree.fromstring(content, parser=parser) + + @frappe.whitelist() def process_genericode_import( code_list_name: str, diff --git a/erpnext/edi/doctype/code_list/test_code_list_import.py b/erpnext/edi/doctype/code_list/test_code_list_import.py new file mode 100644 index 00000000000..a8eb721ea1f --- /dev/null +++ b/erpnext/edi/doctype/code_list/test_code_list_import.py @@ -0,0 +1,200 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +from unittest.mock import Mock, patch + +import frappe +import requests +from frappe.tests.utils import FrappeTestCase + +from erpnext.edi.doctype.code_list import code_list_import + +SAMPLE_GENERICODE = b""" + + + Test Code List + 1.0 + test-code-list + Code list for tests + + Test Agency + TEST + + https://example.com/codelists/test.xml + + test-code-list-v1 + + + + + + + + A + Alpha + Group 1 + + + B + Beta + Group 2 + + + C + Gamma + Group 1 + + + +""" + + +class TestCodeListImport(FrappeTestCase): + def test_import_genericode_rejects_remote_file_url(self): + self.set_upload_context( + file_name="trusted.xml", + file_url="https://example.com/codelists/trusted.xml", + ) + + with patch("erpnext.edi.doctype.code_list.code_list_import.requests.get") as mock_get: + with self.assertRaisesRegex( + frappe.ValidationError, "Importing Code Lists from remote URLs is not allowed." + ): + code_list_import.import_genericode() + + mock_get.assert_not_called() + + def test_import_genericode_rejects_file_scheme_url(self): + self.set_upload_context( + file_name="trusted.xml", + file_url="file:///tmp/trusted.xml", + ) + + with patch("erpnext.edi.doctype.code_list.code_list_import.requests.get") as mock_get: + with self.assertRaisesRegex( + frappe.ValidationError, "Importing Code Lists from remote URLs is not allowed." + ): + code_list_import.import_genericode() + + mock_get.assert_not_called() + + def test_import_genericode_from_trusted_url(self): + response = Mock() + response.content = SAMPLE_GENERICODE + response.raise_for_status.return_value = None + + with patch( + "erpnext.edi.doctype.code_list.code_list_import.requests.get", + return_value=response, + ) as mock_get: + import_result = code_list_import.import_genericode_from_url( + "https://example.com/codelists/trusted.xml" + ) + + self.assert_import_response(import_result) + mock_get.assert_called_once_with( + "https://example.com/codelists/trusted.xml", + timeout=code_list_import.GENERICODE_FETCH_TIMEOUT, + ) + + file_doc = frappe.get_doc("File", import_result["file"]) + self.assertEqual(code_list_import.read_file_bytes(file_doc), SAMPLE_GENERICODE) + self.assertFalse(file_doc.file_url.startswith("https://")) + + def test_import_genericode_from_trusted_url_propagates_fetch_errors(self): + with patch( + "erpnext.edi.doctype.code_list.code_list_import.requests.get", + side_effect=requests.Timeout, + ): + with self.assertRaises(requests.Timeout): + code_list_import.import_genericode_from_url("https://example.com/codelists/trusted.xml") + + def test_import_genericode_from_uploaded_file_returns_metadata(self): + self.set_upload_context(content=SAMPLE_GENERICODE, file_name="uploaded_genericode.xml") + + import_result = code_list_import.import_genericode() + + self.assert_import_response(import_result) + + file_doc = frappe.get_doc("File", import_result["file"]) + self.assertEqual(code_list_import.read_file_bytes(file_doc), SAMPLE_GENERICODE) + + def test_process_genericode_import_reads_file_doc_content(self): + self.set_upload_context(content=SAMPLE_GENERICODE, file_name="uploaded_genericode.xml") + + import_result = code_list_import.import_genericode() + count = code_list_import.process_genericode_import( + code_list_name=import_result["code_list"], + file_name=import_result["file"], + code_column="code", + title_column="name", + ) + + self.assertEqual(count, 3) + self.assertEqual(frappe.db.count("Common Code", {"code_list": import_result["code_list"]}), 3) + self.assertEqual( + frappe.db.get_value( + "Common Code", + {"code_list": import_result["code_list"], "common_code": "A"}, + "title", + ), + "Alpha", + ) + + def test_import_genericode_from_local_file_url(self): + source_file = frappe.get_doc( + { + "doctype": "File", + "file_name": "library_genericode.xml", + "content": SAMPLE_GENERICODE, + "is_private": 1, + } + ).insert() + self.set_upload_context(file_name=source_file.file_name, file_url=source_file.file_url) + + import_result = code_list_import.import_genericode() + + self.assert_import_response(import_result) + + def set_upload_context( + self, + content: bytes | None = None, + file_name: str = "genericode.xml", + file_url: str | None = None, + docname: str | None = None, + ): + attrs = ("form_dict", "uploaded_file", "uploaded_file_url", "uploaded_filename") + originals = {attr: getattr(frappe.local, attr, None) for attr in attrs} + + frappe.local.form_dict = frappe._dict(doctype="Code List", docname=docname) + frappe.local.uploaded_file = content + frappe.local.uploaded_file_url = file_url + frappe.local.uploaded_filename = file_name + + def restore(): + for attr, value in originals.items(): + setattr(frappe.local, attr, value) + + self.addCleanup(restore) + + def assert_import_response(self, import_result): + self.assertEqual( + set(import_result), + { + "code_list", + "code_list_title", + "file", + "columns", + "example_values", + "filterable_columns", + }, + ) + self.assertEqual(import_result["code_list"], "test-code-list-v1") + self.assertEqual(import_result["code_list_title"], "Test Code List") + self.assertEqual(import_result["columns"], ["code", "name", "category"]) + self.assertEqual(import_result["example_values"]["code"], ["A", "B", "C"]) + self.assertEqual(import_result["example_values"]["name"], ["Alpha", "Beta", "Gamma"]) + self.assertEqual(import_result["example_values"]["category"], ["Group 1", "Group 2", "Group 1"]) + self.assertCountEqual(import_result["filterable_columns"]["category"], ["Group 1", "Group 2"]) + self.assertTrue(frappe.db.exists("Code List", import_result["code_list"])) + self.assertTrue(frappe.db.exists("File", import_result["file"])) diff --git a/erpnext/edi/doctype/common_code/common_code.py b/erpnext/edi/doctype/common_code/common_code.py index d1fd88350be..bb85fc97c56 100644 --- a/erpnext/edi/doctype/common_code/common_code.py +++ b/erpnext/edi/doctype/common_code/common_code.py @@ -9,6 +9,8 @@ from frappe.model.document import Document from frappe.utils.data import get_link_to_form from lxml import etree +from erpnext.edi.doctype.code_list.code_list_import import parse_genericode_content, read_file_bytes + class CommonCode(Document): # begin: auto-generated types @@ -86,15 +88,15 @@ def simple_hash(input_string, length=6): def import_genericode(code_list: str, file_name: str, column_map: dict, filters: dict | None = None): """Import genericode file and create Common Code entries""" - file_path = frappe.utils.file_manager.get_file_path(file_name) - parser = etree.XMLParser(remove_blank_text=True) - tree = etree.parse(file_path, parser=parser) - root = tree.getroot() + file_doc = frappe.get_doc("File", file_name) + file_doc.check_permission("read") + root = parse_genericode_content(read_file_bytes(file_doc)) # Construct the XPath expression xpath_expr = ".//SimpleCodeList/Row" filter_conditions = [ - f"Value[@ColumnRef='{column_ref}']/SimpleValue='{value}'" for column_ref, value in filters.items() + f"Value[@ColumnRef='{column_ref}']/SimpleValue='{value}'" + for column_ref, value in (filters or {}).items() ] if filter_conditions: xpath_expr += "[" + " and ".join(filter_conditions) + "]" @@ -102,7 +104,7 @@ def import_genericode(code_list: str, file_name: str, column_map: dict, filters: elements = root.xpath(xpath_expr) total_elements = len(elements) for i, xml_element in enumerate(elements, start=1): - common_code: "CommonCode" = frappe.new_doc("Common Code") + common_code: CommonCode = frappe.new_doc("Common Code") common_code.code_list = code_list common_code.from_genericode(column_map, xml_element) common_code.save() From 722dc8c3f180be52d640f4d08c20a05e4c291d35 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:27:07 +0000 Subject: [PATCH 10/28] fix: preserve inventory dimensions when raw materials are reset (backport #54440) (#54492) * fix: preserve inventory dimensions when raw materials are reset (#54440) * fix: preserve inventory dimensions when raw materials are reset * test: add test case (cherry picked from commit 0e20e35842bf090241d7b296c19f2714b6fd2588) # Conflicts: # erpnext/patches.txt # erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js # erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py * chore: resolve conflicts * chore: resolve conflicts * chore: resolve conflicts --------- Co-authored-by: Mihir Kandoi --- erpnext/patches.txt | 1 + erpnext/patches/v16_0/scr_inv_dimension.py | 24 +++++++++++ .../inventory_dimension.py | 15 +++++-- .../test_inventory_dimension.py | 1 - .../subcontracting_receipt.js | 8 +++- .../subcontracting_receipt.py | 32 +++++++++++++++ .../test_subcontracting_receipt.py | 41 +++++++++++++++++++ 7 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 erpnext/patches/v16_0/scr_inv_dimension.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 2dcee807fec..5804b0bd0ee 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -433,3 +433,4 @@ erpnext.patches.v15_0.replace_http_with_https_in_sales_partner erpnext.patches.v16_0.add_portal_redirects 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.scr_inv_dimension diff --git a/erpnext/patches/v16_0/scr_inv_dimension.py b/erpnext/patches/v16_0/scr_inv_dimension.py new file mode 100644 index 00000000000..f4b320f674b --- /dev/null +++ b/erpnext/patches/v16_0/scr_inv_dimension.py @@ -0,0 +1,24 @@ +import frappe + +from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions + + +def execute(): + for dimension in get_inventory_dimensions(): + if frappe.db.exists( + "Custom Field", + { + "fieldname": dimension.source_fieldname, + "dt": "Subcontracting Receipt Supplied Item", + "reqd": 1, + }, + ): + frappe.set_value( + "Custom Field", + { + "fieldname": dimension.source_fieldname, + "dt": "Subcontracting Receipt Supplied Item", + "reqd": 1, + }, + {"reqd": 0, "mandatory_depends_on": "eval:doc.reference_name"}, + ) diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py index b43f2991bad..fa87380ddae 100644 --- a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py @@ -166,6 +166,13 @@ class InventoryDimension(Document): if label_start_with: label = f"{label_start_with} {self.dimension_name}" + mandatory_depends_on = self.mandatory_depends_on + if self.reqd: + if doctype == "Stock Entry Detail": + mandatory_depends_on = "eval:doc.s_warehouse" + elif doctype == "Subcontracting Receipt Supplied Item": + mandatory_depends_on = "eval:doc.reference_name" + dimension_fields = [ dict( fieldname="inventory_dimension", @@ -183,11 +190,11 @@ class InventoryDimension(Document): depends_on="eval:doc.s_warehouse" if doctype == "Stock Entry Detail" else "", search_index=1, reqd=1 - if self.reqd and not self.mandatory_depends_on and doctype != "Stock Entry Detail" + if self.reqd + and not self.mandatory_depends_on + and doctype not in ["Stock Entry Detail", "Subcontracting Receipt Supplied Item"] else 0, - mandatory_depends_on="eval:doc.s_warehouse" - if self.reqd and doctype == "Stock Entry Detail" - else self.mandatory_depends_on, + mandatory_depends_on=mandatory_depends_on, ), ] diff --git a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py index 29e811ea4c4..67cf6402488 100644 --- a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py @@ -234,7 +234,6 @@ class TestInventoryDimension(FrappeTestCase): ) ) - doc.load_from_db doc.reqd = 0 doc.save() diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js index 4e502793068..9d6e4c05a20 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js @@ -425,7 +425,13 @@ frappe.ui.form.on("Subcontracting Receipt Item", { set_missing_values(frm); }, - items_delete: (frm) => { + before_items_remove(frm, cdt, cdn) { + const filtered_rows = frm.doc.supplied_items.filter((item) => item.reference_name !== cdn); + frm.doc.supplied_items = filtered_rows; + frm.refresh_field("supplied_items"); + }, + + items_delete(frm) { set_missing_values(frm); }, diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index cd20986dc5a..b193f4fea5b 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -14,6 +14,7 @@ from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.subcontracting_controller import SubcontractingController from erpnext.setup.doctype.brand.brand import get_brand_defaults from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults +from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions from erpnext.stock.doctype.item.item import get_item_defaults from erpnext.stock.get_item_details import get_default_cost_center, get_default_expense_account from erpnext.stock.stock_ledger import get_valuation_rate @@ -118,6 +119,7 @@ class SubcontractingReceipt(SubcontractingController): ) def before_validate(self): + self.save_inventory_dimensions() super().before_validate() self.validate_items_qty() self.set_items_bom() @@ -158,6 +160,7 @@ class SubcontractingReceipt(SubcontractingController): self.set_supplied_items_expense_account() self.set_supplied_items_cost_center() + self.set_supplied_items_inventory_dimensions() def on_submit(self): self.validate_closed_subcontracting_order() @@ -289,6 +292,22 @@ class SubcontractingReceipt(SubcontractingController): self.company, ) + def set_supplied_items_inventory_dimensions(self): + if hasattr(self, "inventory_dimensions") and (inventory_dimensions := get_inventory_dimensions()): + for item in self.supplied_items: + key = ( + item.reference_name, + item.rm_item_code, + item.main_item_code, + item.batch_no, + item.serial_no, + ) + + for dimension in inventory_dimensions: + dimension_values = self.inventory_dimensions.get(dimension.source_fieldname, {}) + if key in dimension_values: + item.set(dimension.source_fieldname, dimension_values[key]) + def set_supplied_items_expense_account(self): for item in self.supplied_items: if not item.expense_account: @@ -305,6 +324,19 @@ class SubcontractingReceipt(SubcontractingController): get_brand_defaults(item.rm_item_code, self.company), ) + def save_inventory_dimensions(self): + if inventory_dimensions := get_inventory_dimensions(): + if not getattr(self, "inventory_dimensions", None): + self.inventory_dimensions = {} + + for dimension in inventory_dimensions: + self.inventory_dimensions[dimension.source_fieldname] = { + (d.reference_name, d.rm_item_code, d.main_item_code, d.batch_no, d.serial_no): d.get( + dimension.source_fieldname + ) + for d in self.supplied_items + } + def reset_supplied_items(self): if ( frappe.db.get_single_value("Buying Settings", "backflush_raw_materials_of_subcontract_based_on") diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py index 26025169979..347154dec68 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py @@ -2002,6 +2002,47 @@ class TestSubcontractingReceipt(FrappeTestCase): self.assertRaises(BOMQuantityError, scr.submit) + def test_inventory_dimensions(self): + """ + The subcontracting controller resets the supplied items table on each save causing the inventory dimensions to be lost. + This test ensures that the inventory dimensions are retained on each save. + """ + from erpnext.stock.doctype.inventory_dimension.test_inventory_dimension import ( + create_inventory_dimension, + ) + + inventory_dimension = create_inventory_dimension( + apply_to_all_doctypes=1, + dimension_name="Inv Site", + reference_document="Inv Site", + document_type="Inv Site", + ) + + inventory_dimension.reqd = 1 + inventory_dimension.save() + + set_backflush_based_on("BOM") + + sco = get_subcontracting_order() + rm_items = get_rm_items(sco.supplied_items) + itemwise_details = make_stock_in_entry(rm_items=rm_items) + make_stock_transfer_entry( + sco_no=sco.name, + rm_items=rm_items, + itemwise_details=copy.deepcopy(itemwise_details), + ) + scr = make_subcontracting_receipt(sco.name) + scr.items[0].inv_site = "Site 1" + scr.save() + + scr.supplied_items[0].inv_site = "Site 1" + scr.save() + + self.assertEqual(scr.supplied_items[0].inv_site, "Site 1") + + inventory_dimension.reqd = 0 + inventory_dimension.save() + def make_return_subcontracting_receipt(**args): args = frappe._dict(args) From 1b08ac248bbfe4c830323a8de002a979c23b8bcf Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Fri, 24 Apr 2026 14:13:59 +0530 Subject: [PATCH 11/28] Revert "fix: preserve inventory dimensions when raw materials are reset (backport #54440)" (#54507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert "fix: preserve inventory dimensions when raw materials are reset (back…" This reverts commit 722dc8c3f180be52d640f4d08c20a05e4c291d35. --- erpnext/patches.txt | 1 - erpnext/patches/v16_0/scr_inv_dimension.py | 24 ----------- .../inventory_dimension.py | 15 ++----- .../test_inventory_dimension.py | 1 + .../subcontracting_receipt.js | 8 +--- .../subcontracting_receipt.py | 32 --------------- .../test_subcontracting_receipt.py | 41 ------------------- 7 files changed, 6 insertions(+), 116 deletions(-) delete mode 100644 erpnext/patches/v16_0/scr_inv_dimension.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 5804b0bd0ee..2dcee807fec 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -433,4 +433,3 @@ erpnext.patches.v15_0.replace_http_with_https_in_sales_partner erpnext.patches.v16_0.add_portal_redirects 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.scr_inv_dimension diff --git a/erpnext/patches/v16_0/scr_inv_dimension.py b/erpnext/patches/v16_0/scr_inv_dimension.py deleted file mode 100644 index f4b320f674b..00000000000 --- a/erpnext/patches/v16_0/scr_inv_dimension.py +++ /dev/null @@ -1,24 +0,0 @@ -import frappe - -from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions - - -def execute(): - for dimension in get_inventory_dimensions(): - if frappe.db.exists( - "Custom Field", - { - "fieldname": dimension.source_fieldname, - "dt": "Subcontracting Receipt Supplied Item", - "reqd": 1, - }, - ): - frappe.set_value( - "Custom Field", - { - "fieldname": dimension.source_fieldname, - "dt": "Subcontracting Receipt Supplied Item", - "reqd": 1, - }, - {"reqd": 0, "mandatory_depends_on": "eval:doc.reference_name"}, - ) diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py index fa87380ddae..b43f2991bad 100644 --- a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py @@ -166,13 +166,6 @@ class InventoryDimension(Document): if label_start_with: label = f"{label_start_with} {self.dimension_name}" - mandatory_depends_on = self.mandatory_depends_on - if self.reqd: - if doctype == "Stock Entry Detail": - mandatory_depends_on = "eval:doc.s_warehouse" - elif doctype == "Subcontracting Receipt Supplied Item": - mandatory_depends_on = "eval:doc.reference_name" - dimension_fields = [ dict( fieldname="inventory_dimension", @@ -190,11 +183,11 @@ class InventoryDimension(Document): depends_on="eval:doc.s_warehouse" if doctype == "Stock Entry Detail" else "", search_index=1, reqd=1 - if self.reqd - and not self.mandatory_depends_on - and doctype not in ["Stock Entry Detail", "Subcontracting Receipt Supplied Item"] + if self.reqd and not self.mandatory_depends_on and doctype != "Stock Entry Detail" else 0, - mandatory_depends_on=mandatory_depends_on, + mandatory_depends_on="eval:doc.s_warehouse" + if self.reqd and doctype == "Stock Entry Detail" + else self.mandatory_depends_on, ), ] diff --git a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py index 67cf6402488..29e811ea4c4 100644 --- a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py @@ -234,6 +234,7 @@ class TestInventoryDimension(FrappeTestCase): ) ) + doc.load_from_db doc.reqd = 0 doc.save() diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js index 9d6e4c05a20..4e502793068 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js @@ -425,13 +425,7 @@ frappe.ui.form.on("Subcontracting Receipt Item", { set_missing_values(frm); }, - before_items_remove(frm, cdt, cdn) { - const filtered_rows = frm.doc.supplied_items.filter((item) => item.reference_name !== cdn); - frm.doc.supplied_items = filtered_rows; - frm.refresh_field("supplied_items"); - }, - - items_delete(frm) { + items_delete: (frm) => { set_missing_values(frm); }, diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index b193f4fea5b..cd20986dc5a 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -14,7 +14,6 @@ from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.subcontracting_controller import SubcontractingController from erpnext.setup.doctype.brand.brand import get_brand_defaults from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults -from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions from erpnext.stock.doctype.item.item import get_item_defaults from erpnext.stock.get_item_details import get_default_cost_center, get_default_expense_account from erpnext.stock.stock_ledger import get_valuation_rate @@ -119,7 +118,6 @@ class SubcontractingReceipt(SubcontractingController): ) def before_validate(self): - self.save_inventory_dimensions() super().before_validate() self.validate_items_qty() self.set_items_bom() @@ -160,7 +158,6 @@ class SubcontractingReceipt(SubcontractingController): self.set_supplied_items_expense_account() self.set_supplied_items_cost_center() - self.set_supplied_items_inventory_dimensions() def on_submit(self): self.validate_closed_subcontracting_order() @@ -292,22 +289,6 @@ class SubcontractingReceipt(SubcontractingController): self.company, ) - def set_supplied_items_inventory_dimensions(self): - if hasattr(self, "inventory_dimensions") and (inventory_dimensions := get_inventory_dimensions()): - for item in self.supplied_items: - key = ( - item.reference_name, - item.rm_item_code, - item.main_item_code, - item.batch_no, - item.serial_no, - ) - - for dimension in inventory_dimensions: - dimension_values = self.inventory_dimensions.get(dimension.source_fieldname, {}) - if key in dimension_values: - item.set(dimension.source_fieldname, dimension_values[key]) - def set_supplied_items_expense_account(self): for item in self.supplied_items: if not item.expense_account: @@ -324,19 +305,6 @@ class SubcontractingReceipt(SubcontractingController): get_brand_defaults(item.rm_item_code, self.company), ) - def save_inventory_dimensions(self): - if inventory_dimensions := get_inventory_dimensions(): - if not getattr(self, "inventory_dimensions", None): - self.inventory_dimensions = {} - - for dimension in inventory_dimensions: - self.inventory_dimensions[dimension.source_fieldname] = { - (d.reference_name, d.rm_item_code, d.main_item_code, d.batch_no, d.serial_no): d.get( - dimension.source_fieldname - ) - for d in self.supplied_items - } - def reset_supplied_items(self): if ( frappe.db.get_single_value("Buying Settings", "backflush_raw_materials_of_subcontract_based_on") diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py index 347154dec68..26025169979 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py @@ -2002,47 +2002,6 @@ class TestSubcontractingReceipt(FrappeTestCase): self.assertRaises(BOMQuantityError, scr.submit) - def test_inventory_dimensions(self): - """ - The subcontracting controller resets the supplied items table on each save causing the inventory dimensions to be lost. - This test ensures that the inventory dimensions are retained on each save. - """ - from erpnext.stock.doctype.inventory_dimension.test_inventory_dimension import ( - create_inventory_dimension, - ) - - inventory_dimension = create_inventory_dimension( - apply_to_all_doctypes=1, - dimension_name="Inv Site", - reference_document="Inv Site", - document_type="Inv Site", - ) - - inventory_dimension.reqd = 1 - inventory_dimension.save() - - set_backflush_based_on("BOM") - - sco = get_subcontracting_order() - rm_items = get_rm_items(sco.supplied_items) - itemwise_details = make_stock_in_entry(rm_items=rm_items) - make_stock_transfer_entry( - sco_no=sco.name, - rm_items=rm_items, - itemwise_details=copy.deepcopy(itemwise_details), - ) - scr = make_subcontracting_receipt(sco.name) - scr.items[0].inv_site = "Site 1" - scr.save() - - scr.supplied_items[0].inv_site = "Site 1" - scr.save() - - self.assertEqual(scr.supplied_items[0].inv_site, "Site 1") - - inventory_dimension.reqd = 0 - inventory_dimension.save() - def make_return_subcontracting_receipt(**args): args = frappe._dict(args) From 6df39aec54dfc455e10fb09ac9150a0245cc0bd1 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:06:32 +0530 Subject: [PATCH 12/28] fix(PCV): set correct filters of `from_date` and `to_date` on General Ledger Report on clicking `Ledger` button (backport #54522) (#54523) Co-authored-by: diptanilsaha fix(PCV): set correct filters of `from_date` and `to_date` on General Ledger Report on clicking `Ledger` button (#54522) --- .../doctype/period_closing_voucher/period_closing_voucher.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.js b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.js index 57b05d19d83..7433f18c5ac 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.js +++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.js @@ -46,8 +46,8 @@ frappe.ui.form.on("Period Closing Voucher", { function () { frappe.route_options = { voucher_no: frm.doc.name, - from_date: frm.doc.posting_date, - to_date: moment(frm.doc.modified).format("YYYY-MM-DD"), + from_date: frm.doc.period_start_date, + to_date: frm.doc.period_end_date, company: frm.doc.company, categorize_by: "", show_cancelled_entries: frm.doc.docstatus === 2, From 68d213a244a9307e3fa17de8c76dbbfacd0350e9 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:24:43 +0530 Subject: [PATCH 13/28] fix(stock): set incoming rate as zero for outward sle (backport #54514) (#54532) fix(stock): set incoming rate as zero for outward sle (cherry picked from commit ce37530e70b1fa6141a525e0e819d88d6f8fc08c) Co-authored-by: Sudharsanan11 --- erpnext/stock/stock_ledger.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 084b37113f0..9cbce196e94 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -947,6 +947,9 @@ class update_entries_after: if not self.wh_data.qty_after_transaction: self.wh_data.stock_value = 0.0 + if sle.actual_qty < 0: + sle.incoming_rate = 0 + stock_value_difference = self.wh_data.stock_value - self.wh_data.prev_stock_value self.wh_data.prev_stock_value = self.wh_data.stock_value From 973444e20ebcf50b932fa55ac142ebc01fbc7629 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:06:37 +0530 Subject: [PATCH 14/28] feat: danish_bosnian_address_template (backport #54093) (#54515) feat: danish_bosnian_address_template (#54093) (cherry picked from commit e517eeaaa2025e96820c97e7e439abce8ce508a9) Co-authored-by: mahsem <137205921+mahsem@users.noreply.github.com> --- .../address_template/templates/bosnia_and_herzegovina.html | 4 ++++ erpnext/regional/address_template/templates/denmark.html | 4 ++++ 2 files changed, 8 insertions(+) create mode 100644 erpnext/regional/address_template/templates/bosnia_and_herzegovina.html create mode 100644 erpnext/regional/address_template/templates/denmark.html diff --git a/erpnext/regional/address_template/templates/bosnia_and_herzegovina.html b/erpnext/regional/address_template/templates/bosnia_and_herzegovina.html new file mode 100644 index 00000000000..0c2ed73f0ae --- /dev/null +++ b/erpnext/regional/address_template/templates/bosnia_and_herzegovina.html @@ -0,0 +1,4 @@ +{{ address_line1 }}
+{% if address_line2 %}{{ address_line2 }}
{% endif -%} +{{ pincode }} {{ city | upper }}
+{{ country | upper }} \ No newline at end of file diff --git a/erpnext/regional/address_template/templates/denmark.html b/erpnext/regional/address_template/templates/denmark.html new file mode 100644 index 00000000000..0c2ed73f0ae --- /dev/null +++ b/erpnext/regional/address_template/templates/denmark.html @@ -0,0 +1,4 @@ +{{ address_line1 }}
+{% if address_line2 %}{{ address_line2 }}
{% endif -%} +{{ pincode }} {{ city | upper }}
+{{ country | upper }} \ No newline at end of file From b01049814a465714d4ee98a19f5697b3d7535162 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 05:51:18 +0000 Subject: [PATCH 15/28] refactor: quality inspection item query (backport #54511) (#54539) * refactor: quality inspection item query (#54511) (cherry picked from commit be2a4b7b2a0f0154bcd89796398d62db73d61c8c) # Conflicts: # erpnext/stock/doctype/quality_inspection/quality_inspection.py * chore: resolve conflicts --------- Co-authored-by: Mihir Kandoi --- .../quality_inspection/quality_inspection.js | 22 +--- .../quality_inspection/quality_inspection.py | 101 +++++++++--------- 2 files changed, 58 insertions(+), 65 deletions(-) diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.js b/erpnext/stock/doctype/quality_inspection/quality_inspection.js index 8d5764d5697..e991f63a0f3 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.js +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.js @@ -48,26 +48,14 @@ frappe.ui.form.on("Quality Inspection", { // item code based on GRN/DN frm.set_query("item_code", function (doc) { - let doctype = doc.reference_type; - - if (doc.reference_type !== "Job Card") { - doctype = - doc.reference_type == "Stock Entry" ? "Stock Entry Detail" : doc.reference_type + " Item"; - } - if (doc.reference_type && doc.reference_name) { - let filters = { - from: doctype, - parent_doctype: doc.reference_type, - inspection_type: doc.inspection_type, - }; - - if (doc.reference_type == doctype) filters["reference_name"] = doc.reference_name; - else filters["parent"] = doc.reference_name; - return { query: "erpnext.stock.doctype.quality_inspection.quality_inspection.item_query", - filters: filters, + filters: { + reference_doctype: doc.reference_type, + reference_name: doc.reference_name, + inspection_type: doc.inspection_type, + }, }; } }); diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index 6f5b184ec00..36e620e18a6 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -361,58 +361,63 @@ class QualityInspection(Document): @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def item_query(doctype, txt, searchfield, start, page_len, filters): - from frappe.desk.reportview import get_match_cond + reference_doctype = filters.get("reference_doctype") - from_doctype = cstr(filters.get("from")) - parent_doctype = cstr(filters.get("parent_doctype")) - if not from_doctype or not frappe.db.exists("DocType", from_doctype): + if not reference_doctype: return [] - - mcond = get_match_cond(parent_doctype or from_doctype) - cond, qi_condition = "", "and (quality_inspection is null or quality_inspection = '')" - - if filters.get("parent"): - if ( - from_doctype in ["Purchase Invoice Item", "Purchase Receipt Item"] - and filters.get("inspection_type") != "In Process" - ): - cond = """and item_code in (select name from `tabItem` where - inspection_required_before_purchase = 1)""" - elif ( - from_doctype in ["Sales Invoice Item", "Delivery Note Item"] - and filters.get("inspection_type") != "In Process" - ): - cond = """and item_code in (select name from `tabItem` where - inspection_required_before_delivery = 1)""" - elif from_doctype == "Stock Entry Detail": - cond = """and s_warehouse is null""" - - if from_doctype in ["Supplier Quotation Item"]: - qi_condition = "" - - return frappe.db.sql( - f""" - SELECT distinct item_code, item_name - FROM `tab{from_doctype}` - WHERE parent=%(parent)s and docstatus < 2 and item_code like %(txt)s - {qi_condition} {cond} {mcond} - ORDER BY item_code limit {cint(page_len)} offset {cint(start)} - """, - {"parent": filters.get("parent"), "txt": "%%%s%%" % txt}, + elif reference_doctype == "Job Card": + production_item, item_name = frappe.get_value( + "Job Card", filters.get("reference_name"), ["production_item", "item_name"] ) + return ((production_item, item_name),) + else: + my_filters = [ + ["items.parent", "=", filters.get("reference_name")], + "and", + ["items.item_code", "like", f"%{txt}%"], + "and", + ["docstatus", "<", 2], + "and", + ["items.quality_inspection", "is", "not set"], + ] - elif filters.get("reference_name"): - return frappe.db.sql( - f""" - SELECT production_item - FROM `tab{from_doctype}` - WHERE name = %(reference_name)s and docstatus < 2 and production_item like %(txt)s - {qi_condition} {cond} {mcond} - ORDER BY production_item - limit {cint(page_len)} offset {cint(start)} - """, - {"reference_name": filters.get("reference_name"), "txt": "%%%s%%" % txt}, - ) + if reference_doctype == "Stock Entry": + my_filters.extend( + [ + "and", + ["items.t_warehouse", "is", "not set"], + ] + ) + elif filters.get("inspection_type") != "In Process": + my_filters.extend( + [ + "and", + [ + "items.item_code", + "in", + frappe.get_list( + "Item", + filters={ + "inspection_required_before_purchase" + if filters.get("inspection_type") == "Incoming" + else "inspection_required_before_delivery": 1 + }, + pluck="name", + ), + ], + ] + ) + + return frappe.get_query( + reference_doctype, + fields=["items.item_code, items.item_name"], + filters=my_filters, + offset=start, + limit=page_len, + order_by="items.item_code", + ignore_permissions=False, + distinct=True, + ).run() @frappe.whitelist() From 8569ff67ffaa910777df729d2e8910a1a7092965 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 07:12:24 +0000 Subject: [PATCH 16/28] fix(stock): remove validation for transfer_qty field (backport #54542) (#54544) fix(stock): remove validation for transfer_qty field (#54542) (cherry picked from commit 60a6b38c314261cbaa8092c8fa62337b50c317a6) Co-authored-by: Pandiyan P --- .../stock/doctype/stock_entry_detail/stock_entry_detail.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json index 5c46d8e58d0..4e422e320b9 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -270,8 +270,7 @@ "oldfieldname": "transfer_qty", "oldfieldtype": "Currency", "print_hide": 1, - "read_only": 1, - "reqd": 1 + "read_only": 1 }, { "default": "0", @@ -617,7 +616,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-03-02 14:05:23.116017", + "modified": "2026-04-27 11:40:38.294196", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Detail", From f869e86c9c7c8330f7834634b8263792979366b5 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Mon, 27 Apr 2026 15:45:45 +0530 Subject: [PATCH 17/28] Revert "refactor: quality inspection item query (backport #54511)" (#54557) Revert "refactor: quality inspection item query (backport #54511) (#54539)" This reverts commit b01049814a465714d4ee98a19f5697b3d7535162. --- .../quality_inspection/quality_inspection.js | 22 +++- .../quality_inspection/quality_inspection.py | 101 +++++++++--------- 2 files changed, 65 insertions(+), 58 deletions(-) diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.js b/erpnext/stock/doctype/quality_inspection/quality_inspection.js index e991f63a0f3..8d5764d5697 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.js +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.js @@ -48,14 +48,26 @@ frappe.ui.form.on("Quality Inspection", { // item code based on GRN/DN frm.set_query("item_code", function (doc) { + let doctype = doc.reference_type; + + if (doc.reference_type !== "Job Card") { + doctype = + doc.reference_type == "Stock Entry" ? "Stock Entry Detail" : doc.reference_type + " Item"; + } + if (doc.reference_type && doc.reference_name) { + let filters = { + from: doctype, + parent_doctype: doc.reference_type, + inspection_type: doc.inspection_type, + }; + + if (doc.reference_type == doctype) filters["reference_name"] = doc.reference_name; + else filters["parent"] = doc.reference_name; + return { query: "erpnext.stock.doctype.quality_inspection.quality_inspection.item_query", - filters: { - reference_doctype: doc.reference_type, - reference_name: doc.reference_name, - inspection_type: doc.inspection_type, - }, + filters: filters, }; } }); diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index 36e620e18a6..6f5b184ec00 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -361,63 +361,58 @@ class QualityInspection(Document): @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def item_query(doctype, txt, searchfield, start, page_len, filters): - reference_doctype = filters.get("reference_doctype") + from frappe.desk.reportview import get_match_cond - if not reference_doctype: + from_doctype = cstr(filters.get("from")) + parent_doctype = cstr(filters.get("parent_doctype")) + if not from_doctype or not frappe.db.exists("DocType", from_doctype): return [] - elif reference_doctype == "Job Card": - production_item, item_name = frappe.get_value( - "Job Card", filters.get("reference_name"), ["production_item", "item_name"] + + mcond = get_match_cond(parent_doctype or from_doctype) + cond, qi_condition = "", "and (quality_inspection is null or quality_inspection = '')" + + if filters.get("parent"): + if ( + from_doctype in ["Purchase Invoice Item", "Purchase Receipt Item"] + and filters.get("inspection_type") != "In Process" + ): + cond = """and item_code in (select name from `tabItem` where + inspection_required_before_purchase = 1)""" + elif ( + from_doctype in ["Sales Invoice Item", "Delivery Note Item"] + and filters.get("inspection_type") != "In Process" + ): + cond = """and item_code in (select name from `tabItem` where + inspection_required_before_delivery = 1)""" + elif from_doctype == "Stock Entry Detail": + cond = """and s_warehouse is null""" + + if from_doctype in ["Supplier Quotation Item"]: + qi_condition = "" + + return frappe.db.sql( + f""" + SELECT distinct item_code, item_name + FROM `tab{from_doctype}` + WHERE parent=%(parent)s and docstatus < 2 and item_code like %(txt)s + {qi_condition} {cond} {mcond} + ORDER BY item_code limit {cint(page_len)} offset {cint(start)} + """, + {"parent": filters.get("parent"), "txt": "%%%s%%" % txt}, ) - return ((production_item, item_name),) - else: - my_filters = [ - ["items.parent", "=", filters.get("reference_name")], - "and", - ["items.item_code", "like", f"%{txt}%"], - "and", - ["docstatus", "<", 2], - "and", - ["items.quality_inspection", "is", "not set"], - ] - if reference_doctype == "Stock Entry": - my_filters.extend( - [ - "and", - ["items.t_warehouse", "is", "not set"], - ] - ) - elif filters.get("inspection_type") != "In Process": - my_filters.extend( - [ - "and", - [ - "items.item_code", - "in", - frappe.get_list( - "Item", - filters={ - "inspection_required_before_purchase" - if filters.get("inspection_type") == "Incoming" - else "inspection_required_before_delivery": 1 - }, - pluck="name", - ), - ], - ] - ) - - return frappe.get_query( - reference_doctype, - fields=["items.item_code, items.item_name"], - filters=my_filters, - offset=start, - limit=page_len, - order_by="items.item_code", - ignore_permissions=False, - distinct=True, - ).run() + elif filters.get("reference_name"): + return frappe.db.sql( + f""" + SELECT production_item + FROM `tab{from_doctype}` + WHERE name = %(reference_name)s and docstatus < 2 and production_item like %(txt)s + {qi_condition} {cond} {mcond} + ORDER BY production_item + limit {cint(page_len)} offset {cint(start)} + """, + {"reference_name": filters.get("reference_name"), "txt": "%%%s%%" % txt}, + ) @frappe.whitelist() From 4dff436104e85326a9356aadbc3b80aec73ce103 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:39:01 +0530 Subject: [PATCH 18/28] fix(purchase_register): filter tax rows by parenttype in invoice tax map query (backport #54272) (#54443) Co-authored-by: ljain112 --- .../purchase_register/purchase_register.py | 2 +- .../test_purchase_register.py | 47 +++++++++++++++++++ .../sales_register/test_sales_register.py | 38 +++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/report/purchase_register/purchase_register.py b/erpnext/accounts/report/purchase_register/purchase_register.py index 026aecce036..8d9782e24e2 100644 --- a/erpnext/accounts/report/purchase_register/purchase_register.py +++ b/erpnext/accounts/report/purchase_register/purchase_register.py @@ -501,7 +501,7 @@ def get_invoice_tax_map(invoice_list, invoice_expense_map, expense_accounts, inc else sum(base_tax_amount_after_discount_amount) * -1 end as tax_amount from `tabPurchase Taxes and Charges` where parent in (%s) and category in ('Total', 'Valuation and Total') - and base_tax_amount_after_discount_amount != 0 + and base_tax_amount_after_discount_amount != 0 and parenttype='Purchase Invoice' group by parent, account_head, add_deduct_tax """ % ", ".join(["%s"] * len(invoice_list)), diff --git a/erpnext/accounts/report/purchase_register/test_purchase_register.py b/erpnext/accounts/report/purchase_register/test_purchase_register.py index a7a5c07152b..bbada17817e 100644 --- a/erpnext/accounts/report/purchase_register/test_purchase_register.py +++ b/erpnext/accounts/report/purchase_register/test_purchase_register.py @@ -6,6 +6,7 @@ from frappe.tests.utils import FrappeTestCase from frappe.utils import add_months, today from erpnext.accounts.report.purchase_register.purchase_register import execute +from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt class TestPurchaseRegister(FrappeTestCase): @@ -26,6 +27,52 @@ class TestPurchaseRegister(FrappeTestCase): self.assertEqual(first_row.total_tax, 100) self.assertEqual(first_row.grand_total, 1100) + def test_purchase_register_ignores_tax_rows_from_other_doctype(self): + frappe.db.sql("delete from `tabPurchase Invoice` where company='_Test Company 6'") + frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 6'") + + filters = frappe._dict(company="_Test Company 6", from_date=add_months(today(), -1), to_date=today()) + + pi = make_purchase_invoice() + + # Real workflow setup: create a Purchase Receipt tax row in the same shared child table. + pr = make_purchase_receipt( + company="_Test Company 6", + supplier="_Test Supplier", + item="_Test Item", + warehouse="_Test Warehouse - _TC6", + cost_center="_Test Cost Center - _TC6", + do_not_save=1, + do_not_submit=1, + qty=1, + rate=1000, + ) + pr.append( + "taxes", + { + "account_head": "GST - _TC6", + "cost_center": "_Test Cost Center - _TC6", + "add_deduct_tax": "Add", + "category": "Valuation and Total", + "charge_type": "Actual", + "description": "PR Tax", + "tax_amount": 100.0, + "rate": 100, + }, + ) + pr.insert() + pr.submit() + + # Mimic custom naming collision across doctypes (same parent value in shared child table). + frappe.rename_doc("Purchase Receipt", pr.name, pi.name, force=True) + + report_results = execute(filters) + first_row = frappe._dict(report_results[1][0]) + + self.assertEqual(first_row.voucher_no, pi.name) + self.assertEqual(first_row.total_tax, 100) + self.assertEqual(first_row.grand_total, 1100) + def test_purchase_register_ledger_view(self): frappe.db.sql("delete from `tabPurchase Invoice` where company='_Test Company 6'") frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 6'") diff --git a/erpnext/accounts/report/sales_register/test_sales_register.py b/erpnext/accounts/report/sales_register/test_sales_register.py index 95aa5add24c..9e72f81f6e5 100644 --- a/erpnext/accounts/report/sales_register/test_sales_register.py +++ b/erpnext/accounts/report/sales_register/test_sales_register.py @@ -5,6 +5,7 @@ from frappe.utils import getdate, today from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.report.sales_register.sales_register import execute from erpnext.accounts.test.accounts_mixin import AccountsTestMixin +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order class TestItemWiseSalesRegister(AccountsTestMixin, FrappeTestCase): @@ -75,6 +76,43 @@ class TestItemWiseSalesRegister(AccountsTestMixin, FrappeTestCase): report_output = {k: v for k, v in res[0].items() if k in expected_result} self.assertDictEqual(report_output, expected_result) + def test_sales_register_ignores_tax_rows_from_other_doctype(self): + si = self.create_sales_invoice(rate=98) + + # Real workflow setup: create a Sales Order with taxes in the shared child table. + so = make_sales_order( + item=self.item, + company=self.company, + customer=self.customer, + rate=77, + do_not_save=1, + do_not_submit=1, + ) + so.append( + "taxes", + { + "charge_type": "Actual", + "account_head": self.income_account, + "description": "SO Tax", + "tax_amount": 55.0, + }, + ) + so.insert() + so.submit() + + # Mimic custom naming collision across doctypes (same parent value in shared child table). + frappe.rename_doc("Sales Order", so.name, si.name, force=True) + + filters = frappe._dict({"from_date": today(), "to_date": today(), "company": self.company}) + report = execute(filters) + + res = [x for x in report[1] if x.get("voucher_no") == si.name] + self.assertEqual(len(res), 1) + result = frappe._dict(res[0]) + self.assertEqual(result.net_total, 98.0) + self.assertEqual(result.tax_total, 0) + self.assertEqual(result.grand_total, 98.0) + def test_journal_with_cost_center_filter(self): je1 = frappe.get_doc( { From 78b2e45cb98b62c53fddddc84d20bf62b0e7c93a Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 15:14:58 +0000 Subject: [PATCH 19/28] fix: debit credit not equal in purchase transactions for multi currency (backport #54456) (#54563) fix: debit credit not equal in purchase transactions for multi currency (#54456) (cherry picked from commit 601581d6f818b678c284d47f9b6328ef0873b7dd) Co-authored-by: Mihir Kandoi --- erpnext/controllers/buying_controller.py | 12 +++++++++++- .../doctype/purchase_receipt/purchase_receipt.py | 9 ++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index eedb0ce6eba..c1c3e182c79 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -364,7 +364,17 @@ class BuyingController(SubcontractingController): get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0 ) - net_rate = item.qty * item.base_net_rate + net_rate = ( + flt( + (item.base_net_amount / item.received_qty) * item.qty, + item.precision("base_net_amount"), + ) + if item.received_qty + and frappe.get_single_value( + "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice" + ) + else item.base_net_amount + ) if item.sales_incoming_rate: # for internal transfer net_rate = item.qty * item.sales_incoming_rate diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index f200294a777..16f3eb38cac 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -510,7 +510,14 @@ class PurchaseReceipt(BuyingController): else flt(item.net_amount, item.precision("net_amount")) ) - outgoing_amount = item.qty * item.base_net_rate + outgoing_amount = ( + flt((item.base_net_amount / item.received_qty) * item.qty, item.precision("base_net_amount")) + if item.received_qty + and frappe.get_single_value( + "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice" + ) + else item.base_net_amount + ) if self.is_internal_transfer() and item.valuation_rate: outgoing_amount = abs(get_stock_value_difference(self.name, item.name, item.from_warehouse)) credit_amount = outgoing_amount From e7a29abdb0cfc2ce7896ce2c06ee3d4f9445b32f Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Tue, 28 Apr 2026 10:19:43 +0530 Subject: [PATCH 20/28] fix: unknown column error on item code in quality inspection (#54565) --- erpnext/stock/doctype/quality_inspection/quality_inspection.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index 6f5b184ec00..3448a8ff8de 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -394,7 +394,8 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): f""" SELECT distinct item_code, item_name FROM `tab{from_doctype}` - WHERE parent=%(parent)s and docstatus < 2 and item_code like %(txt)s + JOIN `tab{parent_doctype}` ON `tab{parent_doctype}`.name = `tab{from_doctype}`.parent + WHERE parent=%(parent)s and `tab{parent_doctype}`.docstatus < 2 and item_code like %(txt)s {qi_condition} {cond} {mcond} ORDER BY item_code limit {cint(page_len)} offset {cint(start)} """, From 49ab25dda8eca1f4e67a924f8a7530d0888eec18 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 05:30:26 +0000 Subject: [PATCH 21/28] fix: negative quantity check in validate_item_qty (backport #54559) (#54571) fix: negative quantity check in validate_item_qty (#54559) Fix negative quantity check in validate_item_qty When saving a Blanket Order with a blank qty field in the items table, the following error is raised: TypeError: '<' not supported between instances of 'NoneType' and 'int' Root cause: The validate_item_qty method compares d.qty < 0 directly. When the qty field is left empty, its value is None, and Python cannot compare None with an integer. Fix Wrap d.qty with flt(), which safely converts None (and any non-numeric value) to 0.0 before the comparison. # Before if d.qty < 0: # After if flt(d.qty) < 0: (cherry picked from commit 63edd5ddc6b6894102fd6d40ea735113022f995c) Co-authored-by: Vinay Mishra <39999379+vinaymishraofficial@users.noreply.github.com> --- erpnext/manufacturing/doctype/blanket_order/blanket_order.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/blanket_order/blanket_order.py b/erpnext/manufacturing/doctype/blanket_order/blanket_order.py index 988dce7122a..03f4179f37b 100644 --- a/erpnext/manufacturing/doctype/blanket_order/blanket_order.py +++ b/erpnext/manufacturing/doctype/blanket_order/blanket_order.py @@ -120,7 +120,7 @@ class BlanketOrder(Document): def validate_item_qty(self): for d in self.items: - if d.qty < 0: + if flt(d.qty) < 0: frappe.throw(_("Row {0}: Quantity cannot be negative.").format(d.idx)) From 1a8dc7e3328d8d7c053c32642c307b502f3e4d40 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:13:16 +0530 Subject: [PATCH 22/28] fix: update status of quotation in patch (backport #54577) (#54579) fix: update status of quotation in patch (#54577) (cherry picked from commit 2088a01c198ca498dea05c1db183d7055351759f) Co-authored-by: Mihir Kandoi --- .../set_ordered_qty_in_quotation_item.py | 19 +++++++++++++++---- .../selling/doctype/quotation/quotation.js | 3 +-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/erpnext/patches/v16_0/set_ordered_qty_in_quotation_item.py b/erpnext/patches/v16_0/set_ordered_qty_in_quotation_item.py index faa99fcd2ca..46d185a0408 100644 --- a/erpnext/patches/v16_0/set_ordered_qty_in_quotation_item.py +++ b/erpnext/patches/v16_0/set_ordered_qty_in_quotation_item.py @@ -10,7 +10,18 @@ def execute(): ) if data: frappe.db.auto_commit_on_many_writes = 1 - frappe.db.bulk_update( - "Quotation Item", {d.quotation_item: {"ordered_qty": d.ordered_qty} for d in data} - ) - frappe.db.auto_commit_on_many_writes = 0 + try: + frappe.db.bulk_update( + "Quotation Item", {d.quotation_item: {"ordered_qty": d.ordered_qty} for d in data} + ) + quotations = frappe.get_all( + "Quotation Item", + filters={"name": ["in", [d.quotation_item for d in data]]}, + pluck="parent", + distinct=True, + ) + for quotation in quotations: + doc = frappe.get_doc("Quotation", quotation) + doc.set_status(update=True, update_modified=False) + finally: + frappe.db.auto_commit_on_many_writes = 0 diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index 480ca04b6a9..ab112188ebc 100644 --- a/erpnext/selling/doctype/quotation/quotation.js +++ b/erpnext/selling/doctype/quotation/quotation.js @@ -123,6 +123,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext. frappe.datetime.get_diff(doc.valid_till, frappe.datetime.get_today()) >= 0) ) { this.frm.add_custom_button(__("Sales Order"), () => this.make_sales_order(), __("Create")); + cur_frm.page.set_inner_btn_group_as_primary(__("Create")); this.frm.add_custom_button(__("Update Items"), () => { erpnext.utils.update_child_items({ frm: this.frm, @@ -137,8 +138,6 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext. this.frm.trigger("set_as_lost_dialog"); }); } - - cur_frm.page.set_inner_btn_group_as_primary(__("Create")); } if (this.frm.doc.docstatus === 0 && frappe.model.can_read("Opportunity")) { From cceedd669fccf51c8d769aa8ccaea716003cd689 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:44:39 +0000 Subject: [PATCH 23/28] fix(payment_entry): escape arguments on invoice and order fetching sql queries (backport #54582) (#54585) Co-authored-by: diptanilsaha fix(payment_entry): escape arguments on invoice and order fetching sql queries (#54582) --- .../doctype/payment_entry/payment_entry.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 262dc89d44f..2723ef81d2f 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -2306,22 +2306,20 @@ def get_outstanding_reference_documents(args, validate=False): # Get positive outstanding sales /purchase invoices condition = "" if args.get("voucher_type") and args.get("voucher_no"): - condition = " and voucher_type={} and voucher_no={}".format( - frappe.db.escape(args["voucher_type"]), frappe.db.escape(args["voucher_no"]) - ) + condition = f" and voucher_type={frappe.db.escape(args['voucher_type'])} and voucher_no={frappe.db.escape(args['voucher_no'])}" common_filter.append(ple.voucher_type == args["voucher_type"]) common_filter.append(ple.voucher_no == args["voucher_no"]) # Add cost center condition if args.get("cost_center"): - condition += " and cost_center='%s'" % args.get("cost_center") + condition += f" and cost_center={frappe.db.escape(args.get('cost_center'))}" accounting_dimensions_filter.append(ple.cost_center == args.get("cost_center")) # dynamic dimension filters active_dimensions = get_dimensions()[0] for dim in active_dimensions: if args.get(dim.fieldname): - condition += f" and {dim.fieldname}='{args.get(dim.fieldname)}'" + condition += f" and {dim.fieldname}={frappe.db.escape(args.get(dim.fieldname))}" accounting_dimensions_filter.append(ple[dim.fieldname] == args.get(dim.fieldname)) date_fields_dict = { @@ -2331,17 +2329,15 @@ def get_outstanding_reference_documents(args, validate=False): for fieldname, date_fields in date_fields_dict.items(): if args.get(date_fields[0]) and args.get(date_fields[1]): - condition += " and {} between '{}' and '{}'".format( - fieldname, args.get(date_fields[0]), args.get(date_fields[1]) - ) + condition += f" and {fieldname} between {frappe.db.escape(args.get(date_fields[0]))} and {frappe.db.escape(args.get(date_fields[1]))}" posting_and_due_date.append(ple[fieldname][args.get(date_fields[0]) : args.get(date_fields[1])]) elif args.get(date_fields[0]): # if only from date is supplied - condition += f" and {fieldname} >= '{args.get(date_fields[0])}'" + condition += f" and {fieldname} >= {frappe.db.escape(args.get(date_fields[0]))}" posting_and_due_date.append(ple[fieldname].gte(args.get(date_fields[0]))) elif args.get(date_fields[1]): # if only to date is supplied - condition += f" and {fieldname} <= '{args.get(date_fields[1])}'" + condition += f" and {fieldname} <= {frappe.db.escape(args.get(date_fields[1]))}" posting_and_due_date.append(ple[fieldname].lte(args.get(date_fields[1]))) if args.get("company"): @@ -2561,7 +2557,7 @@ def get_orders_to_be_billed( active_dimensions = get_dimensions(True)[0] for dim in active_dimensions: if filters.get(dim.fieldname): - condition += f" and {dim.fieldname}='{filters.get(dim.fieldname)}'" + condition += f" and {dim.fieldname}={frappe.db.escape(filters.get(dim.fieldname))}" if party_account_currency == company_currency: grand_total_field = "base_grand_total" From 03f3a28f54d8172d1512d5c70ac3afc6f2c23823 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:14:07 +0530 Subject: [PATCH 24/28] fix(`get_stock_balance`): validate inventory dimension fieldnames (backport #54587) (#54588) * fix(`get_stock_balance`): validate inventory dimension fieldnames (#54587) (cherry picked from commit 084c7f72f051963c35daac3eeb7664c935c172d2) # Conflicts: # erpnext/stock/utils.py * chore: resolved conflicts --------- Co-authored-by: diptanilsaha --- erpnext/stock/utils.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py index 0c03e350d02..8dfcf5a833e 100644 --- a/erpnext/stock/utils.py +++ b/erpnext/stock/utils.py @@ -10,9 +10,8 @@ from frappe.query_builder.functions import CombineDatetime, IfNull, Sum from frappe.utils import cstr, flt, get_link_to_form, get_time, getdate, nowdate, nowtime import erpnext -from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( - get_available_serial_nos, -) +from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions +from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import get_available_serial_nos from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses from erpnext.stock.serial_batch_bundle import BatchNoValuation, SerialNoValuation from erpnext.stock.valuation import FIFOValuation, LIFOValuation @@ -124,11 +123,19 @@ def get_stock_balance( } extra_cond = "" + if inventory_dimensions_dict: + inventory_dimensions_fieldname = [d.get("fieldname") for d in get_inventory_dimensions()] + for field, value in inventory_dimensions_dict.items(): - column = frappe.utils.sanitize_column(field) + if field not in inventory_dimensions_fieldname: + frappe.throw( + _("{0} is not a valid {1} fieldname.").format( + frappe.bold(field), frappe.bold("Inventory Dimension") + ) + ) args[field] = value - extra_cond += f" and {column} = %({field})s" + extra_cond += f" and {field} = %({field})s" last_entry = get_previous_sle(args, extra_cond=extra_cond) From 44af1755564ef5ace8b98ba3deed5120b88d66bc Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:31:58 +0000 Subject: [PATCH 25/28] refactor(`sms_center`): replaced raw SQL queries with `Query Builder` (backport #54600) (#54602) Co-authored-by: diptanilsaha --- .../selling/doctype/sms_center/sms_center.py | 143 ++++++++++++------ 1 file changed, 94 insertions(+), 49 deletions(-) diff --git a/erpnext/selling/doctype/sms_center/sms_center.py b/erpnext/selling/doctype/sms_center/sms_center.py index 99f035141c5..b35b608f4b4 100644 --- a/erpnext/selling/doctype/sms_center/sms_center.py +++ b/erpnext/selling/doctype/sms_center/sms_center.py @@ -6,6 +6,7 @@ import frappe from frappe import _, msgprint from frappe.core.doctype.sms_settings.sms_settings import send_sms from frappe.model.document import Document +from frappe.query_builder import functions as fn from frappe.utils import cstr @@ -41,73 +42,117 @@ class SMSCenter(Document): @frappe.whitelist() def create_receiver_list(self): - rec, where_clause = "", "" - if self.send_to == "All Customer Contact": - where_clause = " and dl.link_doctype = 'Customer'" - if self.customer: - where_clause += ( - " and dl.link_name = '%s'" % self.customer.replace("'", "'") - or " and ifnull(dl.link_name, '') != ''" - ) - if self.send_to == "All Supplier Contact": - where_clause = " and dl.link_doctype = 'Supplier'" - if self.supplier: - where_clause += ( - " and dl.link_name = '%s'" % self.supplier.replace("'", "'") - or " and ifnull(dl.link_name, '') != ''" - ) - if self.send_to == "All Sales Partner Contact": - where_clause = " and dl.link_doctype = 'Sales Partner'" - if self.sales_partner: - where_clause += ( - "and dl.link_name = '%s'" % self.sales_partner.replace("'", "'") - or " and ifnull(dl.link_name, '') != ''" - ) + query = None + + if self.send_to == "": + return + if self.send_to in [ "All Contact", "All Customer Contact", "All Supplier Contact", "All Sales Partner Contact", ]: - rec = frappe.db.sql( - """select CONCAT(ifnull(c.first_name,''), ' ', ifnull(c.last_name,'')), - c.mobile_no from `tabContact` c, `tabDynamic Link` dl where ifnull(c.mobile_no,'')!='' and - c.docstatus != 2 and dl.parent = c.name%s""" - % where_clause - ) + query = self.get_contact_query_for_all_contacts() elif self.send_to == "All Lead (Open)": - rec = frappe.db.sql( - """select lead_name, mobile_no from `tabLead` where - ifnull(mobile_no,'')!='' and docstatus != 2 and status='Open'""" - ) + query = self.get_contact_query_for_all_open_leads() elif self.send_to == "All Employee (Active)": - where_clause = ( - self.department and " and department = '%s'" % self.department.replace("'", "'") or "" - ) - where_clause += self.branch and " and branch = '%s'" % self.branch.replace("'", "'") or "" - - rec = frappe.db.sql( - """select employee_name, cell_number from - `tabEmployee` where status = 'Active' and docstatus < 2 and - ifnull(cell_number,'')!='' %s""" - % where_clause - ) + query = self.get_contact_query_for_all_active_employee() elif self.send_to == "All Sales Person": - rec = frappe.db.sql( - """select sales_person_name, - tabEmployee.cell_number from `tabSales Person` left join tabEmployee - on `tabSales Person`.employee = tabEmployee.name - where ifnull(tabEmployee.cell_number,'')!=''""" - ) + query = self.get_contact_query_for_all_sales_person() + + rec = query.run(as_list=1) rec_list = "" for d in rec: rec_list += d[0] + " - " + d[1] + "\n" self.receiver_list = rec_list + def get_contact_query_for_all_contacts(self): + Contact = frappe.qb.DocType("Contact") + DynamicLink = frappe.qb.DocType("Dynamic Link") + query = ( + frappe.qb.from_(Contact) + .join(DynamicLink) + .on(DynamicLink.parent == Contact.name) + .select( + fn.Concat(fn.IfNull(Contact.first_name, ""), " ", fn.IfNull(Contact.last_name, "")), + Contact.mobile_no, + ) + .where((fn.IfNull(Contact.mobile_no, "") != "") & (Contact.docstatus != 2)) + ) + + if self.send_to == "All Customer Contact": + query = query.where(DynamicLink.link_doctype == "Customer") + query = ( + query.where(DynamicLink.link_name == self.customer) + if self.customer + else query.where(fn.IfNull(DynamicLink.link_name, "") != "") + ) + + elif self.send_to == "All Supplier Contact": + query = query.where(DynamicLink.link_doctype == "Supplier") + query = ( + query.where(DynamicLink.link_name == self.supplier) + if self.supplier + else query.where(fn.IfNull(DynamicLink.link_name, "") != "") + ) + + elif self.send_to == "All Sales Partner Contact": + query = query.where(DynamicLink.link_doctype == "Sales Partner") + query = ( + query.where(DynamicLink.link_name == self.sales_partner) + if self.sales_partner + else query.where(fn.IfNull(DynamicLink.link_name, "") != "") + ) + return query + + def get_contact_query_for_all_open_leads(self): + Lead = frappe.qb.DocType("Lead") + query = ( + frappe.qb.from_(Lead) + .select(Lead.lead_name, Lead.mobile) + .where((fn.IfNull(Lead.mobile_no, "") != "") & (Lead.docstatus != 2) & (Lead.status == "Open")) + ) + return query + + def get_contact_query_for_all_active_employee(self): + Employee = frappe.qb.DocType("Employee") + query = ( + frappe.qb.from_(Employee) + .select(Employee.employee_name, Employee.cell_number) + .where( + (Employee.status == "Active") + & (Employee.docstatus != 2) + & (fn.IfNull(Employee.cell_number, "") != "") + ) + ) + + if self.department: + query = query.where(Employee.department == self.department) + + if self.branch: + query = query.where(Employee.branch == self.branch) + + return query + + def get_contact_query_for_all_sales_person(self): + SalesPerson = frappe.qb.DocType("Sales Person") + Employee = frappe.qb.DocType("Employee") + + query = ( + frappe.qb.from_(SalesPerson) + .left_join(Employee) + .on(SalesPerson.employee == Employee.name) + .select(SalesPerson.sales_person_name, Employee.cell_number) + .where(fn.IfNull(Employee.cell_number, "") != "") + ) + + return query + def get_receiver_nos(self): receiver_nos = [] if self.receiver_list: From 176d980764f136025d33888aed552746f589054f Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:23:03 +0530 Subject: [PATCH 26/28] =?UTF-8?q?fix:=20duplicate=20entries=20being=20show?= =?UTF-8?q?n=20in=20batch=20exists=20in=20future=20transact=E2=80=A6=20(ba?= =?UTF-8?q?ckport=20#54604)=20(#54605)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: duplicate entries being shown in batch exists in future transact… (#54604) fix: duplicate entries being shown in batch exists in future transactions msg (cherry picked from commit 54f20de7e3f261b311338475740e8c082b69beb3) Co-authored-by: Mihir Kandoi --- .../doctype/serial_and_batch_bundle/serial_and_batch_bundle.py | 1 + 1 file changed, 1 insertion(+) 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 b0b8c221f8e..8cf96a149d7 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 @@ -925,6 +925,7 @@ class SerialandBatchBundle(Document): parent.voucher_type, parent.voucher_no, ) + .distinct() .where( (child.parent != self.name) & (parent.item_code == self.item_code) From 44f3f34c9e4b6ad4b562cc435a1dad556daa20aa Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:39:42 +0530 Subject: [PATCH 27/28] fix: add project filter to accounts payable and receivable reports (backport #54344) (#54441) Co-authored-by: ljain112 --- .../accounts_payable/accounts_payable.js | 11 +++++ .../accounts_payable/test_accounts_payable.py | 46 +++++++++++++++++ .../accounts_payable_summary.js | 11 +++++ .../accounts_receivable.js | 11 +++++ .../accounts_receivable.py | 14 +++++- .../test_accounts_receivable.py | 49 +++++++++++++++++++ .../accounts_receivable_summary.js | 11 +++++ 7 files changed, 151 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.js b/erpnext/accounts/report/accounts_payable/accounts_payable.js index c061b0c3902..5b8a9195d26 100644 --- a/erpnext/accounts/report/accounts_payable/accounts_payable.js +++ b/erpnext/accounts/report/accounts_payable/accounts_payable.js @@ -34,6 +34,17 @@ frappe.query_reports["Accounts Payable"] = { }, options: "Cost Center", }, + { + fieldname: "project", + label: __("Project"), + fieldtype: "MultiSelectList", + options: "Project", + get_data: function (txt) { + return frappe.db.get_link_options("Project", txt, { + company: frappe.query_report.get_filter_value("company"), + }); + }, + }, { fieldname: "party_account", label: __("Payable Account"), diff --git a/erpnext/accounts/report/accounts_payable/test_accounts_payable.py b/erpnext/accounts/report/accounts_payable/test_accounts_payable.py index 0c104f6f96e..5a4e11b5291 100644 --- a/erpnext/accounts/report/accounts_payable/test_accounts_payable.py +++ b/erpnext/accounts/report/accounts_payable/test_accounts_payable.py @@ -120,3 +120,49 @@ class TestAccountsPayable(AccountsTestMixin, FrappeTestCase): self.assertEqual(len(report[1]), 2) self.assertEqual([pi.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term]) + + def test_project_filter(self): + project = frappe.get_doc( + {"doctype": "Project", "project_name": "_Test AP Project", "company": self.company} + ).insert() + + pi = self.create_purchase_invoice(do_not_submit=True) + pi.project = project.name + pi.save().submit() + + filters = { + "company": self.company, + "report_date": today(), + "range": "30, 60, 90, 120", + "project": [project.name], + } + + report = execute(filters)[1] + self.assertEqual(len(report), 1) + row = report[0] + self.assertEqual(row.project, project.name) + self.assertEqual(row.invoiced, 300.0) + + def test_project_on_report_output(self): + """ + Report row must carry the invoice's project. + """ + filters = { + "company": self.company, + "report_date": today(), + "range": "30, 60, 90, 120", + } + + project = frappe.get_doc( + {"doctype": "Project", "project_name": "_Test AP Project Output", "company": self.company} + ).insert() + + pi = self.create_purchase_invoice(do_not_submit=True) + pi.project = project.name + pi.save().submit() + + report = execute(filters) + + self.assertEqual(len(report[1]), 1) + row = report[1][0] + self.assertEqual([pi.name, project.name, 300], [row.voucher_no, row.project, row.outstanding]) diff --git a/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js b/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js index a4cb0584bf1..3f603b62833 100644 --- a/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js +++ b/erpnext/accounts/report/accounts_payable_summary/accounts_payable_summary.js @@ -53,6 +53,17 @@ frappe.query_reports["Accounts Payable Summary"] = { }, options: "Cost Center", }, + { + fieldname: "project", + label: __("Project"), + fieldtype: "MultiSelectList", + options: "Project", + get_data: function (txt) { + return frappe.db.get_link_options("Project", txt, { + company: frappe.query_report.get_filter_value("company"), + }); + }, + }, { fieldname: "party_type", label: __("Party Type"), diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js index 4255568d1f9..02bb54abc79 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js @@ -36,6 +36,17 @@ frappe.query_reports["Accounts Receivable"] = { }, options: "Cost Center", }, + { + fieldname: "project", + label: __("Project"), + fieldtype: "MultiSelectList", + options: "Project", + get_data: function (txt) { + return frappe.db.get_link_options("Project", txt, { + company: frappe.query_report.get_filter_value("company"), + }); + }, + }, { fieldname: "party_type", label: __("Party Type"), diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 831873055f1..96992895f93 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -194,6 +194,7 @@ class ReceivablePayableReport: and ple.against_voucher_type in self.advance_payment_doctypes ): self.voucher_balance[key].cost_center = ple.cost_center + self.voucher_balance[key].project = ple.project self.get_invoices(ple) @@ -360,6 +361,7 @@ class ReceivablePayableReport: posting_date, account_currency, cost_center, + project, sum(invoiced) `invoiced`, sum(paid) `paid`, sum(credit_note) `credit_note`, @@ -388,6 +390,7 @@ class ReceivablePayableReport: "credit_note_in_account_currency", "outstanding_in_account_currency", "cost_center", + "project", ]: _d[field] = x.get(field) @@ -925,6 +928,7 @@ class ReceivablePayableReport: ple.against_voucher_no, ple.party_type, ple.cost_center, + ple.project, ple.party, ple.posting_date, ple.due_date, @@ -992,6 +996,9 @@ class ReceivablePayableReport: if self.filters.cost_center: self.get_cost_center_conditions() + if self.filters.project: + self.qb_selection_filter.append(self.ple.project.isin(self.filters.project)) + self.add_accounting_dimensions_filters() def get_cost_center_conditions(self): @@ -1231,6 +1238,7 @@ class ReceivablePayableReport: ) self.add_column(label=_("Cost Center"), fieldname="cost_center", fieldtype="Data") + self.add_column(label=_("Project"), fieldname="project", fieldtype="Link", options="Project") self.add_column(label=_("Voucher Type"), fieldname="voucher_type", fieldtype="Data") self.add_column( label=_("Voucher No"), @@ -1403,6 +1411,7 @@ class InitSQLProceduresForAR: posting_date date, account_currency {_varchar_type}, cost_center {_varchar_type}, + project {_varchar_type}, invoiced {_currency_type}, paid {_currency_type}, credit_note {_currency_type}, @@ -1422,6 +1431,7 @@ class InitSQLProceduresForAR: against_voucher_no {_varchar_type}, party_type {_varchar_type}, cost_center {_varchar_type}, + project {_varchar_type}, party {_varchar_type}, posting_date date, due_date date, @@ -1450,7 +1460,7 @@ class InitSQLProceduresForAR: begin if not exists (select name from `{_voucher_balance_name}` where name = `{genkey_function_name}`(ple, false)) then - insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, false), ple.voucher_type, ple.voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency, ple.cost_center, 0, 0, 0, 0, 0, 0); + insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, false), ple.voucher_type, ple.voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency, ple.cost_center, ple.project, 0, 0, 0, 0, 0, 0); end if; end; """ @@ -1492,7 +1502,7 @@ class InitSQLProceduresForAR: end if; - insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, true), ple.against_voucher_type, ple.against_voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency,'', invoiced, paid, 0, invoiced_in_account_currency, paid_in_account_currency, 0); + insert into `{_voucher_balance_name}` values (`{genkey_function_name}`(ple, true), ple.against_voucher_type, ple.against_voucher_no, ple.party, ple.account, ple.posting_date, ple.account_currency,'', '', invoiced, paid, 0, invoiced_in_account_currency, paid_in_account_currency, 0); end; """ diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py index 88a3b818196..93130fa353a 100644 --- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py @@ -1204,3 +1204,52 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase): self.assertEqual(len(report[1]), 2) self.assertEqual([si.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term]) + + def test_project_filter(self): + project = frappe.get_doc( + {"doctype": "Project", "project_name": "_Test AR Project", "company": self.company} + ).insert() + + si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True) + si.project = project.name + si.save().submit() + + filters = { + "company": self.company, + "report_date": today(), + "range": "30, 60, 90, 120", + "project": [project.name], + } + + report = execute(filters)[1] + self.assertEqual(len(report), 1) + row = report[0] + self.assertEqual(row.project, project.name) + self.assertEqual(row.invoiced, 100.0) + + def test_project_on_report_output(self): + """ + Report row must carry the invoice's project even when the payment entry + has no project set. + """ + filters = { + "company": self.company, + "report_date": today(), + "range": "30, 60, 90, 120", + } + + project = frappe.get_doc( + {"doctype": "Project", "project_name": "_Test AR Project Output", "company": self.company} + ).insert() + + si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True) + si.project = project.name + si.save().submit() + + # payment has no project — report row must still show the invoice's project + self.create_payment_entry(si.name) + report = execute(filters) + + self.assertEqual(len(report[1]), 1) + row = report[1][0] + self.assertEqual([si.name, project.name, 60], [row.voucher_no, row.project, row.outstanding]) diff --git a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js index c8e59d6e054..46585071174 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js +++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.js @@ -53,6 +53,17 @@ frappe.query_reports["Accounts Receivable Summary"] = { }, options: "Cost Center", }, + { + fieldname: "project", + label: __("Project"), + fieldtype: "MultiSelectList", + options: "Project", + get_data: function (txt) { + return frappe.db.get_link_options("Project", txt, { + company: frappe.query_report.get_filter_value("company"), + }); + }, + }, { fieldname: "party_type", label: __("Party Type"), From 51e7c6604336255350d2aa9e639648eb287f6556 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:35:46 +0000 Subject: [PATCH 28/28] fix: avoid double reduction of pe reference outstanding (backport #54193) (#54612) * fix: avoid double reduction of pe reference outstanding (#54193) Co-authored-by: diptanilsaha (cherry picked from commit d1a80d40c4f8e7459a76640ecadf1cf9173509cf) # Conflicts: # erpnext/accounts/utils.py * chore: resolved conflict * chore: remove unused import of DateTimeLikeObject --------- Co-authored-by: Ravibharathi <131471282+ravibharathi656@users.noreply.github.com> Co-authored-by: diptanilsaha --- .../payment_entry/test_payment_entry.py | 24 +++++++++++++++++++ erpnext/accounts/utils.py | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index c38ff11f83c..0a8b69206ed 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -200,6 +200,30 @@ class TestPaymentEntry(FrappeTestCase): outstanding_amount = flt(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount")) self.assertEqual(outstanding_amount, 100) + def test_reference_outstanding_amount_on_advance_pull(self): + from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice + + so = make_sales_order(qty=1, rate=1000) + pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC") + pe.paid_amount = pe.received_amount = 500 + pe.references[0].allocated_amount = 500 + pe.insert() + pe.submit() + + so.reload() + self.assertEqual(so.advance_paid, 500) + + si = make_sales_invoice(so.name) + si.allocate_advances_automatically = 1 + si.save() + self.assertEqual(si.get("advances")[0].allocated_amount, 500) + self.assertEqual(si.get("advances")[0].reference_name, pe.name) + si.submit() + + pe.load_from_db() + self.assertEqual(pe.references[0].reference_name, si.name) + self.assertEqual(pe.references[0].outstanding_amount, si.outstanding_amount) + def test_payment_entry_against_pi(self): pi = make_purchase_invoice( supplier="_Test Supplier USD", diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 751acd0d4f4..930f632fbf7 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -500,7 +500,7 @@ def reconcile_against_document( skip_ref_details_update_for_pe=skip_ref_details_update_for_pe, dimensions_dict=dimensions_dict, ) - if referenced_row.get("outstanding_amount"): + if referenced_row.get("outstanding_amount") and entry.get("outstanding_amount") is None: referenced_row.outstanding_amount -= flt(entry.allocated_amount) reposting_rows.append(referenced_row)