refactor(api): extract rate card match support

This commit is contained in:
2026-03-31 14:33:38 +02:00
parent aeffb2a069
commit b093a47c1b
3 changed files with 195 additions and 71 deletions
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import {
pickBestRateCardLineMatch,
scoreRateCardLineMatch,
} from "../router/rate-card-match-support.js";
describe("rate card match support", () => {
it("scores weighted matches with optional case-insensitive keys", () => {
expect(scoreRateCardLineMatch({
roleId: "role_1",
chapter: "Delivery",
seniority: "Senior",
}, {
criteria: {
roleId: "role_1",
chapter: "delivery",
seniority: "senior",
},
weights: {
roleId: 4,
chapter: 2,
seniority: 1,
},
caseInsensitiveKeys: ["chapter", "seniority"],
})).toEqual({
line: {
roleId: "role_1",
chapter: "Delivery",
seniority: "Senior",
},
score: 7,
mismatch: false,
});
});
it("marks mismatches when a populated criterion conflicts with a populated line field", () => {
expect(scoreRateCardLineMatch({
roleId: "role_pm",
chapter: "Project Management",
}, {
criteria: {
roleId: "role_3d",
chapter: "Project Management",
},
weights: {
roleId: 4,
chapter: 2,
},
})).toEqual({
line: {
roleId: "role_pm",
chapter: "Project Management",
},
score: 2,
mismatch: true,
});
});
it("picks the highest-scoring non-mismatching line", () => {
const lines = [
{ id: "generic", roleId: null, chapter: null, location: null, seniority: null, workType: null },
{ id: "specific", roleId: "role_3d", chapter: "Delivery", location: null, seniority: null, workType: null },
];
expect(pickBestRateCardLineMatch(lines, {
criteria: {
roleId: "role_3d",
chapter: "Delivery",
},
weights: {
roleId: 4,
chapter: 2,
},
})).toEqual(lines[1]);
});
});
@@ -0,0 +1,78 @@
type MatchKey = "roleId" | "chapter" | "location" | "seniority" | "workType";
type MatchableRateCardLine = Partial<Record<MatchKey, string | null>>;
type RateCardLineMatchCriteria = Partial<Record<MatchKey, string | undefined>>;
type RateCardLineMatchWeights = Partial<Record<MatchKey, number>>;
type RateCardLineMatchResult<TLine> = {
line: TLine;
score: number;
mismatch: boolean;
};
function normalizeRateCardMatchValue(
value: string,
caseInsensitive: boolean,
): string {
return caseInsensitive ? value.toLowerCase() : value;
}
function rateCardMatchValuesEqual(input: {
lineValue: string;
criteriaValue: string;
caseInsensitive: boolean;
}): boolean {
return normalizeRateCardMatchValue(input.lineValue, input.caseInsensitive) ===
normalizeRateCardMatchValue(input.criteriaValue, input.caseInsensitive);
}
export function scoreRateCardLineMatch<TLine extends MatchableRateCardLine>(
line: TLine,
input: {
criteria: RateCardLineMatchCriteria;
weights: RateCardLineMatchWeights;
caseInsensitiveKeys?: readonly MatchKey[];
},
): RateCardLineMatchResult<TLine> {
const caseInsensitiveKeys = new Set(input.caseInsensitiveKeys ?? []);
let score = 0;
let mismatch = false;
for (const [key, criteriaValue] of Object.entries(input.criteria) as Array<[MatchKey, string | undefined]>) {
const lineValue = line[key];
if (!criteriaValue || !lineValue) {
continue;
}
if (rateCardMatchValuesEqual({
lineValue,
criteriaValue,
caseInsensitive: caseInsensitiveKeys.has(key),
})) {
score += input.weights[key] ?? 0;
continue;
}
mismatch = true;
}
return { line, score, mismatch };
}
export function pickBestRateCardLineMatch<
TLine extends MatchableRateCardLine,
>(
lines: TLine[],
input: {
criteria: RateCardLineMatchCriteria;
weights: RateCardLineMatchWeights;
caseInsensitiveKeys?: readonly MatchKey[];
},
): TLine | null {
const candidates = lines
.map((line) => scoreRateCardLineMatch(line, input))
.filter((candidate) => !candidate.mismatch)
.sort((left, right) => right.score - left.score);
return candidates[0]?.line ?? null;
}
+41 -71
View File
@@ -2,6 +2,10 @@ import type { Prisma } from "@capakraken/db";
import { ROLE_BRIEF_SELECT } from "../db/selects.js";
import { findUniqueOrThrow } from "../db/helpers.js";
import { fmtEur } from "../lib/format-utils.js";
import {
pickBestRateCardLineMatch,
scoreRateCardLineMatch,
} from "./rate-card-match-support.js";
export const rateCardLineSelect = {
id: true,
@@ -43,6 +47,25 @@ type ResolveRateLineCriteria = {
workType?: string | undefined;
};
const lookupBestRateMatchWeights = {
roleId: 4,
chapter: 2,
seniority: 1,
} as const;
const lookupBestRateCaseInsensitiveKeys = [
"chapter",
"seniority",
] as const;
const resolveBestRateLineWeights = {
roleId: 4,
chapter: 2,
location: 1,
seniority: 1,
workType: 1,
} as const;
function buildEffectiveRateCardWhere(effectiveAt: Date): Prisma.RateCardWhereInput {
return {
isActive: true,
@@ -126,30 +149,21 @@ export async function lookupBestRateMatch(
for (const card of rateCards) {
for (const line of card.lines) {
let score = 0;
let mismatch = false;
if (roleId && line.role) {
if (line.role.id === roleId) {
score += 4;
} else {
mismatch = true;
}
}
if (input.chapter && line.chapter) {
if (line.chapter.toLowerCase() === input.chapter.toLowerCase()) {
score += 2;
} else {
mismatch = true;
}
}
if (input.seniority && line.seniority) {
if (line.seniority.toLowerCase() === input.seniority.toLowerCase()) {
score += 1;
} else {
mismatch = true;
}
}
const scored = scoreRateCardLineMatch({
roleId: line.role?.id ?? null,
chapter: line.chapter,
seniority: line.seniority,
}, {
criteria: {
roleId,
chapter: input.chapter,
seniority: input.seniority,
},
weights: lookupBestRateMatchWeights,
caseInsensitiveKeys: lookupBestRateCaseInsensitiveKeys,
});
let score = scored.score;
const mismatch = scored.mismatch;
if (input.clientId && card.client?.id === input.clientId) {
score += 3;
}
@@ -288,52 +302,8 @@ export function resolveBestRateLineMatch<
workType: string | null;
},
>(lines: TLine[], criteria: ResolveRateLineCriteria): TLine | null {
const scored = lines.map((line) => {
let score = 0;
let mismatch = false;
if (criteria.roleId && line.roleId) {
if (line.roleId === criteria.roleId) {
score += 4;
} else {
mismatch = true;
}
}
if (criteria.chapter && line.chapter) {
if (line.chapter === criteria.chapter) {
score += 2;
} else {
mismatch = true;
}
}
if (criteria.location && line.location) {
if (line.location === criteria.location) {
score += 1;
} else {
mismatch = true;
}
}
if (criteria.seniority && line.seniority) {
if (line.seniority === criteria.seniority) {
score += 1;
} else {
mismatch = true;
}
}
if (criteria.workType && line.workType) {
if (line.workType === criteria.workType) {
score += 1;
} else {
mismatch = true;
}
}
return { line, score, mismatch };
return pickBestRateCardLineMatch(lines, {
criteria,
weights: resolveBestRateLineWeights,
});
const candidates = scored
.filter((candidate) => !candidate.mismatch)
.sort((left, right) => right.score - left.score);
return candidates[0]?.line ?? null;
}