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 ChargeabilityExplainabilityInput = ChargeabilityReportInput & { resourceQuery?: string | undefined; resourceLimit?: number | undefined; }; type ChargeabilityReportDbClient = Pick< PrismaClient, "assignment" | "resource" | "project" | "vacation" | "holidayCalendar" | "systemSettings" >; const CHARGEABILITY_LOCATION_FIELDS = [ "country", "federalState", "city", "orgUnit", "managementLevelGroup", "managementLevel", ] as const; const CHARGEABILITY_DERIVATION_FIELDS = [ "baseAvailableHours", "publicHolidayCount", "publicHolidayWorkdayCount", "publicHolidayHoursDeduction", "absenceDayEquivalent", "absenceHoursDeduction", "effectiveAvailableHours", ] as const; function buildChargeabilityExplainability(input: ChargeabilityExplainabilityInput) { const activeFilters = [ ...(input.orgUnitId ? ["orgUnitId"] : []), ...(input.managementLevelGroupId ? ["managementLevelGroupId"] : []), ...(input.countryId ? ["countryId"] : []), ...(input.resourceQuery ? ["resourceQuery"] : []), ...(input.includeProposed ? ["includeProposed"] : []), ]; return { locationFields: [...CHARGEABILITY_LOCATION_FIELDS], monthDerivationFields: [...CHARGEABILITY_DERIVATION_FIELDS], activeFilters, formulas: { sah: "baseAvailableHours - publicHolidayHoursDeduction - absenceHoursDeduction = effectiveAvailableHours", chargeabilityPct: "chargeabilityHours / sahHours", targetHours: "sahHours * targetPct", gapHours: "chargeabilityHours - targetHours", }, notes: [ "Location fields explain why two resources can have different SAH in the same month because country, federal state, and city holidays may differ.", "Holiday deductions and absence deductions are tracked separately; absence does not deduct days that are already public holidays.", "Include proposed work changes chargeability ratios and hours, but it does not change holiday or absence-based SAH derivation.", ], }; } 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, })), explainability: buildChargeabilityExplainability(input), }; } 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, explainability: buildChargeabilityExplainability(input), }; } 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, explainability: buildChargeabilityExplainability(input), 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); }