diff --git a/docs/engineering/TRACEABILITY_FILE_INTAKE.md b/docs/engineering/TRACEABILITY_FILE_INTAKE.md index b01256b72..6751eb607 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`. @@ -202,24 +202,19 @@ rules below apply: one transaction-set type, GE01 must match the ST/SE count, and any FSMA failure keeps the whole interchange out of the canonical stream. -An 856 on this route follows the same line rules as `POST -/api/v1/ingest/edi`: a missing or bad quantity or unit, a line with no -product, or a set with no ship-from or ship-to name is HTTP 422 -`edi_856_missing_required_kdes` before the ISA13 is reserved. The rules -below apply to the other sets. - -On this route a present quantity that is zero or not numeric is HTTP 422 -`edi_non_positive_quantity`. A present unit is HTTP 422 -`edi_unmapped_unit_of_measure` unless it is an element-355 code this -route maps (`CA` and `CS` cases, `CT` cartons, `BX` boxes, `BG` bags, -`LB` lbs, `KG` kg, `EA` each, `UN` units, `PL` pallets, `PC` and `PK` -pieces), a tolerated non-X12 alias (`CAS`, `CTN`, `BOX`, `LBR`, -`KGM`), or a value that is already an FSMA unit name (`cases`, -`cartons`, `lbs`, and the rest of `VALID_UNITS_OF_MEASURE`). A blank -unit is stored as `units`. A set with no SN1 or RCD -still produces one event: the quantity element is absent, so the -persisted quantity is `1.0`, a missing product becomes `EDI 856 Item` -or `EDI 861 Item`, and a missing location name becomes `Unknown`. +An 856, 861, 944 or 945 on this route needs a positive quantity, a +recognized unit and a product on every line, plus the set's party names. +Anything missing or bad is HTTP 422 `edi__missing_required_kdes` +before the ISA13 is reserved, so a corrected resend is not a replay. + +A recognized unit is an element-355 code this route maps (`CA` and `CS` +cases, `CT` cartons, `BX` boxes, `BG` bags, `LB` lbs, `KG` kg, `EA` +each, `UN` units, `PL` pallets, `PC` and `PK` pieces), a tolerated +non-X12 alias (`CAS`, `CTN`, `BOX`, `LBR`, `KGM`), or a value that is +already an FSMA unit name (`cases`, `cartons`, `lbs`, and the rest of +`VALID_UNITS_OF_MEASURE`). None of these are synthesized: a blank unit +is rejected rather than stored as `units`, and a set with no SN1 or RCD +is rejected rather than stored with quantity `1.0`. Header KDEs are copied onto every line. 856 copies the ASN number (BSN02), carrier (TD5), and the reference document (`REF` `BM`, `SI`, diff --git a/services/ingestion/app/edi_ingestion/routes.py b/services/ingestion/app/edi_ingestion/routes.py index 5c5a65b9b..a814adc3c 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"], @@ -324,7 +325,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, @@ -334,6 +335,108 @@ 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, ...]]] = { + "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, + quantity_override: float | None, + unit_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``. Event dates have their own + check. X12 has no element naming who assigned a lot, so the TLC source + reference comes from the caller on every set. Each line needs a product, a + positive quantity and a valid unit here, because the route would otherwise + synthesize ``EDI Item``, ``1.0`` or ``units`` and the gate cannot catch + an invented value. An 856 uses ``_legacy_856_placeholder_errors`` instead. + """ + sources = _KDE_SOURCES_BY_SET[transaction_set] + no_source = _KDES_WITH_NO_SOURCE_BY_SET.get(transaction_set, frozenset()) + errors: list[dict[str, Any]] = [] + # Lines replace header values, as in the route's line expansion. + lines = [{**extracted, **line} for line in extracted.get("line_items") or [{}]] + + 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": + present = bool(product_description_override) or all(line.get("product_description") for line in lines) + message = f"EDI {transaction_set} line has no product identification or description." + elif kde == "quantity" and not no_source: + present = quantity_override is not None or all( + _positive_float_or_none(line.get("quantity")) is not None for line in lines + ) + message = f"EDI {transaction_set} line has no positive quantity." + elif kde == "unit_of_measure" and not no_source: + present = all( + _valid_unit_or_none(unit_override if unit_override is not None else line.get("unit_of_measure")) + is not None + for line in lines + ) + message = f"EDI {transaction_set} line has no recognized X12 unit of measure." + 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"), @@ -987,6 +1090,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"), @@ -1095,7 +1203,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( @@ -1107,8 +1217,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"), @@ -1263,6 +1373,19 @@ def _preflight_all_sets() -> None: ship_to_override=None, ), ) + else: + _preflight_document_required_kdes( + transaction_set=slice_set, + extracted=slice_extracted, + tlc_source_reference=tlc_source, + product_description_override=product_description, + quantity_override=quantity_override, + unit_override=unit_of_measure_override, + 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. @@ -1277,8 +1400,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 1acff39d2..d9efcd17a 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 @@ -283,3 +286,347 @@ 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_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", ["quantity", "unit_of_measure", "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", "good", "bad", "missing"), + [ + ("861", "RCD**200*CA", "RCD**200", ["unit_of_measure"]), + ("861", "RCD**200*CA", "RCD**200*XX", ["unit_of_measure"]), + ("861", "RCD**200*CA", "RCD***CA", ["quantity"]), + ("861", "RCD**200*CA", "RCD**0*CA", ["quantity"]), + ( + "944", + "W07*200*CA**UP*012345678905***LOT-ROMAINE-A", + "W07*200***UP*012345678905***LOT-ROMAINE-A", + ["unit_of_measure"], + ), + ( + "944", + "W07*200*CA**UP*012345678905***LOT-ROMAINE-A", + "W07**CA**UP*012345678905***LOT-ROMAINE-A", + ["quantity"], + ), + ("945", "W12*CC*200*200*0*CA****LOT-ROMAINE-A", "W12*CC*200*200*0*****LOT-ROMAINE-A", ["unit_of_measure"]), + ("945", "W12*CC*200*200*0*CA****LOT-ROMAINE-A", "W12*CC*200**0*CA****LOT-ROMAINE-A", ["quantity"]), + ], +) +def test_quantity_and_unit_are_not_synthesized_on_other_sets( + client: TestClient, + stored: list[dict[str, Any]], + reserved: list[Any], + transaction_set: str, + good: str, + bad: str, + missing: list[str], +) -> None: + body = [bad if item == good else item for item in VALID_DOCUMENTS[transaction_set]] + assert bad in body + + response = _post(client, "/document", _document(transaction_set, *body), tlc_source_reference=TLC_SOURCE) + + _assert_rejected_before_reservation(response, transaction_set, missing, stored, reserved) + + +def test_unit_override_supplies_a_blank_unit(client: TestClient, stored: list[dict[str, Any]]) -> None: + body = ["RCD**200" if item == "RCD**200*CA" else item for item in VALID_DOCUMENTS["861"]] + + response = _post( + client, + "/document", + _document("861", *body), + tlc_source_reference=TLC_SOURCE, + unit_of_measure_override="CA", + ) + + assert response.status_code == 201, response.text + assert len(stored) == 1 + + +@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 72b4da09a..fb3be145e 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 @@ -461,10 +461,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*~" )