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:
parent
5bacd048c6
commit
09b9c0f280
3 changed files with 481 additions and 21 deletions
182
scripts/2026-10-24-rematch-quote-suggestions.ts
Normal file
182
scripts/2026-10-24-rematch-quote-suggestions.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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>>,
|
||||
|
|
|
|||
Loading…
Reference in a new issue