import { TRPCError } from "@trpc/server"; import { calculateEffectiveAvailableHours, calculateEffectiveBookedHours } from "../lib/resource-capacity.js"; import { calculateScenarioEntryHours, collectScenarioSkillSet, getScenarioAvailability, loadScenarioAvailabilityContexts, roundToTenths, scenarioResourceSelect, scenarioSimulationAssignmentInclude, scenarioSimulationProjectSelect, scenarioUtilizationAssignmentSelect, type ScenarioChangeInput, type ScenarioDb, type ScenarioEntry, } from "./scenario-shared.js"; export async function simulateProjectScenario( db: ScenarioDb, input: { projectId: string; changes: ScenarioChangeInput[]; }, ) { const { projectId, changes } = input; const project = await db.project.findUnique({ where: { id: projectId }, select: scenarioSimulationProjectSelect, }); if (!project) { throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); } const currentAssignments = await db.assignment.findMany({ where: { projectId, status: { not: "CANCELLED" } }, include: scenarioSimulationAssignmentInclude, }); const resourceIds = new Set(); for (const change of changes) { if (change.resourceId) resourceIds.add(change.resourceId); } for (const assignment of currentAssignments) { if (assignment.resourceId) resourceIds.add(assignment.resourceId); } const resources = await db.resource.findMany({ where: { id: { in: [...resourceIds] } }, select: scenarioResourceSelect, }); const resourceMap = new Map(resources.map((resource) => [resource.id, resource])); const removedAssignmentIds = new Set( changes.filter((change) => change.remove && change.assignmentId).map((change) => change.assignmentId!), ); const modifiedAssignmentIds = new Set( changes.filter((change) => !change.remove && change.assignmentId).map((change) => change.assignmentId!), ); const scenarioEntries: ScenarioEntry[] = []; for (const assignment of currentAssignments) { if (removedAssignmentIds.has(assignment.id)) continue; if (modifiedAssignmentIds.has(assignment.id)) continue; scenarioEntries.push({ resourceId: assignment.resourceId, lcrCents: assignment.resource?.lcrCents ?? 0, hoursPerDay: assignment.hoursPerDay, startDate: assignment.startDate, endDate: assignment.endDate, availability: getScenarioAvailability(assignment.resource?.availability), isNew: false, }); } for (const change of changes) { if (change.remove) continue; const resource = change.resourceId ? resourceMap.get(change.resourceId) : null; scenarioEntries.push({ resourceId: change.resourceId ?? null, lcrCents: resource?.lcrCents ?? 0, hoursPerDay: change.hoursPerDay, startDate: change.startDate, endDate: change.endDate, availability: getScenarioAvailability(resource?.availability), isNew: !change.assignmentId, }); } const affectedResourceIds = [...new Set( scenarioEntries .map((entry) => entry.resourceId) .filter((resourceId): resourceId is string => Boolean(resourceId)), )]; const allAssignmentsForResources = affectedResourceIds.length > 0 ? await db.assignment.findMany({ where: { resourceId: { in: affectedResourceIds }, status: { not: "CANCELLED" }, }, select: scenarioUtilizationAssignmentSelect, }) : []; const assignmentsByResource = new Map(); for (const assignment of allAssignmentsForResources) { if (!assignment.resourceId) continue; const existing = assignmentsByResource.get(assignment.resourceId) ?? []; existing.push(assignment); assignmentsByResource.set(assignment.resourceId, existing); } let windowStart = project.startDate; let windowEnd = project.endDate; for (const entry of scenarioEntries) { if (entry.startDate < windowStart) windowStart = entry.startDate; if (entry.endDate > windowEnd) windowEnd = entry.endDate; } const contexts = await loadScenarioAvailabilityContexts( db, resources, windowStart, windowEnd, ); let scenarioCostCents = 0; let scenarioHours = 0; for (const entry of scenarioEntries) { const totalHours = calculateScenarioEntryHours(entry, { periodStart: windowStart, periodEnd: windowEnd, contexts, }); scenarioCostCents += Math.round(totalHours * entry.lcrCents); scenarioHours += totalHours; } let baselineCostCents = 0; let baselineHours = 0; for (const assignment of currentAssignments) { const totalHours = calculateScenarioEntryHours({ resourceId: assignment.resourceId, lcrCents: assignment.resource?.lcrCents ?? 0, hoursPerDay: assignment.hoursPerDay, startDate: assignment.startDate, endDate: assignment.endDate, availability: getScenarioAvailability(assignment.resource?.availability), }, { periodStart: windowStart, periodEnd: windowEnd, contexts, }); baselineHours += totalHours; baselineCostCents += Math.round(totalHours * (assignment.resource?.lcrCents ?? 0)); } const resourceImpacts = affectedResourceIds.map((resourceId) => { const resource = resourceMap.get(resourceId); if (!resource) return null; const availability = getScenarioAvailability(resource.availability); const context = contexts.get(resourceId); const totalAvailableHours = calculateEffectiveAvailableHours({ availability, periodStart: windowStart, periodEnd: windowEnd, context, }); const currentProjectAssignments = (assignmentsByResource.get(resourceId) ?? []).filter( (assignment) => assignment.projectId === projectId, ); let currentProjectHours = 0; for (const assignment of currentProjectAssignments) { currentProjectHours += calculateEffectiveBookedHours({ availability, startDate: assignment.startDate, endDate: assignment.endDate, hoursPerDay: assignment.hoursPerDay, periodStart: windowStart, periodEnd: windowEnd, context, }); } const scenarioResourceEntries = scenarioEntries.filter((entry) => entry.resourceId === resourceId); let scenarioProjectHours = 0; for (const entry of scenarioResourceEntries) { scenarioProjectHours += calculateEffectiveBookedHours({ availability, startDate: entry.startDate, endDate: entry.endDate, hoursPerDay: entry.hoursPerDay, periodStart: windowStart, periodEnd: windowEnd, context, }); } const otherProjectAssignments = (assignmentsByResource.get(resourceId) ?? []).filter( (assignment) => assignment.projectId !== projectId, ); let otherProjectsHours = 0; for (const assignment of otherProjectAssignments) { otherProjectsHours += calculateEffectiveBookedHours({ availability, startDate: assignment.startDate, endDate: assignment.endDate, hoursPerDay: assignment.hoursPerDay, periodStart: windowStart, periodEnd: windowEnd, context, }); } const currentTotalHours = otherProjectsHours + currentProjectHours; const scenarioTotalHours = otherProjectsHours + scenarioProjectHours; const currentUtilization = totalAvailableHours > 0 ? (currentTotalHours / totalAvailableHours) * 100 : 0; const scenarioUtilization = totalAvailableHours > 0 ? (scenarioTotalHours / totalAvailableHours) * 100 : 0; return { resourceId, resourceName: resource.displayName, chargeabilityTarget: resource.chargeabilityTarget, currentUtilization: roundToTenths(currentUtilization), scenarioUtilization: roundToTenths(scenarioUtilization), utilizationDelta: roundToTenths(scenarioUtilization - currentUtilization), isOverallocated: scenarioUtilization > 100, }; }).filter((impact): impact is NonNullable => impact !== null); const warnings: string[] = []; for (const impact of resourceImpacts) { if (impact.isOverallocated) { warnings.push( `${impact.resourceName} would be at ${impact.scenarioUtilization.toFixed(1)}% utilization (over-allocated)`, ); } } const budgetCents = project.budgetCents ?? 0; if (budgetCents > 0 && scenarioCostCents > budgetCents) { const overBudgetPct = Math.round(((scenarioCostCents - budgetCents) / budgetCents) * 100); warnings.push(`Scenario exceeds budget by ${overBudgetPct}%`); } const currentSkills = new Set(); for (const assignment of currentAssignments) { for (const skill of collectScenarioSkillSet(assignment.resource?.skills as Array<{ skill?: string | null }> | null | undefined)) { currentSkills.add(skill); } } const scenarioSkills = new Set(); for (const entry of scenarioEntries) { if (!entry.resourceId) continue; const resource = resourceMap.get(entry.resourceId); for (const skill of collectScenarioSkillSet(resource?.skills as Array<{ skill?: string | null }> | null | undefined)) { scenarioSkills.add(skill); } } const baselineSkillCount = currentSkills.size; const scenarioSkillCount = scenarioSkills.size; const skillCoveragePct = baselineSkillCount > 0 ? Math.round((scenarioSkillCount / baselineSkillCount) * 100) : scenarioSkillCount > 0 ? 100 : 0; return { baseline: { totalCostCents: baselineCostCents, totalHours: baselineHours, headcount: currentAssignments.length, skillCount: baselineSkillCount, }, scenario: { totalCostCents: scenarioCostCents, totalHours: scenarioHours, headcount: scenarioEntries.length, skillCount: scenarioSkillCount, }, delta: { costCents: scenarioCostCents - baselineCostCents, hours: scenarioHours - baselineHours, headcount: scenarioEntries.length - currentAssignments.length, skillCoveragePct, }, resourceImpacts, warnings, budgetCents, }; }