368fd6d7ad
Introduces an admin-configurable rules engine that determines per-day cost attribution (CHARGE/ZERO/REDUCE) and chargeability reporting (COUNT/SKIP) for absence types (sick, vacation, public holiday). Includes shared types, Zod schemas, Prisma model, rule matching with specificity scoring, default rules, calculator integration, CRUD API router, seed data, chargeability report integration, and admin UI. 283/283 engine tests, 209/209 API tests, 0 TS errors. Co-Authored-By: claude-flow <ruv@ruv.net>
110 lines
3.5 KiB
TypeScript
110 lines
3.5 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"> & {
|
|
/** When provided (from rules engine), used instead of dailyCostCents * days */
|
|
adjustedTotalCostCents?: number;
|
|
})[],
|
|
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 totalCents = alloc.adjustedTotalCostCents ?? (alloc.dailyCostCents * countWorkingDaysInRange(
|
|
new Date(alloc.startDate),
|
|
new Date(alloc.endDate),
|
|
));
|
|
|
|
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;
|
|
}
|