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>
This commit is contained in:
parent
c5cfca1955
commit
a6d53e3b2b
6 changed files with 363 additions and 4 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
127
src/components/price-check-marker.test.tsx
Normal file
127
src/components/price-check-marker.test.tsx
Normal file
|
|
@ -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> = {}): 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(
|
||||
<PriceCheckMarker
|
||||
itemId="i1"
|
||||
collection="quote_items"
|
||||
cost_price={8}
|
||||
selling_price={12}
|
||||
supplier="sup1"
|
||||
intel={intel({ match_kind: "pos_pick", pos_pick: { stock_item_id: "si-1", match: "exact" } })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("POS R 8,00 · 12 low stock")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leads with the closest item and what differs", () => {
|
||||
render(
|
||||
<PriceCheckMarker
|
||||
itemId="i1"
|
||||
collection="quote_items"
|
||||
cost_price={8}
|
||||
selling_price={12}
|
||||
supplier="sup1"
|
||||
intel={
|
||||
intel({
|
||||
match_kind: "pos_pick",
|
||||
pos_pick: {
|
||||
stock_item_id: "si-1",
|
||||
match: "closest",
|
||||
differs: ["length 65 vs 60"],
|
||||
reason: "nearest bolt",
|
||||
},
|
||||
})
|
||||
}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<PriceCheckMarker
|
||||
itemId="i1"
|
||||
collection="quote_items"
|
||||
cost_price={8}
|
||||
selling_price={12}
|
||||
supplier="sup1"
|
||||
intel={
|
||||
intel({
|
||||
match_kind: "pos_pick",
|
||||
pos_pick: {
|
||||
stock_item_id: "si-1",
|
||||
match: "closest",
|
||||
differs: ["length 65 vs 60"],
|
||||
reason: "nearest bolt",
|
||||
},
|
||||
})
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<PriceCheckMarker
|
||||
itemId="i1"
|
||||
collection="quote_items"
|
||||
cost_price={8}
|
||||
selling_price={12}
|
||||
supplier="sup1"
|
||||
intel={intel({ match_kind: "fuzzy" })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Similar items: POS R 8,00 · 12 low stock")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -57,6 +57,7 @@ function asIntel(raw: unknown): ItemIntel | null {
|
|||
if (!raw || typeof raw !== "object") return null;
|
||||
const i = raw as Partial<ItemIntel>;
|
||||
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 ? (
|
||||
<Section
|
||||
title={
|
||||
data.pos_pick.match === "closest"
|
||||
? "Closest POS item"
|
||||
: "POS item the AI picked"
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-1 rounded-md border border-border/60 bg-card px-2 py-1.5">
|
||||
<Line
|
||||
label={
|
||||
data.stock_list.find(
|
||||
(r) => 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 ? (
|
||||
<ul className="list-disc pl-4 text-[11px] text-warn">
|
||||
{(data.pos_pick.differs ?? []).map((d, i) => (
|
||||
<li key={`${i}-${d}`}>{d}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{data.pos_pick.reason ? (
|
||||
<p className="text-[11px] text-muted-foreground">{data.pos_pick.reason}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{data.stock_list[0] ? (
|
||||
<Section title="POS stock">
|
||||
<div className="flex flex-col gap-0.5 rounded-md border border-border/60 bg-card px-2 py-1.5">
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(" · ");
|
||||
|
|
|
|||
Loading…
Reference in a new issue