From 16cf5e274640cccc9f0b913ae1a529402f54a7d7 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 17 Oct 2026 02:14:53 +0200 Subject: [PATCH] feat(collection-ai): card check, tick and list passthrough Co-Authored-By: Claude Opus 5.5 --- .../2026-10-17-add-collection-ai.ts | 4 +- src/lib/server/collection-ai-service.test.ts | 937 ++++++++++++++++++ src/lib/server/collection-ai-service.ts | 871 ++++++++++++++++ .../server/collection-confirm-service.test.ts | 187 ++++ src/lib/server/collection-confirm-service.ts | 111 +++ 5 files changed, 2109 insertions(+), 1 deletion(-) create mode 100644 src/lib/server/collection-ai-service.test.ts create mode 100644 src/lib/server/collection-ai-service.ts diff --git a/scripts/migrations/2026-10-17-add-collection-ai.ts b/scripts/migrations/2026-10-17-add-collection-ai.ts index 14a065c..efc8717 100644 --- a/scripts/migrations/2026-10-17-add-collection-ai.ts +++ b/scripts/migrations/2026-10-17-add-collection-ai.ts @@ -124,7 +124,9 @@ async function main() { { name: "updated", type: "autodate", onCreate: true, onUpdate: true }, ], indexes: [ - "CREATE UNIQUE INDEX idx_collection_doc_reads_hash ON collection_doc_reads (content_hash)", + // Not unique: identical uploads get one row each (per source + + // filename) sharing the hash, and skipped PDFs carry an empty hash. + "CREATE INDEX idx_collection_doc_reads_hash ON collection_doc_reads (content_hash)", "CREATE INDEX idx_collection_doc_reads_source ON collection_doc_reads (source_collection, source_id, filename)", ], }); diff --git a/src/lib/server/collection-ai-service.test.ts b/src/lib/server/collection-ai-service.test.ts new file mode 100644 index 0000000..6596cab --- /dev/null +++ b/src/lib/server/collection-ai-service.test.ts @@ -0,0 +1,937 @@ +import { createHash } from "node:crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { CollectionAiCheck, CollectionDocLine, CollectionDocRead } from "@/lib/types"; + +// ─── In-memory PocketBase stub ────────────────────────────────────── + +type Row = Record & { id: string }; +type Actor = { name?: string; source?: string; ignoreRequestUser?: boolean }; + +const db = new Map>(); +const writes: { op: string; coll: string; id: string; data: Record; actor: Actor | null }[] = []; +const events: string[] = []; +let currentActor: Actor | null = null; +let orderItemFields = ["description", "ai_check"]; +let schemaChecks = 0; +let idSeq = 0; + +function table(name: string): Map { + let t = db.get(name); + if (!t) { + t = new Map(); + db.set(name, t); + } + return t; +} + +// Tiny PB filter evaluator: `a = "x"`, `!=`, `~`, `<`, `>`, `&&`, `||`, parens. +type Tok = { t: "str" | "op" | "id"; v: string }; +function tokenize(s: string): Tok[] { + const out: Tok[] = []; + let i = 0; + while (i < s.length) { + const c = s[i]; + if (/\s/.test(c)) { + i += 1; + continue; + } + if (c === '"') { + let j = i + 1; + let v = ""; + while (s[j] !== '"') { + if (s[j] === "\\") { + v += s[j + 1]; + j += 2; + } else { + v += s[j]; + j += 1; + } + } + out.push({ t: "str", v }); + i = j + 1; + continue; + } + const two = s.slice(i, i + 2); + if (["&&", "||", "!=", ">=", "<="].includes(two)) { + out.push({ t: "op", v: two }); + i += 2; + continue; + } + if ("()=~<>".includes(c)) { + out.push({ t: "op", v: c }); + i += 1; + continue; + } + const m = /^[A-Za-z0-9_.-]+/.exec(s.slice(i)); + if (!m) throw new Error(`bad filter at ${s.slice(i)}`); + out.push({ t: "id", v: m[0] }); + i += m[0].length; + } + return out; +} +function compile(filter: string): (row: Row) => boolean { + const toks = tokenize(filter); + let p = 0; + const value = (tok: Tok) => (row: Row): unknown => { + if (tok.t === "str") return tok.v; + if (/^-?\d+(\.\d+)?$/.test(tok.v)) return Number(tok.v); + if (tok.v === "true") return true; + if (tok.v === "false") return false; + return row[tok.v]; + }; + function prim(): (row: Row) => boolean { + if (toks[p].v === "(") { + p += 1; + const e = expr(); + p += 1; // ")" + return e; + } + const lhs = value(toks[p++]); + const op = toks[p++].v; + const rhs = value(toks[p++]); + return (row) => { + const a = lhs(row); + const b = rhs(row); + switch (op) { + case "=": + return String(a ?? "") === String(b ?? ""); + case "!=": + return String(a ?? "") !== String(b ?? ""); + case "~": + return String(a ?? "").toLowerCase().includes(String(b ?? "").toLowerCase()); + case "<": + return Number(a) < Number(b); + case ">": + return Number(a) > Number(b); + default: + throw new Error(`op ${op}`); + } + }; + } + function and(): (row: Row) => boolean { + const parts = [prim()]; + while (toks[p]?.v === "&&") { + p += 1; + parts.push(prim()); + } + return (row) => parts.every((f) => f(row)); + } + function expr(): (row: Row) => boolean { + const parts = [and()]; + while (toks[p]?.v === "||") { + p += 1; + parts.push(and()); + } + return (row) => parts.some((f) => f(row)); + } + return expr(); +} + +function list(name: string, opts: { filter?: string; sort?: string } = {}): Row[] { + let rows = [...table(name).values()]; + if (opts.filter) { + const f = compile(opts.filter); + rows = rows.filter(f); + } + if (opts.sort) { + const desc = opts.sort.startsWith("-"); + const key = opts.sort.replace(/^-/, ""); + rows = rows.sort((a, b) => { + const c = String(a[key] ?? "").localeCompare(String(b[key] ?? "")); + return desc ? -c : c; + }); + } + return rows.map((r) => structuredClone(r)); +} + +const pb = { + collections: { + getOne: async (name: string) => { + schemaChecks += 1; + return { name, fields: (name === "order_items" ? orderItemFields : []).map((f) => ({ name: f })) }; + }, + }, + files: { + getToken: async () => "tok", + getURL: (rec: { id: string; collectionName?: string }, filename: string) => + `http://pb/api/files/${rec.collectionName}/${rec.id}/${filename}`, + }, + collection(name: string) { + return { + getFullList: async (opts: { filter?: string; sort?: string } = {}) => list(name, opts), + getList: async (_page: number, perPage: number, opts: { filter?: string; sort?: string } = {}) => ({ + items: list(name, opts).slice(0, perPage), + }), + getOne: async (id: string) => { + const r = table(name).get(id); + if (!r) throw new Error(`${name}/${id} not found`); + return structuredClone(r); + }, + create: async (data: Record) => { + idSeq += 1; + const id = `new${idSeq}`; + const row = { id, ...structuredClone(data) } as Row; + table(name).set(id, row); + writes.push({ op: "create", coll: name, id, data, actor: currentActor }); + return structuredClone(row); + }, + update: async (id: string, patch: Record) => { + const r = table(name).get(id); + if (!r) throw new Error(`${name}/${id} not found`); + Object.assign(r, structuredClone(patch)); + writes.push({ op: "update", coll: name, id, data: patch, actor: currentActor }); + return structuredClone(r); + }, + }; + }, +}; + +vi.mock("@/lib/server/pocketbase-admin", () => ({ getAdminPocketBase: async () => pb })); +vi.mock("@/lib/server/audit-context", () => ({ + runWithAuditActor: async (actor: Actor, fn: () => Promise) => { + const prev = currentActor; + currentActor = actor; + try { + return await fn(); + } finally { + currentActor = prev; + } + }, +})); + +const skuCalls: { id: string; sku: string; actor: { id: string; name: string }; audit: Actor | null }[] = []; +vi.mock("@/lib/server/collection-confirm-service", () => ({ + updateOrderItemSku: async (id: string, args: { sku: string; actor: { id: string; name: string } }) => { + skuCalls.push({ id, sku: args.sku, actor: args.actor, audit: currentActor }); + const r = table("order_items").get(id)!; + r.sku = args.sku; + writes.push({ op: "update", coll: "order_items", id, data: { sku: args.sku }, actor: currentActor }); + return structuredClone(r); + }, +})); +const stockSkuCalls: { id: string; patch: Record; audit: Actor | null }[] = []; +vi.mock("@/lib/server/stock-purchase-items-service", () => ({ + updateStockPurchaseItem: async (input: { id: string; patch: Record }) => { + stockSkuCalls.push({ id: input.id, patch: input.patch, audit: currentActor }); + const r = table("stock_purchase_items").get(input.id)!; + Object.assign(r, input.patch); + writes.push({ op: "update", coll: "stock_purchase_items", id: input.id, data: input.patch, actor: currentActor }); + return { item: structuredClone(r), previous: {} }; + }, +})); + +const settings = { + enabled: true, + collection_ai_enabled: true, + collection_ai_last_run_at: "", + collection_ai_last_run_summary: null as null | { started_at: string; finished_at: string }, +}; +const recordRun = vi.fn(async (fields: { last_run_at?: string; summary?: unknown }) => { + events.push(`record:${Object.keys(fields).join(",")}`); +}); +vi.mock("@/lib/server/ai-settings-service", async (importOriginal) => ({ + ...(await importOriginal()), + getAiSettings: async () => ({ ...settings }), + clearAiSettingsCache: vi.fn(), + recordCollectionAiRun: (f: { last_run_at?: string; summary?: unknown }) => recordRun(f), +})); + +import { AiDisabledError } from "@/lib/server/ai-settings-service"; +import type { DocumentReadResult } from "./collection-ai-read"; +import type { PairDecision, PairPoLine } from "./collection-ai-pair"; +import { + checkCollectionCard, + COLLECTION_AI_DISABLED_ERROR, + COLLECTION_AI_NOT_SET_UP_ERROR, + resetAiCheckFieldCheck, + runCollectionAiTick, + TICK_MAX_FILES, + TICK_TIME_BUDGET_MS, + type CollectionAiDeps, +} from "./collection-ai-service"; + +// ─── Fixtures ─────────────────────────────────────────────────────── + +const NOW = new Date("2026-10-17T08:00:00.000Z"); +const PO_CEM = "PO202610003CEM"; +const PO_CAS = "PO202610009CAS"; + +function docLine(over: Partial): CollectionDocLine { + return { + line_no: 1, + code: "", + description: "", + quantity: null, + unit: "EA", + unit_price: null, + line_total: null, + to_follow: null, + price_note: "", + ...over, + }; +} + +/** NSS tax invoice 5028256 (transcribed): covers both POs, prices per 100. */ +function nssRead(over: Partial = {}): DocumentReadResult { + return { + doc_type: "tax_invoice", + document_no: "5028256", + doc_date: "2026-10-16", + supplier_name: "NSS Fasteners", + po_refs: [PO_CEM, "PO 202610009 CAS"], + lines: [ + docLine({ line_no: 1, code: "SM293306030", description: "M20X60 HEX BOLT 8.8", quantity: 4, unit_price: 14.51 }), + docLine({ line_no: 2, code: "AMC93118100", description: "M8X25 S/S CAP SCREW", quantity: 40, unit_price: 0.33, price_note: "printed 33.00 per 100" }), + docLine({ line_no: 3, code: "SM2NUT9B506", description: "M6 NYLOCK NUT", quantity: 100, unit_price: 0.2 }), + ], + notes: [], + model: "MiniMax-M3", + ...over, + }; +} + +function addPo(id: string, po_number: string, supplier = "sup-nss") { + table("purchase_orders").set(id, { id, po_number, supplier, created: "2026-10-01 00:00:00.000Z" }); +} +function addOrderLine(id: string, purchase_order: string, over: Record = {}) { + table("order_items").set(id, { + id, + purchase_order, + description: `line ${id}`, + po_description: "", + sku: "", + order_qty: 1, + quantity: 1, + qty_collected: 1, + cost_price: 1, + item_status: "collected", + order_item_attachements: [], + ai_check: null, + drv_collected_at: "2026-10-16 10:00:00.000Z", + created: "2026-10-10 00:00:00.000Z", + ...over, + }); +} +function addStockPurchase(id: string, purchase_number: string, attachments: string[] = []) { + table("stock_purchases").set(id, { + id, + purchase_number, + supplier: "sup-nss", + additional_attachments: attachments, + created: "2026-10-01 00:00:00.000Z", + }); +} +function addStockLine(id: string, purchase: string, over: Record = {}) { + table("stock_purchase_items").set(id, { + id, + purchase, + stock_item: `st-${id}`, + description: `stock ${id}`, + sku: "", + quantity: 10, + quantity_received: 5, + cost_price: 1, + status: "collected", + ai_check: null, + created: "2026-10-12 00:00:00.000Z", + ...over, + }); +} +function addRead(over: Partial & { source_id: string; filename: string }): string { + idSeq += 1; + const id = String(over.id ?? `read${idSeq}`); + table("collection_doc_reads").set(id, { + id, + source_collection: "order_items", + content_hash: `hash-${id}`, + status: "read", + error: "", + findings: [], + read_at: "2026-10-16T09:00:00.000Z", + ...nssRead(), + ...over, + }); + return id; +} + +/** The NSS card: CEM has the collected M20 (photo) and the assigned M8x25; CAS has the M6 nylock. */ +function seedNss(opts: { attachments?: string[] } = {}) { + table("suppliers").set("sup-nss", { id: "sup-nss", suppliername: "NSS Fasteners" }); + addPo("po-cem", PO_CEM); + addPo("po-cas", PO_CAS); + addOrderLine("l1", "po-cem", { + description: "M20x60 hex bolt", + order_qty: 4, + quantity: 4, + qty_collected: 4, + cost_price: 14.51, + order_item_attachements: opts.attachments ?? ["nss_photo.jpg"], + }); + addOrderLine("l2", "po-cem", { + description: "M8x25 S/S", + order_qty: 40, + quantity: 40, + qty_collected: 0, + item_status: "assigned", + }); + addOrderLine("l3", "po-cas", { + description: "M6 nylock", + sku: "MY-SKU", + order_qty: 100, + quantity: 100, + qty_collected: 100, + cost_price: 0.2, + }); +} + +/** Default pairing plan: doc line n → PO line id + same_item. */ +let pairPlan: Record = {}; + +function makeDeps(over: Partial = {}) { + const contents: Record = {}; + const readDocument = vi.fn(async (img: { base64: string; contentType: string }) => { + events.push(`read:${img.contentType}`); + return nssRead(); + }); + const pairLines = vi.fn(async (docLines: CollectionDocLine[], poLines: PairPoLine[]) => { + events.push("pair"); + const ids = new Set(poLines.map((p) => p.id)); + const pairs = new Map(); + for (const d of docLines) { + const plan = pairPlan[d.line_no]; + pairs.set( + d.line_no, + plan && ids.has(plan.id) + ? { doc_line_no: d.line_no, po_line_id: plan.id, same_item: plan.same, reason: "same bolt" } + : { doc_line_no: d.line_no, po_line_id: null, same_item: null, reason: "" }, + ); + } + return { pairs, model: "MiniMax-M3" }; + }); + const fetchFile = vi.fn(async (_rec: { collection: string; id: string }, filename: string) => { + const bytes = Buffer.from(contents[filename] ?? `bytes of ${filename}`); + return { base64: bytes.toString("base64"), contentType: "image/jpeg", bytes }; + }); + const deps = { + readDocument, + pairLines, + fetchFile, + now: () => NOW, + ...over, + } as CollectionAiDeps & { + readDocument: typeof readDocument; + pairLines: typeof pairLines; + fetchFile: typeof fetchFile; + }; + return { deps, contents }; +} + +function aiCheck(coll: string, id: string): CollectionAiCheck | null { + return (table(coll).get(id)?.ai_check as CollectionAiCheck | null) ?? null; +} +function reads(): Row[] { + return [...table("collection_doc_reads").values()]; +} + +beforeEach(() => { + db.clear(); + writes.length = 0; + events.length = 0; + skuCalls.length = 0; + stockSkuCalls.length = 0; + currentActor = null; + orderItemFields = ["description", "ai_check"]; + schemaChecks = 0; + idSeq = 0; + pairPlan = { + 1: { id: "l1", same: "sure" }, + 2: { id: "l2", same: "sure" }, + 3: { id: "l3", same: "likely" }, + }; + settings.enabled = true; + settings.collection_ai_enabled = true; + settings.collection_ai_last_run_at = ""; + settings.collection_ai_last_run_summary = null; + recordRun.mockClear(); + resetAiCheckFieldCheck(); +}); + +// ─── checkCollectionCard ──────────────────────────────────────────── + +describe("checkCollectionCard", () => { + it("refuses to run (no writes) when order_items has no ai_check field", async () => { + seedNss(); + orderItemFields = ["description"]; + const { deps } = makeDeps(); + + const r = await checkCollectionCard(PO_CEM, { deps }); + + expect(r.error).toBe(COLLECTION_AI_NOT_SET_UP_ERROR); + expect(writes).toEqual([]); + expect(deps.readDocument).not.toHaveBeenCalled(); + }); + + it("caches a positive schema check for the life of the process", async () => { + seedNss(); + const { deps } = makeDeps(); + await checkCollectionCard(PO_CEM, { deps }); + await checkCollectionCard(PO_CEM, { deps }); + expect(schemaChecks).toBe(1); + }); + + it("returns the AI-disabled error and writes nothing when the read reports AI off", async () => { + seedNss({ attachments: ["notes.pdf", "nss_photo.jpg"] }); + const { deps } = makeDeps({ + readDocument: vi.fn(async () => { + throw new AiDisabledError("AI is disabled"); + }), + }); + + const r = await checkCollectionCard(PO_CEM, { deps }); + + expect(r.error).toBe(COLLECTION_AI_DISABLED_ERROR); + expect(writes).toEqual([]); + }); + + it("does not fetch or read a file that already has a read row for the same source and filename", async () => { + seedNss(); + const readId = addRead({ source_id: "l1", filename: "nss_photo.jpg" }); + const { deps } = makeDeps(); + + const r = await checkCollectionCard(PO_CEM, { deps }); + + expect(deps.fetchFile).not.toHaveBeenCalled(); + expect(deps.readDocument).not.toHaveBeenCalled(); + expect(r.files_read).toBe(0); + expect(reads()).toHaveLength(1); + expect(aiCheck("order_items", "l1")?.read_id).toBe(readId); + }); + + it("reads three identical uploads once; the other two get rows sharing the content hash", async () => { + seedNss({ attachments: ["a.jpg", "b.jpg", "c.jpg"] }); + const { deps, contents } = makeDeps(); + contents["a.jpg"] = contents["b.jpg"] = contents["c.jpg"] = "same photo bytes"; + + const r = await checkCollectionCard(PO_CEM, { deps }); + + expect(deps.readDocument).toHaveBeenCalledTimes(1); + expect(r.files_read).toBe(1); + const rows = reads(); + expect(rows.map((x) => x.filename).sort()).toEqual(["a.jpg", "b.jpg", "c.jpg"]); + const hash = createHash("sha256").update(Buffer.from("same photo bytes")).digest("hex"); + expect(rows.every((x) => x.content_hash === hash)).toBe(true); + expect(rows.every((x) => x.status === "read" && x.document_no === "5028256")).toBe(true); + + // A later run re-reads nothing. + await checkCollectionCard(PO_CEM, { deps }); + expect(deps.readDocument).toHaveBeenCalledTimes(1); + expect(reads()).toHaveLength(3); + }); + + it("does not re-read a known photo uploaded again under a new name", async () => { + seedNss({ attachments: ["old.jpg", "again.jpg"] }); + const { deps, contents } = makeDeps(); + contents["again.jpg"] = "photo"; + const hash = createHash("sha256").update(Buffer.from("photo")).digest("hex"); + addRead({ source_id: "l1", filename: "old.jpg", content_hash: hash }); + + await checkCollectionCard(PO_CEM, { deps }); + + expect(deps.readDocument).not.toHaveBeenCalled(); + const again = reads().find((x) => x.filename === "again.jpg"); + expect(again?.content_hash).toBe(hash); + expect(again?.status).toBe("read"); + }); + + it("stores a PDF as skipped with 'PDF not read yet' and makes no AI call", async () => { + seedNss({ attachments: ["Invoice 5028256.pdf"] }); + const { deps } = makeDeps(); + + const r = await checkCollectionCard(PO_CEM, { deps }); + + expect(deps.readDocument).not.toHaveBeenCalled(); + expect(deps.fetchFile).not.toHaveBeenCalled(); + expect(deps.pairLines).not.toHaveBeenCalled(); + const rows = reads(); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + source_collection: "order_items", + source_id: "l1", + filename: "Invoice 5028256.pdf", + status: "skipped", + notes: ["PDF not read yet"], + }); + // No document was read, so no line gets a check. + expect(aiCheck("order_items", "l1")).toBeNull(); + expect(r.lines_checked).toBe(0); + }); + + it("NSS: resolves both POs, checks every collected line on both and records the findings", async () => { + seedNss(); + const { deps } = makeDeps(); + + const r = await checkCollectionCard(PO_CEM, { deps }); + + expect(r.error).toBe(""); + expect(r.files_read).toBe(1); + // Both POs' lines (any status) were offered to the pairing call. + const poLines = deps.pairLines.mock.calls[0][1] as PairPoLine[]; + expect(poLines.map((l) => l.id).sort()).toEqual(["l1", "l2", "l3"]); + expect(poLines.find((l) => l.id === "l2")).toMatchObject({ po_number: PO_CEM, collected: false }); + expect(poLines.find((l) => l.id === "l3")).toMatchObject({ po_number: PO_CAS, collected: true, sku: "MY-SKU" }); + + // Every collected line on both POs gets ai_check; the assigned line doesn't. + const c1 = aiCheck("order_items", "l1")!; + const c3 = aiCheck("order_items", "l3")!; + expect(c1.doc_line?.code).toBe("SM293306030"); + expect(c1).toMatchObject({ document_no: "5028256", doc_date: "2026-10-16", doc_type: "tax_invoice", same_item: "sure" }); + expect(c1.checked_at).toBe(NOW.toISOString()); + expect(c1.model).toBe("MiniMax-M3"); + expect(c3.doc_line?.code).toBe("SM2NUT9B506"); + expect(c3.same_item).toBe("likely"); + expect(aiCheck("order_items", "l2")).toBeNull(); + expect(r.lines_checked).toBe(2); + + const read = reads()[0]; + expect(c1.read_id).toBe(read.id); + const findings = read.findings as string[]; + expect(findings).toContain(`Document 5028256 covers 2 POs: ${PO_CEM}, ${PO_CAS}.`); + expect(findings).toContain("M8x25 S/S × 40 is on the document but not marked collected."); + }); + + it("gives a collected card line with no document line an all-red check (doc_line null)", async () => { + seedNss(); + addOrderLine("l4", "po-cem", { description: "Washers" }); + const { deps } = makeDeps(); + + await checkCollectionCard(PO_CEM, { deps }); + + const c4 = aiCheck("order_items", "l4")!; + expect(c4).not.toBeNull(); + expect(c4.doc_line).toBeNull(); + expect(c4.same_item).toBeNull(); + expect(c4.document_no).toBe("5028256"); + }); + + it("fills an empty SKU on a 'sure' pairing via updateOrderItemSku and records sku_filled_value", async () => { + seedNss(); + const { deps } = makeDeps(); + + const r = await checkCollectionCard(PO_CEM, { deps }); + + expect(skuCalls).toEqual([ + { + id: "l1", + sku: "SM293306030", + actor: { id: "ai-collection-check", name: "AI collection check" }, + audit: expect.objectContaining({ name: "AI collection check" }), + }, + ]); + expect(table("order_items").get("l1")?.sku).toBe("SM293306030"); + expect(aiCheck("order_items", "l1")?.sku_filled_value).toBe("SM293306030"); + expect(r.skus_filled).toBe(1); + }); + + it("never changes an existing SKU, even on a 'sure' pairing", async () => { + seedNss(); + pairPlan[3] = { id: "l3", same: "sure" }; + const { deps } = makeDeps(); + + await checkCollectionCard(PO_CEM, { deps }); + + expect(skuCalls.map((c) => c.id)).toEqual(["l1"]); + expect(table("order_items").get("l3")?.sku).toBe("MY-SKU"); + expect(aiCheck("order_items", "l3")?.sku_filled_value).toBe(""); + }); + + it("keeps a rep's SKU on a re-run and drops the AI marker", async () => { + seedNss(); + const { deps } = makeDeps(); + await checkCollectionCard(PO_CEM, { deps }); + expect(aiCheck("order_items", "l1")?.sku_filled_value).toBe("SM293306030"); + + table("order_items").get("l1")!.sku = "REP-CODE"; + skuCalls.length = 0; + await checkCollectionCard(PO_CEM, { deps }); + + expect(skuCalls).toEqual([]); + expect(table("order_items").get("l1")?.sku).toBe("REP-CODE"); + expect(aiCheck("order_items", "l1")?.sku_filled_value).toBe(""); + }); + + it("keeps the AI marker on a re-run while the SKU is still the AI's value", async () => { + seedNss(); + const { deps } = makeDeps(); + await checkCollectionCard(PO_CEM, { deps }); + await checkCollectionCard(PO_CEM, { deps }); + expect(skuCalls).toHaveLength(1); + expect(aiCheck("order_items", "l1")?.sku_filled_value).toBe("SM293306030"); + }); + + it("never fills on a 'different' or 'likely' pairing", async () => { + seedNss(); + table("order_items").get("l3")!.sku = ""; + pairPlan[1] = { id: "l1", same: "different" }; + const { deps } = makeDeps(); + + await checkCollectionCard(PO_CEM, { deps }); + + expect(skuCalls).toEqual([]); + expect(table("order_items").get("l1")?.sku).toBe(""); + expect(table("order_items").get("l3")?.sku).toBe(""); + expect(aiCheck("order_items", "l1")?.same_item).toBe("different"); + }); + + it("stock rows: reads the parent purchase's attachments and fills the SKU through updateStockPurchaseItem", async () => { + addStockPurchase("sp1", "PO26101501STK", ["stock_inv.jpg"]); + addStockLine("s1", "sp1", { description: "M6 nylock nut" }); + pairPlan = { 1: { id: "s1", same: "sure" } }; + const { deps } = makeDeps({ + readDocument: vi.fn(async () => + nssRead({ + po_refs: ["PO26101501STK"], + lines: [docLine({ line_no: 1, code: "SM2NUT9B506", description: "M6 NYLOCK", quantity: 5, unit_price: 0.2 })], + }), + ), + }); + + const r = await checkCollectionCard("PO26101501STK", { deps }); + + expect(r.error).toBe(""); + expect(deps.fetchFile).toHaveBeenCalledWith( + expect.objectContaining({ collection: "stock_purchases", id: "sp1" }), + "stock_inv.jpg", + ); + expect(reads()[0]).toMatchObject({ source_collection: "stock_purchases", source_id: "sp1" }); + expect(stockSkuCalls).toEqual([ + { id: "s1", patch: { sku: "SM2NUT9B506" }, audit: expect.objectContaining({ name: "AI collection check" }) }, + ]); + expect(aiCheck("stock_purchase_items", "s1")).toMatchObject({ + sku_filled_value: "SM2NUT9B506", + same_item: "sure", + }); + }); + + it("falls back to the card's PO when no printed PO reference resolves, and says so", async () => { + seedNss(); + const { deps } = makeDeps({ + readDocument: vi.fn(async () => nssRead({ po_refs: ["ORDER 99887"] })), + }); + + await checkCollectionCard(PO_CEM, { deps }); + + const poLines = deps.pairLines.mock.calls[0][1] as PairPoLine[]; + expect(poLines.map((l) => l.id).sort()).toEqual(["l1", "l2"]); + const findings = reads()[0].findings as string[]; + expect(findings).toContain(`No PO number on the document matched; checked against ${PO_CEM}.`); + }); + + it("runs every write as the AI collection check actor", async () => { + seedNss({ attachments: ["nss_photo.jpg", "x.pdf"] }); + const { deps } = makeDeps(); + + await checkCollectionCard(PO_CEM, { deps }); + + expect(writes.length).toBeGreaterThan(0); + for (const w of writes) { + expect(w.actor).toEqual({ name: "AI collection check", source: "agent", ignoreRequestUser: true }); + } + }); + + it("credits the person on a manual run", async () => { + seedNss(); + const { deps } = makeDeps(); + + await checkCollectionCard(PO_CEM, { deps, actorName: "Dave" }); + + for (const w of writes) { + expect(w.actor).toEqual({ + name: "AI collection check (run by Dave)", + source: "agent", + ignoreRequestUser: true, + }); + } + expect(skuCalls[0].actor).toEqual({ id: "ai-collection-check", name: "AI collection check (run by Dave)" }); + }); + + it("records a failed read without writing line checks, and reports the error", async () => { + seedNss(); + const { deps } = makeDeps({ + readDocument: vi.fn(async () => { + throw new Error("AI response was cut off (max_tokens)"); + }), + }); + + const r = await checkCollectionCard(PO_CEM, { deps }); + + expect(r.error).toMatch(/max_tokens/); + expect(reads()[0]).toMatchObject({ status: "failed", error: "AI response was cut off (max_tokens)" }); + expect(aiCheck("order_items", "l1")).toBeNull(); + }); + + it("never writes cost, quantity or status", async () => { + seedNss(); + const { deps } = makeDeps(); + await checkCollectionCard(PO_CEM, { deps }); + const forbidden = ["cost_price", "qty_collected", "quantity_received", "item_status", "status"]; + for (const w of writes.filter((x) => x.coll !== "collection_doc_reads")) { + for (const k of forbidden) expect(Object.keys(w.data)).not.toContain(k); + } + }); +}); + +// ─── runCollectionAiTick ──────────────────────────────────────────── + +/** One card per PO with one collected line carrying one photo. */ +function seedCards(n: number, opts: { withRead?: boolean } = {}) { + for (let i = 1; i <= n; i += 1) { + const num = `PO2026100${String(i).padStart(2, "0")}XX`; + addPo(`po${i}`, num); + addOrderLine(`c${i}`, `po${i}`, { + order_item_attachements: [`p${i}.jpg`], + drv_collected_at: `2026-10-16 10:00:${String(i).padStart(2, "0")}.000Z`, + }); + if (opts.withRead) { + addRead({ source_id: `c${i}`, filename: `p${i}.jpg`, po_refs: [num], content_hash: `h${i}` }); + } + } +} + +describe("runCollectionAiTick", () => { + it("does nothing unless both the global AI and collection checks are on", async () => { + seedCards(1); + const { deps } = makeDeps(); + settings.collection_ai_enabled = false; + expect(await runCollectionAiTick(deps)).toEqual({ ran: false, reason: "disabled" }); + settings.collection_ai_enabled = true; + settings.enabled = false; + expect(await runCollectionAiTick(deps)).toEqual({ ran: false, reason: "disabled" }); + expect(recordRun).not.toHaveBeenCalled(); + expect(deps.readDocument).not.toHaveBeenCalled(); + }); + + it("does not overlap a run still inside its lease", async () => { + seedCards(1); + settings.collection_ai_last_run_at = new Date(NOW.getTime() - 60_000).toISOString(); + settings.collection_ai_last_run_summary = { + started_at: "2026-10-17T07:00:00.000Z", + finished_at: "2026-10-17T07:00:30.000Z", + }; + const { deps } = makeDeps(); + expect(await runCollectionAiTick(deps)).toEqual({ ran: false, reason: "running" }); + expect(recordRun).not.toHaveBeenCalled(); + }); + + it("runs again once the lease has expired", async () => { + seedCards(1); + settings.collection_ai_last_run_at = new Date(NOW.getTime() - 11 * 60_000).toISOString(); + const { deps } = makeDeps(); + const r = await runCollectionAiTick(deps); + expect(r.ran).toBe(true); + }); + + it("records last_run_at before any work, then the summary", async () => { + seedCards(1); + pairPlan = { 1: { id: "c1", same: "sure" } }; + const { deps } = makeDeps(); + + const r = await runCollectionAiTick(deps); + + expect(events[0]).toBe("record:last_run_at"); + expect(events.at(-1)).toBe("record:summary"); + expect(recordRun.mock.calls[0][0]).toEqual({ last_run_at: NOW.toISOString() }); + expect(r.summary).toMatchObject({ files_read: 1, cards_checked: 1, errors: [] }); + }); + + it("picks only cards with unread photos or unchecked lines", async () => { + // Card 1: read and checked → skipped. + // Card 2: photo never read → checked. + // Card 3: read exists but the line has no ai_check → checked (no new read). + seedCards(3); + addRead({ source_id: "c1", filename: "p1.jpg", po_refs: ["PO202610001XX"], content_hash: "h1" }); + table("order_items").get("c1")!.ai_check = { read_id: "x" }; + addRead({ source_id: "c3", filename: "p3.jpg", po_refs: ["PO202610003XX"], content_hash: "h3" }); + const { deps } = makeDeps({ + readDocument: vi.fn(async () => nssRead({ po_refs: ["PO202610002XX"] })), + }); + + const r = await runCollectionAiTick(deps); + + expect(deps.readDocument).toHaveBeenCalledTimes(1); + expect(deps.fetchFile.mock.calls.map((c) => c[1])).toEqual(["p2.jpg"]); + expect(r.summary?.cards_checked).toBe(2); + expect(aiCheck("order_items", "c2")).not.toBeNull(); + expect(aiCheck("order_items", "c3")).not.toBeNull(); + }); + + it("ignores a card whose only attachments are already-skipped PDFs", async () => { + addPo("pdfpo", "PO202610077XX"); + addOrderLine("d1", "pdfpo", { order_item_attachements: ["inv.pdf"] }); + addRead({ source_id: "d1", filename: "inv.pdf", status: "skipped", content_hash: "" }); + const { deps } = makeDeps(); + const r = await runCollectionAiTick(deps); + expect(r.summary?.cards_checked).toBe(0); + }); + + it("reads at most TICK_MAX_FILES files, newest cards first", async () => { + seedCards(12); + const { deps } = makeDeps(); + + const r = await runCollectionAiTick(deps); + + expect(deps.readDocument).toHaveBeenCalledTimes(TICK_MAX_FILES); + expect(r.summary?.files_read).toBe(TICK_MAX_FILES); + expect(deps.fetchFile.mock.calls[0][1]).toBe("p12.jpg"); + expect(deps.fetchFile.mock.calls.map((c) => c[1])).not.toContain("p1.jpg"); + }); + + it("stops starting cards once the time budget is spent", async () => { + seedCards(3); + // Run start and the first card's budget check see NOW; every later + // clock read is past the budget. + let calls = 0; + const { deps } = makeDeps({ + now: () => { + calls += 1; + return new Date(NOW.getTime() + (calls <= 2 ? 0 : TICK_TIME_BUDGET_MS)); + }, + }); + + const r = await runCollectionAiTick(deps); + + expect(r.summary?.cards_checked).toBe(1); + }); + + it("isolates a failing card: the others still run and the error is recorded", async () => { + seedCards(2, { withRead: true }); + pairPlan = { 1: { id: "c1", same: "sure" } }; + const { deps } = makeDeps(); + const pairImpl = deps.pairLines.getMockImplementation()!; + deps.pairLines.mockImplementation(async (docLines, poLines) => { + if (poLines.some((l) => l.id === "c2")) throw new Error("pairing exploded"); + return pairImpl(docLines, poLines); + }); + + const r = await runCollectionAiTick(deps); + + expect(r.summary?.cards_checked).toBe(2); + expect(r.summary?.errors).toEqual(["PO202610002XX: pairing failed: pairing exploded"]); + expect(aiCheck("order_items", "c1")).not.toBeNull(); + expect(aiCheck("order_items", "c2")).toBeNull(); + }); + + it("keeps the summary bounded: at most 20 errors of at most 300 characters", async () => { + seedCards(25, { withRead: true }); + const { deps } = makeDeps({ + pairLines: vi.fn(async () => { + throw new Error("x".repeat(1000)); + }), + }); + + const r = await runCollectionAiTick(deps); + + expect(r.summary?.cards_checked).toBe(25); + expect(r.summary?.errors).toHaveLength(20); + for (const e of r.summary!.errors) expect(e.length).toBeLessThanOrEqual(300); + }); +}); diff --git a/src/lib/server/collection-ai-service.ts b/src/lib/server/collection-ai-service.ts new file mode 100644 index 0000000..4536f46 --- /dev/null +++ b/src/lib/server/collection-ai-service.ts @@ -0,0 +1,871 @@ +/** + * collection-ai-service — orchestration for the Collection Confirm AI checks. + * + * `checkCollectionCard` checks ONE card (a PO number): it reads each driver + * photo once (deduped by sha256 of the bytes, cached in + * `collection_doc_reads`), resolves the POs each document names, pairs the + * document lines with those POs' lines via the AI, fills empty SKUs, writes + * `ai_check` on every collected line and the card findings on the read. + * `runCollectionAiTick` is the scheduled entry point. + * + * Guarantees: the AI writes only `ai_check`, `collection_doc_reads` rows and + * an EMPTY line SKU (through the same services as a manual edit). It never + * writes cost, quantities, statuses or invoice details, and never touches a + * SKU a person set. + */ +import { createHash } from "node:crypto"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { runWithAuditActor } from "@/lib/server/audit-context"; +import { + AiDisabledError, + clearAiSettingsCache, + getAiSettings, + recordCollectionAiRun, +} from "@/lib/server/ai-settings-service"; +import { getOrderItemsCollectionName } from "@/lib/server/orders-service"; +import { updateOrderItemSku } from "@/lib/server/collection-confirm-service"; +import { updateStockPurchaseItem } from "@/lib/server/stock-purchase-items-service"; +import { normalizePoReference } from "@/lib/server/stock-purchase-invoice-matching-service"; +import { readDocumentImage, type DocumentReadResult } from "@/lib/server/collection-ai-read"; +import { + pairDocumentLines, + type PairDecision, + type PairPoLine, +} from "@/lib/server/collection-ai-pair"; +import { buildDocFindings } from "@/lib/server/collection-ai-findings"; +import type { + CollectionAiCheck, + CollectionAiRunSummary, + CollectionDocLine, + CollectionDocRead, + CollectionDocType, +} from "@/lib/types"; + +export const COLLECTION_AI_NOT_SET_UP_ERROR = + "Collection checks are not set up yet: run `npm run pb:migrate:collection-ai` against PocketBase first"; +export const COLLECTION_AI_DISABLED_ERROR = "AI is disabled in settings"; +export const TICK_MAX_FILES = 10; +export const TICK_TIME_BUDGET_MS = 90_000; +export const TICK_LEASE_MS = 10 * 60_000; + +export interface CollectionAiDeps { + readDocument?: typeof readDocumentImage; + pairLines?: typeof pairDocumentLines; + fetchFile?: ( + rec: { collection: string; id: string }, + filename: string, + ) => Promise<{ base64: string; contentType: string; bytes: Buffer }>; + now?: () => Date; +} + +export interface CardCheckResult { + po_number: string; + files_read: number; + lines_checked: number; + skus_filled: number; + error: string; +} + +const ACTOR_NAME = "AI collection check"; +const SKU_ACTOR_ID = "ai-collection-check"; +const READS = "collection_doc_reads"; +const STOCK_LINES = "stock_purchase_items"; +const STOCK_POS = "stock_purchases"; +const CUSTOMER_POS = "purchase_orders"; +const MAX_SUMMARY_ERRORS = 20; +const MAX_SUMMARY_ERROR_CHARS = 300; +const CHUNK = 50; +const PDF_NOTE = "PDF not read yet"; +const IMAGE_EXT = new Set(["jpg", "jpeg", "png", "webp", "gif"]); +/** Same cap as `updateOrderItemSku`. */ +const MAX_SKU_LENGTH = 64; +/** Item statuses that mean the line was physically collected. */ +const PRE_COLLECTION = new Set([ + "", + "pending", + "processed", + "po_generated", + "collection_pool", + "assigned", + "approval_needed", + "approval_rejected", + "cancelled", + "removed", + "assigned_to_driver", +]); + +type AdminPb = Awaited>; +type Rec = Record & { id: string }; + +// ─── Small helpers ────────────────────────────────────────────────── + +function errMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +function q(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +function chunks(items: T[], size = CHUNK): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +} + +function str(v: unknown): string { + return typeof v === "string" ? v : ""; +} + +function num(v: unknown): number { + return typeof v === "number" && Number.isFinite(v) ? v : 0; +} + +function fileList(v: unknown): string[] { + if (v == null || v === "") return []; + return (Array.isArray(v) ? v : [v]).filter((f): f is string => typeof f === "string" && f !== ""); +} + +function ext(filename: string): string { + const m = /\.([A-Za-z0-9]+)$/.exec(filename); + return m ? m[1].toLowerCase() : ""; +} + +/** Keep the stored run summary small: ≤ 20 errors of ≤ 300 characters. */ +function boundErrors(errors: string[]): string[] { + return errors + .slice(0, MAX_SUMMARY_ERRORS) + .map((e) => (e.length > MAX_SUMMARY_ERROR_CHARS ? `${e.slice(0, MAX_SUMMARY_ERROR_CHARS - 1)}…` : e)); +} + +/** `OR`-ed equality filters on one field, chunked. */ +async function listByField( + pb: AdminPb, + collection: string, + field: string, + values: string[], + extra = "", +): Promise { + const out: Rec[] = []; + for (const group of chunks([...new Set(values)].filter((v) => v !== ""))) { + const ors = group.map((v) => `${field} = "${q(v)}"`).join(" || "); + out.push( + ...(await pb.collection(collection).getFullList({ + filter: extra ? `(${ors}) && ${extra}` : ors, + })), + ); + } + return out; +} + +// ─── Schema guard ─────────────────────────────────────────────────── + +let aiCheckFieldConfirmed = false; + +/** + * PocketBase silently drops unknown fields on update. Refuse to run until + * the migration has added `ai_check`. A positive answer is cached. + */ +async function hasAiCheckField(pb: AdminPb): Promise { + if (aiCheckFieldConfirmed) return true; + const collection = (await pb.collections.getOne(getOrderItemsCollectionName())) as { + fields?: Array<{ name: string }>; + schema?: Array<{ name: string }>; + }; + const fields = collection.fields ?? collection.schema ?? []; + aiCheckFieldConfirmed = fields.some((f) => f.name === "ai_check"); + return aiCheckFieldConfirmed; +} + +/** Test hook: forget the cached schema check. */ +export function resetAiCheckFieldCheck(): void { + aiCheckFieldConfirmed = false; +} + +// ─── Lines and POs ────────────────────────────────────────────────── + +type PoRef = { kind: "customer" | "stock"; id: string; po_number: string; supplier: string }; + +type Line = { + kind: "customer" | "stock"; + collection: string; + id: string; + poId: string; + poNumber: string; + description: string; + po_description: string; + /** The line's OWN sku (stock: `stock_purchase_items.sku`, no catalogue fallback). */ + sku: string; + order_qty: number; + quantity: number; + qty_collected: number; + cost_price: number | null; + status: string; + /** Physically collected (any post-collection status). */ + collected: boolean; + /** On the Collection Confirm page right now. */ + onPage: boolean; + ai_check: CollectionAiCheck | null; + attachments: string[]; + sortKey: string; +}; + +function customerLine(r: Rec, po: PoRef | undefined, collection: string): Line { + const status = str(r.item_status); + return { + kind: "customer", + collection, + id: r.id, + poId: str(r.purchase_order), + poNumber: po?.po_number ?? "", + description: str(r.description), + po_description: str(r.po_description), + sku: str(r.sku).trim(), + order_qty: num(r.order_qty), + quantity: num(r.quantity), + qty_collected: num(r.qty_collected), + cost_price: typeof r.cost_price === "number" ? r.cost_price : null, + status, + collected: !PRE_COLLECTION.has(status), + onPage: status === "collected", + ai_check: (r.ai_check as CollectionAiCheck | null) ?? null, + attachments: [...fileList(r.order_item_attachements), ...fileList(r.order_item_attachments)], + sortKey: str(r.drv_collected_at) || str(r.updated) || str(r.created), + }; +} + +function stockLine(r: Rec, po: PoRef | undefined): Line { + const status = str(r.status); + const quantity = num(r.quantity); + const received = num(r.quantity_received); + return { + kind: "stock", + collection: STOCK_LINES, + id: r.id, + poId: str(r.purchase), + poNumber: po?.po_number ?? "", + description: str(r.description), + po_description: "", + sku: str(r.sku).trim(), + order_qty: quantity, + quantity, + qty_collected: received, + cost_price: typeof r.cost_price === "number" ? r.cost_price : null, + status, + collected: !PRE_COLLECTION.has(status), + // Same filter as listCollectedItems' stock leg. + onPage: status === "collected" && received < quantity, + ai_check: (r.ai_check as CollectionAiCheck | null) ?? null, + attachments: [], + sortKey: str(r.updated) || str(r.created), + }; +} + +function toPoRef(kind: PoRef["kind"], r: Rec): PoRef { + return { + kind, + id: r.id, + po_number: kind === "customer" ? str(r.po_number) : str(r.purchase_number), + supplier: str(r.supplier), + }; +} + +async function loadLinesForPos(pb: AdminPb, pos: PoRef[], onlyCollected: boolean): Promise { + const orderItems = getOrderItemsCollectionName(); + const byId = new Map(pos.map((p) => [`${p.kind}:${p.id}`, p])); + const custIds = pos.filter((p) => p.kind === "customer").map((p) => p.id); + const stockIds = pos.filter((p) => p.kind === "stock").map((p) => p.id); + const [cust, stock] = await Promise.all([ + listByField(pb, orderItems, "purchase_order", custIds, onlyCollected ? 'item_status = "collected"' : ""), + listByField(pb, STOCK_LINES, "purchase", stockIds, onlyCollected ? 'status = "collected"' : ""), + ]); + const lines = [ + ...cust.map((r) => customerLine(r, byId.get(`customer:${str(r.purchase_order)}`), orderItems)), + ...stock.map((r) => stockLine(r, byId.get(`stock:${str(r.purchase)}`))), + ]; + return onlyCollected ? lines.filter((l) => l.onPage) : lines; +} + +// ─── Files and reads ──────────────────────────────────────────────── + +type FileRef = { collection: string; id: string; filename: string }; + +const fileKey = (f: { collection: string; id: string; filename: string }) => + `${f.collection}\u0000${f.id}\u0000${f.filename}`; + +/** Customer photos live on the line; stock photos on the parent purchase. */ +function filesForLines(lines: Line[], stockPurchases: Map): FileRef[] { + const out = new Map(); + for (const l of lines) { + const refs: FileRef[] = + l.kind === "customer" + ? l.attachments.map((filename) => ({ collection: l.collection, id: l.id, filename })) + : fileList(stockPurchases.get(l.poId)?.additional_attachments).map((filename) => ({ + collection: STOCK_POS, + id: l.poId, + filename, + })); + for (const f of refs) out.set(fileKey(f), f); + } + return [...out.values()]; +} + +function toRead(r: Rec): CollectionDocRead { + const arr = (v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []); + return { + id: r.id, + source_collection: str(r.source_collection), + source_id: str(r.source_id), + filename: str(r.filename), + content_hash: str(r.content_hash), + status: (str(r.status) || "failed") as CollectionDocRead["status"], + doc_type: str(r.doc_type) as CollectionDocRead["doc_type"], + document_no: str(r.document_no), + doc_date: str(r.doc_date), + supplier_name: str(r.supplier_name), + po_refs: arr(r.po_refs), + lines: arr(r.lines), + notes: arr(r.notes), + findings: arr(r.findings), + model: str(r.model), + error: str(r.error), + read_at: str(r.read_at), + }; +} + +/** Existing reads per file (newest `read_at` wins for a repeated row). */ +async function loadReadsForFiles(pb: AdminPb, files: FileRef[]): Promise> { + const out = new Map(); + const sources = [...new Map(files.map((f) => [`${f.collection}\u0000${f.id}`, f])).values()]; + for (const group of chunks(sources)) { + const filter = group + .map((s) => `(source_collection = "${q(s.collection)}" && source_id = "${q(s.id)}")`) + .join(" || "); + const rows = await pb.collection(READS).getFullList({ filter, sort: "read_at" }); + for (const r of rows) { + const read = toRead(r); + out.set(fileKey({ collection: read.source_collection, id: read.source_id, filename: read.filename }), read); + } + } + return out; +} + +async function findReadByHash(pb: AdminPb, hash: string): Promise { + const page = await pb.collection(READS).getList(1, 20, { + filter: `content_hash = "${q(hash)}"`, + sort: "read_at", + }); + const rows = page.items.map(toRead).filter((r) => r.status !== "failed"); + return rows[0] ?? null; +} + +async function defaultFetchFile( + rec: { collection: string; id: string }, + filename: string, +): Promise<{ base64: string; contentType: string; bytes: Buffer }> { + const pb = await getAdminPocketBase(); + const token = await pb.files.getToken(); + const url = pb.files.getURL({ id: rec.id, collectionName: rec.collection }, filename, { + token, + thumb: "1600x0", + }); + const res = await fetch(url); + if (!res.ok) throw new Error(`file fetch failed (${res.status})`); + const bytes = Buffer.from(await res.arrayBuffer()); + let contentType = (res.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase(); + if (contentType === "image/jpg") contentType = "image/jpeg"; + if (!contentType.startsWith("image/")) { + const e = ext(filename); + if (IMAGE_EXT.has(e)) contentType = `image/${e === "jpg" ? "jpeg" : e}`; + } + return { base64: bytes.toString("base64"), contentType, bytes }; +} + +type ReadFields = Omit; + +function emptyRead(f: FileRef, now: Date): ReadFields { + return { + source_collection: f.collection, + source_id: f.id, + filename: f.filename, + content_hash: "", + status: "skipped", + doc_type: "", + document_no: "", + doc_date: "", + supplier_name: "", + po_refs: [], + lines: [], + notes: [], + findings: [], + model: "", + error: "", + read_at: now.toISOString(), + }; +} + +function fromResult(f: FileRef, hash: string, r: DocumentReadResult, now: Date): ReadFields { + return { + ...emptyRead(f, now), + content_hash: hash, + status: "read", + doc_type: r.doc_type, + document_no: r.document_no, + doc_date: r.doc_date, + supplier_name: r.supplier_name, + po_refs: r.po_refs, + lines: r.lines, + notes: r.notes, + model: r.model, + }; +} + +/** A new row for an identical upload: same content, its own source. */ +function copyOf(f: FileRef, known: ReadFields): ReadFields { + return { ...known, source_collection: f.collection, source_id: f.id, filename: f.filename }; +} + +class DisabledSignal extends Error {} + +// ─── PO reference resolution ──────────────────────────────────────── + +type PoIndex = { byNorm: Map }; + +async function loadPoIndex(pb: AdminPb): Promise { + const byNorm = new Map(); + const add = (ref: PoRef) => { + const norm = normalizePoReference(ref.po_number); + if (!norm) return; + byNorm.set(norm, [...(byNorm.get(norm) ?? []), ref]); + }; + // Same approach as stock-purchase-invoice-matching: suppliers print PO + // numbers however they like, so compare normalised over recent rows. + const [cust, stock] = await Promise.all([ + pb.collection(CUSTOMER_POS).getList(1, 500, { sort: "-created" }), + pb.collection(STOCK_POS).getList(1, 500, { sort: "-created" }), + ]); + for (const r of cust.items) add(toPoRef("customer", r)); + for (const r of stock.items) add(toPoRef("stock", r)); + return { byNorm }; +} + +function resolveRefs(index: PoIndex, refs: string[]): PoRef[] { + const out = new Map(); + for (const ref of refs) { + for (const po of index.byNorm.get(normalizePoReference(ref)) ?? []) out.set(`${po.kind}:${po.id}`, po); + } + return [...out.values()]; +} + +async function supplierNames(pb: AdminPb, ids: string[]): Promise> { + const out = new Map(); + if (ids.length === 0) return out; + try { + for (const r of await listByField(pb, "suppliers", "id", ids)) out.set(r.id, str(r.suppliername)); + } catch { + // Supplier names only feed one finding; never fail the check over them. + } + return out; +} + +// ─── Card check ───────────────────────────────────────────────────── + +type Candidate = { doc: CollectionDocRead; docLine: CollectionDocLine | null; decision: PairDecision | null; model: string }; + +function newer(a: CollectionDocRead, b: CollectionDocRead): boolean { + return a.read_at > b.read_at || (a.read_at === b.read_at && a.id > b.id); +} + +export async function checkCollectionCard( + poNumber: string, + opts: { actorName?: string; maxFiles?: number; deps?: CollectionAiDeps } = {}, +): Promise { + return checkCard(poNumber, { ...opts, retryFailed: true }); +} + +async function checkCard( + poNumber: string, + opts: { actorName?: string; maxFiles?: number; deps?: CollectionAiDeps; retryFailed: boolean }, +): Promise { + const deps = opts.deps ?? {}; + const readDocument = deps.readDocument ?? readDocumentImage; + const pairLines = deps.pairLines ?? pairDocumentLines; + const fetchFile = deps.fetchFile ?? defaultFetchFile; + const nowFn = deps.now ?? (() => new Date()); + const maxFiles = opts.maxFiles ?? TICK_MAX_FILES; + const actorName = opts.actorName ? `${ACTOR_NAME} (run by ${opts.actorName})` : ACTOR_NAME; + const result: CardCheckResult = { po_number: poNumber, files_read: 0, lines_checked: 0, skus_filled: 0, error: "" }; + const fail = (msg: string) => { + if (!result.error) result.error = msg; + }; + + // ignoreRequestUser: a manual run carries the rep's PB token, which the + // audit hook would otherwise credit instead of the AI. + return runWithAuditActor({ name: actorName, source: "agent", ignoreRequestUser: true }, async () => { + const pb = await getAdminPocketBase(); + if (!(await hasAiCheckField(pb))) { + result.error = COLLECTION_AI_NOT_SET_UP_ERROR; + return result; + } + const settings = await getAiSettings(); + if (!settings.enabled) { + result.error = COLLECTION_AI_DISABLED_ERROR; + return result; + } + const now = nowFn(); + + // 1. The card: every PO record with this number, and its lines on the page. + const [custPos, stockPos] = await Promise.all([ + pb.collection(CUSTOMER_POS).getFullList({ filter: `po_number = "${q(poNumber)}"` }), + pb.collection(STOCK_POS).getFullList({ filter: `purchase_number = "${q(poNumber)}"` }), + ]); + const cardPos = [...custPos.map((r) => toPoRef("customer", r)), ...stockPos.map((r) => toPoRef("stock", r))]; + if (cardPos.length === 0) { + result.error = `PO ${poNumber} not found`; + return result; + } + const stockPurchases = new Map(stockPos.map((r) => [r.id, r])); + const cardLines = await loadLinesForPos(pb, cardPos, true); + if (cardLines.length === 0) return result; + + // 2. Read each file once. Writes are queued so "AI off" writes nothing. + const files = filesForLines(cardLines, stockPurchases); + const readsByFile = await loadReadsForFiles(pb, files); + const byHash = new Map(); + const queued: Array<{ file: FileRef; fields: ReadFields; replaceId: string | null }> = []; + try { + for (const file of files) { + const existing = readsByFile.get(fileKey(file)); + if (existing && !(opts.retryFailed && existing.status === "failed")) continue; + const replaceId = existing?.id ?? null; + const extension = ext(file.filename); + if (!IMAGE_EXT.has(extension)) { + const fields = emptyRead(file, now); + fields.notes = [extension === "pdf" ? PDF_NOTE : `Not an image (.${extension || "?"})`]; + queued.push({ file, fields, replaceId }); + continue; + } + if (result.files_read >= maxFiles) continue; + + let fetched: Awaited>>; + try { + fetched = await fetchFile({ collection: file.collection, id: file.id }, file.filename); + } catch (e) { + // Transient: no row, so the next run tries again. + fail(`${file.filename}: ${errMessage(e)}`); + continue; + } + const hash = createHash("sha256").update(fetched.bytes).digest("hex"); + let known = byHash.get(hash) ?? null; + if (!known) { + const found = await findReadByHash(pb, hash); + if (found) { + const rest: Partial = { ...found }; + delete rest.id; + known = rest as ReadFields; + } + } + if (known) { + queued.push({ file, fields: copyOf(file, known), replaceId }); + continue; + } + + let fields: ReadFields; + try { + const read = await readDocument({ base64: fetched.base64, contentType: fetched.contentType }); + fields = fromResult(file, hash, read, now); + result.files_read += 1; + } catch (e) { + if (e instanceof AiDisabledError) throw new DisabledSignal(); + const msg = errMessage(e); + fields = { ...emptyRead(file, now), content_hash: hash }; + if (msg.startsWith("Unsupported image type")) { + fields.notes = [msg]; + } else { + fields.status = "failed"; + fields.error = msg.slice(0, 2000); + fail(`read failed for ${file.filename}: ${msg}`); + result.files_read += 1; + } + } + if (fields.status !== "failed") byHash.set(hash, fields); + queued.push({ file, fields, replaceId }); + } + } catch (e) { + if (e instanceof DisabledSignal) { + result.error = COLLECTION_AI_DISABLED_ERROR; + return result; + } + throw e; + } + for (const { file, fields, replaceId } of queued) { + try { + const saved = replaceId + ? await pb.collection(READS).update(replaceId, fields) + : await pb.collection(READS).create(fields); + readsByFile.set(fileKey(file), toRead(saved)); + } catch (e) { + fail(`saving the read of ${file.filename} failed: ${errMessage(e)}`); + } + } + + // 3. Distinct documents on this card (one canonical row per content). + const docsByHash = new Map(); + for (const file of files) { + const read = readsByFile.get(fileKey(file)); + if (!read || read.status !== "read") continue; + if (read.doc_type !== "tax_invoice" && read.doc_type !== "delivery_note") continue; + const key = read.content_hash || read.id; + const prev = docsByHash.get(key); + // Canonical = the oldest row for that content (stable across runs). + if (!prev || newer(prev, read)) docsByHash.set(key, read); + } + const docs = [...docsByHash.values()]; + if (docs.length === 0) return result; + + // 4. Pair each document with the lines of every PO it names. + let poIndex: PoIndex | null = null; + const candidates = new Map(); + const lineDocs = new Map(); + const linesById = new Map(cardLines.map((l) => [l.id, l])); + const checkedDocs: CollectionDocRead[] = []; + + for (const doc of docs) { + try { + let resolved: PoRef[] = []; + if (doc.po_refs.length > 0) { + poIndex ??= await loadPoIndex(pb); + resolved = resolveRefs(poIndex, doc.po_refs); + } + let fellBackToPo: string | null = null; + if (resolved.length === 0) { + resolved = cardPos; + fellBackToPo = poNumber; + } + const poLines = await loadLinesForPos(pb, resolved, false); + const pairInput: PairPoLine[] = poLines.map((l) => ({ + id: l.id, + po_number: l.poNumber, + description: l.description, + collected: l.collected, + order_qty: l.order_qty, + quantity: l.quantity, + qty_collected: l.qty_collected, + sku: l.sku, + po_description: l.po_description, + cost_price: l.cost_price, + status: l.status, + })); + + let paired: { pairs: Map; model: string }; + try { + paired = await pairLines(doc.lines, pairInput); + } catch (e) { + if (e instanceof AiDisabledError) { + result.error = COLLECTION_AI_DISABLED_ERROR; + return result; + } + fail(`pairing failed: ${errMessage(e)}`); + continue; + } + + const suppliers = await supplierNames(pb, [...new Set(resolved.map((p) => p.supplier))]); + const findings = buildDocFindings({ + read: doc, + resolvedPos: resolved.map((p) => ({ po_number: p.po_number, supplier_name: suppliers.get(p.supplier) ?? "" })), + fellBackToPo, + poLines: pairInput, + pairs: [...paired.pairs.values()].map((p) => ({ doc_line_no: p.doc_line_no, po_line_id: p.po_line_id })), + }); + await pb.collection(READS).update(doc.id, { findings }); + checkedDocs.push(doc); + + for (const l of poLines) { + if (!l.onPage) continue; + linesById.set(l.id, l); + lineDocs.set(l.id, [...(lineDocs.get(l.id) ?? []), doc]); + } + const docLineByNo = new Map(doc.lines.map((d) => [d.line_no, d])); + for (const decision of paired.pairs.values()) { + if (!decision.po_line_id) continue; + const docLine = docLineByNo.get(decision.doc_line_no) ?? null; + if (!docLine) continue; + candidates.set(decision.po_line_id, [ + ...(candidates.get(decision.po_line_id) ?? []), + { doc, docLine, decision, model: paired.model || doc.model }, + ]); + } + } catch (e) { + fail(`document ${doc.document_no || doc.filename}: ${errMessage(e)}`); + } + } + if (checkedDocs.length === 0) return result; + const newestDoc = checkedDocs.reduce((a, b) => (newer(b, a) ? b : a)); + + // 5. Write ai_check on every collected line (card + other POs the documents named). + for (const line of linesById.values()) { + const paired = (candidates.get(line.id) ?? []).reduce( + (best, c) => (!best || newer(c.doc, best.doc) ? c : best), + null, + ); + const covering = lineDocs.get(line.id) ?? []; + const fallbackDoc = covering.length > 0 ? covering.reduce((a, b) => (newer(b, a) ? b : a)) : newestDoc; + const chosen: Candidate = paired ?? { doc: fallbackDoc, docLine: null, decision: null, model: fallbackDoc.model }; + try { + const fresh = await pb.collection(line.collection).getOne(line.id); + const ownSku = str(fresh.sku).trim(); + const previous = (fresh.ai_check as CollectionAiCheck | null) ?? null; + // The robot marker survives a re-run only while the SKU is still the AI's. + let skuFilled = + previous?.sku_filled_value && str(fresh.sku) === previous.sku_filled_value ? previous.sku_filled_value : ""; + const code = chosen.docLine?.code.trim() ?? ""; + if (!ownSku && code && code.length <= MAX_SKU_LENGTH && chosen.decision?.same_item === "sure") { + if (line.kind === "customer") { + await updateOrderItemSku(line.id, { sku: code, actor: { id: SKU_ACTOR_ID, name: actorName } }); + } else { + await updateStockPurchaseItem({ id: line.id, patch: { sku: code } }); + } + skuFilled = code; + result.skus_filled += 1; + } + const docNo = chosen.doc.document_no || "the document"; + const check: CollectionAiCheck = { + read_id: chosen.doc.id, + document_no: chosen.doc.document_no, + doc_date: chosen.doc.doc_date, + doc_type: chosen.doc.doc_type as CollectionDocType, + doc_line: chosen.docLine, + same_item: chosen.decision?.same_item ?? null, + reason: chosen.decision?.reason ?? "", + sku_filled_value: skuFilled, + findings: chosen.docLine ? [] : [`Not found on ${docNo}.`], + checked_at: now.toISOString(), + model: chosen.model, + }; + await pb.collection(line.collection).update(line.id, { ai_check: check }); + result.lines_checked += 1; + } catch (e) { + fail(`write failed for line ${line.id}: ${errMessage(e)}`); + } + } + return result; + }); +} + +// ─── Tick ─────────────────────────────────────────────────────────── + +/** + * Scheduled entry point (n8n, every few minutes). Does nothing unless the + * global AI and Collection checks are both on. Picks cards with unread + * photos or unchecked lines, newest first, and checks them card by card + * within TICK_MAX_FILES reads and the time budget. One card failing never + * stops the others. + */ +export async function runCollectionAiTick( + deps: CollectionAiDeps = {}, +): Promise<{ ran: boolean; reason?: "disabled" | "running"; summary?: CollectionAiRunSummary }> { + const nowFn = deps.now ?? (() => new Date()); + + clearAiSettingsCache(); + const settings = await getAiSettings(); + if (!settings.enabled || !settings.collection_ai_enabled) { + return { ran: false, reason: "disabled" }; + } + + const now = nowFn(); + // In-progress lease, as in the quote assistant: a start newer than the + // last summary means a run is still going (n8n may have timed out on it). + const last = Date.parse(settings.collection_ai_last_run_at || ""); + const lastFinished = Date.parse(settings.collection_ai_last_run_summary?.finished_at || ""); + const unfinished = Number.isNaN(lastFinished) || lastFinished < last; + if (!Number.isNaN(last) && unfinished && now.getTime() - last < TICK_LEASE_MS) { + return { ran: false, reason: "running" }; + } + + const startedAt = now.toISOString(); + await recordCollectionAiRun({ last_run_at: startedAt }); + + const summary: CollectionAiRunSummary = { + started_at: startedAt, + finished_at: startedAt, + files_read: 0, + cards_checked: 0, + errors: [], + }; + + try { + const pb = await getAdminPocketBase(); + const cards = await cardsNeedingWork(pb); + const startMs = now.getTime(); + for (const poNumber of cards) { + if (summary.files_read >= TICK_MAX_FILES) break; + if (nowFn().getTime() - startMs >= TICK_TIME_BUDGET_MS) break; + summary.cards_checked += 1; + try { + const r = await checkCard(poNumber, { + maxFiles: TICK_MAX_FILES - summary.files_read, + deps, + retryFailed: false, + }); + summary.files_read += r.files_read; + if (r.error) summary.errors.push(`${poNumber}: ${r.error}`); + } catch (e) { + summary.errors.push(`${poNumber}: ${errMessage(e)}`); + } + } + } catch (e) { + summary.errors.push(`tick failed: ${errMessage(e)}`); + } + + summary.errors = boundErrors(summary.errors); + summary.finished_at = nowFn().toISOString(); + await recordCollectionAiRun({ summary }); + return { ran: true, summary }; +} + +/** + * PO numbers of cards with work, newest collected line first. A card has + * work when one of its image files has no read row yet, or one of its lines + * has no `ai_check` while the card has a readable document. + */ +async function cardsNeedingWork(pb: AdminPb): Promise { + const orderItems = getOrderItemsCollectionName(); + const [custRows, stockRows] = await Promise.all([ + pb.collection(orderItems).getFullList({ filter: 'item_status = "collected"', sort: "-drv_collected_at" }), + pb + .collection(STOCK_LINES) + .getFullList({ filter: 'status = "collected" && quantity_received < quantity', sort: "-created" }), + ]); + const [custPos, stockPos] = await Promise.all([ + listByField(pb, CUSTOMER_POS, "id", custRows.map((r) => str(r.purchase_order))), + listByField(pb, STOCK_POS, "id", stockRows.map((r) => str(r.purchase))), + ]); + const custPoById = new Map(custPos.map((r) => [r.id, toPoRef("customer", r)])); + const stockPoById = new Map(stockPos.map((r) => [r.id, toPoRef("stock", r)])); + const stockPurchases = new Map(stockPos.map((r) => [r.id, r])); + const lines = [ + ...custRows.map((r) => customerLine(r, custPoById.get(str(r.purchase_order)), orderItems)), + ...stockRows.map((r) => stockLine(r, stockPoById.get(str(r.purchase)))), + ] + .filter((l) => l.poNumber !== "") + .sort((a, b) => b.sortKey.localeCompare(a.sortKey)); + + const byCard = new Map(); + for (const l of lines) byCard.set(l.poNumber, [...(byCard.get(l.poNumber) ?? []), l]); + + const allFiles = filesForLines(lines, stockPurchases); + const reads = await loadReadsForFiles(pb, allFiles); + + const out: string[] = []; + for (const [poNumber, cardLines] of byCard) { + const files = filesForLines(cardLines, stockPurchases); + const unread = files.some((f) => IMAGE_EXT.has(ext(f.filename)) && !reads.has(fileKey(f))); + const hasDocument = files.some((f) => { + const r = reads.get(fileKey(f)); + return r?.status === "read" && (r.doc_type === "tax_invoice" || r.doc_type === "delivery_note"); + }); + const unchecked = hasDocument && cardLines.some((l) => !l.ai_check); + if (unread || unchecked) out.push(poNumber); + } + return out; +} diff --git a/src/lib/server/collection-confirm-service.test.ts b/src/lib/server/collection-confirm-service.test.ts index 55aa3ca..c048cf7 100644 --- a/src/lib/server/collection-confirm-service.test.ts +++ b/src/lib/server/collection-confirm-service.test.ts @@ -1214,3 +1214,190 @@ describe("clearCollectedRows", () => { ).rejects.toMatchObject({ code: "invalid_action" }); }); }); + +describe("listCollectedItems — AI check passthrough", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const check = (read_id: string) => ({ + read_id, + document_no: "5028256", + doc_date: "2026-10-16", + doc_type: "tax_invoice", + doc_line: null, + same_item: null, + reason: "", + sku_filled_value: "", + findings: [], + checked_at: "2026-10-17T08:00:00.000Z", + model: "MiniMax-M3", + }); + + const makeAiAdmin = (opts: { + orderItems?: Record[]; + stockItems?: Record[]; + docReads?: Record[]; + posItems?: Record[]; + }) => { + const docReadsGetFullList = vi.fn().mockResolvedValue(opts.docReads ?? []); + const posGetFullList = vi.fn().mockResolvedValue(opts.posItems ?? []); + const admin = { + collection: vi.fn().mockImplementation((name: string) => { + if (name === "order_items_v2") { + return { getFullList: vi.fn().mockResolvedValue(opts.orderItems ?? []) }; + } + if (name === "stock_purchase_items") { + return { getFullList: vi.fn().mockResolvedValue(opts.stockItems ?? []) }; + } + if (name === "collection_doc_reads") return { getFullList: docReadsGetFullList }; + if (name === "stock_items") return { getFullList: posGetFullList }; + throw new Error(`unexpected collection ${name}`); + }), + }; + 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 { admin, docReadsGetFullList } = makeAiAdmin({ + orderItems: [ + { id: "oi-1", item_status: "collected", sku: "", ai_check: check("r1") }, + { id: "oi-2", item_status: "collected", sku: "", ai_check: check("r1") }, + { id: "oi-3", item_status: "collected", sku: "", ai_check: null }, + ], + docReads: [ + { + id: "r1", + doc_type: "tax_invoice", + document_no: "5028256", + doc_date: "2026-10-16", + supplier_name: "NSS Fasteners", + findings: ["Document 5028256 covers 2 POs: A, B."], + }, + ], + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never); + + const rows = await listCollectedItems(); + const byId = new Map(rows.map((r) => [r.id, r])); + + expect(docReadsGetFullList).toHaveBeenCalledTimes(1); + expect(docReadsGetFullList.mock.calls[0]?.[0]).toMatchObject({ filter: 'id = "r1"' }); + expect(byId.get("oi-1")?.ai_check?.read_id).toBe("r1"); + expect(byId.get("oi-1")?.ai_doc).toEqual({ + read_id: "r1", + doc_type: "tax_invoice", + document_no: "5028256", + doc_date: "2026-10-16", + supplier_name: "NSS Fasteners", + findings: ["Document 5028256 covers 2 POs: A, B."], + }); + expect(byId.get("oi-2")?.ai_doc?.read_id).toBe("r1"); + expect(byId.get("oi-3")?.ai_doc).toBeNull(); + }); + + it("gives rows without ai_check ai_check: null and ai_doc: null", async () => { + const { admin, docReadsGetFullList } = makeAiAdmin({ + orderItems: [{ id: "oi-1", item_status: "collected" }], + stockItems: [ + { id: "spi-1", description: "x", quantity: 2, quantity_received: 1, status: "collected", stock_item: "st-1" }, + ], + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never); + + const rows = await listCollectedItems(); + + expect(rows).toHaveLength(2); + for (const r of rows) { + expect(r.ai_check).toBeNull(); + expect(r.ai_doc).toBeNull(); + } + expect(docReadsGetFullList).not.toHaveBeenCalled(); + }); + + it("passes a stock row's ai_check through", async () => { + const { admin } = makeAiAdmin({ + stockItems: [ + { + id: "spi-1", + description: "x", + quantity: 2, + quantity_received: 1, + status: "collected", + stock_item: "st-1", + ai_check: check("r9"), + }, + ], + docReads: [{ id: "r9", doc_type: "delivery_note", document_no: "DN1", doc_date: "", supplier_name: "", findings: [] }], + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never); + + const [row] = await listCollectedItems(); + + expect(row?.ai_check?.read_id).toBe("r9"); + expect(row?.ai_doc).toMatchObject({ read_id: "r9", doc_type: "delivery_note", document_no: "DN1" }); + }); + + it("sets in_pos and pos_stock_item_id when a stock_items SKU normalises to the row's SKU", async () => { + const { admin, posGetFullList } = makeAiAdmin({ + orderItems: [ + { id: "oi-1", item_status: "collected", sku: "sm293306030" }, + { id: "oi-2", item_status: "collected", sku: "NOPE-1" }, + { id: "oi-3", item_status: "collected", sku: "" }, + ], + posItems: [ + { id: "st-9", sku: "SM-293306030" }, + { id: "st-8", sku: "NOPE-12" }, + ], + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never); + + const rows = await listCollectedItems(); + const byId = new Map(rows.map((r) => [r.id, r])); + + expect(posGetFullList).toHaveBeenCalledTimes(1); + const filter = (posGetFullList.mock.calls[0]?.[0] as { filter?: string }).filter ?? ""; + expect(filter).toContain('sku ~ "sm293306030"'); + expect(filter).toContain('sku ~ "NOPE-1"'); + expect(byId.get("oi-1")).toMatchObject({ in_pos: true, pos_stock_item_id: "st-9" }); + expect(byId.get("oi-2")).toMatchObject({ in_pos: false, pos_stock_item_id: null }); + expect(byId.get("oi-3")).toMatchObject({ in_pos: false, pos_stock_item_id: null }); + }); + + it("marks stock rows in_pos with their catalogue item", async () => { + const { admin, posGetFullList } = makeAiAdmin({ + stockItems: [ + { id: "spi-1", description: "x", quantity: 2, quantity_received: 1, status: "collected", stock_item: "st-1" }, + ], + }); + vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never); + + const [row] = await listCollectedItems(); + + expect(row).toMatchObject({ in_pos: true, pos_stock_item_id: "st-1" }); + expect(posGetFullList).not.toHaveBeenCalled(); + }); + + it("stays fail-soft when the reads or POS lookups fail", async () => { + const admin = { + collection: vi.fn().mockImplementation((name: string) => { + if (name === "order_items_v2") { + return { + getFullList: vi.fn().mockResolvedValue([ + { id: "oi-1", item_status: "collected", sku: "A1", ai_check: check("r1") }, + ]), + }; + } + if (name === "stock_purchase_items") return { getFullList: vi.fn().mockResolvedValue([]) }; + return { getFullList: vi.fn().mockRejectedValue(new Error("down")) }; + }), + }; + vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never); + + const [row] = await listCollectedItems(); + + expect(row?.ai_check?.read_id).toBe("r1"); + expect(row?.ai_doc).toMatchObject({ read_id: "r1", document_no: "5028256", findings: [] }); + expect(row?.in_pos).toBeNull(); + }); +}); diff --git a/src/lib/server/collection-confirm-service.ts b/src/lib/server/collection-confirm-service.ts index e9b2cc1..2f5dd72 100644 --- a/src/lib/server/collection-confirm-service.ts +++ b/src/lib/server/collection-confirm-service.ts @@ -2,6 +2,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 { normaliseCode } from "@/lib/collection-ai-ticks"; import { appendCostNote, @@ -368,6 +369,10 @@ export async function listCollectedItems(): Promise { sku: s.expand?.stock_item?.sku, supplier: s.expand?.purchase?.expand?.supplier?.id ?? undefined, stock_line_status: s.status ?? null, + ai_check: (s as { ai_check?: CollectionAiCheck | null }).ai_check ?? null, + // Stock lines are already tied to a catalogue item. + in_pos: true, + pos_stock_item_id: s.stock_item || null, assigned_at: s.assigned_at ?? null, // Stock rows have no `drv_collected_at`, so the card header uses // this as the "when was this last touched" fallback. @@ -489,9 +494,115 @@ export async function listCollectedItems(): Promise { } } + await attachAiFields(pb, merged); + return merged; } +type AdminPb = Awaited>; + +const AI_LOOKUP_CHUNK = 50; + +function chunked(items: T[], size = AI_LOOKUP_CHUNK): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +} + +function pbQuote(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +/** + * Collection Confirm AI passthrough: `ai_check` as stored, the document + * summary (`ai_doc`, incl. card findings) of the read it names, and the + * POS lookup (`in_pos`). Every lookup is fail-soft — the page must work + * exactly as before when the AI data is missing or PB hiccups. + */ +async function attachAiFields(pb: AdminPb, rows: ConfirmRow[]): Promise { + for (const row of rows) { + row.ai_check = row.ai_check ?? null; + row.ai_doc = null; + } + + // 1. Document reads, batch-loaded by id. + const readIds = [...new Set(rows.flatMap((r) => (r.ai_check?.read_id ? [r.ai_check.read_id] : [])))]; + const readsById = new Map>(); + if (readIds.length > 0) { + try { + for (const ids of chunked(readIds)) { + const found = await pb.collection("collection_doc_reads").getFullList<{ + id: string; + doc_type?: CollectionDocType | ""; + document_no?: string; + doc_date?: string; + supplier_name?: string; + findings?: unknown; + }>({ + filter: ids.map((id) => `id = "${pbQuote(id)}"`).join(" || "), + fields: "id,doc_type,document_no,doc_date,supplier_name,findings", + }); + for (const r of found) { + readsById.set(r.id, { + read_id: r.id, + doc_type: (r.doc_type || "other") as CollectionDocType, + document_no: r.document_no ?? "", + doc_date: r.doc_date ?? "", + supplier_name: r.supplier_name ?? "", + findings: Array.isArray(r.findings) ? (r.findings as string[]) : [], + }); + } + } + } catch { + // Fall back to what ai_check carries (no card findings). + } + } + for (const row of rows) { + const check = row.ai_check; + if (!check) continue; + row.ai_doc = readsById.get(check.read_id) ?? { + read_id: check.read_id, + doc_type: check.doc_type, + document_no: check.document_no, + doc_date: check.doc_date, + supplier_name: "", + findings: [], + }; + } + + // 2. In POS: a stock_items row whose normalised SKU equals the line's. + const customer = rows.filter((r) => r.source !== "stock"); + const skus = [...new Set(customer.map((r) => (r.sku ?? "").trim()).filter((s) => s !== ""))]; + const posByCode = new Map(); + let posFailed = false; + if (skus.length > 0) { + try { + for (const group of chunked(skus)) { + const found = await pb.collection("stock_items").getFullList<{ id: string; sku?: string }>({ + filter: group.map((s) => `sku ~ "${pbQuote(s)}"`).join(" || "), + fields: "id,sku", + }); + for (const item of found) { + const code = normaliseCode(item.sku); + if (code && !posByCode.has(code)) posByCode.set(code, item.id); + } + } + } catch { + posFailed = true; + } + } + for (const row of customer) { + if (posFailed) { + row.in_pos = null; + row.pos_stock_item_id = null; + continue; + } + const id = posByCode.get(normaliseCode(row.sku)) ?? null; + row.in_pos = id !== null && normaliseCode(row.sku) !== ""; + row.pos_stock_item_id = row.in_pos ? id : null; + } +} + /** * Update the cost price on a single collected order_item and stamp a * `cost`-category note onto the row so the audit trail is preserved.