Repository navigation
fix(ingestion): EPCIS Preview, harvest creating_class_instance, generated test IDs #3716
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5c0f171
b360fcc
aaaa6fb
498b7eb
9891e1d
8e52606
dc7e3d5
5b4f7ae
820d771
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,45 @@ def class_uri(): | |
| return f"urn:epc:class:lgtin:{digits[:7]}.{digits[7:]}.{uuid4().hex}" | ||
|
|
||
|
|
||
| def _independent_gs1_mod10_check_digit(data_digits: str) -> int: | ||
| """GS1 General Specifications mod-10, written out for the test. | ||
|
|
||
| From the rightmost data digit: weights alternate 3, 1, 3, 1, ... | ||
| Check digit is (10 - (sum % 10)) % 10. Independent of | ||
| shared.fsma_validation / exporter._gs1_check_digit_valid. | ||
| """ | ||
| if not data_digits.isdigit(): | ||
| raise ValueError("data digits must be numeric") | ||
| total = 0 | ||
| for i, ch in enumerate(reversed(data_digits)): | ||
| total += (3 if i % 2 == 0 else 1) * int(ch) | ||
| return (10 - (total % 10)) % 10 | ||
|
|
||
|
|
||
| def _gs1_with_check(data_digits: str) -> str: | ||
| """Append check digit from the independent mod-10 above.""" | ||
| return data_digits + str(_independent_gs1_mod10_check_digit(data_digits)) | ||
|
|
||
|
|
||
| def generated_gtin14() -> str: | ||
| base = str(uuid4().int % (10**13)).zfill(13) | ||
| return _gs1_with_check(base) | ||
|
|
||
|
|
||
| def generated_gln13() -> str: | ||
| base = str(uuid4().int % (10**12)).zfill(12) | ||
| return _gs1_with_check(base) | ||
|
|
||
|
|
||
| def generated_sscc18() -> str: | ||
| base = str(uuid4().int % (10**17)).zfill(17) | ||
| return _gs1_with_check(base) | ||
|
|
||
|
|
||
| def generated_lot() -> str: | ||
| return f"SYN-{uuid4().hex[:12]}" | ||
|
|
||
|
|
||
| def assert_conformant(document): | ||
| jsonschema = pytest.importorskip("jsonschema") | ||
| jsonschema.Draft7Validator( | ||
|
|
@@ -84,6 +123,7 @@ def test_empty_export_has_no_fabricated_events(): | |
|
|
||
|
|
||
| def test_stored_event_export_preserves_metadata_namespace(): | ||
| """Namespace + @context terms without pyld (pyld is not in requirements.lock).""" | ||
| event = { | ||
| "type": "ObjectEvent", | ||
| "eventTime": datetime.now(timezone.utc).isoformat(), | ||
|
|
@@ -101,44 +141,77 @@ def test_stored_event_export_preserves_metadata_namespace(): | |
| assert_conformant(document) | ||
| assert "regengine:metadata" in document | ||
| assert "metadata" not in document | ||
| jsonld = pytest.importorskip("pyld").jsonld | ||
| context = fixture("epcis-context.jsonld") | ||
| dropped = [] | ||
| assert document["@context"][0] == CONTEXT_URL | ||
| assert isinstance(document["@context"][1], dict) | ||
| assert document["@context"][1].get("regengine") == "https://regengine.co/ns/" | ||
| assert "regengine:source" in document["regengine:metadata"] | ||
| assert "regengine:event_count" in document["regengine:metadata"] | ||
|
Comment on lines
+144
to
+148
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| def loader(url, options=None): | ||
| if url != CONTEXT_URL: | ||
| raise ValueError(f"Unexpected context URL: {url}") | ||
| return {"contextUrl": None, "documentUrl": url, "document": context} | ||
|
|
||
| expanded = jsonld.expand(document, options={"documentLoader": loader}, on_property_dropped=dropped.append) | ||
| assert not dropped | ||
| assert "https://regengine.co/ns/metadata" in expanded[0] | ||
| def test_gs1_check_digit_independent_mod10_and_published_examples(): | ||
| """Independent mod-10 + known published vectors; production must agree. | ||
|
|
||
| Published examples are the independent guard (not minted via production). | ||
| Generated IDs are minted with the test-local mod-10, then checked by the | ||
| exporter so a reversed weight in production would fail. | ||
| """ | ||
| # Published GS1 examples (data + expected check digit), each cited from a | ||
| # GS1 document on ref.gs1.org / gs1.org — no third-party pages. | ||
| published = ( | ||
| # GTIN-14 09506000134352 — GS1 General Specifications Release 26.0 | ||
| # §1.2.4.4 (JSON-LD example gs1:gtin); also QR Code powered by GS1 | ||
| # best practices (2023), Best practice 1. | ||
| ("0950600013435", "2", 14), | ||
| # GLN-13 0614141000005 — GS1 Verifiable Credentials White Paper on | ||
| # Data Model and Validations 1.0.1 §5.1.2 (partyGLN for GS1 US). | ||
| ("061414100000", "5", 13), | ||
| # SSCC-18 376104250021234569 — GS1 General Specifications Release 26.0 | ||
| # §7.9.1 Table 7-9 (18-digit check digit calculation example). | ||
| ("37610425002123456", "9", 18), | ||
| ) | ||
| for data, expected_cd, length in published: | ||
| assert _independent_gs1_mod10_check_digit(data) == int(expected_cd) | ||
| full = data + expected_cd | ||
| assert len(full) == length | ||
| assert exporter._gs1_check_digit_valid(full) | ||
| wrong_cd = "0" if expected_cd != "0" else "1" | ||
| bad = data + wrong_cd | ||
| assert _independent_gs1_mod10_check_digit(data) != int(wrong_cd) | ||
| assert not exporter._gs1_check_digit_valid(bad) | ||
|
|
||
| def test_gs1_check_digit_weights_match_known_valid_examples(): | ||
| """Rightmost data digit must receive weight 3 (GS1 mod-10).""" | ||
| # GTIN-14, GLN-13, and EAN-13 textbook examples | ||
| assert exporter._gs1_check_digit_valid("09506000134352") | ||
| assert exporter._gs1_check_digit_valid("0614141000005") | ||
| assert exporter._gs1_check_digit_valid("4006381333931") | ||
| # SSCC-18: base 10614141123456789 → check digit 7 | ||
| assert exporter._gs1_check_digit_valid("106141411234567897") | ||
| assert not exporter._gs1_check_digit_valid("09506000134351") | ||
| # Generated structure + production agreement with independent mint. | ||
| gtin = generated_gtin14() | ||
| gln = generated_gln13() | ||
| sscc = generated_sscc18() | ||
| assert len(gtin) == 14 and gtin.isdigit() | ||
| assert len(gln) == 13 and gln.isdigit() | ||
| assert len(sscc) == 18 and sscc.isdigit() | ||
| assert _independent_gs1_mod10_check_digit(gtin[:-1]) == int(gtin[-1]) | ||
| assert _independent_gs1_mod10_check_digit(gln[:-1]) == int(gln[-1]) | ||
| assert _independent_gs1_mod10_check_digit(sscc[:-1]) == int(sscc[-1]) | ||
| assert exporter._gs1_check_digit_valid(gtin) | ||
| assert exporter._gs1_check_digit_valid(gln) | ||
| assert exporter._gs1_check_digit_valid(sscc) | ||
| bad_gtin = gtin[:-1] + ("0" if gtin[-1] != "0" else "1") | ||
| assert not exporter._gs1_check_digit_valid(bad_gtin) | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
|
|
||
| def test_export_builds_class_from_query_gtin_and_lot(): | ||
| """Regression: query returns gtin (not a test-only epc_class column).""" | ||
| gtin = generated_gtin14() | ||
| gln = generated_gln13() | ||
| lot = generated_lot() | ||
| row = { | ||
| "event_type": "receiving", | ||
| "epcis_event_type": "ObjectEvent", | ||
| "epcis_action": "ADD", | ||
| "epcis_biz_step": "receiving", | ||
| "event_timestamp": datetime.now(timezone.utc), | ||
| "gtin": "09506000134352", | ||
| "traceability_lot_code": "SYN-LOT-001", | ||
| "gtin": gtin, | ||
| "traceability_lot_code": lot, | ||
| "quantity": 10, | ||
| "unit_of_measure": "lbs", | ||
| "location_gln": "0614141000005", | ||
| "location_gln": gln, | ||
| } | ||
| ctx = type("Tenant", (), {"tenant_id": str(uuid4())})() | ||
| with patch.object(exporter, "_query_tenant_events", return_value=[row]): | ||
|
|
@@ -147,10 +220,10 @@ def test_export_builds_class_from_query_gtin_and_lot(): | |
| ) | ||
| document = json.loads(response.body) | ||
| event = document["epcisBody"]["eventList"][0] | ||
| assert event["quantityList"][0]["epcClass"] == "https://id.gs1.org/01/09506000134352/10/SYN-LOT-001" | ||
| assert event["quantityList"][0]["epcClass"] == f"https://id.gs1.org/01/{gtin}/10/{lot}" | ||
| assert event["quantityList"][0]["uom"] == "LBR" | ||
| assert event["regengine:unitOfMeasure"] == "lbs" | ||
| assert event["readPoint"]["id"] == "https://id.gs1.org/414/0614141000005" | ||
| assert event["readPoint"]["id"] == f"https://id.gs1.org/414/{gln}" | ||
| assert document["regengine:exportMetadata"]["skippedEventsCount"] == 0 | ||
| assert "regengine:message" not in document["regengine:exportMetadata"] | ||
|
|
||
|
|
@@ -164,7 +237,7 @@ def test_export_builds_class_from_product_id_kde(): | |
| "epcis_biz_step": None, | ||
| "event_timestamp": datetime.now(timezone.utc), | ||
| "product_id": epc_class, | ||
| "traceability_lot_code": "SYN-LOT-002", | ||
| "traceability_lot_code": generated_lot(), | ||
| "quantity": None, | ||
| "unit_of_measure": None, | ||
| "location_gln": None, | ||
|
|
@@ -188,7 +261,7 @@ def test_transformation_cte_emitted_as_transformation_event(): | |
| "epcis_biz_step": "commissioning", | ||
| "event_timestamp": datetime.now(timezone.utc), | ||
| "epc_class": epc_class, | ||
| "traceability_lot_code": "OUT-LOT", | ||
| "traceability_lot_code": generated_lot(), | ||
| "quantity": 5, | ||
| "unit_of_measure": "kg", | ||
| "sha256_hash": "a" * 64, | ||
|
|
@@ -211,8 +284,8 @@ def test_transformation_cte_emitted_as_transformation_event(): | |
| assert document["regengine:exportMetadata"]["skippedEventsCount"] == 0 | ||
|
|
||
|
|
||
| def test_harvesting_maps_to_collecting_bizstep(): | ||
| """Phase A keeps harvesting → collecting.""" | ||
| def test_harvesting_maps_to_creating_class_instance_bizstep(): | ||
| """Harvesting commissions a class instance (CBV creating_class_instance).""" | ||
| epc_class = class_uri() | ||
| row = { | ||
| "event_type": "harvesting", | ||
|
|
@@ -221,7 +294,7 @@ def test_harvesting_maps_to_collecting_bizstep(): | |
| "epcis_biz_step": None, | ||
| "event_timestamp": datetime.now(timezone.utc), | ||
| "epc_class": epc_class, | ||
| "traceability_lot_code": "HARVEST-1", | ||
| "traceability_lot_code": generated_lot(), | ||
| "quantity": 1, | ||
| "unit_of_measure": "EA", | ||
| "location_gln": None, | ||
|
|
@@ -232,4 +305,4 @@ def test_harvesting_maps_to_collecting_bizstep(): | |
| exporter.export_epcis(exporter.ExportRequest(tenant_id=ctx.tenant_id), validate=False, ctx=ctx) | ||
| ) | ||
| document = json.loads(response.body) | ||
| assert document["epcisBody"]["eventList"][0]["bizStep"] == "collecting" | ||
| assert document["epcisBody"]["eventList"][0]["bizStep"] == "creating_class_instance" | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Harvest bizstep mapping changed unexpectedly
Medium Severity
_CTE_TO_BIZSTEPnow mapsharvestingtocreating_class_instanceinstead ofcollecting. Graph export still emitscollectingfor the same CTE, so the two Preview paths no longer agree, and this is an event-shape change this PR marks out of scope.Additional Locations (1)
services/ingestion/tests/test_epcis_export_gs1_2.py#L245-L267Reviewed by Cursor Bugbot for commit 498b7eb. Configure here.