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:
parent
c1656f5ccd
commit
137b458a06
11 changed files with 210 additions and 26 deletions
124
src/app/(app)/collection-confirm/page-ai.test.tsx
Normal file
124
src/app/(app)/collection-confirm/page-ai.test.tsx
Normal 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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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} />);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue