fix(orders): drop linked-elsewhere quotes before deciding to widen the window

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-24 12:49:19 +02:00
parent 09b9c0f280
commit c0a720387a
2 changed files with 49 additions and 15 deletions

View file

@ -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

View file

@ -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;