From 1e4cafaa0e0220c5346fbeddcc340999eef89b47 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 06:07:23 +0000 Subject: [PATCH 01/15] fix: add portal user ownership check to supplier quotation (backport #54298) (#54299) Co-authored-by: Mihir Kandoi fix: add portal user ownership check to supplier quotation (#54298) --- .../doctype/request_for_quotation/request_for_quotation.py | 5 +++++ .../request_for_quotation/test_request_for_quotation.py | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py index 73ff7545ca5..791dc3088bc 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -474,6 +474,11 @@ def create_supplier_quotation(doc): if isinstance(doc, str): doc = json.loads(doc) + if frappe.session.user not in frappe.get_all( + "Portal User", {"parent": doc.get("supplier")}, pluck="user" + ): + frappe.throw(_("Not Permitted"), frappe.PermissionError) + try: sq_doc = frappe.get_doc( { diff --git a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py index c2578c49080..a92d8d95626 100644 --- a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py @@ -263,6 +263,13 @@ def make_request_for_quotation(**args) -> "RequestforQuotation": for data in supplier_data: rfq.append("suppliers", data) + frappe.new_doc( + "Portal User", + user="Administrator", + parent=data.get("supplier"), + parentfield="portal_users", + parenttype="Supplier", + ).insert() rfq.append( "items", From 9ee059465ada595e5d132e372a1b2fa4d9ad166d Mon Sep 17 00:00:00 2001 From: PKSowmiya05 Date: Wed, 15 Apr 2026 17:17:51 +0530 Subject: [PATCH 02/15] fix: non-collapsible in customer quick entry (cherry picked from commit 53e120269dbe716eed0a6a7157fcf47fc332f788) --- erpnext/public/js/utils/contact_address_quick_entry.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/public/js/utils/contact_address_quick_entry.js b/erpnext/public/js/utils/contact_address_quick_entry.js index a13a6d38d5b..0d20bd1a538 100644 --- a/erpnext/public/js/utils/contact_address_quick_entry.js +++ b/erpnext/public/js/utils/contact_address_quick_entry.js @@ -38,7 +38,7 @@ frappe.ui.form.ContactAddressQuickEntryForm = class ContactAddressQuickEntryForm { fieldtype: "Section Break", label: __("Primary Contact Details"), - collapsible: 1, + collapsible: 0, }, { label: __("First Name"), @@ -69,7 +69,7 @@ frappe.ui.form.ContactAddressQuickEntryForm = class ContactAddressQuickEntryForm { fieldtype: "Section Break", label: __("Primary Address Details"), - collapsible: 1, + collapsible: 0, }, { label: __("Address Line 1"), From 28367ac966bd12ea55327f91eede7f03d8c409ec Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:39:57 +0530 Subject: [PATCH 03/15] fix: reset base_rounded_total when rounded_total resets (backport #54241) (#54303) * fix: reset base_rounded_total when rounded_total resets (cherry picked from commit f8d278b73309cbc02ed1474bff88453321c0e2fd) # Conflicts: # erpnext/controllers/tests/test_taxes_and_totals.py # erpnext/public/js/controllers/taxes_and_totals.js * chore: spelling mistake (cherry picked from commit e2ac4765877851bbf0a41864e3a0589840c58c27) * chore: resolve conflicts --------- Co-authored-by: ljain112 --- erpnext/controllers/taxes_and_totals.py | 17 ++++----- .../tests/test_taxes_and_totals.py | 37 +++++++++++++++++++ .../public/js/controllers/taxes_and_totals.js | 29 ++++++++------- 3 files changed, 61 insertions(+), 22 deletions(-) create mode 100644 erpnext/controllers/tests/test_taxes_and_totals.py diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 4b6fc4f4054..03befbe851a 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -693,18 +693,17 @@ class calculate_taxes_and_totals: if self.doc.meta.get_field("rounded_total"): if self.doc.is_rounded_total_disabled(): self.doc.rounded_total = 0 - self.doc.base_rounded_total = 0 self.doc.rounding_adjustment = 0 - return - self.doc.rounded_total = round_based_on_smallest_currency_fraction( - self.doc.grand_total, self.doc.currency, self.doc.precision("rounded_total") - ) + else: + self.doc.rounded_total = round_based_on_smallest_currency_fraction( + self.doc.grand_total, self.doc.currency, self.doc.precision("rounded_total") + ) - # rounding adjustment should always be the difference vetween grand and rounded total - self.doc.rounding_adjustment = flt( - self.doc.rounded_total - self.doc.grand_total, self.doc.precision("rounding_adjustment") - ) + # rounding adjustment should always be the difference between grand and rounded total + self.doc.rounding_adjustment = flt( + self.doc.rounded_total - self.doc.grand_total, self.doc.precision("rounding_adjustment") + ) self._set_in_company_currency(self.doc, ["rounding_adjustment", "rounded_total"]) diff --git a/erpnext/controllers/tests/test_taxes_and_totals.py b/erpnext/controllers/tests/test_taxes_and_totals.py new file mode 100644 index 00000000000..504716f4684 --- /dev/null +++ b/erpnext/controllers/tests/test_taxes_and_totals.py @@ -0,0 +1,37 @@ +import frappe +from frappe.tests.utils import FrappeTestCase + +from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + + +class TestTaxesAndTotals(FrappeTestCase): + def test_disabling_rounded_total_resets_base_fields(self): + """Disabling rounded total should also clear base rounded values.""" + so = make_sales_order(do_not_save=True) + so.items[0].qty = 1 + so.items[0].rate = 1000.25 + so.items[0].price_list_rate = 1000.25 + so.items[0].discount_percentage = 0 + so.items[0].discount_amount = 0 + so.set("taxes", []) + + so.disable_rounded_total = 0 + calculate_taxes_and_totals(so) + + self.assertEqual(so.grand_total, 1000.25) + self.assertEqual(so.rounded_total, 1000.0) + self.assertEqual(so.rounding_adjustment, -0.25) + self.assertEqual(so.base_grand_total, 1000.25) + self.assertEqual(so.base_rounded_total, 1000.0) + self.assertEqual(so.base_rounding_adjustment, -0.25) + + # User toggles disable_rounded_total after values are already set. + so.disable_rounded_total = 1 + + calculate_taxes_and_totals(so) + + self.assertEqual(so.rounded_total, 0) + self.assertEqual(so.rounding_adjustment, 0) + self.assertEqual(so.base_rounded_total, 0) + self.assertEqual(so.base_rounding_adjustment, 0) diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index 4f327cebd5a..7b07c13bf11 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -674,24 +674,27 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { set_rounded_total() { var disable_rounded_total = 0; - if(frappe.meta.get_docfield(this.frm.doc.doctype, "disable_rounded_total", this.frm.doc.name)) { + if (frappe.meta.get_docfield(this.frm.doc.doctype, "disable_rounded_total", this.frm.doc.name)) { disable_rounded_total = this.frm.doc.disable_rounded_total; } else if (frappe.sys_defaults.disable_rounded_total) { disable_rounded_total = frappe.sys_defaults.disable_rounded_total; } - if (cint(disable_rounded_total)) { - this.frm.doc.rounded_total = 0; - this.frm.doc.base_rounded_total = 0; - this.frm.doc.rounding_adjustment = 0; - return; - } - - if(frappe.meta.get_docfield(this.frm.doc.doctype, "rounded_total", this.frm.doc.name)) { - this.frm.doc.rounded_total = round_based_on_smallest_currency_fraction(this.frm.doc.grand_total, - this.frm.doc.currency, precision("rounded_total")); - this.frm.doc.rounding_adjustment = flt(this.frm.doc.rounded_total - this.frm.doc.grand_total, - precision("rounding_adjustment")); + if (frappe.meta.get_docfield(this.frm.doc.doctype, "rounded_total", this.frm.doc.name)) { + if (cint(disable_rounded_total)) { + this.frm.doc.rounded_total = 0; + this.frm.doc.rounding_adjustment = 0; + } else { + this.frm.doc.rounded_total = round_based_on_smallest_currency_fraction( + this.frm.doc.grand_total, + this.frm.doc.currency, + precision("rounded_total"), + ); + this.frm.doc.rounding_adjustment = flt( + this.frm.doc.rounded_total - this.frm.doc.grand_total, + precision("rounding_adjustment"), + ); + } this.set_in_company_currency(this.frm.doc, ["rounding_adjustment", "rounded_total"]); } From d9d8fc69126b5e6295191ab84d3646611b6390b7 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:14:17 +0530 Subject: [PATCH 04/15] fix: move make_dimension_in_accounting_doctypes from after_insert to on_update (backport #54172) (#54317) * fix: move make_dimension_in_accounting_doctypes from after_insert to on_update (cherry picked from commit ee067e6015909a27462a5e6a0093a22a45c03820) # Conflicts: # erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py * chore: resolve conflicts in accounting_dimension.py --------- Co-authored-by: Shllokkk --- .../doctype/accounting_dimension/accounting_dimension.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py index 02019943c25..f654ccd3db0 100644 --- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py +++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py @@ -82,13 +82,15 @@ class AccountingDimension(Document): else: frappe.throw(_("Company {0} is added more than once").format(frappe.bold(default.company))) - def after_insert(self): + def on_update(self): if frappe.flags.in_test: make_dimension_in_accounting_doctypes(doc=self) else: frappe.enqueue( make_dimension_in_accounting_doctypes, doc=self, queue="long", enqueue_after_commit=True ) + frappe.flags.accounting_dimensions = None + frappe.flags.accounting_dimensions_details = None def on_trash(self): if frappe.flags.in_test: @@ -103,10 +105,6 @@ class AccountingDimension(Document): if not self.fieldname: self.fieldname = scrub(self.label) - def on_update(self): - frappe.flags.accounting_dimensions = None - frappe.flags.accounting_dimensions_details = None - def make_dimension_in_accounting_doctypes(doc, doclist=None): if not doclist: From 3229fce9a508d73d1c3d31171a51c08ddebc082c Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Fri, 17 Apr 2026 18:18:02 +0530 Subject: [PATCH 05/15] fix: negative batch report showing same batch-warehouse multiple times (cherry picked from commit 700572980da92e8d28353661b3f7cf03963f87c7) --- .../negative_batch_report.py | 77 +++++++++++-------- 1 file changed, 47 insertions(+), 30 deletions(-) diff --git a/erpnext/stock/report/negative_batch_report/negative_batch_report.py b/erpnext/stock/report/negative_batch_report/negative_batch_report.py index b12bd87d538..e6b2c1a747d 100644 --- a/erpnext/stock/report/negative_batch_report/negative_batch_report.py +++ b/erpnext/stock/report/negative_batch_report/negative_batch_report.py @@ -90,45 +90,62 @@ def get_data(filters) -> list[dict]: batch_negative_data = [] flt_precision = frappe.db.get_default("float_precision") or 2 + distinct_batches = set() for company in companies: - for batch in batches: - _c, data = stock_ledger_execute( - frappe._dict( - { - "company": company, - "batch_no": batch, - "from_date": add_to_date(today(), years=-12), - "to_date": today(), - "segregate_serial_batch_bundle": 1, - "warehouse": filters.get("warehouse"), - "valuation_field_type": "Currency", - } - ) - ) - - previous_qty = 0 - for row in data: - if flt(row.get("qty_after_transaction"), flt_precision) < 0: - batch_negative_data.append( + warehouses = get_warehouses(filters, company) + for warehouse in warehouses: + for batch in batches: + _c, data = stock_ledger_execute( + frappe._dict( { - "posting_date": row.get("date"), - "batch_no": row.get("batch_no"), - "item_code": row.get("item_code"), - "item_name": row.get("item_name"), - "warehouse": row.get("warehouse"), - "actual_qty": row.get("actual_qty"), - "qty_after_transaction": row.get("qty_after_transaction"), - "previous_qty": previous_qty, - "voucher_type": row.get("voucher_type"), - "voucher_no": row.get("voucher_no"), + "company": company, + "batch_no": batch, + "from_date": add_to_date(today(), years=-12), + "to_date": today(), + "segregate_serial_batch_bundle": 1, + "warehouse": warehouse, + "valuation_field_type": "Currency", } ) + ) - previous_qty = row.get("qty_after_transaction") + previous_qty = 0 + for row in data: + key = (row.get("warehouse"), batch) + if key in distinct_batches: + continue + + if flt(row.get("qty_after_transaction"), flt_precision) < 0: + batch_negative_data.append( + { + "posting_date": row.get("date"), + "batch_no": row.get("batch_no"), + "item_code": row.get("item_code"), + "item_name": row.get("item_name"), + "warehouse": row.get("warehouse"), + "actual_qty": row.get("actual_qty"), + "qty_after_transaction": row.get("qty_after_transaction"), + "previous_qty": previous_qty, + "voucher_type": row.get("voucher_type"), + "voucher_no": row.get("voucher_no"), + } + ) + + distinct_batches.add(key) + + previous_qty = row.get("qty_after_transaction") return batch_negative_data +def get_warehouses(filters, company): + warehouse_filters = {"company": company, "disabled": 0} + if filters.get("warehouse"): + warehouse_filters["name"] = filters["warehouse"] + + return frappe.get_all("Warehouse", pluck="name", filters=warehouse_filters) + + def get_batches(filters): batch_filters = {} if filters.get("item_code"): From 6b7bdfdfd395fbe0514fc8bce388d42ce881a204 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 07:57:37 +0000 Subject: [PATCH 06/15] Fix : None handling in pricing rule free item quantity calculation (backport #54375) (#54395) Fix : None handling in pricing rule free item quantity calculation (#54375) * fix(pricing_rule): handle None qty in transaction_qty calculation * Update erpnext/accounts/doctype/pricing_rule/utils.py --------- (cherry picked from commit 82438d6c720f4a19423291af6d2b8008105bf4a1) Co-authored-by: Jaganath-Tridots Co-authored-by: Jagan Co-authored-by: Mihir Kandoi --- erpnext/accounts/doctype/pricing_rule/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/pricing_rule/utils.py b/erpnext/accounts/doctype/pricing_rule/utils.py index 86a1adcb994..ec308b2f9a7 100644 --- a/erpnext/accounts/doctype/pricing_rule/utils.py +++ b/erpnext/accounts/doctype/pricing_rule/utils.py @@ -658,7 +658,7 @@ def get_product_discount_rule(pricing_rule, item_details, args=None, doc=None): if pricing_rule.is_recursive: transaction_qty = sum( [ - row.qty + flt(row.qty) for row in doc.items if not row.is_free_item and row.item_code == args.item_code From 799f89703636bc554e0dab98e8fc42de71e83267 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:34:19 +0530 Subject: [PATCH 07/15] =?UTF-8?q?fix(dashboard-trends):=20set=20default=20?= =?UTF-8?q?fiscal=20year=20and=20company=20before=20val=E2=80=A6=20(backpo?= =?UTF-8?q?rt=20#54339)=20(#54399)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashboard-trends): set default fiscal year and company before val… (#54339) * fix(dashboard-trends): set default fiscal year and company before validating filters Ensure and are populated with default values * fix(dashboard-trends): ensure fiscal_year and company are properly set before validation to avoid empty filter issues * Update erpnext/controllers/trends.py --------- Co-authored-by: Mihir Kandoi (cherry picked from commit d61b5fd5f61dfdcf210600a2d72671ccfd5e406e) # Conflicts: # erpnext/controllers/trends.py * chore: resolve conflicts --------- Co-authored-by: Ahmed AbuKhatwa <82771130+AhmedAbokhatwa@users.noreply.github.com> Co-authored-by: Mihir Kandoi --- erpnext/controllers/trends.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/erpnext/controllers/trends.py b/erpnext/controllers/trends.py index bc4e2b346d4..f8e152f5299 100644 --- a/erpnext/controllers/trends.py +++ b/erpnext/controllers/trends.py @@ -4,7 +4,9 @@ import frappe from frappe import _ -from frappe.utils import getdate +from frappe.utils import DateTimeLikeObject, getdate, today + +from erpnext.accounts.utils import get_fiscal_year def get_columns(filters, trans): @@ -45,6 +47,10 @@ def get_columns(filters, trans): def validate_filters(filters): + if not filters.get("fiscal_year"): + filters["fiscal_year"] = get_fiscal_year(today())[0] + if not filters.get("company"): + filters["company"] = frappe.defaults.get_user_default("Company") for f in ["Fiscal Year", "Based On", "Period", "Company"]: if not filters.get(f.lower().replace(" ", "_")): frappe.throw(_("{0} is mandatory").format(_(f))) From 1ccbc9f621d8e67cdd3217dd36dd8b226604be06 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:53:44 +0530 Subject: [PATCH 08/15] fix: changed qty validation from qty field to stock_qty (backport #54352) (#54356) fix: changed qty validation from qty field to stock_qty (#54352) (cherry picked from commit ba01d66c24a7efe56dd433155ba5aece3d33bb63) Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com> --- erpnext/selling/doctype/quotation/quotation.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index b4e433ac805..2f70260a68a 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -188,7 +188,7 @@ class Quotation(SellingController): ) for row in self._items: - if row.name not in ordered_items or row.qty > ordered_items[row.name]: + if row.name not in ordered_items or row.stock_qty > ordered_items[row.name]: return "Partially Ordered" return "Ordered" @@ -409,9 +409,9 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar target.run_method("calculate_taxes_and_totals") def update_item(obj, target, source_parent): - balance_qty = obj.qty if is_unit_price_row(obj) else obj.qty - ordered_items.get(obj.name, 0.0) - target.qty = balance_qty if balance_qty > 0 else 0 - target.stock_qty = flt(target.qty) * flt(obj.conversion_factor) + balance_stock_qty = obj.stock_qty - ordered_items.get(obj.name, 0.0) + target.stock_qty = balance_stock_qty if balance_stock_qty > 0 else 0 + target.qty = flt(target.stock_qty) / flt(obj.conversion_factor) if obj.against_blanket_order: target.against_blanket_order = obj.against_blanket_order @@ -425,7 +425,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, ar 2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty 3. If no selections: Simple row: Map if adequate qty """ - if not ((item.qty > ordered_items.get(item.name, 0.0)) or is_unit_price_row(item)): + if not ((item.stock_qty > ordered_items.get(item.name, 0.0)) or is_unit_price_row(item)): return False if not selected_rows: From ffa0268a578eaec41ef748fad8fb5caa49dd6593 Mon Sep 17 00:00:00 2001 From: Pandiyan P Date: Mon, 20 Apr 2026 11:58:16 +0530 Subject: [PATCH 09/15] fix: fetch item tax template from item group when creating item (#54405) --- erpnext/stock/doctype/item/item.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index a84f49286ad..eec66717a1f 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -791,6 +791,20 @@ class Item(Document): {"company": defaults.get("company"), "default_warehouse": defaults.default_warehouse}, ) + item_group = frappe.get_cached_doc("Item Group", self.item_group) + if not self.taxes and item_group.taxes: + for tax in item_group.taxes: + self.append( + "taxes", + { + "item_tax_template": tax.item_tax_template, + "tax_category": tax.tax_category, + "valid_from": tax.valid_from, + "minimum_net_rate": tax.minimum_net_rate, + "maximum_net_rate": tax.maximum_net_rate, + }, + ) + def update_variants(self): if self.flags.dont_update_variants or frappe.db.get_single_value( "Item Variant Settings", "do_not_update_variants" From 2c1ea8d30c1fe4fed81086b3f77eeafc7b2d1bf0 Mon Sep 17 00:00:00 2001 From: Ravibharathi <131471282+ravibharathi656@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:56:37 +0530 Subject: [PATCH 10/15] fix(vat audit report): fallback to item name when item code is missing (#54049) * fix(vat audit report): fallback to item name when item code is missing * fix: validate south africa company selection * fix: simplify parent item lookup * fix: handle missing item mapping * fix: use list instead of set --- .../south_africa_vat_settings.py | 12 +- .../vat_audit_report/vat_audit_report.js | 7 + .../vat_audit_report/vat_audit_report.py | 192 +++++++++--------- 3 files changed, 115 insertions(+), 96 deletions(-) diff --git a/erpnext/regional/doctype/south_africa_vat_settings/south_africa_vat_settings.py b/erpnext/regional/doctype/south_africa_vat_settings/south_africa_vat_settings.py index aa3cd4b05af..bb1b67e6e07 100644 --- a/erpnext/regional/doctype/south_africa_vat_settings/south_africa_vat_settings.py +++ b/erpnext/regional/doctype/south_africa_vat_settings/south_africa_vat_settings.py @@ -1,9 +1,12 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt -# import frappe +import frappe +from frappe import _ from frappe.model.document import Document +from erpnext import get_region + class SouthAfricaVATSettings(Document): # begin: auto-generated types @@ -22,4 +25,9 @@ class SouthAfricaVATSettings(Document): vat_accounts: DF.Table[SouthAfricaVATAccount] # end: auto-generated types - pass + def validate(self): + self.validate_company_region() + + def validate_company_region(self): + if self.company and get_region(self.company) != "South Africa": + frappe.throw(_("Company {0} is not in South Africa.").format(frappe.bold(self.company))) diff --git a/erpnext/regional/report/vat_audit_report/vat_audit_report.js b/erpnext/regional/report/vat_audit_report/vat_audit_report.js index de4fde596cd..b9aa11f8061 100644 --- a/erpnext/regional/report/vat_audit_report/vat_audit_report.js +++ b/erpnext/regional/report/vat_audit_report/vat_audit_report.js @@ -10,6 +10,13 @@ frappe.query_reports["VAT Audit Report"] = { options: "Company", reqd: 1, default: frappe.defaults.get_user_default("Company"), + get_query: function () { + return { + filters: { + country: "South Africa", + }, + }; + }, }, { fieldname: "from_date", diff --git a/erpnext/regional/report/vat_audit_report/vat_audit_report.py b/erpnext/regional/report/vat_audit_report/vat_audit_report.py index 718b6c0df31..0c73c8e10eb 100644 --- a/erpnext/regional/report/vat_audit_report/vat_audit_report.py +++ b/erpnext/regional/report/vat_audit_report/vat_audit_report.py @@ -6,8 +6,11 @@ import json import frappe from frappe import _ +from frappe.query_builder.functions import Coalesce, NullIf from frappe.utils import formatdate, get_link_to_form +from erpnext import get_region + def execute(filters=None): return VATAuditReport(filters).run() @@ -21,19 +24,10 @@ class VATAuditReport: self.doctypes = ["Purchase Invoice", "Sales Invoice"] def run(self): + self.validate_company_region() self.get_sa_vat_accounts() self.get_columns() for doctype in self.doctypes: - self.select_columns = """ - name as voucher_no, - posting_date, remarks""" - columns = ( - ", supplier as party, credit_to as account" - if doctype == "Purchase Invoice" - else ", customer as party, debit_to as account" - ) - self.select_columns += columns - self.get_invoice_data(doctype) if self.invoices: @@ -43,6 +37,14 @@ class VATAuditReport: return self.columns, self.data + def validate_company_region(self): + if self.filters.company and get_region(self.filters.company) != "South Africa": + frappe.throw( + _( + "The company {0} is not in South Africa. VAT Audit Report is only available for companies in South Africa." + ).format(frappe.bold(self.filters.company)) + ) + def get_sa_vat_accounts(self): self.sa_vat_accounts = frappe.get_all( "South Africa VAT Account", filters={"parent": self.filters.company}, pluck="account" @@ -54,47 +56,59 @@ class VATAuditReport: frappe.throw(_("Please set VAT Accounts in {0}").format(link_to_settings)) def get_invoice_data(self, doctype): - conditions = self.get_conditions() self.invoices = frappe._dict() - - invoice_data = frappe.db.sql( - f""" - SELECT - {self.select_columns} - FROM - `tab{doctype}` - WHERE - docstatus = 1 {conditions} - and is_opening = 'No' - ORDER BY - posting_date DESC - """, - self.filters, - as_dict=1, + invoice_doctype = frappe.qb.DocType(doctype) + party_field = invoice_doctype.supplier if doctype == "Purchase Invoice" else invoice_doctype.customer + account_field = ( + invoice_doctype.credit_to if doctype == "Purchase Invoice" else invoice_doctype.debit_to ) - for d in invoice_data: - self.invoices.setdefault(d.voucher_no, d) + query = ( + frappe.qb.from_(invoice_doctype) + .select( + invoice_doctype.name.as_("voucher_no"), + invoice_doctype.posting_date, + invoice_doctype.remarks, + party_field.as_("party"), + account_field.as_("account"), + ) + .where(invoice_doctype.docstatus == 1) + .where(invoice_doctype.is_opening == "No") + .orderby(invoice_doctype.posting_date, order=frappe.qb.desc) + ) + + if self.filters.get("company"): + query = query.where(invoice_doctype.company == self.filters.company) + if self.filters.get("from_date"): + query = query.where(invoice_doctype.posting_date >= self.filters.from_date) + if self.filters.get("to_date"): + query = query.where(invoice_doctype.posting_date <= self.filters.to_date) + + invoice_data = query.run(as_dict=True) + + for row in invoice_data: + self.invoices.setdefault(row.voucher_no, row) def get_invoice_items(self, doctype): self.invoice_items = frappe._dict() + item_doctype = frappe.qb.DocType(doctype + " Item") - items = frappe.db.sql( - """ - SELECT - item_code, parent, base_net_amount, is_zero_rated - FROM - `tab{} Item` - WHERE - parent in ({}) - """.format(doctype, ", ".join(["%s"] * len(self.invoices))), - tuple(self.invoices), - as_dict=1, + items = ( + frappe.qb.from_(item_doctype) + .select( + Coalesce(NullIf(item_doctype.item_code, ""), item_doctype.item_name).as_("item"), + item_doctype.parent, + item_doctype.base_net_amount, + item_doctype.is_zero_rated, + ) + .where(item_doctype.parent.isin(list(self.invoices.keys()))) + .run(as_dict=True) ) - for d in items: - self.invoice_items.setdefault(d.parent, {}).setdefault(d.item_code, {"net_amount": 0.0}) - self.invoice_items[d.parent][d.item_code]["net_amount"] += d.get("base_net_amount", 0) - self.invoice_items[d.parent][d.item_code]["is_zero_rated"] = d.is_zero_rated + + for row in items: + self.invoice_items.setdefault(row.parent, {}).setdefault(row.item, {"net_amount": 0.0}) + self.invoice_items[row.parent][row.item]["net_amount"] += row.get("base_net_amount", 0) + self.invoice_items[row.parent][row.item]["is_zero_rated"] = row.is_zero_rated def get_items_based_on_tax_rate(self, doctype): self.items_based_on_tax_rate = frappe._dict() @@ -103,52 +117,54 @@ class VATAuditReport: "Purchase Taxes and Charges" if doctype == "Purchase Invoice" else "Sales Taxes and Charges" ) - self.tax_details = frappe.db.sql( - """ - SELECT - parent, account_head, item_wise_tax_detail - FROM - `tab{}` - WHERE - parenttype = {} and docstatus = 1 - and parent in ({}) - ORDER BY - account_head - """.format(self.tax_doctype, "%s", ", ".join(["%s"] * len(self.invoices.keys()))), - tuple([doctype, *list(self.invoices.keys())]), + tax_doctype = frappe.qb.DocType(self.tax_doctype) + self.tax_details = ( + frappe.qb.from_(tax_doctype) + .select(tax_doctype.parent, tax_doctype.account_head, tax_doctype.item_wise_tax_detail) + .where(tax_doctype.parenttype == doctype) + .where(tax_doctype.docstatus == 1) + .where(tax_doctype.parent.isin(list(self.invoices.keys()))) + .where(tax_doctype.account_head.isin(self.sa_vat_accounts)) + .orderby(tax_doctype.account_head) + .run(as_dict=True) ) - for parent, account, item_wise_tax_detail in self.tax_details: - if item_wise_tax_detail: - try: - if account in self.sa_vat_accounts: - item_wise_tax_detail = json.loads(item_wise_tax_detail) - else: - continue - for item_code, taxes in item_wise_tax_detail.items(): - is_zero_rated = self.invoice_items.get(parent).get(item_code).get("is_zero_rated") - # to skip items with non-zero tax rate in multiple rows - if taxes[0] == 0 and not is_zero_rated: - continue - tax_rate = self.get_item_amount_map(parent, item_code, taxes) + for tax_detail in self.tax_details: + if not tax_detail.item_wise_tax_detail: + continue - if tax_rate is not None: - rate_based_dict = self.items_based_on_tax_rate.setdefault(parent, {}).setdefault( - tax_rate, [] - ) - if item_code not in rate_based_dict: - rate_based_dict.append(item_code) - except ValueError: + try: + item_wise_tax_detail = json.loads(tax_detail.item_wise_tax_detail) + except ValueError: + continue + + parent_items = self.invoice_items.get(tax_detail.parent, {}) + parent_tax_rates = self.items_based_on_tax_rate.setdefault(tax_detail.parent, {}) + + for item, taxes in item_wise_tax_detail.items(): + is_zero_rated = parent_items.get(item, {}).get("is_zero_rated") + # to skip items with non-zero tax rate in multiple rows + if taxes[0] == 0 and not is_zero_rated: continue - def get_item_amount_map(self, parent, item_code, taxes): - net_amount = self.invoice_items.get(parent).get(item_code).get("net_amount") + tax_rate = self.get_item_amount_map(tax_detail.parent, item, taxes) + if tax_rate is not None: + rate_based_dict = parent_tax_rates.setdefault(tax_rate, []) + if item not in rate_based_dict: + rate_based_dict.append(item) + + def get_item_amount_map(self, parent, item, taxes): + item_details = self.invoice_items.get(parent, {}).get(item) + if not item_details: + return None + + net_amount = item_details.get("net_amount", 0) tax_rate = taxes[0] tax_amount = taxes[1] gross_amount = net_amount + tax_amount self.item_tax_rate.setdefault(parent, {}).setdefault( - item_code, + item, { "tax_rate": tax_rate, "gross_amount": 0.0, @@ -157,24 +173,12 @@ class VATAuditReport: }, ) - self.item_tax_rate[parent][item_code]["net_amount"] += net_amount - self.item_tax_rate[parent][item_code]["tax_amount"] += tax_amount - self.item_tax_rate[parent][item_code]["gross_amount"] += gross_amount + self.item_tax_rate[parent][item]["net_amount"] += net_amount + self.item_tax_rate[parent][item]["tax_amount"] += tax_amount + self.item_tax_rate[parent][item]["gross_amount"] += gross_amount return tax_rate - def get_conditions(self): - conditions = "" - for opts in ( - ("company", " and company=%(company)s"), - ("from_date", " and posting_date>=%(from_date)s"), - ("to_date", " and posting_date<=%(to_date)s"), - ): - if self.filters.get(opts[0]): - conditions += opts[1] - - return conditions - def get_data(self, doctype): consolidated_data = self.get_consolidated_data(doctype) section_name = _("Purchases") if doctype == "Purchase Invoice" else _("Sales") From 813f4644a05b4a23b4a1520d0043f415e78dfb95 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:48:06 +0000 Subject: [PATCH 11/15] fix(pos_invoice_item): fetch `grant_commission` from `item_code` (backport #54413) (#54417) * fix(pos_invoice_item): fetch `grant_commission` from `item_code` (#54413) (cherry picked from commit 6c51e4cd1fc46835cf812976d2a075b4ec27ca82) # Conflicts: # erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json * chore: resolve conflicts --------- Co-authored-by: diptanilsaha --- .../accounts/doctype/pos_invoice_item/pos_invoice_item.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json b/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json index 0a0be67edc2..38c36bb245c 100644 --- a/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json +++ b/erpnext/accounts/doctype/pos_invoice_item/pos_invoice_item.json @@ -812,6 +812,7 @@ }, { "default": "0", + "fetch_from": "item_code.grant_commission", "fieldname": "grant_commission", "fieldtype": "Check", "label": "Grant Commission", @@ -858,7 +859,7 @@ ], "istable": 1, "links": [], - "modified": "2024-05-07 15:56:54.343317", + "modified": "2026-04-20 16:16:12.322024", "modified_by": "Administrator", "module": "Accounts", "name": "POS Invoice Item", From 35bd43775cc49ec64f6d276991f549c516082697 Mon Sep 17 00:00:00 2001 From: sarathibalamurugan Date: Mon, 20 Apr 2026 16:17:38 +0530 Subject: [PATCH 12/15] fix: clear conditions table when calculate_based_on is set to Fixed (cherry picked from commit d73920be12727c83c60dd79c5e3b8be6935702a7) --- erpnext/accounts/doctype/shipping_rule/shipping_rule.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/erpnext/accounts/doctype/shipping_rule/shipping_rule.js b/erpnext/accounts/doctype/shipping_rule/shipping_rule.js index 1ece3e6c3dd..5c02fd2f127 100644 --- a/erpnext/accounts/doctype/shipping_rule/shipping_rule.js +++ b/erpnext/accounts/doctype/shipping_rule/shipping_rule.js @@ -25,6 +25,10 @@ frappe.ui.form.on("Shipping Rule", { }, calculate_based_on: function (frm) { frm.trigger("toggle_reqd"); + if (frm.doc.calculate_based_on === "Fixed") { + frm.clear_table("conditions"); + frm.refresh_field("conditions"); + } }, toggle_reqd: function (frm) { frm.toggle_reqd("shipping_amount", frm.doc.calculate_based_on === "Fixed"); From 9e10ecc4cb0530ee3ab19e7999c404a1d3c6a01f Mon Sep 17 00:00:00 2001 From: ravibharathi656 Date: Mon, 20 Apr 2026 18:58:06 +0530 Subject: [PATCH 13/15] fix: clear shipping rule conditions for fixed shipping rule (cherry picked from commit d6bb0ae093a15b4c5430d43d3eed9e6000c920d5) --- erpnext/accounts/doctype/shipping_rule/shipping_rule.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py index abbb6a58119..a2db95d03c9 100644 --- a/erpnext/accounts/doctype/shipping_rule/shipping_rule.py +++ b/erpnext/accounts/doctype/shipping_rule/shipping_rule.py @@ -58,6 +58,11 @@ class ShippingRule(Document): self.validate_overlapping_shipping_rule_conditions() def validate_from_to_values(self): + if self.calculate_based_on == "Fixed": + if self.conditions: + self.set("conditions", []) + return + zero_to_values = [] for d in self.get("conditions"): From e22326065d0e20fe87baed0929e4643f85ae4347 Mon Sep 17 00:00:00 2001 From: Lakshit Jain Date: Wed, 22 Apr 2026 00:02:19 +0530 Subject: [PATCH 14/15] feat: enhance tax withholding details report with additional columns support (backport #54409) (#54432) --- .../tax_withholding_details.py | 66 ++++++++++++++--- .../test_tax_withholding_details.py | 40 ++++++++++- .../tds_computation_summary.py | 70 +++++++++++-------- 3 files changed, 135 insertions(+), 41 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..ea6a07b5f16 100644 --- a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py +++ b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py @@ -11,6 +11,10 @@ from erpnext.accounts.utils import get_currency_precision def execute(filters=None): + return _execute(filters) + + +def _execute(filters=None, additional_table_columns=None): if filters.get("party_type") == "Customer": party_naming_by = frappe.db.get_single_value("Selling Settings", "cust_master_name") else: @@ -25,9 +29,9 @@ def execute(filters=None): net_total_map, ) = get_tds_docs(filters) - columns = get_columns(filters) + columns = get_columns(filters, additional_table_columns) - res = get_result(filters, tds_accounts, tax_category_map, net_total_map) + res = get_result(filters, tds_accounts, tax_category_map, net_total_map, additional_table_columns) return columns, res @@ -38,12 +42,14 @@ def validate_filters(filters): frappe.throw(_("From Date must be before To Date")) -def get_result(filters, tds_accounts, tax_category_map, net_total_map): +def get_result(filters, tds_accounts, tax_category_map, net_total_map, additional_table_columns=None): party_names = {v.party for v in net_total_map.values() if v.party} party_map = get_party_pan_map(filters.get("party_type"), party_names) tax_rate_map = get_tax_rate_map(filters) gle_map = get_gle_map(net_total_map) precision = get_currency_precision() + twc = get_tax_withholding_category_details(additional_table_columns) + twc_additional_columns = _get_twc_additional_columns(additional_table_columns) entries = {} for (voucher_type, name), details in gle_map.items(): @@ -119,8 +125,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, @@ -135,17 +141,47 @@ def get_result(filters, tds_accounts, tax_category_map, net_total_map): } ) + if tax_withholding_category: + if twc_details := twc.get(tax_withholding_category, {}): + for col in twc_additional_columns or []: + row[col] = twc_details.get(col) + key = entry.voucher_no if key in entries: entries[key]["tax_amount"] += tax_amount 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 +def get_tax_withholding_category_details(additional_table_columns=None): + if not additional_table_columns: + return {} + + category_fields = _get_twc_additional_columns(additional_table_columns) + + if not category_fields: + return {} + + rows = frappe.get_all("Tax Withholding Category", fields=["name", *category_fields]) + + return {row["name"]: row for row in rows} + + +def _get_twc_additional_columns(additional_table_columns): + if not additional_table_columns: + return [] + + return [ + col.get("fieldname") + for col in additional_table_columns + if col.get("_doctype") == "Tax Withholding Category" and col.get("fieldname") + ] + + def get_party_pan_map(party_type, party_names): party_map = frappe._dict() @@ -201,19 +237,22 @@ def get_gle_map(net_total_map): return gle_map -def get_columns(filters): +def get_columns(filters, additional_table_columns=None): 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": "section_code", + "fieldname": "tax_withholding_category", "fieldtype": "Link", - "width": 90, + "width": 180, }, {"label": _(frappe.unscrub(pan)), "fieldname": pan, "fieldtype": "Data", "width": 60}, ] + if additional_table_columns: + columns.extend(additional_table_columns) + if filters.naming_series == "Naming Series": columns.append( { @@ -236,7 +275,12 @@ def get_columns(filters): columns.extend( [ - {"label": _("Entity Type"), "fieldname": "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/tax_withholding_details/test_tax_withholding_details.py b/erpnext/accounts/report/tax_withholding_details/test_tax_withholding_details.py index 56dba9d86d3..4662a2d7b51 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 @@ -11,7 +11,7 @@ from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sal from erpnext.accounts.doctype.tax_withholding_category.test_tax_withholding_category import ( create_tax_withholding_category, ) -from erpnext.accounts.report.tax_withholding_details.tax_withholding_details import execute +from erpnext.accounts.report.tax_withholding_details.tax_withholding_details import _execute, execute from erpnext.accounts.test.accounts_mixin import AccountsTestMixin from erpnext.accounts.utils import get_fiscal_year @@ -112,13 +112,49 @@ class TestTaxWithholdingDetails(AccountsTestMixin, FrappeTestCase): ] self.check_expected_values(result, expected_values) + def test_additional_tax_withholding_category_column(self): + tds_category = "TDS - Additional Column" + create_tax_category(tds_category, rate=10, account="TDS - _TC", cumulative_threshold=1) + inv = make_purchase_invoice(rate=1000, do_not_submit=True) + inv.apply_tds = 1 + inv.tax_withholding_category = tds_category + inv.submit() + + field_name = "category_name" + expected_value = "Additional Column Display Name" + frappe.db.set_value("Tax Withholding Category", tds_category, field_name, expected_value) + + additional_table_columns = [ + { + "label": "Category Name", + "fieldname": field_name, + "fieldtype": "Data", + "width": 140, + "_doctype": "Tax Withholding Category", + } + ] + + filters = frappe._dict( + company="_Test Company", + party_type="Supplier", + from_date=today(), + to_date=today(), + ) + + columns, data = _execute(filters, additional_table_columns) + + self.assertTrue(any(col.get("fieldname") == field_name for col in columns)) + invoice_row = next((row for row in data if row.get("ref_no") == inv.name), None) + self.assertIsNotNone(invoice_row) + self.assertEqual(invoice_row.get(field_name), expected_value) + def check_expected_values(self, result, expected_values): for i in range(len(result)): voucher = frappe._dict(result[i]) 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..2a4eaf841e5 100644 --- a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py +++ b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py @@ -2,6 +2,7 @@ import frappe from frappe import _ from erpnext.accounts.report.tax_withholding_details.tax_withholding_details import ( + _get_twc_additional_columns, get_result, get_tds_docs, ) @@ -9,6 +10,10 @@ from erpnext.accounts.utils import get_fiscal_year def execute(filters=None): + return _execute(filters) + + +def _execute(filters=None, additional_table_columns=None): if filters.get("party_type") == "Customer": party_naming_by = frappe.db.get_single_value("Selling Settings", "cust_master_name") else: @@ -18,15 +23,15 @@ def execute(filters=None): validate_filters(filters) - columns = get_columns(filters) + columns = get_columns(filters, additional_table_columns) ( tds_accounts, tax_category_map, net_total_map, ) = get_tds_docs(filters) - res = get_result(filters, tds_accounts, tax_category_map, net_total_map) - final_result = group_by_party_and_category(res, filters) + res = get_result(filters, tds_accounts, tax_category_map, net_total_map, additional_table_columns) + final_result = group_by_party_and_category(res, filters, additional_table_columns) return columns, final_result @@ -44,32 +49,33 @@ def validate_filters(filters): filters["fiscal_year"] = from_year -def group_by_party_and_category(data, filters): +def group_by_party_and_category(data, filters, additional_table_columns=None): party_category_wise_map = {} + twc_additional_columns = _get_twc_additional_columns(additional_table_columns) for row in data: - party_category_wise_map.setdefault( - (row.get("party"), row.get("section_code")), - { - "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"), - "rate": row.get("rate"), - "total_amount": 0.0, - "tax_amount": 0.0, - }, - ) + key = (row.get("party"), row.get("tax_withholding_category")) + default_row = { + "pan": row.get("pan"), + "tax_id": row.get("tax_id"), + "party": row.get("party"), + "party_name": row.get("party_name"), + "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( - "total_amount", 0.0 - ) + if twc_additional_columns: + for col in twc_additional_columns: + default_row[col] = row.get(col) - party_category_wise_map.get((row.get("party"), row.get("section_code")))["tax_amount"] += row.get( - "tax_amount", 0.0 - ) + party_category_wise_map.setdefault(key, default_row) + + 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) @@ -84,7 +90,7 @@ def get_final_result(party_category_wise_map): return out -def get_columns(filters): +def get_columns(filters, additional_table_columns=None): pan = "pan" if frappe.db.has_column(filters.party_type, "pan") else "tax_id" columns = [ {"label": _(frappe.unscrub(pan)), "fieldname": pan, "fieldtype": "Data", "width": 90}, @@ -107,16 +113,24 @@ def get_columns(filters): } ) + if additional_table_columns: + columns.extend(additional_table_columns) + columns.extend( [ { - "label": _("Section Code"), + "label": _("Tax Withholding Category"), "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": _(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 bd957a9bbcb8f109b3e214a7f82f0fca51cafa83 Mon Sep 17 00:00:00 2001 From: diptanilsaha Date: Wed, 22 Apr 2026 00:19:08 +0530 Subject: [PATCH 15/15] Revert "feat: enhance tax withholding details report with additional columns support (backport #54409)" (#54458) --- .../tax_withholding_details.py | 66 +++-------------- .../test_tax_withholding_details.py | 40 +---------- .../tds_computation_summary.py | 70 ++++++++----------- 3 files changed, 41 insertions(+), 135 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 ea6a07b5f16..d93c60b2cf4 100644 --- a/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py +++ b/erpnext/accounts/report/tax_withholding_details/tax_withholding_details.py @@ -11,10 +11,6 @@ from erpnext.accounts.utils import get_currency_precision def execute(filters=None): - return _execute(filters) - - -def _execute(filters=None, additional_table_columns=None): if filters.get("party_type") == "Customer": party_naming_by = frappe.db.get_single_value("Selling Settings", "cust_master_name") else: @@ -29,9 +25,9 @@ def _execute(filters=None, additional_table_columns=None): net_total_map, ) = get_tds_docs(filters) - columns = get_columns(filters, additional_table_columns) + columns = get_columns(filters) - res = get_result(filters, tds_accounts, tax_category_map, net_total_map, additional_table_columns) + res = get_result(filters, tds_accounts, tax_category_map, net_total_map) return columns, res @@ -42,14 +38,12 @@ def validate_filters(filters): frappe.throw(_("From Date must be before To Date")) -def get_result(filters, tds_accounts, tax_category_map, net_total_map, additional_table_columns=None): +def get_result(filters, tds_accounts, tax_category_map, net_total_map): party_names = {v.party for v in net_total_map.values() if v.party} party_map = get_party_pan_map(filters.get("party_type"), party_names) tax_rate_map = get_tax_rate_map(filters) gle_map = get_gle_map(net_total_map) precision = get_currency_precision() - twc = get_tax_withholding_category_details(additional_table_columns) - twc_additional_columns = _get_twc_additional_columns(additional_table_columns) entries = {} for (voucher_type, name), details in gle_map.items(): @@ -125,8 +119,8 @@ def get_result(filters, tds_accounts, tax_category_map, net_total_map, additiona row.update( { - "tax_withholding_category": tax_withholding_category or "", - "party_entity_type": party_map.get(party, {}).get(party_type), + "section_code": tax_withholding_category or "", + "entity_type": party_map.get(party, {}).get(party_type), "rate": rate, "total_amount": total_amount, "grand_total": grand_total, @@ -141,47 +135,17 @@ def get_result(filters, tds_accounts, tax_category_map, net_total_map, additiona } ) - if tax_withholding_category: - if twc_details := twc.get(tax_withholding_category, {}): - for col in twc_additional_columns or []: - row[col] = twc_details.get(col) - key = entry.voucher_no if key in entries: entries[key]["tax_amount"] += tax_amount else: entries[key] = row out = list(entries.values()) - out.sort(key=lambda x: (x["tax_withholding_category"], x["transaction_date"], x["ref_no"])) + out.sort(key=lambda x: (x["section_code"], x["transaction_date"], x["ref_no"])) return out -def get_tax_withholding_category_details(additional_table_columns=None): - if not additional_table_columns: - return {} - - category_fields = _get_twc_additional_columns(additional_table_columns) - - if not category_fields: - return {} - - rows = frappe.get_all("Tax Withholding Category", fields=["name", *category_fields]) - - return {row["name"]: row for row in rows} - - -def _get_twc_additional_columns(additional_table_columns): - if not additional_table_columns: - return [] - - return [ - col.get("fieldname") - for col in additional_table_columns - if col.get("_doctype") == "Tax Withholding Category" and col.get("fieldname") - ] - - def get_party_pan_map(party_type, party_names): party_map = frappe._dict() @@ -237,22 +201,19 @@ def get_gle_map(net_total_map): return gle_map -def get_columns(filters, additional_table_columns=None): +def get_columns(filters): pan = "pan" if frappe.db.has_column(filters.party_type, "pan") else "tax_id" columns = [ { - "label": _("Tax Withholding Category"), + "label": _("Section Code"), "options": "Tax Withholding Category", - "fieldname": "tax_withholding_category", + "fieldname": "section_code", "fieldtype": "Link", - "width": 180, + "width": 90, }, {"label": _(frappe.unscrub(pan)), "fieldname": pan, "fieldtype": "Data", "width": 60}, ] - if additional_table_columns: - columns.extend(additional_table_columns) - if filters.naming_series == "Naming Series": columns.append( { @@ -275,12 +236,7 @@ def get_columns(filters, additional_table_columns=None): columns.extend( [ - { - "label": _(f"{filters.get('party_type', 'Party')} Type"), - "fieldname": "party_entity_type", - "fieldtype": "Data", - "width": 100, - }, + {"label": _("Entity Type"), "fieldname": "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 4662a2d7b51..56dba9d86d3 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 @@ -11,7 +11,7 @@ from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sal from erpnext.accounts.doctype.tax_withholding_category.test_tax_withholding_category import ( create_tax_withholding_category, ) -from erpnext.accounts.report.tax_withholding_details.tax_withholding_details import _execute, execute +from erpnext.accounts.report.tax_withholding_details.tax_withholding_details import execute from erpnext.accounts.test.accounts_mixin import AccountsTestMixin from erpnext.accounts.utils import get_fiscal_year @@ -112,49 +112,13 @@ class TestTaxWithholdingDetails(AccountsTestMixin, FrappeTestCase): ] self.check_expected_values(result, expected_values) - def test_additional_tax_withholding_category_column(self): - tds_category = "TDS - Additional Column" - create_tax_category(tds_category, rate=10, account="TDS - _TC", cumulative_threshold=1) - inv = make_purchase_invoice(rate=1000, do_not_submit=True) - inv.apply_tds = 1 - inv.tax_withholding_category = tds_category - inv.submit() - - field_name = "category_name" - expected_value = "Additional Column Display Name" - frappe.db.set_value("Tax Withholding Category", tds_category, field_name, expected_value) - - additional_table_columns = [ - { - "label": "Category Name", - "fieldname": field_name, - "fieldtype": "Data", - "width": 140, - "_doctype": "Tax Withholding Category", - } - ] - - filters = frappe._dict( - company="_Test Company", - party_type="Supplier", - from_date=today(), - to_date=today(), - ) - - columns, data = _execute(filters, additional_table_columns) - - self.assertTrue(any(col.get("fieldname") == field_name for col in columns)) - invoice_row = next((row for row in data if row.get("ref_no") == inv.name), None) - self.assertIsNotNone(invoice_row) - self.assertEqual(invoice_row.get(field_name), expected_value) - def check_expected_values(self, result, expected_values): for i in range(len(result)): voucher = frappe._dict(result[i]) voucher_expected_values = expected_values[i] voucher_actual_values = ( voucher.ref_no, - voucher.tax_withholding_category, + voucher.section_code, 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 2a4eaf841e5..cbceaeed092 100644 --- a/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py +++ b/erpnext/accounts/report/tds_computation_summary/tds_computation_summary.py @@ -2,7 +2,6 @@ import frappe from frappe import _ from erpnext.accounts.report.tax_withholding_details.tax_withholding_details import ( - _get_twc_additional_columns, get_result, get_tds_docs, ) @@ -10,10 +9,6 @@ from erpnext.accounts.utils import get_fiscal_year def execute(filters=None): - return _execute(filters) - - -def _execute(filters=None, additional_table_columns=None): if filters.get("party_type") == "Customer": party_naming_by = frappe.db.get_single_value("Selling Settings", "cust_master_name") else: @@ -23,15 +18,15 @@ def _execute(filters=None, additional_table_columns=None): validate_filters(filters) - columns = get_columns(filters, additional_table_columns) + columns = get_columns(filters) ( tds_accounts, tax_category_map, net_total_map, ) = get_tds_docs(filters) - res = get_result(filters, tds_accounts, tax_category_map, net_total_map, additional_table_columns) - final_result = group_by_party_and_category(res, filters, additional_table_columns) + res = get_result(filters, tds_accounts, tax_category_map, net_total_map) + final_result = group_by_party_and_category(res, filters) return columns, final_result @@ -49,33 +44,32 @@ def validate_filters(filters): filters["fiscal_year"] = from_year -def group_by_party_and_category(data, filters, additional_table_columns=None): +def group_by_party_and_category(data, filters): party_category_wise_map = {} - twc_additional_columns = _get_twc_additional_columns(additional_table_columns) for row in data: - key = (row.get("party"), row.get("tax_withholding_category")) - default_row = { - "pan": row.get("pan"), - "tax_id": row.get("tax_id"), - "party": row.get("party"), - "party_name": row.get("party_name"), - "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.setdefault( + (row.get("party"), row.get("section_code")), + { + "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"), + "rate": row.get("rate"), + "total_amount": 0.0, + "tax_amount": 0.0, + }, + ) - if twc_additional_columns: - for col in twc_additional_columns: - default_row[col] = row.get(col) + party_category_wise_map.get((row.get("party"), row.get("section_code")))["total_amount"] += row.get( + "total_amount", 0.0 + ) - party_category_wise_map.setdefault(key, default_row) - - 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) + party_category_wise_map.get((row.get("party"), row.get("section_code")))["tax_amount"] += row.get( + "tax_amount", 0.0 + ) final_result = get_final_result(party_category_wise_map) @@ -90,7 +84,7 @@ def get_final_result(party_category_wise_map): return out -def get_columns(filters, additional_table_columns=None): +def get_columns(filters): pan = "pan" if frappe.db.has_column(filters.party_type, "pan") else "tax_id" columns = [ {"label": _(frappe.unscrub(pan)), "fieldname": pan, "fieldtype": "Data", "width": 90}, @@ -113,24 +107,16 @@ def get_columns(filters, additional_table_columns=None): } ) - if additional_table_columns: - columns.extend(additional_table_columns) - columns.extend( [ { - "label": _("Tax Withholding Category"), + "label": _("Section Code"), "options": "Tax Withholding Category", - "fieldname": "tax_withholding_category", + "fieldname": "section_code", "fieldtype": "Link", "width": 180, }, - { - "label": _(f"{filters.get('party_type', 'Party')} Type"), - "fieldname": "party_entity_type", - "fieldtype": "Data", - "width": 180, - }, + {"label": _("Entity Type"), "fieldname": "entity_type", "fieldtype": "Data", "width": 180}, { "label": _("TDS Rate %") if filters.get("party_type") == "Supplier" else _("TCS Rate %"), "fieldname": "rate",