WFDASH-WEBAPP/scripts/2026-10-24-rematch-quote-suggestions.ts
Dave 09b9c0f280 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>
2026-10-24 12:45:56 +02:00

182 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
});