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:
parent
79d54ce3fd
commit
a4345c1f85
8 changed files with 170 additions and 10 deletions
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}).`,
|
||||
|
|
|
|||
|
|
@ -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.`;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue