import { deriveResourceForecast, calculateGroupChargeability, calculateGroupTarget, sumFte, getMonthRange, getMonthKeys, countWorkingDaysInOverlap, calculateSAH, calculateAllocation, DEFAULT_CALCULATION_RULES, type AssignmentSlice, } from "@planarchy/engine"; import type { CalculationRule, AbsenceDay } from "@planarchy/shared"; import type { SpainScheduleRule } from "@planarchy/shared"; import { isChargeabilityActualBooking, listAssignmentBookings } from "@planarchy/application"; import { VacationStatus } from "@planarchy/db"; import { z } from "zod"; import { createTRPCRouter, controllerProcedure } from "../trpc.js"; import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js"; export const chargeabilityReportRouter = createTRPCRouter({ getReport: controllerProcedure .input( z.object({ startMonth: z.string().regex(/^\d{4}-\d{2}$/), // "2026-01" 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), }), ) .query(async ({ ctx, input }) => { const { startMonth, endMonth, includeProposed } = input; // Parse month range 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); // Fetch resources with filters 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 ctx.db.resource.findMany({ where: resourceWhere, select: { id: true, eid: true, displayName: true, fte: 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, })), }; } // Fetch all bookings (assignments + legacy allocations) in the date range const resourceIds = resources.map((r) => r.id); const allBookings = await listAssignmentBookings(ctx.db, { startDate: rangeStart, endDate: rangeEnd, resourceIds, }); // Enrich with utilization category — fetch project util categories in bulk const projectIds = [...new Set(allBookings.map((b) => b.projectId))]; const projectUtilCats = projectIds.length > 0 ? await ctx.db.project.findMany({ where: { id: { in: projectIds } }, select: { id: true, utilizationCategory: { select: { code: true } } }, }) : []; const projectUtilCatMap = new Map( projectUtilCats.map((p) => [p.id, p.utilizationCategory?.code ?? null]), ); // Normalize bookings to a common shape const assignments = allBookings .filter((booking) => booking.resourceId !== null) .filter((booking) => isChargeabilityActualBooking(booking, includeProposed)) .map((b) => ({ resourceId: b.resourceId!, startDate: b.startDate, endDate: b.endDate, hoursPerDay: b.hoursPerDay, project: { status: b.project.status, utilizationCategory: { code: projectUtilCatMap.get(b.projectId) ?? null }, }, })); // Fetch vacations/absences in the range (including type for rules engine) const vacations = await ctx.db.vacation.findMany({ where: { resourceId: { in: resourceIds }, status: VacationStatus.APPROVED, startDate: { lte: rangeEnd }, endDate: { gte: rangeStart }, }, select: { resourceId: true, startDate: true, endDate: true, type: true, isHalfDay: true, }, }); // Load calculation rules for chargeability adjustments let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES; try { const dbRules = await ctx.db.calculationRule.findMany({ where: { isActive: true }, orderBy: [{ priority: "desc" }], }); if (dbRules.length > 0) { calcRules = dbRules as unknown as CalculationRule[]; } } catch { // table may not exist yet } // Build per-resource, per-month forecasts const resourceRows = resources.map((resource) => { const resourceAssignments = assignments.filter((a) => a.resourceId === resource.id); const resourceVacations = vacations.filter((v) => v.resourceId === resource.id); // Prefer mgmt level group target; fall back to legacy chargeabilityTarget (0-100 → 0-1) const targetPct = resource.managementLevelGroup?.targetPercentage ?? (resource.chargeabilityTarget / 100); const dailyHours = resource.country?.dailyWorkingHours ?? 8; const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null; const months = monthKeys.map((key) => { const [y, m] = key.split("-").map(Number) as [number, number]; const { start: monthStart, end: monthEnd } = getMonthRange(y, m); // Compute absence days for SAH const absenceDates: string[] = []; for (const v of resourceVacations) { const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime())); const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime())); if (vStart > vEnd) continue; const cursor = new Date(vStart); cursor.setUTCHours(0, 0, 0, 0); const endNorm = new Date(vEnd); endNorm.setUTCHours(0, 0, 0, 0); while (cursor <= endNorm) { absenceDates.push(cursor.toISOString().slice(0, 10)); cursor.setUTCDate(cursor.getUTCDate() + 1); } } // Calculate SAH for this resource+month const sahResult = calculateSAH({ dailyWorkingHours: dailyHours, scheduleRules, fte: resource.fte, periodStart: monthStart, periodEnd: monthEnd, publicHolidays: [], // TODO: integrate public holidays from country absenceDays: absenceDates, }); // Build typed absence days for this resource in this month const monthAbsenceDays: AbsenceDay[] = []; for (const v of resourceVacations) { const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime())); const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime())); if (vStart > vEnd) continue; const absCursor = new Date(vStart); absCursor.setUTCHours(0, 0, 0, 0); const absEndNorm = new Date(vEnd); absEndNorm.setUTCHours(0, 0, 0, 0); const triggerType = v.type === "SICK" ? "SICK" as const : v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const : "VACATION" as const; while (absCursor <= absEndNorm) { monthAbsenceDays.push({ date: new Date(absCursor), type: triggerType, ...(v.isHalfDay ? { isHalfDay: true } : {}), }); absCursor.setUTCDate(absCursor.getUTCDate() + 1); } } // Build assignment slices for this month, using rules to compute chargeable hours const slices: AssignmentSlice[] = []; for (const a of resourceAssignments) { const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate); if (workingDays <= 0) continue; const categoryCode = a.project.utilizationCategory?.code ?? "Chg"; // If there are absences and rules, compute rules-adjusted chargeable hours if (monthAbsenceDays.length > 0) { const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime())); const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime())); const calcResult = calculateAllocation({ lcrCents: 0, // we only need hours, not costs hoursPerDay: a.hoursPerDay, startDate: overlapStart, endDate: overlapEnd, availability: { monday: dailyHours, tuesday: dailyHours, wednesday: dailyHours, thursday: dailyHours, friday: dailyHours, saturday: 0, sunday: 0 }, absenceDays: monthAbsenceDays, calculationRules: calcRules, }); slices.push({ hoursPerDay: a.hoursPerDay, workingDays, categoryCode, ...(calcResult.totalChargeableHours !== undefined ? { totalChargeableHours: calcResult.totalChargeableHours } : {}), }); } else { slices.push({ hoursPerDay: a.hoursPerDay, workingDays, categoryCode, }); } } const forecast = deriveResourceForecast({ fte: resource.fte, targetPercentage: targetPct, assignments: slices, sah: sahResult.standardAvailableHours, }); return { monthKey: key, sah: sahResult.standardAvailableHours, ...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, }; }); // Compute group totals per month const groupTotals = monthKeys.map((key, monthIdx) => { const groupInputs = resourceRows.map((r) => ({ fte: r.fte, chargeability: r.months[monthIdx]!.chg, })); const targetInputs = resourceRows.map((r) => ({ fte: r.fte, targetPercentage: r.targetPct, })); const chg = calculateGroupChargeability(groupInputs); const target = calculateGroupTarget(targetInputs); return { monthKey: key, totalFte: sumFte(resourceRows), chg, target, gap: chg - target, }; }); const directory = await getAnonymizationDirectory(ctx.db); return { monthKeys, resources: anonymizeResources(resourceRows, directory), groupTotals, }; }), });