From 2b5083d3c08a30732978760878bd69e0c0bece9e Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:42:18 -0800 Subject: [PATCH 1/2] fix(edi): reject /document files the KDE gate would refuse before ISA13 /document answered 201 with accepted: 0 for every 861, 850 and 810, and for 856, 944 and 945 documents missing a required KDE. It also stored a synthesized "EDI Item" for a line with no product id. - Check each set's required KDEs before the ISA13 is reserved. A miss is a logged 422, so the corrected resend is not a 409 replay. - 861 now emits receive_date, immediate_previous_source, reference_document and tlc_source_reference. The TLC source comes from the caller's form field, as on 856. - 850 and 810 always return 422. The PO date, invoice date and buyer and seller parties are not a ship date or shipping locations. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/TRACEABILITY_FILE_INTAKE.md | 2 +- .../ingestion/app/edi_ingestion/routes.py | 141 +++++++-- .../tests/test_edi_856_real_kde_gate.py | 282 +++++++++++++++++- .../test_edi_wire_format_2974_2976_2978.py | 6 +- 4 files changed, 405 insertions(+), 26 deletions(-) diff --git a/docs/engineering/TRACEABILITY_FILE_INTAKE.md b/docs/engineering/TRACEABILITY_FILE_INTAKE.md index 64118c01c..d79017ba6 100644 --- a/docs/engineering/TRACEABILITY_FILE_INTAKE.md +++ b/docs/engineering/TRACEABILITY_FILE_INTAKE.md @@ -12,7 +12,7 @@ assertion and must match that principal. | EPCIS 2.0 upload on `/ingest` | `POST /api/v1/epcis/events`, `/events/batch`, `/events/xml` | tenant context (`require_tenant_context`) | The dashboard tab posts JSON or XML to those routes | | ASN and receiving advice lines | `POST /api/v1/ingest/edi` and `POST /api/v1/ingest/edi/document` | `edi.ingest` | X12 856 and 861, one CTE per item line | | Warehouse advices | `POST /api/v1/ingest/edi/document` | `edi.ingest` | X12 944 and 945, one CTE per item line | -| Purchase order and invoice | `POST /api/v1/ingest/edi/document` | `edi.ingest` | X12 850 and 810, one event per ST/SE set, quantities summed | +| Purchase order and invoice | `POST /api/v1/ingest/edi/document` | `edi.ingest` | X12 850 and 810 parse, then return 422: they have no ship date or shipping locations, so nothing is stored | `POST /api/v1/ingest/edi` accepts a single 856 transaction set. 861, 944, 945, 850, and 810 belong on `/document`. diff --git a/services/ingestion/app/edi_ingestion/routes.py b/services/ingestion/app/edi_ingestion/routes.py index b03892b8a..445ebc94f 100644 --- a/services/ingestion/app/edi_ingestion/routes.py +++ b/services/ingestion/app/edi_ingestion/routes.py @@ -38,6 +38,7 @@ from ..shared.upload_limits import MAX_EDI_FILE_SIZE_BYTES, read_upload_with_limit from ..webhook_compat import ingest_events from ..webhook_models import ( + REQUIRED_KDES_BY_CTE, IngestEvent, WebhookCTEType, WebhookPayload, @@ -251,7 +252,7 @@ def _add(field: str, placeholder: str, message: str) -> None: return errors -def _reject_legacy_856_placeholders( +def _reject_missing_required_kdes( *, errors: list[dict[str, Any]], sender_tenant_id: str, @@ -259,10 +260,11 @@ def _reject_legacy_856_placeholders( extracted: dict[str, Any], partner_id: str | None, source: str, + transaction_set: str = "856", ) -> None: rejection_record = _record_edi_rejection( tenant_id=sender_tenant_id, - transaction_set="856", + transaction_set=transaction_set, traceability_lot_code=traceability_lot_code, errors=errors, extracted=extracted, @@ -272,11 +274,10 @@ def _reject_legacy_856_placeholders( raise HTTPException( status_code=422, detail={ - "error": "edi_856_missing_required_kdes", + "error": f"edi_{transaction_set}_missing_required_kdes", "message": ( - "EDI 856 is missing required FSMA KDEs. No events were " - "persisted; fix the supplier document or provide explicit " - "operator-reviewed overrides." + f"EDI {transaction_set} is missing required FSMA KDEs. No events " + "were persisted; each entry in errors says what the KDE needs." ), "missing_kdes": [err["loc"][0] for err in errors], "rejection_id": rejection_record["rejection_id"], @@ -320,7 +321,7 @@ def _preflight_856_required_kdes( } ) if errors: - _reject_legacy_856_placeholders( + _reject_missing_required_kdes( errors=errors, sender_tenant_id=sender_tenant_id, traceability_lot_code=traceability_lot_code, @@ -330,6 +331,95 @@ def _preflight_856_required_kdes( ) +# Extracted fields that can honestly supply a required KDE, by transaction set. +# The route copies these fields into the KDEs in ``_build_ingest_event_for_set``. +_REFERENCE_DOCUMENT_SOURCE = ("reference_document_number",) +_SHIPMENT_KDE_SOURCES: dict[str, tuple[str, ...]] = { + "ship_from_location": ("ship_from_name",), + "ship_to_location": ("ship_to_name",), + "reference_document": _REFERENCE_DOCUMENT_SOURCE, +} +_RECEIPT_KDE_SOURCES: dict[str, tuple[str, ...]] = { + "receiving_location": ("receiving_location_name",), + "immediate_previous_source": ("ship_from_name", "ship_from_gln"), + "reference_document": _REFERENCE_DOCUMENT_SOURCE, +} +_KDE_SOURCES_BY_SET: dict[str, dict[str, tuple[str, ...]]] = { + "856": _SHIPMENT_KDE_SOURCES, + "945": _SHIPMENT_KDE_SOURCES, + "861": _RECEIPT_KDE_SOURCES, + "944": _RECEIPT_KDE_SOURCES, + "850": {"reference_document": _REFERENCE_DOCUMENT_SOURCE}, + "810": {"reference_document": _REFERENCE_DOCUMENT_SOURCE}, +} + +# 850 and 810 map to the shipping CTE but do not record a shipment. BEG05 is the +# PO date and BIG01 the invoice date, and N1 BY/SE/RI name commercial parties, +# not shipping locations. None of these may stand in for a shipping KDE. +_SHIPPING_KDES_WITH_NO_SOURCE = frozenset({"ship_date", "ship_from_location", "ship_to_location"}) +_KDES_WITH_NO_SOURCE_BY_SET: dict[str, frozenset[str]] = { + "850": _SHIPPING_KDES_WITH_NO_SOURCE, + "810": _SHIPPING_KDES_WITH_NO_SOURCE, +} + + +def _preflight_document_required_kdes( + *, + transaction_set: str, + extracted: dict[str, Any], + tlc_source_reference: str | None, + product_description_override: str | None, + sender_tenant_id: str, + traceability_lot_code: str, + partner_id: str | None, + source: str, +) -> None: + """Reject a document the required-KDE gate would refuse, before ISA13 is reserved. + + Without this the gate drops the events inside ``ingest_events`` and + ``/document`` answers 201 with ``accepted: 0``. Quantity, unit and event + dates have their own checks. X12 has no element naming who assigned a lot, + so the TLC source reference comes from the caller on every set. A line with + no product id fails here so the route cannot synthesize ``EDI Item``. + """ + sources = _KDE_SOURCES_BY_SET[transaction_set] + no_source = _KDES_WITH_NO_SOURCE_BY_SET.get(transaction_set, frozenset()) + errors: list[dict[str, Any]] = [] + + for kde in REQUIRED_KDES_BY_CTE[_CTE_TYPE_BY_SET[transaction_set]]: + if kde in no_source: + present = False + message = f"EDI {transaction_set} does not record a shipment, so it has no source for {kde}." + elif kde == "tlc_source_reference": + present = bool(tlc_source_reference) + message = f"Provide the tlc_source_reference form field; EDI {transaction_set} does not carry it." + elif kde == "product_description": + # Lines replace header values, as in the route's line expansion. + lines = extracted.get("line_items", [{}]) + present = bool(product_description_override) or all( + {**extracted, **line}.get("product_description") for line in lines + ) + message = f"EDI {transaction_set} line has no product identification or description." + elif kde in sources: + present = any(extracted.get(field) for field in sources[kde]) + message = f"EDI {transaction_set} has no value for {kde}." + else: + continue + if not present: + errors.append({"loc": [kde], "msg": message, "type": "missing_required_kde"}) + + if errors: + _reject_missing_required_kdes( + errors=errors, + sender_tenant_id=sender_tenant_id, + traceability_lot_code=traceability_lot_code, + extracted=extracted, + partner_id=partner_id, + source=source, + transaction_set=transaction_set, + ) + + # Event-date field (and optional time field) per transaction set. _EVENT_DATE_FIELDS: dict[str, tuple[str | None, str | None]] = { "856": ("ship_date_raw", "ship_time_raw"), @@ -723,7 +813,7 @@ def _preflight_856() -> None: ship_to_override=ship_to_location_override, ) if placeholder_errors: - _reject_legacy_856_placeholders( + _reject_missing_required_kdes( errors=placeholder_errors, sender_tenant_id=sender_tenant_id, traceability_lot_code=traceability_lot_code, @@ -993,6 +1083,11 @@ def _build_ingest_event_for_set( elif transaction_set == "861": kdes.update( { + # The names the required-KDE gate checks (§1.1345(a)(4), (7), (8)). + "receive_date": event_date, + "immediate_previous_source": extracted.get("ship_from_name") or extracted.get("ship_from_gln"), + "reference_document": extracted.get("reference_document_number"), + "tlc_source_reference": extracted.get("tlc_source_reference"), "receiving_advice_number": extracted.get("receiving_advice_number"), "po_number": extracted.get("po_number"), "ship_from_location": extracted.get("ship_from_name"), @@ -1101,7 +1196,9 @@ def _build_ingest_event_for_set( "(856 ASN, 850 Purchase Order, 810 Invoice, 861 Receiving Advice, " "944 Warehouse Stock Transfer Receipt Advice, " "945 Warehouse Shipping Advice) " - "and extracts FSMA-relevant KDEs for CTE creation." + "and extracts FSMA-relevant KDEs for CTE creation. " + "850 and 810 do not record a shipment, so they currently return 422 " + "listing the shipping KDEs they cannot supply." ), ) async def ingest_edi_document( @@ -1113,8 +1210,8 @@ async def ingest_edi_document( tlc_source_reference: Optional[str] = Form( None, description=( - "TLC source reference copied to the KDEs of every 856/944/945 line. " - "Required for 856, §1.1340(a)(7): missing returns 422." + "Required: who assigned the TLC, §1.1340(a)(7). Copied to the KDEs of " + "every line; X12 does not carry it. Missing returns 422." ), ), tenant_id: Optional[str] = Form(None, description="Optional tenant override"), @@ -1250,15 +1347,16 @@ def _preflight_all_sets() -> None: partner_id=x_partner_id, source=source, ) - if slice_set == "856": - _preflight_856_required_kdes( - extracted=slice_extracted, - tlc_source_reference=tlc_source, - sender_tenant_id=sender_tenant_id, - traceability_lot_code=traceability_lot_code, - partner_id=x_partner_id, - source=source, - ) + _preflight_document_required_kdes( + transaction_set=slice_set, + extracted=slice_extracted, + tlc_source_reference=tlc_source, + product_description_override=product_description, + sender_tenant_id=sender_tenant_id, + traceability_lot_code=traceability_lot_code, + partner_id=x_partner_id, + source=source, + ) # Content rejections run after the allowlist and before the ISA13 is # reserved, so a corrected resend is not refused as a 409 replay. @@ -1273,8 +1371,7 @@ def _preflight_all_sets() -> None: first_event_indexes.append(len(event_inputs)) if slice_set in ("856", "861", "944", "945"): header = {key: value for key, value in slice_extracted.items() if key != "line_items"} - if slice_set in ("856", "944", "945"): - header["tlc_source_reference"] = tlc_source + header["tlc_source_reference"] = tlc_source if slice_set in ("944", "945"): date_field = "receive_date_raw" if slice_set == "944" else "ship_date_raw" header["_event_timestamp"], header["_event_date"] = _ship_timestamp_and_date( diff --git a/services/ingestion/tests/test_edi_856_real_kde_gate.py b/services/ingestion/tests/test_edi_856_real_kde_gate.py index 38997ab32..0d2e8bdbf 100644 --- a/services/ingestion/tests/test_edi_856_real_kde_gate.py +++ b/services/ingestion/tests/test_edi_856_real_kde_gate.py @@ -1,9 +1,12 @@ -"""A valid 856 must get through the real required-KDE gate on both EDI routes. +"""Valid EDI must get through the real required-KDE gate, or fail before ISA13. Every other EDI route test stubs ``ingest_events``, so nothing ran the gate in ``_validate_event_kdes`` against what the routes actually build. That is how a valid ASN came to return HTTP 201 with ``accepted: 0``. Only storage is faked here: ``ingest_events`` and the gate are the real ones. + +The later tests cover every set on ``/document``. Each one either stores, or gets +a logged 422 before the ISA13 is reserved so a corrected resend is not a replay. """ from __future__ import annotations @@ -222,3 +225,280 @@ def test_856_with_no_reference_document_at_all_is_rejected( assert response.status_code == 422, response.text assert response.json()["detail"]["missing_kdes"] == ["reference_document"] assert stored == [] + + +def _document(transaction_set: str, *body: str) -> bytes: + segments = [f"ST*{transaction_set}*0001", *body] + segments.append(f"SE*{len(segments) + 1}*0001") + return ( + "ISA*00* *00* *ZZ*SENDERID *ZZ*RECEIVERID " + "*260310*1200*U*00401*000000001*0*P*>~GS*SH*SENDERID*RECEIVERID*20260310*1200*1*X*004010~" + + "~".join(segments) + + "~GE*1*1~IEA*1*000000001~" + ).encode() + + +SHIP_FROM = "N1*SF*Valley Fresh Farms*92*6141410000014" +SHIP_TO = "N1*ST*Metro Distribution Center*92*6141410000069" +PRODUCT = "PID*F****Romaine Hearts" +WAREHOUSE_PRODUCT = "G69*Romaine Hearts" +ASN_LINE = ("LIN**SK*ROMAINE-12CT", "SN1**200*CA") +ASN_HEADER = ("BSN*00*ASN-12345*20260310*1200", "HL*1**S") +RECEIPT_LINE = (PRODUCT, "RCD**200*CA") +PO_HEADER = "BEG*00*NE*PO-1**20260310" +INVOICE_HEADER = "BIG*20260310*INV-1**PO-1" + +VALID_DOCUMENTS = { + "861": ("BRA*RA-77*20260311*00", "REF*PO*PO-1", SHIP_FROM, SHIP_TO, *RECEIPT_LINE), + "944": ( + "W17*F*20260311*R-1*ORD-1", + SHIP_FROM, + "N1*WH*Metro Warehouse*92*6141410000069", + "W07*200*CA**UP*012345678905***LOT-ROMAINE-A", + WAREHOUSE_PRODUCT, + ), + "945": ( + "W06*N*ORD-1*20260310*SHIP-1", + SHIP_FROM, + SHIP_TO, + "W12*CC*200*200*0*CA****LOT-ROMAINE-A", + WAREHOUSE_PRODUCT, + ), +} + + +def _without(transaction_set: str, segment: str) -> bytes: + body = VALID_DOCUMENTS[transaction_set] + assert segment in body + return _document(transaction_set, *(item for item in body if item != segment)) + + +@pytest.fixture() +def reserved(stored: list[dict[str, Any]], monkeypatch: pytest.MonkeyPatch) -> list[Any]: + """Record ISA13 reservations. ``stored`` first, as it stubs the same name.""" + calls: list[Any] = [] + monkeypatch.setattr( + edi_routes, + "check_and_record_interchange", + lambda **kwargs: (calls.append(kwargs), (False, None))[1], + ) + return calls + + +def _assert_rejected_before_reservation( + response: Any, + transaction_set: str, + missing: list[str], + stored: list[dict[str, Any]], + reserved: list[Any], +) -> None: + assert response.status_code == 422, response.text + detail = response.json()["detail"] + assert detail["error"] == f"edi_{transaction_set}_missing_required_kdes" + assert detail["missing_kdes"] == missing + assert detail["rejection_id"] + assert stored == [] + assert reserved == [] + + +@pytest.mark.parametrize("transaction_set", VALID_DOCUMENTS) +def test_valid_document_stores_through_the_real_gate( + client: TestClient, stored: list[dict[str, Any]], reserved: list[Any], transaction_set: str +) -> None: + raw = _document(transaction_set, *VALID_DOCUMENTS[transaction_set]) + + response = _post(client, "/document", raw, tlc_source_reference=TLC_SOURCE) + + assert response.status_code == 201, response.text + result = response.json()["ingestion_result"] + assert (result["accepted"], result["rejected"]) == (1, 0), result + assert len(stored) == 1 + assert len(reserved) == 1 + assert stored[0]["kdes"]["tlc_source_reference"] == TLC_SOURCE + + +def test_861_kdes_come_from_the_document(client: TestClient, stored: list[dict[str, Any]]) -> None: + raw = _document("861", *VALID_DOCUMENTS["861"]) + + response = _post(client, "/document", raw, tlc_source_reference=TLC_SOURCE) + + assert response.status_code == 201, response.text + kdes = stored[0]["kdes"] + assert kdes["receive_date"] == "2026-03-11" + assert kdes["receiving_location"] == "Metro Distribution Center" + assert kdes["immediate_previous_source"] == "Valley Fresh Farms" + assert kdes["reference_document"] == "RA-77" + + +def test_861_previous_source_can_be_the_ship_from_gln(client: TestClient, stored: list[dict[str, Any]]) -> None: + body = ["N1*SF**92*6141410000014" if item == SHIP_FROM else item for item in VALID_DOCUMENTS["861"]] + + response = _post(client, "/document", _document("861", *body), tlc_source_reference=TLC_SOURCE) + + assert response.status_code == 201, response.text + assert stored[0]["kdes"]["immediate_previous_source"] == "6141410000014" + + +@pytest.mark.parametrize( + ("body", "missing"), + [ + (("N1*SF**92*6141410000014", SHIP_TO), ["ship_from_location"]), + ((SHIP_FROM, "N1*ST**92*6141410000069"), ["ship_to_location"]), + (("N1*SF**92*6141410000014", "N1*ST**92*6141410000069"), ["ship_from_location", "ship_to_location"]), + ], + ids=["no_ship_from_name", "no_ship_to_name", "neither_name"], +) +def test_856_party_with_no_name_is_rejected_before_reservation( + client: TestClient, + stored: list[dict[str, Any]], + reserved: list[Any], + body: tuple[str, ...], + missing: list[str], +) -> None: + raw = _document("856", *ASN_HEADER, *body, *ASN_LINE, "REF*BM*BOL-9001") + + response = _post(client, "/document", raw, tlc_source_reference=TLC_SOURCE) + + _assert_rejected_before_reservation(response, "856", missing, stored, reserved) + + +def test_location_override_does_not_supply_the_ship_from_kde( + client: TestClient, stored: list[dict[str, Any]], reserved: list[Any] +) -> None: + raw = _document("856", *ASN_HEADER, "N1*SF**92*6141410000014", SHIP_TO, *ASN_LINE, "REF*BM*BOL-9001") + + response = _post(client, "/document", raw, tlc_source_reference=TLC_SOURCE, location_override="Dock 4") + + _assert_rejected_before_reservation(response, "856", ["ship_from_location"], stored, reserved) + + +def test_856_line_with_no_product_id_is_rejected_before_reservation( + client: TestClient, stored: list[dict[str, Any]], reserved: list[Any] +) -> None: + raw = _document("856", *ASN_HEADER, SHIP_FROM, SHIP_TO, "SN1**200*CA", "REF*BM*BOL-9001") + + response = _post(client, "/document", raw, tlc_source_reference=TLC_SOURCE) + + _assert_rejected_before_reservation(response, "856", ["product_description"], stored, reserved) + + +def test_one_856_line_with_no_product_id_rejects_the_document( + client: TestClient, stored: list[dict[str, Any]], reserved: list[Any] +) -> None: + raw = _document("856", *ASN_HEADER, SHIP_FROM, SHIP_TO, *ASN_LINE, "LIN*2", "SN1**100*CA", "REF*BM*BOL-9001") + + response = _post(client, "/document", raw, tlc_source_reference=TLC_SOURCE) + + _assert_rejected_before_reservation(response, "856", ["product_description"], stored, reserved) + + +def test_product_description_override_supplies_the_missing_product( + client: TestClient, stored: list[dict[str, Any]] +) -> None: + raw = _document("856", *ASN_HEADER, SHIP_FROM, SHIP_TO, "SN1**200*CA", "REF*BM*BOL-9001") + + response = _post(client, "/document", raw, tlc_source_reference=TLC_SOURCE, product_description="Romaine Hearts") + + assert response.status_code == 201, response.text + assert stored[0]["product_description"] == "Romaine Hearts" + + +@pytest.mark.parametrize("transaction_set", VALID_DOCUMENTS) +def test_document_with_no_tlc_source_is_rejected_before_reservation( + client: TestClient, stored: list[dict[str, Any]], reserved: list[Any], transaction_set: str +) -> None: + raw = _document(transaction_set, *VALID_DOCUMENTS[transaction_set]) + + response = _post(client, "/document", raw) + + _assert_rejected_before_reservation(response, transaction_set, ["tlc_source_reference"], stored, reserved) + + +@pytest.mark.parametrize( + ("transaction_set", "segment", "missing"), + [ + ("861", SHIP_FROM, ["immediate_previous_source"]), + ("861", SHIP_TO, ["receiving_location"]), + ("861", PRODUCT, ["product_description"]), + ("944", SHIP_FROM, ["immediate_previous_source"]), + ("944", "N1*WH*Metro Warehouse*92*6141410000069", ["receiving_location"]), + ("944", WAREHOUSE_PRODUCT, ["product_description"]), + ("945", SHIP_FROM, ["ship_from_location"]), + ("945", SHIP_TO, ["ship_to_location"]), + ("945", WAREHOUSE_PRODUCT, ["product_description"]), + ], +) +def test_missing_kde_is_rejected_before_reservation( + client: TestClient, + stored: list[dict[str, Any]], + reserved: list[Any], + transaction_set: str, + segment: str, + missing: list[str], +) -> None: + raw = _without(transaction_set, segment) + + response = _post(client, "/document", raw, tlc_source_reference=TLC_SOURCE) + + _assert_rejected_before_reservation(response, transaction_set, missing, stored, reserved) + + +@pytest.mark.parametrize( + ("transaction_set", "body"), + [ + ( + "850", + ( + PO_HEADER, + "N1*BY*Metro Distribution Center*92*6141410000069", + "N1*SE*Valley Fresh Farms*92*6141410000014", + "PO1*1*200*CA*2.50**UP*012345678905", + PRODUCT, + ), + ), + ( + "810", + ( + INVOICE_HEADER, + "N1*RI*Metro Distribution Center*92*6141410000069", + "N1*SF*Valley Fresh Farms*92*6141410000014", + "IT1*1*200*CA*2.50**UP*012345678905", + PRODUCT, + "TDS*50000", + ), + ), + ], +) +def test_commercial_documents_supply_no_shipping_kdes( + client: TestClient, + stored: list[dict[str, Any]], + reserved: list[Any], + transaction_set: str, + body: tuple[str, ...], +) -> None: + """The PO or invoice date is not a ship date, and buyer and seller are not locations.""" + raw = _document(transaction_set, *body) + + response = _post(client, "/document", raw, tlc_source_reference=TLC_SOURCE) + + _assert_rejected_before_reservation( + response, + transaction_set, + ["ship_date", "ship_from_location", "ship_to_location"], + stored, + reserved, + ) + + +def test_one_bad_set_in_a_batch_rejects_the_interchange( + client: TestClient, stored: list[dict[str, Any]], reserved: list[Any] +) -> None: + good = ("ST*856*0001", *ASN_HEADER, SHIP_FROM, SHIP_TO, *ASN_LINE, "REF*BM*BOL-9001", "SE*10*0001") + bad = ("ST*856*0002", *ASN_HEADER, SHIP_FROM, *ASN_LINE, "REF*BM*BOL-9002", "SE*9*0002") + single = _document("856", *ASN_HEADER).decode() + envelope, _, _ = single.partition("ST*856") + raw = (envelope + "~".join((*good, *bad)) + "~GE*2*1~IEA*1*000000001~").encode() + + response = _post(client, "/document", raw, tlc_source_reference=TLC_SOURCE) + + _assert_rejected_before_reservation(response, "856", ["ship_to_location"], stored, reserved) diff --git a/services/ingestion/tests/test_edi_wire_format_2974_2976_2978.py b/services/ingestion/tests/test_edi_wire_format_2974_2976_2978.py index f19e312d5..5fbffef42 100644 --- a/services/ingestion/tests/test_edi_wire_format_2974_2976_2978.py +++ b/services/ingestion/tests/test_edi_wire_format_2974_2976_2978.py @@ -460,10 +460,12 @@ def test_856_blank_sn1_quantity_is_rejected(client: TestClient, captured_payload b"ST*856*1~" b"BSN*00**" + ship_date + b"~" b"HL*1**S~" - b"N1*SF*~" + b"N1*SF*Farm~" + b"N1*ST*DC~" + b"LIN**SK*SKU-1~" b"SN1***CA~" b"REF*BM*BOL-1~" - b"SE*7*1~" + b"SE*9*1~" b"GE*1*1~" b"IEA*1*~" ) From 0299785c35a445ede921c751c0e3733b13d73b47 Mon Sep 17 00:00:00 2001 From: Christopher <131207452+PetrefiedThunder@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:44:42 -0800 Subject: [PATCH 2/2] fix(edi): treat an 856 or 861 with no item line as missing its product The product check looped over no lines and passed, so these documents reached FSMA validation after the ISA13 was reserved. Co-Authored-By: Claude Opus 5.5 --- services/ingestion/app/edi_ingestion/routes.py | 2 +- services/ingestion/tests/test_edi_856_real_kde_gate.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/services/ingestion/app/edi_ingestion/routes.py b/services/ingestion/app/edi_ingestion/routes.py index 445ebc94f..c4f0d16c2 100644 --- a/services/ingestion/app/edi_ingestion/routes.py +++ b/services/ingestion/app/edi_ingestion/routes.py @@ -395,7 +395,7 @@ def _preflight_document_required_kdes( message = f"Provide the tlc_source_reference form field; EDI {transaction_set} does not carry it." elif kde == "product_description": # Lines replace header values, as in the route's line expansion. - lines = extracted.get("line_items", [{}]) + lines = extracted.get("line_items") or [{}] present = bool(product_description_override) or all( {**extracted, **line}.get("product_description") for line in lines ) diff --git a/services/ingestion/tests/test_edi_856_real_kde_gate.py b/services/ingestion/tests/test_edi_856_real_kde_gate.py index 0d2e8bdbf..755f8f266 100644 --- a/services/ingestion/tests/test_edi_856_real_kde_gate.py +++ b/services/ingestion/tests/test_edi_856_real_kde_gate.py @@ -382,6 +382,16 @@ def test_856_line_with_no_product_id_is_rejected_before_reservation( _assert_rejected_before_reservation(response, "856", ["product_description"], stored, reserved) +def test_856_with_no_item_line_is_rejected_before_reservation( + client: TestClient, stored: list[dict[str, Any]], reserved: list[Any] +) -> None: + raw = _document("856", *ASN_HEADER, SHIP_FROM, SHIP_TO, "REF*BM*BOL-9001") + + response = _post(client, "/document", raw, tlc_source_reference=TLC_SOURCE) + + _assert_rejected_before_reservation(response, "856", ["product_description"], stored, reserved) + + def test_one_856_line_with_no_product_id_rejects_the_document( client: TestClient, stored: list[dict[str, Any]], reserved: list[Any] ) -> None: