diff --git a/packages/api/src/__tests__/assistant-tools-report-read.test.ts b/packages/api/src/__tests__/assistant-tools-report-read.test.ts new file mode 100644 index 0000000..55a20dc --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-report-read.test.ts @@ -0,0 +1,293 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; +import { listAssignmentBookings } from "@capakraken/application"; +import { loadResourceDailyAvailabilityContexts } from "../lib/resource-capacity.js"; + +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(), + 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(), + }; +}); + +vi.mock("../lib/resource-capacity.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + calculateEffectiveAvailableHours: vi.fn(({ context }: { context?: unknown }) => (context ? 160 : 176)), + calculateEffectiveBookedHours: vi.fn(() => 0), + countEffectiveWorkingDays: vi.fn(({ context }: { context?: unknown }) => (context ? 20 : 22)), + getAvailabilityHoursForDate: vi.fn(() => 8), + loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map()), + }; +}); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-report-test-helpers.js"; + +describe("assistant report read tools", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes report reads through the report router path", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_1", + displayName: "Bruce Banner", + chapter: "Delivery", + country: { name: "Germany" }, + }, + ]), + count: vi.fn().mockResolvedValue(1), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const result = await executeTool( + "run_report", + JSON.stringify({ + entity: "resource", + columns: ["displayName", "chapter", "country.name"], + filters: [{ field: "displayName", op: "contains", value: "Bruce" }], + limit: 25, + }), + ctx, + ); + + expect(db.resource.findMany).toHaveBeenCalledWith({ + select: { + id: true, + displayName: true, + chapter: true, + country: { select: { name: true } }, + }, + where: { + displayName: { contains: "Bruce", mode: "insensitive" }, + }, + take: 25, + skip: 0, + }); + expect(db.resource.count).toHaveBeenCalledWith({ + where: { + displayName: { contains: "Bruce", mode: "insensitive" }, + }, + }); + expect(JSON.parse(result.content)).toEqual({ + rows: [ + { + id: "res_1", + displayName: "Bruce Banner", + chapter: "Delivery", + "country.name": "Germany", + }, + ], + rowCount: 1, + totalCount: 1, + columns: ["id", "displayName", "chapter", "country.name"], + groups: [], + }); + }); + + it("passes report grouping and sorting options through the assistant tool", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_1", + displayName: "Bruce Banner", + chapter: "Delivery", + }, + ]), + count: vi.fn().mockResolvedValue(1), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + await executeTool( + "run_report", + JSON.stringify({ + entity: "resource", + columns: ["displayName", "chapter"], + groupBy: "chapter", + sortBy: "displayName", + sortDir: "desc", + limit: 25, + }), + ctx, + ); + + expect(db.resource.findMany).toHaveBeenCalledWith({ + select: { + id: true, + displayName: true, + chapter: true, + }, + where: {}, + orderBy: [{ chapter: "asc" }, { displayName: "desc" }], + take: 25, + skip: 0, + }); + }); + + it("adds explainability metadata for resource_month reports", async () => { + vi.mocked(listAssignmentBookings).mockResolvedValue([]); + vi.mocked(loadResourceDailyAvailabilityContexts).mockResolvedValue(new Map([ + [ + "res_1", + { + holidayDates: new Set(["2026-01-06"]), + absenceFractionsByDate: new Map([["2026-01-12", 1]]), + vacationFractionsByDate: new Map([["2026-01-12", 1]]), + }, + ], + ])); + + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_1", + eid: "E-1", + displayName: "Peter Parker", + email: "peter@example.com", + chapter: "Delivery", + resourceType: "EMPLOYEE", + isActive: true, + chgResponsibility: false, + rolledOff: false, + departed: false, + lcrCents: 10000, + ucrCents: 15000, + currency: "EUR", + fte: 1, + availability: { + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + saturday: 0, + sunday: 0, + }, + chargeabilityTarget: 80, + federalState: "Bayern", + countryId: "country_de", + metroCityId: "metro_muc", + country: { code: "DE", name: "Germany" }, + metroCity: { name: "Munich" }, + orgUnit: { name: "Consulting" }, + managementLevelGroup: { name: "Senior", targetPercentage: 0.8 }, + managementLevel: { name: "Consultant" }, + }, + ]), + }, + }; + const ctx = createToolContext(db, { userRole: SystemRole.CONTROLLER }); + + const result = await executeTool( + "run_report", + JSON.stringify({ + entity: "resource_month", + periodMonth: "2026-01", + columns: [ + "displayName", + "countryCode", + "federalState", + "metroCityName", + "monthlyBaseAvailableHours", + "monthlyPublicHolidayCount", + "monthlyPublicHolidayHoursDeduction", + "monthlyAbsenceDayEquivalent", + "monthlyAbsenceHoursDeduction", + "monthlySahHours", + ], + limit: 25, + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + rows: [ + { + id: "res_1:2026-01", + displayName: "Peter Parker", + countryCode: "DE", + federalState: "Bayern", + metroCityName: "Munich", + monthlyBaseAvailableHours: 176, + monthlyPublicHolidayCount: 1, + monthlyPublicHolidayHoursDeduction: 8, + monthlyAbsenceDayEquivalent: 1, + monthlyAbsenceHoursDeduction: 8, + monthlySahHours: 160, + }, + ], + rowCount: 1, + totalCount: 1, + columns: [ + "id", + "displayName", + "countryCode", + "federalState", + "metroCityName", + "monthlyBaseAvailableHours", + "monthlyPublicHolidayCount", + "monthlyPublicHolidayHoursDeduction", + "monthlyAbsenceDayEquivalent", + "monthlyAbsenceHoursDeduction", + "monthlySahHours", + ], + groups: [], + explainability: { + entity: "resource_month", + periodMonth: "2026-01", + locationContextColumns: ["countryCode", "federalState", "metroCityName"], + holidayMetricColumns: ["monthlyPublicHolidayCount", "monthlyPublicHolidayHoursDeduction"], + absenceMetricColumns: ["monthlyAbsenceDayEquivalent", "monthlyAbsenceHoursDeduction"], + capacityMetricColumns: ["monthlyBaseAvailableHours", "monthlySahHours"], + chargeabilityMetricColumns: [], + missingRecommendedColumns: [ + "countryName", + "monthlyPublicHolidayWorkdayCount", + "monthlyBaseWorkingDays", + "monthlyEffectiveWorkingDays", + "monthlyChargeabilityTargetPct", + "monthlyTargetHours", + ], + notes: [ + "monthlySahHours already reflects region-specific public holidays from country, federal state, and city context when those attributes exist on the resource.", + "monthlyAbsence* metrics only deduct workdays that are not already counted as public holidays.", + "monthlyBaseAvailableHours shows pre-deduction capacity; compare it with holiday, absence, and SAH columns to explain the final monthly availability.", + ], + }, + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-report-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-report-test-helpers.ts new file mode 100644 index 0000000..dceafe0 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-report-test-helpers.ts @@ -0,0 +1,29 @@ +import { PermissionKey, SystemRole } from "@capakraken/shared"; + +import type { ToolContext } from "../router/assistant-tools.js"; + +export function createToolContext( + db: Record, + options?: { + permissions?: PermissionKey[]; + userRole?: SystemRole; + }, +): ToolContext { + const userRole = options?.userRole ?? SystemRole.ADMIN; + return { + db: db as ToolContext["db"], + userId: "user_1", + userRole, + permissions: new Set(options?.permissions ?? []), + session: { + user: { email: "assistant@example.com", name: "Assistant User", image: null }, + expires: "2026-03-29T00:00:00.000Z", + }, + dbUser: { + id: "user_1", + systemRole: userRole, + permissionOverrides: null, + }, + roleDefaults: null, + }; +}