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, flattenRow, getValidScalarField, reportEntitySchema, ReportInputSchema, type ReportInput, type ReportQueryResult, validateReportInput, } from "./report-query-config.js"; import { COLUMN_MAP, 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 { 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[] = []; 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) => 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 { 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())]; 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) => 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}` }); } }