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(" · ");