diff --git a/src/lib/server/order-quote-match.test.ts b/src/lib/server/order-quote-match.test.ts index 84b6862..97dde7f 100644 --- a/src/lib/server/order-quote-match.test.ts +++ b/src/lib/server/order-quote-match.test.ts @@ -7,6 +7,9 @@ import { scoreOrderVsQuote, RULE_POINTS, MATCH_CONFIDENCE_THRESHOLD, + pairQuoteLines, + LINE_PAIR_CONFIDENT, + LINE_PAIR_POSSIBLE, } from "@/lib/server/order-quote-match"; /** @@ -424,3 +427,48 @@ describe("scoreOrderVsQuote — normalization & thresholding", () => { expect(r.lines[0].points).toBeGreaterThan(0); }); }); + +describe("pairQuoteLines", () => { + it("tiers a near-identical line as confident", () => { + const pairs = pairQuoteLines( + [{ description: "M20 HEX BOLT GR8.8 ZINC" }], + [{ description: "Hex Bolt M20 Gr8.8 Zinc" }], + ); + expect(pairs).toHaveLength(1); + expect(pairs[0].orderLineIdx).toBe(0); + expect(pairs[0].quoteLineIdx).toBe(0); + expect(pairs[0].tier).toBe("confident"); + }); + + it("tiers a partial-overlap line as possible", () => { + const pairs = pairQuoteLines( + [{ description: "cable gland brass M20" }], + [{ description: "cable gland stainless M25" }], + ); + expect(pairs).toHaveLength(1); + expect(pairs[0].tier).toBe("possible"); + expect(pairs[0].jaccard).toBeGreaterThanOrEqual(LINE_PAIR_POSSIBLE); + expect(pairs[0].jaccard).toBeLessThan(LINE_PAIR_CONFIDENT); + }); + + it("returns no pair when descriptions do not overlap", () => { + const pairs = pairQuoteLines( + [{ description: "galvanised threaded rod 3 metre" }], + [{ description: "silicone sealant clear cartridge" }], + ); + expect(pairs).toEqual([]); + }); + + it("never pairs one quote line to two order lines", () => { + const pairs = pairQuoteLines( + [ + { description: "hex bolt m20 gr8.8 zinc" }, + { description: "hex bolt m20 gr8.8 zinc" }, + ], + [{ description: "hex bolt m20 gr8.8 zinc" }], + ); + expect(pairs).toHaveLength(1); + const quoteIdxs = pairs.map((p) => p.quoteLineIdx); + expect(new Set(quoteIdxs).size).toBe(quoteIdxs.length); + }); +}); diff --git a/src/lib/server/order-quote-match.ts b/src/lib/server/order-quote-match.ts index ab8751b..b697acb 100644 --- a/src/lib/server/order-quote-match.ts +++ b/src/lib/server/order-quote-match.ts @@ -166,7 +166,7 @@ export interface ScoreBreakdown { * from the pool, repeat. Lines that don't have any meaningful match * fall through with `descMatched=false` so the matcher can still * see the line-by-line picture. */ -function pairLinesByDescription( +export function pairLinesByDescription( orderItems: ReadonlyArray<{ description: string }>, quoteItems: ReadonlyArray<{ description: string }> ): Array<{ oi: number; qi: number; jaccard: number }> { @@ -384,3 +384,46 @@ export function scoreCandidate( { items: quoteItems } ).confidence; } + +/* ============================================================================ + * Line-pairing tiers + * + * `pairLinesByDescription` already greedily pairs order lines to quote + * lines and drops anything below 0.3 Jaccard. These constants split + * what survives into two tiers: + * + * >= 0.5 confident — safe to persist onto `order_items.quote_item` + * 0.3–0.5 possible — shown to the operator with a confirm button, + * never written without their say-so + * + * Both numbers already existed in this file (0.5 is the + * `descriptionFull` award boundary, 0.3 is the candidate floor) — they + * are named here rather than reintroduced. + * ========================================================================== */ + +export const LINE_PAIR_CONFIDENT = 0.5; +export const LINE_PAIR_POSSIBLE = 0.3; + +export type LinePairTier = "confident" | "possible"; + +export interface QuoteLinePair { + orderLineIdx: number; + quoteLineIdx: number; + jaccard: number; + tier: LinePairTier; +} + +/** Pair order lines to quote lines by description overlap, tiered by + * confidence. Delegates to `pairLinesByDescription` so there is + * exactly one pairing algorithm in the codebase. */ +export function pairQuoteLines( + orderItems: ReadonlyArray<{ description: string }>, + quoteItems: ReadonlyArray<{ description: string }>, +): QuoteLinePair[] { + return pairLinesByDescription(orderItems, quoteItems).map((p) => ({ + orderLineIdx: p.oi, + quoteLineIdx: p.qi, + jaccard: p.jaccard, + tier: p.jaccard >= LINE_PAIR_CONFIDENT ? "confident" : "possible", + })); +}