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;
}
// ---------------------------------------------------------------------------