pairOrderLines' no-overwrite guard only checked whether an order line's own row already carried a quote_item, not whether the quote line it was about to claim was already held by a different order line. A description drift (or a hand-confirmed link) on one line let a fresh confident match on another line silently double-assign the same quote_item, so two parts would render the same estimator's note and attachments — the exact failure this feature exists to prevent, reached from the other direction. Fixed by seeding a claimed-quote-item set from the fetched order rows before the write loop, and skipping any confident pair that targets an already-claimed quote line. unmatchedOrderItemIds now also excludes any line that already carries a link, even when this run's pairing pass didn't re-propose it. Also names the two field projections (PAIR_ORDER_ITEM_FIELDS, PAIR_QUOTE_ITEM_FIELDS) next to the existing MATCHER_QUOTE_ITEM_FIELDS constant, and adds a test that asserts on the actual `fields` string passed to each getFullList call. Previously nothing distinguished `fields: "id,description"` from `fields: "id,description,quote_item"` at the order-items query — a one-token trim silently disabled the no-overwrite guard (every row read as unlinked) with the full suite staying green, since the existing behavioral tests supply `quote_item` values directly in their fixtures rather than through the query. Verified by re-running that exact mutation against the new test: it fails, and neither pre-existing behavioral test catches it on its own.
481 lines
17 KiB
TypeScript
481 lines
17 KiB
TypeScript
/**
|
|
* order-quote-link-service — server-side order ↔ quote matching.
|
|
*
|
|
* The PocketBase half of the matcher. Its predecessor lives in
|
|
* `src/lib/pb-helpers.ts` and runs on the browser's PB singleton,
|
|
* which is why matching could only ever happen when an operator
|
|
* clicked a button. This version runs on `getAdminPocketBase()` so it
|
|
* can be called from `createOrder`, from the backlog sweep, and from
|
|
* route handlers.
|
|
*
|
|
* The pure scoring (`scoreOrderVsQuote`, `pairQuoteLines`,
|
|
* `quoteWindowForOrder`) lives in `order-quote-match.ts` and is reused
|
|
* untouched — DO NOT reimplement any of it here.
|
|
*
|
|
* Policy: this module NEVER writes `orders.quote`. It only ever writes
|
|
* the soft suggestion (`potential_quote`). Promoting a suggestion to a
|
|
* hard link is a deliberate human action that goes through
|
|
* `setOrderQuote` in `orders-service.ts`.
|
|
*/
|
|
|
|
import { ClientResponseError } from "pocketbase";
|
|
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
|
import { fetchQuoteItemsForQuotesAdmin } from "@/lib/server/quotes-service";
|
|
import type { QuoteItem } from "@/lib/types";
|
|
import {
|
|
scoreOrderVsQuote,
|
|
pairQuoteLines,
|
|
quoteWindowForOrder,
|
|
quoteToOrderAgeDays,
|
|
MATCH_CONFIDENCE_THRESHOLD,
|
|
FUZZY_CANDIDATE_CAP,
|
|
FUZZY_WINDOW_DAYS,
|
|
FUZZY_WINDOW_WIDEN_DAYS,
|
|
type ScoreBreakdown,
|
|
} from "@/lib/server/order-quote-match";
|
|
|
|
export type QuoteMatchMethod =
|
|
| "exact_quote_number"
|
|
| "exact_quote_reference"
|
|
| "exact_rfq_number"
|
|
| "exact_external_number"
|
|
| "company_customer_desc_price"
|
|
| "no_match";
|
|
|
|
export interface QuoteMatchCandidate {
|
|
quoteId: string;
|
|
quoteNumber: string;
|
|
quoteCreated: string;
|
|
confidence: number;
|
|
breakdown?: ScoreBreakdown;
|
|
method: Exclude<QuoteMatchMethod, "no_match">;
|
|
}
|
|
|
|
export interface SuggestQuoteResult {
|
|
orderId: string;
|
|
best: QuoteMatchCandidate | null;
|
|
alternatives: QuoteMatchCandidate[];
|
|
method: QuoteMatchMethod;
|
|
/** Which pass produced the result — for the sweep's reporting.
|
|
* "initial" is the FUZZY_WINDOW_DAYS pass, not a fixed "90d" label
|
|
* — retuning that constant must not make this label lie. */
|
|
window: "exact" | "initial" | "widened" | "none";
|
|
/** Days from the matched quote's creation to the order's. null when unmatched. */
|
|
ageGapDays: number | null;
|
|
/** Set when the order was deliberately not processed. */
|
|
skipped?: "already_linked";
|
|
}
|
|
|
|
interface OrderRow {
|
|
id: string;
|
|
created: string;
|
|
company?: string;
|
|
customer?: string;
|
|
quote?: string;
|
|
quote_reference_text?: string;
|
|
rfq_number?: string;
|
|
}
|
|
|
|
interface QuoteRow {
|
|
id: string;
|
|
quote_number?: string;
|
|
company?: string;
|
|
customer?: string;
|
|
created: string;
|
|
}
|
|
|
|
interface LineRow {
|
|
id: string;
|
|
description?: string;
|
|
selling_price?: number;
|
|
quantity?: number;
|
|
}
|
|
|
|
const ORDER_COLLECTION = "orders";
|
|
const QUOTE_COLLECTION = "quotes";
|
|
const ORDER_ITEMS_COLLECTION = "order_items";
|
|
|
|
/** The scalars `fuzzyPass` reads off each candidate quote. Explicit
|
|
* `fields` on the query — `quotes` also carries `email_body`,
|
|
* `ai_insights`, `migration_meta`, etc., and pulling those for up to
|
|
* FUZZY_CANDIDATE_CAP (200) candidates (twice, if the window widens)
|
|
* to read five scalars would dominate the whole operation's cost at
|
|
* intake volume. */
|
|
const CANDIDATE_QUOTE_FIELDS = "id,quote_number,company,customer,created";
|
|
|
|
/** What `scoreOrderVsQuote` needs off each quote line. Passed
|
|
* explicitly to `fetchQuoteItemsForQuotesAdmin` — its default
|
|
* projection belongs to the duplicate-quote scan, a different
|
|
* stakeholder with different needs. If that default is ever trimmed,
|
|
* this call must not silently lose `description` or `selling_price`
|
|
* and start scoring every candidate near zero. */
|
|
const MATCHER_QUOTE_ITEM_FIELDS = "id,quote,description,selling_price,quantity";
|
|
|
|
/** What `pairOrderLines` needs off each order line: enough to pair by
|
|
* description, plus the existing `quote_item` so the write loop can
|
|
* detect an already-set link and never clobber it. If this ever
|
|
* drops `quote_item`, that guard goes silently blind — every line
|
|
* reads as unlinked and a human-confirmed pairing can be
|
|
* overwritten. */
|
|
const PAIR_ORDER_ITEM_FIELDS = "id,description,quote_item";
|
|
|
|
/** What `pairOrderLines` needs off each quote line — just enough to
|
|
* pair by description. Passed explicitly to
|
|
* `fetchQuoteItemsForQuotesAdmin` for the same reason as
|
|
* `MATCHER_QUOTE_ITEM_FIELDS` above: its default projection belongs
|
|
* to the duplicate-quote scan, a different caller with different
|
|
* needs, and must not silently drift under this one. */
|
|
const PAIR_QUOTE_ITEM_FIELDS = "id,quote,description";
|
|
|
|
function esc(s: string): string {
|
|
return (s || "").replace(/"/g, '\\"');
|
|
}
|
|
|
|
function project(rows: ReadonlyArray<LineRow>) {
|
|
return rows.map((r) => ({
|
|
description: r.description || "",
|
|
selling_price: Number(r.selling_price) || 0,
|
|
quantity: Number(r.quantity) || 1,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Try the four exact-identifier fields, in priority order. Never date
|
|
* limited — a citation is evidence regardless of age.
|
|
*
|
|
* Scoped to the order's own company when known: `quote_reference_text`
|
|
* and `rfq_number` are customer-supplied free text, and without this
|
|
* clause a reference-number collision across two customers would
|
|
* return a confidence-1.0 suggestion pointing at a different
|
|
* company's quote.
|
|
*/
|
|
async function findExactMatch(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
ref: string,
|
|
companyId?: string,
|
|
): Promise<{ quote: QuoteRow; method: QuoteMatchCandidate["method"] } | null> {
|
|
const trimmed = (ref || "").trim();
|
|
if (!trimmed) return null;
|
|
const fields = [
|
|
["quote_number", "exact_quote_number"],
|
|
["quote_reference", "exact_quote_reference"],
|
|
["rfq_number", "exact_rfq_number"],
|
|
["external_number", "exact_external_number"],
|
|
] as const;
|
|
for (const [field, method] of fields) {
|
|
const clauses = [`${field} = "${esc(trimmed)}"`];
|
|
if (companyId) clauses.push(`company = "${esc(companyId)}"`);
|
|
try {
|
|
const list = await pb
|
|
.collection(QUOTE_COLLECTION)
|
|
.getList<QuoteRow>(1, 5, { filter: clauses.join(" && ") });
|
|
if (list.items.length > 0) return { quote: list.items[0], method };
|
|
} catch (err) {
|
|
// A field this deploy's PB schema doesn't have 400s — try the
|
|
// next one; all four fields exist per pb-exports/pb_schema-2.json
|
|
// so this is a defensive fallback, not the expected path.
|
|
// Anything else (expired admin token, connection reset, PB
|
|
// restart mid-sweep) is a real failure and must propagate:
|
|
// swallowing it here would let the fuzzy path silently run
|
|
// instead, and writeSuggestion would then persist
|
|
// `quote_matching_attempted: true` / `quote_match_method:
|
|
// "no_match"` for an order that actually had a valid citation —
|
|
// permanently hiding it from the sweep's "haven't tried yet"
|
|
// filter, since nothing revisits an order once that flag is set.
|
|
if (err instanceof ClientResponseError && err.status === 400) continue;
|
|
throw err;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Score every candidate quote inside one date window. */
|
|
async function fuzzyPass(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
order: OrderRow,
|
|
orderItems: LineRow[],
|
|
windowDays: number,
|
|
): Promise<QuoteMatchCandidate[]> {
|
|
if (orderItems.length === 0) return [];
|
|
|
|
const win = quoteWindowForOrder(order.created, windowDays);
|
|
const filters: string[] = [
|
|
`created >= "${win.from}"`,
|
|
`created <= "${win.to}"`,
|
|
"is_template != true",
|
|
];
|
|
if (order.company) filters.push(`company = "${esc(order.company)}"`);
|
|
if (order.customer) {
|
|
filters.push(`(customer = "${esc(order.customer)}" || customer = "")`);
|
|
}
|
|
|
|
const candidates = await pb
|
|
.collection(QUOTE_COLLECTION)
|
|
.getList<QuoteRow>(1, FUZZY_CANDIDATE_CAP, {
|
|
filter: filters.join(" && "),
|
|
sort: "-created",
|
|
fields: CANDIDATE_QUOTE_FIELDS,
|
|
});
|
|
if (candidates.items.length === 0) return [];
|
|
|
|
// A naive `quote = "x" || quote = "y" || ...` OR-chain over
|
|
// FUZZY_CANDIDATE_CAP (200) ids blows PB's filter length limit
|
|
// (HTTP 400) well before hitting the cap — `fetchQuoteItemsForQuotesAdmin`
|
|
// tries PB's `IN (...)` syntax first and falls back to chunked
|
|
// OR-chains, so a company with a large quote history doesn't break
|
|
// the matcher outright. Reused (not reimplemented) from
|
|
// `quotes-service.ts`, which already has this exact chunking logic
|
|
// for its duplicate-quote scan.
|
|
const quoteIds = candidates.items.map((q) => q.id);
|
|
const quoteItems = await fetchQuoteItemsForQuotesAdmin(pb, quoteIds, MATCHER_QUOTE_ITEM_FIELDS);
|
|
|
|
const byQuote = new Map<string, QuoteItem[]>();
|
|
for (const it of quoteItems) {
|
|
if (!it.quote) continue;
|
|
const bucket = byQuote.get(it.quote);
|
|
if (bucket) bucket.push(it);
|
|
else byQuote.set(it.quote, [it]);
|
|
}
|
|
|
|
const projectedOrder = project(orderItems);
|
|
const scored: QuoteMatchCandidate[] = [];
|
|
for (const q of candidates.items) {
|
|
const breakdown = scoreOrderVsQuote(
|
|
{ companyId: order.company, customerId: order.customer, items: projectedOrder },
|
|
{ company: q.company, customer: q.customer, items: project(byQuote.get(q.id) ?? []) },
|
|
);
|
|
if (breakdown.confidence >= MATCH_CONFIDENCE_THRESHOLD) {
|
|
scored.push({
|
|
quoteId: q.id,
|
|
quoteNumber: q.quote_number || "",
|
|
quoteCreated: q.created,
|
|
confidence: breakdown.confidence,
|
|
breakdown,
|
|
method: "company_customer_desc_price",
|
|
});
|
|
}
|
|
}
|
|
scored.sort((a, b) =>
|
|
b.confidence !== a.confidence
|
|
? b.confidence - a.confidence
|
|
: (b.quoteCreated || "").localeCompare(a.quoteCreated || ""),
|
|
);
|
|
return scored;
|
|
}
|
|
|
|
/**
|
|
* Find the best candidate quote for an order and write it as a soft
|
|
* suggestion. Idempotent and safe to re-run.
|
|
*/
|
|
export async function suggestQuoteForOrder(
|
|
orderId: string,
|
|
): Promise<SuggestQuoteResult> {
|
|
const pb = await getAdminPocketBase();
|
|
pb.autoCancellation(false);
|
|
|
|
const order = await pb.collection(ORDER_COLLECTION).getOne<OrderRow>(orderId);
|
|
|
|
// An order with a confirmed hard link is settled. Never second-guess it.
|
|
if (order.quote) {
|
|
return {
|
|
orderId,
|
|
best: null,
|
|
alternatives: [],
|
|
method: "no_match",
|
|
window: "none",
|
|
ageGapDays: null,
|
|
skipped: "already_linked",
|
|
};
|
|
}
|
|
|
|
// 1. Exact identifier citation, if the order carries reference text.
|
|
const ref = (order.quote_reference_text || order.rfq_number || "").trim();
|
|
const exact = await findExactMatch(pb, ref, order.company);
|
|
if (exact) {
|
|
const best: QuoteMatchCandidate = {
|
|
quoteId: exact.quote.id,
|
|
quoteNumber: exact.quote.quote_number || "",
|
|
quoteCreated: exact.quote.created,
|
|
confidence: 1,
|
|
method: exact.method,
|
|
};
|
|
await writeSuggestion(pb, orderId, best);
|
|
return {
|
|
orderId,
|
|
best,
|
|
alternatives: [],
|
|
method: exact.method,
|
|
window: "exact",
|
|
ageGapDays: quoteToOrderAgeDays(exact.quote.created, order.created),
|
|
};
|
|
}
|
|
|
|
// 2. Fuzzy — 90-day window first, widening only if it comes up empty.
|
|
const orderItems = await pb
|
|
.collection(ORDER_ITEMS_COLLECTION)
|
|
.getFullList<LineRow>({
|
|
filter: `order = "${esc(orderId)}"`,
|
|
fields: "id,description,selling_price,quantity",
|
|
batch: 1000,
|
|
});
|
|
|
|
let scored = await fuzzyPass(pb, order, orderItems, FUZZY_WINDOW_DAYS);
|
|
let window: SuggestQuoteResult["window"] = "initial";
|
|
if (scored.length === 0) {
|
|
scored = await fuzzyPass(pb, order, orderItems, FUZZY_WINDOW_WIDEN_DAYS);
|
|
window = "widened";
|
|
}
|
|
|
|
const best = scored[0] ?? null;
|
|
await writeSuggestion(pb, orderId, best);
|
|
|
|
return {
|
|
orderId,
|
|
best,
|
|
alternatives: scored.slice(1, 6),
|
|
method: best ? "company_customer_desc_price" : "no_match",
|
|
window: best ? window : "none",
|
|
ageGapDays: best ? quoteToOrderAgeDays(best.quoteCreated, order.created) : null,
|
|
};
|
|
}
|
|
|
|
/** Persist (or clear) the soft suggestion. Never touches `quote`. */
|
|
async function writeSuggestion(
|
|
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
|
orderId: string,
|
|
best: QuoteMatchCandidate | null,
|
|
): Promise<void> {
|
|
await pb.collection(ORDER_COLLECTION).update(orderId, {
|
|
quote_matching_attempted: true,
|
|
quote_match_method: best ? best.method : "no_match",
|
|
potential_quote: best ? best.quoteId : null,
|
|
potential_quote_confidence: best ? best.confidence : 0,
|
|
});
|
|
}
|
|
|
|
/* ============================================================================
|
|
* Per-line pairing
|
|
*
|
|
* `order_items.quote_item` has existed all along but was only ever
|
|
* written by `createOrderFromQuote`. When a quote is linked to an
|
|
* order after the fact, nothing paired the lines — so the process page
|
|
* re-guessed the pairing on every render with an exact/substring
|
|
* match, and any wording difference silently hid the buyer's note.
|
|
*
|
|
* Confident pairs are persisted here so the guess happens once.
|
|
* Possible pairs are returned but NOT written: the operator confirms
|
|
* them, so a buyer is never shown a note from the wrong line without
|
|
* being told it is a guess.
|
|
* ========================================================================== */
|
|
|
|
export interface PairedLine {
|
|
orderItemId: string;
|
|
quoteItemId: string;
|
|
/** Jaccard overlap of the two descriptions, 0..1. */
|
|
confidence: number;
|
|
}
|
|
|
|
export interface PairOrderLinesResult {
|
|
confident: PairedLine[];
|
|
possible: PairedLine[];
|
|
unmatchedOrderItemIds: string[];
|
|
}
|
|
|
|
/**
|
|
* Pair an order's lines against a quote's lines, persisting only the
|
|
* confident ones. Safe to re-run: a line whose `quote_item` is already
|
|
* set is never overwritten.
|
|
*/
|
|
export async function pairOrderLines(
|
|
orderId: string,
|
|
quoteId: string,
|
|
): Promise<PairOrderLinesResult> {
|
|
const pb = await getAdminPocketBase();
|
|
pb.autoCancellation(false);
|
|
|
|
const [orderItems, quoteItems] = await Promise.all([
|
|
pb
|
|
.collection(ORDER_ITEMS_COLLECTION)
|
|
.getFullList<LineRow & { quote_item?: string }>({
|
|
filter: `order = "${esc(orderId)}"`,
|
|
fields: PAIR_ORDER_ITEM_FIELDS,
|
|
batch: 1000,
|
|
}),
|
|
// Reuses the same helper the fuzzy pass uses for quote_items — it
|
|
// already handles PB's IN(...) filter and the chunked-OR-chain
|
|
// fallback, so there is one code path in this file that knows how
|
|
// to fetch quote_items rather than two.
|
|
fetchQuoteItemsForQuotesAdmin(pb, [quoteId], PAIR_QUOTE_ITEM_FIELDS),
|
|
]);
|
|
|
|
// `pairQuoteLines` returns indices into exactly the arrays passed to
|
|
// it. Both arrays below are built with a single `.map()` over
|
|
// `orderItems`/`quoteItems` — `.map()` is index-preserving by
|
|
// definition, so `orderLineIdx`/`quoteLineIdx` always refer back to
|
|
// the same position in `orderItems`/`quoteItems`. No sort or filter
|
|
// runs on either array between the fetch and this call, so the
|
|
// mapping can't be thrown off.
|
|
const pairs = pairQuoteLines(
|
|
orderItems.map((r) => ({ description: r.description || "" })),
|
|
quoteItems.map((r) => ({ description: r.description || "" })),
|
|
);
|
|
|
|
const confident: PairedLine[] = [];
|
|
const possible: PairedLine[] = [];
|
|
const pairedOrderIdx = new Set<number>();
|
|
|
|
for (const p of pairs) {
|
|
pairedOrderIdx.add(p.orderLineIdx);
|
|
const entry: PairedLine = {
|
|
orderItemId: orderItems[p.orderLineIdx].id,
|
|
quoteItemId: quoteItems[p.quoteLineIdx].id,
|
|
confidence: p.jaccard,
|
|
};
|
|
if (p.tier === "confident") confident.push(entry);
|
|
else possible.push(entry);
|
|
}
|
|
|
|
// Quote lines already claimed by an order line's existing DB value.
|
|
// Seeded up front, before any write — a stale link (the line's
|
|
// description drifted since it was set, or an operator hand-picked
|
|
// it) still occupies that quote line. Without this, a fresh
|
|
// confident pair on a *different* order line can point a second
|
|
// line at the same quote_item: both would then render the same
|
|
// estimator's note and attachments, which is exactly the failure
|
|
// this feature exists to prevent, just reached from the other
|
|
// direction (`row?.quote_item` below only guards the pair's own
|
|
// order line, not the quote line it targets).
|
|
const claimedQuoteItemIds = new Set(
|
|
orderItems.map((r) => r.quote_item).filter((v): v is string => Boolean(v)),
|
|
);
|
|
|
|
// Persist confident pairs only, and never clobber an existing link
|
|
// (a line paired by createOrderFromQuote, or confirmed by hand).
|
|
//
|
|
// Sequential, one write per line — same tradeoff as the bulk-cancel
|
|
// and delivery loops elsewhere in orders-service.ts ("one PB
|
|
// round-trip per write is fine here; we cap at a few dozen items
|
|
// per order in practice"). Parallelizing with Promise.all would add
|
|
// concurrent-write risk against PB for no real gain at this volume.
|
|
for (const c of confident) {
|
|
const row = orderItems.find((r) => r.id === c.orderItemId);
|
|
if (row?.quote_item) continue;
|
|
if (claimedQuoteItemIds.has(c.quoteItemId)) continue;
|
|
await pb.collection(ORDER_ITEMS_COLLECTION).update(c.orderItemId, {
|
|
quote_item: c.quoteItemId,
|
|
});
|
|
}
|
|
|
|
return {
|
|
confident,
|
|
possible,
|
|
// A line already carrying a `quote_item` in the DB is matched
|
|
// even if this run's algorithm didn't (re-)propose a pair for
|
|
// it — e.g. its description drifted away from its original
|
|
// counterpart. Only a line with neither an existing link nor a
|
|
// fresh pair counts as genuinely unmatched.
|
|
unmatchedOrderItemIds: orderItems
|
|
.filter((r, idx) => !pairedOrderIdx.has(idx) && !r.quote_item)
|
|
.map((r) => r.id),
|
|
};
|
|
}
|