From c0a720387aaa8797d54adac9a5404e3b70f3119f Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 24 Oct 2026 12:49:19 +0200 Subject: [PATCH] fix(orders): drop linked-elsewhere quotes before deciding to widen the window Co-Authored-By: Claude Opus 5.5 --- .../server/order-quote-link-service.test.ts | 33 +++++++++++++++++++ src/lib/server/order-quote-link-service.ts | 31 ++++++++--------- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/lib/server/order-quote-link-service.test.ts b/src/lib/server/order-quote-link-service.test.ts index b7435b4..a148dd8 100644 --- a/src/lib/server/order-quote-link-service.test.ts +++ b/src/lib/server/order-quote-link-service.test.ts @@ -417,6 +417,39 @@ describe("computeQuoteSuggestion", () => { expect(res.alternatives.map((c) => c.quoteId)).not.toContain("q1"); }); + it("widens to 12 months when every 90-day candidate is linked to another order", async () => { + // The 90-day window only holds q1, which another order already + // links. The filter must run before the widen decision, or the + // 12-month window (holding q2) is never searched. + const base = matchingOrder(); + const quotesGetList = vi + .fn() + .mockResolvedValueOnce({ + items: [{ id: "q1", quote_number: "Q-1", company: "co1", customer: "cust1", created: "2026-07-02T00:00:00.000Z" }], + }) + .mockResolvedValueOnce({ + items: [{ id: "q2", quote_number: "Q-2", company: "co1", customer: "cust1", created: "2025-11-01T00:00:00.000Z" }], + }); + const pb = makePb({ + ...base, + [QUOTES]: { getList: quotesGetList }, + [ORDERS]: { + getOne: vi.fn().mockResolvedValue(ORDER), + getFullList: vi.fn().mockImplementation((opts: { filter: string }) => + Promise.resolve(opts.filter.includes("q1") ? [{ id: "other_order", quote: "q1" }] : []), + ), + update: vi.fn().mockResolvedValue({}), + }, + }); + mockAdminPb(pb); + + const res = await computeQuoteSuggestion("ord1"); + + expect(quotesGetList).toHaveBeenCalledTimes(2); + expect(res.best?.quoteId).toBe("q2"); + expect(res.window).toBe("widened"); + }); + 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 diff --git a/src/lib/server/order-quote-link-service.ts b/src/lib/server/order-quote-link-service.ts index 85772c9..5817ebf 100644 --- a/src/lib/server/order-quote-link-service.ts +++ b/src/lib/server/order-quote-link-service.ts @@ -387,25 +387,26 @@ async function computeSuggestionWithPb( batch: 1000, }); - let scored = await fuzzyPass(pb, order, orderItems, FUZZY_WINDOW_DAYS); - let window: SuggestQuoteResult["window"] = "initial"; - if (scored.length === 0) { - scored = await fuzzyPass(pb, order, orderItems, FUZZY_WINDOW_WIDEN_DAYS); - 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) { + // Candidates another order already hard-links are dropped inside + // each window, BEFORE deciding to widen: a 90-day window holding only + // spoken-for quotes must still widen to 365 days. This covers the + // whole scored list, so a linked-elsewhere quote can't linger in + // `alternatives` either. + const unlinked = async (cands: QuoteMatchCandidate[]) => { + if (cands.length === 0) return cands; const linked = await quotesLinkedToOtherOrders( pb, - scored.map((c) => c.quoteId), + cands.map((c) => c.quoteId), orderId, ); - if (linked.size > 0) { - scored = scored.filter((c) => !linked.has(c.quoteId)); - } + return linked.size > 0 ? cands.filter((c) => !linked.has(c.quoteId)) : cands; + }; + + let scored = await unlinked(await fuzzyPass(pb, order, orderItems, FUZZY_WINDOW_DAYS)); + let window: SuggestQuoteResult["window"] = "initial"; + if (scored.length === 0) { + scored = await unlinked(await fuzzyPass(pb, order, orderItems, FUZZY_WINDOW_WIDEN_DAYS)); + window = "widened"; } const best = scored[0] ?? null;