Files
CapaKraken/packages/api/src/__tests__/chargeability-report-router.test.ts
T

536 lines
17 KiB
TypeScript

import { SystemRole } from "@capakraken/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
return {
...actual,
isChargeabilityActualBooking: actual.isChargeabilityActualBooking,
listAssignmentBookings: vi.fn(),
};
});
import { listAssignmentBookings } from "@capakraken/application";
import { chargeabilityReportRouter } from "../router/chargeability-report.js";
import { createCallerFactory } from "../trpc.js";
const createCaller = createCallerFactory(chargeabilityReportRouter);
function createControllerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "controller@example.com", name: "Controller", image: null },
expires: "2026-03-14T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_controller",
systemRole: SystemRole.CONTROLLER,
permissionOverrides: null,
},
});
}
describe("chargeability report router", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("excludes proposed bookings by default but includes them when requested", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_1",
eid: "E-001",
displayName: "Alice",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_es",
federalState: null,
metroCityId: "city_1",
chargeabilityTarget: 80,
country: {
id: "country_es",
code: "ES",
dailyWorkingHours: 8,
scheduleRules: null,
},
orgUnit: { id: "org_1", name: "CGI" },
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
managementLevel: { id: "level_1", name: "L7" },
metroCity: { id: "city_1", name: "Barcelona" },
},
]),
},
project: {
findMany: vi.fn().mockResolvedValue([
{ id: "project_confirmed", utilizationCategory: { code: "Chg" } },
{ id: "project_proposed", utilizationCategory: { code: "Chg" } },
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "assignment_confirmed",
projectId: "project_confirmed",
resourceId: "resource_1",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-06T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "CONFIRMED",
project: {
id: "project_confirmed",
name: "Confirmed Project",
shortCode: "CP",
status: "ACTIVE",
orderType: "CLIENT",
dynamicFields: null,
},
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
},
{
id: "assignment_proposed",
projectId: "project_proposed",
resourceId: "resource_1",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-06T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "PROPOSED",
project: {
id: "project_proposed",
name: "Proposed Project",
shortCode: "PP",
status: "ACTIVE",
orderType: "CLIENT",
dynamicFields: null,
},
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
},
]);
const caller = createControllerCaller(db);
const strict = await caller.getReport({
startMonth: "2026-03",
endMonth: "2026-03",
});
const withProposed = await caller.getReport({
startMonth: "2026-03",
endMonth: "2026-03",
includeProposed: true,
});
const strictMonth = strict.resources[0]?.months[0];
const proposedMonth = withProposed.resources[0]?.months[0];
expect(strictMonth).toBeDefined();
expect(proposedMonth).toBeDefined();
expect(strictMonth?.chg).toBeGreaterThan(0);
expect(proposedMonth?.chg).toBeGreaterThan(strictMonth?.chg ?? 0);
expect(proposedMonth?.chg).toBeCloseTo((strictMonth?.chg ?? 0) * 2, 5);
expect(withProposed.groupTotals[0]?.chg).toBeGreaterThan(strict.groupTotals[0]?.chg ?? 0);
});
it("includes imported TBD draft work only when proposed bookings are enabled", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_1",
eid: "E-001",
displayName: "Alice",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_es",
federalState: null,
metroCityId: "city_1",
chargeabilityTarget: 80,
country: {
id: "country_es",
code: "ES",
dailyWorkingHours: 8,
scheduleRules: null,
},
orgUnit: { id: "org_1", name: "CGI" },
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
managementLevel: { id: "level_1", name: "L7" },
metroCity: { id: "city_1", name: "Barcelona" },
},
]),
},
project: {
findMany: vi.fn().mockResolvedValue([
{ id: "project_tbd", utilizationCategory: { code: "Chg" } },
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "assignment_tbd",
projectId: "project_tbd",
resourceId: "resource_1",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-06T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "PROPOSED",
project: {
id: "project_tbd",
name: "TBD Project",
shortCode: "TBD-P1",
status: "DRAFT",
orderType: "CLIENT",
dynamicFields: { dispoImport: { isTbd: true } },
},
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
},
]);
const caller = createControllerCaller(db);
const strict = await caller.getReport({
startMonth: "2026-03",
endMonth: "2026-03",
});
const withProposed = await caller.getReport({
startMonth: "2026-03",
endMonth: "2026-03",
includeProposed: true,
});
expect(strict.resources[0]?.months[0]?.chg).toBe(0);
expect(withProposed.resources[0]?.months[0]?.chg).toBeGreaterThan(0);
expect(withProposed.groupTotals[0]?.chg).toBeGreaterThan(strict.groupTotals[0]?.chg ?? 0);
});
it("reduces SAH for German public holidays based on the calendar", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_de",
eid: "E-001",
displayName: "Alice",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: null,
metroCityId: "city_1",
chargeabilityTarget: 80,
country: {
id: "country_de",
code: "DE",
dailyWorkingHours: 8,
scheduleRules: null,
},
orgUnit: { id: "org_1", name: "CGI" },
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
managementLevel: { id: "level_1", name: "L7" },
metroCity: { id: "city_1", name: "Munich" },
},
]),
},
project: {
findMany: vi.fn().mockResolvedValue([
{ id: "project_full_month", utilizationCategory: { code: "Chg" } },
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "assignment_full_month",
projectId: "project_full_month",
resourceId: "resource_de",
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-01-31T00:00:00.000Z"),
hoursPerDay: 7,
dailyCostCents: 0,
status: "CONFIRMED",
project: {
id: "project_full_month",
name: "Full Month Project",
shortCode: "FMP",
status: "ACTIVE",
orderType: "CLIENT",
dynamicFields: null,
},
resource: { id: "resource_de", displayName: "Alice", chapter: "CGI" },
},
]);
const caller = createControllerCaller(db);
const report = await caller.getReport({
startMonth: "2026-01",
endMonth: "2026-01",
});
const month = report.resources[0]?.months[0];
expect(month).toBeDefined();
expect(month?.sah).toBe(168);
expect(month?.chg).toBeCloseTo(0.875, 5);
});
it("applies city-specific public holidays to SAH", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_augsburg",
eid: "E-001",
displayName: "Alice",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: "city_1",
chargeabilityTarget: 80,
country: {
id: "country_de",
code: "DE",
dailyWorkingHours: 8,
scheduleRules: null,
},
orgUnit: { id: "org_1", name: "CGI" },
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
managementLevel: { id: "level_1", name: "L7" },
metroCity: { id: "city_1", name: "Augsburg" },
},
{
id: "resource_munich",
eid: "E-002",
displayName: "Bob",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: "city_2",
chargeabilityTarget: 80,
country: {
id: "country_de",
code: "DE",
dailyWorkingHours: 8,
scheduleRules: null,
},
orgUnit: { id: "org_1", name: "CGI" },
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
managementLevel: { id: "level_1", name: "L7" },
metroCity: { id: "city_2", name: "Munich" },
},
]),
},
project: {
findMany: vi.fn().mockResolvedValue([]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
vi.mocked(listAssignmentBookings).mockResolvedValue([]);
const caller = createControllerCaller(db);
const report = await caller.getReport({
startMonth: "2028-08",
endMonth: "2028-08",
});
const augsburg = report.resources.find((resource) => resource.city === "Augsburg");
const munich = report.resources.find((resource) => resource.city === "Munich");
expect(augsburg?.months[0]?.sah).toBe((munich?.months[0]?.sah ?? 0) - 8);
});
it("respects individual weekday availability when computing booked hours", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_pt",
eid: "E-003",
displayName: "Carla",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 0 },
countryId: "country_de",
federalState: null,
metroCityId: "city_3",
chargeabilityTarget: 80,
country: {
id: "country_de",
code: "DE",
dailyWorkingHours: 8,
scheduleRules: null,
},
orgUnit: { id: "org_1", name: "CGI" },
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
managementLevel: { id: "level_1", name: "L7" },
metroCity: { id: "city_3", name: "Berlin" },
},
]),
},
project: {
findMany: vi.fn().mockResolvedValue([
{ id: "project_week", utilizationCategory: { code: "Chg" } },
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "assignment_week",
projectId: "project_week",
resourceId: "resource_pt",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-06T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "CONFIRMED",
project: {
id: "project_week",
name: "Week Project",
shortCode: "WP",
status: "ACTIVE",
orderType: "CLIENT",
dynamicFields: null,
},
resource: { id: "resource_pt", displayName: "Carla", chapter: "CGI" },
},
]);
const caller = createControllerCaller(db);
const report = await caller.getReport({
startMonth: "2026-03",
endMonth: "2026-03",
});
const month = report.resources[0]?.months[0];
expect(month).toBeDefined();
expect(month?.chg).toBeCloseTo(16 / 144, 5);
});
it("returns a filtered detailed report with rounded percentages", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_1",
eid: "E-001",
displayName: "Alice",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_es",
federalState: null,
metroCityId: "city_1",
chargeabilityTarget: 80,
country: {
id: "country_es",
code: "ES",
dailyWorkingHours: 8,
scheduleRules: null,
},
orgUnit: { id: "org_1", name: "CGI" },
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
managementLevel: { id: "level_1", name: "L7" },
metroCity: { id: "city_1", name: "Barcelona" },
},
]),
},
project: {
findMany: vi.fn().mockResolvedValue([
{ id: "project_confirmed", utilizationCategory: { code: "Chg" } },
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "assignment_confirmed",
projectId: "project_confirmed",
resourceId: "resource_1",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-06T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "CONFIRMED",
project: {
id: "project_confirmed",
name: "Confirmed Project",
shortCode: "CP",
status: "ACTIVE",
orderType: "CLIENT",
dynamicFields: null,
},
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
},
]);
const caller = createControllerCaller(db);
const result = await caller.getDetail({
startMonth: "2026-03",
endMonth: "2026-03",
resourceQuery: "ali",
resourceLimit: 10,
});
expect(result.filters).toEqual({
startMonth: "2026-03",
endMonth: "2026-03",
orgUnitId: null,
managementLevelGroupId: null,
countryId: null,
includeProposed: false,
resourceQuery: "ali",
});
expect(result.groupTotals).toEqual([
expect.objectContaining({
monthKey: "2026-03",
totalFte: 1,
chargeabilityPct: expect.any(Number),
targetPct: 80,
}),
]);
expect(result.resourceCount).toBe(1);
expect(result.returnedResourceCount).toBe(1);
expect(result.truncated).toBe(false);
expect(result.resources).toEqual([
expect.objectContaining({
displayName: "Alice",
targetPct: 80,
country: "ES",
city: "Barcelona",
managementLevelGroup: "Senior",
managementLevel: "L7",
months: [
expect.objectContaining({
monthKey: "2026-03",
sah: expect.any(Number),
chargeabilityPct: expect.any(Number),
gapPct: expect.any(Number),
}),
],
}),
]);
});
});