refactor(api): extract report query engine

This commit is contained in:
2026-03-31 10:07:23 +02:00
parent dfbe46bddb
commit aa3a3dac73
5 changed files with 1054 additions and 940 deletions
@@ -0,0 +1,375 @@
import {
isChargeabilityActualBooking,
isChargeabilityRelevantProject,
listAssignmentBookings,
} from "@capakraken/application";
import { TRPCError } from "@trpc/server";
import type { WeekdayAvailability } from "@capakraken/shared";
import { z } from "zod";
import { controllerProcedure } from "../trpc.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
countEffectiveWorkingDays,
getAvailabilityHoursForDate,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import {
buildSelect,
buildWhere,
csvEscape,
type EntityKey,
FilterSchema,
flattenRow,
getValidScalarField,
reportEntitySchema,
ReportInputSchema,
type ReportInput,
type ReportQueryResult,
validateReportInput,
} from "./report-query-config.js";
import { COLUMN_MAP, type ColumnDef, RESOURCE_MONTH_COLUMNS } from "./report-columns.js";
import {
buildReportGroups,
matchesInMemoryFilter,
pickColumns,
sortInMemoryRows,
} from "./report-query-utils.js";
export const reportQueryProcedures = {
getAvailableColumns: controllerProcedure
.input(z.object({ entity: reportEntitySchema }))
.query(({ input }) => {
const columns = COLUMN_MAP[input.entity];
if (!columns) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${input.entity}` });
}
return columns.map(({ key, label, dataType }) => ({ key, label, dataType }));
}),
getReportData: controllerProcedure
.input(ReportInputSchema)
.query(async ({ ctx, input }) => executeReportQuery(ctx.db, input)),
exportReport: controllerProcedure
.input(ReportInputSchema.omit({ offset: true }).extend({
limit: z.number().int().min(1).max(50000).default(5000),
}))
.mutation(async ({ ctx, input }) => {
const result = await executeReportQuery(ctx.db, { ...input, offset: 0 });
const outputColumns = result.columns;
const entityColumns = COLUMN_MAP[input.entity];
const headerLabels = outputColumns.map((key) => {
const def = entityColumns.find((column) => column.key === key);
return def?.label ?? key;
});
const csvLines = [headerLabels.map(csvEscape).join(",")];
const groupByLabel = input.groupBy
? entityColumns.find((column) => column.key === input.groupBy)?.label ?? input.groupBy
: null;
const groupStartByIndex = new Map(
result.groups.map((group) => [group.startIndex, group] as const),
);
result.rows.forEach((row, index) => {
const group = groupStartByIndex.get(index);
if (group && groupByLabel) {
csvLines.push(outputColumns.map((_, columnIndex) => (
columnIndex === 0 ? csvEscape(`${groupByLabel}: ${group.label} (${group.rowCount})`) : ""
)).join(","));
}
csvLines.push(outputColumns.map((column) => csvEscape(row[column])).join(","));
});
return { csv: csvLines.join("\n"), rowCount: result.rows.length };
}),
};
async function executeReportQuery(
db: any,
input: ReportInput,
): Promise<ReportQueryResult> {
validateReportInput(input);
if (input.entity === "resource_month") {
return executeResourceMonthReport(db, input);
}
const { entity, columns, filters, sortBy, sortDir, limit, offset } = input;
const select = buildSelect(entity, columns);
const where = buildWhere(entity, filters);
const orderBy: Record<string, string>[] = [];
if (input.groupBy) {
const groupField = getValidScalarField(entity, input.groupBy);
if (!groupField) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unsupported group field for ${entity}: ${input.groupBy}`,
});
}
orderBy.push({ [groupField]: "asc" });
}
if (sortBy) {
const validField = getValidScalarField(entity, sortBy);
if (!validField) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unsupported sort field for ${entity}: ${sortBy}`,
});
}
orderBy.push({ [validField]: sortDir });
}
const modelDelegate = getModelDelegate(db, entity);
const [rawRows, totalCount] = await Promise.all([
modelDelegate.findMany({
select,
where,
...(orderBy.length > 0 ? { orderBy } : {}),
take: limit,
skip: offset,
}),
modelDelegate.count({ where }),
]);
const flattenedRows = rawRows.map((row: Record<string, unknown>) => flattenRow(row));
const rows = sortInMemoryRows(flattenedRows, input.groupBy, sortBy, sortDir, COLUMN_MAP[entity]);
const outputColumns = ["id", ...columns.filter((column) => column !== "id")];
const groups = buildReportGroups(rows, input.groupBy);
return {
rows: rows.map((row) => pickColumns(row, outputColumns)),
columns: outputColumns,
totalCount,
groups,
};
}
async function executeResourceMonthReport(
db: any,
input: ReportInput,
): Promise<ReportQueryResult> {
const periodMonth = input.periodMonth ?? new Date().toISOString().slice(0, 7);
const [year, month] = periodMonth.split("-").map(Number) as [number, number];
const periodStart = new Date(Date.UTC(year, month - 1, 1));
const periodEnd = new Date(Date.UTC(year, month, 0));
const resources = await db.resource.findMany({
select: {
id: true,
eid: true,
displayName: true,
email: true,
chapter: true,
resourceType: true,
isActive: true,
chgResponsibility: true,
rolledOff: true,
departed: true,
lcrCents: true,
ucrCents: true,
currency: true,
fte: true,
availability: true,
chargeabilityTarget: true,
federalState: true,
countryId: true,
metroCityId: true,
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
orgUnit: { select: { name: true } },
managementLevelGroup: { select: { name: true, targetPercentage: true } },
managementLevel: { select: { name: true } },
},
orderBy: { displayName: "asc" },
});
const resourceIds = resources.map((resource: any) => resource.id);
const [bookings, contexts] = await Promise.all([
resourceIds.length > 0
? listAssignmentBookings(db, {
startDate: periodStart,
endDate: periodEnd,
resourceIds,
})
: Promise.resolve([]),
loadResourceDailyAvailabilityContexts(
db,
resources.map((resource: any) => ({
id: resource.id,
availability: resource.availability as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
periodStart,
periodEnd,
),
]);
const rows = resources.map((resource: any) => {
const availability = resource.availability as WeekdayAvailability;
const context = contexts.get(resource.id);
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
const baseWorkingDays = countEffectiveWorkingDays({
availability,
periodStart,
periodEnd,
context: undefined,
});
const effectiveWorkingDays = countEffectiveWorkingDays({
availability,
periodStart,
periodEnd,
context,
});
const baseAvailableHours = calculateEffectiveAvailableHours({
availability,
periodStart,
periodEnd,
context: undefined,
});
const sahHours = calculateEffectiveAvailableHours({
availability,
periodStart,
periodEnd,
context,
});
const holidayDates = [...(context?.holidayDates ?? new Set<string>())];
const publicHolidayWorkdayCount = holidayDates.reduce((count, isoDate) => (
count + (getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0)
), 0);
const publicHolidayHoursDeduction = holidayDates.reduce((sum, isoDate) => (
sum + getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`))
), 0);
let absenceDayEquivalent = 0;
let absenceHoursDeduction = 0;
for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) {
const dayHours = getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`));
if (dayHours <= 0 || context?.holidayDates.has(isoDate)) {
continue;
}
absenceDayEquivalent += fraction;
absenceHoursDeduction += dayHours * fraction;
}
const actualBookedHours = resourceBookings
.filter((booking) => isChargeabilityActualBooking(booking, false))
.reduce((sum, booking) => sum + calculateEffectiveBookedHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart,
periodEnd,
context,
}), 0);
const expectedBookedHours = resourceBookings
.filter((booking) => isChargeabilityRelevantProject(booking.project, true))
.reduce((sum, booking) => sum + calculateEffectiveBookedHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart,
periodEnd,
context,
}), 0);
const targetPct = resource.managementLevelGroup?.targetPercentage != null
? resource.managementLevelGroup.targetPercentage * 100
: resource.chargeabilityTarget;
return {
id: `${resource.id}:${periodMonth}`,
resourceId: resource.id,
monthKey: periodMonth,
periodStart: periodStart.toISOString(),
periodEnd: periodEnd.toISOString(),
eid: resource.eid,
displayName: resource.displayName,
email: resource.email,
chapter: resource.chapter,
resourceType: resource.resourceType,
isActive: resource.isActive,
chgResponsibility: resource.chgResponsibility,
rolledOff: resource.rolledOff,
departed: resource.departed,
countryCode: resource.country?.code ?? null,
countryName: resource.country?.name ?? null,
federalState: resource.federalState,
metroCityName: resource.metroCity?.name ?? null,
orgUnitName: resource.orgUnit?.name ?? null,
managementLevelGroupName: resource.managementLevelGroup?.name ?? null,
managementLevelName: resource.managementLevel?.name ?? null,
fte: roundMetric(resource.fte),
lcrCents: resource.lcrCents,
ucrCents: resource.ucrCents,
currency: resource.currency,
monthlyChargeabilityTargetPct: roundMetric(targetPct),
monthlyTargetHours: roundMetric((sahHours * targetPct) / 100),
monthlyBaseWorkingDays: roundMetric(baseWorkingDays),
monthlyEffectiveWorkingDays: roundMetric(effectiveWorkingDays),
monthlyBaseAvailableHours: roundMetric(baseAvailableHours),
monthlySahHours: roundMetric(sahHours),
monthlyPublicHolidayCount: holidayDates.length,
monthlyPublicHolidayWorkdayCount: publicHolidayWorkdayCount,
monthlyPublicHolidayHoursDeduction: roundMetric(publicHolidayHoursDeduction),
monthlyAbsenceDayEquivalent: roundMetric(absenceDayEquivalent),
monthlyAbsenceHoursDeduction: roundMetric(absenceHoursDeduction),
monthlyActualBookedHours: roundMetric(actualBookedHours),
monthlyExpectedBookedHours: roundMetric(expectedBookedHours),
monthlyActualChargeabilityPct: roundMetric(sahHours > 0 ? (actualBookedHours / sahHours) * 100 : 0),
monthlyExpectedChargeabilityPct: roundMetric(sahHours > 0 ? (expectedBookedHours / sahHours) * 100 : 0),
monthlyUnassignedHours: roundMetric(Math.max(0, sahHours - actualBookedHours)),
};
});
const filteredRows = rows.filter((row: Record<string, unknown>) => input.filters.every((filter) => matchesInMemoryFilter(
row,
filter,
RESOURCE_MONTH_COLUMNS,
)));
const sortedRows = sortInMemoryRows(
filteredRows,
input.groupBy,
input.sortBy,
input.sortDir,
RESOURCE_MONTH_COLUMNS,
);
const totalCount = sortedRows.length;
const pagedRows = sortedRows.slice(input.offset, input.offset + input.limit);
const outputColumns = ["id", ...input.columns.filter((column) => column !== "id")];
const groups = buildReportGroups(pagedRows, input.groupBy);
return {
rows: pagedRows.map((row) => pickColumns(row, outputColumns)),
columns: outputColumns,
totalCount,
groups,
};
}
function roundMetric(value: number): number {
return Math.round(value * 10) / 10;
}
function getModelDelegate(db: any, entity: EntityKey) {
switch (entity) {
case "resource":
return db.resource;
case "project":
return db.project;
case "assignment":
return db.assignment;
default:
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${entity}` });
}
}