b41c1d2501
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61) Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com> Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
222 lines
6.6 KiB
TypeScript
222 lines
6.6 KiB
TypeScript
import type {
|
|
Allocation,
|
|
ConflictDetail,
|
|
CostImpact,
|
|
Resource,
|
|
ShiftValidationResult,
|
|
ValidationError,
|
|
ValidationWarning,
|
|
} from "@nexus/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<Resource, "id" | "displayName" | "lcrCents" | "availability">;
|
|
allAllocationsForResource: Pick<
|
|
Allocation,
|
|
"id" | "startDate" | "endDate" | "hoursPerDay" | "status" | "projectId"
|
|
>[];
|
|
/** 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,
|
|
};
|
|
}
|