From cb8669c489237b4fb64e659820e17011d0684562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 22:35:15 +0200 Subject: [PATCH] refactor(api): strengthen report template persistence --- .../api/src/__tests__/report-router.test.ts | 135 +++++++++++ .../report-template-procedure-support.ts | 218 +++++++++++++++--- 2 files changed, 324 insertions(+), 29 deletions(-) diff --git a/packages/api/src/__tests__/report-router.test.ts b/packages/api/src/__tests__/report-router.test.ts index e34f507..b0df0ec 100644 --- a/packages/api/src/__tests__/report-router.test.ts +++ b/packages/api/src/__tests__/report-router.test.ts @@ -116,6 +116,70 @@ describe("report router", () => { expect(result.csv).toContain("Alice,DE,1,8,4,156,124.8,156"); }); + 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, + }, + ]); + }); + it("rejects invalid resource_month period months instead of silently normalizing them", async () => { const caller = createControllerCaller({}); @@ -191,4 +255,75 @@ describe("report router", () => { 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),"); + }); }); diff --git a/packages/api/src/router/report-template-procedure-support.ts b/packages/api/src/router/report-template-procedure-support.ts index c43dd7b..b9d8965 100644 --- a/packages/api/src/router/report-template-procedure-support.ts +++ b/packages/api/src/router/report-template-procedure-support.ts @@ -28,6 +28,63 @@ type ReportTemplateRecord = { updatedAt: Date; }; +const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [ + "monthKey", + "displayName", + "eid", + "chapter", + "countryCode", + "countryName", + "federalState", + "metroCityName", + "orgUnitName", + "managementLevelGroupName", + "monthlyBaseWorkingDays", + "monthlyEffectiveWorkingDays", + "monthlyBaseAvailableHours", + "monthlyPublicHolidayCount", + "monthlyPublicHolidayWorkdayCount", + "monthlyPublicHolidayHoursDeduction", + "monthlyAbsenceDayEquivalent", + "monthlyAbsenceHoursDeduction", + "monthlySahHours", + "monthlyChargeabilityTargetPct", + "monthlyTargetHours", + "monthlyActualBookedHours", + "monthlyExpectedBookedHours", + "monthlyActualChargeabilityPct", + "monthlyExpectedChargeabilityPct", + "monthlyUnassignedHours", +] as const; + +const RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS = [ + "monthKey", + "displayName", + "countryName", + "federalState", + "metroCityName", + "monthlyPublicHolidayCount", + "monthlyPublicHolidayHoursDeduction", + "monthlyAbsenceDayEquivalent", + "monthlyAbsenceHoursDeduction", + "monthlySahHours", + "monthlyTargetHours", + "monthlyActualBookedHours", + "monthlyUnassignedHours", +] as const; + +type ResourceMonthTemplateCompleteness = { + scope: "resource_month"; + isAuditReady: boolean; + isRecommendedComplete: boolean; + recommendedColumnCount: number; + selectedRecommendedColumnCount: number; + minimumAuditColumnCount: number; + selectedMinimumAuditColumnCount: number; + missingRecommendedColumns: string[]; + missingMinimumAuditColumns: string[]; +}; + type ReportTemplateContext = Pick; export const SaveReportTemplateInputSchema = z.object({ @@ -75,7 +132,6 @@ export async function saveReportTemplate( validateReportInput(input.config); const payload = input.config as unknown as Prisma.InputJsonValue; const entity = toTemplateEntity(input.config.entity); - const writeData = buildTemplateWriteData(input, entity, payload); if (input.id) { const existing = await ctx.db.reportTemplate.findUnique({ @@ -87,27 +143,32 @@ export async function saveReportTemplate( throw new TRPCError({ code: "FORBIDDEN", message: "Template cannot be updated" }); } - return ctx.db.reportTemplate.update({ - where: { id: input.id }, - data: writeData, - select: { id: true, updatedAt: true }, - }); + try { + return await ctx.db.reportTemplate.update({ + where: { id: input.id }, + data: buildTemplateUpdateData(input, entity, payload), + select: { id: true, updatedAt: true }, + }); + } catch (error) { + throw mapTemplateWriteError(error, input.name); + } } - return ctx.db.reportTemplate.upsert({ - where: { - ownerId_name: { - ownerId, - name: input.name, + try { + return await ctx.db.reportTemplate.upsert({ + where: { + ownerId_name: { + ownerId, + name: input.name, + }, }, - }, - update: omitNameFromTemplateWriteData(writeData), - create: { - ownerId, - ...writeData, - }, - select: { id: true, updatedAt: true }, - }); + update: buildTemplateUpsertUpdateData(input, entity, payload), + create: buildTemplateCreateData(ownerId, input, entity, payload), + select: { id: true, updatedAt: true }, + }); + } catch (error) { + throw mapTemplateWriteError(error, input.name); + } } export async function deleteReportTemplate( @@ -128,40 +189,139 @@ export async function deleteReportTemplate( return { ok: true }; } -function buildTemplateWriteData( +function normalizeTemplateDescription(description: string | undefined): string | null | undefined { + if (description === undefined) { + return undefined; + } + return description.length > 0 ? description : null; +} + +function mapTemplateWriteError(error: unknown, templateName: string): TRPCError { + if ( + typeof error === "object" + && error !== null + && "code" in error + && error.code === "P2002" + ) { + return new TRPCError({ + code: "CONFLICT", + message: `A report template named "${templateName}" already exists`, + }); + } + + if (error instanceof TRPCError) { + return error; + } + + return new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to save report template", + cause: error, + }); +} + +function buildTemplateCreateData( + ownerId: string, input: z.infer, entity: ReportTemplateEntity, config: Prisma.InputJsonValue, -) { - return { +) : Prisma.ReportTemplateUncheckedCreateInput { + const description = normalizeTemplateDescription(input.description); + const data: Prisma.ReportTemplateUncheckedCreateInput = { + ownerId, name: input.name, entity, config, isShared: input.isShared, - ...(input.description !== undefined ? { description: input.description } : {}), }; + + if (description !== undefined) { + data.description = description; + } + + return data; } -function omitNameFromTemplateWriteData( - data: ReturnType, -) { - const { name: _name, ...updateData } = data; - return updateData; +function buildTemplateUpdateData( + input: z.infer, + entity: ReportTemplateEntity, + config: Prisma.InputJsonValue, +): Prisma.ReportTemplateUncheckedUpdateInput { + const description = normalizeTemplateDescription(input.description); + const data: Prisma.ReportTemplateUncheckedUpdateInput = { + name: input.name, + entity, + config, + isShared: input.isShared, + }; + + if (description !== undefined) { + data.description = description; + } + + return data; +} + +function buildTemplateUpsertUpdateData( + input: z.infer, + entity: ReportTemplateEntity, + config: Prisma.InputJsonValue, +): Prisma.ReportTemplateUncheckedUpdateInput { + const description = normalizeTemplateDescription(input.description); + const data: Prisma.ReportTemplateUncheckedUpdateInput = { + entity, + config, + isShared: input.isShared, + }; + + if (description !== undefined) { + data.description = description; + } + + return data; } function mapTemplateRecord(template: ReportTemplateRecord, ownerId: string) { + const config = ReportTemplateConfigSchema.parse(template.config); return { id: template.id, name: template.name, description: template.description, entity: fromTemplateEntity(template.entity), - config: ReportTemplateConfigSchema.parse(template.config), + config, isShared: template.isShared, isOwner: template.ownerId === ownerId, + completeness: getTemplateCompleteness(config), updatedAt: template.updatedAt, }; } +function getTemplateCompleteness( + config: z.infer, +): ResourceMonthTemplateCompleteness | null { + if (config.entity !== "resource_month") { + return null; + } + + const selectedColumns = new Set(config.columns); + const missingRecommendedColumns = RESOURCE_MONTH_RECOMMENDED_COLUMNS + .filter((column) => !selectedColumns.has(column)); + const missingMinimumAuditColumns = RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS + .filter((column) => !selectedColumns.has(column)); + + return { + scope: "resource_month", + isAuditReady: missingMinimumAuditColumns.length === 0, + isRecommendedComplete: missingRecommendedColumns.length === 0, + recommendedColumnCount: RESOURCE_MONTH_RECOMMENDED_COLUMNS.length, + selectedRecommendedColumnCount: RESOURCE_MONTH_RECOMMENDED_COLUMNS.length - missingRecommendedColumns.length, + minimumAuditColumnCount: RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length, + selectedMinimumAuditColumnCount: RESOURCE_MONTH_MINIMUM_AUDIT_COLUMNS.length - missingMinimumAuditColumns.length, + missingRecommendedColumns, + missingMinimumAuditColumns, + }; +} + function toTemplateEntity(entity: EntityKey): ReportTemplateEntity { switch (entity) { case "resource":