WFDASH-WEBAPP/src/lib/collection-ai-ticks.test.ts
Dave 802565d2d8 feat(collection-confirm): check printed prices on delivery notes; show AI reading notes
Plumblink hands out delivery notes with prices on them. The Price tick
was always grey on a delivery note, so a card could go purple without
its prices ever being checked, and the AI's reading notes ("delivery
note, not a tax invoice", "year not printed") were never shown.

- Price tick: compare whenever the document line has a price, invoice
  or delivery note; grey only when no price is printed. The AI still
  only corrects a cost from a tax invoice.
- ai_doc now carries the read's notes; cardNotes() unions them per card
  and the AI findings box lists them under "Reading notes" (the box now
  shows when there are notes but no findings).

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

233 lines
9 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
computeLineTicks,
docUnitCost,
docPriceLabel,
roundCents,
cardAiState,
allGreen,
showsAiFilledSku,
normaliseCode,
effectiveSku,
type TickRow,
} from "./collection-ai-ticks";
import type { CollectionAiCheck, CollectionDocLine } from "@/lib/types";
const line = (o: Partial<CollectionDocLine> = {}): CollectionDocLine => ({
line_no: 1,
code: "SM293306016",
description: "x",
quantity: 40,
unit: "ea",
unit_price: 0.2,
line_total: null,
to_follow: null,
price_note: "",
...o,
});
const check = (o: Partial<CollectionAiCheck> = {}): CollectionAiCheck => ({
read_id: "r1",
document_no: "D1",
doc_date: "",
doc_type: "tax_invoice",
doc_line: line(),
same_item: "sure",
reason: "",
sku_filled_value: "",
findings: [],
checked_at: "",
model: "",
...o,
});
const row = (o: Partial<TickRow> = {}): TickRow => ({
sku: "SM293306016",
qty_collected: 40,
cost_price: 0.2,
order_qty: 40,
...o,
});
describe("normaliseCode", () => {
it("uppercases and strips non-alphanumerics", () => {
expect(normaliseCode(" sm-29/33 ")).toBe("SM2933");
expect(normaliseCode(null)).toBe("");
});
});
describe("computeLineTicks", () => {
it("no pairing gives all red", () => {
expect(computeLineTicks(row(), check({ doc_line: null }))).toEqual({
sku: "red", qty: "red", desc: "red", price: "red",
});
});
it("sku ticks", () => {
expect(computeLineTicks(row({ sku: "sm293306016" }), check()).sku).toBe("green");
// A fill only goes green through the code match (no filled-value shortcut).
expect(
computeLineTicks(row({ sku: "OUR-1" }), check({ sku_filled_value: "OUR-1" })).sku,
).toBe("amber");
expect(
computeLineTicks(
row({ sku: "SM293306016" }),
check({ sku_filled_value: "SM293306016" }),
).sku,
).toBe("green");
expect(computeLineTicks(row({ sku: "OTHER" }), check()).sku).toBe("amber");
expect(
computeLineTicks(row(), check({ doc_line: line({ code: "" }) })).sku,
).toBe("grey");
expect(computeLineTicks(row({ sku: "" }), check()).sku).toBe("red");
});
it("qty ticks", () => {
expect(computeLineTicks(row(), check()).qty).toBe("green");
expect(
computeLineTicks(
row({ qty_collected: 5, order_qty: 8 }),
check({ doc_line: line({ quantity: 5 }) }),
).qty,
).toBe("amber");
expect(
computeLineTicks(row({ qty_collected: 3 }), check({ doc_line: line({ quantity: 4 }) })).qty,
).toBe("red");
expect(
computeLineTicks(row(), check({ doc_line: line({ quantity: null }) })).qty,
).toBe("grey");
expect(
computeLineTicks(
row({ order_qty: 0, quantity: 8, qty_collected: 5 }),
check({ doc_line: line({ quantity: 5 }) }),
).qty,
).toBe("amber");
expect(
computeLineTicks(
row({ order_qty: 0, quantity: 0, qty_collected: 5 }),
check({ doc_line: line({ quantity: 5 }) }),
).qty,
).toBe("green");
});
it("pairing failed gives all grey (can't check), not red", () => {
expect(computeLineTicks(row(), check({ doc_line: null, same_item: null, pair_failed: true }))).toEqual({
sku: "grey", qty: "grey", desc: "grey", price: "grey",
});
});
it("a different or unrated pairing is never green, even when values agree", () => {
for (const same_item of ["different", null] as const) {
expect(computeLineTicks(row(), check({ same_item }))).toEqual({
sku: "red", qty: "red", desc: "red", price: "red",
});
}
});
it("uses the line's own SKU (line_sku) before the catalogue SKU", () => {
expect(effectiveSku({ sku: "CAT-1", line_sku: "SM293306016" })).toBe("SM293306016");
expect(effectiveSku({ sku: "CAT-1", line_sku: "" })).toBe("CAT-1");
expect(effectiveSku({})).toBe("");
expect(
computeLineTicks(row({ sku: undefined, line_sku: "SM293306016" }), check()).sku,
).toBe("green");
expect(
showsAiFilledSku({ line_sku: "SM1" }, check({ sku_filled_value: "SM1" })),
).toBe(true);
});
it("desc ticks", () => {
expect(computeLineTicks(row(), check({ same_item: "sure" })).desc).toBe("green");
expect(computeLineTicks(row(), check({ same_item: "likely" })).desc).toBe("amber");
expect(computeLineTicks(row(), check({ same_item: "different" })).desc).toBe("red");
expect(computeLineTicks(row(), check({ same_item: null })).desc).toBe("red");
});
it("price tick compares the cost with the price after discount", () => {
// HYCO I009753: R15.10 less 25% on the invoice totals = R11.325; cost R11.33.
const hyco = line({ unit_price: 15.1, doc_discount_percent: 25, net_unit_price: 11.325 });
expect(computeLineTicks(row({ cost_price: 11.33 }), check({ doc_line: hyco })).price).toBe("green");
// Paying the full printed price when there was a discount is wrong.
expect(computeLineTicks(row({ cost_price: 15.1 }), check({ doc_line: hyco })).price).toBe("red");
// Swagelok 622831: R558.62 less 5% on the line = R530.689; cost R530.69.
const swage = line({ unit_price: 558.62, discount_percent: 5, net_unit_price: 530.689 });
expect(computeLineTicks(row({ cost_price: 530.69 }), check({ doc_line: swage })).price).toBe("green");
expect(docUnitCost(swage)).toBe(530.689);
expect(docUnitCost(line({ unit_price: 7.6 }))).toBe(7.6);
expect(docUnitCost(line({ unit_price: null }))).toBeNull();
});
it("price ticks", () => {
expect(computeLineTicks(row(), check()).price).toBe("green");
expect(
computeLineTicks(row({ cost_price: 0.345 }), check({ doc_line: line({ unit_price: 0.33 }) })).price,
).toBe("red");
expect(
computeLineTicks(row({ cost_price: 14.515 }), check({ doc_line: line({ unit_price: 14.51 }) })).price,
).toBe("green");
expect(
computeLineTicks(row({ cost_price: 14.51 }), check({ doc_line: line({ unit_price: 14.52 }) })).price,
).toBe("green");
// A delivery note that prints prices (Plumblink) is compared like an invoice.
expect(computeLineTicks(row(), check({ doc_type: "delivery_note" })).price).toBe("green");
expect(
computeLineTicks(row({ cost_price: 0.5 }), check({ doc_type: "delivery_note" })).price,
).toBe("red");
expect(
computeLineTicks(row(), check({ doc_type: "delivery_note", doc_line: line({ unit_price: null }) })).price,
).toBe("grey");
expect(
computeLineTicks(row(), check({ doc_line: line({ unit_price: null }) })).price,
).toBe("grey");
expect(computeLineTicks(row({ cost_price: null }), check()).price).toBe("red");
});
});
describe("allGreen", () => {
it("only when all four green", () => {
expect(allGreen({ sku: "green", qty: "green", desc: "green", price: "green" })).toBe(true);
expect(allGreen({ sku: "green", qty: "green", desc: "green", price: "grey" })).toBe(false);
});
});
describe("showsAiFilledSku", () => {
it("true while sku still equals filled value, false after rep edits", () => {
const c = check({ sku_filled_value: "OUR-1" });
expect(showsAiFilledSku({ sku: "OUR-1" }, c)).toBe(true);
expect(showsAiFilledSku({ sku: "OUR-2" }, c)).toBe(false);
expect(showsAiFilledSku({ sku: "OUR-1" }, null)).toBe(false);
});
});
describe("docPriceLabel", () => {
it("spells out the discount so the operator can follow the sum", () => {
expect(docPriceLabel(line({ unit_price: 15.1, doc_discount_percent: 25, net_unit_price: 11.325 }))).toBe(
"R15.10 less 25% invoice discount = R11.33",
);
expect(docPriceLabel(line({ unit_price: 558.62, discount_percent: 5, net_unit_price: 530.689 }))).toBe(
"R558.62 less 5% = R530.69",
);
expect(docPriceLabel(line({ unit_price: 7.6 }))).toBe("R7.60");
expect(docPriceLabel(line({ unit_price: null }))).toBe("none");
});
});
describe("roundCents", () => {
it("rounds half-cents up despite float error", () => {
expect(roundCents(11.325)).toBe(11.33);
expect(roundCents(530.689)).toBe(530.69);
expect(roundCents(5.7)).toBe(5.7);
});
});
describe("cardAiState", () => {
const good = { ...row(), ai_check: check() };
it("is verified only when every line was checked and no tick is red or amber", () => {
expect(cardAiState([good, good])).toEqual({ state: "verified", checked: 2, needLook: 0 });
// A grey price (no price printed) is fine: nothing the AI could check is off.
expect(cardAiState([{ ...good, ai_check: check({ doc_line: line({ unit_price: null }) }) }]).state).toBe("verified");
});
it("needs a look when a line is off or wasn't checked", () => {
expect(cardAiState([good, { ...row({ cost_price: 99 }), ai_check: check() }])).toEqual({
state: "issues",
checked: 2,
needLook: 1,
});
expect(cardAiState([good, { ...row(), ai_check: null }])).toEqual({ state: "issues", checked: 1, needLook: 1 });
});
it("is unchecked when no line has a usable check", () => {
expect(cardAiState([{ ...row(), ai_check: null }]).state).toBe("unchecked");
expect(cardAiState([{ ...row(), ai_check: check({ pair_failed: true, doc_line: null }) }]).state).toBe("unchecked");
});
});