From 3e0d9d9af7dce8fc4096f83571679eb64b592b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 11:03:50 +0200 Subject: [PATCH] refactor(api): extract scenario router helpers --- packages/api/src/router/scenario-apply.ts | 89 +++ packages/api/src/router/scenario-baseline.ts | 114 ++++ packages/api/src/router/scenario-shared.ts | 196 ++++++ .../api/src/router/scenario-simulation.ts | 297 ++++++++ packages/api/src/router/scenario.ts | 641 +----------------- 5 files changed, 705 insertions(+), 632 deletions(-) create mode 100644 packages/api/src/router/scenario-apply.ts create mode 100644 packages/api/src/router/scenario-baseline.ts create mode 100644 packages/api/src/router/scenario-shared.ts create mode 100644 packages/api/src/router/scenario-simulation.ts diff --git a/packages/api/src/router/scenario-apply.ts b/packages/api/src/router/scenario-apply.ts new file mode 100644 index 0000000..254df2a --- /dev/null +++ b/packages/api/src/router/scenario-apply.ts @@ -0,0 +1,89 @@ +import { TRPCError } from "@trpc/server"; +import { createAuditEntry } from "../lib/audit.js"; +import type { ScenarioChangeInput, ScenarioDb } from "./scenario-shared.js"; + +export async function applyProjectScenario( + db: ScenarioDb, + input: { + projectId: string; + changes: ScenarioChangeInput[]; + userId?: string; + }, +) { + const { projectId, changes, userId } = input; + + const project = await 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) { + await db.assignment.update({ + where: { id: change.assignmentId }, + data: { status: "CANCELLED" }, + }); + continue; + } + + if (change.assignmentId) { + await 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) { + continue; + } + + const resource = await db.resource.findUnique({ + where: { id: change.resourceId }, + select: { lcrCents: true }, + }); + const dailyCostCents = Math.round((resource?.lcrCents ?? 0) * change.hoursPerDay); + + const newAssignment = await 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, + entityType: "ScenarioApplication", + entityId: projectId, + entityName: project.name, + action: "CREATE", + ...(userId ? { userId } : {}), + summary: `Applied scenario to project "${project.name}" (${created.length} allocations created/modified)`, + metadata: { appliedCount: created.length, assignmentIds: created }, + source: "ui", + }); + + return { appliedCount: created.length }; +} diff --git a/packages/api/src/router/scenario-baseline.ts b/packages/api/src/router/scenario-baseline.ts new file mode 100644 index 0000000..2be6361 --- /dev/null +++ b/packages/api/src/router/scenario-baseline.ts @@ -0,0 +1,114 @@ +import { TRPCError } from "@trpc/server"; +import { + calculateScenarioEntryHours, + getScenarioAvailability, + loadScenarioAvailabilityContexts, + scenarioBaselineAssignmentInclude, + scenarioBaselineProjectSelect, + scenarioDemandInclude, + type ScenarioDb, +} from "./scenario-shared.js"; + +export async function readProjectScenarioBaseline( + db: ScenarioDb, + projectId: string, +) { + const project = await db.project.findUnique({ + where: { id: projectId }, + select: scenarioBaselineProjectSelect, + }); + if (!project) { + throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); + } + + const assignments = await db.assignment.findMany({ + where: { + projectId, + status: { not: "CANCELLED" }, + }, + include: scenarioBaselineAssignmentInclude, + }); + + const demands = await db.demandRequirement.findMany({ + where: { + projectId, + status: { not: "CANCELLED" }, + }, + include: scenarioDemandInclude, + }); + + 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 loadScenarioAvailabilityContexts( + db, + assignments + .flatMap((assignment) => (assignment.resource ? [assignment.resource] : [])), + assignmentRangeStart, + assignmentRangeEnd, + ); + + const baselineAllocations = assignments.map((assignment) => { + const availability = getScenarioAvailability(assignment.resource?.availability); + const lcrCents = assignment.resource?.lcrCents ?? 0; + const totalHours = calculateScenarioEntryHours({ + resourceId: assignment.resourceId, + lcrCents, + hoursPerDay: assignment.hoursPerDay, + startDate: assignment.startDate, + endDate: assignment.endDate, + availability, + }, { + periodStart: assignmentRangeStart, + periodEnd: assignmentRangeEnd, + contexts, + }); + const costCents = Math.round(totalHours * lcrCents); + const workingDays = assignment.hoursPerDay > 0 + ? Math.round((totalHours / assignment.hoursPerDay) * 100) / 100 + : 0; + + return { + id: assignment.id, + resourceId: assignment.resourceId, + resourceName: assignment.resource?.displayName ?? "Unknown", + resourceEid: assignment.resource?.eid ?? "", + lcrCents, + roleId: assignment.roleId, + roleName: assignment.roleEntity?.name ?? assignment.role ?? "", + roleColor: assignment.roleEntity?.color ?? null, + startDate: assignment.startDate.toISOString(), + endDate: assignment.endDate.toISOString(), + hoursPerDay: assignment.hoursPerDay, + status: assignment.status, + costCents, + totalHours, + workingDays, + }; + }); + + const baselineDemands = demands.map((demand) => ({ + id: demand.id, + roleId: demand.roleId, + roleName: demand.roleEntity?.name ?? demand.role ?? "", + roleColor: demand.roleEntity?.color ?? null, + startDate: demand.startDate.toISOString(), + endDate: demand.endDate.toISOString(), + hoursPerDay: demand.hoursPerDay, + headcount: demand.headcount, + status: demand.status, + })); + + return { + project, + assignments: baselineAllocations, + demands: baselineDemands, + totalCostCents: baselineAllocations.reduce((sum, allocation) => sum + allocation.costCents, 0), + totalHours: baselineAllocations.reduce((sum, allocation) => sum + allocation.totalHours, 0), + budgetCents: project.budgetCents, + }; +} diff --git a/packages/api/src/router/scenario-shared.ts b/packages/api/src/router/scenario-shared.ts new file mode 100644 index 0000000..621c043 --- /dev/null +++ b/packages/api/src/router/scenario-shared.ts @@ -0,0 +1,196 @@ +import { calculateAllocation } from "@capakraken/engine/allocation"; +import type { WeekdayAvailability } from "@capakraken/shared"; +import { + calculateEffectiveBookedHours, + loadResourceDailyAvailabilityContexts, +} from "../lib/resource-capacity.js"; +import type { TRPCContext } from "../trpc.js"; + +export type ScenarioDb = TRPCContext["db"]; + +export type ScenarioChangeInput = { + assignmentId?: string | undefined; + resourceId?: string | undefined; + roleId?: string | undefined; + startDate: Date; + endDate: Date; + hoursPerDay: number; + remove?: boolean | undefined; +}; + +export type ScenarioAvailability = { + monday: number; + tuesday: number; + wednesday: number; + thursday: number; + friday: number; + saturday: number; + sunday: number; +}; + +export type ScenarioEntry = { + resourceId: string | null; + lcrCents: number; + hoursPerDay: number; + startDate: Date; + endDate: Date; + availability: ScenarioAvailability; + isNew: boolean; +}; + +type ScenarioResourceContextInput = { + id: string; + availability: unknown; + countryId: string | null; + country?: { code: string } | null; + federalState: string | null; + metroCityId: string | null; + metroCity?: { name: string } | null; +}; + +type ScenarioSkillsValue = Array<{ skill?: string | null }> | null | undefined; + +export const DEFAULT_AVAILABILITY: ScenarioAvailability = { + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + saturday: 0, + sunday: 0, +}; + +export const scenarioRoleSelect = { + id: true, + name: true, + color: true, +} as const; + +export const scenarioResourceSelect = { + 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 } }, +} as const; + +export const scenarioBaselineProjectSelect = { + id: true, + name: true, + shortCode: true, + startDate: true, + endDate: true, + budgetCents: true, + orderType: true, +} as const; + +export const scenarioSimulationProjectSelect = { + id: true, + name: true, + budgetCents: true, + orderType: true, + startDate: true, + endDate: true, +} as const; + +export const scenarioBaselineAssignmentInclude = { + resource: { select: scenarioResourceSelect }, + roleEntity: { select: scenarioRoleSelect }, +} as const; + +export const scenarioDemandInclude = { + roleEntity: { select: scenarioRoleSelect }, +} as const; + +export const scenarioSimulationAssignmentInclude = { + resource: { select: scenarioResourceSelect }, +} as const; + +export const scenarioUtilizationAssignmentSelect = { + id: true, + resourceId: true, + projectId: true, + hoursPerDay: true, + startDate: true, + endDate: true, +} as const; + +export function getScenarioAvailability(availability: unknown): ScenarioAvailability { + return (availability as ScenarioAvailability) ?? DEFAULT_AVAILABILITY; +} + +export function roundToTenths(value: number): number { + return Math.round(value * 10) / 10; +} + +export function collectScenarioSkillSet(skills: ScenarioSkillsValue): Set { + const normalized = new Set(); + for (const skill of skills ?? []) { + if (typeof skill?.skill === "string" && skill.skill.trim().length > 0) { + normalized.add(skill.skill.toLowerCase()); + } + } + return normalized; +} + +function toResourceAvailabilityContextInput(resource: ScenarioResourceContextInput) { + return { + id: resource.id, + availability: resource.availability as WeekdayAvailability, + countryId: resource.countryId, + countryCode: resource.country?.code, + federalState: resource.federalState, + metroCityId: resource.metroCityId, + metroCityName: resource.metroCity?.name, + }; +} + +export async function loadScenarioAvailabilityContexts( + db: ScenarioDb, + resources: ScenarioResourceContextInput[], + periodStart: Date, + periodEnd: Date, +) { + return loadResourceDailyAvailabilityContexts( + db, + resources.map(toResourceAvailabilityContextInput), + periodStart, + periodEnd, + ); +} + +export function calculateScenarioEntryHours( + entry: Pick, + options: { + periodStart: Date; + periodEnd: Date; + contexts: Awaited>; + }, +): number { + 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: options.periodStart, + periodEnd: options.periodEnd, + context: options.contexts.get(entry.resourceId), + }); +} diff --git a/packages/api/src/router/scenario-simulation.ts b/packages/api/src/router/scenario-simulation.ts new file mode 100644 index 0000000..3b3e684 --- /dev/null +++ b/packages/api/src/router/scenario-simulation.ts @@ -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(); + 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(); + 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 => 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(); + 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(); + 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, + }; +} diff --git a/packages/api/src/router/scenario.ts b/packages/api/src/router/scenario.ts index d75cc85..55744b3 100644 --- a/packages/api/src/router/scenario.ts +++ b/packages/api/src/router/scenario.ts @@ -1,25 +1,9 @@ -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; +import { applyProjectScenario } from "./scenario-apply.js"; +import { readProjectScenarioBaseline } from "./scenario-baseline.js"; +import { simulateProjectScenario } from "./scenario-simulation.js"; const ScenarioChangeSchema = z.object({ /** Existing assignment to modify — omit to add a new allocation */ @@ -46,148 +30,7 @@ export const scenarioRouter = createTRPCRouter({ .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, - }; + return readProjectScenarioBaseline(ctx.db, input.projectId); }), /** @@ -196,395 +39,7 @@ export const scenarioRouter = createTRPCRouter({ */ 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, - }; - }), + .mutation(async ({ ctx, input }) => simulateProjectScenario(ctx.db, input)), /** * Applies a scenario: creates real assignments from scenario changes. @@ -592,86 +47,8 @@ export const scenarioRouter = createTRPCRouter({ */ 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 }; - }), + .mutation(async ({ ctx, input }) => applyProjectScenario(ctx.db, { + ...input, + userId: ctx.dbUser?.id, + })), });