feat(price-check): pure buildPriceCheck helper for the blue badge

buildPriceCheck(intel, line) turns the Item Intel payload into the two
things the blue price-check badge needs:

  - a one-line summary, e.g. "POS R 0,45 · 120 in stock · last bought
    R 0,42 Fowkes (12 days ago) · 2 other suppliers"
  - the flags worth a look: cost_above_last_paid, cheaper_supplier,
    in_stock, price_list_cheaper — each with a plain-English sentence

Pure and client-safe: no PocketBase, no server imports. Money is
formatted the way the rest of the app formats it (R 0,45).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-24 21:31:02 +02:00
parent 42a683aec1
commit 13220c3c43
2 changed files with 485 additions and 0 deletions

249
src/lib/price-check.test.ts Normal file
View file

@ -0,0 +1,249 @@
/**
* price-check — the blue badge's summary + flags.
*/
import { describe, expect, it } from "vitest";
import type { ItemIntel } from "@/lib/types";
import { buildPriceCheck, formatMoney, matchKindLabel } from "./price-check";
function intel(over: Partial<ItemIntel> = {}): ItemIntel {
return {
summary: "",
generated_at: "2026-10-24T10:00:00.000Z",
stock_list: [],
supplier_history: [],
price_list: [],
recent_quote: [],
recent_order: [],
...over,
};
}
const stockRow = (over: Record<string, unknown> = {}) => ({
stock_item_id: "si-1",
score: 1,
name: "M20 Hex Bolt",
sku: "BOLT-1",
description: "M20 hex bolt",
qty: 120,
min_qty: 20,
cost_price: 0.45,
selling_price: 0.78,
supplier_id: "sup-fowkes",
supplier_name: "Fowkes",
status: "in_stock" as const,
...over,
});
const supplierRow = (over: Record<string, unknown> = {}) => ({
supplier_id: "sup-fowkes",
supplier_name: "Fowkes",
last_cost: 0.42,
last_bought_at: "2026-10-12T08:00:00.000Z",
days_ago: 12,
times_bought: 3,
last_purchase_number: "PO1",
stock_item_id: "si-1",
stock_item_sku: "BOLT-1",
stock_item_name: "M20 Hex Bolt",
...over,
});
const priceListRow = (over: Record<string, unknown> = {}) => ({
score: 0.9,
sku: "BOLT-1",
description: "M20 hex bolt",
brand: "",
category: "",
cost_price: 0.4,
selling_price: 0.7,
supplier: "Afrisam",
source_file: "",
updated: "",
...over,
});
const LINE = { cost_price: 0.45, selling_price: 0.78, supplier: "sup-fowkes" };
/** The app's currency formatter puts a non-breaking space between the
* "R" and the amount. Normalise it so the expectations read normally. */
const plain = (s: string) => s.replace(/ /g, " ");
describe("formatMoney", () => {
it("writes Rand the way the app does", () => {
expect(plain(formatMoney(0.45))).toBe("R 0,45");
expect(plain(formatMoney(1234.5))).toBe("R 1 234,50");
expect(plain(formatMoney(0))).toBe("R 0,00");
});
});
describe("buildPriceCheck — empty intel", () => {
it("says so when there is no intel at all", () => {
const r = buildPriceCheck(null, LINE);
expect(r.summary).toBe("No price data yet");
expect(r.flags).toEqual([]);
expect(r.matchKind).toBe("fuzzy");
});
it("says so when the intel has no sections filled", () => {
const r = buildPriceCheck(intel(), LINE);
expect(r.summary).toBe("No price data yet");
expect(r.flags).toEqual([]);
});
});
describe("buildPriceCheck — summary", () => {
it("reads like the line the operator sees", () => {
const r = buildPriceCheck(
intel({
stock_list: [stockRow()],
supplier_history: [
supplierRow(),
supplierRow({ supplier_id: "sup-b", supplier_name: "Afrisam", last_cost: 0.5 }),
supplierRow({ supplier_id: "sup-c", supplier_name: "Buildmart", last_cost: 0.55 }),
],
match_kind: "stock_item",
}),
LINE,
);
expect(plain(r.summary)).toBe(
"POS R 0,45 · 120 in stock · last bought R 0,42 Fowkes (12 days ago) · 2 other suppliers",
);
expect(r.matchKind).toBe("stock_item");
});
it("says one supplier, singular", () => {
const r = buildPriceCheck(
intel({
stock_list: [stockRow()],
supplier_history: [supplierRow(), supplierRow({ supplier_id: "sup-b", supplier_name: "Afrisam" })],
}),
LINE,
);
expect(r.summary).toContain("1 other supplier");
expect(r.summary).not.toContain("1 other suppliers");
});
it("falls back to the price list when there is no stock row", () => {
const r = buildPriceCheck(intel({ price_list: [priceListRow()] }), LINE);
expect(plain(r.summary)).toBe("price list R 0,40");
});
});
describe("buildPriceCheck — cost_above_last_paid", () => {
it("fires when the line is more than 10% above the last price we paid", () => {
const r = buildPriceCheck(
intel({ supplier_history: [supplierRow({ last_cost: 0.4 })] }),
{ ...LINE, cost_price: 0.45 },
);
const flag = r.flags.find((f) => f.key === "cost_above_last_paid");
expect(flag).toBeDefined();
expect(plain(flag!.message)).toContain("R 0,45");
expect(plain(flag!.message)).toContain("R 0,40");
expect(flag!.message).toContain("Fowkes");
});
it("stays quiet at exactly 10% above", () => {
const r = buildPriceCheck(
intel({ supplier_history: [supplierRow({ last_cost: 0.4090909090909091 })] }),
{ ...LINE, cost_price: 0.45 },
);
expect(r.flags.find((f) => f.key === "cost_above_last_paid")).toBeUndefined();
});
it("ignores a last price of zero", () => {
const r = buildPriceCheck(
intel({ supplier_history: [supplierRow({ last_cost: 0 })] }),
LINE,
);
expect(r.flags.find((f) => f.key === "cost_above_last_paid")).toBeUndefined();
});
});
describe("buildPriceCheck — cheaper_supplier", () => {
it("fires when another supplier is at least 5% below the line cost", () => {
const r = buildPriceCheck(
intel({
supplier_history: [
supplierRow(),
supplierRow({ supplier_id: "sup-b", supplier_name: "Afrisam", last_cost: 0.4 }),
],
}),
LINE,
);
const flag = r.flags.find((f) => f.key === "cheaper_supplier");
expect(flag).toBeDefined();
expect(flag!.message).toContain("Afrisam");
expect(plain(flag!.message)).toContain("R 0,40");
});
it("ignores the supplier already on the line", () => {
const r = buildPriceCheck(
intel({
supplier_history: [supplierRow({ last_cost: 0.4 })],
}),
LINE,
);
expect(r.flags.find((f) => f.key === "cheaper_supplier")).toBeUndefined();
});
it("stays quiet below the 5% gap", () => {
const r = buildPriceCheck(
intel({
supplier_history: [
supplierRow(),
supplierRow({ supplier_id: "sup-b", supplier_name: "Afrisam", last_cost: 0.43 }),
],
}),
LINE,
);
expect(r.flags.find((f) => f.key === "cheaper_supplier")).toBeUndefined();
});
});
describe("buildPriceCheck — in_stock", () => {
it("fires when the top stock row is in stock", () => {
const r = buildPriceCheck(intel({ stock_list: [stockRow()] }), LINE);
const flag = r.flags.find((f) => f.key === "in_stock");
expect(flag).toBeDefined();
expect(flag!.message).toContain("120");
});
it("stays quiet for a low or empty shelf", () => {
const low = buildPriceCheck(
intel({ stock_list: [stockRow({ status: "low", qty: 2 })] }),
LINE,
);
expect(low.flags.find((f) => f.key === "in_stock")).toBeUndefined();
const out = buildPriceCheck(
intel({ stock_list: [stockRow({ status: "out", qty: 0 })] }),
LINE,
);
expect(out.flags.find((f) => f.key === "in_stock")).toBeUndefined();
});
});
describe("buildPriceCheck — price_list_cheaper", () => {
it("fires when a price list row is at least 5% below the line cost", () => {
const r = buildPriceCheck(intel({ price_list: [priceListRow({ cost_price: 0.4 })] }), LINE);
const flag = r.flags.find((f) => f.key === "price_list_cheaper");
expect(flag).toBeDefined();
expect(flag!.message).toContain("Afrisam");
expect(plain(flag!.message)).toContain("R 0,40");
});
it("stays quiet below the 5% gap", () => {
const r = buildPriceCheck(intel({ price_list: [priceListRow({ cost_price: 0.43 })] }), LINE);
expect(r.flags.find((f) => f.key === "price_list_cheaper")).toBeUndefined();
});
});
describe("matchKindLabel", () => {
it("names how the intel matched", () => {
expect(matchKindLabel("stock_item")).toBe("POS item the AI picked");
expect(matchKindLabel("sku")).toBe("Matched by SKU");
expect(matchKindLabel("fuzzy")).toBe("Similar items");
});
});

236
src/lib/price-check.ts Normal file
View file

@ -0,0 +1,236 @@
/**
* 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";
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 "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 ──────────────────────────────────────────────────────────
// 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)}`);
}
return {
summary: bits.join(" · ") || "No price data yet",
flags,
matchKind,
};
}