feat(orders): read-only quote suggestion and stale-suggestion clean-up script

suggestQuoteForOrder is split into computeQuoteSuggestion (all the
logic, no writes) and suggestQuoteForOrder (compute + the existing
writeSuggestion). Behaviour is otherwise unchanged: the same order
ends up with the same suggestion, written the same way.

computeQuoteSuggestion also drops candidate quotes that another order
already hard-links. A quote belongs to one order, and setOrderQuote
refuses a second link with a 409, so offering one invites a dead end.
The check is one query on `orders` (fields id,quote), chunked at 50
ids per OR-chain to stay under PB's filter-length limit, and it
applies to the exact-match citation too — an exact hit linked
elsewhere falls through to the fuzzy pass instead of being suggested.

scripts/2026-10-24-rematch-quote-suggestions.ts re-runs the matcher
over every order with a soft suggestion and no hard link, which is
where the stale 16x/11x repeats live. Dry run unless --apply; the dry
run only reads.

The script has NOT been run — the owner runs the dry run against live
PocketBase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-24 12:45:56 +02:00
parent 5bacd048c6
commit 09b9c0f280
3 changed files with 481 additions and 21 deletions

View file

@ -0,0 +1,182 @@
/**
* One-off clean-up for the stale order → quote suggestions the
* rule-based matcher wrote before it was fixed.
*
* The old scorer awarded up to 0.5 confidence from the header alone
* (same company +3, same customer +2), which cleared the 0.4 threshold
* with no matching line at all. One quote was suggested to 16 different
* orders, another to 11 — the "same few quotes over and over" symptom
* in the design doc.
*
* `scoreOrderVsQuote` now requires a paired order/quote line
* (confidence 0 without one), and the matcher no longer offers a quote
* that another order already hard-links. This script re-runs the
* matcher over every order that carries a soft suggestion but no hard
* link, so the stale suggestions are replaced with what the fixed
* matcher actually thinks.
*
* Dry run unless `--apply`. The dry run only READS (it calls the
* read-only `computeQuoteSuggestion`); `--apply` writes the new
* suggestion through `suggestQuoteForOrder`.
*
* npx tsx scripts/2026-10-24-rematch-quote-suggestions.ts [--apply]
*/
import "./_env";
import { getAdminPocketBase } from "../src/lib/server/pocketbase-admin";
import { runWithAuditActor } from "../src/lib/server/audit-context";
import {
computeQuoteSuggestion,
suggestQuoteForOrder,
} from "../src/lib/server/order-quote-link-service";
const APPLY = process.argv.includes("--apply");
const actor = { id: "claude-quote-rematch", name: "Claude (quote suggestion clean-up)" };
/** Orders with a soft suggestion but no hard link. `potential_quote != ""`
* is the codebase's pattern for "the relation is set" (the mirror of
* `quote = ""`, used in the backlog sweep). */
const ORDER_FILTER = 'potential_quote != "" && quote = ""';
interface OrderRow {
id: string;
order_number?: string;
potential_quote?: string;
}
/** Quote id → quote number, for a report a human can read. */
async function quoteNumbers(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
quoteIds: string[],
): Promise<Map<string, string>> {
const out = new Map<string, string>();
const ids = [...new Set(quoteIds.filter(Boolean))];
if (ids.length === 0) return out;
for (let i = 0; i < ids.length; i += 50) {
const chunk = ids.slice(i, i + 50);
const rows = await pb.collection("quotes").getFullList<{ id: string; quote_number?: string }>({
filter: chunk.map((id) => `id = "${id}"`).join(" || "),
fields: "id,quote_number",
batch: 500,
});
for (const row of rows) out.set(row.id, row.quote_number || row.id);
}
return out;
}
async function main() {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
const orders = await pb
.collection("orders")
.getFullList<OrderRow>({ filter: ORDER_FILTER, fields: "id,order_number,potential_quote", batch: 500 });
console.log(
`${APPLY ? "APPLY" : "DRY "} ${orders.length} order(s) with a soft suggestion and no hard link`,
);
if (orders.length === 0) return;
// Quote numbers for the old suggestions, so the summary can name the
// quotes that were suggested over and over.
const oldNumbers = await quoteNumbers(pb, orders.map((o) => o.potential_quote || ""));
const newQuoteIds: string[] = [];
const rows: Array<{
id: string;
orderNumber: string;
oldQuote: string;
newQuote: string | null;
oldLabel: string;
skipped: boolean;
}> = [];
for (const order of orders) {
const oldQuote = order.potential_quote || "";
const oldLabel = oldNumbers.get(oldQuote) ?? oldQuote;
let newQuote: string | null = null;
let skipped = false;
try {
const res = APPLY
? await suggestQuoteForOrder(order.id)
: await computeQuoteSuggestion(order.id);
newQuote = res.best?.quoteId ?? null;
skipped = res.skipped === "already_linked";
} catch (err) {
console.error(
`ERROR ${order.order_number || order.id}: ${err instanceof Error ? err.message : err}`,
);
continue;
}
if (newQuote) newQuoteIds.push(newQuote);
rows.push({
id: order.id,
orderNumber: order.order_number || order.id,
oldQuote,
newQuote,
oldLabel,
skipped,
});
console.log(
`${APPLY ? "APPLY" : "DRY "} ${order.order_number || order.id}: ${oldLabel} → ${
skipped ? "skipped (already linked)" : (newQuote ?? "none")
}`,
);
}
const newNumbers = await quoteNumbers(pb, newQuoteIds);
// How many orders pointed at each quote BEFORE this run. The repeats
// (one quote suggested to 16 orders) show up here and are expected
// to disappear once the fixes are applied.
const perOldQuote = new Map<string, number>();
for (const row of rows) {
if (!row.oldQuote) continue;
perOldQuote.set(row.oldLabel, (perOldQuote.get(row.oldLabel) ?? 0) + 1);
}
let cleared = 0;
let changed = 0;
let unchanged = 0;
let skipped = 0;
for (const row of rows) {
if (row.skipped) {
skipped++;
continue;
}
if (!row.newQuote) cleared++;
else if (row.newQuote !== row.oldQuote) changed++;
else unchanged++;
}
console.log("\n── suggestions before this run (orders per quote) ──");
const sorted = [...perOldQuote.entries()].sort((a, b) => b[1] - a[1]);
if (sorted.length === 0) console.log(" (none)");
for (const [label, count] of sorted) console.log(` ${count} × ${label}`);
console.log("\n── outcome ──");
console.log(` orders processed : ${rows.length}`);
console.log(` cleared : ${cleared} (suggestion removed — no match left)`);
console.log(` changed : ${changed} (points at a different quote)`);
console.log(` unchanged : ${unchanged} (same quote still the best match)`);
if (skipped > 0) console.log(` skipped : ${skipped} (already hard-linked elsewhere)`);
console.log("\n── suggestions after this run (orders per quote) ──");
const perNewQuote = new Map<string, number>();
for (const row of rows) {
if (!row.newQuote) continue;
const label = newNumbers.get(row.newQuote) ?? row.newQuote;
perNewQuote.set(label, (perNewQuote.get(label) ?? 0) + 1);
}
const sortedNew = [...perNewQuote.entries()].sort((a, b) => b[1] - a[1]);
if (sortedNew.length === 0) console.log(" (none)");
for (const [label, count] of sortedNew) console.log(` ${count} × ${label}`);
if (!APPLY) {
console.log("\nDry run — nothing was written. Re-run with --apply to persist the new suggestions.");
}
}
runWithAuditActor({ ...actor, source: "server", ignoreRequestUser: true }, main).catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
suggestQuoteForOrder,
computeQuoteSuggestion,
pairOrderLines,
sweepUnmatchedOrders,
bucketAgeGaps,
@ -40,18 +41,31 @@ const ORDER = {
* cares about one of them, and an undeclared *method* on a
* hand-rolled mock fails with an opaque "not a function" rather than
* the collection map's clear "unexpected collection" error.
*
* The merge is per METHOD, not per collection: an override that says
* `[ORDERS]: { getOne, update }` keeps the default `getFullList` the
* linked-elsewhere check needs, so a test that only cares about the
* order lookup doesn't have to know about every other query the
* service makes.
*/
function makePb(overrides: Record<string, CollectionMock> = {}) {
const defaults: Record<string, CollectionMock> = {
[ORDERS]: {
getOne: vi.fn().mockResolvedValue(ORDER),
getFullList: vi.fn().mockResolvedValue([]),
update: vi.fn().mockResolvedValue({}),
},
[QUOTES]: { getList: vi.fn().mockResolvedValue({ items: [] }) },
[ORDER_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) },
[QUOTE_ITEMS]: { getFullList: vi.fn().mockResolvedValue([]) },
};
const merged: Record<string, CollectionMock> = { ...defaults, ...overrides };
const merged: Record<string, CollectionMock> = {};
for (const name of new Set([...Object.keys(defaults), ...Object.keys(overrides)])) {
merged[name] = {
...(defaults[name] ?? {}),
...(overrides[name] ?? {}),
};
}
return {
autoCancellation: vi.fn(),
collection: (name: string) => {
@ -300,6 +314,169 @@ describe("suggestQuoteForOrder", () => {
});
});
describe("computeQuoteSuggestion", () => {
beforeEach(() => vi.clearAllMocks());
/**
* One order line that pairs cleanly with a quote line, so the fuzzy
* pass produces real candidates. `quoteItems` are given for every
* candidate quote so each one clears the threshold.
*
* q1 is created LATER than q2 and both score identically, so q1
* sorts first and would be the best match without the
* linked-elsewhere filter. A test that links q1 elsewhere must
* therefore see the best change — otherwise it proves nothing.
*/
function matchingOrder() {
return {
[ORDERS]: {
getOne: vi.fn().mockResolvedValue(ORDER),
update: vi.fn().mockResolvedValue({}),
},
[QUOTES]: {
getList: vi.fn().mockResolvedValue({
items: [
{ id: "q1", quote_number: "Q-1", company: "co1", customer: "cust1", created: "2026-07-02T00:00:00.000Z" },
{ id: "q2", quote_number: "Q-2", company: "co1", customer: "cust1", created: "2026-07-01T00:00:00.000Z" },
],
}),
},
[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().mockImplementation((opts: { filter: string }) =>
Promise.resolve(
["q1", "q2"]
.filter((id) => opts.filter.includes(id))
.map((id) => ({
id: `qi_${id}`,
quote: id,
description: "hex bolt m20 gr8.8 zinc",
selling_price: 10,
quantity: 5,
})),
),
),
},
};
}
it("never writes to the order — it only computes", async () => {
const update = vi.fn().mockResolvedValue({});
const pb = makePb({ ...matchingOrder(), [ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update } });
mockAdminPb(pb);
const res = await computeQuoteSuggestion("ord1");
expect(res.best?.quoteId).toBeTruthy();
expect(update).not.toHaveBeenCalled();
});
it("returns the same best that suggestQuoteForOrder writes", async () => {
const update = vi.fn().mockResolvedValue({});
const pb = makePb({ ...matchingOrder(), [ORDERS]: { getOne: vi.fn().mockResolvedValue(ORDER), update } });
mockAdminPb(pb);
const computed = await computeQuoteSuggestion("ord1");
expect(update).not.toHaveBeenCalled();
const written = await suggestQuoteForOrder("ord1");
expect(written.best?.quoteId).toBe(computed.best?.quoteId);
expect(update).toHaveBeenCalledWith("ord1", expect.objectContaining({
potential_quote: computed.best?.quoteId,
}));
});
it("drops a candidate quote that another order already links to", async () => {
const update = vi.fn().mockResolvedValue({});
// The linked-elsewhere check reads `orders` rows whose `quote` is
// one of the candidate ids. q1 is already the hard link of a
// different order, so it must not be offered — as best or as an
// alternative.
const ordersGetFullList = vi.fn().mockResolvedValue([
{ id: "other_order", quote: "q1" },
]);
const pb = makePb({
...matchingOrder(),
[ORDERS]: {
getOne: vi.fn().mockResolvedValue(ORDER),
getFullList: ordersGetFullList,
update,
},
});
mockAdminPb(pb);
const res = await computeQuoteSuggestion("ord1");
// q1 outranks q2 on its own — so the best changing is proof the
// filter ran, not a coincidence of the fixture.
expect(res.best?.quoteId).toBe("q2");
expect(res.alternatives.map((c) => c.quoteId)).not.toContain("q1");
});
it("keeps a candidate quote that this very order links to", async () => {
const update = vi.fn().mockResolvedValue({});
// The order's own `quote` is never a conflict — the linked check
// excludes the order doing the matching.
const ordersGetFullList = vi.fn().mockResolvedValue([
{ id: "ord1", quote: "q1" },
]);
const pb = makePb({
...matchingOrder(),
[ORDERS]: {
getOne: vi.fn().mockResolvedValue(ORDER),
getFullList: ordersGetFullList,
update,
},
});
mockAdminPb(pb);
const res = await computeQuoteSuggestion("ord1");
expect(res.best?.quoteId).toBe("q1");
expect(res.alternatives.map((c) => c.quoteId)).toContain("q2");
});
it("falls through to the fuzzy pass when an exact citation is linked to another order", async () => {
const update = vi.fn().mockResolvedValue({});
const quotesGetList = vi.fn().mockResolvedValue({
items: [{ id: "q_exact", quote_number: "Q-EXACT", company: "co1", created: "2026-07-01T00:00:00.000Z" }],
});
const ordersGetFullList = vi.fn().mockResolvedValue([
{ id: "other_order", quote: "q_exact" },
]);
const pb = makePb({
[ORDERS]: {
getOne: vi.fn().mockResolvedValue({ ...ORDER, quote_reference_text: "Q-EXACT" }),
getFullList: ordersGetFullList,
update,
},
[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: "q_exact", description: "hex bolt m20 gr8.8 zinc", selling_price: 10, quantity: 5 },
]),
},
});
mockAdminPb(pb);
const res = await computeQuoteSuggestion("ord1");
// The exact citation is spoken for, so it is not the best — and
// the fuzzy pass took over instead of stopping at the exact hit.
expect(res.best?.quoteId).not.toBe("q_exact");
expect(res.window).not.toBe("exact");
});
});
describe("pairOrderLines", () => {
beforeEach(() => vi.clearAllMocks());

View file

@ -263,16 +263,79 @@ async function fuzzyPass(
return scored;
}
/** How many quote ids go into one OR-chain when asking PocketBase
* "which of these quotes is already the hard link of some order?".
* A single chain over FUZZY_CANDIDATE_CAP (200) ids blows PB's
* filter-length limit (HTTP 400), so the ids are chunked — same
* reason `fetchQuoteItemsForQuotesAdmin` chunks its quote_items
* fetch. */
const LINKED_ORDER_CHUNK = 50;
/** What the linked-elsewhere check needs off each order row: the id
* (to tell "another order" from this one) and its hard-linked quote. */
const LINKED_ORDER_FIELDS = "id,quote";
/**
* Find the best candidate quote for an order and write it as a soft
* suggestion. Idempotent and safe to re-run.
* Which of the given quote ids are already the hard link of a
* DIFFERENT order.
*
* A quote belongs to one order. Suggesting it for a second order
* invites a human to link it twice, and `setOrderQuote` refuses that
* with a 409 — so the matcher never offers it.
*
* The order doing the matching is excluded from the result: its own
* `orders.quote` (if any) is not a conflict. Callers only reach this
* for orders that have no hard link at all, but the check is explicit
* so the function is safe on its own.
*/
export async function suggestQuoteForOrder(
async function quotesLinkedToOtherOrders(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
quoteIds: string[],
orderId: string,
): Promise<Set<string>> {
const linked = new Set<string>();
const ids = [...new Set(quoteIds.filter(Boolean))];
if (ids.length === 0) return linked;
for (let i = 0; i < ids.length; i += LINKED_ORDER_CHUNK) {
const chunk = ids.slice(i, i + LINKED_ORDER_CHUNK);
const rows = await pb
.collection(ORDER_COLLECTION)
.getFullList<{ id: string; quote?: string }>({
filter: chunk.map((id) => `quote = "${esc(id)}"`).join(" || "),
fields: LINKED_ORDER_FIELDS,
batch: 500,
});
for (const row of rows) {
if (!row.quote) continue;
if (row.id === orderId) continue;
linked.add(row.quote);
}
}
return linked;
}
/**
* Find the best candidate quote for an order. READ-ONLY — nothing is
* written to the order.
*
* `suggestQuoteForOrder` is this plus the write, so a caller that
* only wants to know what the matcher would decide (a dry-run report,
* the AI shortlist) never touches the database.
*/
export async function computeQuoteSuggestion(
orderId: string,
): Promise<SuggestQuoteResult> {
const pb = await getAdminPocketBase();
pb.autoCancellation(false);
return computeSuggestionWithPb(pb, orderId);
}
/** The compute, on an admin client the caller already holds. */
async function computeSuggestionWithPb(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
orderId: string,
): Promise<SuggestQuoteResult> {
const order = await pb.collection(ORDER_COLLECTION).getOne<OrderRow>(orderId);
// An order with a confirmed hard link is settled. Never second-guess it.
@ -292,22 +355,27 @@ export async function suggestQuoteForOrder(
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),
};
// A citation is strong evidence, but it can't beat an existing
// hard link on another order — that quote is already spoken for.
// Fall through to the fuzzy pass instead of suggesting it.
const linked = await quotesLinkedToOtherOrders(pb, [exact.quote.id], orderId);
if (!linked.has(exact.quote.id)) {
const best: QuoteMatchCandidate = {
quoteId: exact.quote.id,
quoteNumber: exact.quote.quote_number || "",
quoteCreated: exact.quote.created,
confidence: 1,
method: exact.method,
};
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.
@ -326,8 +394,21 @@ export async function suggestQuoteForOrder(
window = "widened";
}
// Drop candidates another order already hard-links. This runs over
// the whole scored list (not just the best), so a linked-elsewhere
// quote can't linger in `alternatives` either.
if (scored.length > 0) {
const linked = await quotesLinkedToOtherOrders(
pb,
scored.map((c) => c.quoteId),
orderId,
);
if (linked.size > 0) {
scored = scored.filter((c) => !linked.has(c.quoteId));
}
}
const best = scored[0] ?? null;
await writeSuggestion(pb, orderId, best);
return {
orderId,
@ -339,6 +420,26 @@ export async function suggestQuoteForOrder(
};
}
/**
* 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 result = await computeSuggestionWithPb(pb, orderId);
// A settled order is left exactly as it is — no "no_match" stamp
// over a link a human confirmed.
if (result.skipped === "already_linked") return result;
await writeSuggestion(pb, orderId, result.best);
return result;
}
/** Persist (or clear) the soft suggestion. Never touches `quote`. */
async function writeSuggestion(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,