fix(collection-ai): page handler tests, full document lines, snapshot dialog defaults, tick aria detail

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-17 09:45:12 +02:00
parent c1656f5ccd
commit 137b458a06
11 changed files with 210 additions and 26 deletions

View file

@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Page from "./page";
import { makeAiRow } from "@/components/collection-confirm/ai-fixtures";
import type { ConfirmRow } from "@/lib/server/collection-confirm-service";
vi.mock("@/lib/pocketbase", () => ({
getPocketBase: () => ({ authStore: { token: "tok-123" } }),
}));
function jsonResponse(body: unknown, init: Partial<Response> = {}): Response {
return { ok: true, status: 200, json: async () => body, ...init } as Response;
}
function fetchUrl(input: RequestInfo | URL): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.toString();
return input.url;
}
function aiRow(id: string, over: Partial<ConfirmRow> = {}): ConfirmRow {
return makeAiRow({
id,
sku: `SKU-${id}`,
item_status: "collected",
source: "customer",
expand: {
purchase_order: {
id: "po-1",
po_number: "PO-1",
expand: { supplier: { id: "sup-1", suppliername: "Alpha" } },
},
},
...over,
} as Partial<ConfirmRow>);
}
const rows: ConfirmRow[] = [
aiRow("row-a", { in_pos: false }),
aiRow("row-b", { in_pos: true, pos_stock_item_id: "stock-1" }),
aiRow("row-c", { in_pos: null }),
];
function setupFetch(opts: { confirmOk: boolean }) {
return vi.spyOn(window, "fetch").mockImplementation(async (input) => {
const url = fetchUrl(input);
if (url === "/api/v1/collection-confirm") return jsonResponse({ items: rows });
if (url === "/api/v1/order-items/confirm") {
return opts.confirmOk
? jsonResponse({ updatedIds: [] })
: jsonResponse({ error: "boom" }, { ok: false, status: 500 });
}
if (url === "/api/v1/collection-confirm/add-to-pos") {
return jsonResponse({ results: [{ row_id: "row-a", status: "created", stock_item_id: "s" }] });
}
if (url === "/api/v1/collection-confirm/ai/check") {
return jsonResponse({ files_read: 1, lines_checked: 3, skus_filled: 0, error: null });
}
return jsonResponse({});
});
}
async function openConfirmAndAdd() {
const user = userEvent.setup();
render(<Page />);
await waitFor(() => expect(screen.getByTestId("po-card-PO-1")).toBeInTheDocument());
await user.click(screen.getByTestId("po-card-toggle-PO-1"));
await user.click(screen.getByTestId("po-card-confirm-add-pos-PO-1"));
const dialog = await screen.findByRole("dialog");
// Pre-filled from ai_check.
expect(within(dialog).getByLabelText(/Supplier invoice number/i)).toHaveValue("5028256");
await user.click(within(dialog).getByRole("button", { name: /^Confirm 3 items$/i }));
}
const addCalls = (spy: ReturnType<typeof setupFetch>) =>
spy.mock.calls.filter(([arg]) => fetchUrl(arg) === "/api/v1/collection-confirm/add-to-pos");
describe("CollectionConfirmPage AI handlers", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("does not add to the POS when the confirm fails", async () => {
const spy = setupFetch({ confirmOk: false });
await openConfirmAndAdd();
await waitFor(() =>
expect(spy.mock.calls.some(([a]) => fetchUrl(a) === "/api/v1/order-items/confirm")).toBe(true),
);
await new Promise((r) => setTimeout(r, 50));
expect(addCalls(spy)).toHaveLength(0);
});
it("adds only the not-in-POS customer rows, once, after the confirm succeeds", async () => {
const spy = setupFetch({ confirmOk: true });
await openConfirmAndAdd();
await waitFor(() => expect(addCalls(spy)).toHaveLength(1));
const [, init] = addCalls(spy)[0];
expect(JSON.parse(String((init as RequestInit).body))).toEqual({ row_ids: ["row-a", "row-c"] });
expect((init as RequestInit).headers).toMatchObject({ Authorization: "Bearer tok-123" });
const order = spy.mock.calls.map(([a]) => fetchUrl(a));
expect(order.indexOf("/api/v1/order-items/confirm")).toBeLessThan(
order.indexOf("/api/v1/collection-confirm/add-to-pos"),
);
});
it("Check with AI posts { po_number } with the Bearer header", async () => {
const spy = setupFetch({ confirmOk: true });
const user = userEvent.setup();
render(<Page />);
await waitFor(() => expect(screen.getByTestId("po-card-PO-1")).toBeInTheDocument());
await user.click(screen.getByTestId("po-card-toggle-PO-1"));
await user.click(screen.getByRole("button", { name: /check with ai/i }));
await waitFor(() =>
expect(spy.mock.calls.some(([a]) => fetchUrl(a) === "/api/v1/collection-confirm/ai/check")).toBe(true),
);
const call = spy.mock.calls.find(([a]) => fetchUrl(a) === "/api/v1/collection-confirm/ai/check")!;
const init = call[1] as RequestInit;
expect(init.method).toBe("POST");
expect(JSON.parse(String(init.body))).toEqual({ po_number: "PO-1" });
expect(init.headers).toMatchObject({ Authorization: "Bearer tok-123" });
});
});

