WFDASH-WEBAPP/src/lib/server/order-quote-match.ts
Dave 5bacd048c6 fix(orders): never suggest a quote that shares no line with the order
scoreOrderVsQuote awarded up to 0.5 confidence from the header alone
(same company +3, same customer +2), which clears the 0.4 threshold
with zero paired lines. One quote was suggested to 16 unrelated
orders that way.

A candidate now needs at least one paired order/quote line; with no
pairs the confidence is 0. The header points and raw score stay in
the breakdown so the UI can still show why a quote looked close.

No existing test relied on the header-only confidence, so no fixture
needed changing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 12:31:48 +02:00

532 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string> {
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<string>();
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<string>, b: Set<string>): 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".
*
* No paired line → confidence 0; header signals alone never suggest a
* quote. Same company + same customer is not evidence that the order
* came from that quote — it was the reason one quote got suggested to
* 16 unrelated orders.
*
* 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. */
export 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<number>();
const qUsed = new Set<number>();
// Build all candidate pairs above the "possible" floor, take the
// best first. LINE_PAIR_POSSIBLE is declared further down in this
// file (with LINE_PAIR_CONFIDENT and pairQuoteLines) but is already
// initialized by the time this function runs — module evaluation
// completes before any caller can invoke it.
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 >= LINE_PAIR_POSSIBLE) 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);
}
// No paired line → no match, whatever the header says. A quote that
// shares nothing with the order (or has no lines at all) is not a
// candidate: same company + same customer used to reach 0.5 on the
// header alone, which is above the 0.4 threshold, and one quote was
// suggested to 16 unrelated orders that way.
//
// The header points and `raw` stay in the breakdown — the operator
// can still see WHY the quote was close on paper — but the
// confidence that gates the suggestion is 0.
if (pairs.length === 0) confidence = 0;
// 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;
}
/* ============================================================================
* Line-pairing tiers
*
* `pairLinesByDescription` already greedily pairs order lines to quote
* lines and drops anything below 0.3 Jaccard. These constants split
* what survives into two tiers:
*
* >= 0.5 confident — safe to persist onto `order_items.quote_item`
* 0.3–0.5 possible — shown to the operator with a confirm button,
* never written without their say-so
*
* Both numbers already existed in this file (0.5 is the
* `descriptionFull` award boundary, 0.3 is the candidate floor) — they
* are named here rather than reintroduced.
* ========================================================================== */
export const LINE_PAIR_CONFIDENT = 0.5;
export const LINE_PAIR_POSSIBLE = 0.3;
export type LinePairTier = "confident" | "possible";
export interface QuoteLinePair {
orderLineIdx: number;
quoteLineIdx: number;
jaccard: number;
tier: LinePairTier;
}
/** Pair order lines to quote lines by description overlap, tiered by
* confidence. Delegates to `pairLinesByDescription` so there is
* exactly one pairing algorithm in the codebase. */
export function pairQuoteLines(
orderItems: ReadonlyArray<{ description: string }>,
quoteItems: ReadonlyArray<{ description: string }>,
): QuoteLinePair[] {
return pairLinesByDescription(orderItems, quoteItems).map((p) => ({
orderLineIdx: p.oi,
quoteLineIdx: p.qi,
jaccard: p.jaccard,
tier: p.jaccard >= LINE_PAIR_CONFIDENT ? "confident" : "possible",
}));
}
/* ============================================================================
* Fuzzy search window
*
* The 200-candidate cap alone behaves inconsistently: for a high-volume
* company it reaches back weeks, for a quiet one it reaches back years.
* Anchoring on a date window normalises that and matches how the
* business works — a quote normally converts within about two months.
*
* The window is measured from the ORDER's own created date, never from
* `Date.now()`. A "last 90 days from today" filter would find zero
* candidates for every order in the backlog sweep.
*
* This applies ONLY to the fuzzy description+price path. The four
* exact-identifier lookups are never date limited — a PO citing
* Q-2024-0847 should resolve however old that quote is.
* ========================================================================== */
export const FUZZY_WINDOW_DAYS = 90;
export const FUZZY_WINDOW_WIDEN_DAYS = 365;
/** Forward grace for a quote raised just after the PO landed. */
export const FUZZY_WINDOW_GRACE_DAYS = 7;
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Parse a datetime string that may be either ISO 8601 (`T` separator,
* e.g. `2026-08-29T10:00:00.000Z`) or PocketBase's serialized form
* (a SPACE separator, e.g. `2026-08-29 10:00:00.000Z`).
*
* V8 happens to parse the space-separated form via `new Date(...)`,
* but per the ECMAScript spec that's implementation-defined — other
* engines are free to reject it. Every date this matcher touches
* ultimately comes from a PB `created` field, so we normalize the
* separator explicitly here rather than relying on engine leniency.
*/
function parseFlexibleDate(value: string): Date {
const normalized =
/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(value) ? value.replace(" ", "T") : value;
return new Date(normalized);
}
export interface QuoteWindow {
/** Inclusive lower bound, ISO 8601. */
from: string;
/** Inclusive upper bound, ISO 8601. */
to: string;
}
/** Build the candidate-quote date window for one order. */
export function quoteWindowForOrder(
orderCreated: string,
days: number,
): QuoteWindow {
const anchor = parseFlexibleDate(orderCreated);
if (Number.isNaN(anchor.getTime())) {
throw new Error(
`quoteWindowForOrder: unparseable order date ${JSON.stringify(orderCreated)}`,
);
}
return {
from: new Date(anchor.getTime() - days * DAY_MS).toISOString(),
to: new Date(anchor.getTime() + FUZZY_WINDOW_GRACE_DAYS * DAY_MS).toISOString(),
};
}
/** Whole days between a quote's creation and an order's creation.
* Positive means the quote came first (the normal case). Used by the
* sweep's age-gap histogram.
*
* `Math.round` ties toward +Infinity, so a negative half-day gap
* (e.g. -0.5) rounds to `-0`. `-0 === 0` is true and it stringifies
* as `"0"` almost everywhere, but it's still a footgun (e.g.
* `Object.is`, `1 / days`) — normalize it to a clean `0`. */
export function quoteToOrderAgeDays(
quoteCreated: string,
orderCreated: string,
): number {
const q = parseFlexibleDate(quoteCreated).getTime();
const o = parseFlexibleDate(orderCreated).getTime();
if (Number.isNaN(q) || Number.isNaN(o)) return 0;
const days = Math.round((o - q) / DAY_MS);
return days === 0 ? 0 : days;
}