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:
parent
42a683aec1
commit
13220c3c43
2 changed files with 485 additions and 0 deletions
249
src/lib/price-check.test.ts
Normal file
249
src/lib/price-check.test.ts
Normal 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
236
src/lib/price-check.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue