Files
CapaKraken/packages/api/src/router/chargeability-report-procedure-support.ts
T
Hartmut 1204c186ef perf(api): eliminate N+1 queries, add query guards and missing indexes
- Notification fan-out: replace sequential for loops with Promise.all (allocation-effects, notification-broadcast, create-notification)
- Public holiday batch: group resources by location combo, resolve holidays once per group, replace per-holiday delete/findFirst/create with 3 batched queries (~18K → ~5 queries)
- Add take guards to unbounded findMany calls (resource-analytics: 5000, resource-marketplace: 2000, resource-capacity: 1000, chargeability-report: 2000)
- auto-staffing: add select with only needed fields + take: 5000
- schema.prisma: add 5 missing indexes (ManagementLevel.groupId, Blueprint.isActive/target, Comment.parentId, Vacation.requestedById, Resource.managementLevelGroupId)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 08:35:13 +02:00

537 lines
19 KiB
TypeScript

import {
deriveResourceForecast,
calculateGroupChargeability,
calculateGroupTarget,
sumFte,
getMonthRange,
getMonthKeys,
type AssignmentSlice,
} from "@capakraken/engine";
import type { PrismaClient } from "@capakraken/db";
import type { WeekdayAvailability, PermissionKey } from "@capakraken/shared";
import { PermissionKey as PermissionKeys, round1 } from "@capakraken/shared";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
import { z } from "zod";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
getAvailabilityHoursForDate,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import { requirePermission, type TRPCContext } from "../trpc.js";
type ChargeabilityReportProcedureContext = Pick<TRPCContext, "db"> & {
permissions?: Set<PermissionKey>;
};
function isIsoDateInMonth(isoDate: string, monthKey: string): boolean {
return isoDate.startsWith(`${monthKey}-`);
}
function getMonthCapacityDerivation(input: {
monthKey: string;
availability: WeekdayAvailability;
context: (Awaited<ReturnType<typeof loadResourceDailyAvailabilityContexts>> extends Map<string, infer T> ? T : never) | undefined;
baseAvailableHours: number;
effectiveAvailableHours: number;
}) {
const holidayDates = [...(input.context?.holidayDates ?? new Set<string>())]
.filter((isoDate) => isIsoDateInMonth(isoDate, input.monthKey))
.sort();
const publicHolidayWorkdayCount = holidayDates.reduce((count, isoDate) => (
count + (getAvailabilityHoursForDate(input.availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0)
), 0);
const publicHolidayHoursDeduction = holidayDates.reduce((sum, isoDate) => (
sum + getAvailabilityHoursForDate(input.availability, new Date(`${isoDate}T00:00:00.000Z`))
), 0);
let absenceDayEquivalent = 0;
let absenceHoursDeduction = 0;
for (const [isoDate, fraction] of input.context?.vacationFractionsByDate ?? []) {
if (!isIsoDateInMonth(isoDate, input.monthKey) || input.context?.holidayDates.has(isoDate)) {
continue;
}
const dayHours = getAvailabilityHoursForDate(input.availability, new Date(`${isoDate}T00:00:00.000Z`));
if (dayHours <= 0) {
continue;
}
absenceDayEquivalent += fraction;
absenceHoursDeduction += dayHours * fraction;
}
return {
baseAvailableHours: round1(input.baseAvailableHours),
publicHolidayCount: holidayDates.length,
publicHolidayWorkdayCount,
publicHolidayHoursDeduction: round1(publicHolidayHoursDeduction),
absenceDayEquivalent: round1(absenceDayEquivalent),
absenceHoursDeduction: round1(absenceHoursDeduction),
effectiveAvailableHours: round1(input.effectiveAvailableHours),
};
}
type MonthCapacityDerivation = ReturnType<typeof getMonthCapacityDerivation>;
type ForecastBreakdown = ReturnType<typeof deriveResourceForecast>;
function toHours(ratio: number, sahHours: number): number {
return round1(sahHours * ratio);
}
function buildReportMonth(input: {
monthKey: string;
sahHours: number;
targetRatio: number;
forecast: ForecastBreakdown;
derivation: MonthCapacityDerivation;
}) {
const { monthKey, sahHours, targetRatio, forecast, derivation } = input;
const chargeabilityRatio = forecast.chg;
const businessDevelopmentRatio = forecast.bd;
const marketDevelopmentInnovationRatio = forecast.mdi;
const managementOverheadRatio = forecast.mo;
const peopleDevelopmentRecruitingRatio = forecast.pdr;
const plannedAbsenceRatio = forecast.absence;
const unassignedRatio = forecast.unassigned;
const gapRatio = chargeabilityRatio - targetRatio;
return {
monthKey,
sah: round1(sahHours),
sahHours: round1(sahHours),
chg: chargeabilityRatio,
bd: businessDevelopmentRatio,
mdi: marketDevelopmentInnovationRatio,
mo: managementOverheadRatio,
pdr: peopleDevelopmentRecruitingRatio,
absence: plannedAbsenceRatio,
unassigned: unassignedRatio,
chargeabilityRatio,
businessDevelopmentRatio,
marketDevelopmentInnovationRatio,
managementOverheadRatio,
peopleDevelopmentRecruitingRatio,
plannedAbsenceRatio,
unassignedRatio,
chargeabilityHours: toHours(chargeabilityRatio, sahHours),
businessDevelopmentHours: toHours(businessDevelopmentRatio, sahHours),
marketDevelopmentInnovationHours: toHours(marketDevelopmentInnovationRatio, sahHours),
managementOverheadHours: toHours(managementOverheadRatio, sahHours),
peopleDevelopmentRecruitingHours: toHours(peopleDevelopmentRecruitingRatio, sahHours),
plannedAbsenceHours: toHours(plannedAbsenceRatio, sahHours),
unassignedHours: toHours(unassignedRatio, sahHours),
targetRatio,
targetHours: toHours(targetRatio, sahHours),
gapRatio,
gapHours: toHours(gapRatio, sahHours),
derivation,
};
}
function buildGroupTotal(input: {
monthKey: string;
totalFte: number;
chargeabilityRatio: number;
targetRatio: number;
}) {
const gapRatio = input.chargeabilityRatio - input.targetRatio;
return {
monthKey: input.monthKey,
totalFte: input.totalFte,
chg: input.chargeabilityRatio,
target: input.targetRatio,
gap: gapRatio,
chargeabilityRatio: input.chargeabilityRatio,
targetRatio: input.targetRatio,
gapRatio,
};
}
export const chargeabilityReportInputSchema = z.object({
startMonth: z.string().regex(/^\d{4}-\d{2}$/),
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),
});
export const chargeabilityReportDetailInputSchema = chargeabilityReportInputSchema.extend({
resourceQuery: z.string().optional(),
resourceLimit: z.number().int().min(1).max(100).optional(),
});
type ChargeabilityReportInput = z.infer<typeof chargeabilityReportInputSchema>;
type ChargeabilityReportDetailInput = z.infer<typeof chargeabilityReportDetailInputSchema>;
type ChargeabilityExplainabilityInput = ChargeabilityReportInput & {
resourceQuery?: string | undefined;
resourceLimit?: number | undefined;
};
type ChargeabilityReportDbClient = Pick<
PrismaClient,
"assignment" | "resource" | "project" | "vacation" | "holidayCalendar" | "systemSettings"
>;
const CHARGEABILITY_LOCATION_FIELDS = [
"country",
"federalState",
"city",
"orgUnit",
"managementLevelGroup",
"managementLevel",
] as const;
const CHARGEABILITY_DERIVATION_FIELDS = [
"baseAvailableHours",
"publicHolidayCount",
"publicHolidayWorkdayCount",
"publicHolidayHoursDeduction",
"absenceDayEquivalent",
"absenceHoursDeduction",
"effectiveAvailableHours",
] as const;
function buildChargeabilityExplainability(input: ChargeabilityExplainabilityInput) {
const activeFilters = [
...(input.orgUnitId ? ["orgUnitId"] : []),
...(input.managementLevelGroupId ? ["managementLevelGroupId"] : []),
...(input.countryId ? ["countryId"] : []),
...(input.resourceQuery ? ["resourceQuery"] : []),
...(input.includeProposed ? ["includeProposed"] : []),
];
return {
locationFields: [...CHARGEABILITY_LOCATION_FIELDS],
monthDerivationFields: [...CHARGEABILITY_DERIVATION_FIELDS],
activeFilters,
formulas: {
sah: "baseAvailableHours - publicHolidayHoursDeduction - absenceHoursDeduction = effectiveAvailableHours",
chargeabilityPct: "chargeabilityHours / sahHours",
targetHours: "sahHours * targetPct",
gapHours: "chargeabilityHours - targetHours",
},
notes: [
"Location fields explain why two resources can have different SAH in the same month because country, federal state, and city holidays may differ.",
"Holiday deductions and absence deductions are tracked separately; absence does not deduct days that are already public holidays.",
"Include proposed work changes chargeability ratios and hours, but it does not change holiday or absence-based SAH derivation.",
],
};
}
async function queryChargeabilityReport(
db: ChargeabilityReportDbClient,
input: ChargeabilityReportInput,
) {
const { startMonth, endMonth, includeProposed } = input;
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);
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 db.resource.findMany({
where: resourceWhere,
select: {
id: true,
eid: true,
displayName: true,
fte: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: 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" },
take: 2000,
});
if (resources.length === 0) {
return {
monthKeys,
resources: [],
groupTotals: monthKeys.map((key) => buildGroupTotal({
monthKey: key,
totalFte: 0,
chargeabilityRatio: 0,
targetRatio: 0,
})),
explainability: buildChargeabilityExplainability(input),
};
}
const resourceIds = resources.map((resource) => resource.id);
const allBookings = await listAssignmentBookings(db, {
startDate: rangeStart,
endDate: rangeEnd,
resourceIds,
});
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
db,
resources.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
rangeStart,
rangeEnd,
);
const projectIds = [...new Set(allBookings.map((booking) => booking.projectId))];
const projectUtilCats = projectIds.length > 0
? await db.project.findMany({
where: { id: { in: projectIds } },
select: { id: true, utilizationCategory: { select: { code: true } } },
})
: [];
const projectUtilCatMap = new Map(
projectUtilCats.map((project) => [project.id, project.utilizationCategory?.code ?? null]),
);
const assignments = allBookings
.filter((booking) => booking.resourceId !== null)
.filter((booking) => isChargeabilityActualBooking(booking, includeProposed))
.map((booking) => ({
resourceId: booking.resourceId!,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
project: {
status: booking.project.status,
utilizationCategory: { code: projectUtilCatMap.get(booking.projectId) ?? null },
},
}));
const resourceRows = await Promise.all(resources.map(async (resource) => {
const resourceAssignments = assignments.filter((assignment) => assignment.resourceId === resource.id);
const targetPct = resource.managementLevelGroup?.targetPercentage
?? (resource.chargeabilityTarget / 100);
const availability = resource.availability as unknown as WeekdayAvailability;
const context = availabilityContexts.get(resource.id);
const months = await Promise.all(monthKeys.map(async (key) => {
const [year, month] = key.split("-").map(Number) as [number, number];
const { start: monthStart, end: monthEnd } = getMonthRange(year, month);
const baseAvailableHours = calculateEffectiveAvailableHours({
availability,
periodStart: monthStart,
periodEnd: monthEnd,
context: undefined,
});
const availableHours = calculateEffectiveAvailableHours({
availability,
periodStart: monthStart,
periodEnd: monthEnd,
context,
});
const slices: AssignmentSlice[] = resourceAssignments.flatMap((assignment) => {
const totalChargeableHours = calculateEffectiveBookedHours({
availability,
startDate: assignment.startDate,
endDate: assignment.endDate,
hoursPerDay: assignment.hoursPerDay,
periodStart: monthStart,
periodEnd: monthEnd,
context,
});
if (totalChargeableHours <= 0) {
return [];
}
const categoryCode = assignment.project.utilizationCategory?.code;
return {
hoursPerDay: assignment.hoursPerDay,
workingDays: 0,
categoryCode: typeof categoryCode === "string" && categoryCode.length > 0 ? categoryCode : "Chg",
totalChargeableHours,
};
});
const forecast = deriveResourceForecast({
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
sah: availableHours,
});
const derivation = getMonthCapacityDerivation({
monthKey: key,
availability,
context,
baseAvailableHours,
effectiveAvailableHours: availableHours,
});
return buildReportMonth({
monthKey: key,
sahHours: availableHours,
targetRatio: targetPct,
forecast,
derivation,
});
}));
return {
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
fte: resource.fte,
country: resource.country?.code ?? null,
federalState: resource.federalState ?? null,
city: resource.metroCity?.name ?? null,
orgUnit: resource.orgUnit?.name ?? null,
mgmtGroup: resource.managementLevelGroup?.name ?? null,
mgmtLevel: resource.managementLevel?.name ?? null,
targetPct,
months,
};
}));
const groupTotals = monthKeys.map((key, monthIdx) => {
const groupInputs = resourceRows.map((resource) => ({
fte: resource.fte,
chargeability: resource.months[monthIdx]!.chg,
}));
const targetInputs = resourceRows.map((resource) => ({
fte: resource.fte,
targetPercentage: resource.targetPct,
}));
const chg = calculateGroupChargeability(groupInputs);
const target = calculateGroupTarget(targetInputs);
return buildGroupTotal({
monthKey: key,
totalFte: sumFte(resourceRows),
chargeabilityRatio: chg,
targetRatio: target,
});
});
const directory = await getAnonymizationDirectory(db);
return {
monthKeys,
resources: anonymizeResources(resourceRows, directory),
groupTotals,
explainability: buildChargeabilityExplainability(input),
};
}
export function buildChargeabilityReportDetail(
report: Awaited<ReturnType<typeof queryChargeabilityReport>>,
input: ChargeabilityReportDetailInput,
) {
const resourceQuery = input.resourceQuery?.trim().toLowerCase();
const matchingResources = resourceQuery
? report.resources.filter((resource) => (
resource.displayName.toLowerCase().includes(resourceQuery)
|| resource.eid.toLowerCase().includes(resourceQuery)
))
: report.resources;
const resourceLimit = Math.min(Math.max(input.resourceLimit ?? 25, 1), 100);
const resources = matchingResources.slice(0, resourceLimit).map((resource) => ({
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
fte: round1(resource.fte),
country: resource.country,
federalState: resource.federalState ?? null,
city: resource.city,
orgUnit: resource.orgUnit,
managementLevelGroup: resource.mgmtGroup,
managementLevel: resource.mgmtLevel,
targetPct: round1(resource.targetPct * 100),
months: resource.months.map((month) => ({
monthKey: month.monthKey,
sah: round1(month.sah),
sahHours: round1(month.sahHours),
chargeabilityPct: round1(month.chg * 100),
chargeabilityHours: month.chargeabilityHours,
targetPct: round1(resource.targetPct * 100),
targetHours: month.targetHours,
gapPct: round1((month.chg - resource.targetPct) * 100),
gapHours: month.gapHours,
businessDevelopmentPct: round1(month.businessDevelopmentRatio * 100),
businessDevelopmentHours: month.businessDevelopmentHours,
marketDevelopmentInnovationPct: round1(month.marketDevelopmentInnovationRatio * 100),
marketDevelopmentInnovationHours: month.marketDevelopmentInnovationHours,
managementOverheadPct: round1(month.managementOverheadRatio * 100),
managementOverheadHours: month.managementOverheadHours,
peopleDevelopmentRecruitingPct: round1(month.peopleDevelopmentRecruitingRatio * 100),
peopleDevelopmentRecruitingHours: month.peopleDevelopmentRecruitingHours,
plannedAbsencePct: round1(month.plannedAbsenceRatio * 100),
plannedAbsenceHours: month.plannedAbsenceHours,
unassignedPct: round1(month.unassignedRatio * 100),
unassignedHours: month.unassignedHours,
derivation: month.derivation,
})),
}));
return {
filters: {
startMonth: input.startMonth,
endMonth: input.endMonth,
orgUnitId: input.orgUnitId ?? null,
managementLevelGroupId: input.managementLevelGroupId ?? null,
countryId: input.countryId ?? null,
includeProposed: input.includeProposed ?? false,
resourceQuery: input.resourceQuery ?? null,
},
monthKeys: report.monthKeys,
groupTotals: report.groupTotals.map((group) => ({
monthKey: group.monthKey,
totalFte: round1(group.totalFte),
chargeabilityPct: round1(group.chg * 100),
targetPct: round1(group.target * 100),
gapPct: round1(group.gap * 100),
})),
resourceCount: matchingResources.length,
returnedResourceCount: resources.length,
truncated: resources.length < matchingResources.length,
explainability: buildChargeabilityExplainability(input),
resources,
};
}
export async function getChargeabilityReport(
ctx: ChargeabilityReportProcedureContext,
input: ChargeabilityReportInput,
) {
return queryChargeabilityReport(ctx.db, input);
}
export async function getChargeabilityReportDetail(
ctx: ChargeabilityReportProcedureContext,
input: ChargeabilityReportDetailInput,
) {
requirePermission(
{ permissions: ctx.permissions ?? new Set<PermissionKey>() },
PermissionKeys.VIEW_COSTS,
);
const report = await queryChargeabilityReport(ctx.db, input);
return buildChargeabilityReportDetail(report, input);
}