refactor(api): split resource graph allocation assembly
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user