diff --git a/src/openepcis_client/masterdata/__init__.py b/src/openepcis_client/masterdata/__init__.py index 6e7e905..2656ac0 100644 --- a/src/openepcis_client/masterdata/__init__.py +++ b/src/openepcis_client/masterdata/__init__.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: 2026 benelog GmbH & Co. KG -"""Product and organization records: payloads, upsert, bulk onboarding, GPC. +"""Product and organization records: payloads, upsert, reading back, GS1 import, +bulk onboarding, GPC. - :mod:`~openepcis_client.masterdata.payload` builds the nested catalog document from dotted GS1 term paths and shapes the awkward value kinds; @@ -13,8 +14,9 @@ BulkReport, BulkRowError, GpcNode, + Gs1Record, InvalidKey, Masterdata, ) -__all__ = ["BulkReport", "BulkRowError", "GpcNode", "InvalidKey", "Masterdata"] +__all__ = ["BulkReport", "BulkRowError", "GpcNode", "Gs1Record", "InvalidKey", "Masterdata"] diff --git a/src/openepcis_client/masterdata/payload.py b/src/openepcis_client/masterdata/payload.py index 54dcf41..8204256 100644 --- a/src/openepcis_client/masterdata/payload.py +++ b/src/openepcis_client/masterdata/payload.py @@ -12,8 +12,9 @@ models target markets, contact points and referenced files. Reading values out of the host system is the host adapter's job; this module -only knows how to place a value at a dotted path and how the catalog wants a -few awkward value kinds shaped. The catalog treats ``PUT`` as a merge where an +only knows how to place a value at a dotted path, how to pick one back out of a +catalog document (:func:`pick`, for synchronising the other way), and how the +catalog wants a few awkward value kinds shaped. The catalog treats ``PUT`` as a merge where an absent key means "leave alone", so hosts skip empty values rather than sending ``null``; the shaping helpers answer ``None`` for a value that must be skipped. """ @@ -41,6 +42,54 @@ def place(document: dict[str, Any], path: str, value: Any) -> None: node[last] = value +def pick(document: dict[str, Any], path: str) -> Any: + """The value at a dotted GS1 term path, or ``None`` when nothing is there. + + The reading counterpart of :func:`place`, with the same grammar: a ``[]`` + segment takes the first element of the list. A record a host reads back + from the catalog may hold more than one contact point or target market; + the first is the one :func:`place` would have written, and the only one a + single host field can hold. + + :raises ValueError: for a path that cannot name anything, as :func:`place`. + """ + parts = [p.strip() for p in (path or "").split(".")] + if not all(parts): + raise ValueError(f"{path!r} is not a usable GS1 term path") + node: Any = document + for part in parts: + if not isinstance(node, dict): + return None + if part.endswith("[]"): + items = node.get(part[:-2]) + node = items[0] if isinstance(items, list) and items else None + else: + node = node.get(part) + return node + + +def pick_localized(value: Any, preferred: tuple[str, ...] = ()) -> str | None: + """One text out of a language map, in the order of ``preferred`` tags. + + Falls back to English, then to whichever language the map holds first, so + a record kept in a single language still reads. A plain string passes + through: some catalog fields are language maps in one record and plain + text in another, depending on who wrote them. + """ + if isinstance(value, str): + return value.strip() or None + if not isinstance(value, dict): + return None + for tag in (*preferred, "en"): + text = value.get(tag) + if isinstance(text, str) and text.strip(): + return text.strip() + for text in value.values(): + if isinstance(text, str) and text.strip(): + return text.strip() + return None + + def _descend(node: dict[str, Any], part: str) -> dict[str, Any]: """One step down the path, creating the container the segment implies.""" if part.endswith("[]"): diff --git a/src/openepcis_client/masterdata/service.py b/src/openepcis_client/masterdata/service.py index 3b096e6..d824c0b 100644 --- a/src/openepcis_client/masterdata/service.py +++ b/src/openepcis_client/masterdata/service.py @@ -16,14 +16,18 @@ import csv import io -from collections.abc import Iterable +from collections.abc import Iterable, Iterator from dataclasses import dataclass from typing import Any from ..core import gs1 from ..core.client import Client +from ..core.errors import OpenEpcisError from . import vocabulary +#: Records per page when walking a list. +LIST_PAGE_SIZE = 100 + #: Rows per CSV upload. Keeps every chunk well under the endpoint's 10 MB cap, #: so a chunk cannot be refused for size. CHUNK_ROWS = 2000 @@ -82,6 +86,24 @@ class BulkReport: """Everything else, including rows refused here for an invalid key.""" +@dataclass(frozen=True) +class Gs1Record: + """The catalog records GS1 Germany's answer for one key derives into. + + A GTIN derives a product. A GLN derives an organization, a place, or both, + because GS1 describes a party and its location under one number; at least + one of the two is present. + """ + + key: str + key_type: str + """``GTIN`` or ``GLN``, as the server classified the key.""" + + product: dict[str, Any] | None = None + organization: dict[str, Any] | None = None + place: dict[str, Any] | None = None + + @dataclass(frozen=True) class GpcNode: """One node of the GS1 Global Product Classification.""" @@ -122,6 +144,122 @@ def _upsert(self, kind: vocabulary.Kind, key: str, document: dict[str, Any]) -> payload.setdefault("type", kind.record_type) return self._client.put(f"{kind.endpoint}/{cleaned}", payload) + # -- Reading back -------------------------------------------------------- + + def get_organization(self, gln: str) -> dict[str, Any] | None: + """One organization of the caller's tenant, or ``None`` when it holds none. + + The read is scoped to the tenant server side, so a GLN another tenant + holds answers ``None`` exactly like one nobody holds. + """ + return self._get_record(vocabulary.kind("ORGANIZATION"), gln) + + def iter_organizations( + self, + page_size: int = LIST_PAGE_SIZE, + sort_by: str = "updatedAt", + order: str = "desc", + ) -> Iterator[dict[str, Any]]: + """Every organization of the caller's tenant, page by page. + + The default order is most recently changed first. The resolver sorts + by its change time but does not return it, so there is no cursor to + resume from: a synchroniser walks the whole list and compares each + record with what it last saw (a payload hash, say). That is fine for + the few hundred parties a tenant holds; a ``modifiedSince`` filter on + the server is the step after. + + Pages are fetched lazily. A record changed while the walk is under way + can move between pages and be seen twice or not at all in this walk; + the next walk sees it. + """ + kind = vocabulary.kind("ORGANIZATION") + page = 1 + while True: + answer = ( + self._client.get( + kind.endpoint, + params={ + "page": page, + "pageSize": page_size, + "sortBy": sort_by, + "sortOrder": order, + }, + ) + or {} + ) + records = answer.get("organizations") or [] + yield from records + total_pages = int(answer.get("totalPages") or 0) + if not records or page >= total_pages: + return + page += 1 + + def _get_record(self, kind: vocabulary.Kind, key: str) -> dict[str, Any] | None: + cleaned = gs1.clean(key) + problem = gs1.problem_with(cleaned, kind.key_type) + if problem: + raise InvalidKey(key, problem) + try: + answer = self._client.get(f"{kind.endpoint}/{cleaned}") + except OpenEpcisError as exc: + if exc.status == 404: + return None + raise + return dict(answer) if answer else None + + # -- Import from GS1 ----------------------------------------------------- + + def preview_from_gs1(self, key: str) -> Gs1Record | None: + """What GS1 Germany knows about a key, derived into a catalog record. + + Read-only: nothing is stored. ``None`` when GS1 does not know the key. + Keys in the GS1 example range (``952…``) are refused by the server with + a 400, because Verified by GS1 does not answer for them. + """ + cleaned = gs1.clean(key) + try: + answer = self._client.get(f"/masterdata/sync/{cleaned}/preview") + except OpenEpcisError as exc: + if exc.status == 404: + return None + raise + return self._gs1_record(cleaned, answer or {}) + + def import_from_gs1(self, key: str) -> bool: + """Store what GS1 Germany knows about a key under the caller's tenant. + + Writes a product, place or organization, whichever the key is. Single + attempt, as every ``POST`` through the client: the server answers what + it did, and a repeat after a lost answer is harmless but not ours to + decide. + + :returns: ``True`` when the record was stored, ``False`` when GS1 does + not know the key. + """ + cleaned = gs1.clean(key) + try: + self._client.post(f"/masterdata/sync/{cleaned}") + except OpenEpcisError as exc: + if exc.status == 404: + return False + raise + return True + + @staticmethod + def _gs1_record(key: str, answer: dict[str, Any]) -> Gs1Record: + def part(name: str) -> dict[str, Any] | None: + value = answer.get(name) + return dict(value) if isinstance(value, dict) else None + + return Gs1Record( + key=str(answer.get("key") or key), + key_type=str(answer.get("type") or ""), + product=part("product"), + organization=part("organization"), + place=part("place"), + ) + # -- Bulk onboarding ----------------------------------------------------- def bulk_products(self, rows: Iterable[dict[str, Any]]) -> BulkReport: diff --git a/tests/test_masterdata.py b/tests/test_masterdata.py index 9e988ad..a511b77 100644 --- a/tests/test_masterdata.py +++ b/tests/test_masterdata.py @@ -9,6 +9,7 @@ import pytest from openepcis_client.core import gs1 +from openepcis_client.core.errors import OpenEpcisError from openepcis_client.masterdata import InvalidKey, Masterdata from openepcis_client.masterdata import service as service_module @@ -169,3 +170,107 @@ def test_nodes_are_mapped_and_codeless_ones_dropped(self) -> None: assert len(nodes) == 1 assert nodes[0].code == "10003269" assert nodes[0].lineage == "Furniture > ..." + + +class ReadingClient: + """Answers GET and POST from a script keyed by path; raises where told to.""" + + def __init__(self, answers: dict[str, Any]) -> None: + self.answers = answers + self.calls: list[tuple[str, str, Any]] = [] + + def _answer(self, verb: str, path: str, params: Any) -> Any: + self.calls.append((verb, path, params)) + answer = self.answers.get(path) + if isinstance(answer, OpenEpcisError): + raise answer + if callable(answer): + return answer(params) + return answer + + def get(self, path: str, params: dict[str, Any] | None = None) -> Any: + return self._answer("GET", path, params) + + def post(self, path: str, payload: Any = None) -> Any: + return self._answer("POST", path, payload) + + +def not_found(path: str) -> OpenEpcisError: + return OpenEpcisError("not found", status=404, path=path) + + +class TestReadingBack: + def test_an_organization_is_read_by_its_cleaned_gln(self) -> None: + client = ReadingClient({f"/organizations/{GLN}": {"organizationName": "Acme"}}) + record = masterdata(client).get_organization(f" {GLN[:4]} {GLN[4:]} ") # type: ignore[arg-type] + assert record == {"organizationName": "Acme"} + assert client.calls[0][1] == f"/organizations/{GLN}" + + def test_an_unknown_gln_reads_as_none(self) -> None: + path = f"/organizations/{GLN}" + client = ReadingClient({path: not_found(path)}) + assert masterdata(client).get_organization(GLN) is None # type: ignore[arg-type] + + def test_other_errors_are_not_swallowed(self) -> None: + path = f"/organizations/{GLN}" + client = ReadingClient({path: OpenEpcisError("down", status=503, path=path)}) + with pytest.raises(OpenEpcisError): + masterdata(client).get_organization(GLN) # type: ignore[arg-type] + + def test_an_invalid_gln_is_refused_before_any_request(self) -> None: + client = ReadingClient({}) + with pytest.raises(InvalidKey): + masterdata(client).get_organization(GLN[:-1] + str((int(GLN[-1]) + 1) % 10)) # type: ignore[arg-type] + assert client.calls == [] + + def test_the_walk_follows_the_pages_newest_change_first(self) -> None: + pages = { + 1: { + "organizations": [{"globalLocationNumber": "a"}, {"globalLocationNumber": "b"}], + "totalPages": 2, + }, + 2: {"organizations": [{"globalLocationNumber": "c"}], "totalPages": 2}, + } + client = ReadingClient({"/organizations": lambda params: pages[params["page"]]}) + walked = list(masterdata(client).iter_organizations(page_size=2)) # type: ignore[arg-type] + assert [r["globalLocationNumber"] for r in walked] == ["a", "b", "c"] + first = client.calls[0][2] + assert first == {"page": 1, "pageSize": 2, "sortBy": "updatedAt", "sortOrder": "desc"} + assert len(client.calls) == 2 + + def test_an_empty_tenant_is_one_request_and_no_records(self) -> None: + client = ReadingClient({"/organizations": {"organizations": [], "totalPages": 0}}) + assert list(masterdata(client).iter_organizations()) == [] # type: ignore[arg-type] + assert len(client.calls) == 1 + + +class TestGs1Import: + def test_a_gln_preview_carries_organization_and_place(self) -> None: + path = f"/masterdata/sync/{GLN}/preview" + answer = { + "key": GLN, + "type": "GLN", + "organization": {"organizationName": "Acme"}, + "place": {"physicalLocationName": "Lager"}, + } + client = ReadingClient({path: answer}) + record = masterdata(client).preview_from_gs1(GLN) # type: ignore[arg-type] + assert record is not None + assert record.key_type == "GLN" + assert record.organization == {"organizationName": "Acme"} + assert record.place == {"physicalLocationName": "Lager"} + assert record.product is None + + def test_a_key_gs1_does_not_know_previews_as_none(self) -> None: + path = f"/masterdata/sync/{GLN}/preview" + client = ReadingClient({path: not_found(path)}) + assert masterdata(client).preview_from_gs1(GLN) is None # type: ignore[arg-type] + + def test_import_reports_whether_anything_was_stored(self) -> None: + known = f"/masterdata/sync/{GLN}" + client = ReadingClient({known: {"status": "success"}}) + assert masterdata(client).import_from_gs1(GLN) is True # type: ignore[arg-type] + assert client.calls == [("POST", known, None)] + + client = ReadingClient({known: not_found(known)}) + assert masterdata(client).import_from_gs1(GLN) is False # type: ignore[arg-type] diff --git a/tests/test_payload.py b/tests/test_payload.py index f615353..b6433a4 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -4,7 +4,14 @@ import pytest -from openepcis_client.masterdata.payload import boolean_text, localized, place, quantity +from openepcis_client.masterdata.payload import ( + boolean_text, + localized, + pick, + pick_localized, + place, + quantity, +) class TestPlace: @@ -70,3 +77,46 @@ def test_localized_drops_empty_entries(self) -> None: def test_localized_with_nothing_left_is_none(self) -> None: assert localized({"de": ""}) is None + + +class TestPick: + def test_a_nested_value(self) -> None: + document = {"address": {"postalCode": "50825"}} + assert pick(document, "address.postalCode") == "50825" + + def test_a_list_segment_reads_the_first_element(self) -> None: + document = {"contactPoint": [{"email": "a@example.test"}, {"email": "b@example.test"}]} + assert pick(document, "contactPoint[].email") == "a@example.test" + + def test_what_place_writes_pick_reads(self) -> None: + document: dict[str, object] = {} + place(document, "address.addressCountry.countryCode", "DE") + assert pick(document, "address.addressCountry.countryCode") == "DE" + + @pytest.mark.parametrize( + "document", + [{}, {"address": None}, {"address": "Maarweg"}, {"contactPoint": []}], + ) + def test_anything_missing_on_the_way_is_none(self, document: dict[str, object]) -> None: + assert pick(document, "address.postalCode") is None + assert pick(document, "contactPoint[].email") is None + + def test_an_unusable_path_is_a_configuration_error(self) -> None: + with pytest.raises(ValueError): + pick({}, "address..postalCode") + + +class TestPickLocalized: + def test_the_preferred_language_wins(self) -> None: + assert pick_localized({"en": "Chair", "de": "Stuhl"}, ("de",)) == "Stuhl" + + def test_english_then_anything(self) -> None: + assert pick_localized({"fr": "Chaise", "en": "Chair"}, ("de",)) == "Chair" + assert pick_localized({"fr": "Chaise"}, ("de",)) == "Chaise" + + def test_plain_text_passes_through(self) -> None: + assert pick_localized(" Stuhl ") == "Stuhl" + + def test_nothing_readable_is_none(self) -> None: + assert pick_localized({"de": " "}) is None + assert pick_localized(None) is None