/** * order-quote-link-service — server-side order ↔ quote matching. * * The PocketBase half of the matcher. Its predecessor lives in * `src/lib/pb-helpers.ts` and runs on the browser's PB singleton, * which is why matching could only ever happen when an operator * clicked a button. This version runs on `getAdminPocketBase()` so it * can be called from `createOrder`, from the backlog sweep, and from * route handlers. * * The pure scoring (`scoreOrderVsQuote`, `pairQuoteLines`, * `quoteWindowForOrder`) lives in `order-quote-match.ts` and is reused * untouched — DO NOT reimplement any of it here. * * Policy: this module NEVER writes `orders.quote`. It only ever writes * the soft suggestion (`potential_quote`). Promoting a suggestion to a * hard link is a deliberate human action that goes through * `setOrderQuote` in `orders-service.ts`. */ import { ClientResponseError } from "pocketbase"; import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; import { fetchQuoteItemsForQuotesAdmin } from "@/lib/server/quotes-service"; import type { QuoteItem } from "@/lib/types"; import { scoreOrderVsQuote, pairQuoteLines, quoteWindowForOrder, quoteToOrderAgeDays, MATCH_CONFIDENCE_THRESHOLD, FUZZY_CANDIDATE_CAP, FUZZY_WINDOW_DAYS, FUZZY_WINDOW_WIDEN_DAYS, type ScoreBreakdown, } from "@/lib/server/order-quote-match"; export type QuoteMatchMethod = | "exact_quote_number" | "exact_quote_reference" | "exact_rfq_number" | "exact_external_number" | "company_customer_desc_price" | "no_match"; export interface QuoteMatchCandidate { quoteId: string; quoteNumber: string; quoteCreated: string; confidence: number; breakdown?: ScoreBreakdown; method: Exclude; } export interface SuggestQuoteResult { orderId: string; best: QuoteMatchCandidate | null; alternatives: QuoteMatchCandidate[]; method: QuoteMatchMethod; /** Which pass produced the result — for the sweep's reporting. * "initial" is the FUZZY_WINDOW_DAYS pass, not a fixed "90d" label * — retuning that constant must not make this label lie. */ window: "exact" | "initial" | "widened" | "none"; /** Days from the matched quote's creation to the order's. null when unmatched. */ ageGapDays: number | null; /** Set when the order was deliberately not processed. */ skipped?: "already_linked"; } interface OrderRow { id: string; created: string; company?: string; customer?: string; quote?: string; quote_reference_text?: string; rfq_number?: string; } interface QuoteRow { id: string; quote_number?: string; company?: string; customer?: string; created: string; } interface LineRow { id: string; description?: string; selling_price?: number; quantity?: number; } const ORDER_COLLECTION = "orders"; const QUOTE_COLLECTION = "quotes"; const ORDER_ITEMS_COLLECTION = "order_items"; /** The scalars `fuzzyPass` reads off each candidate quote. Explicit * `fields` on the query — `quotes` also carries `email_body`, * `ai_insights`, `migration_meta`, etc., and pulling those for up to * FUZZY_CANDIDATE_CAP (200) candidates (twice, if the window widens) * to read five scalars would dominate the whole operation's cost at * intake volume. */ const CANDIDATE_QUOTE_FIELDS = "id,quote_number,company,customer,created"; /** What `scoreOrderVsQuote` needs off each quote line. Passed * explicitly to `fetchQuoteItemsForQuotesAdmin` — its default * projection belongs to the duplicate-quote scan, a different * stakeholder with different needs. If that default is ever trimmed, * this call must not silently lose `description` or `selling_price` * and start scoring every candidate near zero. */ const MATCHER_QUOTE_ITEM_FIELDS = "id,quote,description,selling_price,quantity"; /** What `pairOrderLines` needs off each order line: enough to pair by * description, plus the existing `quote_item` so the write loop can * detect an already-set link and never clobber it. If this ever * drops `quote_item`, that guard goes silently blind — every line * reads as unlinked and a human-confirmed pairing can be * overwritten. */ const PAIR_ORDER_ITEM_FIELDS = "id,description,quote_item"; /** What `pairOrderLines` needs off each quote line — just enough to * pair by description. Passed explicitly to * `fetchQuoteItemsForQuotesAdmin` for the same reason as * `MATCHER_QUOTE_ITEM_FIELDS` above: its default projection belongs * to the duplicate-quote scan, a different caller with different * needs, and must not silently drift under this one. */ const PAIR_QUOTE_ITEM_FIELDS = "id,quote,description"; function esc(s: string): string { return (s || "").replace(/"/g, '\\"'); } function project(rows: ReadonlyArray) { return rows.map((r) => ({ description: r.description || "", selling_price: Number(r.selling_price) || 0, quantity: Number(r.quantity) || 1, })); } /** * Try the four exact-identifier fields, in priority order. Never date * limited — a citation is evidence regardless of age. * * Scoped to the order's own company when known: `quote_reference_text` * and `rfq_number` are customer-supplied free text, and without this * clause a reference-number collision across two customers would * return a confidence-1.0 suggestion pointing at a different * company's quote. */ async function findExactMatch( pb: Awaited>, ref: string, companyId?: string, ): Promise<{ quote: QuoteRow; method: QuoteMatchCandidate["method"] } | null> { const trimmed = (ref || "").trim(); if (!trimmed) return null; const fields = [ ["quote_number", "exact_quote_number"], ["quote_reference", "exact_quote_reference"], ["rfq_number", "exact_rfq_number"], ["external_number", "exact_external_number"], ] as const; for (const [field, method] of fields) { const clauses = [`${field} = "${esc(trimmed)}"`]; if (companyId) clauses.push(`company = "${esc(companyId)}"`); try { const list = await pb .collection(QUOTE_COLLECTION) .getList(1, 5, { filter: clauses.join(" && ") }); if (list.items.length > 0) return { quote: list.items[0], method }; } catch (err) { // A field this deploy's PB schema doesn't have 400s — try the // next one; all four fields exist per pb-exports/pb_schema-2.json // so this is a defensive fallback, not the expected path. // Anything else (expired admin token, connection reset, PB // restart mid-sweep) is a real failure and must propagate: // swallowing it here would let the fuzzy path silently run // instead, and writeSuggestion would then persist // `quote_matching_attempted: true` / `quote_match_method: // "no_match"` for an order that actually had a valid citation — // permanently hiding it from the sweep's "haven't tried yet" // filter, since nothing revisits an order once that flag is set. if (err instanceof ClientResponseError && err.status === 400) continue; throw err; } } return null; } /** Score every candidate quote inside one date window. */ async function fuzzyPass( pb: Awaited>, order: OrderRow, orderItems: LineRow[], windowDays: number, ): Promise { if (orderItems.length === 0) return []; const win = quoteWindowForOrder(order.created, windowDays); const filters: string[] = [ `created >= "${win.from}"`, `created <= "${win.to}"`, "is_template != true", ]; if (order.company) filters.push(`company = "${esc(order.company)}"`); if (order.customer) { filters.push(`(customer = "${esc(order.customer)}" || customer = "")`); } const candidates = await pb .collection(QUOTE_COLLECTION) .getList(1, FUZZY_CANDIDATE_CAP, { filter: filters.join(" && "), sort: "-created", fields: CANDIDATE_QUOTE_FIELDS, }); if (candidates.items.length === 0) return []; // A naive `quote = "x" || quote = "y" || ...` OR-chain over // FUZZY_CANDIDATE_CAP (200) ids blows PB's filter length limit // (HTTP 400) well before hitting the cap — `fetchQuoteItemsForQuotesAdmin` // tries PB's `IN (...)` syntax first and falls back to chunked // OR-chains, so a company with a large quote history doesn't break // the matcher outright. Reused (not reimplemented) from // `quotes-service.ts`, which already has this exact chunking logic // for its duplicate-quote scan. const quoteIds = candidates.items.map((q) => q.id); const quoteItems = await fetchQuoteItemsForQuotesAdmin(pb, quoteIds, MATCHER_QUOTE_ITEM_FIELDS); const byQuote = new Map(); for (const it of quoteItems) { if (!it.quote) continue; const bucket = byQuote.get(it.quote); if (bucket) bucket.push(it); else byQuote.set(it.quote, [it]); } const projectedOrder = project(orderItems); const scored: QuoteMatchCandidate[] = []; for (const q of candidates.items) { const breakdown = scoreOrderVsQuote( { companyId: order.company, customerId: order.customer, items: projectedOrder }, { company: q.company, customer: q.customer, items: project(byQuote.get(q.id) ?? []) }, ); if (breakdown.confidence >= MATCH_CONFIDENCE_THRESHOLD) { scored.push({ quoteId: q.id, quoteNumber: q.quote_number || "", quoteCreated: q.created, confidence: breakdown.confidence, breakdown, method: "company_customer_desc_price", }); } } scored.sort((a, b) => b.confidence !== a.confidence ? b.confidence - a.confidence : (b.quoteCreated || "").localeCompare(a.quoteCreated || ""), ); return scored; } /** * Find the best candidate quote for an order and write it as a soft * suggestion. Idempotent and safe to re-run. */ export async function suggestQuoteForOrder( orderId: string, ): Promise { const pb = await getAdminPocketBase(); pb.autoCancellation(false); const order = await pb.collection(ORDER_COLLECTION).getOne(orderId); // An order with a confirmed hard link is settled. Never second-guess it. if (order.quote) { return { orderId, best: null, alternatives: [], method: "no_match", window: "none", ageGapDays: null, skipped: "already_linked", }; } // 1. Exact identifier citation, if the order carries reference text. const ref = (order.quote_reference_text || order.rfq_number || "").trim(); const exact = await findExactMatch(pb, ref, order.company); if (exact) { const best: QuoteMatchCandidate = { quoteId: exact.quote.id, quoteNumber: exact.quote.quote_number || "", quoteCreated: exact.quote.created, confidence: 1, method: exact.method, }; await writeSuggestion(pb, orderId, best); return { orderId, best, alternatives: [], method: exact.method, window: "exact", ageGapDays: quoteToOrderAgeDays(exact.quote.created, order.created), }; } // 2. Fuzzy — 90-day window first, widening only if it comes up empty. const orderItems = await pb .collection(ORDER_ITEMS_COLLECTION) .getFullList({ filter: `order = "${esc(orderId)}"`, fields: "id,description,selling_price,quantity", batch: 1000, }); let scored = await fuzzyPass(pb, order, orderItems, FUZZY_WINDOW_DAYS); let window: SuggestQuoteResult["window"] = "initial"; if (scored.length === 0) { scored = await fuzzyPass(pb, order, orderItems, FUZZY_WINDOW_WIDEN_DAYS); window = "widened"; } const best = scored[0] ?? null; await writeSuggestion(pb, orderId, best); return { orderId, best, alternatives: scored.slice(1, 6), method: best ? "company_customer_desc_price" : "no_match", window: best ? window : "none", ageGapDays: best ? quoteToOrderAgeDays(best.quoteCreated, order.created) : null, }; } /** Persist (or clear) the soft suggestion. Never touches `quote`. */ async function writeSuggestion( pb: Awaited>, orderId: string, best: QuoteMatchCandidate | null, ): Promise { await pb.collection(ORDER_COLLECTION).update(orderId, { quote_matching_attempted: true, quote_match_method: best ? best.method : "no_match", potential_quote: best ? best.quoteId : null, potential_quote_confidence: best ? best.confidence : 0, }); } /* ============================================================================ * Per-line pairing * * `order_items.quote_item` has existed all along but was only ever * written by `createOrderFromQuote`. When a quote is linked to an * order after the fact, nothing paired the lines — so the process page * re-guessed the pairing on every render with an exact/substring * match, and any wording difference silently hid the buyer's note. * * Confident pairs are persisted here so the guess happens once. * Possible pairs are returned but NOT written: the operator confirms * them, so a buyer is never shown a note from the wrong line without * being told it is a guess. * ========================================================================== */ export interface PairedLine { orderItemId: string; quoteItemId: string; /** Jaccard overlap of the two descriptions, 0..1. */ confidence: number; } export interface PairOrderLinesResult { confident: PairedLine[]; possible: PairedLine[]; unmatchedOrderItemIds: string[]; } /** * Pair an order's lines against a quote's lines, persisting only the * confident ones. Safe to re-run: a line whose `quote_item` is already * set is never overwritten. */ export async function pairOrderLines( orderId: string, quoteId: string, ): Promise { const pb = await getAdminPocketBase(); pb.autoCancellation(false); const [orderItems, quoteItems] = await Promise.all([ pb .collection(ORDER_ITEMS_COLLECTION) .getFullList({ filter: `order = "${esc(orderId)}"`, fields: PAIR_ORDER_ITEM_FIELDS, batch: 1000, }), // Reuses the same helper the fuzzy pass uses for quote_items — it // already handles PB's IN(...) filter and the chunked-OR-chain // fallback, so there is one code path in this file that knows how // to fetch quote_items rather than two. fetchQuoteItemsForQuotesAdmin(pb, [quoteId], PAIR_QUOTE_ITEM_FIELDS), ]); // `pairQuoteLines` returns indices into exactly the arrays passed to // it. Both arrays below are built with a single `.map()` over // `orderItems`/`quoteItems` — `.map()` is index-preserving by // definition, so `orderLineIdx`/`quoteLineIdx` always refer back to // the same position in `orderItems`/`quoteItems`. No sort or filter // runs on either array between the fetch and this call, so the // mapping can't be thrown off. const pairs = pairQuoteLines( orderItems.map((r) => ({ description: r.description || "" })), quoteItems.map((r) => ({ description: r.description || "" })), ); const confident: PairedLine[] = []; const possible: PairedLine[] = []; const pairedOrderIdx = new Set(); for (const p of pairs) { pairedOrderIdx.add(p.orderLineIdx); const entry: PairedLine = { orderItemId: orderItems[p.orderLineIdx].id, quoteItemId: quoteItems[p.quoteLineIdx].id, confidence: p.jaccard, }; if (p.tier === "confident") confident.push(entry); else possible.push(entry); } // Quote lines already claimed by an order line's existing DB value. // Seeded up front, before any write — a stale link (the line's // description drifted since it was set, or an operator hand-picked // it) still occupies that quote line. Without this, a fresh // confident pair on a *different* order line can point a second // line at the same quote_item: both would then render the same // estimator's note and attachments, which is exactly the failure // this feature exists to prevent, just reached from the other // direction (`row?.quote_item` below only guards the pair's own // order line, not the quote line it targets). const claimedQuoteItemIds = new Set( orderItems.map((r) => r.quote_item).filter((v): v is string => Boolean(v)), ); // Persist confident pairs only, and never clobber an existing link // (a line paired by createOrderFromQuote, or confirmed by hand). // // Sequential, one write per line — same tradeoff as the bulk-cancel // and delivery loops elsewhere in orders-service.ts ("one PB // round-trip per write is fine here; we cap at a few dozen items // per order in practice"). Parallelizing with Promise.all would add // concurrent-write risk against PB for no real gain at this volume. for (const c of confident) { const row = orderItems.find((r) => r.id === c.orderItemId); if (row?.quote_item) continue; if (claimedQuoteItemIds.has(c.quoteItemId)) continue; await pb.collection(ORDER_ITEMS_COLLECTION).update(c.orderItemId, { quote_item: c.quoteItemId, }); } return { confident, possible, // A line already carrying a `quote_item` in the DB is matched // even if this run's algorithm didn't (re-)propose a pair for // it — e.g. its description drifted away from its original // counterpart. Only a line with neither an existing link nor a // fresh pair counts as genuinely unmatched. unmatchedOrderItemIds: orderItems .filter((r, idx) => !pairedOrderIdx.has(idx) && !r.quote_item) .map((r) => r.id), }; } /* ============================================================================ * Backlog sweep * * Auto-detection at intake only covers orders created from now on. * This walks the orders that were never attempted — the historical * backlog, plus any order whose lines were corrected after creation. * * The age-gap histogram exists so the FUZZY_WINDOW_DAYS default (90) * can be re-tuned against what actually happens in this business * rather than left at an estimate. * ========================================================================== */ export interface AgeGapHistogram { d0_30: number; d31_60: number; d61_90: number; d91_180: number; d181_365: number; d365_plus: number; total: number; } export interface SweepResult { processed: number; suggested: number; noMatch: number; failed: number; /** Total orders still awaiting a sweep after this batch. */ remaining: number; ageGaps: AgeGapHistogram; } /** Bucket quote→order age gaps. Exported for direct unit testing. * * Uses `|g|` because a negative gap (quote created AFTER the order) * is a real observation on back-dated orders; treating it as a * positive day count preserves the bucket semantics. * * Boundary days are counted in the LOWER bucket (≤ 30 stays in * d0_30) so the histogram's edges line up with how operators * describe ranges ("within a month"). */ export function bucketAgeGaps(gaps: ReadonlyArray): AgeGapHistogram { const h: AgeGapHistogram = { d0_30: 0, d31_60: 0, d61_90: 0, d91_180: 0, d181_365: 0, d365_plus: 0, total: 0, }; for (const g of gaps) { const d = Math.abs(g); if (d <= 30) h.d0_30++; else if (d <= 60) h.d31_60++; else if (d <= 90) h.d61_90++; else if (d <= 180) h.d91_180++; else if (d <= 365) h.d181_365++; else h.d365_plus++; h.total++; } return h; } export interface SweepOptions { /** Max orders to process in this batch. */ limit?: number; } export async function sweepUnmatchedOrders( options: SweepOptions = {}, ): Promise { const limit = Math.min(Math.max(options.limit ?? 100, 1), 500); const pb = await getAdminPocketBase(); pb.autoCancellation(false); // Scope: orders where the matcher has never run AND no hard link // exists. `quote = ""` is the codebase's pattern for "no relation" // (verified against orders-service.ts:3749). // // Projection: `id` only. We re-fetch the order inside // suggestQuoteForOrder with the fields the matcher actually reads; // pulling the full record here just bloats the wire. const page = await pb.collection(ORDER_COLLECTION).getList<{ id: string }>(1, limit, { filter: `quote_matching_attempted = false && quote = ""`, sort: "-created", fields: "id", }); let suggested = 0; let noMatch = 0; let failed = 0; const gaps: number[] = []; // Sequential on purpose: each order fans out into a candidate scan, // and running them in parallel would hammer PB during a backlog run. for (const row of page.items) { try { const res = await suggestQuoteForOrder(row.id); if (res.best) { suggested++; if (res.ageGapDays !== null) gaps.push(res.ageGapDays); } else { // Skipped orders (already_linked) and true no_matches both // land here — distinguish via `res.skipped` if a future caller // needs to. For now they're both "nothing new to show." noMatch++; } } catch (err) { failed++; console.error( `[sweepUnmatchedOrders] order ${row.id} failed:`, err instanceof Error ? err.message : err, ); } } return { processed: page.items.length, suggested, noMatch, failed, remaining: Math.max(0, (page.totalItems ?? 0) - page.items.length), ageGaps: bucketAgeGaps(gaps), }; }