feat(collection-ai): line tick rules
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e6cac6e447
commit
5262de97fd
2 changed files with 210 additions and 0 deletions
123
src/lib/collection-ai-ticks.test.ts
Normal file
123
src/lib/collection-ai-ticks.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
87
src/lib/collection-ai-ticks.ts
Normal file
87
src/lib/collection-ai-ticks.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue