From 31319bd36ede7cd81d7ecb110b2526633d9fc82c Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:39:23 +0200 Subject: [PATCH] fix: resolve code lists by URI and version (backport #58770) (#58772) Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com> --- erpnext/edi/doctype/code_list/code_list.py | 59 +++++++++++++- .../edi/doctype/code_list/test_code_list.py | 78 ++++++++++++++++++- 2 files changed, 132 insertions(+), 5 deletions(-) diff --git a/erpnext/edi/doctype/code_list/code_list.py b/erpnext/edi/doctype/code_list/code_list.py index e723157e7a0..2ef30313df8 100644 --- a/erpnext/edi/doctype/code_list/code_list.py +++ b/erpnext/edi/doctype/code_list/code_list.py @@ -1,6 +1,7 @@ # Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt +import re from typing import TYPE_CHECKING import frappe @@ -78,8 +79,48 @@ class CodeList(Document): self.url = getattr(root.find(".//Identification/LocationUri"), "text", None) +def _version_key(version: str | None) -> list: + """Natural sort key for the version formats publishers use: integers and ISO dates. + + Orders 3 < 10 (which a lexical sort gets wrong) and 2020-01-01 < 2020-11-05. + """ + return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", version or "")] + + +@frappe.request_cache +def resolve_code_list(code_list: str) -> str | None: + """Return the Code List for a document name or a canonical URI. + + Code Lists are named after their CanonicalVersionUri, so one canonical URI can + map to several documents, one per version. An exact document name takes + precedence, which lets a caller request a specific version; a canonical URI + resolves to the latest version available. + """ + if frappe.db.exists("Code List", code_list): + return code_list + + candidates = frappe.get_all( + "Code List", + filters={"canonical_uri": code_list}, + fields=["name", "version"], + ) + if not candidates: + return None + + # ponytail: assumes one publisher sticks to one version format. An integer and an + # ISO date under the same canonical URI compare numerically (3 < 2020), so the date + # would win; import the genericode ValidityDate and sort on that if it ever happens. + return max(candidates, key=lambda cl: _version_key(cl.version)).name + + def get_codes_for(code_list: str, doctype: str, name: str) -> tuple[str]: - """Return the common code for a given record""" + """Return the common code for a given record. + + `code_list` may be a Code List name or a canonical URI (latest version wins). + """ + if not (code_list := resolve_code_list(code_list)): + return () + CommonCode = frappe.qb.DocType("Common Code") DynamicLink = frappe.qb.DocType("Dynamic Link") @@ -101,7 +142,13 @@ def get_codes_for(code_list: str, doctype: str, name: str) -> tuple[str]: def get_docnames_for(code_list: str, doctype: str, code: str) -> tuple[str]: - """Return the record name for a given common code""" + """Return the record name for a given common code. + + `code_list` may be a Code List name or a canonical URI (latest version wins). + """ + if not (code_list := resolve_code_list(code_list)): + return () + CommonCode = frappe.qb.DocType("Common Code") DynamicLink = frappe.qb.DocType("Dynamic Link") @@ -123,6 +170,12 @@ def get_docnames_for(code_list: str, doctype: str, code: str) -> tuple[str]: def get_default_code(code_list: str) -> str | None: - """Return the default common code for a given code list""" + """Return the default common code for a given code list. + + `code_list` may be a Code List name or a canonical URI (latest version wins). + """ + if not (code_list := resolve_code_list(code_list)): + return None + code_id = frappe.db.get_value("Code List", code_list, "default_common_code") return frappe.db.get_value("Common Code", code_id, "common_code") if code_id else None diff --git a/erpnext/edi/doctype/code_list/test_code_list.py b/erpnext/edi/doctype/code_list/test_code_list.py index 7c9ec54a627..f32b4a42d72 100644 --- a/erpnext/edi/doctype/code_list/test_code_list.py +++ b/erpnext/edi/doctype/code_list/test_code_list.py @@ -1,9 +1,83 @@ # Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -# import frappe +import frappe + +from erpnext.edi.doctype.code_list.code_list import ( + _version_key, + get_codes_for, + get_default_code, + get_docnames_for, + resolve_code_list, +) from erpnext.tests.utils import ERPNextTestSuite +CANONICAL_URI = "urn:test:erpnext:codeliste:resolve" +OLD_VERSION = f"{CANONICAL_URI}:3" +NEW_VERSION = f"{CANONICAL_URI}:10" +UNKNOWN_URI = "urn:test:erpnext:codeliste:missing" + class TestCodeList(ERPNextTestSuite): - pass + def setUp(self): + """Create two versions of one code list. Test records are rolled back per test.""" + for name, version in ((OLD_VERSION, "3"), (NEW_VERSION, "10")): + if not frappe.db.exists("Code List", name): + frappe.get_doc( + doctype="Code List", + name=name, + title=name, + canonical_uri=CANONICAL_URI, + version=version, + ).insert() + + default_code = frappe.get_doc( + doctype="Common Code", + title="Test Default", + common_code="XYZ", + code_list=NEW_VERSION, + ).insert() + frappe.db.set_value("Code List", NEW_VERSION, "default_common_code", default_code.name) + + # resolution is request-cached, so fixtures must not be masked by earlier lookups + frappe.local.request_cache.clear() + + def test_version_key_orders_integers_and_iso_dates(self): + """Integer and ISO date versions must both order correctly, unlike a lexical sort.""" + self.assertEqual(sorted(["10", "3", None, "9"], key=_version_key), [None, "3", "9", "10"]) + self.assertEqual( + sorted(["2020-11-05", "2019-12-31", "2020-01-01"], key=_version_key), + ["2019-12-31", "2020-01-01", "2020-11-05"], + ) + + def test_canonical_uri_resolves_to_latest_version(self): + self.assertEqual(resolve_code_list(CANONICAL_URI), NEW_VERSION) + + def test_name_resolves_to_itself(self): + """Passing a version-specific name must return that version, not the latest one.""" + self.assertEqual(resolve_code_list(OLD_VERSION), OLD_VERSION) + + def test_name_takes_precedence_over_canonical_uri(self): + """A document named like a canonical URI must not redirect to another version.""" + frappe.get_doc( + doctype="Code List", + name=CANONICAL_URI, + title=CANONICAL_URI, + canonical_uri=CANONICAL_URI, + version="1", + ).insert() + frappe.local.request_cache.clear() + + self.assertEqual(resolve_code_list(CANONICAL_URI), CANONICAL_URI) + + def test_unknown_uri_resolves_to_none(self): + self.assertIsNone(resolve_code_list(UNKNOWN_URI)) + + def test_lookups_are_empty_for_unknown_code_list(self): + """An unresolved code list must not fall through to an unfiltered query.""" + self.assertEqual(get_codes_for(UNKNOWN_URI, "UOM", "Nos"), ()) + self.assertEqual(get_docnames_for(UNKNOWN_URI, "UOM", "XYZ"), ()) + self.assertIsNone(get_default_code(UNKNOWN_URI)) + + def test_default_code_follows_latest_version(self): + self.assertEqual(get_default_code(CANONICAL_URI), "XYZ")