From 9c3f09927f88c5d04acaa4c49f5f8b1fe5ea90a6 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Sun, 21 Jun 2026 05:16:58 +0530 Subject: [PATCH] fix(selling): make Customer name de-duplication work on Postgres get_customer_name's Postgres branch used `Substring(Customer.name, r"\d+$")`, but pypika's Substring is a start/length function, not a regex extractor, so it raised `TypeError: Substring.__init__() missing 1 required positional argument: 'stop'` at query-build time. Creating a second Customer with an existing name therefore failed outright on Postgres. Extract the trailing digits with regexp_replace + NULLIF + CAST instead. A non-numeric trailing token strips to an empty string, which NULLIF turns into NULL so MAX() skips it and COALESCE floors to 0 -- matching MariaDB's CAST(... AS UNSIGNED) -> 0. MariaDB behaviour is unchanged (its branch is untouched). Drops the now-unused Substring import. Adds a test that creates "" and " - 3" and asserts the next de-duplicated name is " - 4" on both engines. Co-Authored-By: Claude Opus 4.8 (1M context) --- erpnext/selling/doctype/customer/customer.py | 14 ++++++++++---- erpnext/selling/doctype/customer/test_customer.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index a79bc9e935a..776df5cd5cb 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -15,7 +15,7 @@ from frappe.model.document import Document from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options from frappe.model.utils.rename_doc import update_linked_doctypes from frappe.query_builder import CustomFunction, Field, functions -from frappe.query_builder.functions import Cast, Coalesce, Max, Substring +from frappe.query_builder.functions import Cast, Coalesce, Max from frappe.utils import cint, cstr, flt, get_formatted_email, today from frappe.utils.user import get_users_with_role @@ -128,9 +128,15 @@ class Customer(TransactionBase): Customer = frappe.qb.DocType("Customer") if frappe.db.db_type == "postgres": - # Postgres: extract trailing digits (e.g. "Customer - 3") and cast to int. - # NOTE: PostgreSQL is strict about types; MySQL's UNSIGNED cast does not exist. - extracted_part = Substring(Customer.name, r"\d+$") + # Postgres: extract the TRAILING digits (e.g. "Customer - 3" -> "3") and cast to int. + # A non-numeric trailing token (e.g. "Customer - Foo") strips to an empty string, which + # NULLIF turns into NULL: MAX() then skips it and COALESCE floors to 0, matching + # MariaDB's CAST(... AS UNSIGNED) -> 0. (pypika's Substring is start/length, not a + # regex, so it can't be used here; UNSIGNED also doesn't exist on postgres, and a raw + # CAST of a non-numeric token to INTEGER would raise instead of yielding NULL.) + regexp_replace = CustomFunction("regexp_replace", ["source", "pattern", "replacement"]) + nullif = CustomFunction("NULLIF", ["expr", "value"]) + extracted_part = nullif(regexp_replace(Customer.name, r"^.*?(\d*)$", r"\1"), "") casted_part = Cast(extracted_part, "INTEGER") else: # MariaDB/MySQL: keep existing behavior. diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py index 4ed94683bec..ed0b435ffc7 100644 --- a/erpnext/selling/doctype/customer/test_customer.py +++ b/erpnext/selling/doctype/customer/test_customer.py @@ -20,6 +20,21 @@ from erpnext.tests.utils import ERPNextTestSuite class TestCustomer(ERPNextTestSuite): + def test_get_customer_name_dedupes_with_numeric_suffix(self): + # When a customer name already exists, get_customer_name appends "- ". + # The Postgres branch extracts the trailing digits with regexp_replace/NULLIF/CAST (pypika's + # Substring cannot do regex extraction); this exercises that path on both engines. + base = "_Test PG Dedup Customer" + for nm in (base, f"{base} - 3"): + if not frappe.db.exists("Customer", nm): + frappe.get_doc( + {"doctype": "Customer", "customer_name": nm, "customer_type": "Individual"} + ).insert() + self.addCleanup(frappe.delete_doc, "Customer", nm, force=1) + + doc = frappe.get_doc({"doctype": "Customer", "customer_name": base, "customer_type": "Individual"}) + self.assertEqual(doc.get_customer_name(), f"{base} - 4") + def test_get_customer_group_details(self): doc = frappe.new_doc("Customer Group") doc.customer_group_name = "_Testing Customer Group"