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>
319 lines
10 KiB
TypeScript
319 lines
10 KiB
TypeScript
/**
|
||
* 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("Similar items: price list R 0,40");
|
||
});
|
||
|
||
it("marks a fuzzy match as similar items, not this item's price", () => {
|
||
const r = buildPriceCheck(intel({ stock_list: [stockRow()], match_kind: "fuzzy" }), LINE);
|
||
expect(plain(r.summary)).toBe("Similar items: POS R 0,45 · 120 in stock");
|
||
});
|
||
|
||
it("does not prefix an exact match", () => {
|
||
const r = buildPriceCheck(intel({ stock_list: [stockRow()], match_kind: "sku" }), LINE);
|
||
expect(plain(r.summary)).toBe("POS R 0,45 · 120 in stock");
|
||
});
|
||
});
|
||
|
||
describe("buildPriceCheck — the AI's POS pick", () => {
|
||
const exactPick = {
|
||
stock_item_id: "si-1",
|
||
match: "exact" as const,
|
||
differs: [],
|
||
reason: "same bolt",
|
||
model: "m",
|
||
};
|
||
|
||
const closestPick = {
|
||
stock_item_id: "si-1",
|
||
match: "closest" as const,
|
||
differs: ["length 65 vs 60", "thread M22 vs M16"],
|
||
reason: "nearest bolt",
|
||
model: "m",
|
||
};
|
||
|
||
it("reads as today when the pick is exact — no prefix", () => {
|
||
const r = buildPriceCheck(
|
||
intel({ stock_list: [stockRow()], match_kind: "pos_pick", pos_pick: exactPick }),
|
||
LINE,
|
||
);
|
||
expect(plain(r.summary)).toBe("POS R 0,45 · 120 in stock");
|
||
expect(r.flags.find((f) => f.key === "closest_only")).toBeUndefined();
|
||
expect(r.matchKind).toBe("pos_pick");
|
||
});
|
||
|
||
it("leads with the closest item and what differs", () => {
|
||
const r = buildPriceCheck(
|
||
intel({ stock_list: [stockRow()], match_kind: "pos_pick", pos_pick: closestPick }),
|
||
LINE,
|
||
);
|
||
expect(plain(r.summary)).toBe(
|
||
"Closest: M20 Hex Bolt (length 65 vs 60, thread M22 vs M16) · POS R 0,45 · 120 in stock",
|
||
);
|
||
});
|
||
|
||
it("flags that the POS has no exact match", () => {
|
||
const r = buildPriceCheck(
|
||
intel({ stock_list: [stockRow()], match_kind: "pos_pick", pos_pick: closestPick }),
|
||
LINE,
|
||
);
|
||
const flag = r.flags.find((f) => f.key === "closest_only");
|
||
expect(flag).toBeDefined();
|
||
expect(flag!.message).toContain("The POS has no exact match");
|
||
expect(flag!.message).toContain("M20 Hex Bolt");
|
||
expect(flag!.message).toContain("length 65 vs 60");
|
||
});
|
||
|
||
it("keeps the fuzzy wording when there is no pick", () => {
|
||
const r = buildPriceCheck(
|
||
intel({ stock_list: [stockRow()], match_kind: "fuzzy" }),
|
||
LINE,
|
||
);
|
||
expect(plain(r.summary)).toBe("Similar items: POS R 0,45 · 120 in stock");
|
||
expect(r.flags.find((f) => f.key === "closest_only")).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
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("pos_pick")).toBe("POS item the AI picked");
|
||
expect(matchKindLabel("sku")).toBe("Matched by SKU");
|
||
expect(matchKindLabel("fuzzy")).toBe("Similar items");
|
||
});
|
||
});
|