diff --git a/src/lib/server/order-quote-link-service.test.ts b/src/lib/server/order-quote-link-service.test.ts new file mode 100644 index 0000000..8c2c962 --- /dev/null +++ b/src/lib/server/order-quote-link-service.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { suggestQuoteForOrder } from "@/lib/server/order-quote-link-service"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; + +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn(), +})); + +const ORDERS = "orders"; +const QUOTES = "quotes"; +const ORDER_ITEMS = "order_items"; +const QUOTE_ITEMS = "quote_items"; + +interface CollectionMock { + getOne?: ReturnType; + getList?: ReturnType; + getFullList?: ReturnType; + update?: ReturnType; +} + +function makePb(collections: Record) { + return { + autoCancellation: vi.fn(), + collection: (name: string) => { + const c = collections[name]; + if (!c) throw new Error(`unexpected collection: ${name}`); + return c; + }, + }; +} + +function useP(pb: ReturnType) { + vi.mocked(getAdminPocketBase).mockResolvedValue( + pb as unknown as Awaited>, + ); +} + +const ORDER = { + id: "ord1", + created: "2026-08-29T10:00:00.000Z", + company: "co1", + customer: "cust1", + quote: "", + quote_reference_text: "", +}; + +describe("suggestQuoteForOrder", () => { + beforeEach(() => vi.clearAllMocks()); + + it("resolves an exact quote_number citation regardless of the date window", async () => { + const update = vi.fn().mockResolvedValue({}); + const quotesGetList = vi.fn().mockResolvedValue({ + items: [{ id: "q_old", quote_number: "Q-2024-0847", created: "2024-01-05T00:00:00.000Z" }], + }); + useP( + makePb({ + [ORDERS]: { + getOne: vi.fn().mockResolvedValue({ ...ORDER, quote_reference_text: "Q-2024-0847" }), + update, + }, + [QUOTES]: { getList: quotesGetList }, + [ORDER_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) }, + }), + ); + + const res = await suggestQuoteForOrder("ord1"); + + expect(res.method).toBe("exact_quote_number"); + expect(res.best?.quoteId).toBe("q_old"); + expect(res.window).toBe("exact"); + // Suggestion only — the hard link is never written. + expect(update).toHaveBeenCalledWith("ord1", expect.objectContaining({ + potential_quote: "q_old", + quote_matching_attempted: true, + })); + expect(update.mock.calls[0][1]).not.toHaveProperty("quote"); + }); + + it("constrains fuzzy candidates to the 90-day window before the order date", async () => { + const quotesGetList = vi.fn().mockResolvedValue({ items: [] }); + useP( + makePb({ + [ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update: vi.fn().mockResolvedValue({}) }, + [QUOTES]: { getList: quotesGetList }, + [ORDER_ITEMS]: { + getFullList: vi.fn().mockResolvedValue([ + { id: "oi1", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 }, + ]), + }, + [QUOTE_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) }, + }), + ); + + await suggestQuoteForOrder("ord1"); + + const filter = quotesGetList.mock.calls[0][2].filter as string; + expect(filter).toContain('created >= "2026-05-31T10:00:00.000Z"'); + expect(filter).toContain('company = "co1"'); + }); + + it("widens to 12 months when the 90-day window yields nothing above threshold", async () => { + const quotesGetList = vi + .fn() + .mockResolvedValueOnce({ items: [] }) + .mockResolvedValueOnce({ + items: [{ id: "q1", quote_number: "Q-1", company: "co1", customer: "cust1", created: "2025-11-01T00:00:00.000Z" }], + }); + useP( + makePb({ + [ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update: vi.fn().mockResolvedValue({}) }, + [QUOTES]: { getList: quotesGetList }, + [ORDER_ITEMS]: { + getFullList: vi.fn().mockResolvedValue([ + { id: "oi1", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 }, + ]), + }, + [QUOTE_ITEMS]: { + getFullList: vi.fn().mockResolvedValue([ + { id: "qi1", quote: "q1", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 }, + ]), + }, + }), + ); + + const res = await suggestQuoteForOrder("ord1"); + + expect(quotesGetList).toHaveBeenCalledTimes(2); + expect(res.window).toBe("widened"); + expect(res.best?.quoteId).toBe("q1"); + }); + + it("records no_match and still stamps quote_matching_attempted", async () => { + const update = vi.fn().mockResolvedValue({}); + useP( + makePb({ + [ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update }, + [QUOTES]: { getList: vi.fn().mockResolvedValue({ items: [] }) }, + [ORDER_ITEMS]: { + getFullList: vi.fn().mockResolvedValue([ + { id: "oi1", description: "widget", selling_price: 1, quantity: 1 }, + ]), + }, + [QUOTE_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) }, + }), + ); + + const res = await suggestQuoteForOrder("ord1"); + + expect(res.method).toBe("no_match"); + expect(res.best).toBeNull(); + expect(update).toHaveBeenCalledWith("ord1", expect.objectContaining({ + quote_matching_attempted: true, + potential_quote: null, + potential_quote_confidence: 0, + })); + }); + + it("skips an order that already has a hard quote link", async () => { + const update = vi.fn(); + useP( + makePb({ + [ORDERS]: { getOne: vi.fn().mockResolvedValue({ ...ORDER, quote: "already" }), update }, + }), + ); + + const res = await suggestQuoteForOrder("ord1"); + + expect(res.method).toBe("no_match"); + expect(res.skipped).toBe("already_linked"); + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/server/order-quote-link-service.ts b/src/lib/server/order-quote-link-service.ts new file mode 100644 index 0000000..b8ebf92 --- /dev/null +++ b/src/lib/server/order-quote-link-service.ts @@ -0,0 +1,295 @@ +/** + * 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 { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { + scoreOrderVsQuote, + 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. */ + window: "exact" | "90d" | "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; +} + +interface QuoteLineRow extends LineRow { + quote: string; +} + +const ORDER_COLLECTION = "orders"; +const QUOTE_COLLECTION = "quotes"; +const ORDER_ITEMS_COLLECTION = "order_items"; +const QUOTE_ITEMS_COLLECTION = "quote_items"; + +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. */ +async function findExactMatch( + pb: Awaited>, + ref: 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) { + try { + const list = await pb + .collection(QUOTE_COLLECTION) + .getList(1, 5, { filter: `${field} = "${esc(trimmed)}"` }); + if (list.items.length > 0) return { quote: list.items[0], method }; + } catch { + // Field may not exist on older PB deploys — try the next. + } + } + 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", + }); + if (candidates.items.length === 0) return []; + + const quoteIds = candidates.items.map((q) => q.id); + const orFilter = quoteIds.map((qid) => `quote = "${qid}"`).join(" || "); + const quoteItems = await pb + .collection(QUOTE_ITEMS_COLLECTION) + .getFullList({ + filter: orFilter, + fields: "id,quote,description,selling_price,quantity", + batch: 1000, + }); + + const byQuote = new Map(); + for (const it of quoteItems) { + 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); + 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"] = "90d"; + 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, + }); +}