From 13220c3c4338e0c0b6fcf858874c8b4f5061cf2d Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 24 Oct 2026 21:31:02 +0200 Subject: [PATCH] feat(price-check): pure buildPriceCheck helper for the blue badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/lib/price-check.test.ts | 249 ++++++++++++++++++++++++++++++++++++ src/lib/price-check.ts | 236 ++++++++++++++++++++++++++++++++++ 2 files changed, 485 insertions(+) create mode 100644 src/lib/price-check.test.ts create mode 100644 src/lib/price-check.ts diff --git a/src/lib/price-check.test.ts b/src/lib/price-check.test.ts new file mode 100644 index 0000000..a1b761e --- /dev/null +++ b/src/lib/price-check.test.ts @@ -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 { + 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 = {}) => ({ + 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 = {}) => ({ + 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 = {}) => ({ + 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"); + }); +}); diff --git a/src/lib/price-check.ts b/src/lib/price-check.ts new file mode 100644 index 0000000..89a6213 --- /dev/null +++ b/src/lib/price-check.ts @@ -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, + }; +}