View file

@ -74,6 +74,8 @@ export default function CollectionConfirmPage() {
rowIds: string[];
/** Set by "Confirm and add to POS": customer rows to add once the confirm succeeds. */
addToPosRowIds?: string[];
/** Snapshotted when the dialog opens so a reload can't remount it and wipe edits. */
defaultValues?: { number: string; date?: string };
} | null>(null);
// Stock-side confirm state. Mirrors `confirmState` but for the
// warehouse "Confirm collection" flow — opens the
@ -975,12 +977,14 @@ export default function CollectionConfirmPage() {
addToPosRowIds: group.rows
.filter((r) => r.source !== "stock" && !r.in_pos)
.map((r) => r.id),
defaultValues: aiInvoiceDefaults(group.rows),
})
}
onConfirm={() =>
setConfirmState({
poNumber: group.poNumber,
rowIds: group.rows.map((r) => r.id),
defaultValues: aiInvoiceDefaults(group.rows),
})
}
onConfirmStock={
@ -1019,13 +1023,7 @@ export default function CollectionConfirmPage() {
open={!!confirmState}
eyebrow={confirmState?.poNumber ?? ""}
rowCount={confirmState?.rowIds.length ?? 0}
defaultValues={
confirmState
? aiInvoiceDefaults(
(rows ?? []).filter((r) => confirmState.rowIds.includes(r.id)),
)
: undefined
}
defaultValues={confirmState?.defaultValues}
costOverrides={scopedCostOverrides}
busy={busy}
onOpenChange={(open) => {

View file

@ -31,9 +31,12 @@ export function AiFindings({ findings }: { findings: string[] }) {
export function DocumentLinesPanel({
poNumber,
lines,
matchedLineNos,
}: {
poNumber: string;
lines: CollectionDocLine[];
/** Document line numbers paired with a row on this card. */
matchedLineNos?: ReadonlySet<number>;
}) {
return (
<div
@ -53,7 +56,8 @@ export function DocumentLinesPanel({
<th className="pr-2 font-medium">Description</th>
<th className="pr-2 text-right font-medium">Qty</th>
<th className="pr-2 text-right font-medium">Per unit</th>
<th className="font-medium">Note</th>
<th className="pr-2 font-medium">Note</th>
<th className="font-medium">Match</th>
</tr>
</thead>
<tbody>
@ -65,7 +69,12 @@ export function DocumentLinesPanel({
<td className="pr-2 text-right tabular-nums">
{line.unit_price == null ? "—" : zar.format(line.unit_price)}
</td>
<td className="text-muted-foreground">{line.price_note}</td>
<td className="pr-2 text-muted-foreground">{line.price_note}</td>
<td>
{matchedLineNos?.has(line.line_no) ? (
<span className="font-medium text-success">matched</span>
) : null}
</td>
</tr>
))}
</tbody>

View file

@ -45,6 +45,7 @@ export function makeAiRow(over: Partial<ConfirmRow> = {}): ConfirmRow {
doc_date: "2026-10-16",
supplier_name: "Acme",
findings: [],
lines: [],
},
in_pos: false,
pos_stock_item_id: null,

View file

@ -55,15 +55,16 @@ describe("ItemsTable AI layer", () => {
}),
});
render(<ItemsTable rows={[row]} onCostChange={noop} onSkuChange={noop} />);
expect(screen.getByLabelText("SKU: matches")).toBeInTheDocument();
expect(screen.getByLabelText("Qty: doesn't match")).toBeInTheDocument();
expect(screen.getByLabelText("Desc: doesn't match")).toBeInTheDocument();
expect(screen.getByLabelText("Price: can't check")).toBeInTheDocument();
expect(screen.getByLabelText("Qty: doesn't match")).toHaveAttribute(
expect(screen.getByLabelText(/^SKU: matches/)).toBeInTheDocument();
expect(screen.getByLabelText(/^Qty: doesn't match/)).toBeInTheDocument();
expect(screen.getByLabelText(/^Desc: doesn't match/)).toBeInTheDocument();
expect(screen.getByLabelText(/^Price: can't check/)).toBeInTheDocument();
expect(screen.getByLabelText(/^Qty: doesn't match/)).toHaveAttribute(
"title",
expect.stringContaining("9"),
);
const desc = screen.getByLabelText("Desc: doesn't match").getAttribute("title") ?? "";
expect(screen.getByLabelText(/^Price: can't check — Ours: R45.00/)).toBeInTheDocument();
const desc = screen.getByLabelText(/^Desc: doesn't match/).getAttribute("title") ?? "";
expect(desc).toContain("Item 1");
expect(desc).toContain("Totally different thing");
});
@ -71,7 +72,7 @@ describe("ItemsTable AI layer", () => {
it("shows the amber label", () => {
const row = makeAiRow({ ai_check: makeCheck({ same_item: "likely" }) });
render(<ItemsTable rows={[row]} onCostChange={noop} onSkuChange={noop} />);
expect(screen.getByLabelText("Desc: have a look")).toBeInTheDocument();
expect(screen.getByLabelText(/^Desc: have a look/)).toBeInTheDocument();
});
it("shows the robot next to the SKU only while the SKU equals sku_filled_value", () => {

View file

@ -67,7 +67,7 @@ function Tick({ name, state, detail }: { name: string; state: TickState; detail:
return (
<span
role="img"
aria-label={`${name}: ${TICK_LABEL[state]}`}
aria-label={`${name}: ${TICK_LABEL[state]} — ${detail}`}
title={detail}
data-tick={name.toLowerCase()}
data-state={state}

View file

@ -44,6 +44,30 @@ describe("PoCard AI layer", () => {
expect(panel).toHaveTextContent("Item one from supplier");
});
it("lists every document line in the panel and marks paired ones matched", async () => {
const extra = {
line_no: 2,
code: "M8X25",
description: "Bolt M8x25",
quantity: 100,
unit: "ea",
unit_price: 0.33,
line_total: 33,
to_follow: null,
price_note: "per 100",
};
const base = makeAiRow();
const row = makeAiRow({
ai_doc: { ...base.ai_doc!, lines: [base.ai_check!.doc_line!, extra] },
});
render(<PoCard rows={[row]} {...props} />);
await userEvent.click(screen.getByTestId("ai-doc-chip-PO-1"));
const panel = screen.getByTestId("ai-doc-lines-PO-1");
expect(panel).toHaveTextContent("M8X25");
expect(panel).toHaveTextContent("Bolt M8x25");
expect(panel.textContent?.match(/matched/g)).toHaveLength(1);
});
it("labels a delivery note", () => {
const row = makeAiRow({
ai_doc: {
@ -53,6 +77,7 @@ describe("PoCard AI layer", () => {
doc_date: "2026-10-16",
supplier_name: "S",
findings: [],
lines: [],
},
});
render(<PoCard rows={[row]} {...props} />);
@ -68,6 +93,7 @@ describe("PoCard AI layer", () => {
doc_date: "2026-10-16",
supplier_name: "S",
findings: ["Freight not on PO"],
lines: [],
},
});
render(<PoCard rows={[row]} {...props} />);

View file

@ -203,20 +203,25 @@ export function PoCard({
const [docPanelOpen, setDocPanelOpen] = useState(false);
const aiDoc = useMemo(() => cardDocument(rows), [rows]);
const aiFindings = useMemo(() => cardFindings(rows), [rows]);
// Lines the AI extracted from the chip's document (those paired with a row), by line number.
const aiDocLines = useMemo(() => {
const byNo = new Map<number, CollectionDocLine>();
// Every line the AI read from the chip's document; lines paired with a row
// on this card are marked "matched". Falls back to the paired lines alone
// when the document's full line list is unavailable.
const { aiDocLines, matchedLineNos } = useMemo(() => {
const paired = new Map<number, CollectionDocLine>();
for (const row of rows) {
const check = row.ai_check;
if (!aiDoc || !check?.doc_line || check.read_id !== aiDoc.read_id) continue;
if (!byNo.has(check.doc_line.line_no)) byNo.set(check.doc_line.line_no, check.doc_line);
if (!paired.has(check.doc_line.line_no)) paired.set(check.doc_line.line_no, check.doc_line);
}
return [...byNo.values()].sort((a, b) => a.line_no - b.line_no);
const all = aiDoc?.lines?.length
? aiDoc.lines
: [...paired.values()].sort((a, b) => a.line_no - b.line_no);
return { aiDocLines: all, matchedLineNos: new Set(paired.keys()) };
}, [rows, aiDoc]);
const hasUnpostedCustomerRows = rows.some((r) => r.source !== "stock" && !r.in_pos);
const aiDocPanel =
docPanelOpen && aiDoc ? (
<DocumentLinesPanel poNumber={poNumber} lines={aiDocLines} />
<DocumentLinesPanel poNumber={poNumber} lines={aiDocLines} matchedLineNos={matchedLineNos} />
) : null;
const uploadInputRef = useRef<HTMLInputElement>(null);
// Ref to the split-view body container; the Splitter uses it to

View file

@ -10,6 +10,7 @@ const doc = (read_id: string, findings: string[], doc_date = "2026-10-16") => ({
doc_date,
supplier_name: "S",
findings,
lines: [],
});
describe("collection-ai-card", () => {

View file

@ -1264,7 +1264,19 @@ describe("listCollectedItems — AI check passthrough", () => {
return { admin, docReadsGetFullList, posGetFullList };
};
it("attaches ai_doc (with findings) from the read named by ai_check.read_id, loading reads in one query", async () => {
const DOC_LINE = {
line_no: 3,
code: "M8X25",
description: "Bolt M8x25",
quantity: 100,
unit: "ea",
unit_price: 0.33,
line_total: 33,
to_follow: null,
price_note: "",
};
it("attaches ai_doc (with findings and all document lines) from the read named by ai_check.read_id, loading reads in one query", async () => {
const { admin, docReadsGetFullList } = makeAiAdmin({
orderItems: [
{ id: "oi-1", item_status: "collected", sku: "", ai_check: check("r1") },
@ -1279,6 +1291,7 @@ describe("listCollectedItems — AI check passthrough", () => {
doc_date: "2026-10-16",
supplier_name: "NSS Fasteners",
findings: ["Document 5028256 covers 2 POs: A, B."],
lines: [DOC_LINE],
},
],
});
@ -1297,6 +1310,7 @@ describe("listCollectedItems — AI check passthrough", () => {
doc_date: "2026-10-16",
supplier_name: "NSS Fasteners",
findings: ["Document 5028256 covers 2 POs: A, B."],
lines: [DOC_LINE],
});
expect(byId.get("oi-2")?.ai_doc?.read_id).toBe("r1");
expect(byId.get("oi-3")?.ai_doc).toBeNull();

View file

@ -1,7 +1,7 @@
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { getOrderItemsCollectionName } from "@/lib/server/orders-service";
import { compressFile } from "@/lib/server/attachment-compressor";
import type { CollectionAiCheck, CollectionDocType } from "@/lib/types";
import type { CollectionAiCheck, CollectionDocLine, CollectionDocType } from "@/lib/types";
import { normaliseCode } from "@/lib/collection-ai-ticks";
import {
@ -121,6 +121,8 @@ export type ConfirmRow = {
doc_date: string;
supplier_name: string;
findings: string[];
/** Every line the AI read from the document, paired or not. */
lines: CollectionDocLine[];
} | null;
/** True when the line's supplier code matches a stock item (POS). */
in_pos?: boolean | null;
@ -538,9 +540,10 @@ async function attachAiFields(pb: AdminPb, rows: ConfirmRow[]): Promise<void> {
doc_date?: string;
supplier_name?: string;
findings?: unknown;
lines?: unknown;
}>({
filter: ids.map((id) => `id = "${pbQuote(id)}"`).join(" || "),
fields: "id,doc_type,document_no,doc_date,supplier_name,findings",
fields: "id,doc_type,document_no,doc_date,supplier_name,findings,lines",
});
for (const r of found) {
readsById.set(r.id, {
@ -550,6 +553,7 @@ async function attachAiFields(pb: AdminPb, rows: ConfirmRow[]): Promise<void> {
doc_date: r.doc_date ?? "",
supplier_name: r.supplier_name ?? "",
findings: Array.isArray(r.findings) ? (r.findings as string[]) : [],
lines: Array.isArray(r.lines) ? (r.lines as CollectionDocLine[]) : [],
});
}
}
@ -567,6 +571,7 @@ async function attachAiFields(pb: AdminPb, rows: ConfirmRow[]): Promise<void> {
doc_date: check.doc_date,
supplier_name: "",
findings: [],
lines: [],
};
}