feat(collection-ai): document findings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-17 01:35:45 +02:00
parent 084e0951c7
commit 122eb9862e
2 changed files with 311 additions and 0 deletions

View file

@ -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>): 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>): 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",
]);
});
});

View file

@ -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<CollectionDocRead, "document_no" | "supplier_name" | "lines">;
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<number, LinePair>();
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<string>();
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;
}