Files
CapaKraken/packages/api/src/router/chargeability-report.ts
T

319 lines
10 KiB
TypeScript

import {
deriveResourceForecast,
calculateGroupChargeability,
calculateGroupTarget,
sumFte,
getMonthRange,
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({
getReport: controllerProcedure
.input(reportInputSchema)
.query(async ({ ctx, input }) => queryChargeabilityReport(ctx.db, input)),
getDetail: controllerProcedure
.input(detailedReportInputSchema)
.query(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.VIEW_COSTS);
const report = await queryChargeabilityReport(ctx.db, input);
return buildChargeabilityReportDetail(report, input);
}),
});