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>
224 lines
9.6 KiB
TypeScript
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");
|
|
});
|
|
});
|