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( (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(); 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); }