WFDASH-WEBAPP/src/lib/server/order-quote-match.test.ts
SJCoetz 7f924ccc45 refactor: switch hardcoded UI colors to semantic theme tokens
Overhaul the app's color system by replacing all hardcoded Tailwind color classes with semantic theme variables to ensure consistent theming across all components, pages, and tests.

Additional changes:
- Add assigned date support for order item status updates: add `assignedDate` field to the order item status PATCH API, implement server-side normalization/validation, and add corresponding tests
- Refactor purchase order PDF workflows to use the consolidated `purchase_orders.documents` field instead of the legacy separate `purchase_order_pdfs` collection
- Fix quote number parsing and formatting to support both dashed (Q-YYYY-MM-NNN) and dashless (QYYYYMMNNN) formats
- Rename `driver_pool` item status to `collection_pool` across the codebase
- Add null handling for order item quantities and pricing to display "—" for missing values
- Improve filter tab styling: make mobile tabs scrollable, prevent text wrapping, hide scrollbar
- Add new utility scripts, API routes, migrations, and polish small UI/UX fixes including tsconfig updates and theme hook improvements
2026-07-07 17:22:03 +02:00

426 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from "vitest";
import {
tokenize,
jaccard,
priceScore,
scoreCandidate,
scoreOrderVsQuote,
RULE_POINTS,
MATCH_CONFIDENCE_THRESHOLD,
} from "@/lib/server/order-quote-match";
/**
* Unit tests for the pure scoring helpers inside the order ↔ quote
* matcher. PB-dependent helpers (findExactMatch, findFuzzyMatches) are
* exercised via the live-PB smoke test — here we just lock the math.
*
* The helpers live in src/lib/server/order-quote-match.ts so they're
* testable without touching PocketBase. pb-helpers re-exports them
* for the matcher API to use.
*/
describe("matcher scoring math", () => {
// ─── Tokenizer ──────────────────────────────────────────────────
it("tokenize drops short / stopword tokens", () => {
const tokens = tokenize("M20 x 40 SET CW NUTS for testing");
// "x" dropped (1 char), "for" dropped (stopword), "cw" dropped (2 char)
expect(tokens.has("m20")).toBe(true);
expect(tokens.has("set")).toBe(true);
expect(tokens.has("nuts")).toBe(true);
expect(tokens.has("testing")).toBe(true);
expect(tokens.has("x")).toBe(false);
expect(tokens.has("for")).toBe(false);
});
it("tokenize is case-insensitive and punctuation-tolerant", () => {
const a = tokenize("Cable Gland M20");
const b = tokenize("cable-gland/m20!");
expect(a).toEqual(b);
});
// ─── Jaccard ────────────────────────────────────────────────────
it("jaccard returns 1.0 for identical sets, 0.0 for disjoint", () => {
const a = new Set(["m20", "nuts", "bolt"]);
expect(jaccard(a, a)).toBe(1);
expect(jaccard(a, new Set(["unrelated"]))).toBe(0);
});
it("jaccard returns the right overlap ratio", () => {
// |a ∩ b| = 1 ("m20"), |a ∪ b| = 5 (m20, nuts, bolt, washer, spring)
// → 1/5 = 0.2
const a = new Set(["m20", "nuts", "bolt"]);
const b = new Set(["m20", "washer", "spring"]);
expect(jaccard(a, b)).toBeCloseTo(1 / 5, 5);
});
it("jaccard returns 0 for two empty sets (no signal, not 1)", () => {
expect(jaccard(new Set(), new Set())).toBe(0);
});
// ─── Price score ───────────────────────────────────────────────
it("priceScore is 1.0 when sums are equal or within 5%", () => {
expect(priceScore(100, 100)).toBe(1);
expect(priceScore(100, 104)).toBe(1); // 4% diff
});
it("priceScore is 0.0 when sums differ by 50%+", () => {
expect(priceScore(100, 50)).toBe(0);
expect(priceScore(100, 200)).toBe(0);
});
it("priceScore falls off linearly between 5% and 50%", () => {
// At 27.5% diff (midpoint) the score should be ~0.5.
// Linear: 1 - (0.275 - 0.05) / 0.45 = 1 - 0.5 = 0.5
expect(priceScore(100, 72.5)).toBeCloseTo(0.5, 1);
});
it("priceScore returns 0 when one side is 0 (no signal)", () => {
expect(priceScore(0, 100)).toBe(0);
expect(priceScore(100, 0)).toBe(0);
});
// ─── Composite score ──────────────────────────────────────────
it("scoreCandidate prefers descriptions over price-only match", () => {
const orderItems = [
{ description: "M20 x 40 nuts", selling_price: 100, quantity: 1 },
];
// Perfect desc match, perfect price → score ~1
const perfect = scoreCandidate(orderItems, [
{ description: "M20 x 40 nuts", selling_price: 100, quantity: 1 },
]);
// Good price but totally different description → score low
const priceOnly = scoreCandidate(orderItems, [
{ description: "completely different thing", selling_price: 100, quantity: 1 },
]);
expect(perfect).toBeGreaterThan(priceOnly);
});
it("scoreCandidate returns 0 for empty order items", () => {
expect(scoreCandidate([], [{ description: "anything", selling_price: 100, quantity: 1 }])).toBe(0);
});
// ─── Threshold sanity ─────────────────────────────────────────
// The rule-based scorer exposes its breakdown to the UI, so an
// operator can audit each signal even on a low-confidence match —
// that auditability means a more lenient threshold (0.4) is honest
// here than it was on the old description+price composite. Pin
// the value so any further change forces a conscious edit and a
// look at the operator experience.
it("confidence threshold is permissive but not zero (0.4) — breakdown-backed", () => {
expect(MATCH_CONFIDENCE_THRESHOLD).toBe(0.4);
});
});
/* ============================================================================
* scoreOrderVsQuote — rule-based scoring tests
*
* Each test exercises one branch of the rule budget so a future refactor
* can't silently drop a signal. Tests use full point values (RULE_POINTS)
* to keep expectations in lock-step with the implementation.
* ========================================================================== */
describe("scoreOrderVsQuote — header signals", () => {
it("awards full company points when relations match exactly", () => {
const r = scoreOrderVsQuote(
{
companyId: "co-1",
customerId: null,
items: [{ description: "M20 nuts", selling_price: 5, quantity: 10 }],
},
{
company: "co-1",
customer: null,
items: [{ description: "M20 nuts", selling_price: 5, quantity: 10 }],
}
);
expect(r.company).toBe(RULE_POINTS.company);
});
it("does not award customer points when order has no customer", () => {
const r = scoreOrderVsQuote(
{
companyId: "co-1",
customerId: null,
items: [{ description: "thing", selling_price: 5, quantity: 1 }],
},
{
company: "co-1",
customer: null,
items: [{ description: "thing", selling_price: 5, quantity: 1 }],
}
);
expect(r.customer).toBeNull();
});
it("awards 0 customer when order has a customer but quote doesn't", () => {
const r = scoreOrderVsQuote(
{
companyId: "co-1",
customerId: "cu-9",
items: [{ description: "x", selling_price: 5, quantity: 1 }],
},
{
company: "co-1",
customer: null,
items: [{ description: "x", selling_price: 5, quantity: 1 }],
}
);
expect(r.customer).toBe(0);
});
it("awards full customer points when both sides match", () => {
const r = scoreOrderVsQuote(
{
companyId: "co-1",
customerId: "cu-9",
items: [{ description: "thing", selling_price: 5, quantity: 1 }],
},
{
company: "co-1",
customer: "cu-9",
items: [{ description: "thing", selling_price: 5, quantity: 1 }],
}
);
expect(r.customer).toBe(RULE_POINTS.customer);
});
});
describe("scoreOrderVsQuote — per-line signals", () => {
it("awards +2 description when per-line Jaccard >= 0.5", () => {
const r = scoreOrderVsQuote(
{
items: [
{ description: "M20 x 40 SET nuts washers bolts", selling_price: 10, quantity: 5 },
],
},
{
items: [
// identical tokens
{ description: "M20 x 40 SET nuts washers bolts", selling_price: 10, quantity: 5 },
],
}
);
expect(r.description).toBe(RULE_POINTS.descriptionFull);
expect(r.lines[0].descMatched).toBe(true);
});
it("awards partial description (1pt) for moderate overlap (0.3..0.5)", () => {
// Need tokens that overlap strongly enough to clear the 0.3 floor
// but stay below 0.5 to exercise the partial-credits branch.
// order tokens {m20, nuts, abc} (3) vs quote tokens {m20, nuts, xyz, qqq}
// → |∩|=2 (m20, nuts), |∪|=5 → 2/5 = 0.4 → partial credit (+1).
const r = scoreOrderVsQuote(
{
items: [
{ description: "M20 nuts abc", selling_price: 10, quantity: 1 },
],
},
{
items: [
{ description: "M20 nuts xyz qqq", selling_price: 10, quantity: 1 },
],
}
);
expect(r.description).toBe(RULE_POINTS.descriptionPartial);
expect(r.lines[0].descMatched).toBe(true);
});
it("awards -1 quantity when order line and matched quote line disagree on qty", () => {
const r = scoreOrderVsQuote(
{
items: [{ description: "M20 nuts washers", selling_price: 10, quantity: 5 }],
},
{
items: [{ description: "M20 nuts washers", selling_price: 10, quantity: 10 }],
}
);
// Same description (full +2), same price (+2), qty mismatched (-1)
expect(r.description).toBe(RULE_POINTS.descriptionFull);
expect(r.price).toBe(RULE_POINTS.price);
expect(r.quantity).toBe(RULE_POINTS.quantityMismatch);
expect(r.lines[0].qtyMatched).toBe(false);
});
it("awards 0 price when prices differ by more than 5%", () => {
const r = scoreOrderVsQuote(
{
items: [
{ description: "M20 nuts washers", selling_price: 10, quantity: 5 },
],
},
{
items: [
{ description: "M20 nuts washers", selling_price: 50, quantity: 5 },
],
}
);
expect(r.price).toBe(0);
expect(r.lines[0].priceMatched).toBe(false);
});
});
describe("scoreOrderVsQuote — item-count penalty", () => {
it("deducts -2 when item count differs by 50% or more", () => {
const r = scoreOrderVsQuote(
{
items: [
{ description: "alpha", selling_price: 1, quantity: 1 },
{ description: "beta", selling_price: 1, quantity: 1 },
],
},
{
items: [
{ description: "alpha", selling_price: 1, quantity: 1 },
{ description: "beta", selling_price: 1, quantity: 1 },
// 4 lines (delta=2, max=4, diffRatio=0.5 → penalty)
{ description: "gamma", selling_price: 1, quantity: 1 },
{ description: "delta", selling_price: 1, quantity: 1 },
],
}
);
expect(r.itemCountDelta).toBe(RULE_POINTS.itemCountPenalty50);
expect(r.orderItemCount).toBe(2);
expect(r.quoteItemCount).toBe(4);
});
it("deducts -1 when item count differs by 25-50%", () => {
// 3 vs 5 (delta=2, max=5, ratio=0.4) → penalty -1
const r = scoreOrderVsQuote(
{
items: [
{ description: "alpha", selling_price: 1, quantity: 1 },
{ description: "beta", selling_price: 1, quantity: 1 },
{ description: "gamma", selling_price: 1, quantity: 1 },
],
},
{
items: [
{ description: "alpha", selling_price: 1, quantity: 1 },
{ description: "beta", selling_price: 1, quantity: 1 },
{ description: "gamma", selling_price: 1, quantity: 1 },
{ description: "delta", selling_price: 1, quantity: 1 },
{ description: "epsilon", selling_price: 1, quantity: 1 },
],
}
);
expect(r.itemCountDelta).toBe(RULE_POINTS.itemCountPenalty25);
});
it("does not penalize identical item counts", () => {
const r = scoreOrderVsQuote(
{
items: [
{ description: "alpha", selling_price: 1, quantity: 1 },
{ description: "beta", selling_price: 1, quantity: 1 },
{ description: "gamma", selling_price: 1, quantity: 1 },
],
},
{
items: [
{ description: "alpha", selling_price: 1, quantity: 1 },
{ description: "beta", selling_price: 1, quantity: 1 },
{ description: "gamma", selling_price: 1, quantity: 1 },
],
}
);
expect(r.itemCountDelta).toBe(0);
});
});
describe("scoreOrderVsQuote — normalization & thresholding", () => {
it("normalizes to 0..1 with bounded ceilings across order sizes", () => {
// A 1-line order that perfectly matches its 1-line quote should
// land near 1.0 the same way a 5-line order that perfectly matches
// its 5-line quote does — per-line signals dominate raw points
// and observedMax scales with line count.
const oneLine = scoreOrderVsQuote(
{
companyId: "co-1",
customerId: "cu-9",
items: [{ description: "M20 nuts", selling_price: 10, quantity: 5 }],
},
{
company: "co-1",
customer: "cu-9",
items: [{ description: "M20 nuts", selling_price: 10, quantity: 5 }],
}
);
expect(oneLine.confidence).toBeGreaterThan(0.9);
expect(oneLine.confidence).toBeLessThanOrEqual(1);
});
it("clamps confidence at 1.0 even when raw points exceed observedMax", () => {
// Construct a case where header signals exist but only 1 line matches:
// company+customer contribute 5, but only 1 line pair → 5 + 5 raw
// vs observedMax 5 + 5 = 10 → 1.0. The clamp guarantees the bound.
const r = scoreOrderVsQuote(
{
companyId: "co-1",
customerId: "cu-9",
items: [
{ description: "M20 nuts", selling_price: 10, quantity: 5 },
{ description: "excess line one", selling_price: 1, quantity: 1 },
{ description: "excess line two", selling_price: 1, quantity: 1 },
],
},
{
company: "co-1",
customer: "cu-9",
items: [{ description: "M20 nuts", selling_price: 10, quantity: 5 }],
}
);
expect(r.confidence).toBeLessThanOrEqual(1);
expect(r.confidence).toBeGreaterThan(0);
});
it("returns 0 confidence when both sides have no items", () => {
const r = scoreOrderVsQuote(
{ items: [] },
{ items: [{ description: "anything", selling_price: 1, quantity: 1 }] }
);
expect(r.confidence).toBe(0);
});
it("floors confidence at 0 (raw points can be negative)", () => {
// Force negative: company null, customer 0, paired line mismatched qty,
// itemCount penalty 50. Should clamp at 0 not go negative.
const r = scoreOrderVsQuote(
{
// No companyId, no customerId → header signals are absent.
items: [{ description: "completely different SKU", selling_price: 10, quantity: 5 }],
},
{
company: "co-1", // mismatched → null
customer: "cu-9", // order has no customer → null
items: [
{ description: "another thing entirely", selling_price: 50, quantity: 50 },
],
}
);
expect(r.confidence).toBe(0);
});
it("surfaces a breakdown with per-line points the UI can render", () => {
const r = scoreOrderVsQuote(
{
companyId: "co-1",
customerId: "cu-9",
items: [
{ description: "M20 nuts washers", selling_price: 10, quantity: 5 },
],
},
{
company: "co-1",
customer: "cu-9",
items: [
{ description: "M20 nuts washers", selling_price: 10, quantity: 5 },
],
}
);
expect(r.lines).toHaveLength(1);
expect(r.lines[0].descMatched).toBe(true);
expect(r.lines[0].priceMatched).toBe(true);
expect(r.lines[0].qtyMatched).toBe(true);
expect(r.lines[0].points).toBeGreaterThan(0);
});
});