From 7411aaa77b7b19c902649e07f0b8af170bca3adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 22:14:53 +0200 Subject: [PATCH] refactor(api): split resource graph allocation assembly --- .../computation-graph-resource-allocation.ts | 138 ++++++++++++++++++ .../src/router/computation-graph-resource.ts | 130 +++-------------- 2 files changed, 162 insertions(+), 106 deletions(-) create mode 100644 packages/api/src/router/computation-graph-resource-allocation.ts diff --git a/packages/api/src/router/computation-graph-resource-allocation.ts b/packages/api/src/router/computation-graph-resource-allocation.ts new file mode 100644 index 0000000..5f34871 --- /dev/null +++ b/packages/api/src/router/computation-graph-resource-allocation.ts @@ -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>["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, + }; +} diff --git a/packages/api/src/router/computation-graph-resource.ts b/packages/api/src/router/computation-graph-resource.ts index b81512f..8167386 100644 --- a/packages/api/src/router/computation-graph-resource.ts +++ b/packages/api/src/router/computation-graph-resource.ts @@ -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, }); }