diff --git a/src/lib/collection-ai-ticks.test.ts b/src/lib/collection-ai-ticks.test.ts new file mode 100644 index 0000000..76982b9 --- /dev/null +++ b/src/lib/collection-ai-ticks.test.ts @@ -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 => ({ + 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 => ({ + 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 => ({ + 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); + }); +}); diff --git a/src/lib/collection-ai-ticks.ts b/src/lib/collection-ai-ticks.ts new file mode 100644 index 0000000..fdae0eb --- /dev/null +++ b/src/lib/collection-ai-ticks.ts @@ -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; +}