fix(acks): make 999 source_batch_id unique per file + surface PCN

Gainwell's MFT ships every 999 with the same ISA interchange
control number (`000000001`) and one 999 ack covers a whole
batch, not a single claim — so the AK2 set_control_number
(patient_control_number) is the same for the ~96 999s in a
batch. With the old synthetic-id formula (`999-{icn}`), all 385
daily acks collapsed onto a single row the operator couldn't
distinguish.

The new formula is `999-{pcn}-{filename_hash8}` (or
`999-{icn}-{filename_hash8}` for envelope-only 999s without an
AK2). The PCN gives the operator a human-readable handle to the
claim batch; the 8-char hash of the inbound filename guarantees
uniqueness within a batch. Fits in the VARCHAR(32) source_batch_id
column (max 22 chars).

Also surface `patient_control_number` in /api/acks list response
(extracted from raw_json's set_responses[0].set_control_number)
and in the Acks UI as the primary label, with the synthetic id
shown dimmed after a middle dot. The detail endpoint already
exposed raw_json for the full 999 parse tree.
This commit is contained in:
tyler
2026-06-24 23:55:58 -06:00
parent c3a6c53096
commit 1381a7652d
5 changed files with 85 additions and 6 deletions
+16 -1
View File
@@ -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")
+47 -4
View File
@@ -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:
+2
View File
@@ -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,
};
}
+12 -1
View File
@@ -365,7 +365,18 @@ export function Acks() {
{a.id}
</TableCell>
<TableCell className="font-mono text-[12px] text-muted-foreground truncate max-w-[180px]">
{a.sourceBatchId}
{a.patientControlNumber ? (
<>
<span className="text-foreground">
{a.patientControlNumber}
</span>
<span className="text-muted-foreground/60">
{" "}· {a.sourceBatchId}
</span>
</>
) : (
a.sourceBatchId
)}
</TableCell>
<TableCell className="text-right display mono text-[hsl(var(--success))]">
{a.acceptedCount}
+8
View File
@@ -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;
}
// ---------------------------------------------------------------------------