import { SystemRole } from "@capakraken/shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@capakraken/application", () => ({ isChargeabilityActualBooking: vi.fn(() => false), isChargeabilityRelevantProject: vi.fn(() => false), listAssignmentBookings: vi.fn().mockResolvedValue([]), })); vi.mock("../lib/resource-capacity.js", () => ({ calculateEffectiveAvailableHours: vi.fn(({ context }: { context?: unknown }) => (context ? 156 : 168)), calculateEffectiveBookedHours: vi.fn(() => 0), countEffectiveWorkingDays: vi.fn(({ context }: { context?: unknown }) => (context ? 19.5 : 21)), getAvailabilityHoursForDate: vi.fn(() => 8), loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map([ [ "res_1", { holidayDates: new Set(["2026-04-10"]), vacationFractionsByDate: new Map([["2026-04-14", 0.5]]), }, ], ])), })); import { reportRouter } from "../router/report.js"; import { createCallerFactory } from "../trpc.js"; const createCaller = createCallerFactory(reportRouter); function createControllerCaller(db: Record) { return createCaller({ session: { user: { email: "controller@example.com", name: "Controller", image: null }, expires: "2099-01-01T00:00:00.000Z", }, db: db as never, dbUser: { id: "user_controller", systemRole: SystemRole.CONTROLLER, permissionOverrides: null, }, }); } describe("report router", () => { beforeEach(() => { vi.clearAllMocks(); }); it("lists the new resource month transparency columns", async () => { const caller = createControllerCaller({}); const columns = await caller.getAvailableColumns({ entity: "resource_month" }); expect(columns).toEqual(expect.arrayContaining([ expect.objectContaining({ key: "monthlyPublicHolidayCount", label: "Holiday Dates" }), expect.objectContaining({ key: "monthlyTargetHours", label: "Target Hours" }), expect.objectContaining({ key: "monthlyUnassignedHours", label: "Unassigned Hours" }), ])); }); it("exposes extended resource and project basis columns for report completeness", async () => { const caller = createControllerCaller({}); const resourceColumns = await caller.getAvailableColumns({ entity: "resource" }); const projectColumns = await caller.getAvailableColumns({ entity: "project" }); expect(resourceColumns).toEqual(expect.arrayContaining([ expect.objectContaining({ key: "enterpriseId", label: "Enterprise ID" }), expect.objectContaining({ key: "valueScore", label: "Value Score" }), expect.objectContaining({ key: "blueprint.name", label: "Blueprint" }), expect.objectContaining({ key: "clientUnit.name", label: "Client Unit" }), ])); expect(projectColumns).toEqual(expect.arrayContaining([ expect.objectContaining({ key: "shoringThreshold", label: "Shoring Threshold (%)" }), expect.objectContaining({ key: "onshoreCountryCode", label: "Onshore Country Code" }), expect.objectContaining({ key: "color", label: "Color" }), ])); }); it("lists backend-managed report blueprints for resource_month", async () => { const caller = createControllerCaller({}); const blueprints = await caller.listBlueprints({ entity: "resource_month" }); expect(blueprints).toEqual([ expect.objectContaining({ id: "resource-month-sah-transparency", entity: "resource_month", templateName: "Monthly SAH transparency", config: expect.objectContaining({ entity: "resource_month", sortBy: "displayName", sortDir: "asc", filters: [], }), }), expect.objectContaining({ id: "resource-month-chargeability-audit", entity: "resource_month", templateName: "Monthly chargeability audit", config: expect.objectContaining({ entity: "resource_month", sortBy: "monthlyActualChargeabilityPct", sortDir: "desc", filters: [], }), }), expect.objectContaining({ id: "resource-month-location-comparison", entity: "resource_month", templateName: "Monthly holiday comparison by location", config: expect.objectContaining({ entity: "resource_month", groupBy: "federalState", sortBy: "monthlyPublicHolidayHoursDeduction", sortDir: "desc", filters: [], }), }), ]); }); it("exports resource month basis and computed columns in CSV", async () => { const db = { resource: { findMany: vi.fn().mockResolvedValue([ { id: "res_1", eid: "alice", displayName: "Alice", email: "alice@example.com", chapter: "VFX", resourceType: "EMPLOYEE", isActive: true, chgResponsibility: false, rolledOff: false, departed: false, lcrCents: 7500, ucrCents: 10000, currency: "EUR", fte: 1, availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, chargeabilityTarget: 80, federalState: "BY", countryId: "country_de", metroCityId: null, country: { code: "DE", name: "Germany" }, metroCity: null, orgUnit: { name: "Delivery" }, managementLevelGroup: null, managementLevel: { name: "Senior" }, }, ]), }, }; const caller = createControllerCaller(db); const result = await caller.exportReport({ entity: "resource_month", columns: [ "displayName", "countryCode", "monthlyPublicHolidayCount", "monthlyPublicHolidayHoursDeduction", "monthlyAbsenceHoursDeduction", "monthlySahHours", "monthlyTargetHours", "monthlyUnassignedHours", ], filters: [], periodMonth: "2026-04", limit: 100, }); expect(result.rowCount).toBe(1); expect(result.csv).toContain("Name,Country Code,Holiday Dates,Holiday Hours Deduction,Absence Hours Deduction,SAH,Target Hours,Unassigned Hours"); expect(result.csv).toContain("Alice,DE,1,8,4,156,124.8,156"); expect(result.columns).toEqual([ "id", "displayName", "countryCode", "monthlyPublicHolidayCount", "monthlyPublicHolidayHoursDeduction", "monthlyAbsenceHoursDeduction", "monthlySahHours", "monthlyTargetHours", "monthlyUnassignedHours", ]); expect(result.explainability).toEqual({ entity: "resource_month", periodMonth: "2026-04", locationContextColumns: ["countryCode"], holidayMetricColumns: ["monthlyPublicHolidayCount", "monthlyPublicHolidayHoursDeduction"], absenceMetricColumns: ["monthlyAbsenceHoursDeduction"], capacityMetricColumns: ["monthlySahHours", "monthlyTargetHours"], chargeabilityMetricColumns: ["monthlyUnassignedHours"], missingRecommendedColumns: [ "countryName", "federalState", "metroCityName", "monthlyPublicHolidayWorkdayCount", "monthlyAbsenceDayEquivalent", "monthlyBaseWorkingDays", "monthlyEffectiveWorkingDays", "monthlyBaseAvailableHours", "monthlyChargeabilityTargetPct", ], notes: [ "monthlySahHours already reflects region-specific public holidays from country, federal state, and city context when those attributes exist on the resource.", "monthlyAbsence* metrics only deduct workdays that are not already counted as public holidays.", "monthlyBaseAvailableHours shows pre-deduction capacity; compare it with holiday, absence, and SAH columns to explain the final monthly availability.", ], }); }); it("keeps holiday and absence deductions separate in resource_month rows", async () => { const db = { resource: { findMany: vi.fn().mockResolvedValue([ { id: "res_1", eid: "alice", displayName: "Alice", email: "alice@example.com", chapter: "VFX", resourceType: "EMPLOYEE", isActive: true, chgResponsibility: false, rolledOff: false, departed: false, lcrCents: 7500, ucrCents: 10000, currency: "EUR", fte: 1, availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, chargeabilityTarget: 80, federalState: "BY", countryId: "country_de", metroCityId: null, country: { code: "DE", name: "Germany" }, metroCity: null, orgUnit: { name: "Delivery" }, managementLevelGroup: null, managementLevel: { name: "Senior" }, }, ]), }, }; const caller = createControllerCaller(db); const result = await caller.getReportData({ entity: "resource_month", columns: [ "displayName", "monthlyPublicHolidayCount", "monthlyPublicHolidayHoursDeduction", "monthlyAbsenceDayEquivalent", "monthlyAbsenceHoursDeduction", "monthlySahHours", ], filters: [], periodMonth: "2026-04", limit: 100, offset: 0, }); expect(result.rows).toEqual([ { id: "res_1:2026-04", displayName: "Alice", monthlyPublicHolidayCount: 1, monthlyPublicHolidayHoursDeduction: 8, monthlyAbsenceDayEquivalent: 0.5, monthlyAbsenceHoursDeduction: 4, monthlySahHours: 156, }, ]); expect(result.explainability).toEqual({ entity: "resource_month", periodMonth: "2026-04", locationContextColumns: [], holidayMetricColumns: ["monthlyPublicHolidayCount", "monthlyPublicHolidayHoursDeduction"], absenceMetricColumns: ["monthlyAbsenceDayEquivalent", "monthlyAbsenceHoursDeduction"], capacityMetricColumns: ["monthlySahHours"], chargeabilityMetricColumns: [], missingRecommendedColumns: [ "countryCode", "countryName", "federalState", "metroCityName", "monthlyPublicHolidayWorkdayCount", "monthlyBaseWorkingDays", "monthlyEffectiveWorkingDays", "monthlyBaseAvailableHours", "monthlyChargeabilityTargetPct", "monthlyTargetHours", ], notes: [ "monthlySahHours already reflects region-specific public holidays from country, federal state, and city context when those attributes exist on the resource.", "monthlyAbsence* metrics only deduct workdays that are not already counted as public holidays.", "monthlyBaseAvailableHours shows pre-deduction capacity; compare it with holiday, absence, and SAH columns to explain the final monthly availability.", ], }); }); it("flattens extended assignment resource and project context columns", async () => { const db = { assignment: { findMany: vi.fn().mockResolvedValue([ { id: "asg_1", hoursPerDay: 6, resource: { displayName: "Alice", resourceType: "EMPLOYEE", chargeabilityTarget: 85, orgUnit: { name: "Delivery" }, managementLevelGroup: { name: "Senior IC" }, managementLevel: { name: "Senior Artist" }, }, project: { name: "Gelddruckmaschine", orderType: "TIME_AND_MATERIAL", allocationType: "PROJECT", blueprint: { name: "Consulting Blueprint" }, utilizationCategory: { name: "Billable" }, }, }, ]), count: vi.fn().mockResolvedValue(1), }, }; const caller = createControllerCaller(db); const result = await caller.getReportData({ entity: "assignment", columns: [ "resource.displayName", "resource.resourceType", "resource.chargeabilityTarget", "resource.orgUnit.name", "resource.managementLevelGroup.name", "resource.managementLevel.name", "project.name", "project.orderType", "project.allocationType", "project.blueprint.name", "project.utilizationCategory.name", "hoursPerDay", ], filters: [], sortBy: "hoursPerDay", sortDir: "desc", limit: 10, offset: 0, }); expect(db.assignment.findMany).toHaveBeenCalledWith({ select: { id: true, hoursPerDay: true, resource: { select: { displayName: true, resourceType: true, chargeabilityTarget: true, orgUnit: { select: { name: true } }, managementLevelGroup: { select: { name: true } }, managementLevel: { select: { name: true } }, }, }, project: { select: { name: true, orderType: true, allocationType: true, blueprint: { select: { name: true } }, utilizationCategory: { select: { name: true } }, }, }, }, where: {}, orderBy: [{ hoursPerDay: "desc" }], take: 10, skip: 0, }); expect(result.rows).toEqual([ { id: "asg_1", "resource.displayName": "Alice", "resource.resourceType": "EMPLOYEE", "resource.chargeabilityTarget": 85, "resource.orgUnit.name": "Delivery", "resource.managementLevelGroup.name": "Senior IC", "resource.managementLevel.name": "Senior Artist", "project.name": "Gelddruckmaschine", "project.orderType": "TIME_AND_MATERIAL", "project.allocationType": "PROJECT", "project.blueprint.name": "Consulting Blueprint", "project.utilizationCategory.name": "Billable", hoursPerDay: 6, }, ]); }); it("rejects invalid resource_month period months instead of silently normalizing them", async () => { const caller = createControllerCaller({}); await expect(caller.getReportData({ entity: "resource_month", columns: ["displayName"], filters: [], periodMonth: "2026-13", limit: 10, offset: 0, })).rejects.toMatchObject({ code: "BAD_REQUEST", message: expect.stringContaining("Invalid"), }); }); it("rejects unknown columns instead of silently dropping them", async () => { const caller = createControllerCaller({ resource: { findMany: vi.fn(), count: vi.fn(), }, }); await expect(caller.getReportData({ entity: "resource", columns: ["displayName", "unknownColumn"], filters: [], limit: 10, offset: 0, })).rejects.toMatchObject({ code: "BAD_REQUEST", message: expect.stringContaining("unknownColumn"), }); }); it("rejects unsupported relation filters instead of silently ignoring them", async () => { const caller = createControllerCaller({ assignment: { findMany: vi.fn(), count: vi.fn(), }, }); await expect(caller.getReportData({ entity: "assignment", columns: ["id", "resource.displayName"], filters: [{ field: "resource.displayName", op: "contains", value: "Alice" }], limit: 10, offset: 0, })).rejects.toMatchObject({ code: "BAD_REQUEST", message: expect.stringContaining("resource.displayName"), }); }); it("rejects invalid numeric filter values instead of silently dropping them", async () => { const caller = createControllerCaller({ resource: { findMany: vi.fn(), count: vi.fn(), }, }); await expect(caller.getReportData({ entity: "resource", columns: ["displayName"], filters: [{ field: "lcrCents", op: "gte", value: "not-a-number" }], limit: 10, offset: 0, })).rejects.toMatchObject({ code: "BAD_REQUEST", message: expect.stringContaining("lcrCents"), }); }); it("returns page-local grouping metadata and grouped CSV sections", async () => { const db = { resource: { findMany: vi.fn().mockResolvedValue([ { id: "res_2", displayName: "Bob", chapter: "Delivery", }, { id: "res_1", displayName: "Alice", chapter: "Design", }, { id: "res_3", displayName: "Cara", chapter: "Design", }, ]), count: vi.fn().mockResolvedValue(3), }, }; const caller = createControllerCaller(db); const data = await caller.getReportData({ entity: "resource", columns: ["displayName", "chapter"], filters: [], groupBy: "chapter", sortBy: "displayName", sortDir: "asc", limit: 10, offset: 0, }); expect(db.resource.findMany).toHaveBeenCalledWith({ select: { id: true, displayName: true, chapter: true, }, where: {}, orderBy: [{ chapter: "asc" }, { displayName: "asc" }], take: 10, skip: 0, }); expect(data.rows).toEqual([ { id: "res_2", displayName: "Bob", chapter: "Delivery" }, { id: "res_1", displayName: "Alice", chapter: "Design" }, { id: "res_3", displayName: "Cara", chapter: "Design" }, ]); expect(data.groups).toEqual([ { key: "chapter:Delivery", label: "Delivery", rowCount: 1, startIndex: 0 }, { key: "chapter:Design", label: "Design", rowCount: 2, startIndex: 1 }, ]); const csv = await caller.exportReport({ entity: "resource", columns: ["displayName", "chapter"], filters: [], groupBy: "chapter", sortBy: "displayName", sortDir: "asc", limit: 10, }); expect(csv.csv).toContain("Chapter: Design (2),"); expect(csv.csv).toContain("Chapter: Delivery (1),"); }); });