From 77b21462d863b92412da87e5936cfedd7fff76ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 11:22:01 +0200 Subject: [PATCH] refactor(api): extract rate card read helpers --- packages/api/src/router/rate-card-read.ts | 339 ++++++++++++++++++++++ packages/api/src/router/rate-card.ts | 300 +------------------ 2 files changed, 353 insertions(+), 286 deletions(-) create mode 100644 packages/api/src/router/rate-card-read.ts diff --git a/packages/api/src/router/rate-card-read.ts b/packages/api/src/router/rate-card-read.ts new file mode 100644 index 0000000..e23d91c --- /dev/null +++ b/packages/api/src/router/rate-card-read.ts @@ -0,0 +1,339 @@ +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"; + +export const rateCardLineSelect = { + id: true, + rateCardId: true, + roleId: true, + chapter: true, + location: true, + seniority: true, + workType: true, + serviceGroup: true, + costRateCents: true, + billRateCents: true, + machineRateCents: true, + attributes: true, + role: { select: ROLE_BRIEF_SELECT }, + createdAt: true, + updatedAt: true, +} as const; + +type RateCardBestMatchInput = { + clientId?: string | undefined; + chapter?: string | undefined; + managementLevelId?: string | undefined; + roleName?: string | undefined; + seniority?: string | undefined; +}; + +type ResolveBestRateInput = { + resourceId?: string | undefined; + roleName?: string | undefined; + date?: Date | undefined; +}; + +type ResolveRateLineCriteria = { + roleId?: string | undefined; + chapter?: string | undefined; + location?: string | undefined; + seniority?: string | undefined; + workType?: string | undefined; +}; + +function buildEffectiveRateCardWhere(effectiveAt: Date): Prisma.RateCardWhereInput { + return { + isActive: true, + OR: [ + { effectiveFrom: null }, + { effectiveFrom: { lte: effectiveAt } }, + ], + AND: [ + { + OR: [ + { effectiveTo: null }, + { effectiveTo: { gte: effectiveAt } }, + ], + }, + ], + }; +} + +export async function lookupBestRateMatch( + db: Pick, + input: RateCardBestMatchInput, +) { + const rateCardWhere: Prisma.RateCardWhereInput = { isActive: true }; + if (input.clientId) { + rateCardWhere.OR = [ + { clientId: input.clientId }, + { clientId: null }, + ]; + } + + const rateCards = await db.rateCard.findMany({ + where: rateCardWhere, + include: { + lines: { + select: { + id: true, + chapter: true, + seniority: true, + costRateCents: true, + billRateCents: true, + role: { select: { id: true, name: true } }, + }, + }, + client: { select: { id: true, name: true } }, + }, + orderBy: [{ effectiveFrom: "desc" }], + }); + + if (rateCards.length === 0) { + return { + bestMatch: null, + alternatives: [], + totalCandidates: 0, + message: "No active rate cards found.", + }; + } + + let roleId: string | undefined; + if (input.roleName) { + const role = await db.role.findFirst({ + where: { name: { contains: input.roleName, mode: "insensitive" } }, + select: { id: true }, + }); + if (role) { + roleId = role.id; + } + } + + const scoredLines: Array<{ + rateCardName: string; + clientId: string | null; + clientName: string | null; + lineId: string; + chapter: string | null; + seniority: string | null; + roleName: string | null; + costRateCents: number; + billRateCents: number | null; + score: number; + }> = []; + + 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; + } + } + if (input.clientId && card.client?.id === input.clientId) { + score += 3; + } + + if (!mismatch) { + scoredLines.push({ + rateCardName: card.name, + clientId: card.client?.id ?? null, + clientName: card.client?.name ?? null, + lineId: line.id, + chapter: line.chapter, + seniority: line.seniority, + roleName: line.role?.name ?? null, + costRateCents: line.costRateCents, + billRateCents: line.billRateCents ?? null, + score, + }); + } + } + } + + scoredLines.sort((left, right) => right.score - left.score); + + return { + bestMatch: scoredLines[0] ?? null, + alternatives: scoredLines.slice(1, 4), + totalCandidates: scoredLines.length, + }; +} + +export async function resolveBestRate( + db: Pick, + input: ResolveBestRateInput, +) { + const effectiveAt = input.date ?? new Date(); + + if (input.resourceId) { + const resource = await findUniqueOrThrow( + db.resource.findUnique({ + where: { id: input.resourceId }, + select: { + id: true, + displayName: true, + chapter: true, + areaRole: { select: { name: true } }, + }, + }), + "Resource", + ); + + const resolved = await lookupBestRateMatch(db, { + ...(resource.chapter ? { chapter: resource.chapter } : {}), + ...(resource.areaRole?.name ? { roleName: resource.areaRole.name } : {}), + }); + + if (resolved.bestMatch) { + return { + rateCard: resolved.bestMatch.rateCardName, + resource: resource.displayName, + rate: fmtEur(resolved.bestMatch.costRateCents), + rateCents: resolved.bestMatch.costRateCents, + matchedBy: resolved.bestMatch.roleName ? `role: ${resolved.bestMatch.roleName}` : "best_match", + }; + } + } + + if (input.roleName) { + const match = await lookupBestRateMatch(db, { roleName: input.roleName }); + if (match.bestMatch) { + return { + rateCard: match.bestMatch.rateCardName, + rate: fmtEur(match.bestMatch.costRateCents), + rateCents: match.bestMatch.costRateCents, + matchedBy: match.bestMatch.roleName ? `role: ${match.bestMatch.roleName}` : "best_match", + alternatives: match.alternatives.map((alternative) => ({ + rateCard: alternative.rateCardName, + role: alternative.roleName, + chapter: alternative.chapter, + seniority: alternative.seniority, + costRate: fmtEur(alternative.costRateCents), + billRate: alternative.billRateCents != null ? fmtEur(alternative.billRateCents) : null, + })), + }; + } + if (match.totalCandidates === 0) { + return { error: "No matching rate card line found." }; + } + } + + const cards = await db.rateCard.findMany({ + where: buildEffectiveRateCardWhere(effectiveAt), + include: { + _count: { select: { lines: true } }, + client: { select: { id: true, name: true, code: true } }, + }, + orderBy: [{ isActive: "desc" }, { effectiveFrom: "desc" }, { name: "asc" }], + }); + const card = cards[0]; + if (!card) { + return { error: "No active rate card found for the given date." }; + } + + const detail = await findUniqueOrThrow( + db.rateCard.findUnique({ + where: { id: card.id }, + include: { + client: { select: { id: true, name: true, code: true } }, + lines: { + select: rateCardLineSelect, + orderBy: [{ chapter: "asc" }, { seniority: "asc" }, { createdAt: "asc" }], + }, + }, + }), + "Rate card", + ); + + return { + rateCard: detail.name, + lines: detail.lines.map((line) => ({ + role: line.role?.name ?? null, + seniority: line.seniority, + chapter: line.chapter, + location: line.location, + costRate: fmtEur(line.costRateCents), + billRate: line.billRateCents != null ? fmtEur(line.billRateCents) : null, + })), + }; +} + +export function resolveBestRateLineMatch< + TLine extends { + roleId: string | null; + chapter: string | null; + location: string | null; + seniority: string | null; + 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 }; + }); + + const candidates = scored + .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.ts b/packages/api/src/router/rate-card.ts index 0ab0cac..6914b7e 100644 --- a/packages/api/src/router/rate-card.ts +++ b/packages/api/src/router/rate-card.ts @@ -5,143 +5,16 @@ import { UpdateRateCardLineSchema, UpdateRateCardSchema, } from "@capakraken/shared"; -import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js"; -import { ROLE_BRIEF_SELECT } from "../db/selects.js"; import { createAuditEntry } from "../lib/audit.js"; -import { fmtEur } from "../lib/format-utils.js"; - -const lineSelect = { - id: true, - rateCardId: true, - roleId: true, - chapter: true, - location: true, - seniority: true, - workType: true, - serviceGroup: true, - costRateCents: true, - billRateCents: true, - machineRateCents: true, - attributes: true, - role: { select: ROLE_BRIEF_SELECT }, - createdAt: true, - updatedAt: true, -} as const; - -async function lookupBestRateMatch( - db: Pick, - input: { - clientId?: string | undefined; - chapter?: string | undefined; - managementLevelId?: string | undefined; - roleName?: string | undefined; - seniority?: string | undefined; - }, -) { - const rateCardWhere: Prisma.RateCardWhereInput = { isActive: true }; - if (input.clientId) { - rateCardWhere.OR = [ - { clientId: input.clientId }, - { clientId: null }, - ]; - } - - const rateCards = await db.rateCard.findMany({ - where: rateCardWhere, - include: { - lines: { - select: { - id: true, - chapter: true, - seniority: true, - costRateCents: true, - billRateCents: true, - role: { select: { id: true, name: true } }, - }, - }, - client: { select: { id: true, name: true } }, - }, - orderBy: [{ effectiveFrom: "desc" }], - }); - - if (rateCards.length === 0) { - return { - bestMatch: null, - alternatives: [], - totalCandidates: 0, - message: "No active rate cards found.", - }; - } - - let roleId: string | undefined; - if (input.roleName) { - const role = await db.role.findFirst({ - where: { name: { contains: input.roleName, mode: "insensitive" } }, - select: { id: true }, - }); - if (role) roleId = role.id; - } - - const scoredLines: Array<{ - rateCardName: string; - clientId: string | null; - clientName: string | null; - lineId: string; - chapter: string | null; - seniority: string | null; - roleName: string | null; - costRateCents: number; - billRateCents: number | null; - score: number; - }> = []; - - 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; - } - if (input.clientId && card.client?.id === input.clientId) score += 3; - - if (!mismatch) { - scoredLines.push({ - rateCardName: card.name, - clientId: card.client?.id ?? null, - clientName: card.client?.name ?? null, - lineId: line.id, - chapter: line.chapter, - seniority: line.seniority, - roleName: line.role?.name ?? null, - costRateCents: line.costRateCents, - billRateCents: line.billRateCents ?? null, - score, - }); - } - } - } - - scoredLines.sort((a, b) => b.score - a.score); - - return { - bestMatch: scoredLines[0] ?? null, - alternatives: scoredLines.slice(1, 4), - totalCandidates: scoredLines.length, - }; -} +import { + lookupBestRateMatch, + rateCardLineSelect, + resolveBestRate, + resolveBestRateLineMatch, +} from "./rate-card-read.js"; export const rateCardRouter = createTRPCRouter({ list: controllerProcedure @@ -195,7 +68,7 @@ export const rateCardRouter = createTRPCRouter({ include: { client: { select: { id: true, name: true, code: true } }, lines: { - select: lineSelect, + select: rateCardLineSelect, orderBy: [{ chapter: "asc" }, { seniority: "asc" }, { createdAt: "asc" }], }, }, @@ -221,114 +94,7 @@ export const rateCardRouter = createTRPCRouter({ roleName: z.string().optional(), date: z.coerce.date().optional(), })) - .query(async ({ ctx, input }) => { - const effectiveAt = input.date ?? new Date(); - - if (input.resourceId) { - const resource = await findUniqueOrThrow( - ctx.db.resource.findUnique({ - where: { id: input.resourceId }, - select: { - id: true, - displayName: true, - chapter: true, - areaRole: { select: { name: true } }, - }, - }), - "Resource", - ); - - const resolved = await lookupBestRateMatch(ctx.db, { - ...(resource.chapter ? { chapter: resource.chapter } : {}), - ...(resource.areaRole?.name ? { roleName: resource.areaRole.name } : {}), - }); - - if (resolved.bestMatch) { - return { - rateCard: resolved.bestMatch.rateCardName, - resource: resource.displayName, - rate: fmtEur(resolved.bestMatch.costRateCents), - rateCents: resolved.bestMatch.costRateCents, - matchedBy: resolved.bestMatch.roleName ? `role: ${resolved.bestMatch.roleName}` : "best_match", - }; - } - } - - if (input.roleName) { - const match = await lookupBestRateMatch(ctx.db, { roleName: input.roleName }); - if (match.bestMatch) { - return { - rateCard: match.bestMatch.rateCardName, - rate: fmtEur(match.bestMatch.costRateCents), - rateCents: match.bestMatch.costRateCents, - matchedBy: match.bestMatch.roleName ? `role: ${match.bestMatch.roleName}` : "best_match", - alternatives: match.alternatives.map((alternative) => ({ - rateCard: alternative.rateCardName, - role: alternative.roleName, - chapter: alternative.chapter, - seniority: alternative.seniority, - costRate: fmtEur(alternative.costRateCents), - billRate: alternative.billRateCents != null ? fmtEur(alternative.billRateCents) : null, - })), - }; - } - if (match.totalCandidates === 0) { - return { error: "No matching rate card line found." }; - } - } - - const cards = await ctx.db.rateCard.findMany({ - where: { - isActive: true, - OR: [ - { effectiveFrom: null }, - { effectiveFrom: { lte: effectiveAt } }, - ], - AND: [ - { - OR: [ - { effectiveTo: null }, - { effectiveTo: { gte: effectiveAt } }, - ], - }, - ], - }, - include: { - _count: { select: { lines: true } }, - client: { select: { id: true, name: true, code: true } }, - }, - orderBy: [{ isActive: "desc" }, { effectiveFrom: "desc" }, { name: "asc" }], - }); - const card = cards[0]; - if (!card) { - return { error: "No active rate card found for the given date." }; - } - const detail = await findUniqueOrThrow( - ctx.db.rateCard.findUnique({ - where: { id: card.id }, - include: { - client: { select: { id: true, name: true, code: true } }, - lines: { - select: lineSelect, - orderBy: [{ chapter: "asc" }, { seniority: "asc" }, { createdAt: "asc" }], - }, - }, - }), - "Rate card", - ); - - return { - rateCard: detail.name, - lines: detail.lines.map((line) => ({ - role: line.role?.name ?? null, - seniority: line.seniority, - chapter: line.chapter, - location: line.location, - costRate: fmtEur(line.costRateCents), - billRate: line.billRateCents != null ? fmtEur(line.billRateCents) : null, - })), - }; - }), + .query(async ({ ctx, input }) => resolveBestRate(ctx.db, input)), create: managerProcedure .input(CreateRateCardSchema) @@ -359,7 +125,7 @@ export const rateCardRouter = createTRPCRouter({ }, }, include: { - lines: { select: lineSelect }, + lines: { select: rateCardLineSelect }, }, }); @@ -463,7 +229,7 @@ export const rateCardRouter = createTRPCRouter({ ...(input.line.machineRateCents !== undefined ? { machineRateCents: input.line.machineRateCents } : {}), attributes: input.line.attributes as Prisma.InputJsonValue, }, - select: lineSelect, + select: rateCardLineSelect, }); void createAuditEntry({ @@ -503,7 +269,7 @@ export const rateCardRouter = createTRPCRouter({ const updated = await ctx.db.rateCardLine.update({ where: { id: input.lineId }, data: updateData, - select: lineSelect, + select: rateCardLineSelect, }); void createAuditEntry({ @@ -575,7 +341,7 @@ export const rateCardRouter = createTRPCRouter({ ...(line.machineRateCents !== undefined ? { machineRateCents: line.machineRateCents } : {}), attributes: line.attributes as Prisma.InputJsonValue, }, - select: lineSelect, + select: rateCardLineSelect, }), ), ); @@ -615,46 +381,8 @@ export const rateCardRouter = createTRPCRouter({ // Find the most specific matching line (most criteria matched wins) const lines = await ctx.db.rateCardLine.findMany({ where: { rateCardId }, - select: lineSelect, + select: rateCardLineSelect, }); - - if (lines.length === 0) return null; - - // Score each line by number of matching criteria - 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 }; - }); - - // Filter out mismatches and find best match - const candidates = scored - .filter((s) => !s.mismatch) - .sort((a, b) => b.score - a.score); - - const best = candidates[0]; - return best ? best.line : null; + return resolveBestRateLineMatch(lines, criteria); }), });