refactor(api): extract report query engine
This commit is contained in:
@@ -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;
|
||||||
@@ -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<EntityKey, Set<string>> = {
|
||||||
|
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<typeof ReportInputSchema>;
|
||||||
|
export type FilterInput = z.infer<typeof FilterSchema>;
|
||||||
|
|
||||||
|
export interface ReportGroupSummary {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
rowCount: number;
|
||||||
|
startIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportQueryResult {
|
||||||
|
rows: Record<string, unknown>[];
|
||||||
|
columns: string[];
|
||||||
|
totalCount: number;
|
||||||
|
groups: ReportGroupSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateReportInput(input: ReportInput | z.infer<typeof ReportTemplateConfigSchema>): 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<string, unknown> {
|
||||||
|
const entityColumns = COLUMN_MAP[entity];
|
||||||
|
const select: Record<string, unknown> = { 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<string, unknown> }).select
|
||||||
|
: {};
|
||||||
|
mergeSelectPath(relationSelect, fieldSegments);
|
||||||
|
select[relationName] = { select: relationSelect };
|
||||||
|
} else {
|
||||||
|
select[colKey] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return select;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeSelectPath(target: Record<string, unknown>, 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<string, unknown> }).select
|
||||||
|
: {};
|
||||||
|
|
||||||
|
mergeSelectPath(nestedSelect, tail);
|
||||||
|
target[head] = { select: nestedSelect };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWhere(
|
||||||
|
entity: EntityKey,
|
||||||
|
filters: Array<{ field: string; op: string; value: string }>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
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<string, unknown>, prefix = ""): Record<string, unknown> {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
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<string, unknown>, 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;
|
||||||
|
}
|
||||||
@@ -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}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown>,
|
||||||
|
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<string, unknown>[],
|
||||||
|
groupBy: string | undefined,
|
||||||
|
sortBy: string | undefined,
|
||||||
|
sortDir: "asc" | "desc",
|
||||||
|
columns: ColumnDef[],
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
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<string, unknown>, columns: string[]): Record<string, unknown> {
|
||||||
|
return Object.fromEntries(columns.map((column) => [column, row[column]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReportGroups(
|
||||||
|
rows: Record<string, unknown>[],
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,524 +1,13 @@
|
|||||||
import { Prisma } from "@capakraken/db";
|
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 { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { controllerProcedure, createTRPCRouter } from "../trpc.js";
|
||||||
// ─── Column Definitions ──────────────────────────────────────────────────────
|
import {
|
||||||
|
type EntityKey,
|
||||||
interface ColumnDef {
|
ReportTemplateConfigSchema,
|
||||||
key: string;
|
validateReportInput,
|
||||||
label: string;
|
} from "./report-query-config.js";
|
||||||
dataType: "string" | "number" | "date" | "boolean";
|
import { reportQueryProcedures } from "./report-query-engine.js";
|
||||||
/** 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<EntityKey, ColumnDef[]> = {
|
|
||||||
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<EntityKey, Set<string>> = {
|
|
||||||
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<typeof ReportTemplateConfigSchema>): 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<string, unknown> {
|
|
||||||
const entityColumns = COLUMN_MAP[entity];
|
|
||||||
const select: Record<string, unknown> = { 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<string, unknown> }).select
|
|
||||||
: {};
|
|
||||||
mergeSelectPath(relationSelect, fieldSegments);
|
|
||||||
select[relationName] = { select: relationSelect };
|
|
||||||
} else {
|
|
||||||
select[colKey] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return select;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeSelectPath(
|
|
||||||
target: Record<string, unknown>,
|
|
||||||
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<string, unknown> }).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<string, unknown> {
|
|
||||||
const where: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
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<string, unknown>, prefix = ""): Record<string, unknown> {
|
|
||||||
const result: Record<string, unknown> = {};
|
|
||||||
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<string, unknown>, 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 });
|
|
||||||
|
|
||||||
const ReportTemplateEntity = {
|
const ReportTemplateEntity = {
|
||||||
RESOURCE: "RESOURCE",
|
RESOURCE: "RESOURCE",
|
||||||
@@ -552,9 +41,9 @@ function getReportTemplateDelegate(db: unknown) {
|
|||||||
}).reportTemplate;
|
}).reportTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Router ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const reportRouter = createTRPCRouter({
|
export const reportRouter = createTRPCRouter({
|
||||||
|
...reportQueryProcedures,
|
||||||
|
|
||||||
listTemplates: controllerProcedure.query(async ({ ctx }) => {
|
listTemplates: controllerProcedure.query(async ({ ctx }) => {
|
||||||
const reportTemplate = getReportTemplateDelegate(ctx.db);
|
const reportTemplate = getReportTemplateDelegate(ctx.db);
|
||||||
const templates = await reportTemplate.findMany({
|
const templates = await reportTemplate.findMany({
|
||||||
@@ -667,414 +156,8 @@ export const reportRouter = createTRPCRouter({
|
|||||||
await reportTemplate.delete({ where: { id: input.id } });
|
await reportTemplate.delete({ where: { id: input.id } });
|
||||||
return { ok: true };
|
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<typeof ReportInputSchema>;
|
|
||||||
type FilterInput = z.infer<typeof FilterSchema>;
|
|
||||||
|
|
||||||
async function executeReportQuery(
|
|
||||||
db: any,
|
|
||||||
input: ReportInput,
|
|
||||||
): Promise<{ rows: Record<string, unknown>[]; 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<string, string> | 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<string, unknown>[]).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<string, unknown>[]; 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<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.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<string, unknown>,
|
|
||||||
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<string, unknown>[],
|
|
||||||
sortBy: string | undefined,
|
|
||||||
sortDir: "asc" | "desc",
|
|
||||||
columns: ColumnDef[],
|
|
||||||
): Record<string, unknown>[] {
|
|
||||||
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<string, unknown>, columns: string[]): Record<string, unknown> {
|
|
||||||
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 {
|
function toTemplateEntity(entity: EntityKey): ReportTemplateEntity {
|
||||||
switch (entity) {
|
switch (entity) {
|
||||||
case "resource":
|
case "resource":
|
||||||
@@ -1104,17 +187,3 @@ function fromTemplateEntity(entity: ReportTemplateEntity): EntityKey {
|
|||||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${entity}` });
|
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}` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user