From 09b9c0f280bebff189f7d666e923f1cf3a3aeba0 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 24 Oct 2026 12:45:56 +0200 Subject: [PATCH] feat(orders): read-only quote suggestion and stale-suggestion clean-up script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../2026-10-24-rematch-quote-suggestions.ts | 182 ++++++++++++++++++ .../server/order-quote-link-service.test.ts | 179 ++++++++++++++++- src/lib/server/order-quote-link-service.ts | 141 ++++++++++++-- 3 files changed, 481 insertions(+), 21 deletions(-) create mode 100644 scripts/2026-10-24-rematch-quote-suggestions.ts diff --git a/scripts/2026-10-24-rematch-quote-suggestions.ts b/scripts/2026-10-24-rematch-quote-suggestions.ts new file mode 100644 index 0000000..f6ef300 --- /dev/null +++ b/scripts/2026-10-24-rematch-quote-suggestions.ts @@ -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>, + quoteIds: string[], +): Promise> { + const out = new Map(); + 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({ 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(); + 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(); + 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); +}); diff --git a/src/lib/server/order-quote-link-service.test.ts b/src/lib/server/order-quote-link-service.test.ts index c909041..b7435b4 100644 --- a/src/lib/server/order-quote-link-service.test.ts +++ b/src/lib/server/order-quote-link-service.test.ts @@ -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 = {}) { const defaults: Record = { [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 = { ...defaults, ...overrides }; + const merged: Record = {}; + 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()); diff --git a/src/lib/server/order-quote-link-service.ts b/src/lib/server/order-quote-link-service.ts index 1e37213..85772c9 100644 --- a/src/lib/server/order-quote-link-service.ts +++ b/src/lib/server/order-quote-link-service.ts @@ -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>, + quoteIds: string[], + orderId: string, +): Promise> { + const linked = new Set(); + 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 { 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>, + orderId: string, +): Promise { const order = await pb.collection(ORDER_COLLECTION).getOne(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 { + 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>,