fix(collection-ai): day-first dates, only fill SKUs read with certainty, tolerant supplier names

- Dates: the model also returns the date as printed; code parses SA
  day-first formats (16-10-26 -> 2026-10-16) and drops dates more than a
  year old or a month ahead (the Bestobell read gave 2016-10-26).
- SKU: a new code_certain flag per line; the AI only fills an empty SKU
  when it read every character clearly (it filled 0515315 for 051315).
- Supplier: names sharing a distinctive word (Bestobell, Belting) no
  longer raise a 'supplier differs' finding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-17 16:36:58 +02:00
parent 79d54ce3fd
commit a4345c1f85
8 changed files with 170 additions and 10 deletions

View file

@ -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>): 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([]);
});
});

View file

@ -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<string> {
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<string>();
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}).`,

View file

@ -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.`;

View file

@ -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",

View file

@ -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<DocumentReadResult, "model"> {
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<DocumentReadResult, "model"> {
const r = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
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<string, unknown>;
@ -105,12 +148,13 @@ export function parseDocumentRead(raw: unknown): Omit<DocumentReadResult, "model
line_total: numOrNull(l.line_total),
to_follow: numOrNull(l.to_follow),
price_note: str(l.price_note),
code_certain: l.code_certain === true,
};
});
return {
doc_type: docType as CollectionDocType,
document_no: str(r.document_no),
doc_date: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : "",
doc_date: date,
supplier_name: str(r.supplier_name),
po_refs: strList(r.po_refs),
lines,
@ -167,5 +211,5 @@ export async function readDocumentImage(
if (!toolBlock || toolBlock.type !== "tool_use") {
throw new Error("AI returned no submit_document_read tool call");
}
return { ...parseDocumentRead(toolBlock.input), model: aiConfig.modelId };
return { ...parseDocumentRead(toolBlock.input, new Date()), model: aiConfig.modelId };
}

View file

@ -290,6 +290,7 @@ function docLine(over: Partial<CollectionDocLine>): 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" };

View file

@ -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") {

View file

@ -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 {