fix(orders): never suggest a quote that shares no line with the order

scoreOrderVsQuote awarded up to 0.5 confidence from the header alone
(same company +3, same customer +2), which clears the 0.4 threshold
with zero paired lines. One quote was suggested to 16 unrelated
orders that way.

A candidate now needs at least one paired order/quote line; with no
pairs the confidence is 0. The header points and raw score stay in
the breakdown so the UI can still show why a quote looked close.

No existing test relied on the header-only confidence, so no fixture
needed changing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-24 12:31:48 +02:00
parent e0058e02d9
commit 5bacd048c6
2 changed files with 45 additions and 0 deletions

View file

@ -432,6 +432,36 @@ describe("scoreOrderVsQuote — normalization & thresholding", () => {
}); });
}); });
describe("scoreOrderVsQuote — needs a paired line", () => {
// A quote that shares nothing with the order is not a candidate,
// however well the header matches. Same company (+3) plus same
// customer (+2) used to score 0.5 with zero paired lines, which is
// how one quote ended up suggested to 16 different orders.
it("scores 0 when company and customer match but no line pairs (270555-MOB vs Q002997)", () => {
const r = scoreOrderVsQuote(
{ companyId: "co", customerId: "cu", items: [{ description: 'Funnel 150mm PVC round 2 piece Flexible ("6") Round', selling_price: 16.64, quantity: 1 }] },
{ company: "co", customer: "cu", items: [{ description: "22mm endurance +", selling_price: 1655.76, quantity: 1 }] },
);
expect(r.company).toBe(RULE_POINTS.company);
expect(r.customer).toBe(RULE_POINTS.customer);
expect(r.confidence).toBe(0);
});
it("scores 0 against a quote with no lines", () => {
const r = scoreOrderVsQuote(
{ companyId: "co", customerId: "cu", items: [{ description: "M16 bolt", selling_price: 3, quantity: 10 }] },
{ company: "co", customer: "cu", items: [] },
);
expect(r.confidence).toBe(0);
});
it("still scores above threshold when one line pairs", () => {
const r = scoreOrderVsQuote(
{ companyId: "co", customerId: "cu", items: [{ description: "Flat washer M16 zinc", selling_price: 1, quantity: 100 }] },
{ company: "co", customer: "cu", items: [{ description: "Flat washer M16 zinc plated", selling_price: 1, quantity: 100 }] },
);
expect(r.confidence).toBeGreaterThanOrEqual(MATCH_CONFIDENCE_THRESHOLD);
});
});
describe("pairQuoteLines", () => { describe("pairQuoteLines", () => {
it("tiers a near-identical line as confident", () => { it("tiers a near-identical line as confident", () => {
const pairs = pairQuoteLines( const pairs = pairQuoteLines(

View file

@ -87,6 +87,11 @@ export function priceScore(a: number, b: number): number {
* is the trust backbone: if the operator disagrees with the breakdown, * is the trust backbone: if the operator disagrees with the breakdown,
* they can tell us which signal is wrong, not just "the match is wrong". * they can tell us which signal is wrong, not just "the match is wrong".
* *
* No paired line → confidence 0; header signals alone never suggest a
* quote. Same company + same customer is not evidence that the order
* came from that quote — it was the reason one quote got suggested to
* 16 unrelated orders.
*
* Normalization maps the raw score to [0, 1] via a piecewise clamp: * Normalization maps the raw score to [0, 1] via a piecewise clamp:
* raw sum is interpreted against a max-of-13 budget (3+2+2+2+1+1+1+1) * raw sum is interpreted against a max-of-13 budget (3+2+2+2+1+1+1+1)
* and floored at 0 when negative. The threshold is intentionally lenient * and floored at 0 when negative. The threshold is intentionally lenient
@ -352,6 +357,16 @@ export function scoreOrderVsQuote(
confidence = Math.max(0, raw) / observedMax; confidence = Math.max(0, raw) / observedMax;
confidence = Math.min(1, confidence); confidence = Math.min(1, confidence);
} }
// No paired line → no match, whatever the header says. A quote that
// shares nothing with the order (or has no lines at all) is not a
// candidate: same company + same customer used to reach 0.5 on the
// header alone, which is above the 0.4 threshold, and one quote was
// suggested to 16 unrelated orders that way.
//
// The header points and `raw` stay in the breakdown — the operator
// can still see WHY the quote was close on paper — but the
// confidence that gates the suggestion is 0.
if (pairs.length === 0) confidence = 0;
// 2-decimal precision so the UI's `xx%` badge is stable across // 2-decimal precision so the UI's `xx%` badge is stable across
// re-renders. // re-renders.
confidence = Math.round(confidence * 100) / 100; confidence = Math.round(confidence * 100) / 100;