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

611 lines
20 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(strict.explainability).toEqual({
locationFields: [
"country",
"federalState",
"city",
"orgUnit",
"managementLevelGroup",
"managementLevel",
],
monthDerivationFields: [
"baseAvailableHours",
"publicHolidayCount",
"publicHolidayWorkdayCount",
"publicHolidayHoursDeduction",
"absenceDayEquivalent",
"absenceHoursDeduction",
"effectiveAvailableHours",
],
activeFilters: [],
formulas: {
sah: "baseAvailableHours - publicHolidayHoursDeduction - absenceHoursDeduction = effectiveAvailableHours",
chargeabilityPct: "chargeabilityHours / sahHours",
targetHours: "sahHours * targetPct",
gapHours: "chargeabilityHours - targetHours",
},
notes: [
"Location fields explain why two resources can have different SAH in the same month because country, federal state, and city holidays may differ.",
"Holiday deductions and absence deductions are tracked separately; absence does not deduct days that are already public holidays.",
"Include proposed work changes chargeability ratios and hours, but it does not change holiday or absence-based SAH derivation.",
],
});
expect(withProposed.explainability.activeFilters).toEqual(["includeProposed"]);
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?.federalState).toBe("BY");
expect(augsburg?.months[0]?.derivation?.publicHolidayCount).toBe(
(munich?.months[0]?.derivation?.publicHolidayCount ?? 0) + 1,
);
expect(augsburg?.months[0]?.derivation?.publicHolidayHoursDeduction).toBe(
(munich?.months[0]?.derivation?.publicHolidayHoursDeduction ?? 0) + 8,
);
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.explainability).toEqual({
locationFields: [
"country",
"federalState",
"city",
"orgUnit",
"managementLevelGroup",
"managementLevel",
],
monthDerivationFields: [
"baseAvailableHours",
"publicHolidayCount",
"publicHolidayWorkdayCount",
"publicHolidayHoursDeduction",
"absenceDayEquivalent",
"absenceHoursDeduction",
"effectiveAvailableHours",
],
activeFilters: ["resourceQuery"],
formulas: {
sah: "baseAvailableHours - publicHolidayHoursDeduction - absenceHoursDeduction = effectiveAvailableHours",
chargeabilityPct: "chargeabilityHours / sahHours",
targetHours: "sahHours * targetPct",
gapHours: "chargeabilityHours - targetHours",
},
notes: [
"Location fields explain why two resources can have different SAH in the same month because country, federal state, and city holidays may differ.",
"Holiday deductions and absence deductions are tracked separately; absence does not deduct days that are already public holidays.",
"Include proposed work changes chargeability ratios and hours, but it does not change holiday or absence-based SAH derivation.",
],
});
expect(result.resources).toEqual([
expect.objectContaining({
displayName: "Alice",
targetPct: 80,
country: "ES",
federalState: null,
city: "Barcelona",
managementLevelGroup: "Senior",
managementLevel: "L7",
months: [
expect.objectContaining({
monthKey: "2026-03",
sah: expect.any(Number),
chargeabilityPct: expect.any(Number),
gapPct: expect.any(Number),
derivation: expect.objectContaining({
baseAvailableHours: expect.any(Number),
effectiveAvailableHours: expect.any(Number),
}),
}),
],
}),
]);
});
});