WFDASH-WEBAPP/src/lib/server/order-quote-match.ts

518 lines
20 KiB
TypeScript
Raw Normal View History

/**
* 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".
*
* 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);
}
// 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;
}