import { calculateAllocation } from "@capakraken/engine/allocation"; import type { WeekdayAvailability } from "@capakraken/shared"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { createTRPCRouter, controllerProcedure, protectedProcedure } 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: protectedProcedure .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { 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(); 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(); 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(); 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 => 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(); const scenarioSkills = new Set(); 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 }; }), });