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
426 lines
15 KiB
TypeScript
426 lines
15 KiB
TypeScript
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);
|
||
});
|
||
});
|