Repository navigation
fix(fsma): strict TLC mismatch is warning, never hard reject #3719
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
7ac8d56
bb21100
b870456
62daa56
e21c7c8
fddd294
597e5db
3b14e57
de4ba40
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 |
|---|---|---|
|
|
@@ -1240,6 +1240,8 @@ def _preflight_all_sets() -> None: | |
| ) | ||
| if slice_set in ("856", "861", "944", "945"): | ||
| slice_extracted["traceability_lot_code"] = validated["tlc"] | ||
| if validated.get("warnings"): | ||
| slice_extracted["fsma_validation_warnings"] = validated["warnings"] | ||
| slice_extracted["fsma_validation_status"] = "passed" | ||
| except ValidationError as ve: | ||
| errors = ve.errors(include_context=False) if slice_set in ("856", "861", "944", "945") else ve.errors() | ||
|
|
@@ -1312,6 +1314,7 @@ def _summarize(slice_set: str, slice_extracted: dict[str, Any]) -> dict[str, Any | |
| "product_description": slice_extracted.get("product_description"), | ||
| "reference_document_number": slice_extracted.get("reference_document_number"), | ||
| "fsma_validation_status": slice_extracted.get("fsma_validation_status"), | ||
| "fsma_validation_warnings": slice_extracted.get("fsma_validation_warnings", []), | ||
|
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. Include all line warnings in the transaction-set summary. For a multi-line 856 or 861,
|
||
| } | ||
| if slice_set in ("856", "861", "944", "945"): | ||
| summary.update( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,7 +39,7 @@ def _validate_edi_as_fsma_event( | |
| forced every caller to treat ``None`` as "maybe-failed, maybe- | ||
| unavailable" and masked schema errors in the audit trail. | ||
| """ | ||
| from shared.schemas import FSMAEvent, FSMAEventType | ||
| from shared.schemas import _TLC_PRODUCTION_RE, FSMAEvent, FSMAEventType | ||
|
|
||
| type_map = { | ||
| "856": FSMAEventType.SHIPPING, | ||
|
|
@@ -56,18 +56,26 @@ def _validate_edi_as_fsma_event( | |
| lot_number=extracted.get("lot_number"), | ||
| quantity=extracted.get("quantity"), | ||
| ) | ||
| tlc = line.lot_number | ||
| # Follow the shared EDI schema TLC rule; originator lot versus | ||
| # GTIN-prefixed TLC remains a founder decision (#1104). | ||
| tlc = line.lot_number.strip() | ||
| # A-2170: a whitespace-only lot strips empty. Reject before the | ||
| # GTIN/UPC rebuild so we never emit a production-shaped "GTIN-" | ||
| # TLC that FSMAEvent would accept, collapsing blank lots onto one id. | ||
| if not tlc: | ||
| _WarehouseLineRequiredKDEs(lot_number=tlc, quantity=line.quantity) | ||
| # Pattern misses are advisory, so check explicitly to retain the | ||
| # warehouse GTIN/UPC rebuild for both accepted and short lots. | ||
| try: | ||
| FSMAEvent.validate_tlc_format(tlc) | ||
| tlc = FSMAEvent.validate_tlc_format(tlc) | ||
| rebuild = not _TLC_PRODUCTION_RE.fullmatch(tlc) | ||
|
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.
|
||
| except ValueError: | ||
| rebuild = True | ||
| if rebuild: | ||
| for item_id, lengths in ( | ||
| (extracted.get("gtin"), (8, 12, 13, 14)), | ||
| (extracted.get("upc"), (12, 13)), | ||
| ): | ||
| if isinstance(item_id, str) and item_id.isascii() and item_id.isdigit() and len(item_id) in lengths: | ||
| tlc = f"{item_id.zfill(14)}-{line.lot_number}" | ||
| tlc = f"{item_id.zfill(14)}-{tlc}" | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
|
||
| break | ||
| elif transaction_set in ("856", "861"): | ||
| # The line lot is the TLC verbatim. Do not prefix a GTIN (#1104). | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,7 +11,9 @@ | |
| from urllib.parse import unquote | ||
| from uuid import UUID, uuid4 | ||
|
|
||
| from pydantic import BaseModel, Field, field_validator | ||
| from pydantic import BaseModel, Field, field_validator, model_validator | ||
|
|
||
| from .fsma_validation import validate_tlc | ||
|
|
||
| # Example constants to avoid duplication in schema examples | ||
| EX_SUBJECT = "food facilities on the FTL" | ||
|
|
@@ -372,7 +374,7 @@ class FSMAEvent(BaseModel): | |
| model before being emitted to Kafka. | ||
|
|
||
| Required KDEs (rejection triggers): | ||
| - ``tlc`` – GTIN-14 + lot, LGTIN class URN, or GS1 Digital Link | ||
| - ``tlc`` – at least 3 characters after stripping whitespace | ||
| - ``event_time`` – ISO 8601 timestamp (required) | ||
| """ | ||
|
|
||
|
|
@@ -386,11 +388,8 @@ class FSMAEvent(BaseModel): | |
| ) | ||
| tlc: str = Field( | ||
| ..., | ||
| description="Traceability Lot Code (GTIN-14 + lot, LGTIN class URN, or GS1 Digital Link)", | ||
| # 15 is the GTIN-14 form (14 digits + 1 lot character). An LGTIN class | ||
| # URN is at least 36 characters and a GS1 Digital Link at least 31, | ||
| # so this floor does not reject either lot-level form. | ||
| min_length=15, | ||
| description="Traceability Lot Code (production-pattern mismatches warn only)", | ||
| min_length=3, | ||
|
Comment on lines
+391
to
+392
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.
|
||
| ) | ||
| product_description: str = Field( | ||
| default="Unknown Product", | ||
|
|
@@ -409,6 +408,8 @@ class FSMAEvent(BaseModel): | |
| reference_document_number: Optional[str] = Field(None, description="Source document reference number") | ||
| tenant_id: Optional[str] = Field(None, description="Tenant identifier for multi-tenancy") | ||
|
|
||
| warnings: List[str] = Field(default_factory=list) | ||
|
|
||
| # Legacy fields retained for backward compatibility with document extraction | ||
| date: Optional[str] = Field(None, description="Event date in YYYY-MM-DD format") | ||
| cte_type: Optional[CTEType] = Field(None, description="Legacy CTE type (use event_type instead)") | ||
|
|
@@ -424,18 +425,23 @@ class FSMAEvent(BaseModel): | |
| @field_validator("tlc") | ||
| @classmethod | ||
| def validate_tlc_format(cls, v: str) -> str: | ||
| """Accept GTIN-14 + lot, an LGTIN class URN, or a GS1 Digital Link.""" | ||
| if _TLC_PRODUCTION_RE.fullmatch(v): | ||
| return v | ||
| for pattern in (_TLC_LGTIN_RE, _TLC_DIGITAL_LINK_RE): | ||
| matched = pattern.fullmatch(v) | ||
| if matched is not None and _decoded_ai10_lot_within_limit(matched.group("lot")): | ||
| return v | ||
| raise ValueError( | ||
| f"TLC '{v}' does not match FSMA 204 production pattern " | ||
| "(accepted formats: a fourteen-digit GTIN prefix followed by a lot " | ||
| "suffix, an LGTIN class URN, or a GS1 Digital Link)" | ||
| ) | ||
| """Strip surrounding whitespace; reject blank or short TLCs. | ||
|
|
||
| Production-pattern advisories are collected on each model instance. | ||
| """ | ||
| cleaned = v.strip() | ||
|
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. Preserve the received TLC after checking its length. For As per path instructions: “Traceability lot codes (TLCs) must be preserved exactly as received (no trimming, case-folding, or re-generation).” Also applies to: 437-437
|
||
| if not cleaned: | ||
| raise ValueError("TLC must not be blank") | ||
| if len(cleaned) < 3: | ||
| raise ValueError("TLC must be at least 3 characters") | ||
| return cleaned | ||
|
|
||
| @model_validator(mode="after") | ||
| def collect_tlc_warnings(self) -> "FSMAEvent": | ||
| for warning in validate_tlc(self.tlc, strict=True).warnings: | ||
| if warning not in self.warnings: | ||
| self.warnings.append(warning) | ||
| return self | ||
|
|
||
| @field_validator("event_time") | ||
| @classmethod | ||
|
|
||
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.
Retain TLC advisories with the ingested event.
For a pattern-missing TLC, this assignment makes the warning available to
_summarizebut not to_build_ingest_event_for_set. That builder sends anIngestEventwithout the warning. After the response is gone, the canonical event has no record of why the formerly rejected TLC passed validation. Add the advisory to the event’s evidence KDEs beforeingest_eventsreceives it. (raw.githubusercontent.com)As per path instructions: “Validation failures must be recorded as evidence, never swallowed.”
Source: Path instructions