319 lines
10 KiB
TypeScript
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);
|
|
}),
|
|
});
|