Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/openepcis_client/masterdata/__init__.py
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -13,8 +14,9 @@
BulkReport,
BulkRowError,
GpcNode,
Gs1Record,
InvalidKey,
Masterdata,
)

__all__ = ["BulkReport", "BulkRowError", "GpcNode", "InvalidKey", "Masterdata"]
__all__ = ["BulkReport", "BulkRowError", "GpcNode", "Gs1Record", "InvalidKey", "Masterdata"]
53 changes: 51 additions & 2 deletions src/openepcis_client/masterdata/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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("[]"):
Expand Down
140 changes: 139 additions & 1 deletion src/openepcis_client/masterdata/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down
105 changes: 105 additions & 0 deletions tests/test_masterdata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]
Loading
Loading