diff --git a/src/lib/server/collection-ai-findings.test.ts b/src/lib/server/collection-ai-findings.test.ts new file mode 100644 index 0000000..f4b5596 --- /dev/null +++ b/src/lib/server/collection-ai-findings.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect } from "vitest"; +import { buildDocFindings, type PoLineLite } from "./collection-ai-findings"; +import type { CollectionDocLine } from "@/lib/types"; + +const dl = (o: Partial): CollectionDocLine => ({ + line_no: 1, + code: "", + description: "", + quantity: null, + unit: "", + unit_price: null, + line_total: null, + to_follow: null, + price_note: "", + ...o, +}); +const pl = (o: Partial): PoLineLite => ({ + id: "p1", + po_number: "PO202610003CEM", + description: "M8x25 stainless steel", + collected: true, + order_qty: 40, + quantity: 40, + qty_collected: 40, + ...o, +}); +const base = { + resolvedPos: [{ po_number: "PO202610003CEM", supplier_name: "NSS Fasteners" }], + fellBackToPo: null, +}; +const read = (lines: CollectionDocLine[], supplier = "NSS Fasteners") => ({ + document_no: "5028256", + supplier_name: supplier, + lines, +}); + +describe("buildDocFindings", () => { + it("returns nothing for a clean match", () => { + expect( + buildDocFindings({ + ...base, + read: read([dl({ line_no: 1, quantity: 40 })]), + poLines: [pl({})], + pairs: [{ doc_line_no: 1, po_line_id: "p1" }], + }), + ).toEqual([]); + }); + + it("lists multiple POs", () => { + expect( + buildDocFindings({ + ...base, + resolvedPos: [ + { po_number: "PO202610003CEM", supplier_name: "NSS Fasteners" }, + { po_number: "PO202610009CAS", supplier_name: "NSS Fasteners" }, + ], + read: read([]), + poLines: [], + pairs: [], + }), + ).toEqual(["Document 5028256 covers 2 POs: PO202610003CEM, PO202610009CAS."]); + }); + + it("flags paired line not collected", () => { + expect( + buildDocFindings({ + ...base, + read: read([dl({ line_no: 1, quantity: 40 })]), + poLines: [pl({ collected: false, qty_collected: 0 })], + pairs: [{ doc_line_no: 1, po_line_id: "p1" }], + }), + ).toEqual(["M8x25 stainless steel × 40 is on the document but not marked collected."]); + }); + + it("flags document line with no PO line", () => { + expect( + buildDocFindings({ + ...base, + read: read([dl({ line_no: 2, description: "Washer M8" })]), + poLines: [], + pairs: [{ doc_line_no: 2, po_line_id: null }], + }), + ).toEqual(['Document line "Washer M8" doesn\'t match any PO line.']); + }); + + it("flags collected PO line not on the document", () => { + expect( + buildDocFindings({ + ...base, + read: read([]), + poLines: [pl({})], + pairs: [], + }), + ).toEqual(["M8x25 stainless steel was marked collected but isn't on the document."]); + }); + + it("flags supplier mismatch once per distinct PO supplier", () => { + expect( + buildDocFindings({ + ...base, + resolvedPos: [ + { po_number: "A", supplier_name: "Acme Steel" }, + { po_number: "B", supplier_name: "Acme Steel" }, + ], + read: read([], "NSS Fasteners"), + poLines: [], + pairs: [], + }).filter((f) => f.startsWith("Supplier")), + ).toEqual([ + "Supplier on the document (NSS Fasteners) differs from the PO supplier (Acme Steel).", + ]); + }); + + it("skips supplier check when document supplier empty", () => { + expect( + buildDocFindings({ + ...base, + resolvedPos: [{ po_number: "A", supplier_name: "Acme Steel" }], + read: read([], ""), + poLines: [], + pairs: [], + }), + ).toEqual([]); + }); + + it("flags to_follow 0 with outstanding quantity", () => { + expect( + buildDocFindings({ + ...base, + read: read([dl({ line_no: 1, quantity: 30, to_follow: 0 })]), + poLines: [pl({ qty_collected: 30, order_qty: 40 })], + pairs: [{ doc_line_no: 1, po_line_id: "p1" }], + }), + ).toEqual([ + "M8x25 stainless steel: supplier shows nothing to follow, but 10 are still outstanding.", + ]); + }); + + it("flags order_qty vs quantity mismatch", () => { + expect( + buildDocFindings({ + ...base, + read: read([dl({ line_no: 1, quantity: 40 })]), + poLines: [pl({ order_qty: 4, quantity: 40, qty_collected: 40 })], + pairs: [{ doc_line_no: 1, po_line_id: "p1" }], + }), + ).toEqual(["M8x25 stainless steel: order line says 40 but the PO quantity is 4."]); + }); + + it("notes fallback PO", () => { + expect( + buildDocFindings({ + ...base, + fellBackToPo: "PO202610003CEM", + read: read([]), + poLines: [], + pairs: [], + }), + ).toEqual(["No PO number on the document matched; checked against PO202610003CEM."]); + }); + + it("orders findings as specified", () => { + const out = buildDocFindings({ + resolvedPos: [ + { po_number: "PO1", supplier_name: "Acme" }, + { po_number: "PO2", supplier_name: "Acme" }, + ], + fellBackToPo: "PO1", + read: read( + [ + dl({ line_no: 1, quantity: 40, to_follow: 0, description: "A" }), + dl({ line_no: 2, description: "Orphan" }), + ], + "Other Co", + ), + poLines: [ + pl({ id: "p1", description: "Alpha", collected: false, order_qty: 4, quantity: 40, qty_collected: 0 }), + pl({ id: "p2", description: "Beta", collected: true }), + ], + pairs: [ + { doc_line_no: 1, po_line_id: "p1" }, + { doc_line_no: 2, po_line_id: null }, + ], + }); + expect(out.map((s) => s.split(" ")[0])).toEqual([ + "Document", // covers + "Alpha", // not collected + "Document", // no PO line + "Beta", // collected not on doc + "Supplier", + "Alpha:", // to follow + "Alpha:", // qty + "No", + ]); + }); +}); diff --git a/src/lib/server/collection-ai-findings.ts b/src/lib/server/collection-ai-findings.ts new file mode 100644 index 0000000..06af087 --- /dev/null +++ b/src/lib/server/collection-ai-findings.ts @@ -0,0 +1,115 @@ +import type { CollectionDocRead } from "@/lib/types"; +import { supplierNamesMatch } from "@/lib/server/stock-purchase-invoice-matching-service"; + +export type PoLineLite = { + id: string; + po_number: string; + description: string; + collected: boolean; + order_qty: number; + quantity: number; + qty_collected: number; +}; + +export type LinePair = { doc_line_no: number; po_line_id: string | null }; + +/** 40 not 40.0; other values as-is. */ +function fmt(n: number): string { + return String(n); +} + +/** Ordered = order_qty, falling back to quantity when order_qty is 0. */ +function ordered(l: PoLineLite): number { + return l.order_qty ? l.order_qty : l.quantity; +} + +/** + * Plain-English findings for one supplier document, in a fixed order: + * PO count, not-collected, unmatched doc lines, collected-but-absent, + * supplier, to-follow, qty disagreement, PO fallback. + */ +export function buildDocFindings(input: { + read: Pick; + resolvedPos: Array<{ po_number: string; supplier_name: string }>; + fellBackToPo: string | null; + poLines: PoLineLite[]; + pairs: LinePair[]; +}): string[] { + const { read, resolvedPos, fellBackToPo, poLines, pairs } = input; + const out: string[] = []; + const byId = new Map(poLines.map((l) => [l.id, l])); + const pairByDocLine = new Map(); + for (const p of pairs) if (!pairByDocLine.has(p.doc_line_no)) pairByDocLine.set(p.doc_line_no, p); + + // Resolve each document line to its PO line (if any). + const resolved = read.lines.map((doc) => { + const pair = pairByDocLine.get(doc.line_no); + const po = pair?.po_line_id ? byId.get(pair.po_line_id) : undefined; + return { doc, po }; + }); + + if (resolvedPos.length >= 2) { + out.push( + `Document ${read.document_no} covers ${resolvedPos.length} POs: ${resolvedPos + .map((p) => p.po_number) + .join(", ")}.`, + ); + } + + for (const { doc, po } of resolved) { + if (po && !po.collected) { + out.push( + `${po.description} × ${doc.quantity == null ? "?" : fmt(doc.quantity)} is on the document but not marked collected.`, + ); + } + } + + for (const { doc, po } of resolved) { + if (!po) out.push(`Document line "${doc.description}" doesn't match any PO line.`); + } + + const pairedIds = new Set(resolved.flatMap((r) => (r.po ? [r.po.id] : []))); + for (const l of poLines) { + if (l.collected && !pairedIds.has(l.id)) { + out.push(`${l.description} was marked collected but isn't on the document.`); + } + } + + if (read.supplier_name) { + 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)) { + seen.add(p.supplier_name); + out.push( + `Supplier on the document (${read.supplier_name}) differs from the PO supplier (${p.supplier_name}).`, + ); + } + } + } + + for (const { doc, po } of resolved) { + if (po && doc.to_follow === 0) { + const outstanding = ordered(po) - po.qty_collected; + if (outstanding > 0) { + out.push( + `${po.description}: supplier shows nothing to follow, but ${fmt(outstanding)} are still outstanding.`, + ); + } + } + } + + for (const { po } of resolved) { + if (po && po.order_qty > 0 && po.quantity > 0 && po.order_qty !== po.quantity) { + out.push( + `${po.description}: order line says ${fmt(po.quantity)} but the PO quantity is ${fmt(po.order_qty)}.`, + ); + } + } + + if (fellBackToPo) { + out.push(`No PO number on the document matched; checked against ${fellBackToPo}.`); + } + + return out; +}