diff --git a/packages/api/src/__tests__/assistant-tools-holiday-capacity.test.ts b/packages/api/src/__tests__/assistant-tools-holiday-capacity.test.ts new file mode 100644 index 0000000..f2cb152 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-holiday-capacity.test.ts @@ -0,0 +1,82 @@ +import { PermissionKey } from "@capakraken/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry: vi.fn().mockResolvedValue(undefined), +})); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-holiday-capacity-test-helpers.js"; + +describe("assistant holiday-aware capacity tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("finds capacity with local holidays respected", async () => { + const db = { + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_by", + displayName: "Bavaria", + eid: "BY-1", + fte: 1, + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + countryId: "country_de", + federalState: "BY", + metroCityId: null, + country: { code: "DE" }, + metroCity: null, + areaRole: { name: "Consultant" }, + chapter: "CGI", + assignments: [], + }, + { + id: "res_hh", + displayName: "Hamburg", + eid: "HH-1", + fte: 1, + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + countryId: "country_de", + federalState: "HH", + metroCityId: null, + country: { code: "DE" }, + metroCity: null, + areaRole: { name: "Consultant" }, + chapter: "CGI", + assignments: [], + }, + ]), + }, + vacation: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; + const ctx = createToolContext(db, [PermissionKey.VIEW_PLANNING]); + + const result = await executeTool( + "find_capacity", + JSON.stringify({ startDate: "2026-01-06", endDate: "2026-01-06", minHoursPerDay: 1 }), + ctx, + ); + + const parsed = JSON.parse(result.content) as { + results: Array<{ name: string; availableHours: number; availableHoursPerDay: number }>; + }; + + expect(parsed.results).toHaveLength(1); + expect(parsed.results[0]).toEqual( + expect.objectContaining({ name: "Hamburg", availableHours: 8, availableHoursPerDay: 8 }), + ); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-holiday-chargeability.test.ts b/packages/api/src/__tests__/assistant-tools-holiday-chargeability.test.ts new file mode 100644 index 0000000..c584b2d --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-holiday-chargeability.test.ts @@ -0,0 +1,123 @@ +import { PermissionKey } from "@capakraken/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry: vi.fn().mockResolvedValue(undefined), +})); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-holiday-capacity-test-helpers.js"; + +describe("assistant holiday-aware chargeability tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calculates chargeability with regional holidays excluded from booked and available hours", async () => { + const resourceRecord = { + id: "res_1", + displayName: "Bruce Banner", + eid: "bruce.banner", + fte: 1, + lcrCents: 5000, + chargeabilityTarget: 80, + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 }, + countryId: "country_de", + federalState: "BY", + metroCityId: null, + country: { id: "country_de", code: "DE", name: "Deutschland", dailyWorkingHours: 8, scheduleRules: null }, + metroCity: null, + managementLevelGroup: null, + }; + const db = { + resource: { + findUnique: vi.fn().mockResolvedValueOnce(resourceRecord), + findUniqueOrThrow: vi.fn().mockResolvedValue(resourceRecord), + findFirst: vi.fn(), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([ + { + id: "assign_1", + hoursPerDay: 8, + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-06T00:00:00.000Z"), + dailyCostCents: 40000, + status: "CONFIRMED", + project: { + id: "project_gamma", + name: "Gamma", + shortCode: "GAM", + budgetCents: null, + winProbability: 100, + utilizationCategory: { code: "Chg" }, + }, + }, + ]), + }, + vacation: { + findMany: vi.fn().mockResolvedValue([]), + }, + holidayCalendar: { + findMany: vi.fn().mockResolvedValue([]), + }, + calculationRule: { + findMany: vi.fn().mockResolvedValue([]), + }, + }; + const ctx = createToolContext(db, [PermissionKey.VIEW_COSTS]); + + const result = await executeTool( + "get_chargeability", + JSON.stringify({ resourceId: "res_1", month: "2026-01" }), + ctx, + ); + + const parsed = JSON.parse(result.content) as { + baseWorkingDays: number; + baseAvailableHours: number; + availableHours: number; + bookedHours: number; + workingDays: number; + targetHours: number; + unassignedHours: number; + holidaySummary: { count: number; workdayCount: number; hoursDeduction: number }; + capacityBreakdown: { formula: string; holidayHoursDeduction: number; absenceHoursDeduction: number }; + locationContext: { federalState: string | null }; + allocations: Array<{ hours: number }>; + }; + + expect(parsed.bookedHours).toBe(8); + expect(parsed.allocations).toEqual([expect.objectContaining({ hours: 8 })]); + expect(parsed.baseWorkingDays).toBe(22); + expect(parsed.baseAvailableHours).toBe(176); + expect(parsed.availableHours).toBe(160); + expect(parsed.workingDays).toBe(20); + expect(parsed.targetHours).toBe(128); + expect(parsed.unassignedHours).toBe(152); + expect(parsed.locationContext.federalState).toBe("BY"); + expect(parsed.holidaySummary).toEqual( + expect.objectContaining({ + count: 2, + workdayCount: 2, + hoursDeduction: 16, + }), + ); + expect(parsed.capacityBreakdown).toEqual( + expect.objectContaining({ + formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours", + holidayHoursDeduction: 16, + absenceHoursDeduction: 0, + }), + ); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-holiday-simulation.test.ts b/packages/api/src/__tests__/assistant-tools-holiday-simulation.test.ts new file mode 100644 index 0000000..8d3768c --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-holiday-simulation.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry: vi.fn().mockResolvedValue(undefined), +})); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-holiday-capacity-test-helpers.js"; + +describe("assistant holiday-aware scenario simulation tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("keeps scenario simulation flat when a proposed change falls on a local holiday", async () => { + const db = { + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "project_1", + name: "Holiday Project", + budgetCents: 500_000, + startDate: new Date("2026-01-01T00:00:00.000Z"), + endDate: new Date("2026-01-31T00:00:00.000Z"), + }), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([ + { + id: "assignment_1", + resourceId: "res_1", + hoursPerDay: 8, + startDate: new Date("2026-01-05T00:00:00.000Z"), + endDate: new Date("2026-01-05T00:00:00.000Z"), + status: "CONFIRMED", + resource: { + id: "res_1", + displayName: "Bruce Banner", + lcrCents: 100, + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + chargeabilityTarget: 80, + countryId: "country_de", + federalState: "BY", + metroCityId: null, + country: { code: "DE", dailyWorkingHours: 8 }, + metroCity: null, + }, + }, + ]), + }, + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_1", + displayName: "Bruce Banner", + lcrCents: 100, + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + chargeabilityTarget: 80, + countryId: "country_de", + federalState: "BY", + metroCityId: null, + country: { code: "DE", dailyWorkingHours: 8 }, + metroCity: null, + }, + ]), + }, + }; + const ctx = createToolContext(db, ["manageAllocations"]); + + const result = await executeTool( + "simulate_scenario", + JSON.stringify({ + projectId: "project_1", + changes: [ + { + resourceId: "res_1", + startDate: "2026-01-06", + endDate: "2026-01-06", + hoursPerDay: 8, + }, + ], + }), + ctx, + ); + + const parsed = JSON.parse(result.content) as { + baseline: { totalHours: number; totalCostCents: number }; + scenario: { totalHours: number; totalCostCents: number }; + delta: { hours: number; costCents: number }; + }; + + expect(parsed.baseline).toEqual( + expect.objectContaining({ + totalHours: 8, + totalCostCents: 800, + }), + ); + expect(parsed.scenario).toEqual( + expect.objectContaining({ + totalHours: 8, + totalCostCents: 800, + }), + ); + expect(parsed.delta).toEqual( + expect.objectContaining({ + hours: 0, + costCents: 0, + }), + ); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-holiday-staffing-suggestions.test.ts b/packages/api/src/__tests__/assistant-tools-holiday-staffing-suggestions.test.ts new file mode 100644 index 0000000..ccc25ea --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-holiday-staffing-suggestions.test.ts @@ -0,0 +1,111 @@ +import { PermissionKey } from "@capakraken/shared"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry: vi.fn().mockResolvedValue(undefined), +})); + +import { executeTool } from "../router/assistant-tools.js"; +import { createToolContext } from "./assistant-tools-holiday-capacity-test-helpers.js"; + +describe("assistant holiday-aware staffing suggestion tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("prefers resources without a local holiday in staffing suggestions", async () => { + const db = { + project: { + findUnique: vi.fn().mockResolvedValue({ + id: "project_1", + name: "Holiday Project", + shortCode: "HP", + startDate: new Date("2026-01-06T00:00:00.000Z"), + endDate: new Date("2026-01-06T00:00:00.000Z"), + }), + findFirst: vi.fn().mockResolvedValue(null), + }, + assignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + resource: { + findMany: vi.fn().mockResolvedValue([ + { + id: "res_by", + displayName: "Bavaria", + eid: "BY-1", + fte: 1, + lcrCents: 10000, + chargeabilityTarget: 80, + valueScore: 10, + skills: [], + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + countryId: "country_de", + federalState: "BY", + metroCityId: null, + country: { code: "DE", name: "Deutschland" }, + metroCity: null, + areaRole: { name: "Consultant" }, + chapter: "CGI", + }, + { + id: "res_hh", + displayName: "Hamburg", + eid: "HH-1", + fte: 1, + lcrCents: 10000, + chargeabilityTarget: 80, + valueScore: 10, + skills: [], + availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, + countryId: "country_de", + federalState: "HH", + metroCityId: null, + country: { code: "DE", name: "Deutschland" }, + metroCity: null, + areaRole: { name: "Consultant" }, + chapter: "CGI", + }, + ]), + }, + }; + const ctx = createToolContext(db, [ + PermissionKey.VIEW_PLANNING, + PermissionKey.VIEW_COSTS, + ]); + + const result = await executeTool( + "get_staffing_suggestions", + JSON.stringify({ projectId: "project_1", limit: 5 }), + ctx, + ); + + const parsed = JSON.parse(result.content) as { + suggestions: Array<{ name: string; availableHours: number }>; + }; + + expect(parsed.suggestions).toHaveLength(1); + expect(parsed.suggestions[0]).toEqual( + expect.objectContaining({ name: "Hamburg", availableHours: 8 }), + ); + expect(db.project.findUnique).toHaveBeenCalledWith({ + where: { id: "project_1" }, + select: expect.objectContaining({ + id: true, + shortCode: true, + name: true, + startDate: true, + endDate: true, + }), + }); + }); +});