feat(collection-ai): card check, tick and list passthrough

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-17 02:14:53 +02:00
parent 2af5d089f4
commit 16cf5e2746
5 changed files with 2109 additions and 1 deletions

View file

@ -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)",
],
});

View file

@ -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<string, unknown> & { id: string };
type Actor = { name?: string; source?: string; ignoreRequestUser?: boolean };
const db = new Map<string, Map<string, Row>>();
const writes: { op: string; coll: string; id: string; data: Record<string, unknown>; 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<string, Row> {
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<string, unknown>) => {
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<string, unknown>) => {
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<unknown>) => {
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<string, unknown>; audit: Actor | null }[] = [];
vi.mock("@/lib/server/stock-purchase-items-service", () => ({
updateStockPurchaseItem: async (input: { id: string; patch: Record<string, unknown> }) => {
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<typeof import("@/lib/server/ai-settings-service")>()),
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>): 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> = {}): 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<string, unknown> = {}) {
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<string, unknown> = {}) {
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<CollectionDocRead> & { 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<number, { id: string; same: "sure" | "likely" | "different" }> = {};
function makeDeps(over: Partial<CollectionAiDeps> = {}) {
const contents: Record<string, string> = {};
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<number, PairDecision>();
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);
});
});

View file

@ -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<ReturnType<typeof getAdminPocketBase>>;
type Rec = Record<string, unknown> & { 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<T>(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<Rec[]> {
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<Rec>({
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<boolean> {
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<Line[]> {
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<string, Rec>): FileRef[] {
const out = new Map<string, FileRef>();
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 = <T>(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<string>(r.po_refs),
lines: arr<CollectionDocLine>(r.lines),
notes: arr<string>(r.notes),
findings: arr<string>(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<Map<string, CollectionDocRead>> {
const out = new Map<string, CollectionDocRead>();
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<Rec>({ 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<CollectionDocRead | null> {
const page = await pb.collection(READS).getList<Rec>(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<CollectionDocRead, "id">;
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<string, PoRef[]> };
async function loadPoIndex(pb: AdminPb): Promise<PoIndex> {
const byNorm = new Map<string, PoRef[]>();
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<Rec>(1, 500, { sort: "-created" }),
pb.collection(STOCK_POS).getList<Rec>(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<string, PoRef>();
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<Map<string, string>> {
const out = new Map<string, string>();
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<CardCheckResult> {
return checkCard(poNumber, { ...opts, retryFailed: true });
}
async function checkCard(
poNumber: string,
opts: { actorName?: string; maxFiles?: number; deps?: CollectionAiDeps; retryFailed: boolean },
): Promise<CardCheckResult> {
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<Rec>({ filter: `po_number = "${q(poNumber)}"` }),
pb.collection(STOCK_POS).getFullList<Rec>({ 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<string, ReadFields>();
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<ReturnType<NonNullable<CollectionAiDeps["fetchFile"]>>>;
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<CollectionDocRead> = { ...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<Rec>(replaceId, fields)
: await pb.collection(READS).create<Rec>(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<string, CollectionDocRead>();
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<string, Candidate[]>();
const lineDocs = new Map<string, CollectionDocRead[]>();
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<number, PairDecision>; 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<Candidate | null>(
(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<Rec>(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<string[]> {
const orderItems = getOrderItemsCollectionName();
const [custRows, stockRows] = await Promise.all([
pb.collection(orderItems).getFullList<Rec>({ filter: 'item_status = "collected"', sort: "-drv_collected_at" }),
pb
.collection(STOCK_LINES)
.getFullList<Rec>({ 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<string, Line[]>();
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;
}

View file

@ -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<string, unknown>[];
stockItems?: Record<string, unknown>[];
docReads?: Record<string, unknown>[];
posItems?: Record<string, unknown>[];
}) => {
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();
});
});

View file

@ -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<ConfirmRow[]> {
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<ConfirmRow[]> {
}
}
await attachAiFields(pb, merged);
return merged;
}
type AdminPb = Awaited<ReturnType<typeof getAdminPocketBase>>;
const AI_LOOKUP_CHUNK = 50;
function chunked<T>(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<void> {
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<string, NonNullable<ConfirmRow["ai_doc"]>>();
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<string, string>();
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.