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:
parent
a705215f82
commit
b7e80e7f85
3 changed files with 150 additions and 1 deletions
|
|
@ -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" }} />,
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue