import { rankResources } from "@capakraken/staffing"; import { listAssignmentBookings } from "@capakraken/application"; import { PermissionKey, toIsoDateOrNull, type WeekdayAvailability } from "@capakraken/shared"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { loadResourceDailyAvailabilityContexts } from "../lib/resource-capacity.js"; import { fmtEur } from "../lib/format-utils.js"; import { planningReadProcedure, requirePermission } from "../trpc.js"; import { ACTIVE_STATUSES, calculateAllocatedHoursForDay, createLocationLabel, getBaseDayAvailability, getEffectiveDayAvailability, round1, toIsoDate, } from "./staffing-shared.js"; import { buildResourceCapacitySummary } from "./staffing-capacity-summary.js"; 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; }; project: { findUnique: (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; }; 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 capacity = buildResourceCapacitySummary({ availability, periodStart: startDate, periodEnd: endDate, context, bookings: activeBookings, }); 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 allocatedHours = capacity.bookedHours; const remainingHours = capacity.remainingHours; const remainingHoursPerDay = capacity.remainingHoursPerDay; const utilizationPercent = capacity.availableHours > 0 ? Math.min(100, (allocatedHours / capacity.availableHours) * 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((skill) => skill.isMainSkill); } if (skillCategory) { skills = skills.filter((skill) => skill.category === skillCategory); } if (minProficiency) { skills = skills.filter((skill) => skill.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(capacity.workingDays * hoursPerDay), baseWorkingDays: capacity.baseWorkingDays, effectiveWorkingDays: capacity.workingDays, baseAvailableHours: capacity.baseAvailableHours, effectiveAvailableHours: capacity.availableHours, bookedHours: capacity.bookedHours, remainingHours: capacity.remainingHours, remainingHoursPerDay, holidayCount: capacity.holidaySummary.count, holidayWorkdayCount: capacity.holidaySummary.workdayCount, holidayHoursDeduction: capacity.holidaySummary.hoursDeduction, absenceDayEquivalent: capacity.absenceSummary.dayEquivalent, absenceHoursDeduction: capacity.absenceSummary.hoursDeduction, }, 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((left, right) => { if (Math.abs(left.score - right.score) <= 2) { const leftValue = enrichedResources.find((resource) => resource.id === left.resourceId)?.valueScore ?? 0; const rightValue = enrichedResources.find((resource) => resource.id === right.resourceId)?.valueScore ?? 0; return rightValue - leftValue; } 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, }; }); } export const staffingSuggestionsReadProcedures = { 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: `${toIsoDateOrNull(startDate)} to ${toIsoDateOrNull(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), }; }), };