From a6d53e3b2b513a596eec36168d37b8d292190e26 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 24 Oct 2026 23:05:09 +0200 Subject: [PATCH] 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 --- .../[id]/intel/refresh/route.test.ts | 79 +++++++++++ .../[collection]/[id]/intel/refresh/route.ts | 20 ++- src/components/price-check-marker.test.tsx | 127 ++++++++++++++++++ src/components/price-check-marker.tsx | 44 ++++++ src/lib/price-check.test.ts | 60 +++++++++ src/lib/price-check.ts | 37 ++++- 6 files changed, 363 insertions(+), 4 deletions(-) create mode 100644 src/app/api/v1/items/[collection]/[id]/intel/refresh/route.test.ts create mode 100644 src/components/price-check-marker.test.tsx diff --git a/src/app/api/v1/items/[collection]/[id]/intel/refresh/route.test.ts b/src/app/api/v1/items/[collection]/[id]/intel/refresh/route.test.ts new file mode 100644 index 0000000..9fcd37c --- /dev/null +++ b/src/app/api/v1/items/[collection]/[id]/intel/refresh/route.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const getOne = vi.fn(); +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn(async () => ({ + collection: () => ({ getOne }), + })), +})); + +const populateItemIntel = vi.fn(async () => ({ summary: "computed" })); +vi.mock("@/lib/server/item-intel-service", () => ({ + populateItemIntel: (...a: unknown[]) => populateItemIntel(...(a as [])), +})); + +import { POST } from "./route"; + +function makeReq(body: unknown = {}) { + return new NextRequest( + "http://localhost/api/v1/items/order_items/i1/intel/refresh", + { method: "POST", body: JSON.stringify(body) }, + ); +} + +const params = { params: Promise.resolve({ collection: "order_items", id: "i1" }) }; + +beforeEach(() => { + vi.clearAllMocks(); + getOne.mockResolvedValue({ id: "i1", ai_item_insights: null }); + populateItemIntel.mockResolvedValue({ summary: "computed" } as never); +}); + +describe("POST /api/v1/items/{collection}/{id}/intel/refresh", () => { + it("recomputes the intel without a pick when the line has none", async () => { + const res = await POST(makeReq(), params); + expect(res.status).toBe(200); + expect(populateItemIntel).toHaveBeenCalledWith("i1", "order_items", { write: true }); + }); + + it("keeps the stored POS pick — a refresh is a no-AI recompute", async () => { + const posPick = { + stock_item_id: "si-1", + match: "closest" as const, + differs: ["length 65 vs 60"], + reason: "nearest bolt", + model: "m", + }; + getOne.mockResolvedValue({ + id: "i1", + ai_item_insights: { summary: "", pos_pick: posPick }, + }); + + const res = await POST(makeReq(), params); + expect(res.status).toBe(200); + expect(populateItemIntel).toHaveBeenCalledWith("i1", "order_items", { + write: true, + posPick, + }); + }); + + it("keeps the stored pick on a dry run too", async () => { + const posPick = { stock_item_id: "si-1", match: "exact" as const }; + getOne.mockResolvedValue({ id: "i1", ai_item_insights: { pos_pick: posPick } }); + + const res = await POST(makeReq({ dry_run: true }), params); + expect(res.status).toBe(200); + expect(populateItemIntel).toHaveBeenCalledWith("i1", "order_items", { + write: false, + posPick, + }); + }); + + it("404s when the line is gone", async () => { + getOne.mockRejectedValue(new Error("404")); + const res = await POST(makeReq(), params); + expect(res.status).toBe(404); + expect(populateItemIntel).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/v1/items/[collection]/[id]/intel/refresh/route.ts b/src/app/api/v1/items/[collection]/[id]/intel/refresh/route.ts index 661bfe4..f457009 100644 --- a/src/app/api/v1/items/[collection]/[id]/intel/refresh/route.ts +++ b/src/app/api/v1/items/[collection]/[id]/intel/refresh/route.ts @@ -50,10 +50,16 @@ export async function POST( const dryRun = body.dry_run === true; // Verify the record exists (cheap check) so the caller gets a clean - // 404 instead of a confusing 500 from the backfill service. + // 404 instead of a confusing 500 from the backfill service. The + // stored blob comes with it: a refresh must not throw away the + // AI's POS pick. const pb = await getAdminPocketBase(); + let storedIntel: ItemIntel | null = null; try { - await pb.collection(collection).getOne(id); + const existing = await pb + .collection(collection) + .getOne<{ ai_item_insights?: ItemIntel }>(id, { fields: "id,ai_item_insights" }); + storedIntel = existing?.ai_item_insights ?? null; } catch { return NextResponse.json( { error: `${collection}/${id} not found` }, @@ -61,6 +67,13 @@ export async function POST( ); } + // The refresh is a no-AI recompute — it keeps the pick the AI made. + const storedPick = storedIntel?.pos_pick; + const options = { + write: false as const, + ...(storedPick ? { posPick: storedPick } : {}), + }; + if (dryRun) { return NextResponse.json({ ok: true, @@ -70,6 +83,7 @@ export async function POST( // Surface a tiny preview of what would change by computing // the intel without writing. intel: await populateItemIntel(id, collection as "quote_items" | "order_items", { + ...options, write: false, }), }); @@ -78,7 +92,7 @@ export async function POST( const intel: ItemIntel = await populateItemIntel( id, collection as "quote_items" | "order_items", - { write: true }, + { ...options, write: true }, ); return NextResponse.json({ ok: true, collection, id, intel }); } catch (error) { diff --git a/src/components/price-check-marker.test.tsx b/src/components/price-check-marker.test.tsx new file mode 100644 index 0000000..fb008cd --- /dev/null +++ b/src/components/price-check-marker.test.tsx @@ -0,0 +1,127 @@ +/** + * price-check-marker — the blue badge and its popover. + */ + +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { ItemIntel } from "@/lib/types"; +import { PriceCheckMarker } from "./price-check-marker"; + +function intel(over: Partial = {}): ItemIntel { + return { + summary: "", + generated_at: "2026-10-24T10:00:00.000Z", + stock_list: [ + { + stock_item_id: "si-1", + score: 1, + name: "HEX BOLT M22X65 GR8.8 ZP", + sku: "BOLT-M22X65", + description: "HEX BOLT M22X65 GR8.8 ZP", + qty: 12, + min_qty: 20, + cost_price: 8, + selling_price: 12, + supplier_id: "sup1", + supplier_name: "Bolt Co", + status: "low", + }, + ], + supplier_history: [], + price_list: [], + recent_quote: [], + recent_order: [], + ...over, + }; +} + +afterEach(cleanup); + +describe("PriceCheckMarker", () => { + it("shows the exact pick's summary with no prefix", () => { + render( + , + ); + expect(screen.getByText("POS R 8,00 · 12 low stock")).toBeTruthy(); + }); + + it("leads with the closest item and what differs", () => { + render( + , + ); + expect( + screen.getByText("Closest: HEX BOLT M22X65 GR8.8 ZP (length 65 vs 60) · POS R 8,00 · 12 low stock"), + ).toBeTruthy(); + }); + + it("shows the pick and the differences at the top of the popover", async () => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /price check/i })); + + await waitFor(() => { + expect(screen.getByText("Closest POS item")).toBeTruthy(); + }); + expect(screen.getByText("length 65 vs 60")).toBeTruthy(); + expect(screen.getByText("nearest bolt")).toBeTruthy(); + expect(screen.getByText(/The POS has no exact match/)).toBeTruthy(); + }); + + it("keeps the fuzzy wording when there is no pick", () => { + render( + , + ); + expect(screen.getByText("Similar items: POS R 8,00 · 12 low stock")).toBeTruthy(); + }); +}); diff --git a/src/components/price-check-marker.tsx b/src/components/price-check-marker.tsx index f79b573..da98f44 100644 --- a/src/components/price-check-marker.tsx +++ b/src/components/price-check-marker.tsx @@ -57,6 +57,7 @@ function asIntel(raw: unknown): ItemIntel | null { if (!raw || typeof raw !== "object") return null; const i = raw as Partial; if (!Array.isArray(i.stock_list) || !Array.isArray(i.supplier_history)) return null; + const pick = i.pos_pick; return { summary: typeof i.summary === "string" ? i.summary : "", generated_at: typeof i.generated_at === "string" ? i.generated_at : "", @@ -67,6 +68,16 @@ function asIntel(raw: unknown): ItemIntel | null { recent_order: Array.isArray(i.recent_order) ? i.recent_order : [], match_kind: i.match_kind, item: i.item, + pos_pick: + pick && typeof pick === "object" && typeof pick.stock_item_id === "string" + ? { + stock_item_id: pick.stock_item_id, + match: pick.match === "closest" ? "closest" : "exact", + differs: Array.isArray(pick.differs) ? pick.differs : [], + reason: typeof pick.reason === "string" ? pick.reason : "", + model: typeof pick.model === "string" ? pick.model : "", + } + : undefined, }; } @@ -250,6 +261,39 @@ export function PriceCheckMarker({ {!loading && data ? ( <> + {data.pos_pick ? ( +
+
+ r.stock_item_id === data.pos_pick?.stock_item_id, + )?.name || "POS item" + } + value={ + data.pos_pick.match === "closest" ? "closest only" : "exact match" + } + /> + {(data.pos_pick.differs ?? []).length > 0 ? ( +
    + {(data.pos_pick.differs ?? []).map((d, i) => ( +
  • {d}
  • + ))} +
+ ) : null} + {data.pos_pick.reason ? ( +

{data.pos_pick.reason}

+ ) : null} +
+
+ ) : null} + {data.stock_list[0] ? (
diff --git a/src/lib/price-check.test.ts b/src/lib/price-check.test.ts index 87a10ee..d828116 100644 --- a/src/lib/price-check.test.ts +++ b/src/lib/price-check.test.ts @@ -142,6 +142,65 @@ describe("buildPriceCheck — summary", () => { }); }); +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( @@ -253,6 +312,7 @@ describe("buildPriceCheck — price_list_cheaper", () => { 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"); }); diff --git a/src/lib/price-check.ts b/src/lib/price-check.ts index f47fe8c..05bd168 100644 --- a/src/lib/price-check.ts +++ b/src/lib/price-check.ts @@ -37,7 +37,8 @@ export type PriceCheckFlagKey = | "cost_above_last_paid" | "cheaper_supplier" | "in_stock" - | "price_list_cheaper"; + | "price_list_cheaper" + | "closest_only"; export interface PriceCheckFlag { key: PriceCheckFlagKey; @@ -64,6 +65,8 @@ export function matchKindLabel(kind: ItemIntelMatchKind): string { switch (kind) { case "stock_item": return "POS item the AI picked"; + case "pos_pick": + return "POS item the AI picked"; case "sku": return "Matched by SKU"; default: @@ -138,6 +141,24 @@ export function buildPriceCheck( // ── Flags ────────────────────────────────────────────────────────── + // The AI could only find a CLOSEST item — the POS has nothing that + // matches the line exactly. This is the flag that matters most: the + // price shown next to it may be for a different size. + const posPick = intel.pos_pick ?? null; + const isClosest = posPick?.match === "closest"; + if (isClosest && posPick) { + const picked = intel.stock_list.find((r) => r.stock_item_id === posPick.stock_item_id); + const name = picked?.name || "the closest item we have"; + const differs = posPick.differs ?? []; + flags.push({ + key: "closest_only", + message: + `The POS has no exact match — closest is ${name}` + + (differs.length > 0 ? ` (${differs.join(", ")})` : "") + + ".", + }); + } + // 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) { @@ -228,6 +249,20 @@ export function buildPriceCheck( bits.push(`price list ${formatMoney(row.cost_price)}`); } + // A closest pick leads with what it is and what is wrong with it — + // the number behind it may be for a different size. + if (isClosest && posPick) { + const picked = intel.stock_list.find((r) => r.stock_item_id === posPick.stock_item_id); + const name = picked?.name || "the closest item we have"; + const differs = posPick.differs ?? []; + const head = `Closest: ${name}${differs.length > 0 ? ` (${differs.join(", ")})` : ""}`; + return { + summary: bits.length > 0 ? `${head} · ${bits.join(" · ")}` : head, + flags, + matchKind, + }; + } + // A fuzzy match may be a different item (an M22 bolt for an M16 line), // so the one-liner says so instead of reading like this item's price. const joined = bits.join(" · ");