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