536 lines
17 KiB
TypeScript
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),
|
|
}),
|
|
],
|
|
}),
|
|
]);
|
|
});
|
|
});
|