import { rankResources } from "@capakraken/staffing"; import { listAssignmentBookings } from "@capakraken/application"; import { PermissionKey, type WeekdayAvailability } from "@capakraken/shared"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { calculateEffectiveAvailableHours, calculateEffectiveBookedHours, countEffectiveWorkingDays, loadResourceDailyAvailabilityContexts, type ResourceDailyAvailabilityContext, } from "../lib/resource-capacity.js"; import { fmtEur } from "../lib/format-utils.js"; import { createTRPCRouter, planningReadProcedure, requirePermission } from "../trpc.js"; const DAY_KEYS: (keyof WeekdayAvailability)[] = [ "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", ]; const ACTIVE_STATUSES = new Set(["PROPOSED", "CONFIRMED", "ACTIVE"]); function toIsoDate(value: Date): string { return value.toISOString().slice(0, 10); } function fmtDate(value: Date | null | undefined): string | null { return value ? value.toISOString().slice(0, 10) : null; } function createUtcDate(year: number, monthIndex: number, day: number): Date { return new Date(Date.UTC(year, monthIndex, day)); } function normalizeUtcDate(value: Date): Date { return createUtcDate(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()); } function createDateRange(input: { startDate?: Date | undefined; endDate?: Date | undefined; durationDays?: number | undefined; }): { startDate: Date; endDate: Date } { const startDate = input.startDate ? normalizeUtcDate(input.startDate) : createUtcDate(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate()); const endDate = input.endDate ? normalizeUtcDate(input.endDate) : createUtcDate( startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate() + Math.max((input.durationDays ?? 21) - 1, 0), ); if (endDate < startDate) { throw new Error("endDate must be on or after startDate."); } return { startDate, endDate }; } function round1(value: number): number { return Math.round(value * 10) / 10; } function getBaseDayAvailability( availability: WeekdayAvailability, date: Date, ): number { const key = DAY_KEYS[date.getUTCDay()]; return key ? (availability[key] ?? 0) : 0; } function getEffectiveDayAvailability( availability: WeekdayAvailability, date: Date, context: ResourceDailyAvailabilityContext | undefined, ): number { const key = DAY_KEYS[date.getUTCDay()]; const baseHours = key ? (availability[key] ?? 0) : 0; if (baseHours <= 0) { return 0; } const fraction = context?.absenceFractionsByDate.get(toIsoDate(date)) ?? 0; return Math.max(0, baseHours * (1 - fraction)); } function overlapsDateRange(startDate: Date, endDate: Date, date: Date): boolean { return date >= startDate && date <= endDate; } function averagePerWorkingDay(totalHours: number, workingDays: number): number { if (workingDays <= 0) { return 0; } return round1(totalHours / workingDays); } function createLocationLabel(input: { countryCode?: string | null; federalState?: string | null; metroCityName?: string | null; }): string { return [ input.countryCode ?? null, input.federalState ?? null, input.metroCityName ?? null, ].filter((value): value is string => Boolean(value && value.trim().length > 0)).join(" / "); } function calculateAllocatedHoursForDay(input: { bookings: Array<{ startDate: Date; endDate: Date; hoursPerDay: number; status: string; isChargeable?: boolean }>; date: Date; context: ResourceDailyAvailabilityContext | undefined; }): { allocatedHours: number; chargeableHours: number } { const isoDate = toIsoDate(input.date); const dayFraction = Math.max(0, 1 - (input.context?.absenceFractionsByDate.get(isoDate) ?? 0)); return input.bookings.reduce( (acc, booking) => { if (!ACTIVE_STATUSES.has(booking.status) || !overlapsDateRange(booking.startDate, booking.endDate, input.date)) { return acc; } const effectiveHours = booking.hoursPerDay * dayFraction; acc.allocatedHours += effectiveHours; if (booking.isChargeable) { acc.chargeableHours += effectiveHours; } return acc; }, { allocatedHours: 0, chargeableHours: 0 }, ); } type StaffingSuggestionInput = { requiredSkills: string[]; preferredSkills?: string[] | undefined; startDate: Date; endDate: Date; hoursPerDay: number; budgetLcrCentsPerHour?: number | undefined; chapter?: string | undefined; skillCategory?: string | undefined; mainSkillsOnly?: boolean | undefined; minProficiency?: number | undefined; }; type StaffingSuggestionsDbClient = Parameters[0] & Parameters[0] & { resource: { findMany: (args: Record) => Promise; }; }; type StaffingResourceRecord = { id: string; displayName: string; eid: string; fte: number | null; chapter: string | null; skills: unknown; lcrCents: number | null; chargeabilityTarget: number | null; availability: unknown; valueScore: number | null; countryId: string | null; federalState: string | null; metroCityId: string | null; country: { code: string; name: string } | null; metroCity: { name: string } | null; areaRole: { name: string } | null; }; type BestProjectResourceRankingMode = | "lowest_lcr" | "highest_remaining_hours_per_day" | "highest_remaining_hours"; type BestProjectResourceInput = { projectId: string; startDate: Date; endDate: Date; minHoursPerDay: number; rankingMode: BestProjectResourceRankingMode; chapter?: string | undefined; roleName?: string | undefined; }; type BestProjectResourceDbClient = Parameters[0] & { assignment: { findMany: (args: Record) => Promise; }; }; type BestProjectResourceDetailDbClient = BestProjectResourceDbClient & { project: { findUnique: (args: Record) => Promise; }; }; type BestProjectResourceAssignmentRecord = { resourceId: string; hoursPerDay: number; startDate: Date; endDate: Date; status: string; resource: { id: string; eid: string; displayName: string; chapter: string | null; lcrCents: number | null; availability: unknown; countryId: string | null; federalState: string | null; metroCityId: string | null; country: { code: string; name: string } | null; metroCity: { name: string } | null; areaRole: { name: string } | null; }; }; type BestProjectResourceOverlapAssignmentRecord = { resourceId: string; projectId: string; hoursPerDay: number; startDate: Date; endDate: Date; status: string; project: { name: string; shortCode: string | null } | null; }; async function queryStaffingSuggestions( db: StaffingSuggestionsDbClient, input: StaffingSuggestionInput, ) { const { requiredSkills, preferredSkills, startDate, endDate, hoursPerDay, budgetLcrCentsPerHour, chapter, skillCategory, mainSkillsOnly, minProficiency, } = input; const resources = await db.resource.findMany({ where: { isActive: true, ...(chapter ? { chapter } : {}), }, select: { id: true, displayName: true, eid: true, fte: true, chapter: true, skills: true, lcrCents: true, chargeabilityTarget: true, availability: true, valueScore: true, countryId: true, federalState: true, metroCityId: true, country: { select: { code: true, name: true } }, metroCity: { select: { name: true } }, areaRole: { select: { name: true } }, }, }) as StaffingResourceRecord[]; const bookings = await listAssignmentBookings(db, { startDate, endDate, resourceIds: resources.map((resource) => resource.id), }); const contexts = await loadResourceDailyAvailabilityContexts( 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, })), startDate, endDate, ); 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 enrichedResources = resources.map((resource) => { const availability = resource.availability as unknown as WeekdayAvailability; const context = contexts.get(resource.id); const resourceBookings = bookingsByResourceId.get(resource.id) ?? []; const activeBookings = resourceBookings.filter((booking) => ACTIVE_STATUSES.has(booking.status)); const baseAvailableHours = calculateEffectiveAvailableHours({ availability, periodStart: startDate, periodEnd: endDate, context: undefined, }); const totalAvailableHours = calculateEffectiveAvailableHours({ availability, periodStart: startDate, periodEnd: endDate, context, }); const baseWorkingDays = countEffectiveWorkingDays({ availability, periodStart: startDate, periodEnd: endDate, context: undefined, }); const effectiveWorkingDays = countEffectiveWorkingDays({ availability, periodStart: startDate, periodEnd: endDate, context, }); const allocatedHours = activeBookings.reduce( (sum, booking) => sum + calculateEffectiveBookedHours({ availability, startDate: booking.startDate, endDate: booking.endDate, hoursPerDay: booking.hoursPerDay, periodStart: startDate, periodEnd: endDate, context, }), 0, ); const holidayDates = [...(context?.holidayDates ?? new Set())].sort(); const holidayWorkdayCount = holidayDates.reduce((count, isoDate) => ( count + (getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0) ), 0); const holidayHoursDeduction = holidayDates.reduce((sum, isoDate) => ( sum + getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)) ), 0); let absenceDayEquivalent = 0; let absenceHoursDeduction = 0; for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) { const dayHours = getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)); if (dayHours <= 0 || context?.holidayDates.has(isoDate)) { continue; } absenceDayEquivalent += fraction; absenceHoursDeduction += dayHours * fraction; } const conflictDays: string[] = []; const conflictDetails: Array<{ date: string; baseHours: number; effectiveHours: number; allocatedHours: number; remainingHours: number; requestedHours: number; shortageHours: number; absenceFraction: number; isHoliday: boolean; }> = []; const cursor = new Date(startDate); cursor.setUTCHours(0, 0, 0, 0); const periodEndAtMidnight = new Date(endDate); periodEndAtMidnight.setUTCHours(0, 0, 0, 0); while (cursor <= periodEndAtMidnight) { const isoDate = toIsoDate(cursor); const baseHoursForDay = getBaseDayAvailability(availability, cursor); const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context); const isHoliday = context?.holidayDates.has(isoDate) ?? false; const absenceFraction = Math.min( 1, Math.max(0, context?.absenceFractionsByDate.get(isoDate) ?? 0), ); if (availableHoursForDay > 0) { const { allocatedHours: allocatedHoursForDay } = calculateAllocatedHoursForDay({ bookings: activeBookings, date: cursor, context, }); if (allocatedHoursForDay + hoursPerDay > availableHoursForDay) { const remainingHoursForDay = Math.max(0, availableHoursForDay - allocatedHoursForDay); conflictDays.push(isoDate); conflictDetails.push({ date: isoDate, baseHours: round1(baseHoursForDay), effectiveHours: round1(availableHoursForDay), allocatedHours: round1(allocatedHoursForDay), remainingHours: round1(remainingHoursForDay), requestedHours: round1(hoursPerDay), shortageHours: round1(Math.max(0, hoursPerDay - remainingHoursForDay)), absenceFraction: round1(absenceFraction), isHoliday, }); } } cursor.setUTCDate(cursor.getUTCDate() + 1); } const remainingHours = Math.max(0, totalAvailableHours - allocatedHours); const remainingHoursPerDay = averagePerWorkingDay(remainingHours, effectiveWorkingDays); const utilizationPercent = totalAvailableHours > 0 ? Math.min(100, (allocatedHours / totalAvailableHours) * 100) : 0; type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean }; let skills = resource.skills as unknown as SkillRow[]; if (mainSkillsOnly) skills = skills.filter((s) => s.isMainSkill); if (skillCategory) skills = skills.filter((s) => s.category === skillCategory); if (minProficiency) skills = skills.filter((s) => s.proficiency >= minProficiency); return { id: resource.id, displayName: resource.displayName, eid: resource.eid, fte: resource.fte, chapter: resource.chapter, role: resource.areaRole?.name ?? null, skills: skills as unknown as import("@capakraken/shared").SkillEntry[], lcrCents: resource.lcrCents, chargeabilityTarget: resource.chargeabilityTarget, currentUtilizationPercent: utilizationPercent, hasAvailabilityConflicts: conflictDays.length > 0, conflictDays, valueScore: resource.valueScore ?? 0, transparency: { location: { countryCode: resource.country?.code ?? null, countryName: resource.country?.name ?? null, federalState: resource.federalState ?? null, metroCityName: resource.metroCity?.name ?? null, label: createLocationLabel({ countryCode: resource.country?.code ?? null, federalState: resource.federalState, metroCityName: resource.metroCity?.name ?? null, }), }, capacity: { requestedHoursPerDay: round1(hoursPerDay), requestedHoursTotal: round1(effectiveWorkingDays * hoursPerDay), baseWorkingDays: round1(baseWorkingDays), effectiveWorkingDays: round1(effectiveWorkingDays), baseAvailableHours: round1(baseAvailableHours), effectiveAvailableHours: round1(totalAvailableHours), bookedHours: round1(allocatedHours), remainingHours: round1(remainingHours), remainingHoursPerDay, holidayCount: holidayDates.length, holidayWorkdayCount, holidayHoursDeduction: round1(holidayHoursDeduction), absenceDayEquivalent: round1(absenceDayEquivalent), absenceHoursDeduction: round1(absenceHoursDeduction), }, conflicts: { count: conflictDays.length, conflictDays, details: conflictDetails, }, }, }; }); const ranked = rankResources({ requiredSkills, preferredSkills, resources: enrichedResources, budgetLcrCentsPerHour, } as unknown as Parameters[0]); const baseRankIndex = new Map(ranked.map((suggestion, index) => [suggestion.resourceId, index])); return [...ranked].sort((a, b) => { if (Math.abs(a.score - b.score) <= 2) { const aVal = (enrichedResources.find((resource) => resource.id === a.resourceId)?.valueScore ?? 0); const bVal = (enrichedResources.find((resource) => resource.id === b.resourceId)?.valueScore ?? 0); return bVal - aVal; } return 0; }).map((suggestion, index) => { const resource = enrichedResources.find((entry) => entry.id === suggestion.resourceId); const fallbackBreakdown = "breakdown" in suggestion ? (suggestion as { breakdown?: { skillScore: number; availabilityScore: number; costScore: number; utilizationScore: number } }).breakdown : undefined; const scoreBreakdown = suggestion.scoreBreakdown ?? { skillScore: fallbackBreakdown?.skillScore ?? 0, availabilityScore: fallbackBreakdown?.availabilityScore ?? 0, costScore: fallbackBreakdown?.costScore ?? 0, utilizationScore: fallbackBreakdown?.utilizationScore ?? 0, total: suggestion.score, }; const baseRank = (baseRankIndex.get(suggestion.resourceId) ?? index) + 1; const tieBreakerApplied = baseRank !== index + 1; return { ...suggestion, resourceName: suggestion.resourceName ?? resource?.displayName ?? "", eid: suggestion.eid ?? resource?.eid ?? "", fte: resource?.fte ?? 0, chapter: resource?.chapter ?? null, role: resource?.role ?? null, scoreBreakdown, matchedSkills: suggestion.matchedSkills ?? requiredSkills.filter((skill) => resource?.skills.some((entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase()), ), missingSkills: suggestion.missingSkills ?? requiredSkills.filter((skill) => !resource?.skills.some((entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase()), ), availabilityConflicts: suggestion.availabilityConflicts ?? resource?.conflictDays ?? [], estimatedDailyCostCents: suggestion.estimatedDailyCostCents ?? ((resource?.lcrCents ?? 0) * 8), currentUtilization: suggestion.currentUtilization ?? round1(resource?.currentUtilizationPercent ?? 0), valueScore: resource?.valueScore ?? 0, location: resource?.transparency.location ?? { countryCode: null, countryName: null, federalState: null, metroCityName: null, label: "", }, capacity: resource?.transparency.capacity ?? { requestedHoursPerDay: round1(hoursPerDay), requestedHoursTotal: 0, baseWorkingDays: 0, effectiveWorkingDays: 0, baseAvailableHours: 0, effectiveAvailableHours: 0, bookedHours: 0, remainingHours: 0, remainingHoursPerDay: 0, holidayCount: 0, holidayWorkdayCount: 0, holidayHoursDeduction: 0, absenceDayEquivalent: 0, absenceHoursDeduction: 0, }, conflicts: resource?.transparency.conflicts ?? { count: 0, conflictDays: [], details: [], }, ranking: { rank: index + 1, baseRank, tieBreakerApplied, tieBreakerReason: tieBreakerApplied ? "Within 2 score points, higher value score moves the candidate up." : null, model: "Composite ranking across skill fit, availability, cost, and utilization.", components: [ { key: "skillScore", label: "Skills", score: scoreBreakdown.skillScore }, { key: "availabilityScore", label: "Availability", score: scoreBreakdown.availabilityScore }, { key: "costScore", label: "Cost", score: scoreBreakdown.costScore }, { key: "utilizationScore", label: "Utilization", score: scoreBreakdown.utilizationScore }, ], }, remainingHoursPerDay: resource?.transparency.capacity.remainingHoursPerDay ?? 0, remainingHours: resource?.transparency.capacity.remainingHours ?? 0, effectiveAvailableHours: resource?.transparency.capacity.effectiveAvailableHours ?? 0, baseAvailableHours: resource?.transparency.capacity.baseAvailableHours ?? 0, holidayHoursDeduction: resource?.transparency.capacity.holidayHoursDeduction ?? 0, }; }); } async function queryBestProjectResource( db: BestProjectResourceDbClient, input: BestProjectResourceInput, ) { const projectAssignmentsResult = await db.assignment.findMany({ where: { projectId: input.projectId, status: { not: "CANCELLED" }, startDate: { lte: input.endDate }, endDate: { gte: input.startDate }, resource: { isActive: true, ...(input.chapter ? { chapter: { contains: input.chapter, mode: "insensitive" } } : {}), ...(input.roleName ? { areaRole: { name: { contains: input.roleName, mode: "insensitive" } } } : {}), }, }, select: { resourceId: true, hoursPerDay: true, startDate: true, endDate: true, status: true, resource: { select: { id: true, eid: true, displayName: true, chapter: true, lcrCents: true, availability: true, countryId: true, federalState: true, metroCityId: true, country: { select: { code: true, name: true } }, metroCity: { select: { name: true } }, areaRole: { select: { name: true } }, }, }, }, orderBy: [{ resourceId: "asc" }, { startDate: "asc" }], }); const projectAssignments = (Array.isArray(projectAssignmentsResult) ? projectAssignmentsResult : []) as BestProjectResourceAssignmentRecord[]; if (projectAssignments.length === 0) { return { period: { startDate: toIsoDate(input.startDate), endDate: toIsoDate(input.endDate), minHoursPerDay: input.minHoursPerDay, rankingMode: input.rankingMode, }, filters: { chapter: input.chapter ?? null, roleName: input.roleName ?? null, }, candidateCount: 0, candidates: [], bestMatch: null, note: "No active project resources matched the requested filters in the selected period.", }; } const resourcesById = new Map(); const assignmentsOnProjectByResourceId = new Map(); for (const assignment of projectAssignments) { resourcesById.set(assignment.resourceId, assignment.resource); const items = assignmentsOnProjectByResourceId.get(assignment.resourceId) ?? []; items.push(assignment); assignmentsOnProjectByResourceId.set(assignment.resourceId, items); } const resourceIds = [...resourcesById.keys()]; const overlappingAssignmentsResult = await db.assignment.findMany({ where: { resourceId: { in: resourceIds }, status: { not: "CANCELLED" }, startDate: { lte: input.endDate }, endDate: { gte: input.startDate }, }, select: { resourceId: true, projectId: true, hoursPerDay: true, startDate: true, endDate: true, status: true, project: { select: { name: true, shortCode: true } }, }, orderBy: [{ resourceId: "asc" }, { startDate: "asc" }], }); const overlappingAssignments = (Array.isArray(overlappingAssignmentsResult) ? overlappingAssignmentsResult : []) as BestProjectResourceOverlapAssignmentRecord[]; const assignmentsByResourceId = new Map(); for (const assignment of overlappingAssignments) { const items = assignmentsByResourceId.get(assignment.resourceId) ?? []; items.push(assignment); assignmentsByResourceId.set(assignment.resourceId, items); } const resources = [...resourcesById.values()]; const contexts = await loadResourceDailyAvailabilityContexts( 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 candidates = resources.map((resource) => { const availability = resource.availability as unknown as WeekdayAvailability; const context = contexts.get(resource.id); const baseWorkingDays = countEffectiveWorkingDays({ availability, periodStart: input.startDate, periodEnd: input.endDate, context: undefined, }); const workingDays = countEffectiveWorkingDays({ availability, periodStart: input.startDate, periodEnd: input.endDate, context, }); const baseAvailableHours = calculateEffectiveAvailableHours({ availability, periodStart: input.startDate, periodEnd: input.endDate, context: undefined, }); const availableHours = calculateEffectiveAvailableHours({ availability, periodStart: input.startDate, periodEnd: input.endDate, context, }); const assignments = assignmentsByResourceId.get(resource.id) ?? []; const bookedHours = assignments.reduce( (sum, assignment) => sum + calculateEffectiveBookedHours({ availability, startDate: assignment.startDate, endDate: assignment.endDate, hoursPerDay: assignment.hoursPerDay, periodStart: input.startDate, periodEnd: input.endDate, context, }), 0, ); const projectHours = (assignmentsOnProjectByResourceId.get(resource.id) ?? []).reduce( (sum, assignment) => sum + calculateEffectiveBookedHours({ availability, startDate: assignment.startDate, endDate: assignment.endDate, hoursPerDay: assignment.hoursPerDay, periodStart: input.startDate, periodEnd: input.endDate, context, }), 0, ); let excludedCapacityDays = 0; for (const fraction of context?.absenceFractionsByDate.values() ?? []) { excludedCapacityDays += fraction; } const holidayWorkdayCount = [...(context?.holidayDates ?? new Set())].reduce((count, isoDate) => ( count + (getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0) ), 0); const holidayHoursDeduction = [...(context?.holidayDates ?? new Set())].reduce((sum, isoDate) => ( sum + getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)) ), 0); let absenceDayEquivalent = 0; let absenceHoursDeduction = 0; for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) { const dayHours = getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)); if (dayHours <= 0 || context?.holidayDates.has(isoDate)) { continue; } absenceDayEquivalent += fraction; absenceHoursDeduction += dayHours * fraction; } const remainingHours = Math.max(0, availableHours - bookedHours); const remainingHoursPerDay = averagePerWorkingDay(remainingHours, workingDays); return { id: resource.id, eid: resource.eid, name: resource.displayName, role: resource.areaRole?.name ?? null, chapter: resource.chapter ?? null, country: resource.country?.name ?? resource.country?.code ?? null, countryCode: resource.country?.code ?? null, federalState: resource.federalState ?? null, metroCity: resource.metroCity?.name ?? null, lcrCents: resource.lcrCents ?? null, lcr: resource.lcrCents != null ? fmtEur(resource.lcrCents) : null, baseWorkingDays: round1(baseWorkingDays), workingDays, excludedCapacityDays: round1(excludedCapacityDays), baseAvailableHours: round1(baseAvailableHours), availableHours: round1(availableHours), bookedHours: round1(bookedHours), remainingHours: round1(remainingHours), remainingHoursPerDay, projectHours: round1(projectHours), assignmentCount: assignments.length, holidaySummary: { count: context?.holidayDates.size ?? 0, workdayCount: holidayWorkdayCount, hoursDeduction: round1(holidayHoursDeduction), holidayDates: [...(context?.holidayDates ?? new Set())].sort(), }, absenceSummary: { dayEquivalent: round1(absenceDayEquivalent), hoursDeduction: round1(absenceHoursDeduction), }, capacityBreakdown: { formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours", baseAvailableHours: round1(baseAvailableHours), holidayHoursDeduction: round1(holidayHoursDeduction), absenceHoursDeduction: round1(absenceHoursDeduction), availableHours: round1(availableHours), }, }; }).filter((candidate) => candidate.remainingHoursPerDay >= input.minHoursPerDay); candidates.sort((left, right) => { if (input.rankingMode === "highest_remaining_hours_per_day") { return right.remainingHoursPerDay - left.remainingHoursPerDay || right.remainingHours - left.remainingHours || (left.lcrCents ?? Number.MAX_SAFE_INTEGER) - (right.lcrCents ?? Number.MAX_SAFE_INTEGER); } if (input.rankingMode === "highest_remaining_hours") { return right.remainingHours - left.remainingHours || right.remainingHoursPerDay - left.remainingHoursPerDay || (left.lcrCents ?? Number.MAX_SAFE_INTEGER) - (right.lcrCents ?? Number.MAX_SAFE_INTEGER); } return (left.lcrCents ?? Number.MAX_SAFE_INTEGER) - (right.lcrCents ?? Number.MAX_SAFE_INTEGER) || right.remainingHoursPerDay - left.remainingHoursPerDay || right.remainingHours - left.remainingHours; }); return { period: { startDate: toIsoDate(input.startDate), endDate: toIsoDate(input.endDate), minHoursPerDay: input.minHoursPerDay, rankingMode: input.rankingMode, }, filters: { chapter: input.chapter ?? null, roleName: input.roleName ?? null, }, candidateCount: candidates.length, bestMatch: candidates[0] ?? null, candidates, }; } export const staffingRouter = createTRPCRouter({ /** * Get ranked resource suggestions for a staffing requirement. */ getSuggestions: planningReadProcedure .input( z.object({ requiredSkills: z.array(z.string()), preferredSkills: z.array(z.string()).optional(), startDate: z.coerce.date(), endDate: z.coerce.date(), hoursPerDay: z.number().min(0).max(24), budgetLcrCentsPerHour: z.number().optional(), chapter: z.string().optional(), skillCategory: z.string().optional(), mainSkillsOnly: z.boolean().optional(), minProficiency: z.number().min(1).max(5).optional(), }), ) .query(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.VIEW_COSTS); return queryStaffingSuggestions(ctx.db as unknown as StaffingSuggestionsDbClient, input); }), getProjectStaffingSuggestions: planningReadProcedure .input( z.object({ projectId: z.string().min(1), roleName: z.string().optional(), startDate: z.coerce.date().optional(), endDate: z.coerce.date().optional(), limit: z.number().int().min(1).max(50).optional().default(5), }), ) .query(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.VIEW_COSTS); const project = await findUniqueOrThrow(ctx.db.project.findUnique({ where: { id: input.projectId }, select: { id: true, shortCode: true, name: true, startDate: true, endDate: true, }, }), "Project"); const startDate = input.startDate ?? project.startDate ?? new Date(); const endDate = input.endDate ?? project.endDate ?? new Date(); const normalizedRoleFilter = input.roleName?.trim().toLowerCase(); const suggestions = await queryStaffingSuggestions(ctx.db as unknown as StaffingSuggestionsDbClient, { requiredSkills: [], startDate, endDate, hoursPerDay: 8, }); return { project: `${project.name} (${project.shortCode})`, period: `${fmtDate(startDate)} to ${fmtDate(endDate)}`, suggestions: suggestions .filter((suggestion) => { if (!normalizedRoleFilter) { return true; } return suggestion.role?.toLowerCase().includes(normalizedRoleFilter) ?? false; }) .map((suggestion) => ({ id: suggestion.resourceId, name: suggestion.resourceName, eid: suggestion.eid, role: suggestion.role, chapter: suggestion.chapter, fte: round1(suggestion.fte ?? 0), lcr: fmtEur(Math.round((suggestion.estimatedDailyCostCents ?? 0) / 8)), workingDays: round1(suggestion.capacity.effectiveWorkingDays), availableHours: round1(suggestion.capacity.remainingHours), bookedHours: round1(suggestion.capacity.bookedHours), availableHoursPerDay: round1(suggestion.capacity.remainingHoursPerDay), utilization: round1(suggestion.currentUtilization ?? 0), })) .filter((suggestion) => suggestion.availableHours > 0) .slice(0, input.limit), }; }), searchCapacity: planningReadProcedure .input( 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), }), ) .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, }; }), /** * Analyze utilization for a specific resource over a date range. */ 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, }; }), /** * Find capacity windows for a resource. */ 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() + 86_400_000)); return windows; }), findBestProjectResource: planningReadProcedure .input( z.object({ projectId: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), minHoursPerDay: z.number().min(0).default(3), rankingMode: z.enum(["lowest_lcr", "highest_remaining_hours_per_day", "highest_remaining_hours"]).default("lowest_lcr"), chapter: z.string().optional(), roleName: z.string().optional(), }), ) .query(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.VIEW_COSTS); return queryBestProjectResource(ctx.db as unknown as BestProjectResourceDbClient, input); }), getBestProjectResourceDetail: planningReadProcedure .input( z.object({ projectId: z.string().min(1), startDate: z.coerce.date().optional(), endDate: z.coerce.date().optional(), durationDays: z.number().int().min(1).optional(), minHoursPerDay: z.number().min(0).default(3), rankingMode: z.enum(["lowest_lcr", "highest_remaining_hours_per_day", "highest_remaining_hours"]).default("lowest_lcr"), chapter: z.string().optional(), roleName: z.string().optional(), }), ) .query(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.VIEW_COSTS); const project = await findUniqueOrThrow(ctx.db.project.findUnique({ where: { id: input.projectId }, select: { id: true, name: true, shortCode: true, status: true, responsiblePerson: true, }, }), "Project"); const { startDate, endDate } = createDateRange({ startDate: input.startDate, endDate: input.endDate, durationDays: input.durationDays, }); const result = await queryBestProjectResource(ctx.db as unknown as BestProjectResourceDbClient, { projectId: input.projectId, startDate, endDate, minHoursPerDay: input.minHoursPerDay, rankingMode: input.rankingMode, ...(input.chapter ? { chapter: input.chapter } : {}), ...(input.roleName ? { roleName: input.roleName } : {}), }); return { ...result, project: { id: project.id, name: project.name, shortCode: project.shortCode, status: project.status, responsiblePerson: project.responsiblePerson, }, }; }), });