diff --git a/src/components/ai-fill-marker.test.tsx b/src/components/ai-fill-marker.test.tsx index fa083a2..7852b67 100644 --- a/src/components/ai-fill-marker.test.tsx +++ b/src/components/ai-fill-marker.test.tsx @@ -28,6 +28,67 @@ function fill(over: Partial = {}): QuoteItemAiFill { afterEach(cleanup); describe("AiFillMarker", () => { + it("shows the closest item and what differs on a closest match", () => { + render( + , + ); + 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( + , + ); + expect(screen.getByText("AI: no match")).toBeTruthy(); + }); + it("renders nothing without ai_fill", () => { const { container } = render( , diff --git a/src/components/ai-fill-marker.tsx b/src/components/ai-fill-marker.tsx index c5013f7..9661896 100644 --- a/src/components/ai-fill-marker.tsx +++ b/src/components/ai-fill-marker.tsx @@ -80,11 +80,37 @@ function FilledNotes({ fill }: { fill: QuoteItemAiFill }) { } function NoMatchNotes({ fill }: { fill: QuoteItemAiFill }) { + const closest = fill.closest; return (
AI found no match + {closest ? ( +
+ Closest + + {closest.name} + + {closest.sku ? ( + {closest.sku} + ) : null} + {closest.differs.length > 0 ? ( +
    + {closest.differs.map((d, i) => ( +
  • {d}
  • + ))} +
+ ) : null} +
+ {zar.format(Number(closest.cost_price) || 0)} + {zar.format(Number(closest.selling_price) || 0)} +
+
+ ) : null} {fill.reason ?

{fill.reason}

: null} {fill.candidates.length > 0 ? (
@@ -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)} diff --git a/src/lib/server/quote-assistant-core.test.ts b/src/lib/server/quote-assistant-core.test.ts index 2b71cef..1b20352 100644 --- a/src/lib/server/quote-assistant-core.test.ts +++ b/src/lib/server/quote-assistant-core.test.ts @@ -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,