feat(sp28): useClaimAcks + useAckClaims hooks with api types

This commit is contained in:
Nora
2026-07-02 11:26:25 -06:00
parent 9e775c0150
commit d2512945ca
6 changed files with 770 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
// @vitest-environment happy-dom
// React Query's internal state updates need an `act`-aware environment
// or it logs noisy warnings. Mirror the useClaimDetail /
// useClaimAcks / useTailStream test convention.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useAckClaims } from "./useAckClaims";
import { api } from "@/lib/api";
// Module-level mock: vitest hoists `vi.mock` above imports. We mock the
// single `api.listAckClaims` method the hook calls.
vi.mock("@/lib/api", () => ({
api: {
listAckClaims: vi.fn(),
},
}));
/** Minimal `renderHook` shim — same pattern as `useClaimAcks.test.ts`. */
function renderHook<TResult>(
useCallback: () => TResult
): {
result: { current: TResult | undefined };
unmount: () => void;
} {
const result: { current: TResult | undefined } = { current: undefined };
const container = document.createElement("div");
document.body.appendChild(container);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
function Probe() {
result.current = useCallback();
return null;
}
const root: Root = createRoot(container);
act(() => {
root.render(
React.createElement(QueryClientProvider, { client: qc }, React.createElement(Probe))
);
});
return {
result,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
/** Poll the result until `predicate` is true or we time out. */
async function waitForState(
predicate: () => boolean,
timeoutMs = 1000
): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error(
`waitForState: predicate did not hold within ${timeoutMs}ms`,
);
}
await act(async () => {
await Promise.resolve();
});
}
}
/** Sample fixture — one 999 with two AK2 set-responses, both auto-linked
* to the same claim. Mirrors the camelCase `ClaimAck` shape (the api
* layer's `mapClaimAck` does the snake_case→camelCase conversion). */
const SAMPLE_ROWS = [
{
id: 11,
claimId: "CLM-1",
batchId: null,
ackId: 99,
ackKind: "999" as const,
ak2Index: 0,
setControlNumber: "991102989",
setAcceptRejectCode: "A",
linkedAt: "2026-07-02T12:00:00Z",
linkedBy: "auto" as const,
claimState: "received",
},
{
id: 12,
claimId: "CLM-1",
batchId: null,
ackId: 99,
ackKind: "999" as const,
ak2Index: 1,
setControlNumber: "991102990",
setAcceptRejectCode: "R",
linkedAt: "2026-07-02T12:00:01Z",
linkedBy: "auto" as const,
claimState: "received",
},
];
describe("useAckClaims", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("test_returns_idle_state_and_does_not_fetch_when_ackId_is_null", () => {
// Drawer closed → no network call, no loading, no error. The hook
// must short-circuit BEFORE the queryFn is ever invoked so a
// closed drawer is free.
const { result, unmount } = renderHook(() => useAckClaims("999", null));
expect(result.current).toBeDefined();
expect(result.current?.data).toBeNull();
expect(result.current?.isLoading).toBe(false);
expect(result.current?.isError).toBe(false);
expect(result.current?.error).toBeNull();
expect(typeof result.current?.refetch).toBe("function");
expect(api.listAckClaims).not.toHaveBeenCalled();
unmount();
});
it("test_returns_idle_state_and_does_not_fetch_when_kind_is_null", () => {
// Defensive — the hook accepts `kind: ClaimAckKind | null` and
// short-circuits when it's null (mirrors the ackId null check).
const { result, unmount } = renderHook(() => useAckClaims(null, 42));
expect(result.current?.data).toBeNull();
expect(result.current?.isLoading).toBe(false);
expect(api.listAckClaims).not.toHaveBeenCalled();
unmount();
});
it("test_fetches_and_returns_claim_ack_rows_for_a_valid_ack", async () => {
(api.listAckClaims as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
SAMPLE_ROWS,
);
const { result, unmount } = renderHook(() => useAckClaims("999", 99));
await waitForState(
() => result.current?.data !== null && result.current?.data !== undefined,
);
// The hook must have invoked the API exactly once with the kind+id.
expect(api.listAckClaims).toHaveBeenCalledTimes(1);
expect(api.listAckClaims).toHaveBeenCalledWith("999", 99);
const data = result.current?.data;
expect(data).not.toBeNull();
expect(data).toHaveLength(2);
// Both rows belong to the same ack (per-AK2 granularity for 999).
expect(data?.[0]?.ackId).toBe(99);
expect(data?.[0]?.ackKind).toBe("999");
expect(data?.[0]?.ak2Index).toBe(0);
expect(data?.[1]?.ak2Index).toBe(1);
// isLoading must flip false once the fetch resolves.
expect(result.current?.isLoading).toBe(false);
expect(result.current?.isError).toBe(false);
unmount();
});
it("test_returns_empty_array_when_ack_has_no_claim_links", async () => {
(api.listAckClaims as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
const { result, unmount } = renderHook(() => useAckClaims("999", 77));
await waitForState(
() => result.current?.data !== null && result.current?.data !== undefined,
);
// Empty list must be surfaced as [] (NOT null), so the AckDrawer
// panel can render its "Link to claim…" dropdown without a null
// check.
expect(result.current?.data).toEqual([]);
unmount();
});
});
+80
View File
@@ -0,0 +1,80 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { ClaimAck, ClaimAckKind } from "@/types";
/**
* Per-ack claims query (AckDrawer · SP28 §5.2).
*
* Lists every `claim_acks` row for the given ack. For 999/277CA,
* one entry per AK2 / ClaimStatus (D1 per-AK2 granularity). For
* TA1, one entry per envelope linked to the originating `Batch`.
* Drives the new `<MatchedClaim />` panel inside `AckDrawer`.
*
* Returns `{ data, isLoading, isError, error, refetch }`:
* - `ackId === null` (drawer closed): the query is disabled and the
* hook short-circuits to the empty drawer state `{ data: null, ... }`
* so a closed drawer doesn't burn a network request or a TanStack
* cache slot. The `AckDrawer` panel guards the `data ?? []` access
* on this and renders the "Link to claim…" dropdown when the list
* is empty.
* - `kind` and `ackId` set: fetches
* `GET /api/acks/{kind}/{ack_id}/claims` via `api.listAckClaims`.
* Cached 30 s — the link rows don't change once created (only
* manual-match / unlink mutate them, and those fire
* `claim_ack_written` / `claim_ack_dropped` events that the
* live-tail subscription delivers via `useTailStream("claim-acks")`).
* - On 404: the hook's retry predicate short-circuits (no retries)
* so the drawer's not-found state appears immediately rather than
* being masked by three back-to-back retry attempts.
*
* The live-tail subscription (`useTailStream("claim-acks")` +
* `useMergedTail("claim-acks", data ?? [], (link) => link.ackId === ackId)`)
* is mounted by the `AckDrawer` itself (per
* `cyclone-frontend-page` convention #3 — subscription on the page,
* not in the hook).
*/
export function useAckClaims(
kind: ClaimAckKind | null,
ackId: number | null,
): {
data: ClaimAck[] | null;
isLoading: boolean;
isError: boolean;
error: Error | null;
refetch: () => void;
} {
const q = useQuery<ClaimAck[]>({
queryKey: ["ack-claims", kind, ackId],
queryFn: () => api.listAckClaims(kind as ClaimAckKind, ackId as number),
enabled: kind !== null && ackId !== null,
staleTime: 30 * 1000,
retry: (failureCount, error) => {
// Don't retry 404 — same posture as the other detail hooks.
if (error instanceof Error && "status" in error) {
const status = (error as { status?: unknown }).status;
if (status === 404) return false;
}
return failureCount < 3;
},
});
if (kind === null || ackId === null) {
return {
data: null,
isLoading: false,
isError: false,
error: null,
refetch: () => {},
};
}
return {
data: q.data ?? null,
isLoading: q.isLoading,
isError: q.isError,
error: q.error,
refetch: () => {
void q.refetch();
},
};
}
+176
View File
@@ -0,0 +1,176 @@
// @vitest-environment happy-dom
// React Query's internal state updates need an `act`-aware environment
// or it logs noisy warnings. Mirror the useClaimDetail /
// useReconciliation / useTailStream test convention.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useClaimAcks } from "./useClaimAcks";
import { api } from "@/lib/api";
// Module-level mock: vitest hoists `vi.mock` above imports. We mock the
// single `api.listClaimAcks` method the hook calls.
vi.mock("@/lib/api", () => ({
api: {
listClaimAcks: vi.fn(),
},
}));
/** Minimal `renderHook` shim — same pattern as `useClaimDetail.test.ts`. */
function renderHook<TResult>(
useCallback: () => TResult
): {
result: { current: TResult | undefined };
unmount: () => void;
} {
const result: { current: TResult | undefined } = { current: undefined };
const container = document.createElement("div");
document.body.appendChild(container);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
function Probe() {
result.current = useCallback();
return null;
}
const root: Root = createRoot(container);
act(() => {
root.render(
React.createElement(QueryClientProvider, { client: qc }, React.createElement(Probe))
);
});
return {
result,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
/** Poll the result until `predicate` is true or we time out. */
async function waitForState(
predicate: () => boolean,
timeoutMs = 1000
): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error(
`waitForState: predicate did not hold within ${timeoutMs}ms`,
);
}
await act(async () => {
await Promise.resolve();
});
}
}
/** Sample fixture matching the camelCase `ClaimAck` shape (the api
* layer's `mapClaimAck` does the snake_case→camelCase conversion, so
* the mock returns the post-conversion shape directly). */
const SAMPLE_ROWS = [
{
id: 1,
claimId: "CLM-1",
batchId: null,
ackId: 99,
ackKind: "999" as const,
ak2Index: 0,
setControlNumber: "991102989",
setAcceptRejectCode: "A",
linkedAt: "2026-07-02T12:00:00Z",
linkedBy: "auto" as const,
claimState: "received",
},
{
id: 2,
claimId: "CLM-1",
batchId: null,
ackId: 99,
ackKind: "999" as const,
ak2Index: 1,
setControlNumber: "991102990",
setAcceptRejectCode: "R",
linkedAt: "2026-07-02T12:00:01Z",
linkedBy: "auto" as const,
claimState: "received",
},
];
describe("useClaimAcks", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("test_returns_idle_state_and_does_not_fetch_when_claimId_is_null", () => {
// Drawer closed → no network call, no loading, no error. The hook
// must short-circuit BEFORE the queryFn is ever invoked so a
// closed drawer is free.
const { result, unmount } = renderHook(() => useClaimAcks(null));
expect(result.current).toBeDefined();
expect(result.current?.data).toBeNull();
expect(result.current?.isLoading).toBe(false);
expect(result.current?.isError).toBe(false);
expect(result.current?.error).toBeNull();
expect(typeof result.current?.refetch).toBe("function");
expect(api.listClaimAcks).not.toHaveBeenCalled();
unmount();
});
it("test_fetches_and_returns_claim_ack_rows_for_a_valid_claim_id", async () => {
(api.listClaimAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
SAMPLE_ROWS,
);
const { result, unmount } = renderHook(() => useClaimAcks("CLM-1"));
await waitForState(
() => result.current?.data !== null && result.current?.data !== undefined,
);
// The hook must have invoked the API exactly once with the claim id.
expect(api.listClaimAcks).toHaveBeenCalledTimes(1);
expect(api.listClaimAcks).toHaveBeenCalledWith("CLM-1");
const data = result.current?.data;
expect(data).not.toBeNull();
expect(data).toHaveLength(2);
// Both rows belong to the same claim (per-AK2 granularity for 999).
expect(data?.[0]?.claimId).toBe("CLM-1");
expect(data?.[0]?.ackKind).toBe("999");
expect(data?.[0]?.setAcceptRejectCode).toBe("A");
expect(data?.[1]?.setAcceptRejectCode).toBe("R");
expect(data?.[1]?.ak2Index).toBe(1);
// isLoading must flip false once the fetch resolves.
expect(result.current?.isLoading).toBe(false);
expect(result.current?.isError).toBe(false);
unmount();
});
it("test_returns_empty_array_when_claim_has_no_acks", async () => {
(api.listClaimAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
const { result, unmount } = renderHook(() => useClaimAcks("CLM-empty"));
await waitForState(
() => result.current?.data !== null && result.current?.data !== undefined,
);
// Empty list must be surfaced as [] (NOT null), so the ClaimDrawer
// panel can render its <EmptyState /> without a null check.
expect(result.current?.data).toEqual([]);
unmount();
});
});
+77
View File
@@ -0,0 +1,77 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { ClaimAck } from "@/types";
/**
* Per-claim acknowledgments query (ClaimDrawer · SP28 §5.1).
*
* Lists every `claim_acks` row for the given claim (per-claim only —
* the backend filters out TA1 batch-level rows with
* `claim_id IS NULL`). Drives the new `<Acknowledgments />` panel
* inside `ClaimDrawer`.
*
* Returns `{ data, isLoading, isError, error, refetch }`:
* - `claimId === null` (drawer closed): the query is disabled and the
* hook short-circuits to `{ data: null, ... }` so a closed drawer
* doesn't burn a network request or a TanStack cache slot. The
* `ClaimDrawer` panel guards the `data ?? []` access on this.
* - `claimId` is set: fetches
* `GET /api/claims/{claim_id}/acks` via `api.listClaimAcks`.
* Cached 30 s — the link rows don't change once created (only
* manual-match / unlink mutate them, and those fire
* `claim_ack_written` / `claim_ack_dropped` events that the
* live-tail subscription delivers via `useTailStream("claim-acks")`).
* - On 404: the hook's retry predicate short-circuits (no retries)
* so the drawer's not-found state appears immediately rather than
* being masked by three back-to-back retry attempts.
*
* The live-tail subscription (`useTailStream("claim-acks")` +
* `useMergedTail("claim-acks", data ?? [], (link) => link.claimId === claimId)`)
* is mounted by the `ClaimDrawer` itself (per
* `cyclone-frontend-page` convention #3 — subscription on the page,
* not in the hook).
*/
export function useClaimAcks(claimId: string | null): {
data: ClaimAck[] | null;
isLoading: boolean;
isError: boolean;
error: Error | null;
refetch: () => void;
} {
const q = useQuery<ClaimAck[]>({
queryKey: ["claim-acks", claimId],
queryFn: () => api.listClaimAcks(claimId as string),
enabled: claimId !== null,
staleTime: 30 * 1000,
retry: (failureCount, error) => {
// Don't retry 404 — same posture as the other detail hooks. The
// drawer treats 404 as a distinct "claim doesn't exist" state
// and doesn't want retries masking it.
if (error instanceof Error && "status" in error) {
const status = (error as { status?: unknown }).status;
if (status === 404) return false;
}
return failureCount < 3;
},
});
if (claimId === null) {
return {
data: null,
isLoading: false,
isError: false,
error: null,
refetch: () => {},
};
}
return {
data: q.data ?? null,
isLoading: q.isLoading,
isError: q.isError,
error: q.error,
refetch: () => {
void q.refetch();
},
};
}
+177
View File
@@ -25,6 +25,8 @@
import type { import type {
Ack, Ack,
BatchDiff, BatchDiff,
ClaimAck,
ClaimAckKind,
ClaimDetail, ClaimDetail,
ClaimOutput, ClaimOutput,
ClaimPayment, ClaimPayment,
@@ -855,6 +857,11 @@ 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;
/**
* SP28: distinct claim ids this 999 ack is linked to. Empty
* array when the auto-linker couldn't resolve (orphan).
*/
linked_claim_ids?: string[];
} }
function mapAck(row: RawAckRow): Ack { function mapAck(row: RawAckRow): Ack {
@@ -866,6 +873,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,
linkedClaimIds: row.linked_claim_ids ?? [],
}; };
} }
@@ -930,6 +938,11 @@ interface RawTa1Row {
receiver_id: string | null; receiver_id: string | null;
source_batch_id: string; source_batch_id: string;
parsed_at: string | null; parsed_at: string | null;
/**
* SP28: originating `Batch` ids this TA1 envelope is linked to.
* Empty array when the auto-linker couldn't resolve (orphan).
*/
linked_claim_ids?: string[];
} }
function mapTa1Ack(row: RawTa1Row): Ta1Ack { function mapTa1Ack(row: RawTa1Row): Ta1Ack {
@@ -944,6 +957,7 @@ function mapTa1Ack(row: RawTa1Row): Ta1Ack {
receiverId: row.receiver_id, receiverId: row.receiver_id,
sourceBatchId: row.source_batch_id, sourceBatchId: row.source_batch_id,
parsedAt: row.parsed_at, parsedAt: row.parsed_at,
linkedClaimIds: row.linked_claim_ids ?? [],
}; };
} }
@@ -969,6 +983,164 @@ async function listTa1Acks(
}; };
} }
// ---------------------------------------------------------------------------
// SP28: claim↔ack link rows.
// The `claim_acks` table is the durable record of which 999 / 277CA / TA1
// acknowledged which claim (or, for TA1, which originating 837 `Batch`).
// Three read endpoints (`listClaimAcks`, `listAckClaims`, `listAckOrphans`)
// plus the manual-match + unmatch write endpoints. The wire shape mirrors
// `to_ui_claim_ack` (backend/src/cyclone/store/ui.py) — camelCase keys,
// ISO Z timestamps, numeric ids from the database row.
// ---------------------------------------------------------------------------
interface RawClaimAckRow {
id: number;
claim_id: string | null;
batch_id: string | null;
ack_id: number;
ack_kind: ClaimAckKind;
ak2_index: number | null;
set_control_number: string | null;
set_accept_reject_code: string | null;
linked_at: string;
linked_by: "auto" | "manual";
claim_state?: string;
}
function mapClaimAck(row: RawClaimAckRow): ClaimAck {
return {
id: row.id,
claimId: row.claim_id,
batchId: row.batch_id,
ackId: row.ack_id,
ackKind: row.ack_kind,
ak2Index: row.ak2_index,
setControlNumber: row.set_control_number,
setAcceptRejectCode: row.set_accept_reject_code,
linkedAt: row.linked_at,
linkedBy: row.linked_by,
claimState: row.claim_state,
};
}
/**
* `GET /api/claims/{claim_id}/acks` — list every `claim_acks` row
* for the given claim (per-claim only; TA1 batch-level rows with
* `claim_id IS NULL` are filtered out by the backend).
*
* Drives `ClaimDrawer`'s new `<Acknowledgments />` panel. Returns
* an array even on the empty case — the panel renders
* `<EmptyState />` when the list is empty (no live-tail data).
*/
async function listClaimAcks(claimId: string): Promise<ClaimAck[]> {
if (!isConfigured) throw notConfiguredError();
const body = await authedFetch<{ items: RawClaimAckRow[] }>(
`/api/claims/${encodeURIComponent(claimId)}/acks`,
);
return body.items.map(mapClaimAck);
}
/**
* `GET /api/acks/{kind}/{ack_id}/claims` — list every `claim_acks`
* row for the given ack. For 999/277CA, one entry per AK2 /
* ClaimStatus (D1 per-AK2 granularity). For TA1, one entry per
* envelope linked to the originating `Batch`.
*
* Drives `AckDrawer`'s new `<MatchedClaim />` panel. Returns an
* array; the panel renders the "Link to claim…" dropdown when the
* list is empty (no auto-link resolved).
*/
async function listAckClaims(
kind: ClaimAckKind,
ackId: number,
): Promise<ClaimAck[]> {
if (!isConfigured) throw notConfiguredError();
const body = await authedFetch<{ items: RawClaimAckRow[] }>(
`/api/acks/${encodeURIComponent(kind)}/${encodeURIComponent(String(ackId))}/claims`,
);
return body.items.map(mapClaimAck);
}
/**
* `POST /api/acks/{kind}/{ack_id}/match-claim` — manual-match fallback
* (D5). Any logged-in user can run this (D9 differs explicitly from
* the admin-only remit-orphans posture — the metadata-only nature of
* an ack link doesn't warrant admin gating).
*
* Idempotent on the server: re-running with the same `claim_id`
* returns the existing row with HTTP 200. Returns 409 if the target
* claim is in a terminal state (REVERSED).
*/
async function matchAckToClaim(
kind: ClaimAckKind,
ackId: number,
claimId: string,
): Promise<ClaimAck> {
if (!isConfigured) throw notConfiguredError();
const body = await authedFetch<RawClaimAckRow>(
`/api/acks/${encodeURIComponent(kind)}/${encodeURIComponent(String(ackId))}/match-claim`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ claim_id: claimId }),
},
);
return mapClaimAck(body);
}
/**
* `DELETE /api/acks/{kind}/{ack_id}/match-claim/{claim_id}` — unlink.
* Removes the `claim_acks` row and publishes a `claim_ack_dropped`
* event; does NOT revert any `Claim.state` mutation the ack may
* have triggered (e.g. an `apply_999_rejections` flip to REJECTED
* stays — unlinking is metadata-only, per spec §D5/§D6).
*/
async function unmatchAck(
kind: ClaimAckKind,
ackId: number,
claimId: string,
): Promise<void> {
if (!isConfigured) throw notConfiguredError();
await authedFetch<void>(
`/api/acks/${encodeURIComponent(kind)}/${encodeURIComponent(String(ackId))}/match-claim/${encodeURIComponent(claimId)}`,
{ method: "DELETE" },
);
}
/**
* One row in the Inbox "Ack orphans" lane (D7). Mirrors the wire
* shape of `GET /api/inbox/ack-orphans?kind=…`. ``candidates`` is a
* top-3 list of relaxed PCN matches so the operator can decide
* whether to dismiss or manually link.
*/
export interface AckOrphanRow {
id: number;
kind: ClaimAckKind;
pcn: string;
sourceBatchId: string;
parsedAt: string;
candidates: Array<{
claimId: string;
score: number;
tier: "strong" | "weak" | "hidden";
}>;
}
/**
* `GET /api/inbox/ack-orphans?kind=…` — list acks that the
* auto-linker couldn't resolve to a claim. Powers the Inbox
* "Ack orphans" lane (mirrors the existing "Payer-rejected" lane
* shape; D7).
*/
async function listAckOrphans(kind: ClaimAckKind): Promise<AckOrphanRow[]> {
if (!isConfigured) throw notConfiguredError();
const body = await authedFetch<{
items: AckOrphanRow[];
total: number;
}>(`/api/inbox/ack-orphans${qs({ kind })}`);
return body.items;
}
/** /**
* Download a ZIP of regenerated X12 837 files for a parsed batch. * Download a ZIP of regenerated X12 837 files for a parsed batch.
* *
@@ -1064,5 +1236,10 @@ export const api = {
listAcks, listAcks,
getAck, getAck,
listTa1Acks, listTa1Acks,
listClaimAcks,
listAckClaims,
matchAckToClaim,
unmatchAck,
listAckOrphans,
getDashboardKpis, getDashboardKpis,
}; };
+72
View File
@@ -537,6 +537,13 @@ export interface Ack {
* round-trip to the detail endpoint. * round-trip to the detail endpoint.
*/ */
patientControlNumber?: string | null; patientControlNumber?: string | null;
/**
* SP28: distinct claim ids this 999 ack is linked to. Populated by
* the backend list endpoint so the Acks page can show a "Claims"
* badge column without a per-row detail round-trip. Empty array
* means no auto-link resolved (orphan).
*/
linkedClaimIds?: string[];
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -569,6 +576,62 @@ export interface Ta1Ack {
receiverId: string | null; receiverId: string | null;
sourceBatchId: string; sourceBatchId: string;
parsedAt: string | null; parsedAt: string | null;
/**
* SP28: originating `Batch` ids this TA1 envelope is linked to
* (per D4, TA1 is envelope-level so it links to `Batch` rather
* than `Claim`). Empty array means no auto-link resolved.
*/
linkedClaimIds?: string[];
}
// ---------------------------------------------------------------------------
// SP28: claim↔ack link rows.
// Mirrors the `claim_acks` table (cyclone.db.ClaimAck) and the wire shape
// of `GET /api/claims/{id}/acks` + `GET /api/acks/{kind}/{id}/claims`.
// Per-AK2 granularity (D1): a single 999 with two AK2 set-responses
// produces two ClaimAck rows. TA1 batch-level rows have `claimId === null`
// — the `ClaimDrawer` panel filters those out; the `AckDrawer` panel
// renders them with the originating Batch instead of a claim.
// ---------------------------------------------------------------------------
/**
* Discriminator for the ack flavor that produced a `ClaimAck` row.
*
* - `"999"` — per-AK2 set-response (one row per AK2 in the inbound 999).
* - `"277ca"` — per-`ClaimStatus` (one row per `STC` in the inbound 277CA).
* - `"ta1"` — envelope-level (one row per TA1, no per-claim granularity).
*/
export type ClaimAckKind = "999" | "277ca" | "ta1";
/**
* One persisted claim↔ack link row, camelCased for the UI. The
* `claimId` is `null` for TA1 batch-level rows (D4) — the
* `ClaimDrawer` panel filters those out so the operator only sees
* per-claim acks; the `AckDrawer` panel renders them with the
* originating Batch instead.
*
* `claimState` is a derived join (the 999/277CA row carries the
* current `Claim.state` so the drawer can render the
* `ClaimStateBadge` inline without a second round-trip). For
* TA1 batch-level rows with `claimId === null`, this is `"n/a"`.
*
* `ak2Index` is populated only for `"999"` rows — 277CA and TA1
* don't have AK2 segments. `setControlNumber` is populated for
* 999/277CA rows (the value the upstream ack actually carried,
* regardless of which join path resolved the link — spec D10).
*/
export interface ClaimAck {
id: number;
claimId: string | null;
batchId: string | null;
ackId: number;
ackKind: ClaimAckKind;
ak2Index: number | null;
setControlNumber: string | null;
setAcceptRejectCode: string | null;
linkedAt: string;
linkedBy: "auto" | "manual";
claimState?: string;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -710,6 +773,15 @@ export interface ClaimDetail {
* composite. * composite.
*/ */
lineReconciliation?: ClaimDetailLineReconciliation[]; lineReconciliation?: ClaimDetailLineReconciliation[];
/**
* SP28: compact form of the claim's `claim_acks` rows. Each entry
* has `ack_id`, `ack_kind`, `set_accept_reject_code`, `parsed_at`
* (no `claim_state` join — the drawer can use this for the
* initial panel render and rely on the live-tail for the full
* per-row payload via `useClaimAcks`). Empty array when the claim
* has no acks.
*/
ackLinks?: ClaimAck[];
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------