diff --git a/packages/api/src/router/report-columns.ts b/packages/api/src/router/report-columns.ts new file mode 100644 index 0000000..434a4da --- /dev/null +++ b/packages/api/src/router/report-columns.ts @@ -0,0 +1,133 @@ +export interface ColumnDef { + key: string; + label: string; + dataType: "string" | "number" | "date" | "boolean"; + prismaPath?: string; +} + +const RESOURCE_COLUMNS: ColumnDef[] = [ + { key: "id", label: "ID", dataType: "string" }, + { key: "eid", label: "Employee ID", dataType: "string" }, + { key: "displayName", label: "Name", dataType: "string" }, + { key: "email", label: "Email", dataType: "string" }, + { key: "chapter", label: "Chapter", dataType: "string" }, + { key: "resourceType", label: "Resource Type", dataType: "string" }, + { key: "lcrCents", label: "LCR (cents)", dataType: "number" }, + { key: "ucrCents", label: "UCR (cents)", dataType: "number" }, + { key: "currency", label: "Currency", dataType: "string" }, + { key: "chargeabilityTarget", label: "Chargeability Target (%)", dataType: "number" }, + { key: "fte", label: "FTE", dataType: "number" }, + { key: "isActive", label: "Active", dataType: "boolean" }, + { key: "chgResponsibility", label: "Chg Responsibility", dataType: "boolean" }, + { key: "rolledOff", label: "Rolled Off", dataType: "boolean" }, + { key: "departed", label: "Departed", dataType: "boolean" }, + { key: "postalCode", label: "Postal Code", dataType: "string" }, + { key: "federalState", label: "Federal State", dataType: "string" }, + { key: "country.code", label: "Country Code", dataType: "string", prismaPath: "country" }, + { key: "country.name", label: "Country", dataType: "string", prismaPath: "country" }, + { key: "metroCity.name", label: "Metro City", dataType: "string", prismaPath: "metroCity" }, + { key: "orgUnit.name", label: "Org Unit", dataType: "string", prismaPath: "orgUnit" }, + { key: "managementLevelGroup.name", label: "Mgmt Level Group", dataType: "string", prismaPath: "managementLevelGroup" }, + { key: "managementLevel.name", label: "Mgmt Level", dataType: "string", prismaPath: "managementLevel" }, + { key: "areaRole.name", label: "Area Role", dataType: "string", prismaPath: "areaRole" }, + { key: "createdAt", label: "Created At", dataType: "date" }, + { key: "updatedAt", label: "Updated At", dataType: "date" }, +]; + +const PROJECT_COLUMNS: ColumnDef[] = [ + { key: "id", label: "ID", dataType: "string" }, + { key: "shortCode", label: "Short Code", dataType: "string" }, + { key: "name", label: "Name", dataType: "string" }, + { key: "orderType", label: "Order Type", dataType: "string" }, + { key: "allocationType", label: "Allocation Type", dataType: "string" }, + { key: "status", label: "Status", dataType: "string" }, + { key: "winProbability", label: "Win Probability (%)", dataType: "number" }, + { key: "budgetCents", label: "Budget (cents)", dataType: "number" }, + { key: "clientId", label: "Client ID", dataType: "string" }, + { key: "startDate", label: "Start Date", dataType: "date" }, + { key: "endDate", label: "End Date", dataType: "date" }, + { key: "responsiblePerson", label: "Responsible Person", dataType: "string" }, + { key: "client.name", label: "Client", dataType: "string", prismaPath: "client" }, + { key: "utilizationCategory.name", label: "Util. Category", dataType: "string", prismaPath: "utilizationCategory" }, + { key: "blueprint.name", label: "Blueprint", dataType: "string", prismaPath: "blueprint" }, + { key: "createdAt", label: "Created At", dataType: "date" }, + { key: "updatedAt", label: "Updated At", dataType: "date" }, +]; + +const ASSIGNMENT_COLUMNS: ColumnDef[] = [ + { key: "id", label: "ID", dataType: "string" }, + { key: "resourceId", label: "Resource ID", dataType: "string" }, + { key: "projectId", label: "Project ID", dataType: "string" }, + { key: "resource.displayName", label: "Resource", dataType: "string", prismaPath: "resource" }, + { key: "resource.eid", label: "Resource EID", dataType: "string", prismaPath: "resource" }, + { key: "resource.chapter", label: "Resource Chapter", dataType: "string", prismaPath: "resource" }, + { key: "resource.country.code", label: "Resource Country Code", dataType: "string", prismaPath: "resource" }, + { key: "resource.federalState", label: "Resource State", dataType: "string", prismaPath: "resource" }, + { key: "resource.country.name", label: "Resource Country", dataType: "string", prismaPath: "resource" }, + { key: "resource.metroCity.name", label: "Resource City", dataType: "string", prismaPath: "resource" }, + { key: "project.name", label: "Project", dataType: "string", prismaPath: "project" }, + { key: "project.shortCode", label: "Project Code", dataType: "string", prismaPath: "project" }, + { key: "project.status", label: "Project Status", dataType: "string", prismaPath: "project" }, + { key: "project.client.name", label: "Project Client", dataType: "string", prismaPath: "project" }, + { key: "startDate", label: "Start Date", dataType: "date" }, + { key: "endDate", label: "End Date", dataType: "date" }, + { key: "hoursPerDay", label: "Hours/Day", dataType: "number" }, + { key: "percentage", label: "Percentage", dataType: "number" }, + { key: "role", label: "Role (legacy)", dataType: "string" }, + { key: "roleEntity.name", label: "Role", dataType: "string", prismaPath: "roleEntity" }, + { key: "dailyCostCents", label: "Daily Cost (cents)", dataType: "number" }, + { key: "status", label: "Status", dataType: "string" }, + { key: "createdAt", label: "Created At", dataType: "date" }, + { key: "updatedAt", label: "Updated At", dataType: "date" }, +]; + +export const RESOURCE_MONTH_COLUMNS: ColumnDef[] = [ + { key: "id", label: "Row ID", dataType: "string" }, + { key: "resourceId", label: "Resource ID", dataType: "string" }, + { key: "monthKey", label: "Month", dataType: "string" }, + { key: "periodStart", label: "Period Start", dataType: "date" }, + { key: "periodEnd", label: "Period End", dataType: "date" }, + { key: "eid", label: "Employee ID", dataType: "string" }, + { key: "displayName", label: "Name", dataType: "string" }, + { key: "email", label: "Email", dataType: "string" }, + { key: "chapter", label: "Chapter", dataType: "string" }, + { key: "resourceType", label: "Resource Type", dataType: "string" }, + { key: "isActive", label: "Active", dataType: "boolean" }, + { key: "chgResponsibility", label: "Chg Responsibility", dataType: "boolean" }, + { key: "rolledOff", label: "Rolled Off", dataType: "boolean" }, + { key: "departed", label: "Departed", dataType: "boolean" }, + { key: "countryCode", label: "Country Code", dataType: "string" }, + { key: "countryName", label: "Country", dataType: "string" }, + { key: "federalState", label: "Federal State", dataType: "string" }, + { key: "metroCityName", label: "Metro City", dataType: "string" }, + { key: "orgUnitName", label: "Org Unit", dataType: "string" }, + { key: "managementLevelGroupName", label: "Mgmt Level Group", dataType: "string" }, + { key: "managementLevelName", label: "Mgmt Level", dataType: "string" }, + { key: "fte", label: "FTE", dataType: "number" }, + { key: "lcrCents", label: "LCR (cents)", dataType: "number" }, + { key: "ucrCents", label: "UCR (cents)", dataType: "number" }, + { key: "currency", label: "Currency", dataType: "string" }, + { key: "monthlyChargeabilityTargetPct", label: "Target Chargeability (%)", dataType: "number" }, + { key: "monthlyTargetHours", label: "Target Hours", dataType: "number" }, + { key: "monthlyBaseWorkingDays", label: "Base Working Days", dataType: "number" }, + { key: "monthlyEffectiveWorkingDays", label: "Effective Working Days", dataType: "number" }, + { key: "monthlyBaseAvailableHours", label: "Base Available Hours", dataType: "number" }, + { key: "monthlySahHours", label: "SAH", dataType: "number" }, + { key: "monthlyPublicHolidayCount", label: "Holiday Dates", dataType: "number" }, + { key: "monthlyPublicHolidayWorkdayCount", label: "Holiday Workdays", dataType: "number" }, + { key: "monthlyPublicHolidayHoursDeduction", label: "Holiday Hours Deduction", dataType: "number" }, + { key: "monthlyAbsenceDayEquivalent", label: "Absence Day Equivalent", dataType: "number" }, + { key: "monthlyAbsenceHoursDeduction", label: "Absence Hours Deduction", dataType: "number" }, + { key: "monthlyActualBookedHours", label: "Actual Booked Hours", dataType: "number" }, + { key: "monthlyExpectedBookedHours", label: "Expected Booked Hours", dataType: "number" }, + { key: "monthlyActualChargeabilityPct", label: "Actual Chargeability (%)", dataType: "number" }, + { key: "monthlyExpectedChargeabilityPct", label: "Expected Chargeability (%)", dataType: "number" }, + { key: "monthlyUnassignedHours", label: "Unassigned Hours", dataType: "number" }, +]; + +export const COLUMN_MAP = { + resource: RESOURCE_COLUMNS, + project: PROJECT_COLUMNS, + assignment: ASSIGNMENT_COLUMNS, + resource_month: RESOURCE_MONTH_COLUMNS, +} as const; diff --git a/packages/api/src/router/report-query-config.ts b/packages/api/src/router/report-query-config.ts new file mode 100644 index 0000000..0904670 --- /dev/null +++ b/packages/api/src/router/report-query-config.ts @@ -0,0 +1,370 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { COLUMN_MAP, type ColumnDef, RESOURCE_MONTH_COLUMNS } from "./report-columns.js"; + +export const ENTITY_MAP = { + resource: "resource", + project: "project", + assignment: "assignment", + resource_month: "resource_month", +} as const; + +export type EntityKey = keyof typeof ENTITY_MAP; + +const PERIOD_MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/; + +const ALLOWED_SCALAR_FIELDS: Record> = { + resource: new Set([ + "id", "eid", "displayName", "email", "chapter", "resourceType", + "lcrCents", "ucrCents", "currency", "chargeabilityTarget", "fte", + "isActive", "chgResponsibility", "rolledOff", "departed", + "postalCode", "federalState", "createdAt", "updatedAt", + ]), + project: new Set([ + "id", "shortCode", "name", "orderType", "allocationType", "status", + "winProbability", "budgetCents", "startDate", "endDate", + "responsiblePerson", "createdAt", "updatedAt", + ]), + assignment: new Set([ + "id", "startDate", "endDate", "hoursPerDay", "percentage", + "role", "dailyCostCents", "status", "createdAt", "updatedAt", + ]), + resource_month: new Set(RESOURCE_MONTH_COLUMNS.map((column) => column.key)), +}; + +export function getValidScalarField(entity: EntityKey, field: string): string | null { + if (ALLOWED_SCALAR_FIELDS[entity].has(field)) { + return field; + } + return null; +} + +export function getColumnDef(entity: EntityKey, columnKey: string): ColumnDef | undefined { + return COLUMN_MAP[entity].find((column) => column.key === columnKey); +} + +function assertKnownColumns(entity: EntityKey, columns: string[]): void { + const invalidColumns = columns.filter((column) => !getColumnDef(entity, column)); + if (invalidColumns.length > 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unknown columns for ${entity}: ${invalidColumns.join(", ")}`, + }); + } +} + +function assertValidFilterField(entity: EntityKey, field: string): string { + if (entity === "resource_month") { + if (!getColumnDef(entity, field)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unknown filter field for ${entity}: ${field}`, + }); + } + return field; + } + + const validField = getValidScalarField(entity, field); + if (!validField) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unsupported filter field for ${entity}: ${field}`, + }); + } + return validField; +} + +function assertValidSortField(entity: EntityKey, field: string): void { + if (entity === "resource_month") { + if (!getColumnDef(entity, field)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unknown sort field for ${entity}: ${field}`, + }); + } + return; + } + + if (!getValidScalarField(entity, field)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unsupported sort field for ${entity}: ${field}`, + }); + } +} + +function assertValidGroupField(entity: EntityKey, field: string): void { + const knownField = + entity === "resource_month" + ? getColumnDef(entity, field)?.key + : getValidScalarField(entity, field); + if (!knownField) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unsupported group field for ${entity}: ${field}`, + }); + } +} + +function parseFilterValueOrThrow(def: ColumnDef, value: string): unknown { + if (def.dataType === "number") { + const parsed = Number(value); + if (Number.isNaN(parsed)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invalid numeric filter value for ${def.key}: ${value}`, + }); + } + return parsed; + } + + if (def.dataType === "boolean") { + if (value !== "true" && value !== "false") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invalid boolean filter value for ${def.key}: ${value}`, + }); + } + return value === "true"; + } + + if (def.dataType === "date") { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invalid date filter value for ${def.key}: ${value}`, + }); + } + return parsed; + } + + return value; +} + +export const reportEntitySchema = z.enum(["resource", "project", "assignment", "resource_month"]); + +export const FilterSchema = z.object({ + field: z.string().min(1), + op: z.enum(["eq", "neq", "gt", "lt", "gte", "lte", "contains", "in"]), + value: z.string(), +}); + +export const ReportInputSchema = z.object({ + entity: reportEntitySchema, + columns: z.array(z.string()).min(1), + filters: z.array(FilterSchema).default([]), + groupBy: z.string().optional(), + sortBy: z.string().optional(), + sortDir: z.enum(["asc", "desc"]).default("asc"), + periodMonth: z.string().regex(PERIOD_MONTH_PATTERN).optional(), + limit: z.number().int().min(1).max(5000).default(50), + offset: z.number().int().min(0).default(0), +}); + +export const ReportTemplateConfigSchema = ReportInputSchema.omit({ limit: true, offset: true }); + +export type ReportInput = z.infer; +export type FilterInput = z.infer; + +export interface ReportGroupSummary { + key: string; + label: string; + rowCount: number; + startIndex: number; +} + +export interface ReportQueryResult { + rows: Record[]; + columns: string[]; + totalCount: number; + groups: ReportGroupSummary[]; +} + +export function validateReportInput(input: ReportInput | z.infer): void { + assertKnownColumns(input.entity, input.columns); + + if (input.periodMonth && !PERIOD_MONTH_PATTERN.test(input.periodMonth)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Invalid periodMonth: ${input.periodMonth}. Expected YYYY-MM with a month between 01 and 12.`, + }); + } + + if (input.entity !== "resource_month" && input.periodMonth) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "periodMonth is only supported for resource_month reports", + }); + } + + if (input.sortBy) { + assertValidSortField(input.entity, input.sortBy); + } + + if (input.groupBy) { + assertValidGroupField(input.entity, input.groupBy); + } + + for (const filter of input.filters) { + const field = assertValidFilterField(input.entity, filter.field); + const def = getColumnDef(input.entity, field); + if (!def) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unknown filter field for ${input.entity}: ${filter.field}`, + }); + } + + if (filter.op === "contains" || filter.op === "in") { + if (def.dataType !== "string") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Filter operator ${filter.op} is only supported for string fields like ${def.key}`, + }); + } + continue; + } + + void parseFilterValueOrThrow(def, filter.value); + } +} + +export function buildSelect(entity: EntityKey, columns: string[]): Record { + const entityColumns = COLUMN_MAP[entity]; + const select: Record = { id: true }; + + for (const colKey of columns) { + const def = entityColumns.find((column) => column.key === colKey); + if (!def) { + continue; + } + + if (colKey.includes(".")) { + const relationName = def.prismaPath ?? colKey.split(".")[0]!; + const existing = select[relationName]; + const fieldSegments = colKey.split(".").slice(1); + const relationSelect = existing && typeof existing === "object" && existing !== null && "select" in existing + ? (existing as { select: Record }).select + : {}; + mergeSelectPath(relationSelect, fieldSegments); + select[relationName] = { select: relationSelect }; + } else { + select[colKey] = true; + } + } + + return select; +} + +function mergeSelectPath(target: Record, segments: string[]): void { + const [head, ...tail] = segments; + if (!head) { + return; + } + + if (tail.length === 0) { + target[head] = true; + return; + } + + const existing = target[head]; + const nestedSelect = existing && typeof existing === "object" && existing !== null && "select" in existing + ? (existing as { select: Record }).select + : {}; + + mergeSelectPath(nestedSelect, tail); + target[head] = { select: nestedSelect }; +} + +export function buildWhere( + entity: EntityKey, + filters: Array<{ field: string; op: string; value: string }>, +): Record { + const where: Record = {}; + + for (const filter of filters) { + const field = assertValidFilterField(entity, filter.field); + const colDef = getColumnDef(entity, field); + if (!colDef) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unknown filter field for ${entity}: ${filter.field}`, + }); + } + const parsedValue = parseFilterValueOrThrow(colDef, filter.value); + + switch (filter.op) { + case "eq": + where[field] = parsedValue; + break; + case "neq": + where[field] = { not: parsedValue }; + break; + case "gt": + where[field] = { gt: parsedValue }; + break; + case "lt": + where[field] = { lt: parsedValue }; + break; + case "gte": + where[field] = { gte: parsedValue }; + break; + case "lte": + where[field] = { lte: parsedValue }; + break; + case "contains": + if (colDef.dataType !== "string") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Filter operator contains is only supported for string fields like ${field}`, + }); + } + where[field] = { contains: filter.value, mode: "insensitive" }; + break; + case "in": + if (colDef.dataType !== "string") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Filter operator in is only supported for string fields like ${field}`, + }); + } + where[field] = { in: filter.value.split(",").map((value) => value.trim()) }; + break; + default: + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Unsupported filter operator: ${filter.op}`, + }); + } + } + + return where; +} + +export function flattenRow(row: Record, prefix = ""): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(row)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (value !== null && typeof value === "object" && !(value instanceof Date) && !Array.isArray(value)) { + Object.assign(result, flattenRow(value as Record, fullKey)); + } else { + result[fullKey] = value; + } + } + return result; +} + +export function csvEscape(value: unknown): string { + if (value === null || value === undefined) { + return ""; + } + if (value instanceof Date) { + return value.toISOString(); + } + const str = String(value); + if (str.includes(",") || str.includes('"') || str.includes("\n")) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} diff --git a/packages/api/src/router/report-query-engine.ts b/packages/api/src/router/report-query-engine.ts new file mode 100644 index 0000000..e703b8d --- /dev/null +++ b/packages/api/src/router/report-query-engine.ts @@ -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 { + 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}` }); + } +} diff --git a/packages/api/src/router/report-query-utils.ts b/packages/api/src/router/report-query-utils.ts new file mode 100644 index 0000000..feae147 --- /dev/null +++ b/packages/api/src/router/report-query-utils.ts @@ -0,0 +1,167 @@ +import type { ColumnDef } from "./report-columns.js"; +import type { FilterInput, ReportGroupSummary } from "./report-query-config.js"; + +function parseFilterValue(def: ColumnDef | undefined, value: string): unknown { + if (!def) { + return value; + } + if (def.dataType === "number") { + const parsed = Number(value); + return Number.isNaN(parsed) ? null : parsed; + } + if (def.dataType === "boolean") { + return value === "true"; + } + if (def.dataType === "date") { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed.getTime(); + } + return value; +} + +export function matchesInMemoryFilter( + row: Record, + filter: FilterInput, + columns: ColumnDef[], +): boolean { + const def = columns.find((column) => column.key === filter.field); + if (!def) { + return true; + } + + const rowValueRaw = row[filter.field]; + const rowValue = def.dataType === "date" && typeof rowValueRaw === "string" + ? new Date(rowValueRaw).getTime() + : rowValueRaw; + const parsedFilterValue = parseFilterValue(def, filter.value); + + if (parsedFilterValue === null) { + return false; + } + + switch (filter.op) { + case "eq": + return rowValue === parsedFilterValue; + case "neq": + return rowValue !== parsedFilterValue; + case "gt": + return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue > parsedFilterValue; + case "lt": + return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue < parsedFilterValue; + case "gte": + return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue >= parsedFilterValue; + case "lte": + return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue <= parsedFilterValue; + case "contains": + return typeof rowValue === "string" && rowValue.toLowerCase().includes(filter.value.toLowerCase()); + case "in": + return filter.value.split(",").map((value) => value.trim()).includes(String(rowValue ?? "")); + default: + return true; + } +} + +export function sortInMemoryRows( + rows: Record[], + groupBy: string | undefined, + sortBy: string | undefined, + sortDir: "asc" | "desc", + columns: ColumnDef[], +): Record[] { + if (!groupBy && !sortBy) { + return rows; + } + + return [...rows].sort((left, right) => { + if (groupBy) { + const groupDef = columns.find((column) => column.key === groupBy); + const groupComparison = compareRowValues(left[groupBy], right[groupBy], groupDef, "asc"); + if (groupComparison !== 0) { + return groupComparison; + } + } + + if (!sortBy) { + return 0; + } + + const sortDef = columns.find((column) => column.key === sortBy); + return compareRowValues(left[sortBy], right[sortBy], sortDef, sortDir); + }); +} + +function compareRowValues( + leftValue: unknown, + rightValue: unknown, + def: ColumnDef | undefined, + sortDir: "asc" | "desc", +): number { + if (leftValue == null && rightValue == null) { + return 0; + } + if (leftValue == null) { + return 1; + } + if (rightValue == null) { + return -1; + } + + const direction = sortDir === "asc" ? 1 : -1; + if (def?.dataType === "number") { + return direction * (Number(leftValue) - Number(rightValue)); + } + if (def?.dataType === "boolean") { + return direction * (Number(Boolean(leftValue)) - Number(Boolean(rightValue))); + } + if (def?.dataType === "date") { + return direction * (new Date(String(leftValue)).getTime() - new Date(String(rightValue)).getTime()); + } + return direction * String(leftValue).localeCompare(String(rightValue), "de"); +} + +export function pickColumns(row: Record, columns: string[]): Record { + return Object.fromEntries(columns.map((column) => [column, row[column]])); +} + +export function buildReportGroups( + rows: Record[], + groupBy: string | undefined, +): ReportGroupSummary[] { + if (!groupBy) { + return []; + } + + const groups: ReportGroupSummary[] = []; + let currentKey: string | null = null; + + rows.forEach((row, index) => { + const rawValue = row[groupBy]; + const label = formatGroupValue(rawValue); + const key = `${groupBy}:${label}`; + + if (key !== currentKey) { + groups.push({ + key, + label, + rowCount: 1, + startIndex: index, + }); + currentKey = key; + return; + } + + groups[groups.length - 1]!.rowCount += 1; + }); + + return groups; +} + +function formatGroupValue(value: unknown): string { + if (value === null || value === undefined || value === "") { + return "No value"; + } + if (value instanceof Date) { + return value.toISOString(); + } + return String(value); +} diff --git a/packages/api/src/router/report.ts b/packages/api/src/router/report.ts index c2e61d4..42e6c33 100644 --- a/packages/api/src/router/report.ts +++ b/packages/api/src/router/report.ts @@ -1,524 +1,13 @@ import { Prisma } from "@capakraken/db"; -import { - isChargeabilityActualBooking, - isChargeabilityRelevantProject, - listAssignmentBookings, -} from "@capakraken/application"; -import type { WeekdayAvailability } from "@capakraken/shared"; -import { createTRPCRouter, controllerProcedure } from "../trpc.js"; -import { - calculateEffectiveAvailableHours, - calculateEffectiveBookedHours, - countEffectiveWorkingDays, - getAvailabilityHoursForDate, - loadResourceDailyAvailabilityContexts, -} from "../lib/resource-capacity.js"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; - -// ─── Column Definitions ────────────────────────────────────────────────────── - -interface ColumnDef { - key: string; - label: string; - dataType: "string" | "number" | "date" | "boolean"; - /** Prisma select path — nested relations use dot notation */ - prismaPath?: string; -} - -const RESOURCE_COLUMNS: ColumnDef[] = [ - { key: "id", label: "ID", dataType: "string" }, - { key: "eid", label: "Employee ID", dataType: "string" }, - { key: "displayName", label: "Name", dataType: "string" }, - { key: "email", label: "Email", dataType: "string" }, - { key: "chapter", label: "Chapter", dataType: "string" }, - { key: "resourceType", label: "Resource Type", dataType: "string" }, - { key: "lcrCents", label: "LCR (cents)", dataType: "number" }, - { key: "ucrCents", label: "UCR (cents)", dataType: "number" }, - { key: "currency", label: "Currency", dataType: "string" }, - { key: "chargeabilityTarget", label: "Chargeability Target (%)", dataType: "number" }, - { key: "fte", label: "FTE", dataType: "number" }, - { key: "isActive", label: "Active", dataType: "boolean" }, - { key: "chgResponsibility", label: "Chg Responsibility", dataType: "boolean" }, - { key: "rolledOff", label: "Rolled Off", dataType: "boolean" }, - { key: "departed", label: "Departed", dataType: "boolean" }, - { key: "postalCode", label: "Postal Code", dataType: "string" }, - { key: "federalState", label: "Federal State", dataType: "string" }, - { key: "country.code", label: "Country Code", dataType: "string", prismaPath: "country" }, - { key: "country.name", label: "Country", dataType: "string", prismaPath: "country" }, - { key: "metroCity.name", label: "Metro City", dataType: "string", prismaPath: "metroCity" }, - { key: "orgUnit.name", label: "Org Unit", dataType: "string", prismaPath: "orgUnit" }, - { key: "managementLevelGroup.name", label: "Mgmt Level Group", dataType: "string", prismaPath: "managementLevelGroup" }, - { key: "managementLevel.name", label: "Mgmt Level", dataType: "string", prismaPath: "managementLevel" }, - { key: "areaRole.name", label: "Area Role", dataType: "string", prismaPath: "areaRole" }, - { key: "createdAt", label: "Created At", dataType: "date" }, - { key: "updatedAt", label: "Updated At", dataType: "date" }, -]; - -const PROJECT_COLUMNS: ColumnDef[] = [ - { key: "id", label: "ID", dataType: "string" }, - { key: "shortCode", label: "Short Code", dataType: "string" }, - { key: "name", label: "Name", dataType: "string" }, - { key: "orderType", label: "Order Type", dataType: "string" }, - { key: "allocationType", label: "Allocation Type", dataType: "string" }, - { key: "status", label: "Status", dataType: "string" }, - { key: "winProbability", label: "Win Probability (%)", dataType: "number" }, - { key: "budgetCents", label: "Budget (cents)", dataType: "number" }, - { key: "clientId", label: "Client ID", dataType: "string" }, - { key: "startDate", label: "Start Date", dataType: "date" }, - { key: "endDate", label: "End Date", dataType: "date" }, - { key: "responsiblePerson", label: "Responsible Person", dataType: "string" }, - { key: "client.name", label: "Client", dataType: "string", prismaPath: "client" }, - { key: "utilizationCategory.name", label: "Util. Category", dataType: "string", prismaPath: "utilizationCategory" }, - { key: "blueprint.name", label: "Blueprint", dataType: "string", prismaPath: "blueprint" }, - { key: "createdAt", label: "Created At", dataType: "date" }, - { key: "updatedAt", label: "Updated At", dataType: "date" }, -]; - -const ASSIGNMENT_COLUMNS: ColumnDef[] = [ - { key: "id", label: "ID", dataType: "string" }, - { key: "resourceId", label: "Resource ID", dataType: "string" }, - { key: "projectId", label: "Project ID", dataType: "string" }, - { key: "resource.displayName", label: "Resource", dataType: "string", prismaPath: "resource" }, - { key: "resource.eid", label: "Resource EID", dataType: "string", prismaPath: "resource" }, - { key: "resource.chapter", label: "Resource Chapter", dataType: "string", prismaPath: "resource" }, - { key: "resource.country.code", label: "Resource Country Code", dataType: "string", prismaPath: "resource" }, - { key: "resource.federalState", label: "Resource State", dataType: "string", prismaPath: "resource" }, - { key: "resource.country.name", label: "Resource Country", dataType: "string", prismaPath: "resource" }, - { key: "resource.metroCity.name", label: "Resource City", dataType: "string", prismaPath: "resource" }, - { key: "project.name", label: "Project", dataType: "string", prismaPath: "project" }, - { key: "project.shortCode", label: "Project Code", dataType: "string", prismaPath: "project" }, - { key: "project.status", label: "Project Status", dataType: "string", prismaPath: "project" }, - { key: "project.client.name", label: "Project Client", dataType: "string", prismaPath: "project" }, - { key: "startDate", label: "Start Date", dataType: "date" }, - { key: "endDate", label: "End Date", dataType: "date" }, - { key: "hoursPerDay", label: "Hours/Day", dataType: "number" }, - { key: "percentage", label: "Percentage", dataType: "number" }, - { key: "role", label: "Role (legacy)", dataType: "string" }, - { key: "roleEntity.name", label: "Role", dataType: "string", prismaPath: "roleEntity" }, - { key: "dailyCostCents", label: "Daily Cost (cents)", dataType: "number" }, - { key: "status", label: "Status", dataType: "string" }, - { key: "createdAt", label: "Created At", dataType: "date" }, - { key: "updatedAt", label: "Updated At", dataType: "date" }, -]; - -const RESOURCE_MONTH_COLUMNS: ColumnDef[] = [ - { key: "id", label: "Row ID", dataType: "string" }, - { key: "resourceId", label: "Resource ID", dataType: "string" }, - { key: "monthKey", label: "Month", dataType: "string" }, - { key: "periodStart", label: "Period Start", dataType: "date" }, - { key: "periodEnd", label: "Period End", dataType: "date" }, - { key: "eid", label: "Employee ID", dataType: "string" }, - { key: "displayName", label: "Name", dataType: "string" }, - { key: "email", label: "Email", dataType: "string" }, - { key: "chapter", label: "Chapter", dataType: "string" }, - { key: "resourceType", label: "Resource Type", dataType: "string" }, - { key: "isActive", label: "Active", dataType: "boolean" }, - { key: "chgResponsibility", label: "Chg Responsibility", dataType: "boolean" }, - { key: "rolledOff", label: "Rolled Off", dataType: "boolean" }, - { key: "departed", label: "Departed", dataType: "boolean" }, - { key: "countryCode", label: "Country Code", dataType: "string" }, - { key: "countryName", label: "Country", dataType: "string" }, - { key: "federalState", label: "Federal State", dataType: "string" }, - { key: "metroCityName", label: "Metro City", dataType: "string" }, - { key: "orgUnitName", label: "Org Unit", dataType: "string" }, - { key: "managementLevelGroupName", label: "Mgmt Level Group", dataType: "string" }, - { key: "managementLevelName", label: "Mgmt Level", dataType: "string" }, - { key: "fte", label: "FTE", dataType: "number" }, - { key: "lcrCents", label: "LCR (cents)", dataType: "number" }, - { key: "ucrCents", label: "UCR (cents)", dataType: "number" }, - { key: "currency", label: "Currency", dataType: "string" }, - { key: "monthlyChargeabilityTargetPct", label: "Target Chargeability (%)", dataType: "number" }, - { key: "monthlyTargetHours", label: "Target Hours", dataType: "number" }, - { key: "monthlyBaseWorkingDays", label: "Base Working Days", dataType: "number" }, - { key: "monthlyEffectiveWorkingDays", label: "Effective Working Days", dataType: "number" }, - { key: "monthlyBaseAvailableHours", label: "Base Available Hours", dataType: "number" }, - { key: "monthlySahHours", label: "SAH", dataType: "number" }, - { key: "monthlyPublicHolidayCount", label: "Holiday Dates", dataType: "number" }, - { key: "monthlyPublicHolidayWorkdayCount", label: "Holiday Workdays", dataType: "number" }, - { key: "monthlyPublicHolidayHoursDeduction", label: "Holiday Hours Deduction", dataType: "number" }, - { key: "monthlyAbsenceDayEquivalent", label: "Absence Day Equivalent", dataType: "number" }, - { key: "monthlyAbsenceHoursDeduction", label: "Absence Hours Deduction", dataType: "number" }, - { key: "monthlyActualBookedHours", label: "Actual Booked Hours", dataType: "number" }, - { key: "monthlyExpectedBookedHours", label: "Expected Booked Hours", dataType: "number" }, - { key: "monthlyActualChargeabilityPct", label: "Actual Chargeability (%)", dataType: "number" }, - { key: "monthlyExpectedChargeabilityPct", label: "Expected Chargeability (%)", dataType: "number" }, - { key: "monthlyUnassignedHours", label: "Unassigned Hours", dataType: "number" }, -]; - -const COLUMN_MAP: Record = { - resource: RESOURCE_COLUMNS, - project: PROJECT_COLUMNS, - assignment: ASSIGNMENT_COLUMNS, - resource_month: RESOURCE_MONTH_COLUMNS, -}; - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -const ENTITY_MAP = { - resource: "resource", - project: "project", - assignment: "assignment", - resource_month: "resource_month", -} as const; - -type EntityKey = keyof typeof ENTITY_MAP; -const PERIOD_MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/; - -/** Allowlist of top-level scalar fields per entity that can be filtered/sorted on. */ -const ALLOWED_SCALAR_FIELDS: Record> = { - resource: new Set([ - "id", "eid", "displayName", "email", "chapter", "resourceType", - "lcrCents", "ucrCents", "currency", "chargeabilityTarget", "fte", - "isActive", "chgResponsibility", "rolledOff", "departed", - "postalCode", "federalState", "createdAt", "updatedAt", - ]), - project: new Set([ - "id", "shortCode", "name", "orderType", "allocationType", "status", - "winProbability", "budgetCents", "startDate", "endDate", - "responsiblePerson", "createdAt", "updatedAt", - ]), - assignment: new Set([ - "id", "startDate", "endDate", "hoursPerDay", "percentage", - "role", "dailyCostCents", "status", "createdAt", "updatedAt", - ]), - resource_month: new Set(RESOURCE_MONTH_COLUMNS.map((column) => column.key)), -}; - -function getValidScalarField(entity: EntityKey, field: string): string | null { - // Only allow top-level scalar fields for filter/sort (no relation traversal in where/orderBy) - if (ALLOWED_SCALAR_FIELDS[entity].has(field)) return field; - return null; -} - -function getColumnDef(entity: EntityKey, columnKey: string): ColumnDef | undefined { - return COLUMN_MAP[entity].find((column) => column.key === columnKey); -} - -function assertKnownColumns(entity: EntityKey, columns: string[]): void { - const invalidColumns = columns.filter((column) => !getColumnDef(entity, column)); - if (invalidColumns.length > 0) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Unknown columns for ${entity}: ${invalidColumns.join(", ")}`, - }); - } -} - -function assertValidFilterField(entity: EntityKey, field: string): string { - if (entity === "resource_month") { - if (!getColumnDef(entity, field)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Unknown filter field for ${entity}: ${field}`, - }); - } - return field; - } - - const validField = getValidScalarField(entity, field); - if (!validField) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Unsupported filter field for ${entity}: ${field}`, - }); - } - return validField; -} - -function assertValidSortField(entity: EntityKey, field: string): void { - if (entity === "resource_month") { - if (!getColumnDef(entity, field)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Unknown sort field for ${entity}: ${field}`, - }); - } - return; - } - - if (!getValidScalarField(entity, field)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Unsupported sort field for ${entity}: ${field}`, - }); - } -} - -function assertValidGroupField(entity: EntityKey, field: string): void { - const knownField = - entity === "resource_month" - ? getColumnDef(entity, field)?.key - : getValidScalarField(entity, field); - if (!knownField) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Unsupported group field for ${entity}: ${field}`, - }); - } -} - -function parseFilterValueOrThrow(def: ColumnDef, value: string): unknown { - if (def.dataType === "number") { - const parsed = Number(value); - if (Number.isNaN(parsed)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invalid numeric filter value for ${def.key}: ${value}`, - }); - } - return parsed; - } - - if (def.dataType === "boolean") { - if (value !== "true" && value !== "false") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invalid boolean filter value for ${def.key}: ${value}`, - }); - } - return value === "true"; - } - - if (def.dataType === "date") { - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invalid date filter value for ${def.key}: ${value}`, - }); - } - return parsed; - } - - return value; -} - -function validateReportInput(input: ReportInput | z.infer): void { - assertKnownColumns(input.entity, input.columns); - - if (input.periodMonth && !PERIOD_MONTH_PATTERN.test(input.periodMonth)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invalid periodMonth: ${input.periodMonth}. Expected YYYY-MM with a month between 01 and 12.`, - }); - } - - if (input.entity !== "resource_month" && input.periodMonth) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "periodMonth is only supported for resource_month reports", - }); - } - - if (input.sortBy) { - assertValidSortField(input.entity, input.sortBy); - } - - if (input.groupBy) { - assertValidGroupField(input.entity, input.groupBy); - } - - for (const filter of input.filters) { - const field = assertValidFilterField(input.entity, filter.field); - const def = getColumnDef(input.entity, field); - if (!def) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Unknown filter field for ${input.entity}: ${filter.field}`, - }); - } - - if (filter.op === "contains" || filter.op === "in") { - if (def.dataType !== "string") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Filter operator ${filter.op} is only supported for string fields like ${def.key}`, - }); - } - continue; - } - - void parseFilterValueOrThrow(def, filter.value); - } -} - -/** - * Build a Prisma `select` object from the requested columns. - * Always includes `id`. For relation columns like "country.name", - * we include the relation with `select: { name: true }`. - */ -function buildSelect(entity: EntityKey, columns: string[]): Record { - const entityColumns = COLUMN_MAP[entity]; - const select: Record = { id: true }; - - for (const colKey of columns) { - const def = entityColumns.find((c) => c.key === colKey); - if (!def) continue; - - if (colKey.includes(".")) { - const relationName = def.prismaPath ?? colKey.split(".")[0]!; - const existing = select[relationName]; - const fieldSegments = colKey.split(".").slice(1); - const relationSelect = existing && typeof existing === "object" && existing !== null && "select" in existing - ? (existing as { select: Record }).select - : {}; - mergeSelectPath(relationSelect, fieldSegments); - select[relationName] = { select: relationSelect }; - } else { - select[colKey] = true; - } - } - - return select; -} - -function mergeSelectPath( - target: Record, - segments: string[], -): void { - const [head, ...tail] = segments; - if (!head) { - return; - } - - if (tail.length === 0) { - target[head] = true; - return; - } - - const existing = target[head]; - const nestedSelect = existing && typeof existing === "object" && existing !== null && "select" in existing - ? (existing as { select: Record }).select - : {}; - - mergeSelectPath(nestedSelect, tail); - target[head] = { select: nestedSelect }; -} - -/** - * Build a Prisma `where` from the filter array. - * Only scalar top-level fields are allowed for safety. - */ -function buildWhere( - entity: EntityKey, - filters: Array<{ field: string; op: string; value: string }>, -): Record { - const where: Record = {}; - - for (const filter of filters) { - const field = assertValidFilterField(entity, filter.field); - const colDef = getColumnDef(entity, field); - if (!colDef) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Unknown filter field for ${entity}: ${filter.field}`, - }); - } - const parsedValue = parseFilterValueOrThrow(colDef, filter.value); - - switch (filter.op) { - case "eq": - where[field] = parsedValue; - break; - case "neq": - where[field] = { not: parsedValue }; - break; - case "gt": - where[field] = { gt: parsedValue }; - break; - case "lt": - where[field] = { lt: parsedValue }; - break; - case "gte": - where[field] = { gte: parsedValue }; - break; - case "lte": - where[field] = { lte: parsedValue }; - break; - case "contains": - if (colDef.dataType !== "string") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Filter operator contains is only supported for string fields like ${field}`, - }); - } - where[field] = { contains: filter.value, mode: "insensitive" }; - break; - case "in": - if (colDef.dataType !== "string") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Filter operator in is only supported for string fields like ${field}`, - }); - } - where[field] = { in: filter.value.split(",").map((v) => v.trim()) }; - break; - default: - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Unsupported filter operator: ${filter.op}`, - }); - break; - } - } - - return where; -} - -/** - * Flatten a Prisma result row so nested relations become dot-notation keys. - * E.g. { country: { name: "DE" } } => { "country.name": "DE" } - */ -function flattenRow(row: Record, prefix = ""): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(row)) { - const fullKey = prefix ? `${prefix}.${key}` : key; - if (value !== null && typeof value === "object" && !(value instanceof Date) && !Array.isArray(value)) { - Object.assign(result, flattenRow(value as Record, fullKey)); - } else { - result[fullKey] = value; - } - } - return result; -} - -/** - * Format a value for CSV output. - */ -function csvEscape(value: unknown): string { - if (value === null || value === undefined) return ""; - if (value instanceof Date) return value.toISOString(); - const str = String(value); - if (str.includes(",") || str.includes('"') || str.includes("\n")) { - return `"${str.replace(/"/g, '""')}"`; - } - return str; -} - -// ─── Input Schema ─────────────────────────────────────────────────────────── - -const reportEntitySchema = z.enum(["resource", "project", "assignment", "resource_month"]); - -const FilterSchema = z.object({ - field: z.string().min(1), - op: z.enum(["eq", "neq", "gt", "lt", "gte", "lte", "contains", "in"]), - value: z.string(), -}); - -const ReportInputSchema = z.object({ - entity: reportEntitySchema, - columns: z.array(z.string()).min(1), - filters: z.array(FilterSchema).default([]), - groupBy: z.string().optional(), - sortBy: z.string().optional(), - sortDir: z.enum(["asc", "desc"]).default("asc"), - periodMonth: z.string().regex(PERIOD_MONTH_PATTERN).optional(), - limit: z.number().int().min(1).max(5000).default(50), - offset: z.number().int().min(0).default(0), -}); - -const ReportTemplateConfigSchema = ReportInputSchema.omit({ limit: true, offset: true }); +import { controllerProcedure, createTRPCRouter } from "../trpc.js"; +import { + type EntityKey, + ReportTemplateConfigSchema, + validateReportInput, +} from "./report-query-config.js"; +import { reportQueryProcedures } from "./report-query-engine.js"; const ReportTemplateEntity = { RESOURCE: "RESOURCE", @@ -552,9 +41,9 @@ function getReportTemplateDelegate(db: unknown) { }).reportTemplate; } -// ─── Router ────────────────────────────────────────────────────────────────── - export const reportRouter = createTRPCRouter({ + ...reportQueryProcedures, + listTemplates: controllerProcedure.query(async ({ ctx }) => { const reportTemplate = getReportTemplateDelegate(ctx.db); const templates = await reportTemplate.findMany({ @@ -667,414 +156,8 @@ export const reportRouter = createTRPCRouter({ await reportTemplate.delete({ where: { id: input.id } }); return { ok: true }; }), - - /** - * Return available columns for a given entity type. - */ - 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 })); - }), - - /** - * Fetch report data with dynamic columns, filters, sorting and pagination. - */ - getReportData: controllerProcedure - .input(ReportInputSchema) - .query(async ({ ctx, input }) => { - return executeReportQuery(ctx.db, input); - }), - - /** - * Same as getReportData but returns a CSV string for download. - */ - 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 rows = result.rows; - const outputColumns = result.columns; - - // Build CSV - const entityColumns = COLUMN_MAP[input.entity]; - const headerLabels = outputColumns.map((key) => { - const def = entityColumns.find((c) => c.key === key); - return def?.label ?? key; - }); - - const csvLines = [ - headerLabels.map(csvEscape).join(","), - ...rows.map((row) => - outputColumns.map((col) => csvEscape(row[col])).join(","), - ), - ]; - - return { csv: csvLines.join("\n"), rowCount: rows.length }; - }), }); -type ReportInput = z.infer; -type FilterInput = z.infer; - -async function executeReportQuery( - db: any, - input: ReportInput, -): Promise<{ rows: Record[]; columns: string[]; totalCount: number }> { - 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); - - let orderBy: Record | undefined; - if (sortBy) { - const validField = getValidScalarField(entity, sortBy); - if (!validField) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Unsupported sort field for ${entity}: ${sortBy}`, - }); - } - orderBy = { [validField]: sortDir }; - } - - const modelDelegate = getModelDelegate(db, entity); - const [rawRows, totalCount] = await Promise.all([ - (modelDelegate as any).findMany({ - select, - where, - ...(orderBy ? { orderBy } : {}), - take: limit, - skip: offset, - }), - (modelDelegate as any).count({ where }), - ]); - - const rows = (rawRows as Record[]).map((row) => flattenRow(row)); - const outputColumns = ["id", ...columns.filter((column) => column !== "id")]; - - return { - rows: rows.map((row) => pickColumns(row, outputColumns)), - columns: outputColumns, - totalCount, - }; -} - -async function executeResourceMonthReport( - db: any, - input: ReportInput, -): Promise<{ rows: Record[]; columns: string[]; totalCount: number }> { - 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.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")]; - - return { - rows: pagedRows.map((row) => pickColumns(row, outputColumns)), - columns: outputColumns, - totalCount, - }; -} - -function parseFilterValue(def: ColumnDef | undefined, value: string): unknown { - if (!def) return value; - if (def.dataType === "number") { - const parsed = Number(value); - return Number.isNaN(parsed) ? null : parsed; - } - if (def.dataType === "boolean") { - return value === "true"; - } - if (def.dataType === "date") { - const parsed = new Date(value); - return Number.isNaN(parsed.getTime()) ? null : parsed.getTime(); - } - return value; -} - -function matchesInMemoryFilter( - row: Record, - filter: FilterInput, - columns: ColumnDef[], -): boolean { - const def = columns.find((column) => column.key === filter.field); - if (!def) { - return true; - } - - const rowValueRaw = row[filter.field]; - const rowValue = def.dataType === "date" && typeof rowValueRaw === "string" - ? new Date(rowValueRaw).getTime() - : rowValueRaw; - const parsedFilterValue = parseFilterValue(def, filter.value); - - if (parsedFilterValue === null) { - return false; - } - - switch (filter.op) { - case "eq": - return rowValue === parsedFilterValue; - case "neq": - return rowValue !== parsedFilterValue; - case "gt": - return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue > parsedFilterValue; - case "lt": - return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue < parsedFilterValue; - case "gte": - return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue >= parsedFilterValue; - case "lte": - return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue <= parsedFilterValue; - case "contains": - return typeof rowValue === "string" && rowValue.toLowerCase().includes(filter.value.toLowerCase()); - case "in": - return filter.value.split(",").map((value) => value.trim()).includes(String(rowValue ?? "")); - default: - return true; - } -} - -function sortInMemoryRows( - rows: Record[], - sortBy: string | undefined, - sortDir: "asc" | "desc", - columns: ColumnDef[], -): Record[] { - if (!sortBy) { - return rows; - } - - const def = columns.find((column) => column.key === sortBy); - if (!def) { - return rows; - } - - const direction = sortDir === "asc" ? 1 : -1; - return [...rows].sort((left, right) => { - const leftValue = left[sortBy]; - const rightValue = right[sortBy]; - - if (leftValue == null && rightValue == null) return 0; - if (leftValue == null) return 1; - if (rightValue == null) return -1; - - if (def.dataType === "number") { - return direction * (Number(leftValue) - Number(rightValue)); - } - if (def.dataType === "boolean") { - return direction * (Number(Boolean(leftValue)) - Number(Boolean(rightValue))); - } - if (def.dataType === "date") { - return direction * (new Date(String(leftValue)).getTime() - new Date(String(rightValue)).getTime()); - } - return direction * String(leftValue).localeCompare(String(rightValue), "de"); - }); -} - -function pickColumns(row: Record, columns: string[]): Record { - return Object.fromEntries(columns.map((column) => [column, row[column]])); -} - -function roundMetric(value: number): number { - return Math.round(value * 10) / 10; -} - function toTemplateEntity(entity: EntityKey): ReportTemplateEntity { switch (entity) { case "resource": @@ -1104,17 +187,3 @@ function fromTemplateEntity(entity: ReportTemplateEntity): EntityKey { throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${entity}` }); } } - -/** Resolve the Prisma model delegate from entity key. */ -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}` }); - } -}