refactor(api): extract scenario router helpers

This commit is contained in:
2026-03-31 11:03:50 +02:00
parent e013d1af9f
commit 3e0d9d9af7
5 changed files with 705 additions and 632 deletions
@@ -0,0 +1,297 @@
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<string>();
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<string, typeof allAssignmentsForResources>();
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<typeof impact> => 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<string>();
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<string>();
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,
};
}