feat(collection-ai): document findings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
084e0951c7
commit
122eb9862e
2 changed files with 311 additions and 0 deletions
196
src/lib/server/collection-ai-findings.test.ts
Normal file
196
src/lib/server/collection-ai-findings.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
115
src/lib/server/collection-ai-findings.ts
Normal file
115
src/lib/server/collection-ai-findings.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue