298 lines
9.9 KiB
TypeScript
298 lines
9.9 KiB
TypeScript
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,
|
|
};
|
|
}
|