chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export * from "./validator.js";
|
||||
@@ -0,0 +1,206 @@
|
||||
import type {
|
||||
Allocation,
|
||||
ConflictDetail,
|
||||
CostImpact,
|
||||
Resource,
|
||||
ShiftValidationResult,
|
||||
ValidationError,
|
||||
ValidationWarning,
|
||||
} from "@planarchy/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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user