refactor(api): extract rate card match support
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user