108 lines
3.3 KiB
TypeScript
108 lines
3.3 KiB
TypeScript
import type { Allocation, BudgetStatus, BudgetWarning } from "@planarchy/shared";
|
|
import { BUDGET_WARNING_THRESHOLDS } from "@planarchy/shared";
|
|
|
|
/**
|
|
* Computes budget status for a project given its allocations.
|
|
* Pure function — all data passed as parameters.
|
|
*/
|
|
export function computeBudgetStatus(
|
|
budgetCents: number,
|
|
winProbability: number,
|
|
allocations: Pick<Allocation, "status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay">[],
|
|
projectStartDate: Date,
|
|
projectEndDate: Date,
|
|
): BudgetStatus {
|
|
const activeStatuses = new Set(["CONFIRMED", "ACTIVE"]);
|
|
const proposedStatuses = new Set(["PROPOSED"]);
|
|
|
|
let confirmedCents = 0;
|
|
let proposedCents = 0;
|
|
|
|
for (const alloc of allocations) {
|
|
const days = countWorkingDaysInRange(
|
|
new Date(alloc.startDate),
|
|
new Date(alloc.endDate),
|
|
);
|
|
const totalCents = alloc.dailyCostCents * days;
|
|
|
|
if (activeStatuses.has(alloc.status)) {
|
|
confirmedCents += totalCents;
|
|
} else if (proposedStatuses.has(alloc.status)) {
|
|
proposedCents += totalCents;
|
|
}
|
|
}
|
|
|
|
const allocatedCents = confirmedCents + proposedCents;
|
|
const remainingCents = budgetCents - allocatedCents;
|
|
const utilizationPercent = budgetCents > 0 ? (allocatedCents / budgetCents) * 100 : 0;
|
|
const winProbabilityWeightedCents = Math.round(allocatedCents * (winProbability / 100));
|
|
|
|
const warnings: BudgetWarning[] = [];
|
|
|
|
if (utilizationPercent >= BUDGET_WARNING_THRESHOLDS.CRITICAL) {
|
|
warnings.push({
|
|
level: "critical",
|
|
code: "BUDGET_CRITICAL",
|
|
message: `Budget utilization at ${utilizationPercent.toFixed(1)}% — critical threshold exceeded`,
|
|
thresholdPercent: BUDGET_WARNING_THRESHOLDS.CRITICAL,
|
|
currentPercent: utilizationPercent,
|
|
});
|
|
} else if (utilizationPercent >= BUDGET_WARNING_THRESHOLDS.WARNING) {
|
|
warnings.push({
|
|
level: "warning",
|
|
code: "BUDGET_WARNING",
|
|
message: `Budget utilization at ${utilizationPercent.toFixed(1)}% — approaching limit`,
|
|
thresholdPercent: BUDGET_WARNING_THRESHOLDS.WARNING,
|
|
currentPercent: utilizationPercent,
|
|
});
|
|
} else if (utilizationPercent >= BUDGET_WARNING_THRESHOLDS.INFO) {
|
|
warnings.push({
|
|
level: "info",
|
|
code: "BUDGET_INFO",
|
|
message: `Budget utilization at ${utilizationPercent.toFixed(1)}%`,
|
|
thresholdPercent: BUDGET_WARNING_THRESHOLDS.INFO,
|
|
currentPercent: utilizationPercent,
|
|
});
|
|
}
|
|
|
|
if (allocatedCents > budgetCents) {
|
|
warnings.push({
|
|
level: "critical",
|
|
code: "BUDGET_EXCEEDED",
|
|
message: `Budget exceeded by ${((allocatedCents - budgetCents) / 100).toFixed(2)} EUR`,
|
|
thresholdPercent: 100,
|
|
currentPercent: utilizationPercent,
|
|
});
|
|
}
|
|
|
|
return {
|
|
budgetCents,
|
|
allocatedCents,
|
|
confirmedCents,
|
|
proposedCents,
|
|
remainingCents,
|
|
utilizationPercent,
|
|
winProbabilityWeightedCents,
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
/** Simple working day counter (Mon-Fri) for budget calculations */
|
|
function countWorkingDaysInRange(startDate: Date, endDate: Date): number {
|
|
let count = 0;
|
|
const current = new Date(startDate);
|
|
current.setHours(0, 0, 0, 0);
|
|
const end = new Date(endDate);
|
|
end.setHours(0, 0, 0, 0);
|
|
|
|
while (current <= end) {
|
|
const dow = current.getDay();
|
|
if (dow !== 0 && dow !== 6) {
|
|
count++;
|
|
}
|
|
current.setDate(current.getDate() + 1);
|
|
}
|
|
|
|
return count;
|
|
}
|