feat(orders): expose tiered quote line pairing

This commit is contained in:
Dave 2026-08-29 11:13:59 +02:00
parent 88fa8e978e
commit f2a1cbf586
2 changed files with 92 additions and 1 deletions

View file

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

View file

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