2026-06-29 11:06:01 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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.
|
2026-07-07 15:22:03 +00:00
|
|
|
|
* - The pure math (tokenize, jaccard, priceScore, scoreOrderVsQuote)
|
2026-06-29 11:06:01 +00:00
|
|
|
|
* 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. */
|
2026-07-07 15:22:03 +00:00
|
|
|
|
export const MATCH_CONFIDENCE_THRESHOLD = 0.4;
|
2026-06-29 11:06:01 +00:00
|
|
|
|
|
|
|
|
|
|
/** 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-07 15:22:03 +00:00
|
|
|
|
/* ============================================================================
|
|
|
|
|
|
* 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. */
|
2026-08-29 09:13:59 +00:00
|
|
|
|
export function pairLinesByDescription(
|
2026-07-07 15:22:03 +00:00
|
|
|
|
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>();
|
|
|
|
|
|
|
2026-08-29 09:23:38 +00:00
|
|
|
|
// 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.
|
2026-07-07 15:22:03 +00:00
|
|
|
|
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]);
|
2026-08-29 09:23:38 +00:00
|
|
|
|
if (j >= LINE_PAIR_POSSIBLE) candidates.push({ oi, qi, j });
|
2026-07-07 15:22:03 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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.
|
|
|
|
|
|
*/
|
2026-06-29 11:06:01 +00:00
|
|
|
|
export function scoreCandidate(
|
|
|
|
|
|
orderItems: ReadonlyArray<{ description: string; selling_price: number; quantity: number }>,
|
|
|
|
|
|
quoteItems: ReadonlyArray<{ description: string; selling_price: number; quantity: number }>
|
|
|
|
|
|
): number {
|
2026-07-07 15:22:03 +00:00
|
|
|
|
if (orderItems.length === 0) return 0;
|
|
|
|
|
|
return scoreOrderVsQuote(
|
|
|
|
|
|
{ items: orderItems },
|
|
|
|
|
|
{ items: quoteItems }
|
|
|
|
|
|
).confidence;
|
2026-06-29 11:06:01 +00:00
|
|
|
|
}
|
2026-08-29 09:13:59 +00:00
|
|
|
|
|
|
|
|
|
|
/* ============================================================================
|
|
|
|
|
|
* 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",
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
2026-08-29 09:28:10 +00:00
|
|
|
|
|
|
|
|
|
|
/* ============================================================================
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
|
2026-08-29 11:20:40 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-29 09:28:10 +00:00
|
|
|
|
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 {
|
2026-08-29 11:20:40 +00:00
|
|
|
|
const anchor = parseFlexibleDate(orderCreated);
|
2026-08-29 09:28:10 +00:00
|
|
|
|
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
|
2026-08-29 11:20:40 +00:00
|
|
|
|
* 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`. */
|
2026-08-29 09:28:10 +00:00
|
|
|
|
export function quoteToOrderAgeDays(
|
|
|
|
|
|
quoteCreated: string,
|
|
|
|
|
|
orderCreated: string,
|
|
|
|
|
|
): number {
|
2026-08-29 11:20:40 +00:00
|
|
|
|
const q = parseFlexibleDate(quoteCreated).getTime();
|
|
|
|
|
|
const o = parseFlexibleDate(orderCreated).getTime();
|
2026-08-29 09:28:10 +00:00
|
|
|
|
if (Number.isNaN(q) || Number.isNaN(o)) return 0;
|
2026-08-29 11:20:40 +00:00
|
|
|
|
const days = Math.round((o - q) / DAY_MS);
|
|
|
|
|
|
return days === 0 ? 0 : days;
|
2026-08-29 09:28:10 +00:00
|
|
|
|
}
|