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>
182 lines
6.5 KiB
TypeScript
182 lines
6.5 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|