fix(selling): match MariaDB's customer-name suffix extraction on Postgres

get_customer_name's Postgres branch extracted the PURE TRAILING digits of the
name (regexp '^.*?(\d*)$'), while the MariaDB branch uses
CAST(SUBSTRING_INDEX(name, ' ', -1) AS UNSIGNED) — the LEADING digits of the last
whitespace token. For a scanned name like "<base> - 3a" MariaDB yields 3 but
Postgres yielded NULL→0, so the next de-duplicated number (and thus the generated
Customer name) diverged between engines.

Make the Postgres branch take the last whitespace token then its leading digits,
mirroring MariaDB exactly ("X - 3a"->3, "X - 1.5"->1, "X - Foo"->0). Add a
regression test with a "<base> - 3a" name asserting the next name is "<base> - 4"
on both engines (it produced "<base> - 1" on the old Postgres regex).
This commit is contained in:
Mihir Kandoi
2026-06-22 07:17:22 +05:30
parent c188ed59ec
commit 53491e2008
2 changed files with 28 additions and 10 deletions

View File

@@ -128,15 +128,17 @@ class Customer(TransactionBase):
Customer = frappe.qb.DocType("Customer")
if frappe.db.db_type == "postgres":
# 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.)
# Mirror MariaDB's CAST(SUBSTRING_INDEX(name, ' ', -1) AS UNSIGNED): take the last
# whitespace-delimited token, then its LEADING digits, and cast to int. So "X - 3" -> 3,
# "X - 3a" -> 3, "X - 1.5" -> 1, matching MariaDB exactly. A non-numeric token (e.g.
# "X - Foo") strips to '' which NULLIF turns into NULL: MAX() skips it and COALESCE floors
# to 0, matching MariaDB's CAST(... AS UNSIGNED) -> 0. (pypika's Substring is start/length,
# not a regex; UNSIGNED 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"), "")
last_token = regexp_replace(Customer.name, r"^.*\s", "")
extracted_part = nullif(regexp_replace(last_token, r"^(\d*).*$", r"\1"), "")
casted_part = Cast(extracted_part, "INTEGER")
else:
# MariaDB/MySQL: keep existing behavior.

View File

@@ -21,9 +21,9 @@ 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.
# When a customer name already exists, get_customer_name appends "- <max suffix + 1>". The
# Postgres branch extracts the suffix 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):
@@ -35,6 +35,22 @@ class TestCustomer(ERPNextTestSuite):
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_name_dedupe_handles_mixed_suffix(self):
# The suffix extractor must read the LEADING digits of the last whitespace-token, like MariaDB's
# CAST(SUBSTRING_INDEX(name, ' ', -1) AS UNSIGNED): "<base> - 3a" -> 3, so the next name is
# "<base> - 4". The earlier Postgres regex read pure-trailing digits, yielding 0 for "3a" and
# diverging from MariaDB (which would have produced "<base> - 1"). Asserts engine parity.
base = "_Test PG Dedup Mixed"
for nm in (base, f"{base} - 3a"):
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"