refactor(api): split resource graph allocation assembly

This commit is contained in:
2026-03-31 22:14:53 +02:00
parent 831a44973c
commit 7411aaa77b
2 changed files with 162 additions and 106 deletions
@@ -0,0 +1,138 @@
import {
calculateAllocation,
deriveResourceForecast,
type AssignmentSlice,
} from "@capakraken/engine";
import type { CalculationRule, WeekdayAvailability } from "@capakraken/shared";
import type { loadResourceGraphAvailability } from "./computation-graph-resource-availability.js";
type ResourceGraphAssignment = {
id: string;
hoursPerDay: number;
startDate: Date;
endDate: Date;
dailyCostCents: number | null;
status: string;
project: {
id: string;
name: string;
shortCode: string;
utilizationCategory: {
code: string;
} | null;
};
};
type ResourceGraphAllocationInput = {
assignments: ResourceGraphAssignment[];
absenceDays: Awaited<ReturnType<typeof loadResourceGraphAvailability>>["absenceDays"];
calcRules: CalculationRule[];
effectiveAvailableHours: number;
monthEnd: Date;
monthStart: Date;
resourceLcrCents: number;
resourceFte: number;
targetPct: number;
weeklyAvailability: WeekdayAvailability;
};
export function buildResourceAllocationSummary(input: ResourceGraphAllocationInput) {
const slices: AssignmentSlice[] = [];
const assignmentBreakdown: Array<{
id: string;
projectId: string;
projectName: string;
projectCode: string;
status: string;
bookedHours: number;
}> = [];
let totalAllocHours = 0;
let totalAllocCostCents = 0;
let totalChargeableHours = 0;
let totalProjectCostCents = 0;
let totalWorkingDaysInMonth = 0;
let hasRulesEffect = false;
for (const assignment of input.assignments) {
const overlapStart = new Date(Math.max(input.monthStart.getTime(), assignment.startDate.getTime()));
const overlapEnd = new Date(Math.min(input.monthEnd.getTime(), assignment.endDate.getTime()));
const categoryCode = assignment.project.utilizationCategory?.code ?? "Chg";
const calcResult = calculateAllocation({
lcrCents: input.resourceLcrCents,
hoursPerDay: assignment.hoursPerDay,
startDate: overlapStart,
endDate: overlapEnd,
availability: input.weeklyAvailability,
absenceDays: input.absenceDays,
calculationRules: input.calcRules,
});
if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) {
continue;
}
totalWorkingDaysInMonth += calcResult.workingDays;
totalAllocHours += calcResult.totalHours;
totalAllocCostCents += calcResult.totalCostCents;
assignmentBreakdown.push({
id: assignment.id,
projectId: assignment.project.id,
projectName: assignment.project.name,
projectCode: assignment.project.shortCode,
status: assignment.status,
bookedHours: calcResult.totalHours,
});
if (calcResult.totalChargeableHours !== undefined) {
totalChargeableHours += calcResult.totalChargeableHours;
totalProjectCostCents += calcResult.totalProjectCostCents ?? calcResult.totalCostCents;
hasRulesEffect = true;
} else {
totalChargeableHours += calcResult.totalHours;
totalProjectCostCents += calcResult.totalCostCents;
}
slices.push({
hoursPerDay: assignment.hoursPerDay,
workingDays: calcResult.workingDays,
categoryCode,
...(calcResult.totalChargeableHours !== undefined
? { totalChargeableHours: calcResult.totalChargeableHours }
: {}),
});
}
const forecast = deriveResourceForecast({
fte: input.resourceFte,
targetPercentage: input.targetPct,
assignments: slices,
sah: input.effectiveAvailableHours,
});
const dailyCostCents = input.assignments.length > 0
? Math.round(input.assignments[0]!.hoursPerDay * input.resourceLcrCents)
: 0;
const avgHoursPerDay = input.assignments.length > 0
? input.assignments.reduce((sum, assignment) => sum + assignment.hoursPerDay, 0) / input.assignments.length
: 0;
const utilizationPct = input.effectiveAvailableHours > 0
? (totalAllocHours / input.effectiveAvailableHours) * 100
: 0;
const chargeableHours = forecast.chg * input.effectiveAvailableHours;
return {
assignmentBreakdown,
avgHoursPerDay,
chargeableHours,
dailyCostCents,
forecast,
hasRulesEffect,
totalAllocCostCents,
totalAllocHours,
totalChargeableHours,
totalProjectCostCents,
totalWorkingDaysInMonth,
utilizationPct,
};
}
@@ -1,14 +1,11 @@
import {
calculateSAH,
calculateAllocation,
deriveResourceForecast,
getMonthRange,
DEFAULT_CALCULATION_RULES,
type AssignmentSlice,
} from "@capakraken/engine";
import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared";
import type { TRPCContext } from "../trpc.js";
import { fmtEur } from "../lib/format-utils.js";
import { buildResourceAllocationSummary } from "./computation-graph-resource-allocation.js";
import { loadResourceGraphAvailability } from "./computation-graph-resource-availability.js";
import { readResourceBudgetGraph } from "./computation-graph-resource-budget.js";
import { buildResourceGraphSnapshot } from "./computation-graph-resource-graph.js";
@@ -133,71 +130,17 @@ export async function readResourceGraphSnapshot(
absenceDays: absenceDateStrings,
});
const slices: AssignmentSlice[] = [];
const assignmentBreakdown: Array<{
id: string;
projectId: string;
projectName: string;
projectCode: string;
status: string;
bookedHours: number;
}> = [];
let totalAllocHours = 0;
let totalAllocCostCents = 0;
let totalChargeableHours = 0;
let totalProjectCostCents = 0;
let hasRulesEffect = false;
for (const a of assignments) {
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
const calcResult = calculateAllocation({
lcrCents: resource.lcrCents,
hoursPerDay: a.hoursPerDay,
startDate: overlapStart,
endDate: overlapEnd,
availability: weeklyAvailability,
absenceDays,
calculationRules: calcRules,
});
if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) continue;
totalAllocHours += calcResult.totalHours;
totalAllocCostCents += calcResult.totalCostCents;
assignmentBreakdown.push({
id: a.id,
projectId: a.project.id,
projectName: a.project.name,
projectCode: a.project.shortCode,
status: a.status,
bookedHours: calcResult.totalHours,
});
if (calcResult.totalChargeableHours !== undefined) {
totalChargeableHours += calcResult.totalChargeableHours;
totalProjectCostCents += calcResult.totalProjectCostCents ?? calcResult.totalCostCents;
hasRulesEffect = true;
} else {
totalChargeableHours += calcResult.totalHours;
totalProjectCostCents += calcResult.totalCostCents;
}
slices.push({
hoursPerDay: a.hoursPerDay,
workingDays: calcResult.workingDays,
categoryCode,
...(calcResult.totalChargeableHours !== undefined
? { totalChargeableHours: calcResult.totalChargeableHours }
: {}),
});
}
const forecast = deriveResourceForecast({
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
sah: effectiveAvailableHours,
const assignmentSummary = buildResourceAllocationSummary({
assignments,
effectiveAvailableHours,
monthStart,
monthEnd,
resourceLcrCents: resource.lcrCents,
resourceFte: resource.fte,
targetPct,
weeklyAvailability,
absenceDays,
calcRules,
});
const { nodes: budgetNodes, links: budgetLinks } = await readResourceBudgetGraph(
ctx.db,
@@ -206,31 +149,6 @@ export async function readResourceGraphSnapshot(
monthEnd,
);
const dailyCostCents = assignments.length > 0
? Math.round(assignments[0]!.hoursPerDay * resource.lcrCents)
: 0;
const avgHoursPerDay = assignments.length > 0
? assignments.reduce((sum, a) => sum + a.hoursPerDay, 0) / assignments.length
: 0;
const totalWorkingDaysInMonth = assignments.reduce((sum, a) => {
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: resource.lcrCents,
hoursPerDay: a.hoursPerDay,
startDate: overlapStart,
endDate: overlapEnd,
availability: weeklyAvailability,
absenceDays,
calculationRules: calcRules,
});
return sum + calcResult.workingDays;
}, 0);
const utilizationPct = effectiveAvailableHours > 0
? (totalAllocHours / effectiveAvailableHours) * 100
: 0;
const chargeableHours = forecast.chg * effectiveAvailableHours;
return buildResourceGraphSnapshot({
month: input.month,
resource,
@@ -258,17 +176,17 @@ export async function readResourceGraphSnapshot(
baseAvailableHours,
effectiveAvailableHours,
effectiveHoursPerWorkingDay,
totalWorkingDaysInMonth,
totalAllocHours,
totalAllocCostCents,
totalChargeableHours,
totalProjectCostCents,
hasRulesEffect,
dailyCostCents,
avgHoursPerDay,
utilizationPct,
forecast,
chargeableHours,
totalWorkingDaysInMonth: assignmentSummary.totalWorkingDaysInMonth,
totalAllocHours: assignmentSummary.totalAllocHours,
totalAllocCostCents: assignmentSummary.totalAllocCostCents,
totalChargeableHours: assignmentSummary.totalChargeableHours,
totalProjectCostCents: assignmentSummary.totalProjectCostCents,
hasRulesEffect: assignmentSummary.hasRulesEffect,
dailyCostCents: assignmentSummary.dailyCostCents,
avgHoursPerDay: assignmentSummary.avgHoursPerDay,
utilizationPct: assignmentSummary.utilizationPct,
forecast: assignmentSummary.forecast,
chargeableHours: assignmentSummary.chargeableHours,
budgetNodes,
budgetLinks,
resolvedHolidays: resolvedHolidays.map((holiday) => ({
@@ -278,7 +196,7 @@ export async function readResourceGraphSnapshot(
calendarName: holiday.calendarName,
sourceType: holiday.sourceType ?? null,
})),
assignmentBreakdown,
assignmentBreakdown: assignmentSummary.assignmentBreakdown,
absenceDayEquivalent,
});
}