From be597dc1c54f67291a200c2953be49b90f37733c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 11:26:45 +0200 Subject: [PATCH] refactor(api): extract staffing suggestions read procedures --- .../src/router/staffing-suggestions-read.ts | 495 +++++++++++++++++ packages/api/src/router/staffing.ts | 499 +----------------- 2 files changed, 498 insertions(+), 496 deletions(-) create mode 100644 packages/api/src/router/staffing-suggestions-read.ts diff --git a/packages/api/src/router/staffing-suggestions-read.ts b/packages/api/src/router/staffing-suggestions-read.ts new file mode 100644 index 0000000..4c0401f --- /dev/null +++ b/packages/api/src/router/staffing-suggestions-read.ts @@ -0,0 +1,495 @@ +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, +} from "../lib/resource-capacity.js"; +import { fmtEur } from "../lib/format-utils.js"; +import { planningReadProcedure, requirePermission } from "../trpc.js"; +import { + ACTIVE_STATUSES, + averagePerWorkingDay, + calculateAllocatedHoursForDay, + createLocationLabel, + getBaseDayAvailability, + getEffectiveDayAvailability, + round1, + toIsoDate, +} from "./staffing-shared.js"; + +function fmtDate(value: Date | null | undefined): string | null { + return value ? value.toISOString().slice(0, 10) : null; +} + +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 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((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(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((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: `${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), + }; + }), +}; diff --git a/packages/api/src/router/staffing.ts b/packages/api/src/router/staffing.ts index 8e77deb..605b97e 100644 --- a/packages/api/src/router/staffing.ts +++ b/packages/api/src/router/staffing.ts @@ -1,503 +1,10 @@ -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, -} from "../lib/resource-capacity.js"; -import { fmtEur } from "../lib/format-utils.js"; -import { createTRPCRouter, planningReadProcedure, requirePermission } from "../trpc.js"; +import { createTRPCRouter } from "../trpc.js"; import { staffingBestProjectResourceProcedures } from "./staffing-best-project-resource.js"; import { staffingCapacityReadProcedures } from "./staffing-capacity-read.js"; -import { - ACTIVE_STATUSES, - averagePerWorkingDay, - calculateAllocatedHoursForDay, - createLocationLabel, - getBaseDayAvailability, - getEffectiveDayAvailability, - round1, - toIsoDate, -} from "./staffing-shared.js"; - -function fmtDate(value: Date | null | undefined): string | null { - return value ? value.toISOString().slice(0, 10) : null; -} - -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; -}; - -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, - }; - }); -} +import { staffingSuggestionsReadProcedures } from "./staffing-suggestions-read.js"; 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), - }; - }), + ...staffingSuggestionsReadProcedures, ...staffingCapacityReadProcedures, ...staffingBestProjectResourceProcedures, });