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:
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user