WFDASH-WEBAPP/src/lib/price-check.ts
Dave a6d53e3b2b feat(price-check): say whether the POS item is exact or only closest
The blue badge read the same whether the AI had matched the item
exactly or only found the nearest one, so a line priced from an M22
bolt looked like any other line.

Now an exact pick reads as before, a closest pick leads with
"Closest: {name} ({differs})" and raises a closest_only flag, and a
fuzzy match keeps its "Similar items:" wording. The popover shows the
pick and the differences at the top.

A refresh is still a no-AI recompute, but it keeps the stored pick
instead of throwing it away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 23:05:09 +02:00

274 lines
9.5 KiB
TypeScript

/**
* price-check — the brain behind the blue "price check" badge.
*
* Pure and client-safe: it takes the Item Intel payload for a line and
* works out two things —
*
* 1. `summary` — the one-line sentence the badge shows, e.g.
* "POS R 0,45 · 120 in stock · last bought R 0,42 Fowkes (12 days
* ago) · 2 other suppliers".
* 2. `flags` — the things worth a second look. Any flag puts an
* orange dot on the badge.
*
* Nothing here talks to PocketBase. The intel payload is fetched by
* the caller (the badge refreshes it via
* `POST /api/v1/items/{collection}/{id}/intel/refresh`).
*/
import type { ItemIntel, ItemIntelMatchKind } from "@/lib/types";
/** Same formatter the rest of the app uses (R 0,45). */
const zar = new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR" });
/** Money, the way the Process pages write it. */
export function formatMoney(amount: number): string {
return zar.format(Number(amount) || 0);
}
/** How far above the last price we paid the line may sit before it is
* worth a look. */
export const COST_ABOVE_LAST_PAID_PCT = 10;
/** How much cheaper another supplier has to be before it is worth a look. */
export const CHEAPER_SUPPLIER_PCT = 5;
/** How much cheaper the price list has to be before it is worth a look. */
export const PRICE_LIST_CHEAPER_PCT = 5;
export type PriceCheckFlagKey =
| "cost_above_last_paid"
| "cheaper_supplier"
| "in_stock"
| "price_list_cheaper"
| "closest_only";
export interface PriceCheckFlag {
key: PriceCheckFlagKey;
/** One short plain-English sentence for the popover. */
message: string;
}
/** The line the check is about. */
export interface PriceCheckLine {
cost_price: number;
selling_price: number;
/** PB supplier id on the line. Empty when the line has no supplier. */
supplier: string;
}
export interface PriceCheck {
summary: string;
flags: PriceCheckFlag[];
matchKind: ItemIntelMatchKind;
}
/** How the intel says it matched — the label the popover shows. */
export function matchKindLabel(kind: ItemIntelMatchKind): string {
switch (kind) {
case "stock_item":
return "POS item the AI picked";
case "pos_pick":
return "POS item the AI picked";
case "sku":
return "Matched by SKU";
default:
return "Similar items";
}
}
/** "12%" / "12,3%" — one decimal only when it adds something. */
function pct(value: number): string {
const rounded = Math.round(value * 10) / 10;
return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}%`;
}
/** `a` is more than `pct`% above `b`. */
function aboveBy(a: number, b: number, percent: number): boolean {
if (!(a > 0) || !(b > 0)) return false;
return a > b * (1 + percent / 100) + 1e-9;
}
/** `a` is at least `percent`% below `b`. */
function belowBy(a: number, b: number, percent: number): boolean {
if (!(a > 0) || !(b > 0)) return false;
return a <= b * (1 - percent / 100) + 1e-9;
}
/** Is this supplier row the one already on the line? */
function isLineSupplier(
row: { supplier_id: string; supplier_name: string },
lineSupplier: string,
): boolean {
const wanted = (lineSupplier || "").trim();
if (!wanted) return false;
if (row.supplier_id && row.supplier_id === wanted) return true;
// The caller may have handed us a supplier name instead of an id.
const name = (row.supplier_name || "").trim().toLowerCase();
return !!name && name === wanted.toLowerCase();
}
/** Does the intel hold anything at all? */
function hasData(intel: ItemIntel): boolean {
return (
intel.stock_list.length > 0 ||
intel.supplier_history.length > 0 ||
intel.price_list.length > 0 ||
intel.recent_quote.length > 0 ||
intel.recent_order.length > 0
);
}
/**
* Build the price check for one line.
*
* `intel` may be null/undefined (nothing computed yet) — the result is
* then "No price data yet" with no flags, which is what the badge shows
* before the first refresh.
*/
export function buildPriceCheck(
intel: ItemIntel | null | undefined,
line: PriceCheckLine,
): PriceCheck {
const matchKind: ItemIntelMatchKind = intel?.match_kind ?? "fuzzy";
if (!intel || !hasData(intel)) {
return { summary: "No price data yet", flags: [], matchKind };
}
const lineCost = Number(line.cost_price) || 0;
const flags: PriceCheckFlag[] = [];
const topStock = intel.stock_list[0] ?? null;
const lastBought = intel.supplier_history[0] ?? null;
const otherSuppliers = Math.max(0, intel.supplier_history.length - 1);
// ── Flags ──────────────────────────────────────────────────────────
// The AI could only find a CLOSEST item — the POS has nothing that
// matches the line exactly. This is the flag that matters most: the
// price shown next to it may be for a different size.
const posPick = intel.pos_pick ?? null;
const isClosest = posPick?.match === "closest";
if (isClosest && posPick) {
const picked = intel.stock_list.find((r) => r.stock_item_id === posPick.stock_item_id);
const name = picked?.name || "the closest item we have";
const differs = posPick.differs ?? [];
flags.push({
key: "closest_only",
message:
`The POS has no exact match — closest is ${name}` +
(differs.length > 0 ? ` (${differs.join(", ")})` : "") +
".",
});
}
// We are paying more than we paid last time.
const knownLastCosts = intel.supplier_history.filter((r) => (r.last_cost || 0) > 0);
if (knownLastCosts.length > 0 && lineCost > 0) {
const cheapest = knownLastCosts.reduce((a, b) => (b.last_cost < a.last_cost ? b : a));
if (aboveBy(lineCost, cheapest.last_cost, COST_ABOVE_LAST_PAID_PCT)) {
const diff = ((lineCost - cheapest.last_cost) / lineCost) * 100;
flags.push({
key: "cost_above_last_paid",
message:
`This line costs ${formatMoney(lineCost)} — we last paid ` +
`${formatMoney(cheapest.last_cost)} at ${cheapest.supplier_name || "another supplier"} ` +
`(${pct(diff)} less).`,
});
}
}
// Another supplier we have bought from before is cheaper.
if (lineCost > 0) {
const cheaper = intel.supplier_history.find(
(r) =>
(r.last_cost || 0) > 0 &&
!isLineSupplier(r, line.supplier) &&
belowBy(r.last_cost, lineCost, CHEAPER_SUPPLIER_PCT),
);
if (cheaper) {
const diff = ((lineCost - cheaper.last_cost) / lineCost) * 100;
flags.push({
key: "cheaper_supplier",
message:
`${cheaper.supplier_name || "Another supplier"} last sold this for ` +
`${formatMoney(cheaper.last_cost)} — ${pct(diff)} below this line's ${formatMoney(lineCost)}.`,
});
}
}
// We already have it on the shelf.
if (topStock && topStock.status === "in_stock" && topStock.qty > 0) {
flags.push({
key: "in_stock",
message: `We have ${topStock.qty} in stock — no need to raise a purchase order.`,
});
}
// Our own price list says it is cheaper.
if (lineCost > 0) {
const cheaper = intel.price_list.find(
(r) => (r.cost_price || 0) > 0 && belowBy(r.cost_price, lineCost, PRICE_LIST_CHEAPER_PCT),
);
if (cheaper) {
const diff = ((lineCost - cheaper.cost_price) / lineCost) * 100;
flags.push({
key: "price_list_cheaper",
message:
`Our price list has it for ${formatMoney(cheaper.cost_price)}` +
(cheaper.supplier ? ` at ${cheaper.supplier}` : "") +
` — ${pct(diff)} below this line.`,
});
}
}
// ── Summary ────────────────────────────────────────────────────────
const bits: string[] = [];
if (topStock && (topStock.cost_price || 0) > 0) {
bits.push(`POS ${formatMoney(topStock.cost_price)}`);
}
if (topStock) {
if (topStock.status === "in_stock") {
bits.push(`${topStock.qty} in stock`);
} else if (topStock.status === "low") {
bits.push(`${topStock.qty} low stock`);
} else {
bits.push("out of stock");
}
}
if (lastBought) {
const days = `${lastBought.days_ago} day${lastBought.days_ago === 1 ? "" : "s"} ago`;
bits.push(
`last bought ${formatMoney(lastBought.last_cost)} ` +
`${lastBought.supplier_name || "another supplier"} (${days})`,
);
}
if (otherSuppliers > 0) {
bits.push(`${otherSuppliers} other supplier${otherSuppliers === 1 ? "" : "s"}`);
}
if (bits.length === 0 && intel.price_list.length > 0) {
const row = intel.price_list[0];
bits.push(`price list ${formatMoney(row.cost_price)}`);
}
// A closest pick leads with what it is and what is wrong with it —
// the number behind it may be for a different size.
if (isClosest && posPick) {
const picked = intel.stock_list.find((r) => r.stock_item_id === posPick.stock_item_id);
const name = picked?.name || "the closest item we have";
const differs = posPick.differs ?? [];
const head = `Closest: ${name}${differs.length > 0 ? ` (${differs.join(", ")})` : ""}`;
return {
summary: bits.length > 0 ? `${head} · ${bits.join(" · ")}` : head,
flags,
matchKind,
};
}
// A fuzzy match may be a different item (an M22 bolt for an M16 line),
// so the one-liner says so instead of reading like this item's price.
const joined = bits.join(" · ");
return {
summary: joined ? (matchKind === "fuzzy" ? `Similar items: ${joined}` : joined) : "No price data yet",
flags,
matchKind,
};
}