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:
parent
09b9c0f280
commit
c0a720387a
2 changed files with 49 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue