Files
Nexus/packages/engine/src/allocation/calculator.ts
T
Hartmut b41c1d2501
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
2026-05-21 16:28:40 +02:00

271 lines
8.3 KiB
TypeScript

import type {
AbsenceDay,
AllocationCalculationInput,
AllocationCalculationResult,
DailyBreakdown,
WeekdayAvailability,
} from "@nexus/shared";
import type { AbsenceTrigger } from "@nexus/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);
}