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) Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com> Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
271 lines
8.3 KiB
TypeScript
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);
|
|
}
|