feat(collection-ai): line tick rules

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-17 01:24:51 +02:00
parent e6cac6e447
commit 5262de97fd
2 changed files with 210 additions and 0 deletions

View file

@ -0,0 +1,123 @@
import { describe, it, expect } from "vitest";
import {
computeLineTicks,
allGreen,
showsAiFilledSku,
normaliseCode,
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");
expect(
computeLineTicks(row({ sku: "OUR-1" }), check({ sku_filled_value: "OUR-1" })).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: 40 }), check()).qty,
).toBe("green");
});
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 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(), check({ doc_type: "delivery_note" })).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);
});
});

View file

@ -0,0 +1,87 @@
import type { CollectionAiCheck } from "@/lib/types";
export type TickState = "green" | "amber" | "red" | "grey";
export interface LineTicks {
sku: TickState;
qty: TickState;
desc: TickState;
price: TickState;
}
export type TickRow = {
sku?: string;
qty_collected?: number;
cost_price?: number | null;
order_qty?: number;
quantity?: number;
};
const PRICE_TOLERANCE = 0.01;
const FLOAT_EPSILON = 1e-9;
/** Uppercase, A-Z0-9 only. */
export function normaliseCode(code: string | null | undefined): string {
return (code ?? "").toUpperCase().replace(/[^A-Z0-9]/g, "");
}
export function computeLineTicks(row: TickRow, check: CollectionAiCheck): LineTicks {
const doc = check.doc_line;
if (!doc) return { sku: "red", qty: "red", desc: "red", price: "red" };
// SKU
const rowSku = row.sku ?? "";
const docCode = normaliseCode(doc.code);
let sku: TickState;
if (!docCode) sku = "grey";
else if (!rowSku.trim()) sku = "red";
else if (
normaliseCode(rowSku) === docCode ||
(check.sku_filled_value !== "" && rowSku === check.sku_filled_value)
)
sku = "green";
else sku = "amber";
// Qty
let qty: TickState;
if (doc.quantity == null) qty = "grey";
else if (doc.quantity !== row.qty_collected) qty = "red";
else {
const ordered = row.order_qty ? row.order_qty : (row.quantity ?? 0);
qty = ordered && ordered !== doc.quantity ? "amber" : "green";
}
// Description
const desc: TickState =
check.same_item === "sure"
? "green"
: check.same_item === "likely"
? "amber"
: "red";
// Price
let price: TickState;
if (check.doc_type === "delivery_note" || doc.unit_price == null) price = "grey";
else if (row.cost_price == null) price = "red";
else
price =
Math.abs(doc.unit_price - row.cost_price) <= PRICE_TOLERANCE + FLOAT_EPSILON
? "green"
: "red";
return { sku, qty, desc, price };
}
export function allGreen(t: LineTicks): boolean {
return (
t.sku === "green" && t.qty === "green" && t.desc === "green" && t.price === "green"
);
}
/** True while the row SKU is still the value the AI filled in. */
export function showsAiFilledSku(
row: TickRow,
check: CollectionAiCheck | null | undefined,
): boolean {
return !!check && check.sku_filled_value !== "" && row.sku === check.sku_filled_value;
}