diff --git a/src/app/(app)/collection-confirm/page-ai.test.tsx b/src/app/(app)/collection-confirm/page-ai.test.tsx new file mode 100644 index 0000000..d478133 --- /dev/null +++ b/src/app/(app)/collection-confirm/page-ai.test.tsx @@ -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 { + 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 { + 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); +} + +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(); + 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) => + 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(); + 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" }); + }); +}); diff --git a/src/app/(app)/collection-confirm/page.tsx b/src/app/(app)/collection-confirm/page.tsx index d409c8f..b027cd3 100644 --- a/src/app/(app)/collection-confirm/page.tsx +++ b/src/app/(app)/collection-confirm/page.tsx @@ -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) => { diff --git a/src/components/collection-confirm/ai-findings.tsx b/src/components/collection-confirm/ai-findings.tsx index 039f5f3..aed0112 100644 --- a/src/components/collection-confirm/ai-findings.tsx +++ b/src/components/collection-confirm/ai-findings.tsx @@ -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; }) { return (
Description Qty Per unit - Note + Note + Match @@ -65,7 +69,12 @@ export function DocumentLinesPanel({ {line.unit_price == null ? "—" : zar.format(line.unit_price)} - {line.price_note} + {line.price_note} + + {matchedLineNos?.has(line.line_no) ? ( + matched + ) : null} + ))} diff --git a/src/components/collection-confirm/ai-fixtures.ts b/src/components/collection-confirm/ai-fixtures.ts index 4745a00..0bbe7f8 100644 --- a/src/components/collection-confirm/ai-fixtures.ts +++ b/src/components/collection-confirm/ai-fixtures.ts @@ -45,6 +45,7 @@ export function makeAiRow(over: Partial = {}): ConfirmRow { doc_date: "2026-10-16", supplier_name: "Acme", findings: [], + lines: [], }, in_pos: false, pos_stock_item_id: null, diff --git a/src/components/collection-confirm/items-table-ai.test.tsx b/src/components/collection-confirm/items-table-ai.test.tsx index 8d0e383..dd758a7 100644 --- a/src/components/collection-confirm/items-table-ai.test.tsx +++ b/src/components/collection-confirm/items-table-ai.test.tsx @@ -55,15 +55,16 @@ describe("ItemsTable AI layer", () => { }), }); render(); - 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(); - 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", () => { diff --git a/src/components/collection-confirm/items-table.tsx b/src/components/collection-confirm/items-table.tsx index f9281c2..61556c8 100644 --- a/src/components/collection-confirm/items-table.tsx +++ b/src/components/collection-confirm/items-table.tsx @@ -67,7 +67,7 @@ function Tick({ name, state, detail }: { name: string; state: TickState; detail: return ( { 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(); + 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(); @@ -68,6 +93,7 @@ describe("PoCard AI layer", () => { doc_date: "2026-10-16", supplier_name: "S", findings: ["Freight not on PO"], + lines: [], }, }); render(); diff --git a/src/components/collection-confirm/po-card.tsx b/src/components/collection-confirm/po-card.tsx index 88ab4cb..2b4062a 100644 --- a/src/components/collection-confirm/po-card.tsx +++ b/src/components/collection-confirm/po-card.tsx @@ -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(); + // 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(); 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 ? ( - + ) : null; const uploadInputRef = useRef(null); // Ref to the split-view body container; the Splitter uses it to diff --git a/src/lib/collection-ai-card.test.ts b/src/lib/collection-ai-card.test.ts index 7a0c500..87ab586 100644 --- a/src/lib/collection-ai-card.test.ts +++ b/src/lib/collection-ai-card.test.ts @@ -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", () => { diff --git a/src/lib/server/collection-confirm-service.test.ts b/src/lib/server/collection-confirm-service.test.ts index a5d8bc9..7b117a7 100644 --- a/src/lib/server/collection-confirm-service.test.ts +++ b/src/lib/server/collection-confirm-service.test.ts @@ -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(); diff --git a/src/lib/server/collection-confirm-service.ts b/src/lib/server/collection-confirm-service.ts index df7dfd6..7ab761d 100644 --- a/src/lib/server/collection-confirm-service.ts +++ b/src/lib/server/collection-confirm-service.ts @@ -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 { 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 { 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 { doc_date: check.doc_date, supplier_name: "", findings: [], + lines: [], }; }