diff --git a/packages/api/src/__tests__/rate-card-match-support.test.ts b/packages/api/src/__tests__/rate-card-match-support.test.ts new file mode 100644 index 0000000..17d2c72 --- /dev/null +++ b/packages/api/src/__tests__/rate-card-match-support.test.ts @@ -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]); + }); +}); diff --git a/packages/api/src/router/rate-card-match-support.ts b/packages/api/src/router/rate-card-match-support.ts new file mode 100644 index 0000000..87f3a2a --- /dev/null +++ b/packages/api/src/router/rate-card-match-support.ts @@ -0,0 +1,78 @@ +type MatchKey = "roleId" | "chapter" | "location" | "seniority" | "workType"; + +type MatchableRateCardLine = Partial>; +type RateCardLineMatchCriteria = Partial>; +type RateCardLineMatchWeights = Partial>; + +type RateCardLineMatchResult = { + 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( + line: TLine, + input: { + criteria: RateCardLineMatchCriteria; + weights: RateCardLineMatchWeights; + caseInsensitiveKeys?: readonly MatchKey[]; + }, +): RateCardLineMatchResult { + 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; +} diff --git a/packages/api/src/router/rate-card-read.ts b/packages/api/src/router/rate-card-read.ts index e23d91c..2579b0f 100644 --- a/packages/api/src/router/rate-card-read.ts +++ b/packages/api/src/router/rate-card-read.ts @@ -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; }