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>
274 lines
9.5 KiB
TypeScript
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,
|
|
};
|
|
}
|