refactor(api): strengthen report template persistence
This commit is contained in:
@@ -116,6 +116,70 @@ describe("report router", () => {
|
|||||||
expect(result.csv).toContain("Alice,DE,1,8,4,156,124.8,156");
|
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 () => {
|
it("rejects invalid resource_month period months instead of silently normalizing them", async () => {
|
||||||
const caller = createControllerCaller({});
|
const caller = createControllerCaller({});
|
||||||
|
|
||||||
@@ -191,4 +255,75 @@ describe("report router", () => {
|
|||||||
message: expect.stringContaining("lcrCents"),
|
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;
|
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">;
|
type ReportTemplateContext = Pick<TRPCContext, "db" | "dbUser">;
|
||||||
|
|
||||||
export const SaveReportTemplateInputSchema = z.object({
|
export const SaveReportTemplateInputSchema = z.object({
|
||||||
@@ -75,7 +132,6 @@ export async function saveReportTemplate(
|
|||||||
validateReportInput(input.config);
|
validateReportInput(input.config);
|
||||||
const payload = input.config as unknown as Prisma.InputJsonValue;
|
const payload = input.config as unknown as Prisma.InputJsonValue;
|
||||||
const entity = toTemplateEntity(input.config.entity);
|
const entity = toTemplateEntity(input.config.entity);
|
||||||
const writeData = buildTemplateWriteData(input, entity, payload);
|
|
||||||
|
|
||||||
if (input.id) {
|
if (input.id) {
|
||||||
const existing = await ctx.db.reportTemplate.findUnique({
|
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" });
|
throw new TRPCError({ code: "FORBIDDEN", message: "Template cannot be updated" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx.db.reportTemplate.update({
|
try {
|
||||||
|
return await ctx.db.reportTemplate.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
data: writeData,
|
data: buildTemplateUpdateData(input, entity, payload),
|
||||||
select: { id: true, updatedAt: true },
|
select: { id: true, updatedAt: true },
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw mapTemplateWriteError(error, input.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx.db.reportTemplate.upsert({
|
try {
|
||||||
|
return await ctx.db.reportTemplate.upsert({
|
||||||
where: {
|
where: {
|
||||||
ownerId_name: {
|
ownerId_name: {
|
||||||
ownerId,
|
ownerId,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
update: omitNameFromTemplateWriteData(writeData),
|
update: buildTemplateUpsertUpdateData(input, entity, payload),
|
||||||
create: {
|
create: buildTemplateCreateData(ownerId, input, entity, payload),
|
||||||
ownerId,
|
|
||||||
...writeData,
|
|
||||||
},
|
|
||||||
select: { id: true, updatedAt: true },
|
select: { id: true, updatedAt: true },
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw mapTemplateWriteError(error, input.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteReportTemplate(
|
export async function deleteReportTemplate(
|
||||||
@@ -128,40 +189,139 @@ export async function deleteReportTemplate(
|
|||||||
return { ok: true };
|
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>,
|
input: z.infer<typeof SaveReportTemplateInputSchema>,
|
||||||
entity: ReportTemplateEntity,
|
entity: ReportTemplateEntity,
|
||||||
config: Prisma.InputJsonValue,
|
config: Prisma.InputJsonValue,
|
||||||
) {
|
) : Prisma.ReportTemplateUncheckedCreateInput {
|
||||||
return {
|
const description = normalizeTemplateDescription(input.description);
|
||||||
|
const data: Prisma.ReportTemplateUncheckedCreateInput = {
|
||||||
|
ownerId,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
entity,
|
entity,
|
||||||
config,
|
config,
|
||||||
isShared: input.isShared,
|
isShared: input.isShared,
|
||||||
...(input.description !== undefined ? { description: input.description } : {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (description !== undefined) {
|
||||||
|
data.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function omitNameFromTemplateWriteData(
|
function buildTemplateUpdateData(
|
||||||
data: ReturnType<typeof buildTemplateWriteData>,
|
input: z.infer<typeof SaveReportTemplateInputSchema>,
|
||||||
) {
|
entity: ReportTemplateEntity,
|
||||||
const { name: _name, ...updateData } = data;
|
config: Prisma.InputJsonValue,
|
||||||
return updateData;
|
): 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) {
|
function mapTemplateRecord(template: ReportTemplateRecord, ownerId: string) {
|
||||||
|
const config = ReportTemplateConfigSchema.parse(template.config);
|
||||||
return {
|
return {
|
||||||
id: template.id,
|
id: template.id,
|
||||||
name: template.name,
|
name: template.name,
|
||||||
description: template.description,
|
description: template.description,
|
||||||
entity: fromTemplateEntity(template.entity),
|
entity: fromTemplateEntity(template.entity),
|
||||||
config: ReportTemplateConfigSchema.parse(template.config),
|
config,
|
||||||
isShared: template.isShared,
|
isShared: template.isShared,
|
||||||
isOwner: template.ownerId === ownerId,
|
isOwner: template.ownerId === ownerId,
|
||||||
|
completeness: getTemplateCompleteness(config),
|
||||||
updatedAt: template.updatedAt,
|
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 {
|
function toTemplateEntity(entity: EntityKey): ReportTemplateEntity {
|
||||||
switch (entity) {
|
switch (entity) {
|
||||||
case "resource":
|
case "resource":
|
||||||
|
|||||||
Reference in New Issue
Block a user