1204c186ef
- 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>
537 lines
19 KiB
TypeScript
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);
|
|
}
|