/** * Pure scoring helpers for the order ↔ quote matcher. * * Kept separate from `pb-helpers.ts` so: * - The matcher's PB-dependent code (filters, getList, etc.) stays * in the client helpers where the rest of the data layer lives. * - The pure math (tokenize, jaccard, priceScore, scoreOrderVsQuote) * can be unit-tested without touching PocketBase — the test * file imports these directly and doesn't need to mock PB. * * Everything here is deterministic given its inputs. */ /** Hard cap on fuzzy-match candidate quotes. PB's getFullList is * fine with thousands, but we don't want to download 500 quotes * + their items just to find one match in dev. */ export const FUZZY_CANDIDATE_CAP = 200; /** Confidence above which we surface a candidate as the "best" match. * Tuned conservatively — better to show no suggestion than the wrong * quote. The operator can always override. */ export const MATCH_CONFIDENCE_THRESHOLD = 0.4; /** Normalize a free-text description into a token set for similarity. * Strips punctuation, lowercases, drops short (< 3 char) and stopword * tokens. */ export function tokenize(s: string): Set { const stop = new Set([ "a", "an", "and", "or", "the", "for", "with", "of", "to", "in", "on", "x", "mm", "cm", "m", "qty", "no", "nos", "size", "type", "part", ]); const out = new Set(); const lower = (s || "").toLowerCase(); for (const tok of lower.split(/[^a-z0-9]+/g)) { if (tok.length < 3) continue; if (stop.has(tok)) continue; out.add(tok); } return out; } /** Jaccard similarity over token sets. 0 = no overlap, 1 = identical. */ export function jaccard(a: Set, b: Set): number { if (a.size === 0 && b.size === 0) return 0; let inter = 0; for (const t of a) if (b.has(t)) inter++; const union = a.size + b.size - inter; return union === 0 ? 0 : inter / union; } /** Price tolerance: 1 if equal, 0 if very different. Tolerant within * 5% to absorb rounding / shipping tweaks, then linear falloff to 0 * at 50% diff. Returns 0 if one side is 0 — no signal in that case. */ export function priceScore(a: number, b: number): number { if (a === 0 && b === 0) return 0; const max = Math.max(a, b); if (max === 0) return 0; const diff = Math.abs(a - b) / max; if (diff <= 0.05) return 1; if (diff >= 0.5) return 0; return 1 - (diff - 0.05) / 0.45; } /* ============================================================================ * Rule-based scoring — scoreOrderVsQuote * * The matcher's primary scoring function. Each candidate quote is awarded * points for matching five high-signal signals — they're chosen because * they're the ones operators look at manually when triaging a "does this * order belong to this quote?" decision: * * +3 Company relation matches (exact id) * +2 Customer relation matches when known (order.customer is set and * equals quote.customer id) * +2 Per-line description overlap (Jaccard >= 0.5) — partial credit * at lower overlap * +2 Per-line selling-price matches within 5% * +1 Per-line quantity matches exactly — deduct 1 if mismatched on a * line that did overlap on description (so a "right word, wrong * quantity" line nudges the score back down) * -2 / -1 Penalty for item-count mismatch (50%+ off deducts 2, 25%+ * off deducts 1) — accounts for the orders where the customer * split a quote across two POs * * The function returns a normalized 0..1 confidence plus a `breakdown` * the UI can render so operators can see WHY a quote was chosen — this * is the trust backbone: if the operator disagrees with the breakdown, * they can tell us which signal is wrong, not just "the match is wrong". * * Normalization maps the raw score to [0, 1] via a piecewise clamp: * raw sum is interpreted against a max-of-13 budget (3+2+2+2+1+1+1+1) * and floored at 0 when negative. The threshold is intentionally lenient * (`MATCH_CONFIDENCE_THRESHOLD = 0.4`) because the rule-based breakdown * is auditable and operators do the final say. * ========================================================================== */ /** Rule-points for the high-signal signals. Each is exposed as a const * so tests can re-derive expected breakdowns without copy-pasting * magic numbers across the file. */ export const RULE_POINTS = { company: 3, customer: 2, descriptionFull: 2, // Jaccard >= 0.5 on a line descriptionPartial: 1, // Jaccard 0.3..0.5 on a line price: 2, // within 5% on a line quantityMatch: 1, // exact qty on a line quantityMismatch: -1, // qty off on a line that did overlap on description itemCountPenalty50: -2, itemCountPenalty25: -1, } as const; /** Maximum raw score breakdown (3 + 2 + 2*N + 2*N + 1*N where N is the * smaller of the order's and the quote's item counts). We normalize * against the observed ceiling for a given (order, quote) pair so the * reported confidence is comparable across order sizes — i.e. a * 5-line order that hits all signals gets ~1.0, and a 1-line order * that hits all signals also gets ~1.0. The constant MAX_BUDGET is * the global ceiling used only when no normalization is possible. */ export const MAX_BUDGET = 13; export interface LineMatchBreakdown { /** The order line and quote line paired (or null if no good pair). */ orderLineIdx: number | null; quoteLineIdx: number | null; description: string; orderQty: number | null; quoteQty: number | null; orderPrice: number | null; quotePrice: number | null; /** Jaccard on tokenized descriptions. */ desc: number; /** Token-level signals: did the order's description overlap with the quote's? */ descMatched: boolean; /** Within 5%? */ priceMatched: boolean; /** Exact qty? */ qtyMatched: boolean; /** Points contributed by this line — sum of (description + price + qty). */ points: number; } export interface ScoreBreakdown { /** Total raw score (sum of points across all signals). */ raw: number; /** Normalized 0..1 used for the UI threshold check. */ confidence: number; /** Per-rule points (with `null` meaning "not applicable / cannot * evaluate"). */ company: number | null; customer: number | null; description: number; price: number; quantity: number; itemCountDelta: number; /** Per-line breakdown, in order-of-best-match. */ lines: LineMatchBreakdown[]; /** Order's and quote's item counts — surfaced so the UI can show * "5 lines in the order, 3 lines in the quote" when itemCountDelta * is non-zero. */ orderItemCount: number; quoteItemCount: number; } /** Match every order line to its best quote line by description * overlap. Greedy: pick the highest-Jaccard pair first, remove both * from the pool, repeat. Lines that don't have any meaningful match * fall through with `descMatched=false` so the matcher can still * see the line-by-line picture. */ function pairLinesByDescription( orderItems: ReadonlyArray<{ description: string }>, quoteItems: ReadonlyArray<{ description: string }> ): Array<{ oi: number; qi: number; jaccard: number }> { const pairs: Array<{ oi: number; qi: number; jaccard: number }> = []; const oTokens = orderItems.map((it) => tokenize(it.description)); const qTokens = quoteItems.map((it) => tokenize(it.description)); const oUsed = new Set(); const qUsed = new Set(); // Build all candidate pairs above 0.3 Jaccard, take the best first. const candidates: Array<{ oi: number; qi: number; j: number }> = []; for (let oi = 0; oi < oTokens.length; oi++) { if (oUsed.has(oi)) continue; for (let qi = 0; qi < qTokens.length; qi++) { if (qUsed.has(qi)) continue; const j = jaccard(oTokens[oi], qTokens[qi]); if (j >= 0.3) candidates.push({ oi, qi, j }); } } candidates.sort((a, b) => b.j - a.j); for (const c of candidates) { if (oUsed.has(c.oi) || qUsed.has(c.qi)) continue; oUsed.add(c.oi); qUsed.add(c.qi); pairs.push({ oi: c.oi, qi: c.qi, jaccard: c.j }); } return pairs; } /** * Score one candidate quote against the order. Returns the breakdown * so the UI can show "why?" next to the confidence badge. * * `order.companyId` and `quote.company` are relation id strings — * match by exact equality. `order.customerId` / `quote.customer` are * the same. Empty/undefined relations count as "not applicable" and * do not contribute to the score. */ export function scoreOrderVsQuote( order: { companyId?: string | null; customerId?: string | null; items: ReadonlyArray<{ description: string; selling_price: number; quantity: number }>; }, quote: { company?: string | null; customer?: string | null; items: ReadonlyArray<{ description: string; selling_price: number; quantity: number }>; } ): ScoreBreakdown { // ─── Header signals ──────────────────────────────────────────── // Company is the dominant relation — if the candidate isn't for the // same company, we don't even consider it (the caller filters that // out first), but we still award 0 if for some reason the relation // is empty on either side. const company = order.companyId && quote.company && order.companyId === quote.company ? RULE_POINTS.company : null; // Customer only applies when the ORDER has a customer attached. // Quotes with empty customer are common (the customer's RFQ didn't // select one) — that's "unknown", not "mismatch". let customer: number | null = null; if (order.customerId) { customer = quote.customer && order.customerId === quote.customer ? RULE_POINTS.customer : 0; } // ─── Per-line signals ───────────────────────────────────────── const pairs = pairLinesByDescription(order.items, quote.items); const oUnmatched = order.items.map((_, i) => i).filter((i) => !pairs.some((p) => p.oi === i)); const qUnmatched = quote.items.map((_, i) => i).filter((i) => !pairs.some((p) => p.qi === i)); let descriptionPoints = 0; let pricePoints = 0; let qtyPoints = 0; const lines: LineMatchBreakdown[] = []; for (const p of pairs) { const o = order.items[p.oi]; const q = quote.items[p.qi]; const descPts = p.jaccard >= 0.5 ? RULE_POINTS.descriptionFull : RULE_POINTS.descriptionPartial; descriptionPoints += descPts; const pScore = priceScore(Number(o.selling_price) || 0, Number(q.selling_price) || 0); const priceMatched = pScore >= 1; const pricePts = priceMatched ? RULE_POINTS.price : 0; pricePoints += pricePts; const oQty = Number(o.quantity) || 0; const qQty = Number(q.quantity) || 0; const qtyMatched = oQty === qQty && oQty > 0; const qtyPts = qtyMatched ? RULE_POINTS.quantityMatch : RULE_POINTS.quantityMismatch; qtyPoints += qtyPts; lines.push({ orderLineIdx: p.oi, quoteLineIdx: p.qi, description: o.description || "", orderQty: oQty, quoteQty: qQty, orderPrice: Number(o.selling_price) || 0, quotePrice: Number(q.selling_price) || 0, desc: p.jaccard, descMatched: true, priceMatched, qtyMatched, points: descPts + pricePts + qtyPts, }); } // Unmatched order lines — no description overlap, no per-line // signals. They get recorded so the UI can show "this order line // has no match in the quote", but they don't deduct or contribute // anything on their own. for (const oi of oUnmatched) { const o = order.items[oi]; lines.push({ orderLineIdx: oi, quoteLineIdx: null, description: o.description || "", orderQty: Number(o.quantity) || 0, quoteQty: null, orderPrice: Number(o.selling_price) || 0, quotePrice: null, desc: 0, descMatched: false, priceMatched: false, qtyMatched: false, points: 0, }); } // Unmatched quote lines — pure excess in the quote that isn't in // the order. We don't render these but they affect the item-count // penalty below. void qUnmatched; // ─── Item-count delta ──────────────────────────────────────── // 50%+ difference → −2; 25%+ difference → −1. The "no signal" case // (order has 0 lines) is handled by the caller — we still guard // here so a 0-line order can't divide-by-zero. const oCount = order.items.length; const qCount = quote.items.length; let itemCountDelta = 0; if (oCount > 0 && qCount > 0) { const diffRatio = Math.abs(oCount - qCount) / Math.max(oCount, qCount); if (diffRatio >= 0.5) itemCountDelta = RULE_POINTS.itemCountPenalty50; else if (diffRatio >= 0.25) itemCountDelta = RULE_POINTS.itemCountPenalty25; } else if (qCount > 0 && oCount === 0) { itemCountDelta = RULE_POINTS.itemCountPenalty50; } // ─── Total + normalize ──────────────────────────────────────── // Raw sum of signals. We build a per-candidate maximum by treating // a "perfect match" as: company+customer on header, and per-line // signal max for min(oCount, qCount) lines. That's the natural cap // because unmatched lines genuinely shouldn't be expected to carry // signals. const headerMax = (company !== null ? RULE_POINTS.company : 0) + (customer !== null ? RULE_POINTS.customer : 0); const pairedLineCount = Math.min(oCount, qCount); const lineMaxForPairs = pairedLineCount * (RULE_POINTS.descriptionFull + RULE_POINTS.price + RULE_POINTS.quantityMatch); // Cap at MAX_BUDGET so a giant candidate never pushes the score past // the global max — this keeps the threshold (0.4) interpretable. const observedMax = Math.min(headerMax + lineMaxForPairs, MAX_BUDGET); const headerSum = (company ?? 0) + (customer ?? 0); const raw = headerSum + descriptionPoints + pricePoints + qtyPoints + itemCountDelta; let confidence = 0; if (observedMax > 0) { confidence = Math.max(0, raw) / observedMax; confidence = Math.min(1, confidence); } // 2-decimal precision so the UI's `xx%` badge is stable across // re-renders. confidence = Math.round(confidence * 100) / 100; return { raw, confidence, company, customer, description: descriptionPoints, price: pricePoints, quantity: qtyPoints, itemCountDelta, lines, orderItemCount: oCount, quoteItemCount: qCount, }; } /** * Backwards-compatible composite score in [0, 1]. * * Used by callers (and tests) that just want a number, not the full * breakdown. New code should prefer `scoreOrderVsQuote` so it can * inspect the breakdown. */ export function scoreCandidate( orderItems: ReadonlyArray<{ description: string; selling_price: number; quantity: number }>, quoteItems: ReadonlyArray<{ description: string; selling_price: number; quantity: number }> ): number { if (orderItems.length === 0) return 0; return scoreOrderVsQuote( { items: orderItems }, { items: quoteItems } ).confidence; }