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:
parent
e0058e02d9
commit
5bacd048c6
2 changed files with 45 additions and 0 deletions
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue