WFDASH-WEBAPP/src/lib/price-check.test.ts
Dave a6d53e3b2b feat(price-check): say whether the POS item is exact or only closest
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>
2026-10-24 23:05:09 +02:00

319 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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");
});
});