From 5bacd048c6ac40b4e2a65dd6871708a1663ad21d Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 24 Oct 2026 12:31:48 +0200 Subject: [PATCH] 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 --- src/lib/server/order-quote-match.test.ts | 30 ++++++++++++++++++++++++ src/lib/server/order-quote-match.ts | 15 ++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/lib/server/order-quote-match.test.ts b/src/lib/server/order-quote-match.test.ts index 66de9fc..d683fb3 100644 --- a/src/lib/server/order-quote-match.test.ts +++ b/src/lib/server/order-quote-match.test.ts @@ -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", () => { it("tiers a near-identical line as confident", () => { const pairs = pairQuoteLines( diff --git a/src/lib/server/order-quote-match.ts b/src/lib/server/order-quote-match.ts index 09f533c..94996e3 100644 --- a/src/lib/server/order-quote-match.ts +++ b/src/lib/server/order-quote-match.ts @@ -87,6 +87,11 @@ export function priceScore(a: number, b: number): number { * 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". * + * 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: * 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 @@ -352,6 +357,16 @@ export function scoreOrderVsQuote( confidence = Math.max(0, raw) / observedMax; 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 // re-renders. confidence = Math.round(confidence * 100) / 100;