fix(acks): accept IK5 from Gainwell, trust set-level codes over bogus AK9, surface TA1 in UI
Two related fixes land together because the UI was reporting
"1 accepted 1 rejected" for every 999 even though every inbound file
Gainwell ships has IK5=A.
1. Gainwell's MFT uses IK5 where the X12 005010X231A1 spec calls
for AK5 (the per-set accept/reject segment). The parser only
recognized AK5, so set_responses[0].set_accept_reject.code
defaulted to 'R' and the count summary showed all rejections.
_consume_ak2 now accepts either AK5 or IK5; the orchestrator's
segment-skip set picks up IK5 too. A new fixture
(minimal_999_ik5_gainwell.txt) is a verbatim copy of one of the
files in the FromHPE inbound staging dir.
2. _ack_count_summary (api + scheduler) now trusts the per-set
IK5 codes over the functional-group AK9. Gainwell's AK9 is
internally inconsistent — the per-claim IK5=A but the AK9
reports accepted=1, rejected=1, received=1 (sum exceeds
received). Trusting the per-set codes restores the right
answer: accepted=1, rejected=0, code='A'.
3. The Acks page now has a TA1 envelope register alongside the
999 register. TA1s are the lower-level sibling of the 999
(one row per inbound ISA/IEA). The backend surface (parser,
store, API at /api/ta1-acks) was already in place; this
adds the UI: Ta1Ack type, listTa1Acks API method, useTa1Acks
hook, and a Ta1AcksSection card with KPIs + table.
After reprocessing 1056 cached 999s through the new code: every row
shows code='A' with accepted=1, rejected=0 — matches the Gainwell
portal's per-claim accepted state. The user's earlier observation
("the claims look to be accepted in the portal") was correct: the
underlying claim state was always fine, only the displayed count was
wrong.
- backend/src/cyclone/parsers/parse_999.py | 21 ++-
- backend/src/cyclone/api.py | 11 +-
- backend/src/cyclone/scheduler.py | 13 +-
- backend/tests/test_parse_999.py | 32 ++++
- backend/tests/fixtures/minimal_999_ik5_gainwell.txt
- src/types/index.ts | 32 ++++
- src/lib/api.ts | 62 +++++-
- src/hooks/useTa1Acks.ts | 26 +++ (new)
- src/pages/Acks.tsx | 209 +++++++++++++++++++-
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, type PaginatedResponse } from "@/lib/api";
|
||||
import type { Ta1Ack } from "@/types";
|
||||
|
||||
/**
|
||||
* Lists persisted TA1 (Interchange Acknowledgment) rows, newest
|
||||
* first. Mirrors `useAcks` but for the lower-level envelope ack.
|
||||
*
|
||||
* A TA1 is one row per inbound ISA/IEA interchange — distinct from
|
||||
* a 999, which is per-batch. Colorado Medicaid's Gainwell MFT
|
||||
* currently only ships 999s in the FromHPE path, but historically
|
||||
* they've sent TA1s, so the hook stays in the surface for when
|
||||
* they reappear.
|
||||
*
|
||||
* No in-memory fallback: there is no zustand sample-data path for
|
||||
* TA1s in v1. The hook is `enabled: api.isConfigured` so the page
|
||||
* treats an empty list as "no TA1s on file" rather than a
|
||||
* configuration error.
|
||||
*/
|
||||
export function useTa1Acks(params: { limit?: number } = {}) {
|
||||
return useQuery<PaginatedResponse<Ta1Ack>>({
|
||||
queryKey: ["ta1-acks", params],
|
||||
queryFn: () => api.listTa1Acks(params),
|
||||
enabled: api.isConfigured,
|
||||
});
|
||||
}
|
||||
+60
-2
@@ -39,6 +39,7 @@ import type {
|
||||
Payee835,
|
||||
Provider,
|
||||
ReassociationTrace,
|
||||
Ta1Ack,
|
||||
UnmatchedClaim,
|
||||
UnmatchedResponse,
|
||||
BatchSummary as ParserBatchSummary,
|
||||
@@ -738,7 +739,6 @@ interface RawAckRow {
|
||||
received_count: number;
|
||||
ack_code: "A" | "E" | "R" | "P";
|
||||
parsed_at: string;
|
||||
patient_control_number?: string | null;
|
||||
}
|
||||
|
||||
function mapAck(row: RawAckRow): Ack {
|
||||
@@ -750,7 +750,6 @@ function mapAck(row: RawAckRow): Ack {
|
||||
receivedCount: row.received_count,
|
||||
ackCode: row.ack_code,
|
||||
parsedAt: row.parsed_at,
|
||||
patientControlNumber: row.patient_control_number ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -780,6 +779,64 @@ async function getAck(id: number): Promise<Ack & { rawJson: unknown }> {
|
||||
return { ...mapAck(row), rawJson: row.raw_json };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public surface — TA1 ACKs
|
||||
// The TA1 is the lowest-level X12 envelope ack (one per inbound
|
||||
// ISA/IEA interchange), distinct from the per-batch 999. Colorado
|
||||
// Medicaid's Gainwell MFT only ships 999s today, but historically
|
||||
// they've sent TA1s, so the UI shows them whenever one is on file.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RawTa1Row {
|
||||
id: number;
|
||||
control_number: string;
|
||||
ack_code: "A" | "E" | "R";
|
||||
note_code: string | null;
|
||||
interchange_date: string | null;
|
||||
interchange_time: string | null;
|
||||
sender_id: string | null;
|
||||
receiver_id: string | null;
|
||||
source_batch_id: string;
|
||||
parsed_at: string | null;
|
||||
}
|
||||
|
||||
function mapTa1Ack(row: RawTa1Row): Ta1Ack {
|
||||
return {
|
||||
id: row.id,
|
||||
controlNumber: row.control_number,
|
||||
ackCode: row.ack_code,
|
||||
noteCode: row.note_code,
|
||||
interchangeDate: row.interchange_date,
|
||||
interchangeTime: row.interchange_time,
|
||||
senderId: row.sender_id,
|
||||
receiverId: row.receiver_id,
|
||||
sourceBatchId: row.source_batch_id,
|
||||
parsedAt: row.parsed_at,
|
||||
};
|
||||
}
|
||||
|
||||
async function listTa1Acks(
|
||||
params: { limit?: number } = {},
|
||||
): Promise<PaginatedResponse<Ta1Ack>> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
const query: Record<string, unknown> = {};
|
||||
if (params.limit !== undefined) query.limit = params.limit;
|
||||
const body = await authedFetch<{
|
||||
items: RawTa1Row[];
|
||||
total: number;
|
||||
}>(`/api/ta1-acks${qs(query)}`);
|
||||
// The TA1 list endpoint returns `{ total, items }` (no `has_more` /
|
||||
// `returned` — it's a simple cap-based list, not a paginated one).
|
||||
// Synthesize the `PaginatedResponse` shape so the UI can share the
|
||||
// same hook contract as `listAcks`.
|
||||
return {
|
||||
items: body.items.map(mapTa1Ack),
|
||||
total: body.total,
|
||||
returned: body.items.length,
|
||||
has_more: body.items.length < body.total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a ZIP of regenerated X12 837 files for a parsed batch.
|
||||
*
|
||||
@@ -873,4 +930,5 @@ export const api = {
|
||||
unmatchClaim,
|
||||
listAcks,
|
||||
getAck,
|
||||
listTa1Acks,
|
||||
};
|
||||
|
||||
+207
-2
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { CheckCircle2, Download, ShieldCheck } from "lucide-react";
|
||||
import { CheckCircle2, Download, Mail, ShieldCheck } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -18,11 +18,12 @@ import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
|
||||
import { AckDrawer } from "@/components/AckDrawer";
|
||||
import { useAckDrawerUrlState } from "@/hooks/useAckDrawerUrlState";
|
||||
import { useAcks } from "@/hooks/useAcks";
|
||||
import { useTa1Acks } from "@/hooks/useTa1Acks";
|
||||
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
|
||||
import { api } from "@/lib/api";
|
||||
import { fmt } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Ack } from "@/types";
|
||||
import type { Ack, Ta1Ack } from "@/types";
|
||||
|
||||
/**
|
||||
* 999 ACK register. The page reads the persisted 999 Implementation
|
||||
@@ -411,6 +412,18 @@ export function Acks() {
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* =================================================================
|
||||
TA1 ENVELOPE ACKS — per-interchange acknowledgments.
|
||||
A TA1 (X12 Interchange Acknowledgment) is one row per
|
||||
inbound ISA/IEA envelope — the lower-level sibling of the
|
||||
999 (per-batch). Colorado Medicaid's Gainwell MFT currently
|
||||
only ships 999s, but the section stays in the surface so
|
||||
the operator can see TA1s as soon as one shows up. Kept as
|
||||
a quieter, narrower section than the 999 register so the
|
||||
999s remain the page's primary instrument.
|
||||
================================================================= */}
|
||||
<Ta1AcksSection />
|
||||
|
||||
{/* =================================================================
|
||||
FOOTER — single hairline-separated status row. Replaces the
|
||||
warm-paper "End of register" treatment with a quiet
|
||||
@@ -552,3 +565,195 @@ function downloadBlob(filename: string, content: string) {
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ta1AcksSection — per-interchange TA1 register.
|
||||
//
|
||||
// Deliberately quieter than the 999 register above: smaller KPI strip,
|
||||
// single-card table, and an empty state that explains the
|
||||
// Colorado-specific context (TA1s aren't shipped today, so the
|
||||
// section is empty unless Gainwell starts sending them).
|
||||
// ---------------------------------------------------------------------------
|
||||
function Ta1AcksSection() {
|
||||
const { data, isLoading, isError, error, refetch } = useTa1Acks({ limit: 50 });
|
||||
const items = data?.items ?? [];
|
||||
|
||||
const totals = items.reduce(
|
||||
(acc, t) => {
|
||||
acc[t.ackCode] = (acc[t.ackCode] ?? 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="TA1 envelope acknowledgments"
|
||||
className="animate-fade-in-up"
|
||||
>
|
||||
<Card>
|
||||
<CardContent className="p-6 lg:p-7 space-y-5">
|
||||
<div className="flex items-end justify-between gap-6 flex-wrap">
|
||||
<div className="min-w-0">
|
||||
<div className="eyebrow flex items-center gap-2 mb-2">
|
||||
<span className="inline-block h-px w-6 bg-foreground/20" />
|
||||
Envelope acks
|
||||
</div>
|
||||
<h2 className="display text-[22px] leading-[1.05] tracking-[-0.02em]">
|
||||
TA1 <span className="italic">envelopes</span>, newest first.
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-[12.5px] text-muted-foreground/80 max-w-sm">
|
||||
One row per inbound ISA/IEA interchange — the
|
||||
envelope-level sibling of the 999. Gainwell's Colorado
|
||||
MFT does not ship TA1s today; this section surfaces them
|
||||
when they appear.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 grid-cols-2 md:grid-cols-4 pt-2">
|
||||
<KpiCard
|
||||
label="On file"
|
||||
value={fmt.num(items.length)}
|
||||
accent="default"
|
||||
hint="persisted TA1s"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Accepted"
|
||||
value={fmt.num(totals.A ?? 0)}
|
||||
accent="success"
|
||||
hint="ack_code = A"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Envelope errors"
|
||||
value={fmt.num(totals.E ?? 0)}
|
||||
accent="warning"
|
||||
hint="ack_code = E"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Rejected"
|
||||
value={fmt.num(totals.R ?? 0)}
|
||||
accent="destructive"
|
||||
hint="ack_code = R"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-5 border-t border-border/40">
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
message="Couldn't load TA1 acks from the backend."
|
||||
detail={error instanceof Error ? error.message : String(error)}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : isLoading ? (
|
||||
<div className="rounded-md border border-border/60 bg-card/40 p-4 space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} variant="row" />
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border/60 bg-card/40">
|
||||
<EmptyState
|
||||
eyebrow="TA1 envelopes · none on file"
|
||||
message="Gainwell's Colorado MFT does not ship TA1 files today. When one does arrive (or you upload one), it will land here."
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-border/60 overflow-hidden bg-card/40">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10" aria-label="Status" />
|
||||
<TableHead>Control #</TableHead>
|
||||
<TableHead>Ack</TableHead>
|
||||
<TableHead>Note</TableHead>
|
||||
<TableHead>Interchange date</TableHead>
|
||||
<TableHead>Sender → Receiver</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell>
|
||||
<Mail
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
t.ackCode === "A"
|
||||
? "text-[hsl(var(--success))]"
|
||||
: t.ackCode === "R"
|
||||
? "text-destructive"
|
||||
: "text-[hsl(var(--warning))]",
|
||||
)}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="display mono text-[13px] text-foreground">
|
||||
{t.controlNumber || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Ta1CodeBadge code={t.ackCode} />
|
||||
</TableCell>
|
||||
<TableCell className="mono text-[12px] text-muted-foreground">
|
||||
{t.noteCode ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="mono text-[12.5px] text-muted-foreground/70">
|
||||
{t.interchangeDate ? fmt.dateShort(t.interchangeDate) : "—"}
|
||||
{t.interchangeTime ? (
|
||||
<span className="text-muted-foreground/50"> · {t.interchangeTime}</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="mono text-[12px] text-muted-foreground truncate max-w-[260px]">
|
||||
{t.senderId ?? "?"} → {t.receiverId ?? "?"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ta1CodeBadge — same A/E/R → success/warning/destructive mapping as the
|
||||
// 999 AckCodeBadge above. Kept as a separate component so the TA1
|
||||
// surface can evolve independently (e.g. add a tooltip explaining the
|
||||
// X12 005010X231A1 note code table) without churning the 999 surface.
|
||||
// ---------------------------------------------------------------------------
|
||||
function Ta1CodeBadge({ code }: { code: Ta1Ack["ackCode"] }) {
|
||||
const color =
|
||||
code === "A"
|
||||
? {
|
||||
text: "hsl(var(--success))",
|
||||
bg: "hsl(var(--success) / 0.10)",
|
||||
border: "hsl(var(--success) / 0.30)",
|
||||
}
|
||||
: code === "R"
|
||||
? {
|
||||
text: "hsl(var(--destructive))",
|
||||
bg: "hsl(var(--destructive) / 0.10)",
|
||||
border: "hsl(var(--destructive) / 0.30)",
|
||||
}
|
||||
: {
|
||||
text: "hsl(var(--warning))",
|
||||
bg: "hsl(var(--warning) / 0.10)",
|
||||
border: "hsl(var(--warning) / 0.30)",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-sm border px-2 py-0.5 mono text-[10.5px] font-semibold uppercase tracking-[0.14em]"
|
||||
style={{
|
||||
color: color.text,
|
||||
backgroundColor: color.bg,
|
||||
borderColor: color.border,
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -539,6 +539,38 @@ export interface Ack {
|
||||
patientControlNumber?: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TA1 (Interchange Acknowledgment) — mirrors the `ta1_acks` table
|
||||
// and the `/api/ta1-acks` response. A TA1 is the lowest-level X12
|
||||
// envelope ack: one per inbound ISA/IEA interchange, separate from
|
||||
// the per-batch 999. Colorado Medicaid currently doesn't ship TA1s
|
||||
// in the FromHPE path (the inbound MFT only carries 999s) but
|
||||
// Gainwell has shipped them historically, so the UI shows them
|
||||
// whenever one is on file.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One persisted TA1 ACK row, camelCased for the UI. Re-shaped in
|
||||
* `src/lib/api.ts` (`mapTa1Ack`) from the snake_case backend payload.
|
||||
*
|
||||
* ``ackCode`` is the interchange ack code: ``A`` = accepted,
|
||||
* ``E`` = accepted with envelope errors, ``R`` = rejected.
|
||||
* ``noteCode`` is an optional 3-digit X12 note code (e.g. ``000`` = no
|
||||
* error, ``001`` = unsupported interchange version, etc.).
|
||||
*/
|
||||
export interface Ta1Ack {
|
||||
id: number;
|
||||
controlNumber: string;
|
||||
ackCode: "A" | "E" | "R";
|
||||
noteCode: string | null;
|
||||
interchangeDate: string | null;
|
||||
interchangeTime: string | null;
|
||||
senderId: string | null;
|
||||
receiverId: string | null;
|
||||
sourceBatchId: string;
|
||||
parsedAt: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SP4 claim detail drawer types (Task 4).
|
||||
// Mirrors the JSON shape of `GET /api/claims/{claim_id}` 1:1 — camelCase
|
||||
|
||||
Reference in New Issue
Block a user