import { calculateAllocation, deriveResourceForecast, getMonthRange, DEFAULT_CALCULATION_RULES, type AssignmentSlice, } from "@capakraken/engine"; import { VacationStatus } from "@capakraken/db"; import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared"; import { z } from "zod"; import { asHolidayResolverDb, collectHolidayAvailability, getResolvedCalendarHolidays, } from "../lib/holiday-availability.js"; import { calculateEffectiveAvailableHours, countEffectiveWorkingDays, loadResourceDailyAvailabilityContexts, } from "../lib/resource-capacity.js"; import { assertCanReadResource } from "../lib/resource-access.js"; import { protectedProcedure, resourceOverviewProcedure } from "../trpc.js"; import { ResourceDirectoryQuerySchema, ResourceListQuerySchema, listStaffResources, mapResourceSummary, mapResourceSummaryDetail, readResourceSummariesSnapshot, readResourceSummaryDetailsSnapshot, } from "./resource-read-shared.js"; 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); } function formatResolvedHolidaySummary(holiday: { date: string; name: string; scope: string; calendarName: string | null; sourceType?: string | null; }) { return { date: holiday.date, name: holiday.name, scope: holiday.scope, calendarName: holiday.calendarName ?? "Built-in", sourceType: holiday.sourceType ?? "system", }; } function summarizeResolvedHolidaySummary(holidays: Array>) { const byScope = new Map(); const bySourceType = new Map(); const byCalendar = new Map(); for (const holiday of holidays) { byScope.set(holiday.scope, (byScope.get(holiday.scope) ?? 0) + 1); bySourceType.set(holiday.sourceType, (bySourceType.get(holiday.sourceType) ?? 0) + 1); byCalendar.set(holiday.calendarName, (byCalendar.get(holiday.calendarName) ?? 0) + 1); } return { byScope: [...byScope.entries()] .sort(([left], [right]) => left.localeCompare(right)) .map(([scope, count]) => ({ scope, count })), bySourceType: [...bySourceType.entries()] .sort(([left], [right]) => left.localeCompare(right)) .map(([sourceType, count]) => ({ sourceType, count })), byCalendar: [...byCalendar.entries()] .sort(([left], [right]) => left.localeCompare(right)) .map(([calendarName, count]) => ({ calendarName, count })), }; } function round1(value: number): number { return Math.round(value * 10) / 10; } function averagePerWorkingDay(totalHours: number, workingDays: number): number { return workingDays > 0 ? round1(totalHours / workingDays) : 0; } export const resourceSummaryReadProcedures = { resolveResponsiblePersonName: resourceOverviewProcedure .input(z.object({ name: z.string() })) .query(async ({ ctx, input }) => { const exact = await ctx.db.resource.findFirst({ where: { displayName: { equals: input.name, mode: "insensitive" }, isActive: true }, select: { displayName: true }, }); if (exact) { return { status: "resolved" as const, displayName: exact.displayName, }; } const candidates = await ctx.db.resource.findMany({ where: { displayName: { contains: input.name, mode: "insensitive" }, isActive: true }, select: { displayName: true, eid: true }, take: 5, }); if (candidates.length === 1) { return { status: "resolved" as const, displayName: candidates[0]!.displayName, }; } if (candidates.length > 1) { return { status: "ambiguous" as const, message: `Multiple resources match "${input.name}": ${candidates.map((candidate) => `${candidate.displayName} (${candidate.eid})`).join(", ")}. Please specify the exact name.`, candidates, }; } return { status: "missing" as const, message: `No active resource found matching "${input.name}". The responsible person must be an existing resource.`, candidates: [], }; }), getChargeabilitySummary: protectedProcedure .input(z.object({ resourceId: z.string(), month: z.string().regex(/^\d{4}-\d{2}$/), })) .query(async ({ ctx, input }) => { await assertCanReadResource( ctx, input.resourceId, "You can only view chargeability details for your own resource unless you have staff access", ); 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 targetRatio = resource.managementLevelGroup?.targetPercentage ?? (resource.chargeabilityTarget / 100); const availability = resource.availability as WeekdayAvailability | null; const weeklyAvailability: WeekdayAvailability = availability ?? { 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, orderType: 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 absenceDays = holidayAvailability.absenceDays; 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 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 absenceDayEquivalent = absenceDays.reduce((sum, absence) => { if (absence.type === "PUBLIC_HOLIDAY") { return sum; } return sum + (absence.isHalfDay ? 0.5 : 1); }, 0); 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 slices: AssignmentSlice[] = []; const assignmentBreakdown: Array<{ project: string; code: string; hours: number; status: string; }> = []; let totalBookedHours = 0; for (const assignment of assignments) { const overlapStart = new Date(Math.max(monthStart.getTime(), assignment.startDate.getTime())); const overlapEnd = new Date(Math.min(monthEnd.getTime(), assignment.endDate.getTime())); const categoryCode = assignment.project.utilizationCategory?.code ?? "Chg"; const calcResult = calculateAllocation({ lcrCents: resource.lcrCents, hoursPerDay: assignment.hoursPerDay, startDate: overlapStart, endDate: overlapEnd, availability: weeklyAvailability, absenceDays, calculationRules: calcRules, orderType: assignment.project.orderType, projectId: assignment.project.id, }); if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) { continue; } totalBookedHours += calcResult.totalHours; assignmentBreakdown.push({ project: assignment.project.name, code: assignment.project.shortCode, hours: round1(calcResult.totalHours), status: assignment.status, }); slices.push({ hoursPerDay: assignment.hoursPerDay, workingDays: calcResult.workingDays, categoryCode, ...(calcResult.totalChargeableHours !== undefined ? { totalChargeableHours: calcResult.totalChargeableHours } : {}), }); } const forecast = deriveResourceForecast({ fte: resource.fte, targetPercentage: targetRatio, assignments: slices, sah: effectiveAvailableHours, }); const formattedHolidays = resolvedHolidays.map((holiday) => formatResolvedHolidaySummary({ ...holiday, calendarName: holiday.calendarName ?? "Built-in", sourceType: holiday.sourceType ?? "system", })); const workingDays = round1(effectiveWorkingDays); const baseWorkingDaysRounded = round1(baseWorkingDays); const baseAvailableHoursRounded = round1(baseAvailableHours); const availableHours = round1(effectiveAvailableHours); const bookedHours = round1(totalBookedHours); const targetPct = round1(targetRatio * 100); const targetHours = availableHours > 0 ? round1((availableHours * targetPct) / 100) : 0; const chargeabilityPct = round1(forecast.chg * 100); const unassignedHours = round1(Math.max(0, availableHours - bookedHours)); return { resource: resource.displayName, eid: resource.eid, month: input.month, periodStart: monthStart.toISOString().slice(0, 10), periodEnd: monthEnd.toISOString().slice(0, 10), fte: round1(resource.fte), target: `${targetPct}%`, targetPct, targetHours, workingDays, baseWorkingDays: baseWorkingDaysRounded, locationContext: { countryCode: resource.country?.code ?? null, country: resource.country?.name ?? resource.country?.code ?? null, federalState: resource.federalState ?? null, metroCity: resource.metroCity?.name ?? null, }, baseAvailableHours: baseAvailableHoursRounded, availableHours, bookedHours, unassignedHours, chargeability: `${chargeabilityPct}%`, chargeabilityPct, onTarget: chargeabilityPct >= targetPct, holidaySummary: { count: formattedHolidays.length, workdayCount: round1(publicHolidayWorkdayCount), hoursDeduction: round1(publicHolidayHoursDeduction), holidays: formattedHolidays, breakdown: summarizeResolvedHolidaySummary(formattedHolidays), }, absenceSummary: { dayEquivalent: round1(absenceDayEquivalent), hoursDeduction: round1(absenceHoursDeduction), }, capacityBreakdown: { formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours", baseAvailableHours: baseAvailableHoursRounded, holidayHoursDeduction: round1(publicHolidayHoursDeduction), absenceHoursDeduction: round1(absenceHoursDeduction), availableHours, }, averages: { availableHoursPerWorkingDay: averagePerWorkingDay(availableHours, workingDays), bookedHoursPerWorkingDay: averagePerWorkingDay(bookedHours, workingDays), remainingHoursPerWorkingDay: averagePerWorkingDay(Math.max(0, availableHours - bookedHours), workingDays), }, allocations: assignmentBreakdown, scheduleContext: { dailyWorkingHours: dailyHours, hasScheduleRules: Boolean(scheduleRules), }, }; }), listSummaries: resourceOverviewProcedure .input(z.object({ search: z.string().optional(), country: z.string().optional(), metroCity: z.string().optional(), orgUnit: z.string().optional(), roleName: z.string().optional(), isActive: z.boolean().optional().default(true), limit: z.number().int().min(1).max(100).default(50), })) .query(async ({ ctx, input }) => { const resources = await readResourceSummariesSnapshot(ctx, { isActive: input.isActive, limit: input.limit, ...(input.search ? { search: input.search } : {}), ...(input.country ? { country: input.country } : {}), ...(input.metroCity ? { metroCity: input.metroCity } : {}), ...(input.orgUnit ? { orgUnit: input.orgUnit } : {}), ...(input.roleName ? { roleName: input.roleName } : {}), }); return resources.map(mapResourceSummary); }), listSummariesDetail: resourceOverviewProcedure .input(z.object({ search: z.string().optional(), country: z.string().optional(), metroCity: z.string().optional(), orgUnit: z.string().optional(), roleName: z.string().optional(), isActive: z.boolean().optional().default(true), limit: z.number().int().min(1).max(100).default(50), })) .query(async ({ ctx, input }) => { const resources = await readResourceSummaryDetailsSnapshot(ctx, { isActive: input.isActive, limit: input.limit, ...(input.search ? { search: input.search } : {}), ...(input.country ? { country: input.country } : {}), ...(input.metroCity ? { metroCity: input.metroCity } : {}), ...(input.orgUnit ? { orgUnit: input.orgUnit } : {}), ...(input.roleName ? { roleName: input.roleName } : {}), }); return resources.map(mapResourceSummaryDetail); }), directory: protectedProcedure .input(ResourceDirectoryQuerySchema) .query(async ({ ctx, input }) => { const { chapter, chapters, isActive, search, eids, countryIds, excludedCountryIds, includeWithoutCountry, resourceTypes, excludedResourceTypes, includeWithoutResourceType, rolledOff, departed, page, limit, cursor, } = input; const resources = await listStaffResources(ctx, { chapter, chapters, isActive, search, eids, countryIds, excludedCountryIds, includeWithoutCountry, resourceTypes, excludedResourceTypes, includeWithoutResourceType, rolledOff, departed, page, limit, cursor, includeRoles: false, }); return { ...resources, resources: resources.resources.map((resource) => ({ id: resource.id, eid: resource.eid, displayName: resource.displayName, chapter: resource.chapter, isActive: resource.isActive, })), }; }), listStaff: resourceOverviewProcedure .input(ResourceListQuerySchema) .query(async ({ ctx, input }) => listStaffResources(ctx, input)), chapters: protectedProcedure.query(async ({ ctx }) => { const resources = await ctx.db.resource.findMany({ where: { isActive: true, chapter: { not: null } }, select: { chapter: true }, distinct: ["chapter"], orderBy: { chapter: "asc" }, }); return resources.map((resource) => resource.chapter as string); }), };