feat(sp28): useClaimAcks + useAckClaims hooks with api types
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -25,6 +25,8 @@
|
||||
import type {
|
||||
Ack,
|
||||
BatchDiff,
|
||||
ClaimAck,
|
||||
ClaimAckKind,
|
||||
ClaimDetail,
|
||||
ClaimOutput,
|
||||
ClaimPayment,
|
||||
@@ -855,6 +857,11 @@ interface RawAckRow {
|
||||
received_count: number;
|
||||
ack_code: "A" | "E" | "R" | "P";
|
||||
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 {
|
||||
@@ -866,6 +873,7 @@ function mapAck(row: RawAckRow): Ack {
|
||||
receivedCount: row.received_count,
|
||||
ackCode: row.ack_code,
|
||||
parsedAt: row.parsed_at,
|
||||
linkedClaimIds: row.linked_claim_ids ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -930,6 +938,11 @@ interface RawTa1Row {
|
||||
receiver_id: string | null;
|
||||
source_batch_id: string;
|
||||
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 {
|
||||
@@ -944,6 +957,7 @@ function mapTa1Ack(row: RawTa1Row): Ta1Ack {
|
||||
receiverId: row.receiver_id,
|
||||
sourceBatchId: row.source_batch_id,
|
||||
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.
|
||||
*
|
||||
@@ -1064,5 +1236,10 @@ export const api = {
|
||||
listAcks,
|
||||
getAck,
|
||||
listTa1Acks,
|
||||
listClaimAcks,
|
||||
listAckClaims,
|
||||
matchAckToClaim,
|
||||
unmatchAck,
|
||||
listAckOrphans,
|
||||
getDashboardKpis,
|
||||
};
|
||||
|
||||
@@ -537,6 +537,13 @@ export interface Ack {
|
||||
* round-trip to the detail endpoint.
|
||||
*/
|
||||
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;
|
||||
sourceBatchId: string;
|
||||
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.
|
||||
*/
|
||||
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[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user