import type { Allocation, ConflictDetail, CostImpact, Resource, ShiftValidationResult, ValidationError, ValidationWarning, } from "@capakraken/shared"; import { calculateAllocation } from "../allocation/calculator.js"; import { validateAvailability } from "../allocation/availability-validator.js"; import { computeBudgetStatus } from "../budget/monitor.js"; export interface ShiftInput { project: { id: string; budgetCents: number; winProbability: number; startDate: Date; endDate: Date; }; newStartDate: Date; newEndDate: Date; allocations: (Pick< Allocation, "id" | "resourceId" | "startDate" | "endDate" | "hoursPerDay" | "percentage" | "role" | "dailyCostCents" | "status" > & { resource: Pick; allAllocationsForResource: Pick[]; /** Extracted from allocation metadata before calling validator */ includeSaturday?: boolean; })[]; } /** * Validates a proposed project timeline shift. * * Pure function — all data passed in, no DB access. * Returns a comprehensive validation result with cost impact and conflicts. */ export function validateShift(input: ShiftInput): ShiftValidationResult { const { project, newStartDate, newEndDate, allocations } = input; const errors: ValidationError[] = []; const warnings: ValidationWarning[] = []; const conflictDetails: ConflictDetail[] = []; const durationMs = project.endDate.getTime() - project.startDate.getTime(); const newDurationMs = newEndDate.getTime() - newStartDate.getTime(); // Validate date range if (newEndDate < newStartDate) { errors.push({ code: "INVALID_DATE_RANGE", message: "New end date must be after new start date", field: "newEndDate", }); return buildResult(false, errors, warnings, conflictDetails, buildZeroCostImpact(project.budgetCents)); } // Warn if duration changed significantly const durationDeltaDays = Math.abs((newDurationMs - durationMs) / (1000 * 60 * 60 * 24)); if (durationDeltaDays > 7) { warnings.push({ code: "DURATION_CHANGED", message: `Project duration changed by ${Math.round(durationDeltaDays)} days`, }); } // Calculate shifted allocation dates and check availability let newTotalCostCents = 0; let currentTotalCostCents = 0; for (const alloc of allocations) { const { resource, allAllocationsForResource } = alloc; const includeSaturday = alloc.includeSaturday ?? false; // Compute current allocation cost const currentCalc = calculateAllocation({ lcrCents: resource.lcrCents, hoursPerDay: alloc.hoursPerDay, startDate: new Date(alloc.startDate), endDate: new Date(alloc.endDate), availability: resource.availability, includeSaturday, }); currentTotalCostCents += currentCalc.totalCostCents; // Shift allocation proportionally within the new project window const shiftedStart = new Date(newStartDate); const shiftedEnd = new Date(newEndDate); // Validate availability for shifted period (excluding current project's allocations) const otherAllocations = allAllocationsForResource.filter( (a) => a.projectId !== project.id, ); const availResult = validateAvailability( shiftedStart, shiftedEnd, alloc.hoursPerDay, resource.availability, otherAllocations, includeSaturday, ); if (!availResult.valid) { const conflictDays = availResult.conflicts.map( (c) => c.date.toISOString().split("T")[0] ?? "", ); conflictDetails.push({ resourceId: resource.id, resourceName: resource.displayName, conflictType: "availability", days: conflictDays, message: `${resource.displayName} has availability conflicts on ${availResult.totalConflictDays} day(s)`, }); if (availResult.totalConflictDays > 5) { errors.push({ code: "AVAILABILITY_CONFLICT", message: `${resource.displayName}: ${availResult.totalConflictDays} day(s) exceed capacity`, resourceId: resource.id, }); } else { warnings.push({ code: "AVAILABILITY_WARNING", message: `${resource.displayName}: ${availResult.totalConflictDays} day(s) may have capacity issues`, resourceId: resource.id, }); } } // Compute new cost for shifted period const newCalc = calculateAllocation({ lcrCents: resource.lcrCents, hoursPerDay: alloc.hoursPerDay, startDate: shiftedStart, endDate: shiftedEnd, availability: resource.availability, includeSaturday, }); newTotalCostCents += newCalc.totalCostCents; } // Budget impact check const currentStatus = computeBudgetStatus( project.budgetCents, project.winProbability, allocations.map((a) => ({ status: a.status, dailyCostCents: a.dailyCostCents, startDate: a.startDate, endDate: a.endDate, hoursPerDay: a.hoursPerDay, })), project.startDate, project.endDate, ); const wouldExceedBudget = newTotalCostCents > project.budgetCents; if (wouldExceedBudget) { errors.push({ code: "BUDGET_EXCEEDED", message: `Shift would exceed budget by ${((newTotalCostCents - project.budgetCents) / 100).toFixed(2)} EUR`, }); } else if (newTotalCostCents > project.budgetCents * 0.95) { warnings.push({ code: "BUDGET_NEAR_LIMIT", message: `Shift would use ${((newTotalCostCents / project.budgetCents) * 100).toFixed(1)}% of budget`, }); } const costImpact: CostImpact = { currentTotalCents: currentTotalCostCents, newTotalCents: newTotalCostCents, deltaCents: newTotalCostCents - currentTotalCostCents, budgetCents: project.budgetCents, budgetUtilizationBefore: currentStatus.utilizationPercent, budgetUtilizationAfter: project.budgetCents > 0 ? (newTotalCostCents / project.budgetCents) * 100 : 0, wouldExceedBudget, }; return buildResult(errors.length === 0, errors, warnings, conflictDetails, costImpact); } function buildResult( valid: boolean, errors: ValidationError[], warnings: ValidationWarning[], conflictDetails: ConflictDetail[], costImpact: CostImpact, ): ShiftValidationResult { return { valid, errors, warnings, costImpact, conflictDetails }; } function buildZeroCostImpact(budgetCents: number): CostImpact { return { currentTotalCents: 0, newTotalCents: 0, deltaCents: 0, budgetCents, budgetUtilizationBefore: 0, budgetUtilizationAfter: 0, wouldExceedBudget: false, }; }