feat(orders): add server-side quote suggestion service
Ports the PocketBase half of the order<->quote matcher off the browser's PB singleton and onto getAdminPocketBase(), so matching can run at order intake and from a backlog sweep instead of only on a button click. Reuses the pure scoring in order-quote-match.ts untouched (scoreOrderVsQuote, quoteWindowForOrder, quoteToOrderAgeDays). suggestQuoteForOrder tries the four exact-identifier fields first (never date-limited), then falls back to a company/customer/ description/price fuzzy scan windowed to 90 days before the order date, widening to 365 days only if that comes up empty. Writes only the soft suggestion (potential_quote, potential_quote_confidence, quote_matching_attempted, quote_match_method) - never orders.quote, the hard link. Orders that already carry a hard link are skipped untouched. pb-helpers.ts's findMatchingQuotesForOrder is left in place; a later task migrates its last UI caller and removes it.
This commit is contained in:
parent
aba3bdca5d
commit
699512e216
2 changed files with 467 additions and 0 deletions
172
src/lib/server/order-quote-link-service.test.ts
Normal file
172
src/lib/server/order-quote-link-service.test.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { suggestQuoteForOrder } from "@/lib/server/order-quote-link-service";
|
||||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
|
||||
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
||||
getAdminPocketBase: vi.fn(),
|
||||
}));
|
||||
|
||||
const ORDERS = "orders";
|
||||
const QUOTES = "quotes";
|
||||
const ORDER_ITEMS = "order_items";
|
||||
const QUOTE_ITEMS = "quote_items";
|
||||
|
||||
interface CollectionMock {
|
||||
getOne?: ReturnType<typeof vi.fn>;
|
||||
getList?: ReturnType<typeof vi.fn>;
|
||||
getFullList?: ReturnType<typeof vi.fn>;
|
||||
update?: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function makePb(collections: Record<string, CollectionMock>) {
|
||||
return {
|
||||
autoCancellation: vi.fn(),
|
||||
collection: (name: string) => {
|
||||
const c = collections[name];
|
||||
if (!c) throw new Error(`unexpected collection: ${name}`);
|
||||
return c;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function useP(pb: ReturnType<typeof makePb>) {
|
||||
vi.mocked(getAdminPocketBase).mockResolvedValue(
|
||||
pb as unknown as Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||||
);
|
||||
}
|
||||
|
||||
const ORDER = {
|
||||
id: "ord1",
|
||||
created: "2026-08-29T10:00:00.000Z",
|
||||
company: "co1",
|
||||
customer: "cust1",
|
||||
quote: "",
|
||||
quote_reference_text: "",
|
||||
};
|
||||
|
||||
describe("suggestQuoteForOrder", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("resolves an exact quote_number citation regardless of the date window", async () => {
|
||||
const update = vi.fn().mockResolvedValue({});
|
||||
const quotesGetList = vi.fn().mockResolvedValue({
|
||||
items: [{ id: "q_old", quote_number: "Q-2024-0847", created: "2024-01-05T00:00:00.000Z" }],
|
||||
});
|
||||
useP(
|
||||
makePb({
|
||||
[ORDERS]: {
|
||||
getOne: vi.fn().mockResolvedValue({ ...ORDER, quote_reference_text: "Q-2024-0847" }),
|
||||
update,
|
||||
},
|
||||
[QUOTES]: { getList: quotesGetList },
|
||||
[ORDER_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) },
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await suggestQuoteForOrder("ord1");
|
||||
|
||||
expect(res.method).toBe("exact_quote_number");
|
||||
expect(res.best?.quoteId).toBe("q_old");
|
||||
expect(res.window).toBe("exact");
|
||||
// Suggestion only — the hard link is never written.
|
||||
expect(update).toHaveBeenCalledWith("ord1", expect.objectContaining({
|
||||
potential_quote: "q_old",
|
||||
quote_matching_attempted: true,
|
||||
}));
|
||||
expect(update.mock.calls[0][1]).not.toHaveProperty("quote");
|
||||
});
|
||||
|
||||
it("constrains fuzzy candidates to the 90-day window before the order date", async () => {
|
||||
const quotesGetList = vi.fn().mockResolvedValue({ items: [] });
|
||||
useP(
|
||||
makePb({
|
||||
[ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update: vi.fn().mockResolvedValue({}) },
|
||||
[QUOTES]: { getList: quotesGetList },
|
||||
[ORDER_ITEMS]: {
|
||||
getFullList: vi.fn().mockResolvedValue([
|
||||
{ id: "oi1", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 },
|
||||
]),
|
||||
},
|
||||
[QUOTE_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) },
|
||||
}),
|
||||
);
|
||||
|
||||
await suggestQuoteForOrder("ord1");
|
||||
|
||||
const filter = quotesGetList.mock.calls[0][2].filter as string;
|
||||
expect(filter).toContain('created >= "2026-05-31T10:00:00.000Z"');
|
||||
expect(filter).toContain('company = "co1"');
|
||||
});
|
||||
|
||||
it("widens to 12 months when the 90-day window yields nothing above threshold", async () => {
|
||||
const quotesGetList = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ items: [] })
|
||||
.mockResolvedValueOnce({
|
||||
items: [{ id: "q1", quote_number: "Q-1", company: "co1", customer: "cust1", created: "2025-11-01T00:00:00.000Z" }],
|
||||
});
|
||||
useP(
|
||||
makePb({
|
||||
[ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update: vi.fn().mockResolvedValue({}) },
|
||||
[QUOTES]: { getList: quotesGetList },
|
||||
[ORDER_ITEMS]: {
|
||||
getFullList: vi.fn().mockResolvedValue([
|
||||
{ id: "oi1", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 },
|
||||
]),
|
||||
},
|
||||
[QUOTE_ITEMS]: {
|
||||
getFullList: vi.fn().mockResolvedValue([
|
||||
{ id: "qi1", quote: "q1", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 },
|
||||
]),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await suggestQuoteForOrder("ord1");
|
||||
|
||||
expect(quotesGetList).toHaveBeenCalledTimes(2);
|
||||
expect(res.window).toBe("widened");
|
||||
expect(res.best?.quoteId).toBe("q1");
|
||||
});
|
||||
|
||||
it("records no_match and still stamps quote_matching_attempted", async () => {
|
||||
const update = vi.fn().mockResolvedValue({});
|
||||
useP(
|
||||
makePb({
|
||||
[ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update },
|
||||
[QUOTES]: { getList: vi.fn().mockResolvedValue({ items: [] }) },
|
||||
[ORDER_ITEMS]: {
|
||||
getFullList: vi.fn().mockResolvedValue([
|
||||
{ id: "oi1", description: "widget", selling_price: 1, quantity: 1 },
|
||||
]),
|
||||
},
|
||||
[QUOTE_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) },
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await suggestQuoteForOrder("ord1");
|
||||
|
||||
expect(res.method).toBe("no_match");
|
||||
expect(res.best).toBeNull();
|
||||
expect(update).toHaveBeenCalledWith("ord1", expect.objectContaining({
|
||||
quote_matching_attempted: true,
|
||||
potential_quote: null,
|
||||
potential_quote_confidence: 0,
|
||||
}));
|
||||
});
|
||||
|
||||
it("skips an order that already has a hard quote link", async () => {
|
||||
const update = vi.fn();
|
||||
useP(
|
||||
makePb({
|
||||
[ORDERS]: { getOne: vi.fn().mockResolvedValue({ ...ORDER, quote: "already" }), update },
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await suggestQuoteForOrder("ord1");
|
||||
|
||||
expect(res.method).toBe("no_match");
|
||||
expect(res.skipped).toBe("already_linked");
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
295
src/lib/server/order-quote-link-service.ts
Normal file
295
src/lib/server/order-quote-link-service.ts
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
/**
|
||||
* 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 { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||||
import {
|
||||
scoreOrderVsQuote,
|
||||
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. */
|
||||
window: "exact" | "90d" | "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;
|
||||
}
|
||||
|
||||
interface QuoteLineRow extends LineRow {
|
||||
quote: string;
|
||||
}
|
||||
|
||||
const ORDER_COLLECTION = "orders";
|
||||
const QUOTE_COLLECTION = "quotes";
|
||||
const ORDER_ITEMS_COLLECTION = "order_items";
|
||||
const QUOTE_ITEMS_COLLECTION = "quote_items";
|
||||
|
||||
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. */
|
||||
async function findExactMatch(
|
||||
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
|
||||
ref: 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) {
|
||||
try {
|
||||
const list = await pb
|
||||
.collection(QUOTE_COLLECTION)
|
||||
.getList<QuoteRow>(1, 5, { filter: `${field} = "${esc(trimmed)}"` });
|
||||
if (list.items.length > 0) return { quote: list.items[0], method };
|
||||
} catch {
|
||||
// Field may not exist on older PB deploys — try the next.
|
||||
}
|
||||
}
|
||||
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",
|
||||
});
|
||||
if (candidates.items.length === 0) return [];
|
||||
|
||||
const quoteIds = candidates.items.map((q) => q.id);
|
||||
const orFilter = quoteIds.map((qid) => `quote = "${qid}"`).join(" || ");
|
||||
const quoteItems = await pb
|
||||
.collection(QUOTE_ITEMS_COLLECTION)
|
||||
.getFullList<QuoteLineRow>({
|
||||
filter: orFilter,
|
||||
fields: "id,quote,description,selling_price,quantity",
|
||||
batch: 1000,
|
||||
});
|
||||
|
||||
const byQuote = new Map<string, QuoteLineRow[]>();
|
||||
for (const it of quoteItems) {
|
||||
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);
|
||||
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"] = "90d";
|
||||
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,
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue