import { isChargeabilityActualBooking, isChargeabilityRelevantProject, listAssignmentBookings, } from "@capakraken/application"; import type { WeekdayAvailability } from "@capakraken/shared"; import { z } from "zod"; import { anonymizeResource, getAnonymizationDirectory, } from "../lib/anonymization.js"; import { calculateEffectiveAvailableHours, calculateEffectiveBookedHours, calculateEffectiveDayAvailability, loadResourceDailyAvailabilityContexts, } from "../lib/resource-capacity.js"; import { controllerProcedure } from "../trpc.js"; import { buildDailyBookedHoursMap } from "./resource-capacity-shared.js"; export const resourceCapacityReadProcedures = { listWithUtilization: controllerProcedure .input( z.object({ startDate: z.string().datetime().optional(), endDate: z.string().datetime().optional(), chapter: z.string().optional(), includeProposed: z.boolean().default(false), limit: z.number().int().min(1).max(500).default(100), }), ) .query(async ({ ctx, input }) => { const now = new Date(); const start = input.startDate ? new Date(input.startDate) : new Date(now.getFullYear(), now.getMonth(), 1); const end = input.endDate ? new Date(input.endDate) : new Date(now.getFullYear(), now.getMonth() + 3, 0); const resources = await ctx.db.resource.findMany({ where: { isActive: true, ...(input.chapter ? { chapter: input.chapter } : {}), }, take: input.limit, orderBy: { displayName: "asc" }, select: { id: true, eid: true, displayName: true, email: true, chapter: true, lcrCents: true, ucrCents: true, currency: true, chargeabilityTarget: true, availability: true, skills: true, dynamicFields: true, blueprintId: true, isActive: true, createdAt: true, updatedAt: true, roleId: true, portfolioUrl: true, postalCode: true, federalState: true, countryId: true, metroCityId: true, valueScore: true, valueScoreBreakdown: true, valueScoreUpdatedAt: true, userId: true, country: { select: { code: true } }, metroCity: { select: { name: true } }, }, }); const bookings = await listAssignmentBookings(ctx.db, { startDate: start, endDate: end, resourceIds: resources.map((resource) => resource.id), }); const bookingsByResourceId = new Map(); for (const booking of bookings) { if (!booking.resourceId) { continue; } const items = bookingsByResourceId.get(booking.resourceId) ?? []; items.push(booking); bookingsByResourceId.set(booking.resourceId, items); } 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, })), start, end, ); const directory = await getAnonymizationDirectory(ctx.db); return resources.map((resource) => { const availability = resource.availability as unknown as WeekdayAvailability; const context = contexts.get(resource.id); const resourceBookings = (bookingsByResourceId.get(resource.id) ?? []).filter( (booking) => booking.resourceId === resource.id && (input.includeProposed || booking.status !== "PROPOSED"), ); const availableHours = calculateEffectiveAvailableHours({ availability, periodStart: start, periodEnd: end, context, }); const bookedHours = resourceBookings.reduce( (sum, booking) => sum + calculateEffectiveBookedHours({ availability, startDate: booking.startDate, endDate: booking.endDate, hoursPerDay: booking.hoursPerDay, periodStart: start, periodEnd: end, context, }), 0, ); const dailyBookedHours = buildDailyBookedHoursMap(resourceBookings, availability, context, start, end); const isOverbooked = Array.from(dailyBookedHours.entries()).some(([isoDate, hours]) => { const date = new Date(`${isoDate}T00:00:00.000Z`); const dayCapacity = calculateEffectiveDayAvailability({ availability, date, context, }); return dayCapacity > 0 && hours > dayCapacity; }); const utilizationPercent = availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0; return anonymizeResource({ ...resource, bookingCount: resourceBookings.length, bookedHours: Math.round(bookedHours), availableHours: Math.round(availableHours), utilizationPercent, isOverbooked, }, directory); }); }), getChargeabilityStats: controllerProcedure .input(z.object({ includeProposed: z.boolean().default(false), resourceId: z.string().optional() })) .query(async ({ ctx, input }) => { const now = new Date(); const start = new Date(now.getFullYear(), now.getMonth(), 1); const end = new Date(now.getFullYear(), now.getMonth() + 1, 0); const resources = await ctx.db.resource.findMany({ where: { isActive: true, ...(input.resourceId ? { id: input.resourceId } : {}), }, select: { id: true, eid: true, displayName: true, chapter: true, chargeabilityTarget: true, availability: true, countryId: true, federalState: true, metroCityId: true, country: { select: { code: true } }, metroCity: { select: { name: true } }, }, take: 1000, }); const bookings = await listAssignmentBookings(ctx.db, { startDate: start, endDate: end, resourceIds: resources.map((resource) => resource.id), }); 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, })), start, end, ); const directory = await getAnonymizationDirectory(ctx.db); return resources.map((resource) => { const availability = resource.availability as unknown as WeekdayAvailability; const context = contexts.get(resource.id); const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id); const actualAllocs = resourceBookings.filter((booking) => isChargeabilityActualBooking(booking, input.includeProposed), ); const expectedAllocs = resourceBookings.filter((booking) => isChargeabilityRelevantProject(booking.project, true), ); const availableHours = calculateEffectiveAvailableHours({ availability, periodStart: start, periodEnd: end, context, }); const actualBookedHours = actualAllocs.reduce( (sum, booking) => sum + calculateEffectiveBookedHours({ availability, startDate: booking.startDate, endDate: booking.endDate, hoursPerDay: booking.hoursPerDay, periodStart: start, periodEnd: end, context, }), 0, ); const expectedBookedHours = expectedAllocs.reduce( (sum, booking) => sum + calculateEffectiveBookedHours({ availability, startDate: booking.startDate, endDate: booking.endDate, hoursPerDay: booking.hoursPerDay, periodStart: start, periodEnd: end, context, }), 0, ); const actualChargeability = availableHours > 0 ? Math.round((actualBookedHours / availableHours) * 100) : 0; const expectedChargeability = availableHours > 0 ? Math.round((expectedBookedHours / availableHours) * 100) : 0; return anonymizeResource({ id: resource.id, eid: resource.eid, displayName: resource.displayName, chapter: resource.chapter, chargeabilityTarget: resource.chargeabilityTarget, actualChargeability, expectedChargeability, availableHours: Math.round(availableHours), }, directory); }); }), };