WFDASH-WEBAPP/src/lib/server/collection-ai-read.test.ts
Dave d73fb6f0ab feat(collection-ai): read invoice discounts and set the discounted cost
HYCO (I009753) prints full prices on the lines and "Discount 25.00%" in
the totals; Swagelok (622831) has a "Disc %" column on each line. The AI
compared our cost (already the discounted price) with the printed price,
so the Price tick went red on both.

- The read now captures a line's discount_percent and a whole-document
  doc_discount_percent (prompt rule 5b) and stores net_unit_price on each
  line (both discounts stacked).
- The Price tick compares the cost with the price after discount; the
  tooltip and the document-lines panel spell it out
  ("R15.10 less 25% invoice discount = R11.33").
- Owner's call: when a "sure" tax-invoice line has a discount and our
  cost isn't the discounted price, the AI sets the cost to it (customer
  lines through updateOrderItemCost, which adds a cost note; stock lines
  through updateStockPurchaseItem) and records why in ai_check.cost_changed,
  shown in purple under the line. No discount, a delivery note or a
  "likely" match: the cost is never touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:18:21 +02:00

224 lines
9.6 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const getAiSettings = vi.fn();
vi.mock("@/lib/server/ai-settings-service", () => ({
getActiveAiConfig: vi.fn(async () => ({
modelId: "MiniMax-M3",
maxTokens: 2000,
apiKey: "k",
baseUrl: "u",
provider: "minimax",
})),
getAiSettings: (...a: unknown[]) => getAiSettings(...a),
}));
import {
parseDocumentRead,
readDocumentImage,
SUBMIT_DOCUMENT_READ_TOOL,
} from "./collection-ai-read";
import { DEFAULT_COLLECTION_AI_PROMPT } from "./collection-ai-prompt";
const nssInput = {
doc_type: "tax_invoice",
document_no: "INV12345",
doc_date: "2026-10-14",
supplier_name: "NSS Fasteners",
po_refs: ["PO202610003CEM", "PO202610009CAS"],
lines: [
{ code: "A1", description: "Bolt", quantity: 100, unit: "ea", unit_price: 0.95, line_total: 95, to_follow: null, price_note: "" },
{ code: "A2", description: "Nut", quantity: 100, unit: "ea", unit_price: 0.33, line_total: 33, to_follow: null, price_note: "printed 33.00 per 100" },
{ code: "A3", description: "Washer", quantity: 50, unit: "ea", unit_price: 0.2, line_total: 10, to_follow: 5, price_note: "" },
{ code: "A4", description: "Anchor", quantity: 2, unit: "ea", unit_price: 14.51, line_total: 29.02, to_follow: null, price_note: "" },
],
notes: ["Rotated photo"],
};
function fakeClient(content: unknown[], stop_reason = "tool_use") {
const create = vi.fn<(args: unknown) => Promise<{ content: unknown[]; stop_reason: string }>>(
async () => ({ content, stop_reason }),
);
return { client: { messages: { create } } as never, create };
}
const toolUse = (input: unknown) => ({ type: "tool_use", id: "t1", name: "submit_document_read", input });
const img = { base64: "AAAA", contentType: "image/jpeg" };
type Args = {
tool_choice: unknown;
tools: unknown;
system: string;
max_tokens: number;
messages: Array<{ content: Array<{ type: string; source?: unknown }> }>;
};
beforeEach(() => {
getAiSettings.mockReset();
getAiSettings.mockResolvedValue({ collection_ai_prompt: "" });
});
describe("parseDocumentRead", () => {
it("renumbers lines 1..n, ignoring the model's line_no (duplicates)", () => {
const r = parseDocumentRead({
lines: [
{ line_no: 1, code: "A", description: "a", quantity: 1, unit: "ea", unit_price: 1 },
{ line_no: 1, code: "B", description: "b", quantity: 2, unit: "ea", unit_price: 2 },
{ line_no: 7, code: "C", description: "c", quantity: 3, unit: "ea", unit_price: 3 },
],
});
expect(r.lines.map((l) => [l.line_no, l.code])).toEqual([
[1, "A"],
[2, "B"],
[3, "C"],
]);
});
it("returns the NSS fixture unchanged and numbers lines 1..n", () => {
const r = parseDocumentRead(nssInput);
expect(r.po_refs).toEqual(["PO202610003CEM", "PO202610009CAS"]);
expect(r.lines.map((l) => l.unit_price)).toEqual([0.95, 0.33, 0.2, 14.51]);
expect(r.lines.map((l) => l.line_no)).toEqual([1, 2, 3, 4]);
expect(r.lines[2].to_follow).toBe(5);
expect(r.doc_type).toBe("tax_invoice");
expect(r.document_no).toBe("INV12345");
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]);
});
describe("discounts", () => {
it("takes a line's Disc % off its unit price (Swagelok 622831)", () => {
const r = parseDocumentRead({
lines: [{ code: "SS-4-HLN-3.00", description: "nipple", quantity: 30, unit: "EA", unit_price: 558.62, discount_percent: 5, line_total: 15920.67 }],
});
expect(r.lines[0]).toMatchObject({ unit_price: 558.62, discount_percent: 5, doc_discount_percent: null });
expect(r.lines[0].net_unit_price).toBeCloseTo(530.689, 3);
});
it("applies a discount printed only in the invoice totals to every line (HYCO I009753)", () => {
const r = parseDocumentRead({
doc_discount_percent: 25,
lines: [
{ code: "H1", description: "str", quantity: 500, unit: "EA", unit_price: 7.6 },
{ code: "H2", description: "45", quantity: 500, unit: "EA", unit_price: 15.1 },
],
});
expect(r.lines.map((l) => l.doc_discount_percent)).toEqual([25, 25]);
expect(r.lines[0].net_unit_price).toBeCloseTo(5.7, 4);
expect(r.lines[1].net_unit_price).toBeCloseTo(11.325, 4);
});
it("stacks a line discount and an invoice discount", () => {
const r = parseDocumentRead({
doc_discount_percent: 10,
lines: [{ code: "A", description: "a", quantity: 1, unit: "", unit_price: 100, discount_percent: 10 }],
});
expect(r.lines[0].net_unit_price).toBeCloseTo(81, 4);
});
it("has no net price without a discount, and ignores impossible percentages", () => {
const r = parseDocumentRead({
doc_discount_percent: 0,
lines: [
{ code: "A", description: "a", quantity: 1, unit: "", unit_price: 10 },
{ code: "B", description: "b", quantity: 1, unit: "", unit_price: 10, discount_percent: 100 },
{ code: "C", description: "c", quantity: 1, unit: "", unit_price: 10, discount_percent: -5 },
{ code: "D", description: "d", quantity: 1, unit: "", unit_price: null, discount_percent: 5 },
],
});
expect(r.lines.map((l) => l.net_unit_price)).toEqual([null, null, null, null]);
expect(r.lines.map((l) => l.discount_percent)).toEqual([null, null, null, 5]);
expect(r.lines[0].doc_discount_percent).toBeNull();
});
});
it("is defensive about bad input", () => {
const r = parseDocumentRead({
doc_type: "receipt",
document_no: " X1 ",
doc_date: "14/10/2026",
lines: [{ code: " c ", description: " d ", quantity: "lots", unit_price: "abc", unit: " ea " }],
});
expect(r.doc_type).toBe("other");
expect(r.document_no).toBe("X1");
expect(r.doc_date).toBe("");
expect(r.lines[0]).toMatchObject({ code: "c", description: "d", unit: "ea", quantity: null, unit_price: null, line_no: 1 });
expect(parseDocumentRead({}).lines).toEqual([]);
expect(parseDocumentRead(null).doc_type).toBe("other");
});
});
describe("readDocumentImage", () => {
it("sends one image block plus text, forces the tool, uses the default prompt", async () => {
const { client, create } = fakeClient([toolUse(nssInput)]);
const res = await readDocumentImage(img, { client });
const args = create.mock.calls[0][0] as Args;
expect(args.tool_choice).toEqual({ type: "tool", name: "submit_document_read" });
expect(args.tools).toEqual([SUBMIT_DOCUMENT_READ_TOOL]);
expect(args.system).toBe(DEFAULT_COLLECTION_AI_PROMPT);
expect(args.max_tokens).toBe(4096);
const content = args.messages[0].content;
expect(content.filter((b) => b.type === "image")).toHaveLength(1);
expect(content[0].source).toEqual({ type: "base64", media_type: "image/jpeg", data: "AAAA" });
expect(content.some((b) => b.type === "text")).toBe(true);
expect(res.model).toBe("MiniMax-M3");
expect(res.lines).toHaveLength(4);
});
it("uses a non-blank override prompt", async () => {
getAiSettings.mockResolvedValue({ collection_ai_prompt: "Custom" });
const { client, create } = fakeClient([toolUse(nssInput)]);
await readDocumentImage(img, { client });
expect((create.mock.calls[0][0] as { system: string }).system).toBe("Custom");
getAiSettings.mockResolvedValue({ collection_ai_prompt: " \n" });
await readDocumentImage(img, { client });
expect((create.mock.calls[1][0] as { system: string }).system).toBe(DEFAULT_COLLECTION_AI_PROMPT);
});
it("rejects unsupported image types before calling the AI", async () => {
const { client, create } = fakeClient([toolUse(nssInput)]);
await expect(readDocumentImage({ base64: "x", contentType: "application/pdf" }, { client })).rejects.toThrow(
"Unsupported image type: application/pdf",
);
expect(create).not.toHaveBeenCalled();
});
it("throws on max_tokens and on a missing tool call", async () => {
await expect(
readDocumentImage(img, { client: fakeClient([toolUse(nssInput)], "max_tokens").client }),
).rejects.toThrow("AI response was cut off (max_tokens)");
await expect(
readDocumentImage(img, { client: fakeClient([{ type: "text", text: "no" }]).client }),
).rejects.toThrow("AI returned no submit_document_read tool call");
});
});