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>
233 lines
9 KiB
TypeScript
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");
|
|
});
|
|
});
|