feat(planning): ship holiday-aware planning and assistant upgrades

This commit is contained in:
2026-03-28 22:49:28 +01:00
parent 2a005794e7
commit 4f48afe7b4
151 changed files with 17738 additions and 1940 deletions
@@ -0,0 +1,575 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
return {
...actual,
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
};
});
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
function createToolContext(
db: Record<string, unknown>,
permissions: string[] = [],
): ToolContext {
return {
db: db as ToolContext["db"],
userId: "user_1",
userRole: "ADMIN",
permissions: new Set(permissions) as ToolContext["permissions"],
};
}
describe("assistant holiday tools", () => {
it("lists regional holidays and distinguishes Bavaria from Hamburg", async () => {
const ctx = createToolContext({});
const bavaria = await executeTool(
"list_holidays_by_region",
JSON.stringify({ countryCode: "DE", federalState: "BY", year: 2026 }),
ctx,
);
const hamburg = await executeTool(
"list_holidays_by_region",
JSON.stringify({ countryCode: "DE", federalState: "HH", year: 2026 }),
ctx,
);
const bavariaResult = JSON.parse(bavaria.content) as {
count: number;
locationContext: { federalState: string | null };
summary: { byScope: Array<{ scope: string; count: number }> };
holidays: Array<{ name: string; date: string }>;
};
const hamburgResult = JSON.parse(hamburg.content) as {
count: number;
locationContext: { federalState: string | null };
holidays: Array<{ name: string; date: string }>;
};
expect(bavariaResult.count).toBeGreaterThan(hamburgResult.count);
expect(bavariaResult.locationContext.federalState).toBe("BY");
expect(bavariaResult.summary.byScope).toEqual(
expect.arrayContaining([expect.objectContaining({ scope: "STATE" })]),
);
expect(bavariaResult.holidays).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
]),
);
expect(hamburgResult.holidays).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
]),
);
});
it("resolves resource-specific holidays including city-local dates", async () => {
const db = {
resource: {
findUnique: vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: "res_1", eid: "bruce.banner", displayName: "Bruce Banner", federalState: "BY", countryId: "country_de", metroCityId: "city_augsburg", country: { code: "DE", name: "Deutschland" }, metroCity: { name: "Augsburg" } }),
findFirst: vi.fn(),
},
};
const ctx = createToolContext(db);
const result = await executeTool(
"get_resource_holidays",
JSON.stringify({ identifier: "bruce.banner", year: 2026 }),
ctx,
);
const parsed = JSON.parse(result.content) as {
resource: { eid: string; federalState: string | null; metroCity: string | null };
summary: { byScope: Array<{ scope: string; count: number }> };
holidays: Array<{ name: string; date: string }>;
};
expect(parsed.resource).toEqual(
expect.objectContaining({
eid: "bruce.banner",
federalState: "BY",
metroCity: "Augsburg",
}),
);
expect(parsed.holidays).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "Augsburger Friedensfest", date: "2026-08-08" }),
]),
);
expect(parsed.summary.byScope).toEqual(
expect.arrayContaining([expect.objectContaining({ scope: "CITY" })]),
);
});
it("calculates chargeability with regional holidays excluded from booked and available hours", async () => {
const db = {
resource: {
findUnique: vi
.fn()
.mockResolvedValueOnce({
id: "res_1",
displayName: "Bruce Banner",
eid: "bruce.banner",
fte: 1,
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE", dailyWorkingHours: 8 },
metroCity: null,
}),
findFirst: vi.fn(),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
hoursPerDay: 8,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
status: "CONFIRMED",
project: { name: "Gamma", shortCode: "GAM" },
},
]),
},
};
const ctx = createToolContext(db);
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(23);
expect(parsed.baseAvailableHours).toBe(184);
expect(parsed.availableHours).toBe(168);
expect(parsed.workingDays).toBe(21);
expect(parsed.targetHours).toBe(134.4);
expect(parsed.unassignedHours).toBe(160);
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,
}),
);
});
it("returns holiday-aware budget forecast data from the dashboard use-case", async () => {
const { getDashboardBudgetForecast } = await import("@capakraken/application");
vi.mocked(getDashboardBudgetForecast).mockResolvedValue([
{
projectId: "project_1",
projectName: "Gelddruckmaschine",
shortCode: "GDM",
budgetCents: 100_000,
spentCents: 60_000,
burnRate: 5_000,
pctUsed: 60,
estimatedExhaustionDate: "2026-02-20",
},
]);
const ctx = createToolContext({}, ["viewCosts"]);
const result = await executeTool("get_budget_forecast", "{}", ctx);
const parsed = JSON.parse(result.content) as {
forecasts: Array<{
projectName: string;
shortCode: string;
budgetCents: number;
spentCents: number;
remainingCents: number;
projectedCents: number;
burnRateCents: number;
burnStatus: string;
}>;
};
expect(getDashboardBudgetForecast).toHaveBeenCalled();
expect(parsed.forecasts).toEqual([
expect.objectContaining({
projectName: "Gelddruckmaschine",
shortCode: "GDM",
budgetCents: 100_000,
spentCents: 60_000,
remainingCents: 40_000,
projectedCents: 100_000,
burnRateCents: 5_000,
burnStatus: "on_track",
}),
]);
});
it("checks resource availability with regional holidays excluded from capacity", async () => {
const db = {
resource: {
findUnique: vi
.fn()
.mockResolvedValueOnce({
id: "res_1",
displayName: "Bruce Banner",
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,
}),
findFirst: vi.fn(),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
hoursPerDay: 8,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
status: "CONFIRMED",
project: { name: "Gamma", shortCode: "GAM" },
},
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const ctx = createToolContext(db);
const result = await executeTool(
"check_resource_availability",
JSON.stringify({ resourceId: "res_1", startDate: "2026-01-05", endDate: "2026-01-06" }),
ctx,
);
const parsed = JSON.parse(result.content) as {
workingDays: number;
periodAvailableHours: number;
periodBookedHours: number;
periodRemainingHours: number;
availableHoursPerDay: number;
isFullyAvailable: boolean;
};
expect(parsed.workingDays).toBe(1);
expect(parsed.periodAvailableHours).toBe(8);
expect(parsed.periodBookedHours).toBe(8);
expect(parsed.periodRemainingHours).toBe(0);
expect(parsed.availableHoursPerDay).toBe(0);
expect(parsed.isFullyAvailable).toBe(false);
});
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,
}),
);
});
it("prefers resources without a local holiday in staffing suggestions", async () => {
const db = {
project: {
findFirst: 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"),
}),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_by",
displayName: "Bavaria",
eid: "BY-1",
fte: 1,
lcrCents: 10000,
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,
lcrCents: 10000,
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);
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 }),
);
});
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);
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 }),
);
});
it("uses holiday-aware assignment hours for assistant shoring ratio", async () => {
const db = {
project: {
findUnique: vi
.fn()
.mockResolvedValueOnce({
id: "project_1",
name: "Holiday Project",
shortCode: "HP",
shoringThreshold: 55,
onshoreCountryCode: "DE",
}),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
resourceId: "res_by",
hoursPerDay: 8,
startDate: new Date("2026-01-06T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
resource: {
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
},
},
{
resourceId: "res_in",
hoursPerDay: 8,
startDate: new Date("2026-01-06T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
resource: {
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_in",
federalState: null,
metroCityId: null,
country: { code: "IN" },
metroCity: null,
},
},
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const ctx = createToolContext(db);
const result = await executeTool(
"get_shoring_ratio",
JSON.stringify({ projectId: "project_1" }),
ctx,
);
expect(result.content).toContain("0% onshore (DE), 100% offshore");
expect(result.content).toContain("IN 100% (1 people)");
});
});