diff --git a/src/lib/server/collection-ai-findings.test.ts b/src/lib/server/collection-ai-findings.test.ts index ea326e1..5f85c7c 100644 --- a/src/lib/server/collection-ai-findings.test.ts +++ b/src/lib/server/collection-ai-findings.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildDocFindings, type PoLineLite } from "./collection-ai-findings"; +import { buildDocFindings, supplierNamesLookAlike, type PoLineLite } from "./collection-ai-findings"; import type { CollectionDocLine } from "@/lib/types"; const dl = (o: Partial): CollectionDocLine => ({ @@ -246,3 +246,32 @@ describe("buildDocFindings", () => { ).toEqual(["M8x25 stainless steel × ? is on the document but not marked collected."]); }); }); + +describe("supplierNamesLookAlike", () => { + it.each([ + ["Belting Supply Services / BEP Bestobell South Africa", "Bestobell / Belting Supplies.,"], + ["Belting Supply Services (BSS) / BEP Bestobell", "Bestobell / Belting Supplies.,"], + ["NSS Fasteners (Pty) Ltd", "NSS Fasteners"], + ])("treats %s and %s as the same supplier", (a, b) => { + expect(supplierNamesLookAlike(a, b)).toBe(true); + }); + + it.each([ + ["NSS Fasteners", "Acme Steel"], + ["Other Co", "Acme"], + ["Belting Supply Services", "Bearing Supply Services"], + ])("keeps %s and %s apart", (a, b) => { + expect(supplierNamesLookAlike(a, b)).toBe(false); + }); + + it("does not report the Bestobell names as a supplier mismatch", () => { + const out = buildDocFindings({ + resolvedPos: [{ po_number: "PO202610010MRE", supplier_name: "Bestobell / Belting Supplies.," }], + read: { document_no: "10245084", supplier_name: "Belting Supply Services / BEP Bestobell South Africa", lines: [] }, + fellBackToPo: null, + poLines: [], + pairs: [], + }); + expect(out.filter((f) => f.startsWith("Supplier"))).toEqual([]); + }); +}); diff --git a/src/lib/server/collection-ai-findings.ts b/src/lib/server/collection-ai-findings.ts index 2863f9d..bcb7132 100644 --- a/src/lib/server/collection-ai-findings.ts +++ b/src/lib/server/collection-ai-findings.ts @@ -1,6 +1,35 @@ import type { CollectionDocRead } from "@/lib/types"; import { supplierNamesMatch } from "@/lib/server/stock-purchase-invoice-matching-service"; +/** Words that appear in many supplier names and so prove nothing on their own. */ +const GENERIC_NAME_WORDS = new Set([ + "supply", "supplies", "supplier", "suppliers", "service", "services", "south", "africa", "african", + "group", "holdings", "trading", "industrial", "industries", "sales", "distributors", "distribution", + "mining", "engineering", "the", "and", "pty", "ltd", "company", +]); + +function distinctiveWords(name: string): Set { + return new Set( + name + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((w) => w.length >= 4 && !GENERIC_NAME_WORDS.has(w)), + ); +} + +/** + * Invoices print a supplier's trading or group name ("Belting Supply + * Services / BEP Bestobell") while our PO uses another ("Bestobell / + * Belting Supplies.,"). Same supplier if the strict match passes or the + * names share a distinctive word. + */ +export function supplierNamesLookAlike(a: string, b: string): boolean { + if (supplierNamesMatch(a, b)) return true; + const wa = distinctiveWords(a); + for (const w of distinctiveWords(b)) if (wa.has(w)) return true; + return false; +} + export type PoLineLite = { id: string; po_number: string; @@ -84,7 +113,7 @@ export function buildDocFindings(input: { const seen = new Set(); for (const p of resolvedPos) { if (!p.supplier_name || seen.has(p.supplier_name)) continue; - if (!supplierNamesMatch(read.supplier_name, p.supplier_name)) { + if (!supplierNamesLookAlike(read.supplier_name, p.supplier_name)) { seen.add(p.supplier_name); out.push( `Supplier on the document (${read.supplier_name}) differs from the PO supplier (${p.supplier_name}).`, diff --git a/src/lib/server/collection-ai-prompt.ts b/src/lib/server/collection-ai-prompt.ts index 6e80162..f280b7a 100644 --- a/src/lib/server/collection-ai-prompt.ts +++ b/src/lib/server/collection-ai-prompt.ts @@ -6,9 +6,9 @@ export const DEFAULT_COLLECTION_AI_PROMPT = `You read photos of South African su Rules: 1. doc_type: "tax_invoice", "delivery_note", or "other" (a photo of goods, not paperwork). -2. document_no: the invoice or delivery-note number printed as the document's own number (not account or VAT numbers). date: the document date as YYYY-MM-DD. +2. document_no: the invoice or delivery-note number printed as the document's own number (not account or VAT numbers). date_as_printed: the document date exactly as printed; doc_date: the same date as YYYY-MM-DD. South African dates are day-first: "16-10-26" is 16 October 2026. 3. po_refs: every customer order / PO number printed (often under "Customer PO", "Customer Order No" or "Your Ref"). Split values like "PO202610003CEM/PO202610009CAS" into separate entries. -4. lines: one entry per item line. code = the supplier's stock code; description as printed; quantity = quantity supplied; unit as printed. +4. lines: one entry per item line. code = the supplier's stock code; description as printed; quantity = quantity supplied; unit as printed. code_certain = true only if you can read every character of the code clearly; if any digit or letter is a guess, set false. 5. unit_price must be the price for ONE unit. If the document prices per 100 (or per 1000, per box of N), divide accordingly and say so in price_note (e.g. "printed 33.00 per 100"). 6. to_follow: the "to follow" / back-order quantity if printed, else null. 7. Never guess a number you cannot read clearly: use null. The photo may be rotated or partly covered.`; diff --git a/src/lib/server/collection-ai-read.test.ts b/src/lib/server/collection-ai-read.test.ts index 88ff2b9..42f1029 100644 --- a/src/lib/server/collection-ai-read.test.ts +++ b/src/lib/server/collection-ai-read.test.ts @@ -83,6 +83,39 @@ describe("parseDocumentRead", () => { expect(r.notes).toEqual(["Rotated photo"]); }); + describe("dates", () => { + const now = new Date("2026-10-17T12:00:00Z"); + it.each([ + ["16-10-26", "2026-10-16"], + ["16/10/26", "2026-10-16"], + ["16.10.2026", "2026-10-16"], + ["2026/10/16", "2026-10-16"], + ["2026-10-16", "2026-10-16"], + ["5/9/26", "2026-09-05"], + ])("reads the printed date %s as %s (day-month-year)", (printed, iso) => { + expect(parseDocumentRead({ date_as_printed: printed, doc_date: "2016-10-26" }, now).doc_date).toBe(iso); + }); + it("drops a date more than a year old or more than a month ahead", () => { + expect(parseDocumentRead({ doc_date: "2016-10-26" }, now).doc_date).toBe(""); + expect(parseDocumentRead({ doc_date: "2026-12-30" }, now).doc_date).toBe(""); + expect(parseDocumentRead({ doc_date: "2026-10-16" }, now).doc_date).toBe("2026-10-16"); + }); + it("drops an impossible calendar date", () => { + expect(parseDocumentRead({ date_as_printed: "31-02-26" }, now).doc_date).toBe(""); + }); + }); + + it("marks a code certain only when the model says so explicitly", () => { + const r = parseDocumentRead({ + lines: [ + { code: "A1", description: "a", quantity: 1, unit: "", unit_price: 1, code_certain: true }, + { code: "B2", description: "b", quantity: 1, unit: "", unit_price: 1, code_certain: "yes" }, + { code: "C3", description: "c", quantity: 1, unit: "", unit_price: 1 }, + ], + }); + expect(r.lines.map((l) => l.code_certain)).toEqual([true, false, false]); + }); + it("is defensive about bad input", () => { const r = parseDocumentRead({ doc_type: "receipt", diff --git a/src/lib/server/collection-ai-read.ts b/src/lib/server/collection-ai-read.ts index 21f1595..b7c76f0 100644 --- a/src/lib/server/collection-ai-read.ts +++ b/src/lib/server/collection-ai-read.ts @@ -26,6 +26,10 @@ export const SUBMIT_DOCUMENT_READ_TOOL = { }, document_no: { type: "string", description: "The document's own invoice / delivery-note number." }, doc_date: { type: "string", description: "Document date as YYYY-MM-DD, or empty if unreadable." }, + date_as_printed: { + type: "string", + description: "The document date exactly as printed (e.g. \"16-10-26\" or \"2026/10/16\"), or empty.", + }, supplier_name: { type: "string", description: "Supplier name printed on the document." }, po_refs: { type: "array", @@ -40,6 +44,10 @@ export const SUBMIT_DOCUMENT_READ_TOOL = { properties: { line_no: { type: "number", description: "1-based position on the document." }, code: { type: "string", description: "Supplier stock code." }, + code_certain: { + type: "boolean", + description: "true only if every character of the code is clearly legible; false if any character is a guess.", + }, description: { type: "string" }, quantity: { type: ["number", "null"], description: "Quantity supplied." }, unit: { type: "string" }, @@ -85,11 +93,46 @@ function strList(v: unknown): string[] { return v.map(str).filter((s) => s !== ""); } -/** Defensive parse of the tool input; never throws. */ -export function parseDocumentRead(raw: unknown): Omit { +const DAY_MS = 86_400_000; + +function isoIfValid(y: number, m: number, d: number): string { + const dt = new Date(Date.UTC(y, m - 1, d)); + if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== d) return ""; + return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`; +} + +/** + * South African documents print dates day-first ("16-10-26", "16/10/2026") + * or year-first ("2026/10/16"). Two-digit years are 20YY. + */ +export function parsePrintedDate(printed: string): string { + const s = printed.trim(); + let m = s.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/); + if (m) return isoIfValid(+m[1], +m[2], +m[3]); + m = s.match(/^(\d{1,2})[-/.](\d{1,2})[-/.](\d{2}|\d{4})$/); + if (m) return isoIfValid(m[3].length === 2 ? 2000 + +m[3] : +m[3], +m[2], +m[1]); + return ""; +} + +/** A collection document is dated within the last year and not more than a month ahead. */ +function plausibleDate(iso: string, now: Date): string { + if (!iso) return ""; + const t = Date.parse(`${iso}T00:00:00Z`); + if (Number.isNaN(t)) return ""; + return t >= now.getTime() - 400 * DAY_MS && t <= now.getTime() + 31 * DAY_MS ? iso : ""; +} + +/** + * Defensive parse of the tool input; never throws. Pass `now` to drop + * implausible dates (production always does). + */ +export function parseDocumentRead(raw: unknown, now?: Date): Omit { const r = (raw && typeof raw === "object" ? raw : {}) as Record; const docType = typeof r.doc_type === "string" && DOC_TYPES.includes(r.doc_type) ? r.doc_type : "other"; - const date = str(r.doc_date); + const isoDate = str(r.doc_date); + let date = parsePrintedDate(str(r.date_as_printed)); + if (!date && /^\d{4}-\d{2}-\d{2}$/.test(isoDate)) date = isoIfValid(+isoDate.slice(0, 4), +isoDate.slice(5, 7), +isoDate.slice(8, 10)); + if (now) date = plausibleDate(date, now); const rawLines = Array.isArray(r.lines) ? r.lines : []; const lines: CollectionDocLine[] = rawLines.map((item, i) => { const l = (item && typeof item === "object" ? item : {}) as Record; @@ -105,12 +148,13 @@ export function parseDocumentRead(raw: unknown): Omit): CollectionDocLine { line_total: null, to_follow: null, price_note: "", + code_certain: true, ...over, }; } @@ -727,6 +728,25 @@ describe("checkCollectionCard", () => { expect(r.skus_filled).toBe(1); }); + it("does not fill a SKU the AI couldn't read with certainty, even on a 'sure' pairing", async () => { + seedNss(); + const { deps } = makeDeps({ + readDocument: vi.fn(async () => + nssRead({ + lines: [docLine({ line_no: 1, code: "0515315", description: "M20X60 HEX BOLT 8.8", quantity: 4, unit_price: 14.51, code_certain: false })], + }), + ), + }); + + const r = await checkCollectionCard(PO_CEM, { deps }); + + expect(skuCalls).toEqual([]); + expect(table("order_items").get("l1")?.sku).toBe(""); + expect(aiCheck("order_items", "l1")?.sku_filled_value).toBe(""); + expect(aiCheck("order_items", "l1")?.doc_line?.code).toBe("0515315"); + expect(r.skus_filled).toBe(0); + }); + it("never changes an existing SKU, even on a 'sure' pairing", async () => { seedNss(); pairPlan[3] = { id: "l3", same: "sure" }; diff --git a/src/lib/server/collection-ai-service.ts b/src/lib/server/collection-ai-service.ts index 92e54ae..65aa527 100644 --- a/src/lib/server/collection-ai-service.ts +++ b/src/lib/server/collection-ai-service.ts @@ -828,7 +828,10 @@ async function checkCard( 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") { + // Only a code the AI read character-for-character may become a SKU: + // a sure pairing with a misread digit would still be a wrong SKU. + const codeCertain = chosen.docLine?.code_certain === true; + if (!ownSku && code && codeCertain && code.length <= MAX_SKU_LENGTH && chosen.decision?.same_item === "sure") { // A failed fill must not cost the line its check. try { if (line.kind === "customer") { diff --git a/src/lib/types.ts b/src/lib/types.ts index 8e545c9..afce876 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -566,6 +566,8 @@ export interface CollectionDocLine { line_total: number | null; to_follow: number | null; price_note: string; + /** The AI read every character of `code` clearly. Only then may it fill a SKU. */ + code_certain?: boolean; } export interface CollectionDocRead {