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>
151 lines
5.2 KiB
TypeScript
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 };
|
|
}
|