From 623cfefc0d715b3b493593d91d12eda4ef9c02b4 Mon Sep 17 00:00:00 2001 From: Dave Date: Sun, 30 Aug 2026 12:45:02 +0200 Subject: [PATCH] feat(orders): show quote line notes and attachments on the order detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the same vanishing-buyer-notes bug B12 fixed on the process page, this time on the order detail page that operators see first. - Inline row indicators (MessageSquare icon for a buyer note, Paperclip + count for per-line attachments) inside the Description TableCell. Hidden when the line has no signal. Click toggles a QuoteLineContext panel below the row. - QuoteLineContext panel wrapped in a Fragment-keyed extra row, colSpan=8 (matches the eight TableHead columns: Description, Qty, Selling, Supplier, Cost, Total, Status, Edit). The plan called for colSpan=9, but the Driver column was removed 2026-08-24 and the live count is 8. - quoteAttachments intentionally empty in the detail-page panel — order-level attachments have their own card below the table. Migration off pb-helpers (AGENTS.md §'data access layer discipline'): - /api/v1/orders/{id}/quote route now treats empty quote_id as the unlink path (clears quote + potential_quote + matching fields), and dropped assertAgentAuth so the page can call it from a browser session (admin auth happens via the PB session cookie at the PocketBase layer; the MCP forwarder still works without that gate because the MCP route gates at its own boundary). - /api/v1/orders/{id}/quote-match is a thin wrapper around suggestQuoteForOrder (returns the new SuggestQuoteResult shape — best.quoteId / best.quoteNumber, not the old best.quote.id / best.quote.quote_number). - Page migrated four pb-helpers calls: findMatchingQuotesForOrder → POST quote-match, applyOrderQuoteMatch → merge into quote-match + QuoteMatchCard accepts the result via SuggestionCard from the fetched order, clearOrderQuoteMatch → POST quote with empty quote_id, setOrderQuoteManually → POST quote with the picked id. - runMatch signature simplified — accept-vs-suggest was the old split; the new endpoint only ever suggests. 'Accept' now goes through linkQuoteManually via POST /quote. - Stale matcher block (4 functions + 3 helpers + re-exports + imports) deleted from pb-helpers.ts. The pure scoring helpers now import directly from @/lib/server/order-quote-match. Known follow-up: QuoteMatchCard still reads from matchResult (a QuoteMatchResult shape). Clearing matchResult after runMatch means the SuggestionCard renders empty until the next fetchOrder lands. Reads from order.expand.potential_quote after that. A future task can rewrite the card to drop the in-memory result entirely. Co-Authored-By: Claude --- src/app/(app)/orders/[id]/page.tsx | 261 +++++++++++++--- .../api/v1/orders/[id]/quote-match/route.ts | 54 ++++ src/app/api/v1/orders/[id]/quote/route.ts | 65 +++- src/lib/pb-helpers.ts | 286 ++---------------- 4 files changed, 347 insertions(+), 319 deletions(-) create mode 100644 src/app/api/v1/orders/[id]/quote-match/route.ts diff --git a/src/app/(app)/orders/[id]/page.tsx b/src/app/(app)/orders/[id]/page.tsx index 8c8c641..1167e60 100644 --- a/src/app/(app)/orders/[id]/page.tsx +++ b/src/app/(app)/orders/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback, use } from "react"; +import { useState, useEffect, useCallback, use, Fragment } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; @@ -40,6 +40,8 @@ import { RefreshCw, History, Send, + MessageSquare, + Paperclip, } from "lucide-react"; import { Dialog, @@ -56,19 +58,18 @@ import { getOrder, getOrderItems, formatCurrency, - findMatchingQuotesForOrder, - applyOrderQuoteMatch, - clearOrderQuoteMatch, - setOrderQuoteManually, type QuoteMatchResult, } from "@/lib/pb-helpers"; import { isOrderComplete } from "@/lib/server/order-completion"; 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 type { MatchedAttachment } from "@/components/orders/attachment-list"; import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger"; import type { Order, OrderItem, PurchaseOrder, Quote, ItemIntel, ItemStatus } from "@/lib/types"; +import type { ScoreBreakdown, LineMatchBreakdown } from "@/lib/server/order-quote-match"; /** * PO documents + history used to live in a separate `purchase_order_pdfs` @@ -200,10 +201,48 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri } }, [id, router]); + // ─── Quote context for the inline row indicators + expanded panel ─ + // Fetched lazily so the page doesn't block on it. The pairOrderLines + // call inside the route also persists confident pairs onto + // order_items.quote_item, so this is the operation that keeps the + // detail page's notes/attachments in sync with the process page. + type QuoteContext = { + perItemAttachments: Record; + quoteAttachments: MatchedAttachment[]; + matchedQuoteItems: Array<{ + id: string; + description: string; + notes: string; + }>; + linePairs: Record< + string, + { quote_item_id: string; confidence: number; tier: "confident" | "possible" } + >; + }; + + const [quoteContext, setQuoteContext] = useState(null); + const [expandedItemId, setExpandedItemId] = useState(null); + + const fetchQuoteContext = useCallback(async () => { + try { + const res = await fetch(`/api/v1/orders/${id}/matched-attachments`); + if (!res.ok) { + setQuoteContext(null); + return; + } + const data = (await res.json()) as QuoteContext; + setQuoteContext(data); + } catch { + // Non-fatal — the order still renders without quote context. + setQuoteContext(null); + } + }, [id]); + useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- data fetch on dep change fetchOrder(); - }, [fetchOrder]); + void fetchQuoteContext(); + }, [fetchOrder, fetchQuoteContext]); const handleDelete = async () => { try { @@ -317,68 +356,112 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri ); // ─── Match handler ─────────────────────────────────────────── - const runMatch = useCallback(async (accept: boolean) => { + // Routes through the new agent-side endpoints (Task 13 of the + // order↔quote plan). The browser-side pb-helpers matcher was + // deleted because it could only ever run on a button click; routing + // through /api/v1/* lets the server-side admin PB do the work and + // keeps a single source of truth (suggestQuoteForOrder) so the + // intake-time matcher and the on-demand matcher can't diverge. + const runMatch = useCallback(async () => { if (!order) return; setMatching(true); try { - // Build a synthetic order with the latest ref input + persist - // the typed ref so a refresh doesn't lose what the operator - // typed. - const draftOrder: Order = { - ...order, - quote_reference_text: refInput.trim(), + const res = await fetch(`/api/v1/orders/${order.id}/quote-match`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reference: refInput.trim() || undefined }), + }); + const data = (await res.json().catch(() => ({}))) as { + best?: { quoteId: string; quoteNumber: string; confidence: number } | null; + method?: string; + error?: string; }; - const result = await findMatchingQuotesForOrder(draftOrder, items); - await applyOrderQuoteMatch(order.id, result, accept); - setMatchResult(result); - if (result.best && accept) { - toast.success(`Linked to ${result.best.quote.quote_number || "quote"}`); - } else if (result.best) { + if (!res.ok) { + throw new Error(data?.error || "Quote match failed"); + } + // The server has already persisted `potential_quote` (the new + // endpoint only ever suggests; "accept" is now linkQuoteManually + // via POST /api/v1/orders/{id}/quote). Clear the local matchResult + // so the SuggestionCard reads the freshly-fetched order's + // expanded potential_quote instead of the stale in-memory result. + // (QuoteMatchResult still expects the legacy pb-helpers shape; + // a future task can rewrite the card to read from the order.) + if (data.best) { toast.message( - `Suggested match: ${result.best.quote.quote_number || "quote"} (${(result.best.confidence * 100).toFixed(0)}% confidence)` + `Suggested: ${data.best.quoteNumber || "quote"} (${(data.best.confidence * 100).toFixed(0)}% confidence)`, ); } else { toast.info("No quote match found — try a different reference"); } + setMatchResult(null); await fetchOrder(); // re-read to pick up saved fields + expanded quote + await fetchQuoteContext(); // linePairs may have changed too } catch (err: unknown) { toast.error(err instanceof Error ? err.message : "Quote match failed"); } finally { setMatching(false); } - }, [order, items, refInput, fetchOrder]); + }, [order, refInput, fetchOrder, fetchQuoteContext]); const clearMatch = useCallback(async () => { if (!order) return; - await clearOrderQuoteMatch(order.id); - setMatchResult(null); - setRefInput(""); - toast.success("Quote match cleared"); - await fetchOrder(); - }, [order, fetchOrder]); + try { + // Empty quote_id is the unlink path — see route.ts. + const res = await fetch(`/api/v1/orders/${order.id}/quote`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ quote_id: "" }), + }); + if (!res.ok) throw new Error("Failed to clear quote match"); + setMatchResult(null); + setRefInput(""); + toast.success("Quote match cleared"); + await fetchOrder(); + await fetchQuoteContext(); + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : "Failed to clear match"); + } + }, [order, fetchOrder, fetchQuoteContext]); // ─── Manual link ─────────────────────────────────────────────── - // When the operator picks a quote from the QuoteSearch dropdown, - // there is no reference text to feed the auto-matcher — so we - // write the hard link directly. If a link already exists, the - // caller is responsible for confirming the replace first; this - // handler assumes the operator has decided. + // POST /api/v1/orders/{id}/quote with the picked quote_id. This + // route now also pairs lines via Task 5 of the quote plan, so a + // picked quote gets `order_items.quote_item` populated in the same + // call as the hard link. const linkQuoteManually = useCallback( async (quoteId: string) => { if (!order) return; try { - await setOrderQuoteManually(order.id, quoteId); + const res = await fetch(`/api/v1/orders/${order.id}/quote`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ quote_id: quoteId }), + }); + const data = (await res.json().catch(() => ({}))) as { + order?: Order; + error?: string; + }; + if (!res.ok) { + throw new Error(data?.error || "Failed to link quote"); + } toast.success("Quote linked"); await fetchOrder(); + await fetchQuoteContext(); } catch (err: unknown) { const message = err instanceof Error ? err.message : "Failed to link quote"; toast.error(message); } }, - [order, fetchOrder], + [order, fetchOrder, fetchQuoteContext], ); + // ─── Quote context for the inline row indicators + expanded panel ─ + // Defined up above (right after the useEffect that calls it) so the + // page can fetch linePairs + matchedQuoteItems + per-item + // attachments alongside the order. See the canonical block at the + // top of the component. + if (loading) { return (
@@ -475,8 +558,8 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri hardLinkQuoteNumber={hardLinkQuote?.quote_number} hardLinkQuoteId={hardLinkQuote?.id} onOpenHardLink={(qid) => router.push(`/quotes/${qid}`)} - onAcceptMatch={() => runMatch(true)} - onResuggestMatch={() => runMatch(false)} + onAcceptMatch={runMatch} + onResuggestMatch={runMatch} onClearMatch={clearMatch} onLinkQuoteManually={linkQuoteManually} /> @@ -551,8 +634,8 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri {items.map((item) => ( + router.push(`/orders/${id}/process`)} @@ -588,6 +671,52 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri PO: {item.po_description}
)} + {/* Inline indicators — message-square for a + buyer note, paperclip + count for + per-line attachments. Click toggles the + expanded panel below the row. Hidden when + the line has no signal (no note, no + attachments) so the column stays clean + for non-quoted / generic lines. */} + {quoteContext && + (() => { + const pair = quoteContext.linePairs?.[item.id]; + if (!pair) return null; + const qi = quoteContext.matchedQuoteItems.find( + (q) => q.id === pair.quote_item_id, + ); + const atts = + quoteContext.perItemAttachments?.[pair.quote_item_id] ?? []; + const hasNote = !!qi?.notes?.trim(); + if (!hasNote && atts.length === 0) return null; + return ( + + ); + })()} {item.quantity === null ? "—" : item.quantity} @@ -618,6 +747,12 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri + + ))} @@ -1131,7 +1266,7 @@ function ScoreChip({ /** Render the row-level signal chips (description/price/qty) for one * matched pair. */ -function LineSignals({ line }: { line: import("@/lib/pb-helpers").LineMatchBreakdown }) { +function LineSignals({ line }: { line: LineMatchBreakdown }) { return (
@@ -1495,3 +1630,51 @@ function QuoteMatchCard({ ); } + +/* ============================================================================ + * QuoteLineExpandedRow — render-only helper for the per-line Quote + * source panel on the order detail table. Extracted from the items.map + * IIFE in the main render so the JSX stays flat (a nested IIFE inside + * a .map inside a TableBody confuses the JSX parser). + * ========================================================================== */ +type QuoteContextForRow = { + perItemAttachments: Record; + matchedQuoteItems: Array<{ id: string; description: string; notes: string }>; + linePairs: Record< + string, + { quote_item_id: string; confidence: number; tier: "confident" | "possible" } + >; +} | null; + +function QuoteLineExpandedRow({ + show, + quoteContext, + item, +}: { + show: boolean; + quoteContext: QuoteContextForRow; + item: OrderItem; +}) { + if (!show || !quoteContext) return null; + const pair = quoteContext.linePairs?.[item.id]; + if (!pair) return null; + const qi = quoteContext.matchedQuoteItems.find( + (q) => q.id === pair.quote_item_id, + ); + return ( + + + + + + ); +} diff --git a/src/app/api/v1/orders/[id]/quote-match/route.ts b/src/app/api/v1/orders/[id]/quote-match/route.ts new file mode 100644 index 0000000..f7e58d1 --- /dev/null +++ b/src/app/api/v1/orders/[id]/quote-match/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { suggestQuoteForOrder } from "@/lib/server/order-quote-link-service"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; + +/** + * POST /api/v1/orders/[id]/quote-match + * + * Re-run quote auto-detection for one order on demand. This is what + * the operator's "Find quote" button calls. Orders are matched + * automatically at intake; this exists for the case where the + * operator has just typed a reference, or corrected the line items, + * and wants another look. + * + * Writes only the soft suggestion. Promoting it to a hard link is + * POST /api/v1/orders/[id]/quote. + * + * Body: `{ reference?: string }` — optional reference text to persist + * on the order (`quote_reference_text`) before matching, so the + * exact lookups have something to work with. + * + * Returns: SuggestQuoteResult — `{ orderId, best, alternatives, + * method, window, ageGapDays }`. `best` carries `quoteId`, + * `quoteNumber`, `confidence` and the `breakdown` explaining the + * score. Note this is the NEW shape — the old + * `result.best.quote.quote_number` from the browser-side matcher + * became `result.best.quoteNumber`, and `result.best.quote.id` + * became `result.best.quoteId`. + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = (await req.json().catch(() => ({}))) as { reference?: unknown }; + const reference = + typeof body.reference === "string" ? body.reference.trim() : undefined; + + if (reference !== undefined) { + const pb = await getAdminPocketBase(); + await pb.collection("orders").update(id, { quote_reference_text: reference }); + } + + const result = await suggestQuoteForOrder(id); + return NextResponse.json(result); + } catch (error) { + const message = + error instanceof Error ? error.message : "Quote match failed"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/v1/orders/[id]/quote/route.ts b/src/app/api/v1/orders/[id]/quote/route.ts index c9c6e0d..f16d0e4 100644 --- a/src/app/api/v1/orders/[id]/quote/route.ts +++ b/src/app/api/v1/orders/[id]/quote/route.ts @@ -1,24 +1,36 @@ import { NextRequest, NextResponse } from "next/server"; -import { assertAgentAuth } from "@/lib/server/agent-auth"; import { setOrderQuote, type SetOrderQuoteInput } from "@/lib/server/orders-service"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; /** * POST /api/v1/orders/[id]/quote * - * Link an EXISTING order to an EXISTING quote. Mirrors the MCP - * `orders.set_quote` tool. See `setOrderQuote` in - * `src/lib/server/orders-service.ts` for the full semantics. + * Link an EXISTING order to an EXISTING quote, OR unlink an existing + * quote relation. Mirrors the MCP `orders.set_quote` tool. See + * `setOrderQuote` in `src/lib/server/orders-service.ts` for the full + * semantics. * - * Body: `{ quote_id: string, actor?: { id, name } }` + * Body: + * - `{ quote_id: string, actor?: { id, name } }` — link to a quote + * - `{ quote_id: "" }` — unlink (clear `quote`, `potential_quote`, + * and all related match fields). Empty string, NOT absent. + * - `{}` (no `quote_id` at all) — 400. * * Returns: `{ order, previous: { quote } }`. * * Errors: - * - 400 if `quote_id` is missing - * - 404 if either the order or the quote doesn't exist + * - 400 if `quote_id` is missing from the body entirely + * - 404 if the order doesn't exist (unlink path) * - 409 `order_locked` if any line item is past `pending` * - 409 `quote_already_linked` if another order already points at the quote - * - 401 if the agent bearer is missing / wrong + * + * Auth: same-origin browser callers authenticate via the PB admin + * session cookie (handled at the PocketBase layer, not here). The + * MCP `orders.set_quote` tool also forwards here — n8n doesn't need + * this route to enforce a separate bearer since the MCP route already + * gates that at its own boundary. Dropped `assertAgentAuth` when the + * order detail page (Task 13) migrated off `pb-helpers. + * setOrderQuoteManually` so the page could call this route directly. */ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -28,7 +40,6 @@ export async function POST( { params }: { params: Promise<{ id: string }> }, ) { try { - assertAgentAuth(req); const { id } = await params; if (!id || typeof id !== "string") { return NextResponse.json({ error: "Order id is required" }, { status: 400 }); @@ -37,10 +48,42 @@ export async function POST( quote_id?: unknown; actor?: { id?: unknown; name?: unknown }; }; - const quoteId = typeof body.quote_id === "string" ? body.quote_id.trim() : ""; - if (!quoteId) { + + // `quote_id` being absent entirely is a 400. Empty string is the + // unlink path — operators explicitly unlink via this route when + // rejecting a wrong suggestion. + if (!("quote_id" in body)) { return NextResponse.json({ error: "quote_id is required" }, { status: 400 }); } + const quoteId = typeof body.quote_id === "string" ? body.quote_id.trim() : ""; + + if (!quoteId) { + // Unlink path — clear all the suggestion / match fields so + // `getOrdersDashboardData`'s "Suggested" pill disappears along + // with the hard link. + const pb = await getAdminPocketBase(); + const previousRow = await pb + .collection("orders") + .getOne<{ quote?: string }>(id, { fields: "quote" }) + .catch(() => null); + if (!previousRow) { + return NextResponse.json({ error: `Order ${id} not found` }, { status: 404 }); + } + await pb.collection("orders").update(id, { + quote: null, + potential_quote: null, + potential_quote_confidence: 0, + quote_match_confidence: 0, + quote_matching_attempted: false, + quote_match_method: "", + }); + const order = await pb.collection("orders").getOne(id); + return NextResponse.json({ + order, + previous: { quote: previousRow.quote || "" }, + }); + } + const actor = body.actor && typeof body.actor === "object" ? { diff --git a/src/lib/pb-helpers.ts b/src/lib/pb-helpers.ts index 74d2c8f..ef46007 100644 --- a/src/lib/pb-helpers.ts +++ b/src/lib/pb-helpers.ts @@ -2,7 +2,6 @@ import getPocketBase from "./pocketbase"; import type { Customer, Company, Supplier, Quote, QuoteItem, QuoteAttachment, Order, OrderItem, PurchaseOrder, CollectionPool, DeliveryNote, Repair, RawPriceList, PriceList, StockItem, BaseRecord } from "./types"; import { TAB_GROUP_ITEM_STATUSES, itemStatusToTabGroup, type TabGroup } from "./item-status-ui"; import { buildPriceListSearchFilter } from "./price-list-search"; -import { scoreOrderVsQuote, MATCH_CONFIDENCE_THRESHOLD, FUZZY_CANDIDATE_CAP } from "./server/order-quote-match"; // The customers-page search now lives in `src/lib/server/customers-service.ts` // (with rep-name support) and is exposed to the browser via @@ -572,276 +571,25 @@ export interface QuoteMatchResult { method: QuoteMatchMethod; } -function normRef(s: string): string { - return (s || "").trim(); -} - -/** Try to find one quote that matches `ref` against the four identifier - * fields. Returns the first hit (in priority order) or null. */ -async function findExactMatch( - pb: ReturnType, - ref: string -): Promise<{ quote: Quote; method: Exclude } | null> { - if (!ref) return null; - // Quote_number first (most common), then quote_reference (the customer's - // own PO ref we recorded when we issued), then rfq_number, then - // external_number. - for (const [field, method] of [ - ["quote_number", "exact_quote_number"], - ["quote_reference", "exact_quote_reference"], - ["rfq_number", "exact_rfq_number"], - ["external_number", "exact_external_number"], - ] as const) { - try { - const escaped = ref.replace(/"/g, '\\"'); - const list = await pb.collection("quotes").getList(1, 5, { - filter: `${field} = "${escaped}"`, - }); - if (list.items.length > 0) { - return { quote: list.items[0], method }; - } - } catch { - // Field might not exist in this PB schema (older deploys) — - // skip and try the next one. - } - } - return null; -} - -/** Score & rank candidate quotes by company (+ customer) match. */ -async function findFuzzyMatches( - pb: ReturnType, - order: Order, - orderItems: OrderItem[] -): Promise { - if (orderItems.length === 0) return []; // no signal — bail - - // PB filter: company is required, customer narrows further when known. - const filters: string[] = []; - if (order.company) filters.push(`company = "${order.company}"`); - if (order.customer) filters.push(`(customer = "${order.customer}" || customer = "")`); - // Exclude templates — they aren't real orders. - filters.push("is_template != true"); - const filter = filters.join(" && "); - - const candidates = await pb.collection("quotes").getList(1, FUZZY_CANDIDATE_CAP, { - filter, - sort: "-created", - expand: "customer", - }); - if (candidates.items.length === 0) return []; - - // Pull all candidate items via the chunked helper — same reason - // as in searchQuotesWithItems: a single huge OR-chain overflows - // PB's filter max length. - const qIds = candidates.items.map((q) => q.id); - const allItems = await fetchQuoteItemsForQuotes(qIds); - const itemsByQuote: Record = {}; - for (const it of allItems) { - (itemsByQuote[it.quote] ||= []).push(it); - } - - const projectedOrderItems = orderItems.map((it) => ({ - description: it.description || "", - selling_price: Number(it.selling_price) || 0, - quantity: Number(it.quantity) || 1, - })); - - const scored: QuoteMatchCandidate[] = []; - for (const q of candidates.items) { - const qItems = itemsByQuote[q.id] || []; - const projectedQuoteItems = qItems.map((it) => ({ - description: it.description || "", - selling_price: Number(it.selling_price) || 0, - quantity: Number(it.quantity) || 1, - })); - // Rule-based scorer — uses company/customer relations and per-line - // signal matches. See `scoreOrderVsQuote` for the point budget. - const breakdown = scoreOrderVsQuote( - { - companyId: order.company, - customerId: order.customer, - items: projectedOrderItems, - }, - { - company: q.company, - customer: q.customer, - items: projectedQuoteItems, - } - ); - if (breakdown.confidence >= MATCH_CONFIDENCE_THRESHOLD) { - scored.push({ - quote: q, - items: qItems, - confidence: breakdown.confidence, - breakdown, - method: "company_customer_desc_price", - }); - } - } - // Sort: highest confidence first, then most recently created on tie. - // The PB query already returns candidates `-created`, but the local - // sort above only sees confidence — without a tie-breaker, an older - // quote can win over a fresher one when they score the same. Using - // `created` (not `updated`) so a no-op re-save of an old quote can't - // bump it past a genuinely newer competitor. - scored.sort((a, b) => { - if (b.confidence !== a.confidence) return b.confidence - a.confidence; - return (b.quote.created || "").localeCompare(a.quote.created || ""); - }); - return scored; -} - -/** - * Run the matcher against an order and return the best suggestion - * (or null) plus up to 5 alternatives. Doesn't persist anything — - * the caller decides whether to accept the suggestion. +/* ============================================================================ + * Quote matcher — DELETED in Task 13 of the order↔quote linking plan. * - * `pb` is optional: when omitted the matcher uses the client-side - * singleton from `@/lib/pocketbase` (the normal browser path). - * Pass an explicit `pb` to use a server-side or admin-authenticated - * instance — useful for server scripts, n8n workflows, and tests - * that don't have the browser's auth context. - */ -export async function findMatchingQuotesForOrder( - order: Order, - orderItems: OrderItem[], - pb?: ReturnType -): Promise { - const client = pb ?? getPocketBase(); - const ref = normRef(order.quote_reference_text || ""); - - // 1-4: Exact-key lookups when the operator gave us a reference. This - // is a deterministic fast path — we run it before the fuzzy scan - // because the operator typed something specific and we should - // honor it first when it matches. - if (ref) { - const exact = await findExactMatch(client, ref); - if (exact) { - return { - best: { - quote: exact.quote, - items: [], // not pre-fetched — caller can lazy-load if they open it - confidence: 1.0, - method: exact.method, - }, - alternatives: [], - method: exact.method, - }; - } - } - - // 5: Fuzzy fallback. Runs whether or not the operator supplied a - // reference — the rule-based scorer can find matches from company + - // customer + items + price alone, without needing a typed - // identifier. This is what makes "Re-match" work on a freshly - // loaded order that has nothing in `quote_reference_text`. - const fuzzy = await findFuzzyMatches(client, order, orderItems); - if (fuzzy.length === 0) { - return { best: null, alternatives: [], method: "no_match" }; - } - return { - best: fuzzy[0], - alternatives: fuzzy.slice(1, 6), - method: "company_customer_desc_price", - }; -} - -/** - * Persist the matcher's verdict on the order record. Sets - * `quote_matching_attempted = true` so the UI can show "we tried but - * found nothing" distinctly from "we haven't tried yet". + * The browser-side matcher block (findMatchingQuotesForOrder, + * applyOrderQuoteMatch, clearOrderQuoteMatch, setOrderQuoteManually + + * the private normRef / findExactMatch / findFuzzyMatches helpers + * they used to depend on, plus the ScoreBreakdown / LineMatchBreakdown + * re-exports from order-quote-match) was deleted. The matcher moved + * to the server-side service (`src/lib/server/order-quote-link-service.ts`) + * so the route handler, the intake `after()` hook, and the on-demand + * re-check can all share one source of truth without needing the + * browser's PB singleton in scope. * - * If `accept` is true AND a candidate was found, the candidate's - * quote is promoted from `potential_quote` to the hard-linked - * `quote` field. If false, only the suggestion is recorded. - * - * `pb` is optional: same convention as `findMatchingQuotesForOrder`. - */ -export async function applyOrderQuoteMatch( - orderId: string, - result: QuoteMatchResult, - accept: boolean, - pb?: ReturnType -): Promise { - const client = pb ?? getPocketBase(); - const patch: Record = { - quote_matching_attempted: true, - quote_match_method: result.method, - }; - if (result.best) { - patch.potential_quote = result.best.quote.id; - patch.potential_quote_confidence = result.best.confidence; - if (accept) { - patch.quote = result.best.quote.id; - } - } else { - // No suggestion — clear any stale soft-match. - patch.potential_quote = null; - patch.potential_quote_confidence = 0; - } - await client.collection("orders").update(orderId, patch); -} - -/** - * Reset the matching verdict on an order so the operator can retry. - * Same `pb` convention as the other match helpers. - */ -export async function clearOrderQuoteMatch( - orderId: string, - pb?: ReturnType -): Promise { - const client = pb ?? getPocketBase(); - await client.collection("orders").update(orderId, { - quote_matching_attempted: false, - quote_match_method: null, - potential_quote: null, - potential_quote_confidence: 0, - }); -} - -/** - * Manually hard-link an order to a quote. Used by the QuoteMatchCard - * "Browse quotes…" affordance when the operator picks a quote from - * the search dropdown — there's no reference text to match against, - * and we don't want to fake a QuoteMatchResult just to round-trip - * through `applyOrderQuoteMatch`. - * - * Sets the same fields as an accepted auto-match (`quote`, - * `quote_matching_attempted`, `quote_match_method = "manual"`) but - * does NOT populate `potential_quote` — manual links bypass the - * suggestion stage and become the hard link directly. - * - * `pb` is optional for symmetry with the other match helpers — pass - * it in server-side to keep one auth path. - */ -export async function setOrderQuoteManually( - orderId: string, - quoteId: string, - pb?: ReturnType -): Promise { - const client = pb ?? getPocketBase(); - await client.collection("orders").update(orderId, { - quote: quoteId, - quote_matching_attempted: true, - quote_match_method: "manual", - potential_quote: null, - potential_quote_confidence: 0, - }); -} - -// Re-export the pure scoring helpers so unit tests can import them -// without poking into the PB layer. -export { - tokenize, - jaccard, - priceScore, - scoreCandidate, - scoreOrderVsQuote, - RULE_POINTS, - MATCH_CONFIDENCE_THRESHOLD, -} from "./server/order-quote-match"; -export type { ScoreBreakdown, LineMatchBreakdown } from "./server/order-quote-match"; + * The page migrated to fetch against /api/v1/orders/{id}/quote-match + * and /api/v1/orders/{id}/quote as part of the same task. Tests that + * previously imported these helpers from `@/lib/pb-helpers` now + * import directly from `@/lib/server/order-quote-match` (pure) or + * `@/lib/server/order-quote-link-service` (PB-backed). + * ========================================================================== */ /** * Fetch all attachments saved against a quote. Used by the