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 { ROLE_BRIEF_SELECT } from "../db/selects.js";
|
||||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { fmtEur } from "../lib/format-utils.js";
|
import { fmtEur } from "../lib/format-utils.js";
|
||||||
|
import {
|
||||||
|
pickBestRateCardLineMatch,
|
||||||
|
scoreRateCardLineMatch,
|
||||||
|
} from "./rate-card-match-support.js";
|
||||||
|
|
||||||
export const rateCardLineSelect = {
|
export const rateCardLineSelect = {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -43,6 +47,25 @@ type ResolveRateLineCriteria = {
|
|||||||
workType?: string | undefined;
|
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 {
|
function buildEffectiveRateCardWhere(effectiveAt: Date): Prisma.RateCardWhereInput {
|
||||||
return {
|
return {
|
||||||
isActive: true,
|
isActive: true,
|
||||||
@@ -126,30 +149,21 @@ export async function lookupBestRateMatch(
|
|||||||
|
|
||||||
for (const card of rateCards) {
|
for (const card of rateCards) {
|
||||||
for (const line of card.lines) {
|
for (const line of card.lines) {
|
||||||
let score = 0;
|
const scored = scoreRateCardLineMatch({
|
||||||
let mismatch = false;
|
roleId: line.role?.id ?? null,
|
||||||
|
chapter: line.chapter,
|
||||||
if (roleId && line.role) {
|
seniority: line.seniority,
|
||||||
if (line.role.id === roleId) {
|
}, {
|
||||||
score += 4;
|
criteria: {
|
||||||
} else {
|
roleId,
|
||||||
mismatch = true;
|
chapter: input.chapter,
|
||||||
}
|
seniority: input.seniority,
|
||||||
}
|
},
|
||||||
if (input.chapter && line.chapter) {
|
weights: lookupBestRateMatchWeights,
|
||||||
if (line.chapter.toLowerCase() === input.chapter.toLowerCase()) {
|
caseInsensitiveKeys: lookupBestRateCaseInsensitiveKeys,
|
||||||
score += 2;
|
});
|
||||||
} else {
|
let score = scored.score;
|
||||||
mismatch = true;
|
const mismatch = scored.mismatch;
|
||||||
}
|
|
||||||
}
|
|
||||||
if (input.seniority && line.seniority) {
|
|
||||||
if (line.seniority.toLowerCase() === input.seniority.toLowerCase()) {
|
|
||||||
score += 1;
|
|
||||||
} else {
|
|
||||||
mismatch = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (input.clientId && card.client?.id === input.clientId) {
|
if (input.clientId && card.client?.id === input.clientId) {
|
||||||
score += 3;
|
score += 3;
|
||||||
}
|
}
|
||||||
@@ -288,52 +302,8 @@ export function resolveBestRateLineMatch<
|
|||||||
workType: string | null;
|
workType: string | null;
|
||||||
},
|
},
|
||||||
>(lines: TLine[], criteria: ResolveRateLineCriteria): TLine | null {
|
>(lines: TLine[], criteria: ResolveRateLineCriteria): TLine | null {
|
||||||
const scored = lines.map((line) => {
|
return pickBestRateCardLineMatch(lines, {
|
||||||
let score = 0;
|
criteria,
|
||||||
let mismatch = false;
|
weights: resolveBestRateLineWeights,
|
||||||
|
|
||||||
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 };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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