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>
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
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)[] = [
|
||||
@@ -64,10 +67,16 @@ export function countWorkingDays(
|
||||
* 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 } = input;
|
||||
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
|
||||
@@ -83,6 +92,16 @@ export function calculateAllocation(input: AllocationCalculationInput): Allocati
|
||||
}),
|
||||
);
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -94,49 +113,120 @@ export function calculateAllocation(input: AllocationCalculationInput): Allocati
|
||||
|
||||
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;
|
||||
|
||||
if (isVacation) {
|
||||
// Vacation always blocks the day
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Cost = hours × lcrCents (already in cents-per-hour)
|
||||
const dayCostCents = Math.round(effectiveHours * lcrCents);
|
||||
// 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 } : {}),
|
||||
});
|
||||
|
||||
if (dayIsWorkday) {
|
||||
workingDays++;
|
||||
totalHours += effectiveHours;
|
||||
}
|
||||
totalChargeableHours += chargeableHours ?? effectiveHours;
|
||||
totalProjectCostCents += dayCostCents;
|
||||
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
@@ -150,6 +240,7 @@ export function calculateAllocation(input: AllocationCalculationInput): Allocati
|
||||
totalCostCents,
|
||||
dailyCostCents,
|
||||
dailyBreakdown: breakdown,
|
||||
...(hasRules ? { totalChargeableHours, totalProjectCostCents } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user