feat(orders): show AI quote match on the order page

Task 8 of the AI order assistant plan.

- new AiQuoteMatchPanel: "Linked by AI" / "AI suggests Q…", the
  reason, the doubts and the line-pair table (order line -> quote line
  with same_item)
- the quote match card gains a "Match with AI" button in every state,
  posts to /api/v1/orders/{id}/assistant/run with the PB token, toasts
  the 409 message verbatim, then reloads the order
- new card state: the AI suggested a quote -> the AI panel + Accept
  (links the AI's quote) / Match with AI / Dismiss
- the AI suggestion marks the order as quote_matching_attempted so the
  deterministic sweep cannot overwrite it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-24 13:13:28 +02:00
parent 85533245bf
commit 00d09b3b12
5 changed files with 506 additions and 8 deletions

View file

@ -42,6 +42,7 @@ import {
Send,
MessageSquare,
Paperclip,
Bot,
} from "lucide-react";
import {
Dialog,
@ -66,6 +67,10 @@ import { ItemStatusPill } from "@/components/item-status-pill";
import { QuoteSearch } from "@/components/ui/quote-search";
import { SendPoModal } from "@/components/purchase-orders/send-po-modal";
import { QuoteLineContext } from "@/components/orders/quote-line-context";
import {
AiQuoteMatchPanel,
type AiMatchLine,
} from "@/components/orders/ai-quote-match-panel";
import type { MatchedAttachment } from "@/components/orders/attachment-list";
import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger";
@ -141,6 +146,23 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
const [matching, setMatching] = useState(false);
const [matchResult, setMatchResult] = useState<QuoteMatchResult | null>(null);
// ─── AI order assistant ──────────────────────────────────────
// `order.ai_quote_match` is written by the server-side assistant
// (src/lib/server/order-assistant-service.ts). The page only shows
// what it decided and offers a "Match with AI" button to run it now.
const [aiMatching, setAiMatching] = useState(false);
// The quote lines of the quote the assistant chose — fetched so the
// line-pair table can show descriptions instead of bare ids.
const [aiQuoteLines, setAiQuoteLines] = useState<AiMatchLine[]>([]);
// The assistant's own suggestion, kept apart from `matchResult` (the
// rule-based matcher). The assistant writes `potential_quote` too, but
// the card shows the AI's reasoning when the AI was the one that ran.
const [aiSuggestion, setAiSuggestion] = useState<{
quoteId: string;
quoteNumber: string;
confidence: number;
} | null>(null);
const fetchOrder = useCallback(async () => {
setLoading(true);
try {
@ -171,6 +193,19 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
// reload. (`best` is null when method === "no_match" — the
// operator's intent is encoded in the saved fields, so we
// don't need to re-run the matcher to render the verdict.)
setAiSuggestion(null);
if (data.quote_match_method === "ai_suggest" && data.ai_quote_match?.quote_id) {
// The AI order assistant suggested this quote. It has its own
// card state, so it is kept out of the rule-based matcher state.
setAiSuggestion({
quoteId: data.ai_quote_match.quote_id,
quoteNumber:
data.ai_quote_match.quote_number ||
data.expand?.potential_quote?.quote_number ||
"",
confidence: data.potential_quote_confidence,
});
}
if (data.quote_matching_attempted) {
const method = data.quote_match_method || "no_match";
const allowedMethods: ReadonlyArray<QuoteMatchResult["method"]> = [
@ -263,6 +298,36 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
void fetchQuoteContext();
}, [fetchOrder, fetchQuoteContext]);
// The quote lines of the quote the assistant chose — so the line-pair
// table can show what was matched instead of bare ids. Non-fatal: the
// panel still renders with ids when this fails.
const aiQuoteId = order?.ai_quote_match?.quote_id ?? null;
useEffect(() => {
if (!aiQuoteId) {
setAiQuoteLines([]);
return;
}
let cancelled = false;
void (async () => {
try {
const pb = getPocketBase();
const res = await pb.collection("quote_items").getList<{ id: string; description?: string }>(
1,
200,
{ filter: `quote = "${aiQuoteId}"`, fields: "id,description" },
);
if (!cancelled) {
setAiQuoteLines(res.items.map((l) => ({ id: l.id, description: l.description || "" })));
}
} catch {
if (!cancelled) setAiQuoteLines([]);
}
})();
return () => {
cancelled = true;
};
}, [aiQuoteId]);
const handleDelete = async () => {
try {
const pb = getPocketBase();
@ -422,8 +487,53 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
}
}, [order, refInput, fetchOrder, fetchQuoteContext]);
const clearMatch = useCallback(async () => {
// ─── Match with AI ───────────────────────────────────────────
// Runs the server-side order assistant for this one order. The
// assistant decides; the route never changes a line's status and
// never sends anything.
const runAiMatch = useCallback(async () => {
if (!order) return;
setAiMatching(true);
try {
const token = getPocketBase().authStore.token;
const res = await fetch(
`/api/v1/orders/${encodeURIComponent(order.id)}/assistant/run`,
{
method: "POST",
headers: token ? { Authorization: token } : {},
},
);
const data = (await res.json().catch(() => ({}))) as {
outcome?: string;
quoteId?: string | null;
error?: string;
};
if (!res.ok) {
// 409 carries a message the operator needs to see verbatim
// ("AI is disabled in settings", "not set up yet…").
throw new Error(data?.error || "AI match failed");
}
if (data.outcome === "linked") {
toast.success("AI linked this order to a quote");
} else if (data.outcome === "suggested") {
toast.message("AI suggests a quote — review it below and accept or dismiss");
} else if (data.outcome === "no_match") {
toast.info("AI found no matching quote");
} else if (data.outcome === "skipped") {
toast.info("Nothing for the AI to match on this order");
} else {
toast.error(data?.error || "AI match failed");
}
await fetchOrder();
await fetchQuoteContext();
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "AI match failed");
} finally {
setAiMatching(false);
}
}, [order, fetchOrder, fetchQuoteContext]);
const clearMatch = useCallback(async () => { if (!order) return;
try {
// Empty quote_id is the unlink path — see route.ts.
const res = await fetch(`/api/v1/orders/${order.id}/quote`, {
@ -585,6 +695,11 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
onResuggestMatch={runMatch}
onClearMatch={clearMatch}
onLinkQuoteManually={linkQuoteManually}
onMatchWithAi={runAiMatch}
aiMatching={aiMatching}
aiQuoteLines={aiQuoteLines}
orderLines={items.map((l) => ({ id: l.id, description: l.description || "" }))}
aiSuggestion={aiSuggestion}
/>
{/* ─── Order items table ─────────────────────────────────── */}
@ -1248,15 +1363,21 @@ function CompletePill() {
/* ============================================================================
* QuoteMatchCard
*
* One dedicated card for the order ↔ quote relationship. Three states:
* One dedicated card for the order ↔ quote relationship. Four states:
*
* 1. HARD LINK present (order.quote is set) — show the linked quote
* prominently. Show a "matched by …" line so the operator knows
* how the link was made. Subtle "Open link" link to view the
* quote, plus a "Re-match" affordance for the rare case they
* want to fix a wrong link.
* want to fix a wrong link. When the AI order assistant made the
* link, the AI panel shows its reasoning next to it.
*
* 2. SOFT SUGGESTION present (potential_quote, no hard link) —
* 2. THE AI ASSISTANT SUGGESTED A QUOTE (`ai_quote_match.status ===
* "suggested"`) — show the AI panel with its reasoning, doubts and
* line pairs, then Accept / Match with AI / Dismiss. The AI was
* not sure, so a person decides.
*
* 3. SOFT SUGGESTION present (potential_quote, no hard link) —
* show the suggested quote with confidence + method, with
* Accept / Try again / Clear actions. This is the most common
* state on freshly created orders from manual channels. When
@ -1264,7 +1385,7 @@ function CompletePill() {
* score breakdown so the operator can audit each signal —
* that's the trust backbone for the rule-based system.
*
* 3. EMPTY (no hard link, no suggestion, not yet tried) — show the
* 4. EMPTY (no hard link, no suggestion, not yet tried) — show the
* Re-match button (runs the rule-based scorer against all of
* the company's quotes) plus an optional reference input for
* the deterministic exact-key path. This is the friction fix:
@ -1272,6 +1393,9 @@ function CompletePill() {
* Reference text is optional because the rule-based scorer
* works from company + customer + items alone.
*
* Every state also carries a "Match with AI" button, so the operator
* can ask the AI to work it out at any time.
*
* Stays a single card so the operator's mental model is one place
* to think about the order ↔ quote relationship, instead of three
* scattered badges / inputs.
@ -1405,6 +1529,11 @@ function QuoteMatchCard({
onResuggestMatch,
onClearMatch,
onLinkQuoteManually,
onMatchWithAi,
aiMatching,
aiQuoteLines,
orderLines,
aiSuggestion,
}: {
order: Order;
refInput: string;
@ -1418,12 +1547,52 @@ function QuoteMatchCard({
onResuggestMatch: () => void;
onClearMatch: () => void;
onLinkQuoteManually: (quoteId: string) => void;
onMatchWithAi: () => void;
aiMatching: boolean;
aiQuoteLines: AiMatchLine[];
orderLines: AiMatchLine[];
aiSuggestion: { quoteId: string; quoteNumber: string; confidence: number } | null;
}) {
// Three rendering modes:
const hasSuggestion = !!(matchResult && matchResult.best);
const lastMethod = matchResult?.method ?? null;
const lastAttempted = !!order.quote_matching_attempted;
// The AI order assistant's verdict on this order. Shown when the
// assistant produced one — "ai_auto" (it linked the order) or
// "ai_suggest" (it is suggesting a quote).
const aiMatch = order.ai_quote_match ?? null;
const showAiPanel =
!!aiMatch &&
(order.quote_match_method === "ai_auto" || order.quote_match_method === "ai_suggest");
const aiPanel = showAiPanel ? (
<AiQuoteMatchPanel
match={aiMatch}
orderLines={orderLines}
quoteLines={aiQuoteLines}
linkedQuoteId={hardLinkQuoteId ?? null}
/>
) : null;
// Every state gets the same affordance: ask the AI to work it out.
const matchWithAiButton = (
<Button
variant="outline"
size="sm"
onClick={onMatchWithAi}
disabled={aiMatching || matching}
data-testid="match-with-ai-button"
>
{aiMatching ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Bot className="mr-2 h-3.5 w-3.5" />
)}
Match with AI
</Button>
);
// Pending quote pick from the "Browse quotes…" dropdown. When the
// operator picks one and there's already a link (state 1) or
// suggestion (state 2), we want to confirm before overwriting.
@ -1499,6 +1668,7 @@ function QuoteMatchCard({
{matching ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Sparkles className="mr-2 h-3.5 w-3.5" />}
Re-match
</Button>
{matchWithAiButton}
{searchDropdown}
<Button
variant="ghost"
@ -1511,6 +1681,7 @@ function QuoteMatchCard({
Unlink
</Button>
</div>
{aiPanel}
</CardContent>
<Dialog
open={!!pendingPick}
@ -1541,7 +1712,64 @@ function QuoteMatchCard({
);
}
// ─── State 2: soft suggestion present ──────────────────────
// ─── State 2: the AI assistant suggested a quote ────────────
// The assistant is not sure, so a person decides. The AI panel
// carries the reasoning, the doubts and the line pairs; the Accept
// button links the quote the assistant chose.
if (aiSuggestion) {
const aiNum = aiSuggestion.quoteNumber || "(quote)";
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Bot className="h-4 w-4 text-ai" />
AI Suggests a Quote
</CardTitle>
<CardDescription>
The AI matched this order to a quote but is not sure enough to link it
itself. Review its reasoning, then accept or dismiss.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{aiPanel}
<div className="flex items-center gap-3 flex-wrap">
<Button
variant="outline"
className="h-auto py-2 px-3 font-mono text-sm"
onClick={() => onOpenHardLink(aiSuggestion.quoteId)}
>
<ExternalLink className="mr-2 h-3.5 w-3.5" />
{aiNum}
</Button>
<Badge variant="secondary" className="text-xs">
{(aiSuggestion.confidence * 100).toFixed(0)}% confidence
</Badge>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button
size="sm"
onClick={() => onLinkQuoteManually(aiSuggestion.quoteId)}
disabled={matching}
>
{matching ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-3.5 w-3.5" />
)}
Accept
</Button>
{matchWithAiButton}
<Button variant="ghost" size="sm" onClick={onClearMatch} disabled={matching} className="text-muted-foreground">
<X className="mr-2 h-3.5 w-3.5" />
Dismiss
</Button>
</div>
</CardContent>
</Card>
);
}
// ─── State 3: soft suggestion present ──────────────────────
if (hasSuggestion && matchResult?.best) {
const cand = matchResult.best;
const qnum = cand.quote.quote_number || "(quote)";
@ -1575,6 +1803,7 @@ function QuoteMatchCard({
</span>
</div>
{cand.breakdown && <ScoreBreakdownPanel breakdown={cand.breakdown} />}
{aiPanel}
<div className="flex items-center gap-2 flex-wrap">
<Button size="sm" onClick={onAcceptMatch} disabled={matching}>
{matching ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="mr-2 h-3.5 w-3.5" />}
@ -1583,6 +1812,7 @@ function QuoteMatchCard({
<Button variant="ghost" size="sm" onClick={onResuggestMatch} disabled={matching}>
Try again
</Button>
{matchWithAiButton}
{searchDropdown}
<Button variant="ghost" size="sm" onClick={onClearMatch} disabled={matching} className="text-muted-foreground">
<X className="mr-2 h-3.5 w-3.5" />
@ -1594,7 +1824,7 @@ function QuoteMatchCard({
);
}
// ─── State 3: empty / tried-but-no-match ───────────────────
// ─── State 4: empty / tried-but-no-match ───────────────────
// The primary CTA is **Re-match** — runs the rule-based scorer
// against every quote in the company. No reference text required:
// the matcher uses company + customer + items + price alone. The
@ -1624,6 +1854,7 @@ function QuoteMatchCard({
{matching ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
Re-match
</Button>
{matchWithAiButton}
{lastAttempted && (
<Button
type="button"

View file

@ -0,0 +1,105 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { OrderAiQuoteMatch } from "@/lib/types";
import { AiQuoteMatchPanel } from "./ai-quote-match-panel";
afterEach(cleanup);
function match(over: Partial<OrderAiQuoteMatch> = {}): OrderAiQuoteMatch {
return {
status: "suggested",
quote_id: "q1",
quote_number: "Q-2026-10-014",
confidence: "high",
reason: "Same items, slightly different quantities.",
doubts: ["Quote price was R3.40, order shows R3.50"],
lines: [
{ order_line_id: "ol1", quote_line_id: "ql1", quote_id: "q1", same_item: "sure", reason: "same" },
{ order_line_id: "ol2", quote_line_id: null, quote_id: null, same_item: null, reason: "" },
],
auto_linked: false,
lines_hash: "h",
model: "m",
run_at: "2026-10-24T10:00:00.000Z",
...over,
};
}
const ORDER_LINES = [
{ id: "ol1", description: "M16 bolt 60mm" },
{ id: "ol2", description: "M16 nut" },
];
const QUOTE_LINES = [{ id: "ql1", description: "M16 x 60 bolt" }];
describe("AiQuoteMatchPanel", () => {
it("shows the suggestion headline with the quote number", () => {
render(<AiQuoteMatchPanel match={match()} orderLines={ORDER_LINES} quoteLines={QUOTE_LINES} />);
expect(screen.getByText("AI suggests Q-2026-10-014")).toBeTruthy();
});
it("shows 'Linked by AI' when the assistant made the link", () => {
render(
<AiQuoteMatchPanel
match={match({ status: "matched", auto_linked: true })}
orderLines={ORDER_LINES}
quoteLines={QUOTE_LINES}
linkedQuoteId="q1"
/>,
);
expect(screen.getByText("Linked by AI")).toBeTruthy();
});
it("does not claim a link when the order points at another quote", () => {
render(
<AiQuoteMatchPanel
match={match({ status: "matched", auto_linked: true })}
orderLines={ORDER_LINES}
quoteLines={QUOTE_LINES}
linkedQuoteId="q-other"
/>,
);
expect(screen.getByText("AI suggests Q-2026-10-014")).toBeTruthy();
expect(screen.queryByText("Linked by AI")).toBeNull();
});
it("shows the reason", () => {
render(<AiQuoteMatchPanel match={match()} orderLines={ORDER_LINES} quoteLines={QUOTE_LINES} />);
expect(screen.getByText("Same items, slightly different quantities.")).toBeTruthy();
});
it("lists the doubts", () => {
render(<AiQuoteMatchPanel match={match()} orderLines={ORDER_LINES} quoteLines={QUOTE_LINES} />);
expect(screen.getByText("Quote price was R3.40, order shows R3.50")).toBeTruthy();
});
it("shows the line pairs with the order and quote descriptions", () => {
render(<AiQuoteMatchPanel match={match()} orderLines={ORDER_LINES} quoteLines={QUOTE_LINES} />);
expect(screen.getByText("M16 bolt 60mm")).toBeTruthy();
expect(screen.getByText("M16 x 60 bolt")).toBeTruthy();
expect(screen.getByText("sure")).toBeTruthy();
expect(screen.getByText("— no quote line —")).toBeTruthy();
});
it("says so when no quote was found", () => {
render(
<AiQuoteMatchPanel
match={match({ status: "no_match", quote_id: null, quote_number: "", confidence: null })}
orderLines={ORDER_LINES}
quoteLines={QUOTE_LINES}
/>,
);
expect(screen.getByText("AI found no matching quote")).toBeTruthy();
});
it("shows the recorded error", () => {
render(
<AiQuoteMatchPanel
match={match({ status: "error", error: "AI exploded" })}
orderLines={ORDER_LINES}
quoteLines={QUOTE_LINES}
/>,
);
expect(screen.getByText("AI run failed: AI exploded")).toBeTruthy();
});
});

View file

@ -0,0 +1,137 @@
import { Bot, CircleCheck, CircleHelp, TriangleAlert } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import type { OrderAiQuoteMatch } from "@/lib/types";
/**
* The AI order assistant's verdict on this order.
*
* Two states matter to an operator:
* - "Linked by AI" — the assistant linked the order itself, because it
* was sure. The Unlink button next to it is the way back.
* - "AI suggests Q…" — the assistant is not sure, so a person decides.
*
* The line-pair table is the audit trail: which order line went to which
* quote line, and how sure the AI was about each one.
*/
export type AiMatchLine = { id: string; description?: string };
const SAME_ITEM_LABEL: Record<string, string> = {
sure: "sure",
likely: "likely",
unsure: "unsure",
};
const SAME_ITEM_CLASS: Record<string, string> = {
sure: "border-emerald-200 bg-emerald-50 text-emerald-700",
likely: "border-amber-200 bg-amber-50 text-amber-700",
unsure: "border-zinc-200 bg-zinc-50 text-zinc-700",
};
export function AiQuoteMatchPanel({
match,
orderLines = [],
quoteLines = [],
linkedQuoteId,
}: {
match: OrderAiQuoteMatch;
orderLines?: AiMatchLine[];
quoteLines?: AiMatchLine[];
/** The order's hard-linked quote id, when it has one. */
linkedQuoteId?: string | null;
}) {
const orderById = new Map(orderLines.map((l) => [l.id, l.description || ""]));
const quoteById = new Map(quoteLines.map((l) => [l.id, l.description || ""]));
const linkedByAi = !!match.auto_linked && !!linkedQuoteId && linkedQuoteId === match.quote_id;
const quoteLabel = match.quote_number || "a quote";
const headline = linkedByAi
? "Linked by AI"
: match.status === "no_match"
? "AI found no matching quote"
: `AI suggests ${quoteLabel}`;
const HeadlineIcon = linkedByAi ? CircleCheck : match.status === "no_match" ? CircleHelp : Bot;
return (
<div
data-testid="ai-quote-match-panel"
className="rounded-md border border-ai/30 bg-ai/10 px-3 py-2.5 space-y-2"
>
<div className="flex items-center gap-2 flex-wrap">
<HeadlineIcon className="h-4 w-4 text-ai" aria-hidden />
<span className="text-sm font-semibold text-ai">{headline}</span>
{match.confidence && (
<Badge variant="outline" className="text-xs border-ai/30 bg-background text-ai">
{match.confidence} confidence
</Badge>
)}
</div>
{match.reason ? <p className="text-xs">{match.reason}</p> : null}
{match.doubts.length > 0 ? (
<div className="rounded-md border border-warn/30 bg-warn/10 px-2 py-1.5">
<span className="text-xs font-medium text-warn flex items-center gap-1">
<TriangleAlert className="h-3 w-3" aria-hidden />
Doubts
</span>
<ul className="list-disc pl-4 text-xs text-warn">
{match.doubts.map((d, i) => (
<li key={`${i}-${d}`}>{d}</li>
))}
</ul>
</div>
) : null}
{match.lines.length > 0 ? (
<div className="rounded-md border bg-background/60 px-2 py-1.5">
<span className="text-xs font-medium">Line pairs</span>
<table className="mt-1 w-full text-xs">
<thead>
<tr className="text-left text-muted-foreground">
<th className="font-normal py-0.5 pr-2">Order line</th>
<th className="font-normal py-0.5 pr-2">Quote line</th>
<th className="font-normal py-0.5">Match</th>
</tr>
</thead>
<tbody>
{match.lines.map((line) => (
<tr key={line.order_line_id} className="align-top">
<td className="py-0.5 pr-2">
{orderById.get(line.order_line_id) || line.order_line_id}
</td>
<td className="py-0.5 pr-2">
{line.quote_line_id
? quoteById.get(line.quote_line_id) || line.quote_line_id
: "— no quote line —"}
</td>
<td className="py-0.5">
{line.same_item ? (
<span
className={`inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium ${
SAME_ITEM_CLASS[line.same_item] ??
"border-zinc-200 bg-zinc-50 text-zinc-700"
}`}
>
{SAME_ITEM_LABEL[line.same_item] ?? line.same_item}
</span>
) : (
<span className="text-muted-foreground">—</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{match.error ? (
<p className="text-xs text-destructive">AI run failed: {match.error}</p>
) : null}
</div>
);
}

View file

@ -285,9 +285,27 @@ describe("matchOrder", () => {
);
});
it("suggests (and does not link) on medium confidence", async () => {
it("marks the order as attempted so the sweep leaves the suggestion alone", async () => {
const pb = makePb();
mockAdminPb(pb);
await matchOrder("ord1", {
deps: {
link: vi.fn(),
match: vi.fn().mockResolvedValue(aiDecision({ confidence: "medium" })),
suggest: vi.fn().mockResolvedValue(SUGGESTION),
},
});
const orderUpdate = pb.collection(ORDERS).update as ReturnType<typeof vi.fn>;
expect(orderUpdate).toHaveBeenCalledWith(
"ord1",
expect.objectContaining({ quote_matching_attempted: true }),
);
});
it("suggests (and does not link) on medium confidence", async () => { const pb = makePb();
mockAdminPb(pb);
const link = vi.fn();
const res = await matchOrder("ord1", {

View file

@ -276,6 +276,7 @@ export async function matchOrder(
if (shortlist.length === 0) {
await pb.collection("orders").update(orderId, {
quote_matching_attempted: true,
quote_match_method: "ai_no_match",
potential_quote: "",
potential_quote_confidence: 0,
@ -481,6 +482,7 @@ export async function matchOrder(
await writeAiPairs(pb, orderId, aiLines);
await pb.collection("orders").update(orderId, {
quote_matching_attempted: true,
quote_match_method: "ai_auto",
potential_quote: "",
potential_quote_confidence: 0,
@ -500,6 +502,7 @@ export async function matchOrder(
// No plausible quote.
await pb.collection("orders").update(orderId, {
quote_matching_attempted: true,
quote_match_method: "ai_no_match",
potential_quote: "",
potential_quote_confidence: 0,
@ -518,6 +521,10 @@ async function writeSuggestion(
quoteId: string,
): Promise<void> {
await pb.collection("orders").update(orderId, {
// The deterministic sweep only touches orders that were never
// attempted. Marking this one attempted stops the sweep from
// overwriting the AI's suggestion with a rule-based one.
quote_matching_attempted: true,
potential_quote: quoteId,
potential_quote_confidence: confidenceToScore(decision.confidence),
quote_match_method: "ai_suggest",