diff --git a/src/lib/server/order-quote-match.test.ts b/src/lib/server/order-quote-match.test.ts index b368144..c77de0f 100644 --- a/src/lib/server/order-quote-match.test.ts +++ b/src/lib/server/order-quote-match.test.ts @@ -10,6 +10,10 @@ import { pairQuoteLines, LINE_PAIR_CONFIDENT, LINE_PAIR_POSSIBLE, + quoteWindowForOrder, + quoteToOrderAgeDays, + FUZZY_WINDOW_DAYS, + FUZZY_WINDOW_WIDEN_DAYS, } from "@/lib/server/order-quote-match"; /** @@ -484,3 +488,55 @@ describe("pairQuoteLines", () => { expect(new Set(quoteIdxs).size).toBe(quoteIdxs.length); }); }); + +describe("quoteWindowForOrder", () => { + const ORDER_DATE = "2026-08-29T10:00:00.000Z"; + + it("looks back the requested number of days from the ORDER date, not from now", () => { + const w = quoteWindowForOrder(ORDER_DATE, 90); + expect(w.from).toBe("2026-05-31T10:00:00.000Z"); + }); + + it("extends forward by the grace period so a quote raised just after the PO still matches", () => { + const w = quoteWindowForOrder(ORDER_DATE, 90); + expect(w.to).toBe("2026-09-05T10:00:00.000Z"); + }); + + it("widens correctly for the fallback pass", () => { + const w = quoteWindowForOrder(ORDER_DATE, FUZZY_WINDOW_WIDEN_DAYS); + expect(new Date(w.from).getTime()).toBeLessThan( + new Date(quoteWindowForOrder(ORDER_DATE, FUZZY_WINDOW_DAYS).from).getTime(), + ); + }); + + it("throws on an unparseable order date rather than silently matching everything", () => { + expect(() => quoteWindowForOrder("not-a-date", 90)).toThrow(/unparseable/i); + }); +}); + +describe("quoteToOrderAgeDays", () => { + it("returns a positive day count when the quote precedes the order (normal case)", () => { + // Quote created 2026-06-01, order created 2026-08-29 → quote first, + // order 89 days later. + const days = quoteToOrderAgeDays( + "2026-06-01T10:00:00.000Z", + "2026-08-29T10:00:00.000Z", + ); + expect(days).toBe(89); + }); + + it("returns a negative day count when the quote is created after the order (inverted case)", () => { + // Quote created 5 days after the order. + const days = quoteToOrderAgeDays( + "2026-08-29T10:00:00.000Z", + "2026-08-24T10:00:00.000Z", + ); + expect(days).toBe(-5); + }); + + it("returns 0 (not NaN) when either input is unparseable", () => { + expect(quoteToOrderAgeDays("not-a-date", "2026-08-29T10:00:00.000Z")).toBe(0); + expect(quoteToOrderAgeDays("2026-08-29T10:00:00.000Z", "not-a-date")).toBe(0); + expect(Number.isNaN(quoteToOrderAgeDays("garbage", "also-garbage"))).toBe(false); + }); +}); diff --git a/src/lib/server/order-quote-match.ts b/src/lib/server/order-quote-match.ts index 22c9af6..7af0813 100644 --- a/src/lib/server/order-quote-match.ts +++ b/src/lib/server/order-quote-match.ts @@ -431,3 +431,64 @@ export function pairQuoteLines( tier: p.jaccard >= LINE_PAIR_CONFIDENT ? "confident" : "possible", })); } + +/* ============================================================================ + * Fuzzy search window + * + * The 200-candidate cap alone behaves inconsistently: for a high-volume + * company it reaches back weeks, for a quiet one it reaches back years. + * Anchoring on a date window normalises that and matches how the + * business works — a quote normally converts within about two months. + * + * The window is measured from the ORDER's own created date, never from + * `Date.now()`. A "last 90 days from today" filter would find zero + * candidates for every order in the backlog sweep. + * + * This applies ONLY to the fuzzy description+price path. The four + * exact-identifier lookups are never date limited — a PO citing + * Q-2024-0847 should resolve however old that quote is. + * ========================================================================== */ + +export const FUZZY_WINDOW_DAYS = 90; +export const FUZZY_WINDOW_WIDEN_DAYS = 365; +/** Forward grace for a quote raised just after the PO landed. */ +export const FUZZY_WINDOW_GRACE_DAYS = 7; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export interface QuoteWindow { + /** Inclusive lower bound, ISO 8601. */ + from: string; + /** Inclusive upper bound, ISO 8601. */ + to: string; +} + +/** Build the candidate-quote date window for one order. */ +export function quoteWindowForOrder( + orderCreated: string, + days: number, +): QuoteWindow { + const anchor = new Date(orderCreated); + if (Number.isNaN(anchor.getTime())) { + throw new Error( + `quoteWindowForOrder: unparseable order date ${JSON.stringify(orderCreated)}`, + ); + } + return { + from: new Date(anchor.getTime() - days * DAY_MS).toISOString(), + to: new Date(anchor.getTime() + FUZZY_WINDOW_GRACE_DAYS * DAY_MS).toISOString(), + }; +} + +/** Whole days between a quote's creation and an order's creation. + * Positive means the quote came first (the normal case). Used by the + * sweep's age-gap histogram. */ +export function quoteToOrderAgeDays( + quoteCreated: string, + orderCreated: string, +): number { + const q = new Date(quoteCreated).getTime(); + const o = new Date(orderCreated).getTime(); + if (Number.isNaN(q) || Number.isNaN(o)) return 0; + return Math.round((o - q) / DAY_MS); +}