diff --git a/packages/api/src/__tests__/assistant-tools-chargeability-report.test.ts b/packages/api/src/__tests__/assistant-tools-chargeability-report.test.ts new file mode 100644 index 0000000..cd206c4 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-chargeability-report.test.ts @@ -0,0 +1,204 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PermissionKey } from "@capakraken/shared"; +import { listAssignmentBookings } from "@capakraken/application"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + approveEstimateVersion: vi.fn(), + cloneEstimate: vi.fn(), + commitDispoImportBatch: vi.fn(), + countPlanningEntries: vi.fn().mockResolvedValue({ countsByRoleId: new Map() }), + createEstimateExport: vi.fn(), + createEstimatePlanningHandoff: vi.fn(), + createEstimateRevision: vi.fn(), + assessDispoImportReadiness: vi.fn(), + loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map()), + getDashboardDemand: vi.fn().mockResolvedValue([]), + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardOverview: vi.fn(), + getDashboardSkillGapSummary: vi.fn().mockResolvedValue({ + roleGaps: [], + totalOpenPositions: 0, + skillSupplyTop10: [], + resourcesByRole: [], + }), + getDashboardProjectHealth: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + getDashboardTopValueResources: vi.fn().mockResolvedValue([]), + getEstimateById: vi.fn(), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + stageDispoImportBatch: vi.fn(), + submitEstimateVersion: vi.fn(), + updateEstimateDraft: vi.fn(), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-report-test-helpers.js"; + +describe("assistant chargeability report tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the chargeability report readmodel through the assistant", async () => { + 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 ctx = createToolContext( + { + 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([]), + }, + holidayCalendar: { + findMany: vi.fn().mockResolvedValue([]), + }, + }, + { + permissions: [PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS], + }, + ); + + const result = await executeTool( + "get_chargeability_report", + JSON.stringify({ + startMonth: "2026-03", + endMonth: "2026-03", + resourceLimit: 10, + }), + ctx, + ); + + const parsed = JSON.parse(result.content) as { + monthKeys: string[]; + groupTotals: Array<{ monthKey: string; chargeabilityPct: number; targetPct: number }>; + resourceCount: number; + returnedResourceCount: number; + truncated: boolean; + explainability: { + locationFields: string[]; + monthDerivationFields: string[]; + activeFilters: string[]; + formulas: { + sah: string; + chargeabilityPct: string; + targetHours: string; + gapHours: string; + }; + notes: string[]; + }; + resources: Array<{ + displayName: string; + targetPct: number; + months: Array<{ monthKey: string; sah: number; chargeabilityPct: number }>; + }>; + }; + + expect(parsed.monthKeys).toEqual(["2026-03"]); + expect(parsed.groupTotals).toEqual([ + expect.objectContaining({ + monthKey: "2026-03", + chargeabilityPct: expect.any(Number), + targetPct: 80, + }), + ]); + expect(parsed.resourceCount).toBe(1); + expect(parsed.returnedResourceCount).toBe(1); + expect(parsed.truncated).toBe(false); + expect(parsed.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(parsed.resources).toEqual([ + expect.objectContaining({ + displayName: "Alice", + targetPct: 80, + months: [ + expect.objectContaining({ + monthKey: "2026-03", + sah: expect.any(Number), + chargeabilityPct: expect.any(Number), + }), + ], + }), + ]); + }); +});