WFDASH-WEBAPP/src/lib/collection-ai-ticks.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

151 lines
5.2 KiB
TypeScript

import type { CollectionAiCheck, CollectionDocLine } 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;
/** Stock rows: the line's own `stock_purchase_items.sku`. */
line_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, "");
}
/** The SKU the ticks and robot judge: a stock line's own SKU, else the row SKU. */
export function effectiveSku(row: Pick<TickRow, "sku" | "line_sku">): string {
return row.line_sku || row.sku || "";
}
/**
* What one unit actually cost on the document: the price after any line
* or whole-document discount (`net_unit_price`), else the printed price.
*/
export function docUnitCost(doc: Pick<CollectionDocLine, "unit_price" | "net_unit_price">): number | null {
return doc.net_unit_price ?? doc.unit_price ?? null;
}
/** Round to cents the way a person would: 11.325 → 11.33 (plain toFixed gives 11.32). */
export function roundCents(n: number): number {
return Math.round(n * 100 + 1e-7) / 100;
}
/** "R15.10 less 25% invoice discount = R11.33", or just "R7.60" without a discount. */
export function docPriceLabel(
doc: Pick<CollectionDocLine, "unit_price" | "net_unit_price" | "discount_percent" | "doc_discount_percent">,
): string {
if (doc.unit_price == null) return "none";
const printed = `R${doc.unit_price.toFixed(2)}`;
if (doc.net_unit_price == null) return printed;
const less = [
doc.discount_percent ? `${doc.discount_percent}%` : "",
doc.doc_discount_percent ? `${doc.doc_discount_percent}% invoice discount` : "",
].filter(Boolean);
return `${printed} less ${less.join(" and ")} = R${roundCents(doc.net_unit_price).toFixed(2)}`;
}
export function computeLineTicks(row: TickRow, check: CollectionAiCheck): LineTicks {
const doc = check.doc_line;
// Pairing failed: nothing was compared, so nothing can be judged.
if (check.pair_failed && !doc) return { sku: "grey", qty: "grey", desc: "grey", price: "grey" };
// Only a "sure"/"likely" pairing is a match; anything else doesn't match.
if (!doc || (check.same_item !== "sure" && check.same_item !== "likely")) {
return { sku: "red", qty: "red", desc: "red", price: "red" };
}
// SKU (an AI fill goes green through the same code comparison)
const rowSku = effectiveSku(row);
const docCode = normaliseCode(doc.code);
let sku: TickState;
if (!docCode) sku = "grey";
else if (!rowSku.trim()) sku = "red";
else if (normaliseCode(rowSku) === docCode) 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" : "amber";
// Price
let price: TickState;
const unitCost = docUnitCost(doc);
// Delivery notes are compared too when they print a price (Plumblink does).
if (unitCost == null) price = "grey";
else if (row.cost_price == null) price = "red";
else
price =
Math.abs(unitCost - 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 !== "" && effectiveSku(row) === check.sku_filled_value;
}
export interface CardAiState {
/** verified: every line checked, no red/amber tick. issues: checked, but not all clean. */
state: "verified" | "issues" | "unchecked";
/** Lines with a usable AI check (pairing didn't fail). */
checked: number;
/** Lines that are off (a red/amber tick) or weren't checked. */
needLook: number;
}
/** A line passes when nothing the AI could check is off: no red/amber, at least one green. */
function linePasses(t: LineTicks): boolean {
const all = [t.sku, t.qty, t.desc, t.price];
return all.includes("green") && !all.includes("red") && !all.includes("amber");
}
/** How the AI's checks on a PO card add up — drives the collapsed card's colour. */
export function cardAiState(rows: Array<TickRow & { ai_check?: CollectionAiCheck | null }>): CardAiState {
let checked = 0;
let needLook = 0;
for (const r of rows) {
const c = r.ai_check;
if (!c || c.pair_failed) {
needLook += 1;
continue;
}
checked += 1;
if (!linePasses(computeLineTicks(r, c))) needLook += 1;
}
if (checked === 0) return { state: "unchecked", checked, needLook: 0 };
return { state: needLook === 0 ? "verified" : "issues", checked, needLook };
}