feat(quote-assistant): price only an exact match, show the closest

A near-miss used to be priced as if it were the item the customer
asked for. Now only an exact match writes a price; a closest match
writes none and records the nearest item with what differs, so the
rep can see it and decide.

The "AI: no match" badge reads "Closest: {name} ({differs})" when
there is a closest item, and the popover shows its price and the
differences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-24 22:59:25 +02:00
parent a705215f82
commit b7e80e7f85
3 changed files with 150 additions and 1 deletions

View file

@ -28,6 +28,67 @@ function fill(over: Partial<QuoteItemAiFill> = {}): QuoteItemAiFill {
afterEach(cleanup);
describe("AiFillMarker", () => {
it("shows the closest item and what differs on a closest match", () => {
render(
<AiFillMarker
item={{
id: "i1",
ai_fill: fill({
status: "no_match",
stock_item_id: null,
stock_item_name: "",
stock_item_sku: "",
cost_price: 0,
selling_price: 0,
markup: 0,
supplier_id: "",
confidence: "low",
reason: "nearest bolt",
doubts: [],
closest: {
stock_item_id: "stk9",
name: "HEX BOLT M22X65 GR8.8 ZP",
sku: "BOLT-M22X65",
cost_price: 8,
selling_price: 12,
differs: ["length 65 vs 60", "thread M22 vs M16"],
},
}),
pricing_source: "ai_email",
}}
/>,
);
const badge = screen.getByText(
"Closest: HEX BOLT M22X65 GR8.8 ZP (length 65 vs 60, thread M22 vs M16)",
);
expect(badge).toBeTruthy();
});
it("still says AI: no match when there is no closest item", () => {
render(
<AiFillMarker
item={{
id: "i1",
ai_fill: fill({
status: "no_match",
stock_item_id: null,
stock_item_name: "",
stock_item_sku: "",
cost_price: 0,
selling_price: 0,
markup: 0,
supplier_id: "",
confidence: null,
reason: "nothing in the catalogue",
doubts: [],
}),
pricing_source: "ai_email",
}}
/>,
);
expect(screen.getByText("AI: no match")).toBeTruthy();
});
it("renders nothing without ai_fill", () => {
const { container } = render(
<AiFillMarker item={{ id: "i1", ai_fill: null, pricing_source: "manual" }} />,

View file

@ -80,11 +80,37 @@ function FilledNotes({ fill }: { fill: QuoteItemAiFill }) {
}
function NoMatchNotes({ fill }: { fill: QuoteItemAiFill }) {
const closest = fill.closest;
return (
<div className="flex flex-col gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
AI found no match
</span>
{closest ? (
<div className="flex flex-col gap-1 rounded-md border border-warn/30 bg-warn/10 px-2 py-1.5">
<span className="text-xs font-medium text-warn">Closest</span>
<Link
href={`/stock/${closest.stock_item_id}`}
className="text-sm font-medium text-primary underline-offset-2 hover:underline"
>
{closest.name}
</Link>
{closest.sku ? (
<span className="font-mono text-xs text-muted-foreground">{closest.sku}</span>
) : null}
{closest.differs.length > 0 ? (
<ul className="list-disc pl-4 text-xs text-warn">
{closest.differs.map((d, i) => (
<li key={`${i}-${d}`}>{d}</li>
))}
</ul>
) : null}
<div className="flex flex-col gap-1">
<Row label="Cost">{zar.format(Number(closest.cost_price) || 0)}</Row>
<Row label="Selling price">{zar.format(Number(closest.selling_price) || 0)}</Row>
</div>
</div>
) : null}
{fill.reason ? <p className="text-xs">{fill.reason}</p> : null}
{fill.candidates.length > 0 ? (
<div className="flex flex-col gap-1">
@ -108,6 +134,15 @@ function NoMatchNotes({ fill }: { fill: QuoteItemAiFill }) {
);
}
/** The badge text: the closest item when the AI found one, else the
* plain "no match". */
export function aiFillBadgeLabel(fill: QuoteItemAiFill): string {
const closest = fill.closest;
if (!closest) return "AI: no match";
const differs = closest.differs.length > 0 ? ` (${closest.differs.join(", ")})` : "";
return `Closest: ${closest.name}${differs}`;
}
export function AiFillMarker({
item,
className,
@ -151,7 +186,7 @@ function PosAiFillMarker({
className,
)}
>
AI: no match
{aiFillBadgeLabel(fill)}
</PopoverTrigger>
<PopoverContent align="start">
<NoMatchNotes fill={fill} />

View file

@ -256,6 +256,59 @@ describe("meetsConfidence", () => {
describe("buildLinePatch", () => {
const base = { minConfidence: "medium" as const, model: "MiniMax-M2", now: NOW };
it("writes no price for a closest match and records what differs", () => {
const patch = buildLinePatch({
...base,
item: line(),
decision: decision({
match: "closest",
differs: ["length 65 vs 60"],
confidence: "high",
}),
candidates: [cand()],
});
expect(patch).not.toHaveProperty("cost_price");
expect(patch).not.toHaveProperty("selling_price");
expect(patch).not.toHaveProperty("markup");
expect(patch).not.toHaveProperty("pricing_source");
const fill = patch.ai_fill as QuoteItemAiFill;
expect(fill.status).toBe("no_match");
expect(fill.stock_item_id).toBeNull();
expect(fill.cost_price).toBe(0);
expect(fill.closest).toEqual({
stock_item_id: "s-1",
name: "M10 Bolt Zinc",
sku: "BLT10",
cost_price: 1.89,
selling_price: 3.4,
differs: ["length 65 vs 60"],
});
});
it("records no closest item for a plain no-match", () => {
const patch = buildLinePatch({
...base,
item: line(),
decision: decision({ stock_item_id: null, match: "none" }),
candidates: [cand()],
});
const fill = patch.ai_fill as QuoteItemAiFill;
expect(fill.status).toBe("no_match");
expect(fill.closest).toBeUndefined();
});
it("writes the price for an exact match", () => {
const patch = buildLinePatch({
...base,
item: line(),
decision: decision({ match: "exact", differs: [] }),
candidates: [cand()],
});
expect(patch.cost_price).toBe(1.89);
expect((patch.ai_fill as QuoteItemAiFill).status).toBe("filled");
expect((patch.ai_fill as QuoteItemAiFill).closest).toBeUndefined();
});
it("keeps a supplier already on the line (still records the POS supplier in ai_fill)", () => {
const patch = buildLinePatch({
...base,