refactor(api): strengthen report template persistence

This commit is contained in:
2026-03-31 22:35:15 +02:00
parent f2bcf4b7f0
commit cb8669c489
2 changed files with 324 additions and 29 deletions
@@ -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),");
});
});
@@ -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<TRPCContext, "db" | "dbUser">;
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<typeof SaveReportTemplateInputSchema>,
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<typeof buildTemplateWriteData>,
) {
const { name: _name, ...updateData } = data;
return updateData;
function buildTemplateUpdateData(
input: z.infer<typeof SaveReportTemplateInputSchema>,
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<typeof SaveReportTemplateInputSchema>,
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<typeof ReportTemplateConfigSchema>,
): 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":