refactor(api): extract chargeability report procedures

This commit is contained in:
2026-03-31 20:42:33 +02:00
parent 00d5fe7923
commit 958d2368c1
4 changed files with 665 additions and 312 deletions
+1 -1
View File
@@ -15,13 +15,13 @@ Done
- `dispo` - `dispo`
- `insights` - `insights`
- `import-export` - `import-export`
- `chargeability-report`
Ready next Ready next
- none in the conflict-safe backlog - none in the conflict-safe backlog
Deferred or blocked Deferred or blocked
- `assistant-tools` - `assistant-tools`
- `chargeability-report`
- `dashboard` - `dashboard`
- `entitlement` - `entitlement`
- `notification` - `notification`
@@ -0,0 +1,168 @@
import { PermissionKey } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { describe, expect, it } from "vitest";
import {
buildChargeabilityReportDetail,
getChargeabilityReportDetail,
} from "../router/chargeability-report-procedure-support.js";
describe("chargeability report procedure support", () => {
it("builds a filtered detailed report with rounded percentages", () => {
const result = buildChargeabilityReportDetail(
{
monthKeys: ["2026-03"],
groupTotals: [
{
monthKey: "2026-03",
totalFte: 1.234,
chg: 0.456,
target: 0.8,
gap: -0.344,
chargeabilityRatio: 0.456,
targetRatio: 0.8,
gapRatio: -0.344,
},
],
resources: [
{
id: "resource_1",
eid: "E-001",
displayName: "Alice",
fte: 1.234,
country: "ES",
federalState: null,
city: "Barcelona",
orgUnit: "CGI",
mgmtGroup: "Senior",
mgmtLevel: "L7",
targetPct: 0.8,
months: [
{
monthKey: "2026-03",
sah: 120.44,
sahHours: 120.44,
chg: 0.456,
bd: 0.1,
mdi: 0.05,
mo: 0.07,
pdr: 0.08,
absence: 0.09,
unassigned: 0.154,
chargeabilityRatio: 0.456,
businessDevelopmentRatio: 0.1,
marketDevelopmentInnovationRatio: 0.05,
managementOverheadRatio: 0.07,
peopleDevelopmentRecruitingRatio: 0.08,
plannedAbsenceRatio: 0.09,
unassignedRatio: 0.154,
chargeabilityHours: 54.9,
businessDevelopmentHours: 12,
marketDevelopmentInnovationHours: 6,
managementOverheadHours: 8.4,
peopleDevelopmentRecruitingHours: 9.6,
plannedAbsenceHours: 10.8,
unassignedHours: 18.5,
targetRatio: 0.8,
targetHours: 96.4,
gapRatio: -0.344,
gapHours: -41.4,
derivation: {
baseAvailableHours: 128,
publicHolidayCount: 1,
publicHolidayWorkdayCount: 1,
publicHolidayHoursDeduction: 8,
absenceDayEquivalent: 0.5,
absenceHoursDeduction: 4,
effectiveAvailableHours: 120,
},
},
],
},
{
id: "resource_2",
eid: "E-002",
displayName: "Bob",
fte: 1,
country: "DE",
federalState: "BY",
city: "Munich",
orgUnit: "CGI",
mgmtGroup: "Senior",
mgmtLevel: "L7",
targetPct: 0.75,
months: [],
},
],
},
{
startMonth: "2026-03",
endMonth: "2026-03",
resourceQuery: "ali",
resourceLimit: 1,
includeProposed: false,
},
);
expect(result.filters).toEqual({
startMonth: "2026-03",
endMonth: "2026-03",
orgUnitId: null,
managementLevelGroupId: null,
countryId: null,
includeProposed: false,
resourceQuery: "ali",
});
expect(result.groupTotals).toEqual([
{
monthKey: "2026-03",
totalFte: 1.2,
chargeabilityPct: 45.6,
targetPct: 80,
gapPct: -34.4,
},
]);
expect(result.resourceCount).toBe(1);
expect(result.returnedResourceCount).toBe(1);
expect(result.truncated).toBe(false);
expect(result.resources[0]).toEqual(
expect.objectContaining({
displayName: "Alice",
targetPct: 80,
managementLevelGroup: "Senior",
managementLevel: "L7",
months: [
expect.objectContaining({
monthKey: "2026-03",
sah: 120.4,
chargeabilityPct: 45.6,
targetPct: 80,
gapPct: -34.4,
}),
],
}),
);
});
it("rejects detailed reports without the cost-view permission", async () => {
await expect(
getChargeabilityReportDetail(
{
db: {
resource: { findMany: async () => [] },
} as never,
permissions: new Set(),
},
{
startMonth: "2026-03",
endMonth: "2026-03",
includeProposed: false,
},
),
).rejects.toEqual(
expect.objectContaining<Partial<TRPCError>>({
code: "FORBIDDEN",
message: `Permission required: ${PermissionKey.VIEW_COSTS}`,
}),
);
});
});
@@ -0,0 +1,486 @@
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 } 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 round1(value: number): number {
return Math.round(value * 10) / 10;
}
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 ChargeabilityReportDbClient = Pick<
PrismaClient,
"assignment" | "resource" | "project" | "vacation" | "holidayCalendar" | "systemSettings"
>;
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" },
});
if (resources.length === 0) {
return {
monthKeys,
resources: [],
groupTotals: monthKeys.map((key) => buildGroupTotal({
monthKey: key,
totalFte: 0,
chargeabilityRatio: 0,
targetRatio: 0,
})),
};
}
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,
};
}
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,
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);
}
+10 -311
View File
@@ -1,318 +1,17 @@
import { controllerProcedure, createTRPCRouter } from "../trpc.js";
import { import {
deriveResourceForecast, chargeabilityReportDetailInputSchema,
calculateGroupChargeability, chargeabilityReportInputSchema,
calculateGroupTarget, getChargeabilityReport,
sumFte, getChargeabilityReportDetail,
getMonthRange, } from "./chargeability-report-procedure-support.js";
getMonthKeys,
type AssignmentSlice,
} from "@capakraken/engine";
import type { PrismaClient } from "@capakraken/db";
import type { WeekdayAvailability } from "@capakraken/shared";
import { PermissionKey } from "@capakraken/shared";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure, requirePermission } from "../trpc.js";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
function round1(value: number): number {
return Math.round(value * 10) / 10;
}
const reportInputSchema = 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),
});
const detailedReportInputSchema = reportInputSchema.extend({
resourceQuery: z.string().optional(),
resourceLimit: z.number().int().min(1).max(100).optional(),
});
type ChargeabilityReportDbClient = Pick<
PrismaClient,
"assignment" | "resource" | "project" | "vacation" | "holidayCalendar" | "systemSettings"
>;
async function queryChargeabilityReport(
db: ChargeabilityReportDbClient,
input: z.infer<typeof reportInputSchema>,
) {
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" },
});
if (resources.length === 0) {
return {
monthKeys,
resources: [],
groupTotals: monthKeys.map((key) => ({
monthKey: key,
totalFte: 0,
chg: 0,
target: 0,
gap: 0,
})),
};
}
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 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,
});
return {
monthKey: key,
sah: availableHours,
...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,
};
}));
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 {
monthKey: key,
totalFte: sumFte(resourceRows),
chg,
target,
gap: chg - target,
};
});
const directory = await getAnonymizationDirectory(db);
return {
monthKeys,
resources: anonymizeResources(resourceRows, directory),
groupTotals,
};
}
function buildChargeabilityReportDetail(
report: Awaited<ReturnType<typeof queryChargeabilityReport>>,
input: z.infer<typeof detailedReportInputSchema>,
) {
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,
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),
chargeabilityPct: round1(month.chg * 100),
targetPct: round1(resource.targetPct * 100),
gapPct: round1((month.chg - resource.targetPct) * 100),
})),
}));
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,
resources,
};
}
export const chargeabilityReportRouter = createTRPCRouter({ export const chargeabilityReportRouter = createTRPCRouter({
getReport: controllerProcedure getReport: controllerProcedure
.input(reportInputSchema) .input(chargeabilityReportInputSchema)
.query(async ({ ctx, input }) => queryChargeabilityReport(ctx.db, input)), .query(({ ctx, input }) => getChargeabilityReport(ctx, input)),
getDetail: controllerProcedure getDetail: controllerProcedure
.input(detailedReportInputSchema) .input(chargeabilityReportDetailInputSchema)
.query(async ({ ctx, input }) => { .query(({ ctx, input }) => getChargeabilityReportDetail(ctx, input)),
requirePermission(ctx, PermissionKey.VIEW_COSTS);
const report = await queryChargeabilityReport(ctx.db, input);
return buildChargeabilityReportDetail(report, input);
}),
}); });