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
|
Field names match the rest of the Cyclone API (snake_case). The
|
||||||
frontend ``useAcks`` hook re-shapes this to the camelCase ``Ack``
|
frontend ``useAcks`` hook re-shapes this to the camelCase ``Ack``
|
||||||
interface in ``src/types/index.ts``.
|
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,
|
"id": row.id,
|
||||||
"source_batch_id": row.source_batch_id,
|
"source_batch_id": row.source_batch_id,
|
||||||
"accepted_count": row.accepted_count,
|
"accepted_count": row.accepted_count,
|
||||||
@@ -45,7 +51,16 @@ def _ack_to_ui(row) -> dict:
|
|||||||
if row.parsed_at is not None
|
if row.parsed_at is not None
|
||||||
else ""
|
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")
|
@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)
|
received, accepted, rejected, ack_code = _ack_count_summary(result)
|
||||||
icn = result.envelope.control_number
|
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:
|
with db.SessionLocal()() as session:
|
||||||
def _lookup(pcn: str):
|
def _lookup(pcn: str):
|
||||||
@@ -357,9 +366,43 @@ def _ack_count_summary(result: Any) -> tuple[int, int, int, str]:
|
|||||||
return (received, accepted, rejected, code)
|
return (received, accepted, rejected, code)
|
||||||
|
|
||||||
|
|
||||||
def _ack_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
def _ack_synthetic_source_batch_id(
|
||||||
"""Synthetic batches.id for a received 999 with no source batch."""
|
interchange_control_number: str,
|
||||||
return f"999-{(interchange_control_number or '').strip() or '000000001'}"
|
*,
|
||||||
|
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:
|
def _277ca_synthetic_source_batch_id(interchange_control_number: str) -> str:
|
||||||
|
|||||||
@@ -738,6 +738,7 @@ interface RawAckRow {
|
|||||||
received_count: number;
|
received_count: number;
|
||||||
ack_code: "A" | "E" | "R" | "P";
|
ack_code: "A" | "E" | "R" | "P";
|
||||||
parsed_at: string;
|
parsed_at: string;
|
||||||
|
patient_control_number?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapAck(row: RawAckRow): Ack {
|
function mapAck(row: RawAckRow): Ack {
|
||||||
@@ -749,6 +750,7 @@ function mapAck(row: RawAckRow): Ack {
|
|||||||
receivedCount: row.received_count,
|
receivedCount: row.received_count,
|
||||||
ackCode: row.ack_code,
|
ackCode: row.ack_code,
|
||||||
parsedAt: row.parsed_at,
|
parsedAt: row.parsed_at,
|
||||||
|
patientControlNumber: row.patient_control_number ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -365,7 +365,18 @@ export function Acks() {
|
|||||||
{a.id}
|
{a.id}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-[12px] text-muted-foreground truncate max-w-[180px]">
|
<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>
|
||||||
<TableCell className="text-right display mono text-[hsl(var(--success))]">
|
<TableCell className="text-right display mono text-[hsl(var(--success))]">
|
||||||
{a.acceptedCount}
|
{a.acceptedCount}
|
||||||
|
|||||||
@@ -529,6 +529,14 @@ export interface Ack {
|
|||||||
receivedCount: number;
|
receivedCount: number;
|
||||||
ackCode: "A" | "E" | "R" | "P";
|
ackCode: "A" | "E" | "R" | "P";
|
||||||
parsedAt: string;
|
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