import { listAssignmentBookings } from "@capakraken/application"; import { MILLISECONDS_PER_DAY, type WeekdayAvailability } from "@capakraken/shared"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { calculateEffectiveAvailableHours, calculateEffectiveBookedHours, countEffectiveWorkingDays, loadResourceDailyAvailabilityContexts, } from "../lib/resource-capacity.js"; import { planningReadProcedure } from "../trpc.js"; import { ACTIVE_STATUSES, averagePerWorkingDay, calculateAllocatedHoursForDay, getEffectiveDayAvailability, round1, toIsoDate, } from "./staffing-shared.js"; const SearchCapacityInputSchema = z.object({ startDate: z.coerce.date(), endDate: z.coerce.date(), minHoursPerDay: z.number().optional().default(4), roleName: z.string().optional(), chapter: z.string().optional(), limit: z.number().int().min(1).max(100).optional().default(20), }); export const staffingCapacityReadProcedures = { searchCapacity: planningReadProcedure .input( SearchCapacityInputSchema as z.ZodType< z.infer, z.ZodTypeDef, z.input >, ) .query(async ({ ctx, input }) => { const where: Record = { isActive: true }; if (input.roleName) { where.areaRole = { name: { contains: input.roleName, mode: "insensitive" } }; } if (input.chapter) { where.chapter = { contains: input.chapter, mode: "insensitive" }; } const resources = await ctx.db.resource.findMany({ where, select: { id: true, displayName: true, eid: true, fte: true, availability: true, countryId: true, federalState: true, metroCityId: true, country: { select: { code: true } }, metroCity: { select: { name: true } }, areaRole: { select: { name: true } }, chapter: true, }, take: 100, }); const bookings = await listAssignmentBookings(ctx.db, { startDate: input.startDate, endDate: input.endDate, resourceIds: resources.map((resource) => resource.id), }); const bookingsByResourceId = new Map(); for (const booking of bookings) { if (!booking.resourceId) { continue; } const existing = bookingsByResourceId.get(booking.resourceId) ?? []; existing.push(booking); bookingsByResourceId.set(booking.resourceId, existing); } const contexts = await loadResourceDailyAvailabilityContexts( ctx.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, })), input.startDate, input.endDate, ); const results = resources .map((resource) => { const availability = resource.availability as unknown as WeekdayAvailability; const context = contexts.get(resource.id); const workingDays = countEffectiveWorkingDays({ availability, periodStart: input.startDate, periodEnd: input.endDate, context, }); const availableHours = calculateEffectiveAvailableHours({ availability, periodStart: input.startDate, periodEnd: input.endDate, context, }); const bookedHours = (bookingsByResourceId.get(resource.id) ?? []).reduce( (sum, booking) => sum + calculateEffectiveBookedHours({ availability, startDate: booking.startDate, endDate: booking.endDate, hoursPerDay: booking.hoursPerDay, periodStart: input.startDate, periodEnd: input.endDate, context, }), 0, ); const remainingHours = Math.max(0, availableHours - bookedHours); return { id: resource.id, name: resource.displayName, eid: resource.eid, role: resource.areaRole?.name ?? null, chapter: resource.chapter, workingDays, availableHours: round1(remainingHours), availableHoursPerDay: averagePerWorkingDay(remainingHours, workingDays), }; }) .filter((resource) => resource.availableHoursPerDay >= input.minHoursPerDay) .sort((left, right) => right.availableHours - left.availableHours) .slice(0, input.limit); return { period: `${toIsoDate(input.startDate)} to ${toIsoDate(input.endDate)}`, minHoursFilter: input.minHoursPerDay, results, totalFound: results.length, }; }), analyzeUtilization: planningReadProcedure .input( z.object({ resourceId: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), }), ) .query(async ({ ctx, input }) => { const resource = await findUniqueOrThrow( ctx.db.resource.findUnique({ where: { id: input.resourceId }, select: { id: true, displayName: true, chargeabilityTarget: true, availability: true, countryId: true, federalState: true, metroCityId: true, country: { select: { code: true } }, metroCity: { select: { name: true } }, }, }), "Resource", ); const resourceBookings = await listAssignmentBookings(ctx.db, { startDate: input.startDate, endDate: input.endDate, resourceIds: [resource.id], }); const availability = resource.availability as unknown as WeekdayAvailability; const contexts = await loadResourceDailyAvailabilityContexts( ctx.db, [ { id: resource.id, availability, countryId: resource.countryId, countryCode: resource.country?.code, federalState: resource.federalState, metroCityId: resource.metroCityId, metroCityName: resource.metroCity?.name, }, ], input.startDate, input.endDate, ); const context = contexts.get(resource.id); const activeBookings = resourceBookings.map((booking) => ({ startDate: booking.startDate, endDate: booking.endDate, hoursPerDay: booking.hoursPerDay, status: booking.status, projectName: booking.project.name, isChargeable: booking.project.orderType === "CHARGEABLE", })); const overallocatedDays: string[] = []; const underutilizedDays: string[] = []; let totalAvailableHours = 0; let totalChargeableHours = 0; const cursor = new Date(input.startDate); cursor.setUTCHours(0, 0, 0, 0); const end = new Date(input.endDate); end.setUTCHours(0, 0, 0, 0); while (cursor <= end) { const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context); if (availableHoursForDay > 0) { const { allocatedHours, chargeableHours } = calculateAllocatedHoursForDay({ bookings: activeBookings, date: cursor, context, }); totalAvailableHours += availableHoursForDay; totalChargeableHours += chargeableHours; if (allocatedHours > availableHoursForDay) { overallocatedDays.push(toIsoDate(cursor)); } else if (allocatedHours < availableHoursForDay * 0.5) { underutilizedDays.push(toIsoDate(cursor)); } } cursor.setUTCDate(cursor.getUTCDate() + 1); } const currentChargeability = totalAvailableHours > 0 ? (totalChargeableHours / totalAvailableHours) * 100 : 0; return { resourceId: resource.id, resourceName: resource.displayName, chargeabilityTarget: resource.chargeabilityTarget, currentChargeability, chargeabilityGap: resource.chargeabilityTarget - currentChargeability, allocations: activeBookings .filter((booking) => ACTIVE_STATUSES.has(booking.status)) .map((booking) => ({ startDate: booking.startDate, endDate: booking.endDate, hoursPerDay: booking.hoursPerDay, projectName: booking.projectName, isChargeable: booking.isChargeable, })), overallocatedDays, underutilizedDays, }; }), findCapacity: planningReadProcedure .input( z.object({ resourceId: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), minAvailableHoursPerDay: z.number().optional().default(4), }), ) .query(async ({ ctx, input }) => { const resource = await findUniqueOrThrow( ctx.db.resource.findUnique({ where: { id: input.resourceId }, select: { id: true, displayName: true, availability: true, countryId: true, federalState: true, metroCityId: true, country: { select: { code: true } }, metroCity: { select: { name: true } }, }, }), "Resource", ); const resourceBookings = await listAssignmentBookings(ctx.db, { startDate: input.startDate, endDate: input.endDate, resourceIds: [resource.id], }); const availability = resource.availability as unknown as WeekdayAvailability; const contexts = await loadResourceDailyAvailabilityContexts( ctx.db, [ { id: resource.id, availability, countryId: resource.countryId, countryCode: resource.country?.code, federalState: resource.federalState, metroCityId: resource.metroCityId, metroCityName: resource.metroCity?.name, }, ], input.startDate, input.endDate, ); const context = contexts.get(resource.id); const windows: Array<{ resourceId: string; resourceName: string; startDate: Date; endDate: Date; availableHoursPerDay: number; availableDays: number; totalAvailableHours: number; }> = []; let windowStart: Date | null = null; let windowAvailableDays = 0; let windowTotalHours = 0; let windowMinHours = Number.POSITIVE_INFINITY; const closeWindow = (closeDate: Date) => { if (windowStart && windowAvailableDays > 0) { const previousDay = new Date(closeDate); previousDay.setUTCDate(previousDay.getUTCDate() - 1); windows.push({ resourceId: resource.id, resourceName: resource.displayName, startDate: new Date(windowStart), endDate: previousDay, availableHoursPerDay: Number.isFinite(windowMinHours) ? windowMinHours : 0, availableDays: windowAvailableDays, totalAvailableHours: Math.round(windowTotalHours * 10) / 10, }); } windowStart = null; windowAvailableDays = 0; windowTotalHours = 0; windowMinHours = Number.POSITIVE_INFINITY; }; const cursor = new Date(input.startDate); cursor.setUTCHours(0, 0, 0, 0); const end = new Date(input.endDate); end.setUTCHours(0, 0, 0, 0); while (cursor <= end) { const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context); if (availableHoursForDay <= 0) { closeWindow(cursor); cursor.setUTCDate(cursor.getUTCDate() + 1); continue; } const { allocatedHours } = calculateAllocatedHoursForDay({ bookings: resourceBookings.map((booking) => ({ startDate: booking.startDate, endDate: booking.endDate, hoursPerDay: booking.hoursPerDay, status: booking.status, })), date: cursor, context, }); const freeHours = Math.max(0, availableHoursForDay - allocatedHours); if (freeHours >= input.minAvailableHoursPerDay) { if (!windowStart) { windowStart = new Date(cursor); } windowAvailableDays += 1; windowTotalHours += freeHours; windowMinHours = Math.min(windowMinHours, freeHours); } else { closeWindow(cursor); } cursor.setUTCDate(cursor.getUTCDate() + 1); } closeWindow(new Date(end.getTime() + MILLISECONDS_PER_DAY)); return windows; }), };