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 "<name>" and "<name> - 3" and asserts the next
de-duplicated name is "<name> - 4" on both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mihir Kandoi
2026-06-21 05:16:58 +05:30
parent 4255059846
commit 9c3f09927f
2 changed files with 25 additions and 4 deletions

View File

@@ -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.

View File

@@ -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 "- <max trailing number + 1>".
# 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"