diff --git a/backend/src/cyclone/api_routers/acks.py b/backend/src/cyclone/api_routers/acks.py index 3ea8f09..d95b8a8 100644 --- a/backend/src/cyclone/api_routers/acks.py +++ b/backend/src/cyclone/api_routers/acks.py @@ -32,8 +32,14 @@ def _ack_to_ui(row) -> dict: Field names match the rest of the Cyclone API (snake_case). The frontend ``useAcks`` hook re-shapes this to the camelCase ``Ack`` interface in ``src/types/index.ts``. + + Adds ``patient_control_number`` pulled from ``raw_json`` so the + operator can correlate each 999 back to the original claim + batch. The full inbound filename is reachable via + ``GET /api/acks/{ack_id}`` (raw_json carries the full parse + tree) and ``GET /api/admin/scheduler/processed-files``. """ - return { + body = { "id": row.id, "source_batch_id": row.source_batch_id, "accepted_count": row.accepted_count, @@ -45,7 +51,16 @@ def _ack_to_ui(row) -> dict: if row.parsed_at is not None else "" ), + "patient_control_number": None, } + raw = row.raw_json or {} + try: + set_responses = raw.get("set_responses") or [] + if set_responses: + body["patient_control_number"] = set_responses[0].get("set_control_number") + except (AttributeError, TypeError): + pass + return body @router.get("/api/acks") diff --git a/backend/src/cyclone/scheduler.py b/backend/src/cyclone/scheduler.py index 3569cb8..e32153d 100644 --- a/backend/src/cyclone/scheduler.py +++ b/backend/src/cyclone/scheduler.py @@ -155,7 +155,16 @@ def _handle_999(text: str, source_file: str) -> tuple[str, int]: received, accepted, rejected, ack_code = _ack_count_summary(result) icn = result.envelope.control_number - synthetic_id = _ack_synthetic_source_batch_id(icn) + # The natural unique key for a 999 is the AK2 set_control_number + # (= the original claim's patient_control_number). Each 999 ack + # covers exactly one claim, so the PCN is 1:1 with the 999 and + # far more useful for the operator than the ISA interchange + # control number (Gainwell's MFT ships every 999 with the same + # default ICN, which used to collapse all 385 daily acks onto + # ``999-000000001``). Fall back to ICN โ†’ ``unknown`` if the AK2 is + # missing. + pcn = result.set_responses[0].set_control_number if result.set_responses else None + synthetic_id = _ack_synthetic_source_batch_id(icn, pcn=pcn, source_filename=source_file) with db.SessionLocal()() as session: def _lookup(pcn: str): @@ -357,9 +366,43 @@ def _ack_count_summary(result: Any) -> tuple[int, int, int, str]: return (received, accepted, rejected, code) -def _ack_synthetic_source_batch_id(interchange_control_number: str) -> str: - """Synthetic batches.id for a received 999 with no source batch.""" - return f"999-{(interchange_control_number or '').strip() or '000000001'}" +def _ack_synthetic_source_batch_id( + interchange_control_number: str, + *, + pcn: str | None = None, + source_filename: str | None = None, +) -> str: + """Synthetic batches.id for a received 999 with no source batch. + + Gainwell's MFT ships every 999 with the same default ISA + interchange control number (``000000001``), so the ICN alone + collapses all daily acks onto one row. The AK2 + ``set_control_number`` (= the original claim's + patient_control_number) is per-batch โ€” Gainwell's 999 + acks are per-batch, not per-claim, so a daily pull of 385 + 999s typically has only ~4 distinct PCNs. To make every + acks row distinguishable in the UI, the source_batch_id + always includes an 8-char hash of the inbound filename. + + Precedence for the human-readable part of the id + (column is VARCHAR(32)): + + 1. ``999-{pcn}-{hash8}`` if AK2 set_control_number is + present (the common case). 4+9+1+8 = 22 chars max. + 2. ``999-{icn}-{hash8}`` if no AK2 (envelope-only 999). + 3. ``999-{hash12}`` if no filename either (shouldn't + happen in production). + """ + import hashlib + short_hash = "" + if source_filename: + short_hash = hashlib.sha1(source_filename.encode("utf-8")).hexdigest()[:8] + if pcn and pcn.strip(): + return f"999-{pcn.strip()}-{short_hash}" + icn = (interchange_control_number or "").strip() or "000000001" + if short_hash: + return f"999-{icn}-{short_hash}" + return f"999-{short_hash or icn}" def _277ca_synthetic_source_batch_id(interchange_control_number: str) -> str: diff --git a/src/lib/api.ts b/src/lib/api.ts index 0e5d302..ead3361 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -738,6 +738,7 @@ interface RawAckRow { received_count: number; ack_code: "A" | "E" | "R" | "P"; parsed_at: string; + patient_control_number?: string | null; } function mapAck(row: RawAckRow): Ack { @@ -749,6 +750,7 @@ function mapAck(row: RawAckRow): Ack { receivedCount: row.received_count, ackCode: row.ack_code, parsedAt: row.parsed_at, + patientControlNumber: row.patient_control_number ?? null, }; } diff --git a/src/pages/Acks.tsx b/src/pages/Acks.tsx index f707e11..1ee2652 100644 --- a/src/pages/Acks.tsx +++ b/src/pages/Acks.tsx @@ -365,7 +365,18 @@ export function Acks() { {a.id} - {a.sourceBatchId} + {a.patientControlNumber ? ( + <> + + {a.patientControlNumber} + + + {" "}ยท {a.sourceBatchId} + + + ) : ( + a.sourceBatchId + )} {a.acceptedCount} diff --git a/src/types/index.ts b/src/types/index.ts index d307918..6dab372 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -529,6 +529,14 @@ export interface Ack { receivedCount: number; ackCode: "A" | "E" | "R" | "P"; parsedAt: string; + /** + * AK2 set_control_number from the inbound 999 โ€” the + * patient_control_number of the original claim batch this 999 + * acks. Surfaced in the list endpoint so the operator can + * correlate a 999 to a claim batch in the UI without a second + * round-trip to the detail endpoint. + */ + patientControlNumber?: string | null; } // ---------------------------------------------------------------------------