cd78f72f33
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>
207 lines
6.6 KiB
TypeScript
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,
|
|
};
|
|
}
|