Files
Nexus/packages/api/src/router/chargeability-report.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

315 lines
12 KiB
TypeScript

import {
deriveResourceForecast,
calculateGroupChargeability,
calculateGroupTarget,
sumFte,
getMonthRange,
getMonthKeys,
countWorkingDaysInOverlap,
calculateSAH,
calculateAllocation,
DEFAULT_CALCULATION_RULES,
type AssignmentSlice,
} from "@planarchy/engine";
import type { CalculationRule, AbsenceDay } from "@planarchy/shared";
import type { SpainScheduleRule } from "@planarchy/shared";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@planarchy/application";
import { VacationStatus } from "@planarchy/db";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
export const chargeabilityReportRouter = createTRPCRouter({
getReport: controllerProcedure
.input(
z.object({
startMonth: z.string().regex(/^\d{4}-\d{2}$/), // "2026-01"
endMonth: z.string().regex(/^\d{4}-\d{2}$/),
orgUnitId: z.string().optional(),
managementLevelGroupId: z.string().optional(),
countryId: z.string().optional(),
includeProposed: z.boolean().default(false),
}),
)
.query(async ({ ctx, input }) => {
const { startMonth, endMonth, includeProposed } = input;
// Parse month range
const [startYear, startMo] = startMonth.split("-").map(Number) as [number, number];
const [endYear, endMo] = endMonth.split("-").map(Number) as [number, number];
const rangeStart = getMonthRange(startYear, startMo).start;
const rangeEnd = getMonthRange(endYear, endMo).end;
const monthKeys = getMonthKeys(rangeStart, rangeEnd);
// Fetch resources with filters
const resourceWhere = {
isActive: true,
chgResponsibility: true,
departed: false,
rolledOff: false,
...(input.orgUnitId ? { orgUnitId: input.orgUnitId } : {}),
...(input.managementLevelGroupId ? { managementLevelGroupId: input.managementLevelGroupId } : {}),
...(input.countryId ? { countryId: input.countryId } : {}),
};
const resources = await ctx.db.resource.findMany({
where: resourceWhere,
select: {
id: true,
eid: true,
displayName: true,
fte: true,
chargeabilityTarget: true,
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
orgUnit: { select: { id: true, name: true } },
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
managementLevel: { select: { id: true, name: true } },
metroCity: { select: { id: true, name: true } },
},
orderBy: { displayName: "asc" },
});
if (resources.length === 0) {
return {
monthKeys,
resources: [],
groupTotals: monthKeys.map((key) => ({
monthKey: key,
totalFte: 0,
chg: 0,
target: 0,
gap: 0,
})),
};
}
// Fetch all bookings (assignments + legacy allocations) in the date range
const resourceIds = resources.map((r) => r.id);
const allBookings = await listAssignmentBookings(ctx.db, {
startDate: rangeStart,
endDate: rangeEnd,
resourceIds,
});
// Enrich with utilization category — fetch project util categories in bulk
const projectIds = [...new Set(allBookings.map((b) => b.projectId))];
const projectUtilCats = projectIds.length > 0
? await ctx.db.project.findMany({
where: { id: { in: projectIds } },
select: { id: true, utilizationCategory: { select: { code: true } } },
})
: [];
const projectUtilCatMap = new Map(
projectUtilCats.map((p) => [p.id, p.utilizationCategory?.code ?? null]),
);
// Normalize bookings to a common shape
const assignments = allBookings
.filter((booking) => booking.resourceId !== null)
.filter((booking) => isChargeabilityActualBooking(booking, includeProposed))
.map((b) => ({
resourceId: b.resourceId!,
startDate: b.startDate,
endDate: b.endDate,
hoursPerDay: b.hoursPerDay,
project: {
status: b.project.status,
utilizationCategory: { code: projectUtilCatMap.get(b.projectId) ?? null },
},
}));
// Fetch vacations/absences in the range (including type for rules engine)
const vacations = await ctx.db.vacation.findMany({
where: {
resourceId: { in: resourceIds },
status: VacationStatus.APPROVED,
startDate: { lte: rangeEnd },
endDate: { gte: rangeStart },
},
select: {
resourceId: true,
startDate: true,
endDate: true,
type: true,
isHalfDay: true,
},
});
// Load calculation rules for chargeability adjustments
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
try {
const dbRules = await ctx.db.calculationRule.findMany({
where: { isActive: true },
orderBy: [{ priority: "desc" }],
});
if (dbRules.length > 0) {
calcRules = dbRules as unknown as CalculationRule[];
}
} catch {
// table may not exist yet
}
// Build per-resource, per-month forecasts
const resourceRows = resources.map((resource) => {
const resourceAssignments = assignments.filter((a) => a.resourceId === resource.id);
const resourceVacations = vacations.filter((v) => v.resourceId === resource.id);
// Prefer mgmt level group target; fall back to legacy chargeabilityTarget (0-100 → 0-1)
const targetPct = resource.managementLevelGroup?.targetPercentage
?? (resource.chargeabilityTarget / 100);
const dailyHours = resource.country?.dailyWorkingHours ?? 8;
const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null;
const months = monthKeys.map((key) => {
const [y, m] = key.split("-").map(Number) as [number, number];
const { start: monthStart, end: monthEnd } = getMonthRange(y, m);
// Compute absence days for SAH
const absenceDates: string[] = [];
for (const v of resourceVacations) {
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
if (vStart > vEnd) continue;
const cursor = new Date(vStart);
cursor.setUTCHours(0, 0, 0, 0);
const endNorm = new Date(vEnd);
endNorm.setUTCHours(0, 0, 0, 0);
while (cursor <= endNorm) {
absenceDates.push(cursor.toISOString().slice(0, 10));
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
// Calculate SAH for this resource+month
const sahResult = calculateSAH({
dailyWorkingHours: dailyHours,
scheduleRules,
fte: resource.fte,
periodStart: monthStart,
periodEnd: monthEnd,
publicHolidays: [], // TODO: integrate public holidays from country
absenceDays: absenceDates,
});
// Build typed absence days for this resource in this month
const monthAbsenceDays: AbsenceDay[] = [];
for (const v of resourceVacations) {
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
if (vStart > vEnd) continue;
const absCursor = new Date(vStart);
absCursor.setUTCHours(0, 0, 0, 0);
const absEndNorm = new Date(vEnd);
absEndNorm.setUTCHours(0, 0, 0, 0);
const triggerType = v.type === "SICK" ? "SICK" as const
: v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
: "VACATION" as const;
while (absCursor <= absEndNorm) {
monthAbsenceDays.push({
date: new Date(absCursor),
type: triggerType,
...(v.isHalfDay ? { isHalfDay: true } : {}),
});
absCursor.setUTCDate(absCursor.getUTCDate() + 1);
}
}
// Build assignment slices for this month, using rules to compute chargeable hours
const slices: AssignmentSlice[] = [];
for (const a of resourceAssignments) {
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
if (workingDays <= 0) continue;
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
// If there are absences and rules, compute rules-adjusted chargeable hours
if (monthAbsenceDays.length > 0) {
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
const calcResult = calculateAllocation({
lcrCents: 0, // we only need hours, not costs
hoursPerDay: a.hoursPerDay,
startDate: overlapStart,
endDate: overlapEnd,
availability: { monday: dailyHours, tuesday: dailyHours, wednesday: dailyHours, thursday: dailyHours, friday: dailyHours, saturday: 0, sunday: 0 },
absenceDays: monthAbsenceDays,
calculationRules: calcRules,
});
slices.push({
hoursPerDay: a.hoursPerDay,
workingDays,
categoryCode,
...(calcResult.totalChargeableHours !== undefined ? { totalChargeableHours: calcResult.totalChargeableHours } : {}),
});
} else {
slices.push({
hoursPerDay: a.hoursPerDay,
workingDays,
categoryCode,
});
}
}
const forecast = deriveResourceForecast({
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
sah: sahResult.standardAvailableHours,
});
return {
monthKey: key,
sah: sahResult.standardAvailableHours,
...forecast,
};
});
return {
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
fte: resource.fte,
country: resource.country?.code ?? null,
city: resource.metroCity?.name ?? null,
orgUnit: resource.orgUnit?.name ?? null,
mgmtGroup: resource.managementLevelGroup?.name ?? null,
mgmtLevel: resource.managementLevel?.name ?? null,
targetPct,
months,
};
});
// Compute group totals per month
const groupTotals = monthKeys.map((key, monthIdx) => {
const groupInputs = resourceRows.map((r) => ({
fte: r.fte,
chargeability: r.months[monthIdx]!.chg,
}));
const targetInputs = resourceRows.map((r) => ({
fte: r.fte,
targetPercentage: r.targetPct,
}));
const chg = calculateGroupChargeability(groupInputs);
const target = calculateGroupTarget(targetInputs);
return {
monthKey: key,
totalFte: sumFte(resourceRows),
chg,
target,
gap: chg - target,
};
});
const directory = await getAnonymizationDirectory(ctx.db);
return {
monthKeys,
resources: anonymizeResources(resourceRows, directory),
groupTotals,
};
}),
});