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, }; } export const chargeabilityReportRouter = createTRPCRouter({ getReport: controllerProcedure .input(reportInputSchema) .query(async ({ ctx, input }) => queryChargeabilityReport(ctx.db, 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); }), });