refactor(api): extract chargeability report procedures
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -1,318 +1,17 @@
|
||||
import { controllerProcedure, createTRPCRouter } from "../trpc.js";
|
||||
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,
|
||||
};
|
||||
}
|
||||
chargeabilityReportDetailInputSchema,
|
||||
chargeabilityReportInputSchema,
|
||||
getChargeabilityReport,
|
||||
getChargeabilityReportDetail,
|
||||
} from "./chargeability-report-procedure-support.js";
|
||||
|
||||
export const chargeabilityReportRouter = createTRPCRouter({
|
||||
getReport: controllerProcedure
|
||||
.input(reportInputSchema)
|
||||
.query(async ({ ctx, input }) => queryChargeabilityReport(ctx.db, input)),
|
||||
.input(chargeabilityReportInputSchema)
|
||||
.query(({ ctx, input }) => getChargeabilityReport(ctx, 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);
|
||||
}),
|
||||
.input(chargeabilityReportDetailInputSchema)
|
||||
.query(({ ctx, input }) => getChargeabilityReportDetail(ctx, input)),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user