refactor(api): extract chargeability report procedures
This commit is contained in:
@@ -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