feat(orders): add order-anchored fuzzy search window

Adds quoteWindowForOrder (pure) to constrain the fuzzy-match candidate
scan to a date window anchored on the order's own created date, plus
the forward grace period for quotes raised a few days after the PO.
Also adds quoteToOrderAgeDays, used by a later task's age-gap
histogram, with test coverage the plan omitted (normal, inverted, and
unparseable-input cases).
This commit is contained in:
Dave 2026-08-29 11:28:10 +02:00
parent 99050dcac5
commit a4bc52b2ca
2 changed files with 117 additions and 0 deletions

View file

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

View file

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