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:
parent
99050dcac5
commit
a4bc52b2ca
2 changed files with 117 additions and 0 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue