From 958d2368c1a425203eea7ba0fba87e6a419a59d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 20:42:33 +0200 Subject: [PATCH] refactor(api): extract chargeability report procedures --- docs/api-router-procedure-support-backlog.md | 2 +- ...geability-report-procedure-support.test.ts | 168 ++++++ .../chargeability-report-procedure-support.ts | 486 ++++++++++++++++++ .../api/src/router/chargeability-report.ts | 321 +----------- 4 files changed, 665 insertions(+), 312 deletions(-) create mode 100644 packages/api/src/__tests__/chargeability-report-procedure-support.test.ts create mode 100644 packages/api/src/router/chargeability-report-procedure-support.ts diff --git a/docs/api-router-procedure-support-backlog.md b/docs/api-router-procedure-support-backlog.md index afd267b..dc42529 100644 --- a/docs/api-router-procedure-support-backlog.md +++ b/docs/api-router-procedure-support-backlog.md @@ -15,13 +15,13 @@ Done - `dispo` - `insights` - `import-export` +- `chargeability-report` Ready next - none in the conflict-safe backlog Deferred or blocked - `assistant-tools` -- `chargeability-report` - `dashboard` - `entitlement` - `notification` diff --git a/packages/api/src/__tests__/chargeability-report-procedure-support.test.ts b/packages/api/src/__tests__/chargeability-report-procedure-support.test.ts new file mode 100644 index 0000000..9d27dd9 --- /dev/null +++ b/packages/api/src/__tests__/chargeability-report-procedure-support.test.ts @@ -0,0 +1,168 @@ +import { PermissionKey } from "@capakraken/shared"; +import { TRPCError } from "@trpc/server"; +import { describe, expect, it } from "vitest"; +import { + buildChargeabilityReportDetail, + getChargeabilityReportDetail, +} from "../router/chargeability-report-procedure-support.js"; + +describe("chargeability report procedure support", () => { + it("builds a filtered detailed report with rounded percentages", () => { + const result = buildChargeabilityReportDetail( + { + monthKeys: ["2026-03"], + groupTotals: [ + { + monthKey: "2026-03", + totalFte: 1.234, + chg: 0.456, + target: 0.8, + gap: -0.344, + chargeabilityRatio: 0.456, + targetRatio: 0.8, + gapRatio: -0.344, + }, + ], + resources: [ + { + id: "resource_1", + eid: "E-001", + displayName: "Alice", + fte: 1.234, + country: "ES", + federalState: null, + city: "Barcelona", + orgUnit: "CGI", + mgmtGroup: "Senior", + mgmtLevel: "L7", + targetPct: 0.8, + months: [ + { + monthKey: "2026-03", + sah: 120.44, + sahHours: 120.44, + chg: 0.456, + bd: 0.1, + mdi: 0.05, + mo: 0.07, + pdr: 0.08, + absence: 0.09, + unassigned: 0.154, + chargeabilityRatio: 0.456, + businessDevelopmentRatio: 0.1, + marketDevelopmentInnovationRatio: 0.05, + managementOverheadRatio: 0.07, + peopleDevelopmentRecruitingRatio: 0.08, + plannedAbsenceRatio: 0.09, + unassignedRatio: 0.154, + chargeabilityHours: 54.9, + businessDevelopmentHours: 12, + marketDevelopmentInnovationHours: 6, + managementOverheadHours: 8.4, + peopleDevelopmentRecruitingHours: 9.6, + plannedAbsenceHours: 10.8, + unassignedHours: 18.5, + targetRatio: 0.8, + targetHours: 96.4, + gapRatio: -0.344, + gapHours: -41.4, + derivation: { + baseAvailableHours: 128, + publicHolidayCount: 1, + publicHolidayWorkdayCount: 1, + publicHolidayHoursDeduction: 8, + absenceDayEquivalent: 0.5, + absenceHoursDeduction: 4, + effectiveAvailableHours: 120, + }, + }, + ], + }, + { + id: "resource_2", + eid: "E-002", + displayName: "Bob", + fte: 1, + country: "DE", + federalState: "BY", + city: "Munich", + orgUnit: "CGI", + mgmtGroup: "Senior", + mgmtLevel: "L7", + targetPct: 0.75, + months: [], + }, + ], + }, + { + startMonth: "2026-03", + endMonth: "2026-03", + resourceQuery: "ali", + resourceLimit: 1, + includeProposed: false, + }, + ); + + expect(result.filters).toEqual({ + startMonth: "2026-03", + endMonth: "2026-03", + orgUnitId: null, + managementLevelGroupId: null, + countryId: null, + includeProposed: false, + resourceQuery: "ali", + }); + expect(result.groupTotals).toEqual([ + { + monthKey: "2026-03", + totalFte: 1.2, + chargeabilityPct: 45.6, + targetPct: 80, + gapPct: -34.4, + }, + ]); + expect(result.resourceCount).toBe(1); + expect(result.returnedResourceCount).toBe(1); + expect(result.truncated).toBe(false); + expect(result.resources[0]).toEqual( + expect.objectContaining({ + displayName: "Alice", + targetPct: 80, + managementLevelGroup: "Senior", + managementLevel: "L7", + months: [ + expect.objectContaining({ + monthKey: "2026-03", + sah: 120.4, + chargeabilityPct: 45.6, + targetPct: 80, + gapPct: -34.4, + }), + ], + }), + ); + }); + + it("rejects detailed reports without the cost-view permission", async () => { + await expect( + getChargeabilityReportDetail( + { + db: { + resource: { findMany: async () => [] }, + } as never, + permissions: new Set(), + }, + { + startMonth: "2026-03", + endMonth: "2026-03", + includeProposed: false, + }, + ), + ).rejects.toEqual( + expect.objectContaining>({ + code: "FORBIDDEN", + message: `Permission required: ${PermissionKey.VIEW_COSTS}`, + }), + ); + }); +}); diff --git a/packages/api/src/router/chargeability-report-procedure-support.ts b/packages/api/src/router/chargeability-report-procedure-support.ts new file mode 100644 index 0000000..acee810 --- /dev/null +++ b/packages/api/src/router/chargeability-report-procedure-support.ts @@ -0,0 +1,486 @@ +import { + deriveResourceForecast, + calculateGroupChargeability, + calculateGroupTarget, + sumFte, + getMonthRange, + getMonthKeys, + type AssignmentSlice, +} from "@capakraken/engine"; +import type { PrismaClient } from "@capakraken/db"; +import type { WeekdayAvailability, PermissionKey } from "@capakraken/shared"; +import { PermissionKey as PermissionKeys } from "@capakraken/shared"; +import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application"; +import { z } from "zod"; +import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js"; +import { + calculateEffectiveAvailableHours, + calculateEffectiveBookedHours, + getAvailabilityHoursForDate, + loadResourceDailyAvailabilityContexts, +} from "../lib/resource-capacity.js"; +import { requirePermission, type TRPCContext } from "../trpc.js"; + +type ChargeabilityReportProcedureContext = Pick & { + permissions?: Set; +}; + +function round1(value: number): number { + return Math.round(value * 10) / 10; +} + +function isIsoDateInMonth(isoDate: string, monthKey: string): boolean { + return isoDate.startsWith(`${monthKey}-`); +} + +function getMonthCapacityDerivation(input: { + monthKey: string; + availability: WeekdayAvailability; + context: (Awaited> extends Map ? T : never) | undefined; + baseAvailableHours: number; + effectiveAvailableHours: number; +}) { + const holidayDates = [...(input.context?.holidayDates ?? new Set())] + .filter((isoDate) => isIsoDateInMonth(isoDate, input.monthKey)) + .sort(); + const publicHolidayWorkdayCount = holidayDates.reduce((count, isoDate) => ( + count + (getAvailabilityHoursForDate(input.availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0) + ), 0); + const publicHolidayHoursDeduction = holidayDates.reduce((sum, isoDate) => ( + sum + getAvailabilityHoursForDate(input.availability, new Date(`${isoDate}T00:00:00.000Z`)) + ), 0); + + let absenceDayEquivalent = 0; + let absenceHoursDeduction = 0; + for (const [isoDate, fraction] of input.context?.vacationFractionsByDate ?? []) { + if (!isIsoDateInMonth(isoDate, input.monthKey) || input.context?.holidayDates.has(isoDate)) { + continue; + } + const dayHours = getAvailabilityHoursForDate(input.availability, new Date(`${isoDate}T00:00:00.000Z`)); + if (dayHours <= 0) { + continue; + } + absenceDayEquivalent += fraction; + absenceHoursDeduction += dayHours * fraction; + } + + return { + baseAvailableHours: round1(input.baseAvailableHours), + publicHolidayCount: holidayDates.length, + publicHolidayWorkdayCount, + publicHolidayHoursDeduction: round1(publicHolidayHoursDeduction), + absenceDayEquivalent: round1(absenceDayEquivalent), + absenceHoursDeduction: round1(absenceHoursDeduction), + effectiveAvailableHours: round1(input.effectiveAvailableHours), + }; +} + +type MonthCapacityDerivation = ReturnType; +type ForecastBreakdown = ReturnType; + +function toHours(ratio: number, sahHours: number): number { + return round1(sahHours * ratio); +} + +function buildReportMonth(input: { + monthKey: string; + sahHours: number; + targetRatio: number; + forecast: ForecastBreakdown; + derivation: MonthCapacityDerivation; +}) { + const { monthKey, sahHours, targetRatio, forecast, derivation } = input; + const chargeabilityRatio = forecast.chg; + const businessDevelopmentRatio = forecast.bd; + const marketDevelopmentInnovationRatio = forecast.mdi; + const managementOverheadRatio = forecast.mo; + const peopleDevelopmentRecruitingRatio = forecast.pdr; + const plannedAbsenceRatio = forecast.absence; + const unassignedRatio = forecast.unassigned; + const gapRatio = chargeabilityRatio - targetRatio; + + return { + monthKey, + sah: round1(sahHours), + sahHours: round1(sahHours), + chg: chargeabilityRatio, + bd: businessDevelopmentRatio, + mdi: marketDevelopmentInnovationRatio, + mo: managementOverheadRatio, + pdr: peopleDevelopmentRecruitingRatio, + absence: plannedAbsenceRatio, + unassigned: unassignedRatio, + chargeabilityRatio, + businessDevelopmentRatio, + marketDevelopmentInnovationRatio, + managementOverheadRatio, + peopleDevelopmentRecruitingRatio, + plannedAbsenceRatio, + unassignedRatio, + chargeabilityHours: toHours(chargeabilityRatio, sahHours), + businessDevelopmentHours: toHours(businessDevelopmentRatio, sahHours), + marketDevelopmentInnovationHours: toHours(marketDevelopmentInnovationRatio, sahHours), + managementOverheadHours: toHours(managementOverheadRatio, sahHours), + peopleDevelopmentRecruitingHours: toHours(peopleDevelopmentRecruitingRatio, sahHours), + plannedAbsenceHours: toHours(plannedAbsenceRatio, sahHours), + unassignedHours: toHours(unassignedRatio, sahHours), + targetRatio, + targetHours: toHours(targetRatio, sahHours), + gapRatio, + gapHours: toHours(gapRatio, sahHours), + derivation, + }; +} + +function buildGroupTotal(input: { + monthKey: string; + totalFte: number; + chargeabilityRatio: number; + targetRatio: number; +}) { + const gapRatio = input.chargeabilityRatio - input.targetRatio; + + return { + monthKey: input.monthKey, + totalFte: input.totalFte, + chg: input.chargeabilityRatio, + target: input.targetRatio, + gap: gapRatio, + chargeabilityRatio: input.chargeabilityRatio, + targetRatio: input.targetRatio, + gapRatio, + }; +} + +export const chargeabilityReportInputSchema = z.object({ + startMonth: z.string().regex(/^\d{4}-\d{2}$/), + endMonth: z.string().regex(/^\d{4}-\d{2}$/), + orgUnitId: z.string().optional(), + managementLevelGroupId: z.string().optional(), + countryId: z.string().optional(), + includeProposed: z.boolean().default(false), +}); + +export const chargeabilityReportDetailInputSchema = chargeabilityReportInputSchema.extend({ + resourceQuery: z.string().optional(), + resourceLimit: z.number().int().min(1).max(100).optional(), +}); + +type ChargeabilityReportInput = z.infer; +type ChargeabilityReportDetailInput = z.infer; + +type ChargeabilityReportDbClient = Pick< + PrismaClient, + "assignment" | "resource" | "project" | "vacation" | "holidayCalendar" | "systemSettings" +>; + +async function queryChargeabilityReport( + db: ChargeabilityReportDbClient, + input: ChargeabilityReportInput, +) { + const { startMonth, endMonth, includeProposed } = input; + + const [startYear, startMo] = startMonth.split("-").map(Number) as [number, number]; + const [endYear, endMo] = endMonth.split("-").map(Number) as [number, number]; + const rangeStart = getMonthRange(startYear, startMo).start; + const rangeEnd = getMonthRange(endYear, endMo).end; + const monthKeys = getMonthKeys(rangeStart, rangeEnd); + + const resourceWhere = { + isActive: true, + chgResponsibility: true, + departed: false, + rolledOff: false, + ...(input.orgUnitId ? { orgUnitId: input.orgUnitId } : {}), + ...(input.managementLevelGroupId ? { managementLevelGroupId: input.managementLevelGroupId } : {}), + ...(input.countryId ? { countryId: input.countryId } : {}), + }; + + const resources = await db.resource.findMany({ + where: resourceWhere, + select: { + id: true, + eid: true, + displayName: true, + fte: true, + availability: true, + countryId: true, + federalState: true, + metroCityId: true, + chargeabilityTarget: true, + country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } }, + orgUnit: { select: { id: true, name: true } }, + managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } }, + managementLevel: { select: { id: true, name: true } }, + metroCity: { select: { id: true, name: true } }, + }, + orderBy: { displayName: "asc" }, + }); + + if (resources.length === 0) { + return { + monthKeys, + resources: [], + groupTotals: monthKeys.map((key) => buildGroupTotal({ + monthKey: key, + totalFte: 0, + chargeabilityRatio: 0, + targetRatio: 0, + })), + }; + } + + const resourceIds = resources.map((resource) => resource.id); + const allBookings = await listAssignmentBookings(db, { + startDate: rangeStart, + endDate: rangeEnd, + resourceIds, + }); + const availabilityContexts = await loadResourceDailyAvailabilityContexts( + db, + resources.map((resource) => ({ + id: resource.id, + availability: resource.availability as unknown as WeekdayAvailability, + countryId: resource.countryId, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityId: resource.metroCityId, + metroCityName: resource.metroCity?.name, + })), + rangeStart, + rangeEnd, + ); + + const projectIds = [...new Set(allBookings.map((booking) => booking.projectId))]; + const projectUtilCats = projectIds.length > 0 + ? await db.project.findMany({ + where: { id: { in: projectIds } }, + select: { id: true, utilizationCategory: { select: { code: true } } }, + }) + : []; + const projectUtilCatMap = new Map( + projectUtilCats.map((project) => [project.id, project.utilizationCategory?.code ?? null]), + ); + + const assignments = allBookings + .filter((booking) => booking.resourceId !== null) + .filter((booking) => isChargeabilityActualBooking(booking, includeProposed)) + .map((booking) => ({ + resourceId: booking.resourceId!, + startDate: booking.startDate, + endDate: booking.endDate, + hoursPerDay: booking.hoursPerDay, + project: { + status: booking.project.status, + utilizationCategory: { code: projectUtilCatMap.get(booking.projectId) ?? null }, + }, + })); + + const resourceRows = await Promise.all(resources.map(async (resource) => { + const resourceAssignments = assignments.filter((assignment) => assignment.resourceId === resource.id); + const targetPct = resource.managementLevelGroup?.targetPercentage + ?? (resource.chargeabilityTarget / 100); + const availability = resource.availability as unknown as WeekdayAvailability; + const context = availabilityContexts.get(resource.id); + + const months = await Promise.all(monthKeys.map(async (key) => { + const [year, month] = key.split("-").map(Number) as [number, number]; + const { start: monthStart, end: monthEnd } = getMonthRange(year, month); + const baseAvailableHours = calculateEffectiveAvailableHours({ + availability, + periodStart: monthStart, + periodEnd: monthEnd, + context: undefined, + }); + const availableHours = calculateEffectiveAvailableHours({ + availability, + periodStart: monthStart, + periodEnd: monthEnd, + context, + }); + const slices: AssignmentSlice[] = resourceAssignments.flatMap((assignment) => { + const totalChargeableHours = calculateEffectiveBookedHours({ + availability, + startDate: assignment.startDate, + endDate: assignment.endDate, + hoursPerDay: assignment.hoursPerDay, + periodStart: monthStart, + periodEnd: monthEnd, + context, + }); + if (totalChargeableHours <= 0) { + return []; + } + + const categoryCode = assignment.project.utilizationCategory?.code; + + return { + hoursPerDay: assignment.hoursPerDay, + workingDays: 0, + categoryCode: typeof categoryCode === "string" && categoryCode.length > 0 ? categoryCode : "Chg", + totalChargeableHours, + }; + }); + + const forecast = deriveResourceForecast({ + fte: resource.fte, + targetPercentage: targetPct, + assignments: slices, + sah: availableHours, + }); + const derivation = getMonthCapacityDerivation({ + monthKey: key, + availability, + context, + baseAvailableHours, + effectiveAvailableHours: availableHours, + }); + + return buildReportMonth({ + monthKey: key, + sahHours: availableHours, + targetRatio: targetPct, + forecast, + derivation, + }); + })); + + return { + id: resource.id, + eid: resource.eid, + displayName: resource.displayName, + fte: resource.fte, + country: resource.country?.code ?? null, + federalState: resource.federalState ?? null, + city: resource.metroCity?.name ?? null, + orgUnit: resource.orgUnit?.name ?? null, + mgmtGroup: resource.managementLevelGroup?.name ?? null, + mgmtLevel: resource.managementLevel?.name ?? null, + targetPct, + months, + }; + })); + + const groupTotals = monthKeys.map((key, monthIdx) => { + const groupInputs = resourceRows.map((resource) => ({ + fte: resource.fte, + chargeability: resource.months[monthIdx]!.chg, + })); + const targetInputs = resourceRows.map((resource) => ({ + fte: resource.fte, + targetPercentage: resource.targetPct, + })); + + const chg = calculateGroupChargeability(groupInputs); + const target = calculateGroupTarget(targetInputs); + + return buildGroupTotal({ + monthKey: key, + totalFte: sumFte(resourceRows), + chargeabilityRatio: chg, + targetRatio: target, + }); + }); + + const directory = await getAnonymizationDirectory(db); + + return { + monthKeys, + resources: anonymizeResources(resourceRows, directory), + groupTotals, + }; +} + +export function buildChargeabilityReportDetail( + report: Awaited>, + input: ChargeabilityReportDetailInput, +) { + const resourceQuery = input.resourceQuery?.trim().toLowerCase(); + const matchingResources = resourceQuery + ? report.resources.filter((resource) => ( + resource.displayName.toLowerCase().includes(resourceQuery) + || resource.eid.toLowerCase().includes(resourceQuery) + )) + : report.resources; + const resourceLimit = Math.min(Math.max(input.resourceLimit ?? 25, 1), 100); + const resources = matchingResources.slice(0, resourceLimit).map((resource) => ({ + id: resource.id, + eid: resource.eid, + displayName: resource.displayName, + fte: round1(resource.fte), + country: resource.country, + federalState: resource.federalState ?? null, + city: resource.city, + orgUnit: resource.orgUnit, + managementLevelGroup: resource.mgmtGroup, + managementLevel: resource.mgmtLevel, + targetPct: round1(resource.targetPct * 100), + months: resource.months.map((month) => ({ + monthKey: month.monthKey, + sah: round1(month.sah), + sahHours: round1(month.sahHours), + chargeabilityPct: round1(month.chg * 100), + chargeabilityHours: month.chargeabilityHours, + targetPct: round1(resource.targetPct * 100), + targetHours: month.targetHours, + gapPct: round1((month.chg - resource.targetPct) * 100), + gapHours: month.gapHours, + businessDevelopmentPct: round1(month.businessDevelopmentRatio * 100), + businessDevelopmentHours: month.businessDevelopmentHours, + marketDevelopmentInnovationPct: round1(month.marketDevelopmentInnovationRatio * 100), + marketDevelopmentInnovationHours: month.marketDevelopmentInnovationHours, + managementOverheadPct: round1(month.managementOverheadRatio * 100), + managementOverheadHours: month.managementOverheadHours, + peopleDevelopmentRecruitingPct: round1(month.peopleDevelopmentRecruitingRatio * 100), + peopleDevelopmentRecruitingHours: month.peopleDevelopmentRecruitingHours, + plannedAbsencePct: round1(month.plannedAbsenceRatio * 100), + plannedAbsenceHours: month.plannedAbsenceHours, + unassignedPct: round1(month.unassignedRatio * 100), + unassignedHours: month.unassignedHours, + derivation: month.derivation, + })), + })); + + return { + filters: { + startMonth: input.startMonth, + endMonth: input.endMonth, + orgUnitId: input.orgUnitId ?? null, + managementLevelGroupId: input.managementLevelGroupId ?? null, + countryId: input.countryId ?? null, + includeProposed: input.includeProposed ?? false, + resourceQuery: input.resourceQuery ?? null, + }, + monthKeys: report.monthKeys, + groupTotals: report.groupTotals.map((group) => ({ + monthKey: group.monthKey, + totalFte: round1(group.totalFte), + chargeabilityPct: round1(group.chg * 100), + targetPct: round1(group.target * 100), + gapPct: round1(group.gap * 100), + })), + resourceCount: matchingResources.length, + returnedResourceCount: resources.length, + truncated: resources.length < matchingResources.length, + resources, + }; +} + +export async function getChargeabilityReport( + ctx: ChargeabilityReportProcedureContext, + input: ChargeabilityReportInput, +) { + return queryChargeabilityReport(ctx.db, input); +} + +export async function getChargeabilityReportDetail( + ctx: ChargeabilityReportProcedureContext, + input: ChargeabilityReportDetailInput, +) { + requirePermission( + { permissions: ctx.permissions ?? new Set() }, + PermissionKeys.VIEW_COSTS, + ); + const report = await queryChargeabilityReport(ctx.db, input); + return buildChargeabilityReportDetail(report, input); +} diff --git a/packages/api/src/router/chargeability-report.ts b/packages/api/src/router/chargeability-report.ts index dee4294..89e9355 100644 --- a/packages/api/src/router/chargeability-report.ts +++ b/packages/api/src/router/chargeability-report.ts @@ -1,318 +1,17 @@ +import { controllerProcedure, createTRPCRouter } from "../trpc.js"; import { - deriveResourceForecast, - calculateGroupChargeability, - calculateGroupTarget, - sumFte, - getMonthRange, - getMonthKeys, - type AssignmentSlice, -} from "@capakraken/engine"; -import type { PrismaClient } from "@capakraken/db"; -import type { WeekdayAvailability } from "@capakraken/shared"; -import { PermissionKey } from "@capakraken/shared"; -import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application"; -import { z } from "zod"; -import { createTRPCRouter, controllerProcedure, requirePermission } from "../trpc.js"; -import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js"; -import { - calculateEffectiveAvailableHours, - calculateEffectiveBookedHours, - loadResourceDailyAvailabilityContexts, -} from "../lib/resource-capacity.js"; - -function round1(value: number): number { - return Math.round(value * 10) / 10; -} - -const reportInputSchema = z.object({ - startMonth: z.string().regex(/^\d{4}-\d{2}$/), - endMonth: z.string().regex(/^\d{4}-\d{2}$/), - orgUnitId: z.string().optional(), - managementLevelGroupId: z.string().optional(), - countryId: z.string().optional(), - includeProposed: z.boolean().default(false), -}); - -const detailedReportInputSchema = reportInputSchema.extend({ - resourceQuery: z.string().optional(), - resourceLimit: z.number().int().min(1).max(100).optional(), -}); - -type ChargeabilityReportDbClient = Pick< - PrismaClient, - "assignment" | "resource" | "project" | "vacation" | "holidayCalendar" | "systemSettings" ->; - -async function queryChargeabilityReport( - db: ChargeabilityReportDbClient, - input: z.infer, -) { - const { startMonth, endMonth, includeProposed } = input; - - const [startYear, startMo] = startMonth.split("-").map(Number) as [number, number]; - const [endYear, endMo] = endMonth.split("-").map(Number) as [number, number]; - const rangeStart = getMonthRange(startYear, startMo).start; - const rangeEnd = getMonthRange(endYear, endMo).end; - const monthKeys = getMonthKeys(rangeStart, rangeEnd); - - const resourceWhere = { - isActive: true, - chgResponsibility: true, - departed: false, - rolledOff: false, - ...(input.orgUnitId ? { orgUnitId: input.orgUnitId } : {}), - ...(input.managementLevelGroupId ? { managementLevelGroupId: input.managementLevelGroupId } : {}), - ...(input.countryId ? { countryId: input.countryId } : {}), - }; - - const resources = await db.resource.findMany({ - where: resourceWhere, - select: { - id: true, - eid: true, - displayName: true, - fte: true, - availability: true, - countryId: true, - federalState: true, - metroCityId: true, - chargeabilityTarget: true, - country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } }, - orgUnit: { select: { id: true, name: true } }, - managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } }, - managementLevel: { select: { id: true, name: true } }, - metroCity: { select: { id: true, name: true } }, - }, - orderBy: { displayName: "asc" }, - }); - - if (resources.length === 0) { - return { - monthKeys, - resources: [], - groupTotals: monthKeys.map((key) => ({ - monthKey: key, - totalFte: 0, - chg: 0, - target: 0, - gap: 0, - })), - }; - } - - const resourceIds = resources.map((resource) => resource.id); - const allBookings = await listAssignmentBookings(db, { - startDate: rangeStart, - endDate: rangeEnd, - resourceIds, - }); - const availabilityContexts = await loadResourceDailyAvailabilityContexts( - db, - resources.map((resource) => ({ - id: resource.id, - availability: resource.availability as unknown as WeekdayAvailability, - countryId: resource.countryId, - countryCode: resource.country?.code, - federalState: resource.federalState, - metroCityId: resource.metroCityId, - metroCityName: resource.metroCity?.name, - })), - rangeStart, - rangeEnd, - ); - - const projectIds = [...new Set(allBookings.map((booking) => booking.projectId))]; - const projectUtilCats = projectIds.length > 0 - ? await db.project.findMany({ - where: { id: { in: projectIds } }, - select: { id: true, utilizationCategory: { select: { code: true } } }, - }) - : []; - const projectUtilCatMap = new Map( - projectUtilCats.map((project) => [project.id, project.utilizationCategory?.code ?? null]), - ); - - const assignments = allBookings - .filter((booking) => booking.resourceId !== null) - .filter((booking) => isChargeabilityActualBooking(booking, includeProposed)) - .map((booking) => ({ - resourceId: booking.resourceId!, - startDate: booking.startDate, - endDate: booking.endDate, - hoursPerDay: booking.hoursPerDay, - project: { - status: booking.project.status, - utilizationCategory: { code: projectUtilCatMap.get(booking.projectId) ?? null }, - }, - })); - - const resourceRows = await Promise.all(resources.map(async (resource) => { - const resourceAssignments = assignments.filter((assignment) => assignment.resourceId === resource.id); - const targetPct = resource.managementLevelGroup?.targetPercentage - ?? (resource.chargeabilityTarget / 100); - const availability = resource.availability as unknown as WeekdayAvailability; - const context = availabilityContexts.get(resource.id); - - const months = await Promise.all(monthKeys.map(async (key) => { - const [year, month] = key.split("-").map(Number) as [number, number]; - const { start: monthStart, end: monthEnd } = getMonthRange(year, month); - const availableHours = calculateEffectiveAvailableHours({ - availability, - periodStart: monthStart, - periodEnd: monthEnd, - context, - }); - const slices: AssignmentSlice[] = resourceAssignments.flatMap((assignment) => { - const totalChargeableHours = calculateEffectiveBookedHours({ - availability, - startDate: assignment.startDate, - endDate: assignment.endDate, - hoursPerDay: assignment.hoursPerDay, - periodStart: monthStart, - periodEnd: monthEnd, - context, - }); - if (totalChargeableHours <= 0) { - return []; - } - - const categoryCode = assignment.project.utilizationCategory?.code; - - return { - hoursPerDay: assignment.hoursPerDay, - workingDays: 0, - categoryCode: typeof categoryCode === "string" && categoryCode.length > 0 ? categoryCode : "Chg", - totalChargeableHours, - }; - }); - - const forecast = deriveResourceForecast({ - fte: resource.fte, - targetPercentage: targetPct, - assignments: slices, - sah: availableHours, - }); - - return { - monthKey: key, - sah: availableHours, - ...forecast, - }; - })); - - return { - id: resource.id, - eid: resource.eid, - displayName: resource.displayName, - fte: resource.fte, - country: resource.country?.code ?? null, - city: resource.metroCity?.name ?? null, - orgUnit: resource.orgUnit?.name ?? null, - mgmtGroup: resource.managementLevelGroup?.name ?? null, - mgmtLevel: resource.managementLevel?.name ?? null, - targetPct, - months, - }; - })); - - const groupTotals = monthKeys.map((key, monthIdx) => { - const groupInputs = resourceRows.map((resource) => ({ - fte: resource.fte, - chargeability: resource.months[monthIdx]!.chg, - })); - const targetInputs = resourceRows.map((resource) => ({ - fte: resource.fte, - targetPercentage: resource.targetPct, - })); - - const chg = calculateGroupChargeability(groupInputs); - const target = calculateGroupTarget(targetInputs); - - return { - monthKey: key, - totalFte: sumFte(resourceRows), - chg, - target, - gap: chg - target, - }; - }); - - const directory = await getAnonymizationDirectory(db); - - return { - monthKeys, - resources: anonymizeResources(resourceRows, directory), - groupTotals, - }; -} - -function buildChargeabilityReportDetail( - report: Awaited>, - input: z.infer, -) { - const resourceQuery = input.resourceQuery?.trim().toLowerCase(); - const matchingResources = resourceQuery - ? report.resources.filter((resource) => ( - resource.displayName.toLowerCase().includes(resourceQuery) - || resource.eid.toLowerCase().includes(resourceQuery) - )) - : report.resources; - const resourceLimit = Math.min(Math.max(input.resourceLimit ?? 25, 1), 100); - const resources = matchingResources.slice(0, resourceLimit).map((resource) => ({ - id: resource.id, - eid: resource.eid, - displayName: resource.displayName, - fte: round1(resource.fte), - country: resource.country, - city: resource.city, - orgUnit: resource.orgUnit, - managementLevelGroup: resource.mgmtGroup, - managementLevel: resource.mgmtLevel, - targetPct: round1(resource.targetPct * 100), - months: resource.months.map((month) => ({ - monthKey: month.monthKey, - sah: round1(month.sah), - chargeabilityPct: round1(month.chg * 100), - targetPct: round1(resource.targetPct * 100), - gapPct: round1((month.chg - resource.targetPct) * 100), - })), - })); - - return { - filters: { - startMonth: input.startMonth, - endMonth: input.endMonth, - orgUnitId: input.orgUnitId ?? null, - managementLevelGroupId: input.managementLevelGroupId ?? null, - countryId: input.countryId ?? null, - includeProposed: input.includeProposed ?? false, - resourceQuery: input.resourceQuery ?? null, - }, - monthKeys: report.monthKeys, - groupTotals: report.groupTotals.map((group) => ({ - monthKey: group.monthKey, - totalFte: round1(group.totalFte), - chargeabilityPct: round1(group.chg * 100), - targetPct: round1(group.target * 100), - gapPct: round1(group.gap * 100), - })), - resourceCount: matchingResources.length, - returnedResourceCount: resources.length, - truncated: resources.length < matchingResources.length, - resources, - }; -} + chargeabilityReportDetailInputSchema, + chargeabilityReportInputSchema, + getChargeabilityReport, + getChargeabilityReportDetail, +} from "./chargeability-report-procedure-support.js"; export const chargeabilityReportRouter = createTRPCRouter({ getReport: controllerProcedure - .input(reportInputSchema) - .query(async ({ ctx, input }) => queryChargeabilityReport(ctx.db, input)), + .input(chargeabilityReportInputSchema) + .query(({ ctx, input }) => getChargeabilityReport(ctx, input)), getDetail: controllerProcedure - .input(detailedReportInputSchema) - .query(async ({ ctx, input }) => { - requirePermission(ctx, PermissionKey.VIEW_COSTS); - const report = await queryChargeabilityReport(ctx.db, input); - return buildChargeabilityReportDetail(report, input); - }), + .input(chargeabilityReportDetailInputSchema) + .query(({ ctx, input }) => getChargeabilityReportDetail(ctx, input)), });