import { calculateSAH, calculateAllocation, deriveResourceForecast, getMonthRange, DEFAULT_CALCULATION_RULES, type AssignmentSlice, } from "@capakraken/engine"; import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared"; import { VacationStatus } from "@capakraken/db"; import type { TRPCContext } from "../trpc.js"; import { fmtEur } from "../lib/format-utils.js"; import { asHolidayResolverDb, collectHolidayAvailability, getResolvedCalendarHolidays, } from "../lib/holiday-availability.js"; import { calculateEffectiveAvailableHours, countEffectiveWorkingDays, loadResourceDailyAvailabilityContexts, } from "../lib/resource-capacity.js"; import { readResourceBudgetGraph } from "./computation-graph-resource-budget.js"; import { buildResourceGraphSnapshot } from "./computation-graph-resource-graph.js"; type ResourceGraphInput = { resourceId: string; month: string; }; function getAvailabilityHoursForDate( availability: WeekdayAvailability, date: Date, ): number { const dayKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][date.getUTCDay()] as keyof WeekdayAvailability; return availability[dayKey] ?? 0; } function sumAvailabilityHoursForDates( availability: WeekdayAvailability, dates: Date[], ): number { return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0); } export async function readResourceGraphSnapshot( ctx: { db: TRPCContext["db"] }, input: ResourceGraphInput, ) { const [year, month] = input.month.split("-").map(Number) as [number, number]; const { start: monthStart, end: monthEnd } = getMonthRange(year, month); const resource = await ctx.db.resource.findUniqueOrThrow({ where: { id: input.resourceId }, select: { id: true, displayName: true, eid: true, fte: true, lcrCents: true, chargeabilityTarget: true, countryId: true, federalState: true, metroCityId: true, availability: true, country: { select: { id: true, code: true, name: true, dailyWorkingHours: true, scheduleRules: true } }, metroCity: { select: { id: true, name: true } }, managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } }, }, }); const dailyHours = resource.country?.dailyWorkingHours ?? 8; const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null; const targetPct = resource.managementLevelGroup?.targetPercentage ?? (resource.chargeabilityTarget / 100); const avail = resource.availability as WeekdayAvailability | null; const weeklyAvailability: WeekdayAvailability = avail ?? { monday: dailyHours, tuesday: dailyHours, wednesday: dailyHours, thursday: dailyHours, friday: dailyHours, saturday: 0, sunday: 0, }; const assignments = await ctx.db.assignment.findMany({ where: { resourceId: input.resourceId, startDate: { lte: monthEnd }, endDate: { gte: monthStart }, status: { in: ["CONFIRMED", "ACTIVE", "PROPOSED"] }, }, select: { id: true, hoursPerDay: true, startDate: true, endDate: true, dailyCostCents: true, status: true, project: { select: { id: true, name: true, shortCode: true, budgetCents: true, winProbability: true, utilizationCategory: { select: { code: true } }, }, }, }, }); const vacations = await ctx.db.vacation.findMany({ where: { resourceId: input.resourceId, status: VacationStatus.APPROVED, startDate: { lte: monthEnd }, endDate: { gte: monthStart }, }, select: { startDate: true, endDate: true, type: true, isHalfDay: true }, }); const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), { periodStart: monthStart, periodEnd: monthEnd, countryId: resource.countryId, countryCode: resource.country?.code, federalState: resource.federalState, metroCityId: resource.metroCityId, metroCityName: resource.metroCity?.name, }); const holidayAvailability = collectHolidayAvailability({ vacations, periodStart: monthStart, periodEnd: monthEnd, countryCode: resource.country?.code, federalState: resource.federalState, metroCityName: resource.metroCity?.name, resolvedHolidayStrings: resolvedHolidays.map((holiday) => holiday.date), }); const publicHolidayStrings = holidayAvailability.publicHolidayStrings; const absenceDateStrings = holidayAvailability.absenceDateStrings; const absenceDays = holidayAvailability.absenceDays; const halfDayCount = absenceDays.filter((absence) => absence.isHalfDay).length; const vacationDayCount = absenceDays.filter((absence) => absence.type === "VACATION").length; const sickDayCount = absenceDays.filter((absence) => absence.type === "SICK").length; const publicHolidayCount = resolvedHolidays.length; const absenceDayEquivalent = absenceDays.reduce((sum, absence) => { if (absence.type === "PUBLIC_HOLIDAY") { return sum; } return sum + (absence.isHalfDay ? 0.5 : 1); }, 0); const contexts = await loadResourceDailyAvailabilityContexts( ctx.db, [{ id: resource.id, availability: weeklyAvailability, countryId: resource.countryId, countryCode: resource.country?.code, federalState: resource.federalState, metroCityId: resource.metroCityId, metroCityName: resource.metroCity?.name, }], monthStart, monthEnd, ); const availabilityContext = contexts.get(resource.id); 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 } const sahResult = calculateSAH({ dailyWorkingHours: dailyHours, scheduleRules, fte: resource.fte, periodStart: monthStart, periodEnd: monthEnd, publicHolidays: publicHolidayStrings, absenceDays: absenceDateStrings, }); const baseWorkingDays = countEffectiveWorkingDays({ availability: weeklyAvailability, periodStart: monthStart, periodEnd: monthEnd, context: undefined, }); const effectiveWorkingDays = countEffectiveWorkingDays({ availability: weeklyAvailability, periodStart: monthStart, periodEnd: monthEnd, context: availabilityContext, }); const baseAvailableHours = calculateEffectiveAvailableHours({ availability: weeklyAvailability, periodStart: monthStart, periodEnd: monthEnd, context: undefined, }); const effectiveAvailableHours = calculateEffectiveAvailableHours({ availability: weeklyAvailability, periodStart: monthStart, periodEnd: monthEnd, context: availabilityContext, }); const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`)); const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => ( count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0) ), 0); const publicHolidayHoursDeduction = sumAvailabilityHoursForDates( weeklyAvailability, publicHolidayDates, ); const absenceHoursDeduction = absenceDays.reduce((sum, absence) => { if (absence.type === "PUBLIC_HOLIDAY") { return sum; } const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date); return sum + baseHours * (absence.isHalfDay ? 0.5 : 1); }, 0); const effectiveHoursPerWorkingDay = effectiveWorkingDays > 0 ? effectiveAvailableHours / effectiveWorkingDays : 0; const holidayScopeSummary = [ resource.country?.code ?? "—", resource.federalState ?? "—", resource.metroCity?.name ?? "—", ].join(" / "); const holidayExamples = resolvedHolidays.length > 0 ? resolvedHolidays.slice(0, 4).map((holiday) => `${holiday.date} ${holiday.name}`).join(", ") : "none"; const holidayScopeBreakdown = resolvedHolidays.reduce>((counts, holiday) => { counts[holiday.scope] = (counts[holiday.scope] ?? 0) + 1; return counts; }, {}); const slices: AssignmentSlice[] = []; const assignmentBreakdown: Array<{ id: string; projectId: string; projectName: string; projectCode: string; status: string; bookedHours: number; }> = []; let totalAllocHours = 0; let totalAllocCostCents = 0; let totalChargeableHours = 0; let totalProjectCostCents = 0; let hasRulesEffect = false; for (const a of assignments) { const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime())); const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime())); const categoryCode = a.project.utilizationCategory?.code ?? "Chg"; const calcResult = calculateAllocation({ lcrCents: resource.lcrCents, hoursPerDay: a.hoursPerDay, startDate: overlapStart, endDate: overlapEnd, availability: weeklyAvailability, absenceDays, calculationRules: calcRules, }); if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) continue; totalAllocHours += calcResult.totalHours; totalAllocCostCents += calcResult.totalCostCents; assignmentBreakdown.push({ id: a.id, projectId: a.project.id, projectName: a.project.name, projectCode: a.project.shortCode, status: a.status, bookedHours: calcResult.totalHours, }); if (calcResult.totalChargeableHours !== undefined) { totalChargeableHours += calcResult.totalChargeableHours; totalProjectCostCents += calcResult.totalProjectCostCents ?? calcResult.totalCostCents; hasRulesEffect = true; } else { totalChargeableHours += calcResult.totalHours; totalProjectCostCents += calcResult.totalCostCents; } slices.push({ hoursPerDay: a.hoursPerDay, workingDays: calcResult.workingDays, categoryCode, ...(calcResult.totalChargeableHours !== undefined ? { totalChargeableHours: calcResult.totalChargeableHours } : {}), }); } const forecast = deriveResourceForecast({ fte: resource.fte, targetPercentage: targetPct, assignments: slices, sah: effectiveAvailableHours, }); const { nodes: budgetNodes, links: budgetLinks } = await readResourceBudgetGraph( ctx.db, assignments, monthStart, monthEnd, ); const dailyCostCents = assignments.length > 0 ? Math.round(assignments[0]!.hoursPerDay * resource.lcrCents) : 0; const avgHoursPerDay = assignments.length > 0 ? assignments.reduce((sum, a) => sum + a.hoursPerDay, 0) / assignments.length : 0; const totalWorkingDaysInMonth = assignments.reduce((sum, a) => { 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: resource.lcrCents, hoursPerDay: a.hoursPerDay, startDate: overlapStart, endDate: overlapEnd, availability: weeklyAvailability, absenceDays, calculationRules: calcRules, }); return sum + calcResult.workingDays; }, 0); const utilizationPct = effectiveAvailableHours > 0 ? (totalAllocHours / effectiveAvailableHours) * 100 : 0; const chargeableHours = forecast.chg * effectiveAvailableHours; return buildResourceGraphSnapshot({ month: input.month, resource, dailyHours, scheduleRules, targetPct, weeklyAvailability, holidayScopeSummary, holidayExamples, holidayScopeBreakdown, calcRulesCount: calcRules.length, assignmentCount: assignments.length, absenceCount: absenceDateStrings.length, vacationDayCount, sickDayCount, halfDayCount, publicHolidayCount, publicHolidayWorkdayCount, publicHolidayHoursDeduction, absenceHoursDeduction, sahCalendarDays: sahResult.calendarDays, sahWeekendDays: sahResult.weekendDays, baseWorkingDays, effectiveWorkingDays, baseAvailableHours, effectiveAvailableHours, effectiveHoursPerWorkingDay, totalWorkingDaysInMonth, totalAllocHours, totalAllocCostCents, totalChargeableHours, totalProjectCostCents, hasRulesEffect, dailyCostCents, avgHoursPerDay, utilizationPct, forecast, chargeableHours, budgetNodes, budgetLinks, resolvedHolidays: resolvedHolidays.map((holiday) => ({ date: holiday.date, name: holiday.name, scope: holiday.scope, calendarName: holiday.calendarName, sourceType: holiday.sourceType ?? null, })), assignmentBreakdown, absenceDayEquivalent, }); }