feat(collection-ai): card check, tick and list passthrough
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2af5d089f4
commit
16cf5e2746
5 changed files with 2109 additions and 1 deletions
|
|
@ -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)",
|
||||
],
|
||||
});
|
||||
|
|
|
|||
937
src/lib/server/collection-ai-service.test.ts
Normal file
937
src/lib/server/collection-ai-service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
871
src/lib/server/collection-ai-service.ts
Normal file
871
src/lib/server/collection-ai-service.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue