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

429 lines
16 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".
*
* 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 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;
}
/* ============================================================================
* 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",
}));
}