Files
CapaKraken/packages/api/src/router/scenario.ts
T

678 lines
23 KiB
TypeScript

import { calculateAllocation } from "@capakraken/engine/allocation";
import { PermissionKey } from "@capakraken/shared";
import type { WeekdayAvailability } from "@capakraken/shared";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, controllerProcedure, planningReadProcedure, requirePermission } from "../trpc.js";
import { createAuditEntry } from "../lib/audit.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
const DEFAULT_AVAILABILITY = {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
} as const;
const ScenarioChangeSchema = z.object({
/** Existing assignment to modify — omit to add a new allocation */
assignmentId: z.string().optional(),
resourceId: z.string().optional(),
roleId: z.string().optional(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0).max(24),
/** Set to true to mark an existing assignment for removal */
remove: z.boolean().optional(),
});
const SimulateInputSchema = z.object({
projectId: z.string(),
changes: z.array(ScenarioChangeSchema).min(1),
});
export const scenarioRouter = createTRPCRouter({
/**
* Returns current allocations/costs for a project — the baseline for comparison.
*/
getProjectBaseline: planningReadProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.VIEW_COSTS);
const project = await ctx.db.project.findUnique({
where: { id: input.projectId },
select: {
id: true,
name: true,
shortCode: true,
startDate: true,
endDate: true,
budgetCents: true,
orderType: true,
},
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const assignments = await ctx.db.assignment.findMany({
where: {
projectId: input.projectId,
status: { not: "CANCELLED" },
},
include: {
resource: {
select: {
id: true,
displayName: true,
eid: true,
lcrCents: true,
availability: true,
chargeabilityTarget: true,
skills: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
},
roleEntity: { select: { id: true, name: true, color: true } },
},
});
const demands = await ctx.db.demandRequirement.findMany({
where: {
projectId: input.projectId,
status: { not: "CANCELLED" },
},
include: {
roleEntity: { select: { id: true, name: true, color: true } },
},
});
const assignmentRangeStart = assignments.length > 0
? new Date(Math.min(...assignments.map((assignment) => assignment.startDate.getTime())))
: project.startDate;
const assignmentRangeEnd = assignments.length > 0
? new Date(Math.max(...assignments.map((assignment) => assignment.endDate.getTime())))
: project.endDate;
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
assignments
.flatMap((assignment) => (assignment.resource ? [assignment.resource] : []))
.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,
})),
assignmentRangeStart,
assignmentRangeEnd,
);
const baselineAllocations = assignments.map((a) => {
const availability = (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
const lcrCents = a.resource?.lcrCents ?? 0;
const totalHours = a.resourceId
? calculateEffectiveBookedHours({
availability,
startDate: a.startDate,
endDate: a.endDate,
hoursPerDay: a.hoursPerDay,
periodStart: assignmentRangeStart,
periodEnd: assignmentRangeEnd,
context: contexts.get(a.resourceId),
})
: calculateAllocation({
lcrCents,
hoursPerDay: a.hoursPerDay,
startDate: a.startDate,
endDate: a.endDate,
availability,
}).totalHours;
const costCents = Math.round(totalHours * lcrCents);
const workingDays = a.hoursPerDay > 0
? Math.round((totalHours / a.hoursPerDay) * 100) / 100
: 0;
return {
id: a.id,
resourceId: a.resourceId,
resourceName: a.resource?.displayName ?? "Unknown",
resourceEid: a.resource?.eid ?? "",
lcrCents,
roleId: a.roleId,
roleName: a.roleEntity?.name ?? a.role ?? "",
roleColor: a.roleEntity?.color ?? null,
startDate: a.startDate.toISOString(),
endDate: a.endDate.toISOString(),
hoursPerDay: a.hoursPerDay,
status: a.status,
costCents,
totalHours,
workingDays,
};
});
const totalCostCents = baselineAllocations.reduce((sum, allocation) => sum + allocation.costCents, 0);
const totalHours = baselineAllocations.reduce((sum, allocation) => sum + allocation.totalHours, 0);
const baselineDemands = demands.map((d) => ({
id: d.id,
roleId: d.roleId,
roleName: d.roleEntity?.name ?? d.role ?? "",
roleColor: d.roleEntity?.color ?? null,
startDate: d.startDate.toISOString(),
endDate: d.endDate.toISOString(),
hoursPerDay: d.hoursPerDay,
headcount: d.headcount,
status: d.status,
}));
return {
project,
assignments: baselineAllocations,
demands: baselineDemands,
totalCostCents,
totalHours,
budgetCents: project.budgetCents,
};
}),
/**
* Pure simulation: computes cost/hours/utilization impact of scenario changes
* without persisting anything.
*/
simulate: controllerProcedure
.input(SimulateInputSchema)
.mutation(async ({ ctx, input }) => {
const { projectId, changes } = input;
// Load project
const project = await ctx.db.project.findUnique({
where: { id: projectId },
select: { id: true, name: true, budgetCents: true, orderType: true, startDate: true, endDate: true },
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
// Load current assignments for baseline
const currentAssignments = await ctx.db.assignment.findMany({
where: { projectId, status: { not: "CANCELLED" } },
include: {
resource: {
select: {
id: true,
displayName: true,
eid: true,
lcrCents: true,
availability: true,
chargeabilityTarget: true,
skills: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
},
},
});
// Collect all resource IDs we need to look up (from changes)
const resourceIds = new Set<string>();
for (const c of changes) {
if (c.resourceId) resourceIds.add(c.resourceId);
}
// Also add resources from existing assignments
for (const a of currentAssignments) {
if (a.resourceId) resourceIds.add(a.resourceId);
}
// Load resources
const resources = await ctx.db.resource.findMany({
where: { id: { in: [...resourceIds] } },
select: {
id: true,
displayName: true,
eid: true,
lcrCents: true,
availability: true,
chargeabilityTarget: true,
skills: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
});
const resourceMap = new Map(resources.map((r) => [r.id, r]));
// Load roles referenced in changes
const roleIds = new Set<string>();
for (const c of changes) {
if (c.roleId) roleIds.add(c.roleId);
}
const roles = roleIds.size > 0
? await ctx.db.role.findMany({
where: { id: { in: [...roleIds] } },
select: { id: true, name: true, color: true },
})
: [];
const roleMap = new Map(roles.map((r) => [r.id, r]));
// Build scenario: start from current assignments, apply changes
const removedAssignmentIds = new Set(
changes.filter((c) => c.remove && c.assignmentId).map((c) => c.assignmentId!),
);
const modifiedAssignmentIds = new Set(
changes.filter((c) => !c.remove && c.assignmentId).map((c) => c.assignmentId!),
);
// Keep untouched assignments
const scenarioEntries: Array<{
resourceId: string | null;
lcrCents: number;
hoursPerDay: number;
startDate: Date;
endDate: Date;
availability: typeof DEFAULT_AVAILABILITY;
isNew: boolean;
}> = [];
for (const a of currentAssignments) {
if (removedAssignmentIds.has(a.id)) continue;
if (modifiedAssignmentIds.has(a.id)) continue;
scenarioEntries.push({
resourceId: a.resourceId,
lcrCents: a.resource?.lcrCents ?? 0,
hoursPerDay: a.hoursPerDay,
startDate: a.startDate,
endDate: a.endDate,
availability: (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY,
isNew: false,
});
}
// Add modified and new entries from changes
for (const c of changes) {
if (c.remove) continue;
const resource = c.resourceId ? resourceMap.get(c.resourceId) : null;
const lcrCents = resource?.lcrCents ?? 0;
const availability = (resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
scenarioEntries.push({
resourceId: c.resourceId ?? null,
lcrCents,
hoursPerDay: c.hoursPerDay,
startDate: c.startDate,
endDate: c.endDate,
availability,
isNew: !c.assignmentId,
});
}
// Compute per-resource utilization impact
// Load ALL assignments for affected resources (across all projects) to measure total utilization
const affectedResourceIds = [...new Set(scenarioEntries.map((e) => e.resourceId).filter(Boolean))] as string[];
const allAssignmentsForResources = affectedResourceIds.length > 0
? await ctx.db.assignment.findMany({
where: {
resourceId: { in: affectedResourceIds },
status: { not: "CANCELLED" },
},
select: {
id: true,
resourceId: true,
projectId: true,
hoursPerDay: true,
startDate: true,
endDate: true,
},
})
: [];
// Group by resource
const assignmentsByResource = new Map<string, typeof allAssignmentsForResources>();
for (const a of allAssignmentsForResources) {
if (!a.resourceId) continue;
const list = assignmentsByResource.get(a.resourceId) ?? [];
list.push(a);
assignmentsByResource.set(a.resourceId, list);
}
// Determine analysis window (the widest date range from scenario changes)
let windowStart = project.startDate;
let windowEnd = project.endDate;
for (const e of scenarioEntries) {
if (e.startDate < windowStart) windowStart = e.startDate;
if (e.endDate > windowEnd) windowEnd = e.endDate;
}
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.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,
})),
windowStart,
windowEnd,
);
function calculateEntryHours(entry: {
resourceId: string | null;
lcrCents: number;
hoursPerDay: number;
startDate: Date;
endDate: Date;
availability: typeof DEFAULT_AVAILABILITY;
}) {
if (!entry.resourceId) {
return calculateAllocation({
lcrCents: entry.lcrCents,
hoursPerDay: entry.hoursPerDay,
startDate: entry.startDate,
endDate: entry.endDate,
availability: entry.availability,
}).totalHours;
}
return calculateEffectiveBookedHours({
availability: entry.availability,
startDate: entry.startDate,
endDate: entry.endDate,
hoursPerDay: entry.hoursPerDay,
periodStart: windowStart,
periodEnd: windowEnd,
context: contexts.get(entry.resourceId),
});
}
// Compute scenario totals
let scenarioCostCents = 0;
let scenarioHours = 0;
for (const entry of scenarioEntries) {
const totalHours = calculateEntryHours(entry);
scenarioCostCents += Math.round(totalHours * entry.lcrCents);
scenarioHours += totalHours;
}
let baselineCostCents = 0;
let baselineHours = 0;
for (const assignment of currentAssignments) {
const availability = (assignment.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
const totalHours = assignment.resourceId
? calculateEffectiveBookedHours({
availability,
startDate: assignment.startDate,
endDate: assignment.endDate,
hoursPerDay: assignment.hoursPerDay,
periodStart: windowStart,
periodEnd: windowEnd,
context: contexts.get(assignment.resourceId),
})
: calculateAllocation({
lcrCents: assignment.resource?.lcrCents ?? 0,
hoursPerDay: assignment.hoursPerDay,
startDate: assignment.startDate,
endDate: assignment.endDate,
availability,
}).totalHours;
baselineHours += totalHours;
baselineCostCents += Math.round(totalHours * (assignment.resource?.lcrCents ?? 0));
}
const resourceImpacts = affectedResourceIds.map((resId) => {
const resource = resourceMap.get(resId);
if (!resource) return null;
const availability = (resource.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
const context = contexts.get(resId);
const totalAvailableHours = calculateEffectiveAvailableHours({
availability,
periodStart: windowStart,
periodEnd: windowEnd,
context,
});
// Current utilization on this project
const currentProjectAssignments = (assignmentsByResource.get(resId) ?? []).filter(
(a) => a.projectId === projectId,
);
let currentProjectHours = 0;
for (const a of currentProjectAssignments) {
currentProjectHours += calculateEffectiveBookedHours({
availability,
startDate: a.startDate,
endDate: a.endDate,
hoursPerDay: a.hoursPerDay,
periodStart: windowStart,
periodEnd: windowEnd,
context,
});
}
// Scenario hours for this resource on this project
const scenarioResourceEntries = scenarioEntries.filter((e) => e.resourceId === resId);
let scenarioProjectHours = 0;
for (const e of scenarioResourceEntries) {
scenarioProjectHours += calculateEffectiveBookedHours({
availability,
startDate: e.startDate,
endDate: e.endDate,
hoursPerDay: e.hoursPerDay,
periodStart: windowStart,
periodEnd: windowEnd,
context,
});
}
// Total hours across all projects (excluding this project's current, adding scenario)
const otherProjectAssignments = (assignmentsByResource.get(resId) ?? []).filter(
(a) => a.projectId !== projectId,
);
let otherProjectsHours = 0;
for (const a of otherProjectAssignments) {
otherProjectsHours += calculateEffectiveBookedHours({
availability,
startDate: a.startDate,
endDate: a.endDate,
hoursPerDay: a.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: resId,
resourceName: resource.displayName,
chargeabilityTarget: resource.chargeabilityTarget,
currentUtilization: Math.round(currentUtilization * 10) / 10,
scenarioUtilization: Math.round(scenarioUtilization * 10) / 10,
utilizationDelta: Math.round((scenarioUtilization - currentUtilization) * 10) / 10,
isOverallocated: scenarioUtilization > 100,
};
}).filter((x): x is NonNullable<typeof x> => x !== null);
// Build warnings
const warnings: string[] = [];
for (const impact of resourceImpacts) {
if (impact && 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}%`);
}
// Skill coverage: how many unique skills does the scenario team bring vs. current?
const currentSkills = new Set<string>();
const scenarioSkills = new Set<string>();
for (const a of currentAssignments) {
const skills = (a.resource?.skills ?? []) as Array<{ skill: string }>;
for (const s of skills) currentSkills.add(s.skill.toLowerCase());
}
for (const entry of scenarioEntries) {
if (!entry.resourceId) continue;
const resource = resourceMap.get(entry.resourceId);
const skills = (resource?.skills ?? []) as Array<{ skill: string }>;
for (const s of skills) scenarioSkills.add(s.skill.toLowerCase());
}
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,
};
}),
/**
* Applies a scenario: creates real assignments from scenario changes.
* Manager+ access required.
*/
applyScenario: controllerProcedure
.input(SimulateInputSchema)
.mutation(async ({ ctx, input }) => {
const { projectId, changes } = input;
const project = await ctx.db.project.findUnique({
where: { id: projectId },
select: { id: true, name: true },
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const created: string[] = [];
for (const change of changes) {
if (change.remove && change.assignmentId) {
// Cancel the existing assignment
await ctx.db.assignment.update({
where: { id: change.assignmentId },
data: { status: "CANCELLED" },
});
continue;
}
if (change.assignmentId) {
// Modify existing assignment
await ctx.db.assignment.update({
where: { id: change.assignmentId },
data: {
startDate: change.startDate,
endDate: change.endDate,
hoursPerDay: change.hoursPerDay,
...(change.resourceId ? { resourceId: change.resourceId } : {}),
...(change.roleId ? { roleId: change.roleId } : {}),
},
});
created.push(change.assignmentId);
continue;
}
if (!change.resourceId) {
// Skip entries without a resource — cannot create an assignment
continue;
}
// Look up the resource LCR for dailyCostCents
const resource = await ctx.db.resource.findUnique({
where: { id: change.resourceId },
select: { lcrCents: true },
});
const dailyCostCents = Math.round((resource?.lcrCents ?? 0) * change.hoursPerDay);
const newAssignment = await ctx.db.assignment.create({
data: {
projectId,
resourceId: change.resourceId,
...(change.roleId ? { roleId: change.roleId } : {}),
startDate: change.startDate,
endDate: change.endDate,
hoursPerDay: change.hoursPerDay,
percentage: 100,
dailyCostCents,
status: "PROPOSED",
metadata: {},
},
});
created.push(newAssignment.id);
}
void createAuditEntry({
db: ctx.db,
entityType: "ScenarioApplication",
entityId: projectId,
entityName: project.name,
action: "CREATE",
userId: ctx.dbUser?.id,
summary: `Applied scenario to project "${project.name}" (${created.length} allocations created/modified)`,
metadata: { appliedCount: created.length, assignmentIds: created },
source: "ui",
});
return { appliedCount: created.length };
}),
});