Files
Nexus/packages/engine/src/allocation/calculator.ts
T
Hartmut 368fd6d7ad feat: calculation rules engine for decoupled cost attribution and chargeability
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>
2026-03-15 09:29:12 +01:00

258 lines
8.3 KiB
TypeScript

import type {
AbsenceDay,
AllocationCalculationInput,
AllocationCalculationResult,
DailyBreakdown,
WeekdayAvailability,
} from "@planarchy/shared";
import type { AbsenceTrigger } from "@planarchy/shared";
import { getRecurringHoursForDay } from "./recurrence.js";
import { findMatchingRule, applyCostEffect } from "../rules/engine.js";
/** Day-of-week index → availability key */
const DOW_KEYS: (keyof WeekdayAvailability)[] = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
/**
* Returns the availability hours for a given date.
* Returns 0 for days not in the availability map (treated as non-working).
*/
export function getAvailableHoursForDate(
date: Date,
availability: WeekdayAvailability,
): number {
const key = DOW_KEYS[date.getDay()];
if (!key) return 0;
return availability[key] ?? 0;
}
/**
* Checks whether a given date is a working day for this resource.
*/
export function isWorkday(date: Date, availability: WeekdayAvailability): boolean {
return getAvailableHoursForDate(date, availability) > 0;
}
/**
* Counts working days between startDate and endDate (inclusive).
*/
export function countWorkingDays(
startDate: Date,
endDate: Date,
availability: WeekdayAvailability,
): 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) {
if (isWorkday(current, availability)) {
count++;
}
current.setDate(current.getDate() + 1);
}
return count;
}
/**
* Core allocation calculator: given hours/day, LCR, and date range,
* computes total hours, total cost, and daily breakdown.
*
* When calculationRules + absenceDays are provided, the rules engine
* determines per-day cost attribution and chargeability effects.
*
* Monetary values always in integer cents.
*/
export function calculateAllocation(input: AllocationCalculationInput): AllocationCalculationResult {
const {
lcrCents, hoursPerDay, startDate, endDate, availability, includeSaturday,
recurrence, vacationDates, absenceDays, calculationRules, orderType, projectId,
} = input;
// When includeSaturday is not explicitly true, zero out saturday availability
const effectiveAvailability: WeekdayAvailability = includeSaturday
? availability
: { ...availability, saturday: 0 };
// Pre-compute vacation date set (YYYY-MM-DD strings for O(1) lookup)
const vacationDateSet = new Set<string>(
(vacationDates ?? []).map((d) => {
const copy = new Date(d);
copy.setHours(0, 0, 0, 0);
return copy.toISOString().split("T")[0]!;
}),
);
// Pre-compute typed absence day lookup (date key → AbsenceDay)
const absenceDayMap = new Map<string, AbsenceDay>();
for (const ad of absenceDays ?? []) {
const copy = new Date(ad.date);
copy.setHours(0, 0, 0, 0);
absenceDayMap.set(copy.toISOString().split("T")[0]!, ad);
}
const hasRules = calculationRules && calculationRules.length > 0;
const allocationStart = new Date(startDate);
allocationStart.setHours(0, 0, 0, 0);
const breakdown: DailyBreakdown[] = [];
const current = new Date(startDate);
current.setHours(0, 0, 0, 0);
const end = new Date(endDate);
end.setHours(0, 0, 0, 0);
let workingDays = 0;
let totalHours = 0;
let totalChargeableHours = 0;
let totalProjectCostCents = 0;
while (current <= end) {
const dateKey = current.toISOString().split("T")[0]!;
const isVacation = vacationDateSet.has(dateKey);
const absenceDay = absenceDayMap.get(dateKey);
let effectiveHours: number;
let dayIsWorkday: boolean;
let absenceType: AbsenceTrigger | undefined;
let chargeableHours: number | undefined;
let projectCostCents: number;
// Determine if this is an absence day (from typed absenceDays or legacy vacationDates)
const isAbsent = isVacation || !!absenceDay;
if (absenceDay) {
absenceType = absenceDay.type;
} else if (isVacation) {
absenceType = "VACATION";
}
if (isAbsent && hasRules && absenceType) {
// ── Rules-based absence handling ──
const availableHours = getAvailableHoursForDate(current, effectiveAvailability);
dayIsWorkday = availableHours > 0;
if (!dayIsWorkday) {
// Weekend/non-working day — no effect regardless of absence
effectiveHours = 0;
chargeableHours = 0;
projectCostCents = 0;
} else {
const normalHours = Math.min(hoursPerDay, availableHours);
const halfDayFactor = absenceDay?.isHalfDay ? 0.5 : 1;
const absentHours = normalHours * halfDayFactor;
const workedHours = normalHours - absentHours;
// The person does NOT work the absent portion
effectiveHours = workedHours;
const match = findMatchingRule(calculationRules!, absenceType, projectId, orderType);
if (match) {
// Cost effect: how much does the project pay?
const normalCostCents = Math.round(absentHours * lcrCents);
const absentProjectCost = applyCostEffect(normalCostCents, match.costEffect, match.costReductionPercent);
const workedCostCents = Math.round(workedHours * lcrCents);
projectCostCents = workedCostCents + absentProjectCost;
// Chargeability effect: does the person count as chargeable?
if (match.chargeabilityEffect === "COUNT") {
chargeableHours = normalHours; // full hours count toward chargeability
} else {
chargeableHours = workedHours; // only worked portion counts
}
} else {
// No matching rule — legacy behavior: block absent hours
effectiveHours = workedHours;
projectCostCents = Math.round(workedHours * lcrCents);
chargeableHours = workedHours;
}
workingDays++;
totalHours += effectiveHours;
}
} else if (isVacation && !hasRules) {
// ── Legacy behavior: vacation blocks the day entirely ──
effectiveHours = 0;
dayIsWorkday = false;
projectCostCents = 0;
} else if (recurrence) {
// Recurrence pattern — may override hoursPerDay or skip the day entirely
const recurHours = getRecurringHoursForDay(current, recurrence, hoursPerDay, allocationStart);
if (recurHours === 0) {
effectiveHours = 0;
dayIsWorkday = false;
projectCostCents = 0;
} else {
const availableHours = getAvailableHoursForDate(current, effectiveAvailability);
dayIsWorkday = availableHours > 0;
effectiveHours = dayIsWorkday ? Math.min(recurHours, availableHours) : 0;
projectCostCents = Math.round(effectiveHours * lcrCents);
}
if (dayIsWorkday) {
workingDays++;
totalHours += effectiveHours;
}
} else {
const availableHours = getAvailableHoursForDate(current, effectiveAvailability);
dayIsWorkday = availableHours > 0;
effectiveHours = dayIsWorkday ? Math.min(hoursPerDay, availableHours) : 0;
projectCostCents = Math.round(effectiveHours * lcrCents);
if (dayIsWorkday) {
workingDays++;
totalHours += effectiveHours;
}
}
// costCents on DailyBreakdown = project cost (rule-adjusted)
const dayCostCents = projectCostCents;
breakdown.push({
date: new Date(current),
isWorkday: dayIsWorkday,
hours: effectiveHours,
costCents: dayCostCents,
...(absenceType ? { absenceType } : {}),
...(chargeableHours !== undefined ? { chargeableHours } : {}),
});
totalChargeableHours += chargeableHours ?? effectiveHours;
totalProjectCostCents += dayCostCents;
current.setDate(current.getDate() + 1);
}
const totalCostCents = breakdown.reduce((sum, d) => sum + d.costCents, 0);
const dailyCostCents = Math.round(hoursPerDay * lcrCents);
return {
workingDays,
totalHours,
totalCostCents,
dailyCostCents,
dailyBreakdown: breakdown,
...(hasRules ? { totalChargeableHours, totalProjectCostCents } : {}),
};
}
/**
* Calculates total allocation cost for a simple case (without full breakdown).
* Useful for quick budget checks.
*/
export function calculateTotalCost(
lcrCents: number,
hoursPerDay: number,
workingDays: number,
): number {
return Math.round(lcrCents * hoursPerDay * workingDays);
}