refactor(api): extract scenario router helpers
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user