diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index dba36d3..e30563e 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -669,12 +669,13 @@ async def parse_835_endpoint( def _ack_count_summary(result) -> tuple[int, int, int, str]: """Aggregate (received, accepted, rejected, ack_code) from a ParseResult999. - The first functional group carries the canonical counts; falls back - to summing per-set codes if no AK9 was found. + Counts are derived from the set-level ``IK5`` responses (one per + AK2 in the 999), not the functional-group ``AK9``. Gainwell's + MFT ships AK9 segments that contradict the per-set IK5 + (e.g. ``AK9*A*1*1*1`` with ``IK5*A``), so trusting AK9's + rejected count would over-report rejections. The set-level + IK5 is the authoritative per-claim accept/reject signal. """ - if result.functional_group_acks: - fg = result.functional_group_acks[0] - return (fg.received_count, fg.accepted_count, fg.rejected_count, fg.ack_code) sets = result.set_responses received = len(sets) accepted = sum(1 for s in sets if s.set_accept_reject.code == "A") diff --git a/backend/src/cyclone/parsers/parse_999.py b/backend/src/cyclone/parsers/parse_999.py index 00aa1f5..1173bcd 100644 --- a/backend/src/cyclone/parsers/parse_999.py +++ b/backend/src/cyclone/parsers/parse_999.py @@ -8,6 +8,12 @@ Single-pass walker over the tokenized segment list: - AK3 (Segment Context) + AK4 (Element Context) — optional per-segment errors - AK5 (Transaction Set Response Status) — per-set accept/reject - AK9 (Functional Group Response Status) — per-group counts + ack code +- IK5 — a non-standard synonym for ``AK5`` that Gainwell's MFT ships + in place of the spec-defined ``AK5``. The X12 005010X231A1 IG + treats the set-level response segment as ``AK5``; ``IK5`` is a + sender-specific deviation observed on Colorado Medicaid's Gainwell + MFT (verified against the live 999 files in the FromHPE inbound + path). We accept either. - SE / GE / IEA Errors at the file level raise :class:`CycloneParseError`. The parser @@ -146,6 +152,11 @@ def _consume_ak3_ak4(segments: list[list[str]], idx: int) -> tuple[list[SegmentE def _consume_ak2(segments: list[list[str]], idx: int) -> SetFunctionalGroupResponse | None: """Read an AK2 + its child AK3*/AK4* + AK5 segments, return the SetResponse. + The set-level accept/reject segment is canonically ``AK5`` (see + X12 005010X231A1). We also accept ``IK5`` as a synonym because + Gainwell's MFT ships the segment under that id — see the file + header for the full rationale. + Returns None when called with a non-AK2 segment (defensive — the orchestrator only calls this when it sees AK2). """ @@ -164,8 +175,11 @@ def _consume_ak2(segments: list[list[str]], idx: int) -> SetFunctionalGroupRespo if idx < len(segments) and segments[idx][0] == "AK3": seg_errors, idx = _consume_ak3_ak4(segments, idx) # AK5 (set accept/reject) — required by the spec; default to "R" if missing. + # Gainwell's MFT uses IK5 instead of AK5 (sender-specific segment id + # that means the same thing); accept either. The default of "R" + # matters: if the segment is missing entirely, the 999 is a reject. accept_code = "R" - if idx < len(segments) and segments[idx][0] == "AK5": + if idx < len(segments) and segments[idx][0] in ("AK5", "IK5"): ak5 = segments[idx] if len(ak5) > 1 and ak5[1]: accept_code = ak5[1] @@ -256,8 +270,11 @@ def parse_999_text(text: str, *, input_file: str = "") -> ParseResult999: set_responses.append(sr) # Advance past the AK2 + AK3*/AK4*/AK5 cluster # (re-walk from i+1 because _consume_ak2 doesn't return idx). + # ``IK5`` is the Gainwell-specific synonym for ``AK5`` + # and must be in the consumed set here too (see + # _consume_ak2 for the full rationale). i += 1 - while i < len(segments) and segments[i][0] in {"AK3", "AK4", "AK5"}: + while i < len(segments) and segments[i][0] in {"AK3", "AK4", "AK5", "IK5"}: i += 1 else: i += 1 diff --git a/backend/src/cyclone/scheduler.py b/backend/src/cyclone/scheduler.py index e32153d..1f5724b 100644 --- a/backend/src/cyclone/scheduler.py +++ b/backend/src/cyclone/scheduler.py @@ -346,13 +346,14 @@ def _ack_count_summary(result: Any) -> tuple[int, int, int, str]: Mirrors the logic in ``cyclone.api._ack_count_summary`` but lives here so the scheduler can run without importing the API module. + + Counts are derived from the **set-level** ``IK5`` responses + (one per AK2 in the 999), not the functional-group ``AK9`` — + Gainwell's MFT ships AK9 segments that contradict the per-set + IK5 (e.g. ``AK9*A*1*1*1`` with ``IK5*A``), so trusting AK9's + rejected count would over-report rejections. The set-level + IK5 is the authoritative per-claim accept/reject signal. """ - if result.functional_group_acks: - fg = result.functional_group_acks[0] - return ( - fg.received_count, fg.accepted_count, - fg.rejected_count, fg.ack_code, - ) sets = result.set_responses received = len(sets) accepted = sum(1 for s in sets if s.set_accept_reject.code == "A") diff --git a/backend/tests/fixtures/minimal_999_ik5_gainwell.txt b/backend/tests/fixtures/minimal_999_ik5_gainwell.txt new file mode 100644 index 0000000..e6deecc --- /dev/null +++ b/backend/tests/fixtures/minimal_999_ik5_gainwell.txt @@ -0,0 +1,10 @@ +ISA*00* *00* *ZZ*COMEDASSISTPROG*ZZ*11525703 *260527*2303*^*00501*000000001*0*P*:~ +GS*FA*COMEDASSISTPROG*11525703*20260527*2303*1*X*005010X231A1~ +ST*999*0001*005010X231A1~ +AK1*HC*1*005010X222A1~ +AK2*837*991102989*005010X222A1~ +IK5*A~ +AK9*A*1*1*1~ +SE*6*0001~ +GE*1*1~ +IEA*1*000000001~ diff --git a/backend/tests/test_parse_999.py b/backend/tests/test_parse_999.py index d3c5208..8cfbe85 100644 --- a/backend/tests/test_parse_999.py +++ b/backend/tests/test_parse_999.py @@ -11,6 +11,12 @@ from cyclone.parsers.parse_999 import parse_999_text ACCEPTED = Path(__file__).parent / "fixtures" / "minimal_999.txt" REJECTED = Path(__file__).parent / "fixtures" / "minimal_999_rejected.txt" +# Gainwell's MFT ships the set-level accept/reject segment under the +# sender-specific id ``IK5`` instead of the spec-defined ``AK5`` (X12 +# 005010X231A1). This fixture is a verbatim copy of one of the files +# in the FromHPE inbound staging dir — see +# backend/src/cyclone/parsers/parse_999.py for the rationale. +GAINWELL_IK5 = Path(__file__).parent / "fixtures" / "minimal_999_ik5_gainwell.txt" def test_parse_minimal_999_returns_accepted(): @@ -79,3 +85,29 @@ def test_parse_999_garbage_raises(): """Non-EDI input must raise CycloneParseError, not return a half-built result.""" with pytest.raises(CycloneParseError): parse_999_text("not edi at all", input_file="bad.txt") + + +def test_parse_999_gainwell_ik5_segment_accepted(): + """The IK5 set-level segment Gainwell ships must parse as 'A'. + + The X12 005010X231A1 spec calls for ``AK5``; Gainwell's MFT uses + ``IK5`` as a sender-specific synonym. The parser must treat either + id as the set-level accept/reject signal so the per-claim + accepted/rejected counts reflect the real outcome (not the bogus + AK9 the same file carries — Gainwell's ``AK9*A*1*1*1`` is + internally inconsistent: accepted + rejected > received). + """ + text = GAINWELL_IK5.read_text() + result = parse_999_text(text, input_file=GAINWELL_IK5.name) + assert len(result.set_responses) == 1 + s = result.set_responses[0] + assert s.set_accept_reject.code == "A" + assert s.transaction_set_identifier == "837" + assert s.set_control_number == "991102989" + # AK9 is parsed but the per-set signal is what the UI trusts. + assert result.functional_group_acks[0].ack_code == "A" + assert result.functional_group_acks[0].received_count == 1 + # ``summary`` rolls the per-set codes up — this is the field the + # API/UI count summary derives from. + assert result.summary.passed == 1 + assert result.summary.failed == 0 diff --git a/src/hooks/useTa1Acks.ts b/src/hooks/useTa1Acks.ts new file mode 100644 index 0000000..0385eac --- /dev/null +++ b/src/hooks/useTa1Acks.ts @@ -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>({ + queryKey: ["ta1-acks", params], + queryFn: () => api.listTa1Acks(params), + enabled: api.isConfigured, + }); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index ead3361..9984b17 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 { 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> { + if (!isConfigured) throw notConfiguredError(); + const query: Record = {}; + 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, }; diff --git a/src/pages/Acks.tsx b/src/pages/Acks.tsx index 1ee2652..f909e3b 100644 --- a/src/pages/Acks.tsx +++ b/src/pages/Acks.tsx @@ -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() { + {/* ================================================================= + 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. + ================================================================= */} + + {/* ================================================================= 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, + ); + + return ( +
+ + +
+
+
+ + Envelope acks +
+

+ TA1 envelopes, newest first. +

+
+

+ 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. +

+
+ +
+ + + + +
+ +
+ {isError ? ( + refetch()} + /> + ) : isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : items.length === 0 ? ( +
+ +
+ ) : ( +
+ + + + + Control # + Ack + Note + Interchange date + Sender → Receiver + + + + {items.map((t) => ( + + + + + + {t.controlNumber || "—"} + + + + + + {t.noteCode ?? "—"} + + + {t.interchangeDate ? fmt.dateShort(t.interchangeDate) : "—"} + {t.interchangeTime ? ( + · {t.interchangeTime} + ) : null} + + + {t.senderId ?? "?"} → {t.receiverId ?? "?"} + + + ))} + +
+
+ )} +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( + + {code} + + ); +} diff --git a/src/types/index.ts b/src/types/index.ts index 6dab372..15b4e8c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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