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
+177
View File
@@ -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,
};