Files
CapaKraken/packages/engine/src/shift/validator.ts
T
Hartmut cd78f72f33 chore: full technical rename planarchy → capakraken
Complete rename of all technical identifiers across the codebase:

Package names (11 packages):
- @planarchy/* → @capakraken/* in all package.json, tsconfig, imports

Import statements: 277 files, 548 occurrences replaced

Database & Docker:
- PostgreSQL user/db: planarchy → capakraken
- Docker volumes: planarchy_pgdata → capakraken_pgdata
- Connection strings updated in docker-compose, .env, CI

CI/CD:
- GitHub Actions workflow: all filter commands updated
- Test database credentials updated

Infrastructure:
- Redis channel: planarchy:sse → capakraken:sse
- Logger service name: planarchy-api → capakraken-api
- Anonymization seed updated
- Start/stop/restart scripts updated

Test data:
- Seed emails: @planarchy.dev → @capakraken.dev
- E2E test credentials: all 11 spec files updated
- Email defaults: @planarchy.app → @capakraken.app
- localStorage keys: planarchy_* → capakraken_*

Documentation: 30+ .md files updated

Verification:
- pnpm install: workspace resolution works
- TypeScript: only pre-existing TS2589 (no new errors)
- Engine: 310/310 tests pass
- Staffing: 37/37 tests pass

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-27 13:18:09 +01:00

207 lines
6.6 KiB
TypeScript

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<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,
};
}