chore(repo): checkpoint current capakraken implementation state
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PermissionKey, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
|
||||
import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
|
||||
import {
|
||||
ASSISTANT_CONFIRMATION_PREFIX,
|
||||
canExecuteMutationTool,
|
||||
@@ -12,8 +12,11 @@ import {
|
||||
} from "../router/assistant.js";
|
||||
import { TOOL_DEFINITIONS } from "../router/assistant-tools.js";
|
||||
|
||||
function getToolNames(permissions: PermissionKeyValue[]) {
|
||||
return getAvailableAssistantTools(new Set(permissions)).map((tool) => tool.function.name);
|
||||
function getToolNames(
|
||||
permissions: PermissionKeyValue[],
|
||||
userRole: SystemRole = SystemRole.ADMIN,
|
||||
) {
|
||||
return getAvailableAssistantTools(new Set(permissions), userRole).map((tool) => tool.function.name);
|
||||
}
|
||||
|
||||
const TEST_USER_ID = "assistant-test-user";
|
||||
@@ -187,6 +190,9 @@ describe("assistant router tool gating", () => {
|
||||
|
||||
expect(withoutAdvanced).not.toContain("find_best_project_resource");
|
||||
expect(withAdvanced).toContain("find_best_project_resource");
|
||||
expect(withAdvanced).toContain("get_chargeability_report");
|
||||
expect(withAdvanced).toContain("get_resource_computation_graph");
|
||||
expect(withAdvanced).toContain("get_project_computation_graph");
|
||||
});
|
||||
|
||||
it("keeps user administration tools behind manageUsers", () => {
|
||||
@@ -201,6 +207,93 @@ describe("assistant router tool gating", () => {
|
||||
const names = getToolNames([PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS]);
|
||||
|
||||
expect(names).not.toContain("find_best_project_resource");
|
||||
expect(names).not.toContain("get_chargeability_report");
|
||||
expect(names).not.toContain("get_resource_computation_graph");
|
||||
expect(names).not.toContain("get_project_computation_graph");
|
||||
});
|
||||
|
||||
it("keeps controller-grade readmodels hidden from plain users while allowing controller roles", () => {
|
||||
const controllerNames = getToolNames([
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.CONTROLLER);
|
||||
const userNames = getToolNames([
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.USER);
|
||||
|
||||
expect(controllerNames).toContain("get_chargeability_report");
|
||||
expect(controllerNames).toContain("get_resource_computation_graph");
|
||||
expect(controllerNames).toContain("get_project_computation_graph");
|
||||
expect(userNames).not.toContain("get_chargeability_report");
|
||||
expect(userNames).not.toContain("get_resource_computation_graph");
|
||||
expect(userNames).not.toContain("get_project_computation_graph");
|
||||
});
|
||||
|
||||
it("keeps timeline write parity tools behind manager/admin role, manageAllocations, and advanced assistant access", () => {
|
||||
const managerNames = getToolNames([
|
||||
PermissionKey.MANAGE_ALLOCATIONS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.MANAGER);
|
||||
const userNames = getToolNames([
|
||||
PermissionKey.MANAGE_ALLOCATIONS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.USER);
|
||||
const missingAdvancedNames = getToolNames([
|
||||
PermissionKey.MANAGE_ALLOCATIONS,
|
||||
], SystemRole.MANAGER);
|
||||
|
||||
expect(managerNames).toContain("update_timeline_allocation_inline");
|
||||
expect(managerNames).toContain("apply_timeline_project_shift");
|
||||
expect(managerNames).toContain("quick_assign_timeline_resource");
|
||||
expect(managerNames).toContain("batch_quick_assign_timeline_resources");
|
||||
expect(managerNames).toContain("batch_shift_timeline_allocations");
|
||||
expect(userNames).not.toContain("update_timeline_allocation_inline");
|
||||
expect(userNames).not.toContain("apply_timeline_project_shift");
|
||||
expect(userNames).not.toContain("quick_assign_timeline_resource");
|
||||
expect(userNames).not.toContain("batch_quick_assign_timeline_resources");
|
||||
expect(userNames).not.toContain("batch_shift_timeline_allocations");
|
||||
expect(missingAdvancedNames).not.toContain("update_timeline_allocation_inline");
|
||||
expect(missingAdvancedNames).not.toContain("quick_assign_timeline_resource");
|
||||
});
|
||||
|
||||
it("keeps holiday calendar mutation tools admin-only while leaving read tools available", () => {
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||
|
||||
expect(adminNames).toContain("list_holiday_calendars");
|
||||
expect(adminNames).toContain("get_holiday_calendar");
|
||||
expect(adminNames).toContain("preview_resolved_holiday_calendar");
|
||||
expect(adminNames).toContain("create_holiday_calendar");
|
||||
expect(managerNames).toContain("list_holiday_calendars");
|
||||
expect(managerNames).toContain("get_holiday_calendar");
|
||||
expect(managerNames).toContain("preview_resolved_holiday_calendar");
|
||||
expect(managerNames).not.toContain("create_holiday_calendar");
|
||||
expect(managerNames).not.toContain("update_holiday_calendar");
|
||||
expect(managerNames).not.toContain("delete_holiday_calendar");
|
||||
expect(managerNames).not.toContain("create_holiday_calendar_entry");
|
||||
expect(managerNames).not.toContain("update_holiday_calendar_entry");
|
||||
expect(managerNames).not.toContain("delete_holiday_calendar_entry");
|
||||
});
|
||||
|
||||
it("keeps country and metro-city mutation tools admin-only while leaving read tools available", () => {
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||
|
||||
expect(adminNames).toContain("list_countries");
|
||||
expect(adminNames).toContain("get_country");
|
||||
expect(adminNames).toContain("create_country");
|
||||
expect(adminNames).toContain("update_country");
|
||||
expect(adminNames).toContain("create_metro_city");
|
||||
expect(adminNames).toContain("update_metro_city");
|
||||
expect(adminNames).toContain("delete_metro_city");
|
||||
expect(managerNames).toContain("list_countries");
|
||||
expect(managerNames).toContain("get_country");
|
||||
expect(managerNames).not.toContain("create_country");
|
||||
expect(managerNames).not.toContain("update_country");
|
||||
expect(managerNames).not.toContain("create_metro_city");
|
||||
expect(managerNames).not.toContain("update_metro_city");
|
||||
expect(managerNames).not.toContain("delete_metro_city");
|
||||
});
|
||||
|
||||
it("blocks mutation tools until the user confirms a prior assistant summary", () => {
|
||||
@@ -397,5 +490,16 @@ describe("assistant router tool gating", () => {
|
||||
expect(toolDescriptions.get("list_users")).toContain("manageUsers");
|
||||
expect(toolDescriptions.get("create_task_for_user")).toContain("manageProjects");
|
||||
expect(toolDescriptions.get("send_broadcast")).toContain("manageProjects");
|
||||
expect(toolDescriptions.get("create_holiday_calendar")).toContain("Admin role");
|
||||
expect(toolDescriptions.get("create_holiday_calendar_entry")).toContain("Admin role");
|
||||
expect(toolDescriptions.get("get_chargeability_report")).toContain("controller/manager/admin");
|
||||
expect(toolDescriptions.get("get_chargeability_report")).toContain("viewCosts");
|
||||
expect(toolDescriptions.get("get_resource_computation_graph")).toContain("useAssistantAdvancedTools");
|
||||
expect(toolDescriptions.get("get_project_computation_graph")).toContain("controller/manager/admin");
|
||||
expect(toolDescriptions.get("update_timeline_allocation_inline")).toContain("manager/admin");
|
||||
expect(toolDescriptions.get("apply_timeline_project_shift")).toContain("manageAllocations");
|
||||
expect(toolDescriptions.get("quick_assign_timeline_resource")).toContain("useAssistantAdvancedTools");
|
||||
expect(toolDescriptions.get("batch_quick_assign_timeline_resources")).toContain("manageAllocations");
|
||||
expect(toolDescriptions.get("batch_shift_timeline_allocations")).toContain("manager/admin");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PermissionKey } from "@capakraken/shared";
|
||||
import { AllocationStatus, PermissionKey, SystemRole } from "@capakraken/shared";
|
||||
|
||||
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
||||
@@ -11,17 +11,43 @@ vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../sse/event-bus.js", () => ({
|
||||
emitAllocationCreated: vi.fn(),
|
||||
emitAllocationDeleted: vi.fn(),
|
||||
emitAllocationUpdated: vi.fn(),
|
||||
emitProjectShifted: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/budget-alerts.js", () => ({
|
||||
checkBudgetThresholds: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/cache.js", () => ({
|
||||
invalidateDashboardCache: vi.fn(),
|
||||
}));
|
||||
|
||||
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
|
||||
|
||||
function createToolContext(
|
||||
db: Record<string, unknown>,
|
||||
permissions: PermissionKey[] = [],
|
||||
userRole: SystemRole = SystemRole.ADMIN,
|
||||
): ToolContext {
|
||||
return {
|
||||
db: db as ToolContext["db"],
|
||||
userId: "user_1",
|
||||
userRole: "ADMIN",
|
||||
userRole,
|
||||
permissions: new Set(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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -542,6 +568,686 @@ describe("assistant advanced tools and scoping", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("updates timeline allocations inline through the real timeline router mutation", async () => {
|
||||
const existingAssignment = {
|
||||
id: "assignment_1",
|
||||
demandRequirementId: null,
|
||||
resourceId: "resource_1",
|
||||
projectId: "project_1",
|
||||
startDate: new Date("2026-03-16"),
|
||||
endDate: new Date("2026-03-20"),
|
||||
hoursPerDay: 4,
|
||||
percentage: 50,
|
||||
role: "Compositor",
|
||||
roleId: "role_comp",
|
||||
dailyCostCents: 20000,
|
||||
status: AllocationStatus.PROPOSED,
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-03-13"),
|
||||
updatedAt: new Date("2026-03-13"),
|
||||
resource: {
|
||||
id: "resource_1",
|
||||
displayName: "Alice",
|
||||
eid: "E-001",
|
||||
lcrCents: 5000,
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
},
|
||||
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
|
||||
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
|
||||
demandRequirement: null,
|
||||
};
|
||||
const updatedAssignment = {
|
||||
...existingAssignment,
|
||||
hoursPerDay: 6,
|
||||
endDate: new Date("2026-03-21"),
|
||||
percentage: 75,
|
||||
dailyCostCents: 30000,
|
||||
metadata: { includeSaturday: true },
|
||||
updatedAt: new Date("2026-03-14"),
|
||||
};
|
||||
|
||||
const db = {
|
||||
allocation: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
demandRequirement: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
assignment: {
|
||||
findUnique: vi.fn().mockResolvedValue(existingAssignment),
|
||||
update: vi.fn().mockResolvedValue(updatedAssignment),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "resource_1",
|
||||
eid: "E-001",
|
||||
displayName: "Alice",
|
||||
lcrCents: 5000,
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
}),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
||||
};
|
||||
const ctx = createToolContext(
|
||||
db,
|
||||
[PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
|
||||
SystemRole.MANAGER,
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"update_timeline_allocation_inline",
|
||||
JSON.stringify({
|
||||
allocationId: "assignment_1",
|
||||
hoursPerDay: 6,
|
||||
endDate: "2026-03-21",
|
||||
includeSaturday: true,
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.action).toEqual({ type: "invalidate", scope: ["allocation", "timeline", "project"] });
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
allocation: expect.objectContaining({
|
||||
id: "assignment_1",
|
||||
hoursPerDay: 6,
|
||||
endDate: "2026-03-21",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(db.assignment.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "assignment_1" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("quick-assigns a timeline resource through the real timeline router mutation", async () => {
|
||||
const createdAssignment = {
|
||||
id: "assignment_quick_1",
|
||||
demandRequirementId: null,
|
||||
resourceId: "resource_1",
|
||||
projectId: "project_1",
|
||||
startDate: new Date("2026-03-16"),
|
||||
endDate: new Date("2026-03-20"),
|
||||
hoursPerDay: 8,
|
||||
percentage: 100,
|
||||
role: "Team Member",
|
||||
roleId: null,
|
||||
dailyCostCents: 40000,
|
||||
status: AllocationStatus.PROPOSED,
|
||||
metadata: { source: "quickAssign" },
|
||||
createdAt: new Date("2026-03-13"),
|
||||
updatedAt: new Date("2026-03-13"),
|
||||
resource: {
|
||||
id: "resource_1",
|
||||
displayName: "Alice",
|
||||
eid: "E-001",
|
||||
lcrCents: 5000,
|
||||
},
|
||||
project: { id: "project_1", name: "Gelddruckmaschine", shortCode: "GDM" },
|
||||
roleEntity: null,
|
||||
demandRequirement: null,
|
||||
};
|
||||
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "project_1",
|
||||
name: "Gelddruckmaschine",
|
||||
shortCode: "GDM",
|
||||
status: "ACTIVE",
|
||||
responsiblePerson: null,
|
||||
}),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "resource_1",
|
||||
eid: "E-001",
|
||||
displayName: "Alice",
|
||||
lcrCents: 5000,
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
}),
|
||||
},
|
||||
allocation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn(),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn().mockResolvedValue(createdAssignment),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
||||
};
|
||||
const ctx = createToolContext(
|
||||
db,
|
||||
[PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
|
||||
SystemRole.MANAGER,
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"quick_assign_timeline_resource",
|
||||
JSON.stringify({
|
||||
resourceIdentifier: "resource_1",
|
||||
projectIdentifier: "project_1",
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-03-20",
|
||||
hoursPerDay: 8,
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.action).toEqual({ type: "invalidate", scope: ["allocation", "timeline", "project"] });
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
allocation: expect.objectContaining({
|
||||
id: "assignment_quick_1",
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_1",
|
||||
hoursPerDay: 8,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(db.assignment.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("batch quick-assigns timeline resources through the real timeline router mutation", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "project_1",
|
||||
name: "Gelddruckmaschine",
|
||||
shortCode: "GDM",
|
||||
status: "ACTIVE",
|
||||
responsiblePerson: null,
|
||||
}),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockImplementation(async ({ where }: { where: { id: string } }) => ({
|
||||
id: where.id,
|
||||
eid: `E-${where.id}`,
|
||||
displayName: `Resource ${where.id}`,
|
||||
lcrCents: 5000,
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
})),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
create: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: "assignment_batch_1" })
|
||||
.mockResolvedValueOnce({ id: "assignment_batch_2" }),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
||||
};
|
||||
const ctx = createToolContext(
|
||||
db,
|
||||
[PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
|
||||
SystemRole.MANAGER,
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"batch_quick_assign_timeline_resources",
|
||||
JSON.stringify({
|
||||
assignments: [
|
||||
{
|
||||
resourceIdentifier: "resource_1",
|
||||
projectIdentifier: "project_1",
|
||||
startDate: "2026-03-16",
|
||||
endDate: "2026-03-20",
|
||||
hoursPerDay: 8,
|
||||
},
|
||||
{
|
||||
resourceIdentifier: "resource_2",
|
||||
projectIdentifier: "project_1",
|
||||
startDate: "2026-03-23",
|
||||
endDate: "2026-03-27",
|
||||
hoursPerDay: 6,
|
||||
},
|
||||
],
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.action).toEqual({ type: "invalidate", scope: ["allocation", "timeline", "project"] });
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
count: 2,
|
||||
}),
|
||||
);
|
||||
expect(db.assignment.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("applies timeline project shifts through the real timeline router mutation", async () => {
|
||||
const { listAssignmentBookings } = await import("@capakraken/application");
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValueOnce([]);
|
||||
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "project_1",
|
||||
name: "Gelddruckmaschine",
|
||||
shortCode: "GDM",
|
||||
status: "ACTIVE",
|
||||
responsiblePerson: null,
|
||||
budgetCents: 100000,
|
||||
winProbability: 100,
|
||||
startDate: new Date("2026-03-16"),
|
||||
endDate: new Date("2026-03-20"),
|
||||
}),
|
||||
update: vi.fn().mockResolvedValue({
|
||||
id: "project_1",
|
||||
startDate: new Date("2026-03-23"),
|
||||
endDate: new Date("2026-03-27"),
|
||||
}),
|
||||
},
|
||||
demandRequirement: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
||||
};
|
||||
const ctx = createToolContext(
|
||||
db,
|
||||
[PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
|
||||
SystemRole.MANAGER,
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"apply_timeline_project_shift",
|
||||
JSON.stringify({
|
||||
projectIdentifier: "project_1",
|
||||
newStartDate: "2026-03-23",
|
||||
newEndDate: "2026-03-27",
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.action).toEqual({ type: "invalidate", scope: ["allocation", "timeline", "project"] });
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
project: expect.objectContaining({
|
||||
id: "project_1",
|
||||
startDate: "2026-03-23",
|
||||
endDate: "2026-03-27",
|
||||
}),
|
||||
validation: expect.objectContaining({
|
||||
valid: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(db.project.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "project_1" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("batch-shifts timeline allocations through the real timeline router mutation", async () => {
|
||||
const existingAssignment = {
|
||||
id: "assignment_1",
|
||||
demandRequirementId: null,
|
||||
resourceId: "resource_1",
|
||||
projectId: "project_1",
|
||||
startDate: new Date("2026-03-16"),
|
||||
endDate: new Date("2026-03-20"),
|
||||
hoursPerDay: 4,
|
||||
percentage: 50,
|
||||
role: "Compositor",
|
||||
roleId: "role_comp",
|
||||
dailyCostCents: 20000,
|
||||
status: AllocationStatus.PROPOSED,
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-03-13"),
|
||||
updatedAt: new Date("2026-03-13"),
|
||||
resource: {
|
||||
id: "resource_1",
|
||||
displayName: "Alice",
|
||||
eid: "E-001",
|
||||
lcrCents: 5000,
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
},
|
||||
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
|
||||
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
|
||||
demandRequirement: null,
|
||||
};
|
||||
|
||||
const db = {
|
||||
allocation: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
demandRequirement: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
assignment: {
|
||||
findUnique: vi.fn().mockResolvedValue(existingAssignment),
|
||||
update: vi.fn().mockResolvedValue({
|
||||
...existingAssignment,
|
||||
startDate: new Date("2026-03-18"),
|
||||
endDate: new Date("2026-03-22"),
|
||||
}),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
||||
};
|
||||
const ctx = createToolContext(
|
||||
db,
|
||||
[PermissionKey.MANAGE_ALLOCATIONS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
|
||||
SystemRole.MANAGER,
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"batch_shift_timeline_allocations",
|
||||
JSON.stringify({
|
||||
allocationIds: ["assignment_1"],
|
||||
daysDelta: 2,
|
||||
mode: "move",
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.action).toEqual({ type: "invalidate", scope: ["allocation", "timeline", "project"] });
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
count: 1,
|
||||
}),
|
||||
);
|
||||
expect(db.assignment.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "assignment_1" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the chargeability report readmodel through the assistant", async () => {
|
||||
const { listAssignmentBookings } = await import("@capakraken/application");
|
||||
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([]),
|
||||
},
|
||||
},
|
||||
[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;
|
||||
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.resources).toEqual([
|
||||
expect.objectContaining({
|
||||
displayName: "Alice",
|
||||
targetPct: 80,
|
||||
months: [
|
||||
expect.objectContaining({
|
||||
monthKey: "2026-03",
|
||||
sah: expect.any(Number),
|
||||
chargeabilityPct: expect.any(Number),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns a filtered resource computation graph through the assistant", async () => {
|
||||
const resourceRecord = {
|
||||
id: "resource_augsburg",
|
||||
displayName: "Bruce Banner",
|
||||
eid: "bruce.banner",
|
||||
fte: 1,
|
||||
lcrCents: 5_000,
|
||||
chargeabilityTarget: 80,
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_augsburg",
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
country: {
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
name: "Deutschland",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
},
|
||||
metroCity: { id: "city_augsburg", name: "Augsburg" },
|
||||
managementLevelGroup: {
|
||||
id: "mlg_1",
|
||||
name: "Senior",
|
||||
targetPercentage: 0.8,
|
||||
},
|
||||
};
|
||||
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(resourceRecord),
|
||||
findFirst: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn().mockResolvedValue(resourceRecord),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
calculationRule: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
[PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"get_resource_computation_graph",
|
||||
JSON.stringify({
|
||||
resourceId: "resource_augsburg",
|
||||
month: "2026-08",
|
||||
domain: "SAH",
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
resource: { id: string; displayName: string };
|
||||
requestedDomain: string;
|
||||
totalNodeCount: number;
|
||||
selectedNodeCount: number;
|
||||
nodes: Array<{ id: string; domain: string }>;
|
||||
meta: {
|
||||
countryCode: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
resolvedHolidays: Array<{ name: string; scope: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
expect(parsed.resource).toEqual({
|
||||
id: "resource_augsburg",
|
||||
eid: "bruce.banner",
|
||||
displayName: "Bruce Banner",
|
||||
});
|
||||
expect(parsed.requestedDomain).toBe("SAH");
|
||||
expect(parsed.totalNodeCount).toBeGreaterThan(parsed.selectedNodeCount);
|
||||
expect(parsed.selectedNodeCount).toBeGreaterThan(0);
|
||||
expect(parsed.nodes.every((node) => node.domain === "SAH")).toBe(true);
|
||||
expect(parsed.meta).toMatchObject({
|
||||
countryCode: "DE",
|
||||
federalState: "BY",
|
||||
metroCityName: "Augsburg",
|
||||
});
|
||||
expect(parsed.meta.resolvedHolidays).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "Augsburger Friedensfest",
|
||||
scope: "CITY",
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("scopes assistant notification listing to the current user", async () => {
|
||||
const findMany = vi.fn().mockResolvedValue([]);
|
||||
const ctx = createToolContext({
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
|
||||
|
||||
function createToolContext(
|
||||
db: Record<string, unknown>,
|
||||
permissions: string[] = [],
|
||||
userRole: SystemRole = SystemRole.ADMIN,
|
||||
): ToolContext {
|
||||
return {
|
||||
db: db as ToolContext["db"],
|
||||
userId: "user_1",
|
||||
userRole,
|
||||
permissions: new Set(permissions) as ToolContext["permissions"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("assistant country tools", () => {
|
||||
it("lists countries with schedule rules, active state, and metro cities", async () => {
|
||||
const ctx = createToolContext({
|
||||
country: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
name: "Deutschland",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
isActive: true,
|
||||
metroCities: [{ id: "city_muc", name: "Munich" }],
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"list_countries",
|
||||
JSON.stringify({ includeInactive: true }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
count: number;
|
||||
countries: Array<{
|
||||
code: string;
|
||||
isActive: boolean;
|
||||
metroCities: Array<{ id: string; name: string }>;
|
||||
cities: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(parsed.count).toBe(1);
|
||||
expect(parsed.countries[0]).toMatchObject({
|
||||
code: "DE",
|
||||
isActive: true,
|
||||
cities: ["Munich"],
|
||||
metroCities: [{ id: "city_muc", name: "Munich" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("gets a country by code and exposes schedule details and resource count", async () => {
|
||||
const ctx = createToolContext({
|
||||
country: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
findFirst: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: "country_es",
|
||||
code: "ES",
|
||||
name: "Spain",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: {
|
||||
type: "spain",
|
||||
fridayHours: 6.5,
|
||||
summerPeriod: { from: "07-01", to: "09-15" },
|
||||
summerHours: 6.5,
|
||||
regularHours: 9,
|
||||
},
|
||||
isActive: true,
|
||||
metroCities: [{ id: "city_mad", name: "Madrid" }],
|
||||
_count: { resources: 4 },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"get_country",
|
||||
JSON.stringify({ identifier: "ES" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
code: string;
|
||||
resourceCount: number | null;
|
||||
scheduleRules: { type: string };
|
||||
metroCities: Array<{ name: string }>;
|
||||
};
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
code: "ES",
|
||||
resourceCount: 4,
|
||||
scheduleRules: { type: "spain" },
|
||||
metroCities: [{ name: "Madrid" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a country for admin users and returns an invalidation action", async () => {
|
||||
const ctx = createToolContext({
|
||||
country: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue({
|
||||
id: "country_es",
|
||||
code: "ES",
|
||||
name: "Spain",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
isActive: true,
|
||||
metroCities: [],
|
||||
_count: { resources: 0 },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"create_country",
|
||||
JSON.stringify({ code: "ES", name: "Spain", dailyWorkingHours: 8 }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.action).toEqual({
|
||||
type: "invalidate",
|
||||
scope: ["country", "resource", "holidayCalendar", "vacation"],
|
||||
});
|
||||
expect(result.data).toMatchObject({
|
||||
success: true,
|
||||
country: { code: "ES", name: "Spain" },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses country mutations for non-admin users", async () => {
|
||||
const ctx = createToolContext({ country: {} }, [], SystemRole.MANAGER);
|
||||
|
||||
const result = await executeTool(
|
||||
"create_country",
|
||||
JSON.stringify({ code: "ES", name: "Spain" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: "Admin role required to perform this action.",
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes metro cities only when no resources are assigned", async () => {
|
||||
const ctx = createToolContext({
|
||||
metroCity: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "city_ham",
|
||||
name: "Hamburg",
|
||||
_count: { resources: 0 },
|
||||
}),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"delete_metro_city",
|
||||
JSON.stringify({ id: "city_ham" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.action).toEqual({
|
||||
type: "invalidate",
|
||||
scope: ["country", "resource", "holidayCalendar", "vacation"],
|
||||
});
|
||||
expect(result.data).toMatchObject({
|
||||
success: true,
|
||||
message: "Deleted metro city: Hamburg",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
|
||||
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
||||
@@ -8,16 +9,21 @@ vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../lib/audit.js", () => ({
|
||||
createAuditEntry: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
|
||||
|
||||
function createToolContext(
|
||||
db: Record<string, unknown>,
|
||||
permissions: string[] = [],
|
||||
userRole: SystemRole = SystemRole.ADMIN,
|
||||
): ToolContext {
|
||||
return {
|
||||
db: db as ToolContext["db"],
|
||||
userId: "user_1",
|
||||
userRole: "ADMIN",
|
||||
userRole,
|
||||
permissions: new Set(permissions) as ToolContext["permissions"],
|
||||
};
|
||||
}
|
||||
@@ -107,6 +113,193 @@ describe("assistant holiday tools", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lists holiday calendars with scope metadata and entry counts", async () => {
|
||||
const ctx = createToolContext({
|
||||
holidayCalendar: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "cal_by",
|
||||
name: "Bayern Feiertage",
|
||||
scopeType: "STATE",
|
||||
stateCode: "BY",
|
||||
isActive: true,
|
||||
priority: 10,
|
||||
country: { id: "country_de", code: "DE", name: "Deutschland" },
|
||||
metroCity: null,
|
||||
_count: { entries: 2 },
|
||||
entries: [
|
||||
{
|
||||
id: "entry_1",
|
||||
date: new Date("2026-01-06T00:00:00.000Z"),
|
||||
name: "Heilige Drei Koenige",
|
||||
isRecurringAnnual: true,
|
||||
source: "state",
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"list_holiday_calendars",
|
||||
JSON.stringify({ countryCode: "DE", scopeType: "STATE", includeInactive: true }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
count: number;
|
||||
calendars: Array<{
|
||||
name: string;
|
||||
scopeType: string;
|
||||
stateCode: string | null;
|
||||
entryCount: number;
|
||||
country: { code: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(parsed.count).toBe(1);
|
||||
expect(parsed.calendars).toHaveLength(1);
|
||||
expect(parsed.calendars[0]).toMatchObject({
|
||||
name: "Bayern Feiertage",
|
||||
scopeType: "STATE",
|
||||
stateCode: "BY",
|
||||
entryCount: 2,
|
||||
country: { code: "DE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("previews resolved holiday calendars for a scope and shows the source calendar", async () => {
|
||||
const ctx = createToolContext({
|
||||
country: {
|
||||
findUnique: vi.fn().mockResolvedValue({ code: "DE" }),
|
||||
},
|
||||
metroCity: {
|
||||
findUnique: vi.fn().mockResolvedValue({ name: "Augsburg" }),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "cal_city",
|
||||
name: "Augsburg lokal",
|
||||
scopeType: "CITY",
|
||||
priority: 5,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
entries: [
|
||||
{
|
||||
id: "entry_1",
|
||||
date: new Date("2020-08-08T00:00:00.000Z"),
|
||||
name: "Friedensfest lokal",
|
||||
isRecurringAnnual: true,
|
||||
source: "manual",
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"preview_resolved_holiday_calendar",
|
||||
JSON.stringify({ countryId: "country_de", metroCityId: "city_augsburg", year: 2026 }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
count: number;
|
||||
locationContext: { countryCode: string; metroCity: string | null; year: number };
|
||||
holidays: Array<{ name: string; calendarName: string; scope: string; date: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.count).toBeGreaterThan(0);
|
||||
expect(parsed.locationContext).toEqual(
|
||||
expect.objectContaining({
|
||||
countryCode: "DE",
|
||||
metroCity: "Augsburg",
|
||||
year: 2026,
|
||||
}),
|
||||
);
|
||||
expect(parsed.holidays).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "Friedensfest lokal",
|
||||
calendarName: "Augsburg lokal",
|
||||
scope: "CITY",
|
||||
date: "2026-08-08",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a holiday calendar through the assistant for admin users", async () => {
|
||||
const ctx = createToolContext({
|
||||
country: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "country_de", code: "DE", name: "Deutschland" }),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue({
|
||||
id: "cal_by",
|
||||
name: "Bayern Feiertage",
|
||||
scopeType: "STATE",
|
||||
stateCode: "BY",
|
||||
isActive: true,
|
||||
priority: 10,
|
||||
country: { id: "country_de", code: "DE", name: "Deutschland" },
|
||||
metroCity: null,
|
||||
entries: [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"create_holiday_calendar",
|
||||
JSON.stringify({
|
||||
name: "Bayern Feiertage",
|
||||
scopeType: "STATE",
|
||||
countryId: "country_de",
|
||||
stateCode: "BY",
|
||||
priority: 10,
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
success: boolean;
|
||||
message: string;
|
||||
calendar: { name: string; stateCode: string | null };
|
||||
};
|
||||
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.message).toContain("Created holiday calendar");
|
||||
expect(parsed.calendar).toEqual(
|
||||
expect.objectContaining({
|
||||
name: "Bayern Feiertage",
|
||||
stateCode: "BY",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects holiday calendar mutations for non-admin assistant users", async () => {
|
||||
const ctx = createToolContext({}, [], SystemRole.MANAGER);
|
||||
const result = await executeTool(
|
||||
"create_holiday_calendar",
|
||||
JSON.stringify({
|
||||
name: "Hamburg Feiertage",
|
||||
scopeType: "STATE",
|
||||
countryId: "country_de",
|
||||
stateCode: "HH",
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
error: "Admin role required to perform this action.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("calculates chargeability with regional holidays excluded from booked and available hours", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
|
||||
@@ -19,9 +19,13 @@ vi.mock("@capakraken/staffing", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@capakraken/application", () => ({
|
||||
listAssignmentBookings: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
||||
return {
|
||||
...actual,
|
||||
listAssignmentBookings: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
const createCaller = createCallerFactory(staffingRouter);
|
||||
|
||||
|
||||
@@ -1,439 +1,12 @@
|
||||
import { VacationStatus } from "@capakraken/db";
|
||||
import { getPublicHolidays, type WeekdayAvailability } from "@capakraken/shared";
|
||||
|
||||
type CalendarScope = "COUNTRY" | "STATE" | "CITY";
|
||||
|
||||
type HolidayCalendarEntryRecord = {
|
||||
date: Date;
|
||||
isRecurringAnnual: boolean;
|
||||
};
|
||||
|
||||
type HolidayCalendarRecord = {
|
||||
entries: HolidayCalendarEntryRecord[];
|
||||
};
|
||||
|
||||
type VacationRecord = {
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
type: string;
|
||||
isHalfDay: boolean;
|
||||
};
|
||||
|
||||
export type ResourceCapacityProfile = {
|
||||
id: string;
|
||||
availability: WeekdayAvailability;
|
||||
countryId: string | null | undefined;
|
||||
countryCode: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityId: string | null | undefined;
|
||||
metroCityName: string | null | undefined;
|
||||
};
|
||||
|
||||
export type ResourceDailyAvailabilityContext = {
|
||||
absenceFractionsByDate: Map<string, number>;
|
||||
holidayDates: Set<string>;
|
||||
vacationFractionsByDate: Map<string, number>;
|
||||
};
|
||||
|
||||
type ResourceCapacityDbClient = {
|
||||
holidayCalendar?: {
|
||||
findMany: (args: {
|
||||
where: Record<string, unknown>;
|
||||
include: { entries: true };
|
||||
orderBy: Array<Record<string, "asc" | "desc">>;
|
||||
}) => Promise<unknown[]>;
|
||||
};
|
||||
vacation?: {
|
||||
findMany: (args: {
|
||||
where: Record<string, unknown>;
|
||||
select: Record<string, boolean | Record<string, boolean>>;
|
||||
}) => Promise<unknown[]>;
|
||||
};
|
||||
};
|
||||
|
||||
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
];
|
||||
|
||||
const CITY_HOLIDAY_RULES: Array<{
|
||||
countryCode: string;
|
||||
cityName: string;
|
||||
resolveDates: (year: number) => string[];
|
||||
}> = [
|
||||
{
|
||||
countryCode: "DE",
|
||||
cityName: "Augsburg",
|
||||
resolveDates: (year) => [`${year}-08-08`],
|
||||
},
|
||||
];
|
||||
|
||||
function toIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function normalizeCityName(cityName?: string | null): string | null {
|
||||
const normalized = cityName?.trim().toLowerCase();
|
||||
return normalized && normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeStateCode(stateCode?: string | null): string | null {
|
||||
const normalized = stateCode?.trim().toUpperCase();
|
||||
return normalized && normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function getAvailabilityHoursForDate(
|
||||
availability: WeekdayAvailability,
|
||||
date: Date,
|
||||
): number {
|
||||
const key = DAY_KEYS[date.getUTCDay()];
|
||||
return key ? (availability[key] ?? 0) : 0;
|
||||
}
|
||||
|
||||
function listBuiltinHolidayDates(input: {
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
countryCode: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityName: string | null | undefined;
|
||||
}): Set<string> {
|
||||
const dates = new Set<string>();
|
||||
const startIso = toIsoDate(input.periodStart);
|
||||
const endIso = toIsoDate(input.periodEnd);
|
||||
const startYear = input.periodStart.getUTCFullYear();
|
||||
const endYear = input.periodEnd.getUTCFullYear();
|
||||
|
||||
if (input.countryCode === "DE") {
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
for (const holiday of getPublicHolidays(year, input.federalState ?? undefined)) {
|
||||
if (holiday.date >= startIso && holiday.date <= endIso) {
|
||||
dates.add(holiday.date);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedCityName = normalizeCityName(input.metroCityName);
|
||||
if (input.countryCode && normalizedCityName) {
|
||||
for (const rule of CITY_HOLIDAY_RULES) {
|
||||
if (
|
||||
rule.countryCode === input.countryCode
|
||||
&& normalizeCityName(rule.cityName) === normalizedCityName
|
||||
) {
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
for (const date of rule.resolveDates(year)) {
|
||||
if (date >= startIso && date <= endIso) {
|
||||
dates.add(date);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
function resolveCalendarEntryDates(
|
||||
calendars: HolidayCalendarRecord[],
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Set<string> {
|
||||
const dates = new Set<string>();
|
||||
const startIso = toIsoDate(periodStart);
|
||||
const endIso = toIsoDate(periodEnd);
|
||||
const startYear = periodStart.getUTCFullYear();
|
||||
const endYear = periodEnd.getUTCFullYear();
|
||||
|
||||
for (const calendar of calendars) {
|
||||
for (const entry of calendar.entries) {
|
||||
const baseDate = new Date(entry.date);
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
const effectiveDate = entry.isRecurringAnnual
|
||||
? new Date(Date.UTC(year, baseDate.getUTCMonth(), baseDate.getUTCDate()))
|
||||
: baseDate;
|
||||
const isoDate = toIsoDate(effectiveDate);
|
||||
if (isoDate >= startIso && isoDate <= endIso) {
|
||||
dates.add(isoDate);
|
||||
}
|
||||
if (!entry.isRecurringAnnual) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
async function loadCustomHolidayDates(
|
||||
db: ResourceCapacityDbClient,
|
||||
input: {
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
countryId: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityId: string | null | undefined;
|
||||
},
|
||||
): Promise<Set<string>> {
|
||||
if (!input.countryId || typeof db.holidayCalendar?.findMany !== "function") {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const stateCode = normalizeStateCode(input.federalState);
|
||||
const metroCityId = input.metroCityId?.trim() || null;
|
||||
const calendars = await db.holidayCalendar.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
countryId: input.countryId,
|
||||
OR: [
|
||||
{ scopeType: "COUNTRY" as CalendarScope },
|
||||
...(stateCode ? [{ scopeType: "STATE" as CalendarScope, stateCode }] : []),
|
||||
...(metroCityId ? [{ scopeType: "CITY" as CalendarScope, metroCityId }] : []),
|
||||
],
|
||||
},
|
||||
include: { entries: true },
|
||||
orderBy: [{ priority: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
|
||||
return resolveCalendarEntryDates(
|
||||
calendars as HolidayCalendarRecord[],
|
||||
input.periodStart,
|
||||
input.periodEnd,
|
||||
);
|
||||
}
|
||||
|
||||
function buildProfileKey(profile: ResourceCapacityProfile): string {
|
||||
return JSON.stringify({
|
||||
countryId: profile.countryId ?? null,
|
||||
countryCode: profile.countryCode ?? null,
|
||||
federalState: profile.federalState ?? null,
|
||||
metroCityId: profile.metroCityId ?? null,
|
||||
metroCityName: profile.metroCityName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadResourceDailyAvailabilityContexts(
|
||||
db: ResourceCapacityDbClient,
|
||||
resources: ResourceCapacityProfile[],
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Promise<Map<string, ResourceDailyAvailabilityContext>> {
|
||||
const profileHolidayCache = new Map<string, Promise<Set<string>>>();
|
||||
const resourceIds = resources.map((resource) => resource.id);
|
||||
|
||||
const vacations = resourceIds.length > 0 && typeof db.vacation?.findMany === "function"
|
||||
? await db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: { in: resourceIds },
|
||||
status: VacationStatus.APPROVED,
|
||||
startDate: { lte: periodEnd },
|
||||
endDate: { gte: periodStart },
|
||||
},
|
||||
select: {
|
||||
resourceId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
type: true,
|
||||
isHalfDay: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const vacationsByResourceId = new Map<string, VacationRecord[]>();
|
||||
for (const vacation of vacations as VacationRecord[]) {
|
||||
const items = vacationsByResourceId.get(vacation.resourceId) ?? [];
|
||||
items.push(vacation);
|
||||
vacationsByResourceId.set(vacation.resourceId, items);
|
||||
}
|
||||
|
||||
const contexts = new Map<string, ResourceDailyAvailabilityContext>();
|
||||
|
||||
for (const resource of resources) {
|
||||
const profileKey = buildProfileKey(resource);
|
||||
const holidayPromise = profileHolidayCache.get(profileKey)
|
||||
?? (async () => {
|
||||
const builtin = listBuiltinHolidayDates({
|
||||
periodStart,
|
||||
periodEnd,
|
||||
countryCode: resource.countryCode,
|
||||
federalState: resource.federalState,
|
||||
metroCityName: resource.metroCityName,
|
||||
});
|
||||
const custom = await loadCustomHolidayDates(db, {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
countryId: resource.countryId,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
});
|
||||
return new Set([...builtin, ...custom]);
|
||||
})();
|
||||
|
||||
if (!profileHolidayCache.has(profileKey)) {
|
||||
profileHolidayCache.set(profileKey, holidayPromise);
|
||||
}
|
||||
|
||||
const holidayDates = new Set(await holidayPromise);
|
||||
const absenceFractionsByDate = new Map<string, number>();
|
||||
const vacationFractionsByDate = new Map<string, number>();
|
||||
const resourceVacations = vacationsByResourceId.get(resource.id) ?? [];
|
||||
|
||||
for (const vacation of resourceVacations) {
|
||||
const overlapStart = new Date(Math.max(vacation.startDate.getTime(), periodStart.getTime()));
|
||||
const overlapEnd = new Date(Math.min(vacation.endDate.getTime(), periodEnd.getTime()));
|
||||
if (overlapStart > overlapEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cursor = new Date(overlapStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(overlapEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const isoDate = toIsoDate(cursor);
|
||||
const fraction = vacation.isHalfDay ? 0.5 : 1;
|
||||
|
||||
if (vacation.type === "PUBLIC_HOLIDAY") {
|
||||
holidayDates.add(isoDate);
|
||||
}
|
||||
|
||||
if (vacation.type !== "PUBLIC_HOLIDAY") {
|
||||
const existingVacation = vacationFractionsByDate.get(isoDate) ?? 0;
|
||||
vacationFractionsByDate.set(isoDate, Math.max(existingVacation, fraction));
|
||||
}
|
||||
|
||||
const existing = absenceFractionsByDate.get(isoDate) ?? 0;
|
||||
if (vacation.type === "PUBLIC_HOLIDAY" || !holidayDates.has(isoDate)) {
|
||||
absenceFractionsByDate.set(isoDate, Math.max(existing, fraction));
|
||||
}
|
||||
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const isoDate of holidayDates) {
|
||||
const existing = absenceFractionsByDate.get(isoDate) ?? 0;
|
||||
absenceFractionsByDate.set(isoDate, Math.max(existing, 1));
|
||||
}
|
||||
|
||||
contexts.set(resource.id, {
|
||||
absenceFractionsByDate,
|
||||
holidayDates,
|
||||
vacationFractionsByDate,
|
||||
});
|
||||
}
|
||||
|
||||
return contexts;
|
||||
}
|
||||
|
||||
function calculateDayAvailabilityFraction(
|
||||
context: ResourceDailyAvailabilityContext | undefined,
|
||||
isoDate: string,
|
||||
): number {
|
||||
const fraction = context?.absenceFractionsByDate.get(isoDate) ?? 0;
|
||||
return Math.max(0, 1 - fraction);
|
||||
}
|
||||
|
||||
export function calculateEffectiveDayAvailability(input: {
|
||||
availability: WeekdayAvailability;
|
||||
date: Date;
|
||||
context: ResourceDailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
const baseHours = getAvailabilityHoursForDate(input.availability, input.date);
|
||||
if (baseHours <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return baseHours * calculateDayAvailabilityFraction(input.context, toIsoDate(input.date));
|
||||
}
|
||||
|
||||
export function calculateEffectiveAvailableHours(input: {
|
||||
availability: WeekdayAvailability;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
context: ResourceDailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
let hours = 0;
|
||||
const cursor = new Date(input.periodStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(input.periodEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
hours += calculateEffectiveDayAvailability({
|
||||
availability: input.availability,
|
||||
date: cursor,
|
||||
context: input.context,
|
||||
});
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
export function countEffectiveWorkingDays(input: {
|
||||
availability: WeekdayAvailability;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
context: ResourceDailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
let days = 0;
|
||||
const cursor = new Date(input.periodStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(input.periodEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
if (calculateEffectiveDayAvailability({
|
||||
availability: input.availability,
|
||||
date: cursor,
|
||||
context: input.context,
|
||||
}) > 0) {
|
||||
days += 1;
|
||||
}
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
export function calculateEffectiveBookedHours(input: {
|
||||
availability: WeekdayAvailability;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
context: ResourceDailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
const overlapStart = new Date(Math.max(input.startDate.getTime(), input.periodStart.getTime()));
|
||||
const overlapEnd = new Date(Math.min(input.endDate.getTime(), input.periodEnd.getTime()));
|
||||
|
||||
if (overlapStart > overlapEnd) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let hours = 0;
|
||||
const cursor = new Date(overlapStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(overlapEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const dayBaseHours = getAvailabilityHoursForDate(input.availability, cursor);
|
||||
if (dayBaseHours > 0) {
|
||||
hours += input.hoursPerDay * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
|
||||
}
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
export {
|
||||
calculateEffectiveBookedHours,
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveDayAvailability,
|
||||
countEffectiveWorkingDays,
|
||||
getAvailabilityHoursForDate,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "@capakraken/application";
|
||||
export type {
|
||||
ResourceCapacityProfile,
|
||||
ResourceDailyAvailabilityContext,
|
||||
} from "@capakraken/application";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* AI Assistant router — provides a chat endpoint that uses OpenAI Function Calling
|
||||
* to answer questions about plANARCHY data and modify resources/projects.
|
||||
* to answer questions about CapaKraken data and modify resources/projects.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { AssistantApprovalStatus, type PrismaClient } from "@capakraken/db";
|
||||
import { PermissionKey, resolvePermissions, type PermissionOverrides, type SystemRole } from "@capakraken/shared";
|
||||
import { PermissionKey, resolvePermissions, type PermissionOverrides, SystemRole } from "@capakraken/shared";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
|
||||
import { ADVANCED_ASSISTANT_TOOLS, MUTATION_TOOLS, TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
|
||||
@@ -112,6 +112,11 @@ const TOOL_PERMISSION_MAP: Record<string, string> = {
|
||||
create_allocation: "manageAllocations",
|
||||
cancel_allocation: "manageAllocations",
|
||||
update_allocation_status: "manageAllocations",
|
||||
update_timeline_allocation_inline: "manageAllocations",
|
||||
apply_timeline_project_shift: "manageAllocations",
|
||||
quick_assign_timeline_resource: "manageAllocations",
|
||||
batch_quick_assign_timeline_resources: "manageAllocations",
|
||||
batch_shift_timeline_allocations: "manageAllocations",
|
||||
create_demand: "manageAllocations",
|
||||
fill_demand: "manageAllocations",
|
||||
// Vacation management
|
||||
@@ -127,16 +132,71 @@ const TOOL_PERMISSION_MAP: Record<string, string> = {
|
||||
};
|
||||
|
||||
/** Tools that require cost visibility */
|
||||
const COST_TOOLS = new Set(["get_budget_status", "get_chargeability", "resolve_rate", "list_rate_cards", "get_estimate_detail", "find_best_project_resource"]);
|
||||
const COST_TOOLS = new Set([
|
||||
"get_budget_status",
|
||||
"get_chargeability",
|
||||
"get_chargeability_report",
|
||||
"get_resource_computation_graph",
|
||||
"get_project_computation_graph",
|
||||
"resolve_rate",
|
||||
"list_rate_cards",
|
||||
"get_estimate_detail",
|
||||
"find_best_project_resource",
|
||||
]);
|
||||
|
||||
export function getAvailableAssistantTools(permissions: Set<PermissionKey>) {
|
||||
/** Tools that follow controllerProcedure access rules in the main API. */
|
||||
const CONTROLLER_ONLY_TOOLS = new Set([
|
||||
"get_chargeability_report",
|
||||
"get_resource_computation_graph",
|
||||
"get_project_computation_graph",
|
||||
]);
|
||||
|
||||
/** Tools that follow managerProcedure access rules in the main API. */
|
||||
const MANAGER_ONLY_TOOLS = new Set([
|
||||
"update_timeline_allocation_inline",
|
||||
"apply_timeline_project_shift",
|
||||
"quick_assign_timeline_resource",
|
||||
"batch_quick_assign_timeline_resources",
|
||||
"batch_shift_timeline_allocations",
|
||||
]);
|
||||
|
||||
/** Tools that are intentionally limited to ADMIN because the backing routers are admin-only today. */
|
||||
const ADMIN_ONLY_TOOLS = new Set([
|
||||
"create_country",
|
||||
"update_country",
|
||||
"create_metro_city",
|
||||
"update_metro_city",
|
||||
"delete_metro_city",
|
||||
"create_holiday_calendar",
|
||||
"update_holiday_calendar",
|
||||
"delete_holiday_calendar",
|
||||
"create_holiday_calendar_entry",
|
||||
"update_holiday_calendar_entry",
|
||||
"delete_holiday_calendar_entry",
|
||||
]);
|
||||
|
||||
export function getAvailableAssistantTools(permissions: Set<PermissionKey>, userRole: string) {
|
||||
return TOOL_DEFINITIONS.filter((tool) => {
|
||||
const toolName = tool.function.name;
|
||||
const requiredPerm = TOOL_PERMISSION_MAP[toolName];
|
||||
const hasControllerAccess = userRole === SystemRole.ADMIN
|
||||
|| userRole === SystemRole.MANAGER
|
||||
|| userRole === SystemRole.CONTROLLER;
|
||||
const hasManagerAccess = userRole === SystemRole.ADMIN
|
||||
|| userRole === SystemRole.MANAGER;
|
||||
|
||||
if (requiredPerm && !permissions.has(requiredPerm as PermissionKey)) {
|
||||
return false;
|
||||
}
|
||||
if (ADMIN_ONLY_TOOLS.has(toolName) && userRole !== "ADMIN") {
|
||||
return false;
|
||||
}
|
||||
if (MANAGER_ONLY_TOOLS.has(toolName) && !hasManagerAccess) {
|
||||
return false;
|
||||
}
|
||||
if (CONTROLLER_ONLY_TOOLS.has(toolName) && !hasControllerAccess) {
|
||||
return false;
|
||||
}
|
||||
if (COST_TOOLS.has(toolName) && !permissions.has(PermissionKey.VIEW_COSTS)) {
|
||||
return false;
|
||||
}
|
||||
@@ -597,7 +657,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
// 4. Filter tools based on granular permissions
|
||||
const availableTools = getAvailableAssistantTools(permissions);
|
||||
const availableTools = getAvailableAssistantTools(permissions, userRole);
|
||||
|
||||
// 5. Function calling loop
|
||||
const toolCtx: ToolContext = {
|
||||
|
||||
@@ -117,6 +117,20 @@ export {
|
||||
type RecomputeResourceValueScoresInput,
|
||||
} from "./use-cases/resource/index.js";
|
||||
|
||||
export {
|
||||
calculateEffectiveAllocationCostCents,
|
||||
calculateEffectiveAllocationHours,
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours,
|
||||
calculateEffectiveDayAvailability,
|
||||
countEffectiveWorkingDays,
|
||||
enumerateIsoDates,
|
||||
getAvailabilityHoursForDate,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
type ResourceCapacityProfile,
|
||||
type ResourceDailyAvailabilityContext,
|
||||
} from "./lib/resource-capacity.js";
|
||||
|
||||
export {
|
||||
assessDispoImportReadiness,
|
||||
parseMandatoryDispoReferenceWorkbook,
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
import { VacationStatus } from "@capakraken/db";
|
||||
import { getPublicHolidays, type WeekdayAvailability } from "@capakraken/shared";
|
||||
|
||||
const MILLISECONDS_PER_DAY = 86_400_000;
|
||||
|
||||
type CalendarScope = "COUNTRY" | "STATE" | "CITY";
|
||||
|
||||
type HolidayCalendarEntryRecord = {
|
||||
date: Date;
|
||||
isRecurringAnnual: boolean;
|
||||
};
|
||||
|
||||
type HolidayCalendarRecord = {
|
||||
entries: HolidayCalendarEntryRecord[];
|
||||
};
|
||||
|
||||
type VacationRecord = {
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
type: string;
|
||||
isHalfDay: boolean;
|
||||
};
|
||||
|
||||
export type ResourceCapacityProfile = {
|
||||
id: string;
|
||||
availability: WeekdayAvailability;
|
||||
countryId: string | null | undefined;
|
||||
countryCode: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityId: string | null | undefined;
|
||||
metroCityName: string | null | undefined;
|
||||
};
|
||||
|
||||
export type ResourceDailyAvailabilityContext = {
|
||||
absenceFractionsByDate: Map<string, number>;
|
||||
holidayDates: Set<string>;
|
||||
vacationFractionsByDate: Map<string, number>;
|
||||
};
|
||||
|
||||
type ResourceCapacityDbClient = {
|
||||
holidayCalendar?: {
|
||||
findMany: (args: {
|
||||
where: Record<string, unknown>;
|
||||
include: { entries: true };
|
||||
orderBy: Array<Record<string, "asc" | "desc">>;
|
||||
}) => Promise<unknown[]>;
|
||||
};
|
||||
vacation?: {
|
||||
findMany: (args: {
|
||||
where: Record<string, unknown>;
|
||||
select: Record<string, boolean | Record<string, boolean>>;
|
||||
}) => Promise<unknown[]>;
|
||||
};
|
||||
};
|
||||
|
||||
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
];
|
||||
|
||||
const CITY_HOLIDAY_RULES: Array<{
|
||||
countryCode: string;
|
||||
cityName: string;
|
||||
resolveDates: (year: number) => string[];
|
||||
}> = [
|
||||
{
|
||||
countryCode: "DE",
|
||||
cityName: "Augsburg",
|
||||
resolveDates: (year) => [`${year}-08-08`],
|
||||
},
|
||||
];
|
||||
|
||||
function toIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function normalizeCityName(cityName?: string | null): string | null {
|
||||
const normalized = cityName?.trim().toLowerCase();
|
||||
return normalized && normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeStateCode(stateCode?: string | null): string | null {
|
||||
const normalized = stateCode?.trim().toUpperCase();
|
||||
return normalized && normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function getAvailabilityHoursForDate(
|
||||
availability: WeekdayAvailability,
|
||||
date: Date,
|
||||
): number {
|
||||
const key = DAY_KEYS[date.getUTCDay()];
|
||||
return key ? (availability[key] ?? 0) : 0;
|
||||
}
|
||||
|
||||
function listBuiltinHolidayDates(input: {
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
countryCode: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityName: string | null | undefined;
|
||||
}): Set<string> {
|
||||
const dates = new Set<string>();
|
||||
const startIso = toIsoDate(input.periodStart);
|
||||
const endIso = toIsoDate(input.periodEnd);
|
||||
const startYear = input.periodStart.getUTCFullYear();
|
||||
const endYear = input.periodEnd.getUTCFullYear();
|
||||
|
||||
if (input.countryCode === "DE") {
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
for (const holiday of getPublicHolidays(year, input.federalState ?? undefined)) {
|
||||
if (holiday.date >= startIso && holiday.date <= endIso) {
|
||||
dates.add(holiday.date);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedCityName = normalizeCityName(input.metroCityName);
|
||||
if (input.countryCode && normalizedCityName) {
|
||||
for (const rule of CITY_HOLIDAY_RULES) {
|
||||
if (
|
||||
rule.countryCode === input.countryCode
|
||||
&& normalizeCityName(rule.cityName) === normalizedCityName
|
||||
) {
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
for (const date of rule.resolveDates(year)) {
|
||||
if (date >= startIso && date <= endIso) {
|
||||
dates.add(date);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
function resolveCalendarEntryDates(
|
||||
calendars: HolidayCalendarRecord[],
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Set<string> {
|
||||
const dates = new Set<string>();
|
||||
const startIso = toIsoDate(periodStart);
|
||||
const endIso = toIsoDate(periodEnd);
|
||||
const startYear = periodStart.getUTCFullYear();
|
||||
const endYear = periodEnd.getUTCFullYear();
|
||||
|
||||
for (const calendar of calendars) {
|
||||
for (const entry of calendar.entries) {
|
||||
const baseDate = new Date(entry.date);
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
const effectiveDate = entry.isRecurringAnnual
|
||||
? new Date(Date.UTC(year, baseDate.getUTCMonth(), baseDate.getUTCDate()))
|
||||
: baseDate;
|
||||
const isoDate = toIsoDate(effectiveDate);
|
||||
if (isoDate >= startIso && isoDate <= endIso) {
|
||||
dates.add(isoDate);
|
||||
}
|
||||
if (!entry.isRecurringAnnual) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
async function loadCustomHolidayDates(
|
||||
db: ResourceCapacityDbClient,
|
||||
input: {
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
countryId: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityId: string | null | undefined;
|
||||
},
|
||||
): Promise<Set<string>> {
|
||||
if (!input.countryId || typeof db.holidayCalendar?.findMany !== "function") {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const stateCode = normalizeStateCode(input.federalState);
|
||||
const metroCityId = input.metroCityId?.trim() || null;
|
||||
const calendars = await db.holidayCalendar.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
countryId: input.countryId,
|
||||
OR: [
|
||||
{ scopeType: "COUNTRY" as CalendarScope },
|
||||
...(stateCode ? [{ scopeType: "STATE" as CalendarScope, stateCode }] : []),
|
||||
...(metroCityId ? [{ scopeType: "CITY" as CalendarScope, metroCityId }] : []),
|
||||
],
|
||||
},
|
||||
include: { entries: true },
|
||||
orderBy: [{ priority: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
|
||||
return resolveCalendarEntryDates(
|
||||
calendars as HolidayCalendarRecord[],
|
||||
input.periodStart,
|
||||
input.periodEnd,
|
||||
);
|
||||
}
|
||||
|
||||
function buildProfileKey(profile: ResourceCapacityProfile): string {
|
||||
return JSON.stringify({
|
||||
countryId: profile.countryId ?? null,
|
||||
countryCode: profile.countryCode ?? null,
|
||||
federalState: profile.federalState ?? null,
|
||||
metroCityId: profile.metroCityId ?? null,
|
||||
metroCityName: profile.metroCityName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadResourceDailyAvailabilityContexts(
|
||||
db: ResourceCapacityDbClient,
|
||||
resources: ResourceCapacityProfile[],
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Promise<Map<string, ResourceDailyAvailabilityContext>> {
|
||||
const profileHolidayCache = new Map<string, Promise<Set<string>>>();
|
||||
const resourceIds = resources.map((resource) => resource.id);
|
||||
|
||||
const vacations = resourceIds.length > 0 && typeof db.vacation?.findMany === "function"
|
||||
? await db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: { in: resourceIds },
|
||||
status: VacationStatus.APPROVED,
|
||||
startDate: { lte: periodEnd },
|
||||
endDate: { gte: periodStart },
|
||||
},
|
||||
select: {
|
||||
resourceId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
type: true,
|
||||
isHalfDay: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const vacationsByResourceId = new Map<string, VacationRecord[]>();
|
||||
for (const vacation of vacations as VacationRecord[]) {
|
||||
const items = vacationsByResourceId.get(vacation.resourceId) ?? [];
|
||||
items.push(vacation);
|
||||
vacationsByResourceId.set(vacation.resourceId, items);
|
||||
}
|
||||
|
||||
const contexts = new Map<string, ResourceDailyAvailabilityContext>();
|
||||
|
||||
for (const resource of resources) {
|
||||
const profileKey = buildProfileKey(resource);
|
||||
const holidayPromise = profileHolidayCache.get(profileKey)
|
||||
?? (async () => {
|
||||
const builtin = listBuiltinHolidayDates({
|
||||
periodStart,
|
||||
periodEnd,
|
||||
countryCode: resource.countryCode,
|
||||
federalState: resource.federalState,
|
||||
metroCityName: resource.metroCityName,
|
||||
});
|
||||
const custom = await loadCustomHolidayDates(db, {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
countryId: resource.countryId,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
});
|
||||
return new Set([...builtin, ...custom]);
|
||||
})();
|
||||
|
||||
if (!profileHolidayCache.has(profileKey)) {
|
||||
profileHolidayCache.set(profileKey, holidayPromise);
|
||||
}
|
||||
|
||||
const holidayDates = new Set(await holidayPromise);
|
||||
const absenceFractionsByDate = new Map<string, number>();
|
||||
const vacationFractionsByDate = new Map<string, number>();
|
||||
const resourceVacations = vacationsByResourceId.get(resource.id) ?? [];
|
||||
|
||||
for (const vacation of resourceVacations) {
|
||||
const overlapStart = new Date(Math.max(vacation.startDate.getTime(), periodStart.getTime()));
|
||||
const overlapEnd = new Date(Math.min(vacation.endDate.getTime(), periodEnd.getTime()));
|
||||
if (overlapStart > overlapEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cursor = new Date(overlapStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(overlapEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const isoDate = toIsoDate(cursor);
|
||||
const fraction = vacation.isHalfDay ? 0.5 : 1;
|
||||
|
||||
if (vacation.type === "PUBLIC_HOLIDAY") {
|
||||
holidayDates.add(isoDate);
|
||||
}
|
||||
|
||||
if (vacation.type !== "PUBLIC_HOLIDAY") {
|
||||
const existingVacation = vacationFractionsByDate.get(isoDate) ?? 0;
|
||||
vacationFractionsByDate.set(isoDate, Math.max(existingVacation, fraction));
|
||||
}
|
||||
|
||||
const existing = absenceFractionsByDate.get(isoDate) ?? 0;
|
||||
if (vacation.type === "PUBLIC_HOLIDAY" || !holidayDates.has(isoDate)) {
|
||||
absenceFractionsByDate.set(isoDate, Math.max(existing, fraction));
|
||||
}
|
||||
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const isoDate of holidayDates) {
|
||||
const existing = absenceFractionsByDate.get(isoDate) ?? 0;
|
||||
absenceFractionsByDate.set(isoDate, Math.max(existing, 1));
|
||||
}
|
||||
|
||||
contexts.set(resource.id, {
|
||||
absenceFractionsByDate,
|
||||
holidayDates,
|
||||
vacationFractionsByDate,
|
||||
});
|
||||
}
|
||||
|
||||
return contexts;
|
||||
}
|
||||
|
||||
function calculateDayAvailabilityFraction(
|
||||
context: ResourceDailyAvailabilityContext | undefined,
|
||||
isoDate: string,
|
||||
): number {
|
||||
const fraction = context?.absenceFractionsByDate.get(isoDate) ?? 0;
|
||||
return Math.max(0, 1 - fraction);
|
||||
}
|
||||
|
||||
export function calculateEffectiveDayAvailability(input: {
|
||||
availability: WeekdayAvailability;
|
||||
date: Date;
|
||||
context: ResourceDailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
const baseHours = getAvailabilityHoursForDate(input.availability, input.date);
|
||||
if (baseHours <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return baseHours * calculateDayAvailabilityFraction(input.context, toIsoDate(input.date));
|
||||
}
|
||||
|
||||
export function calculateEffectiveAvailableHours(input: {
|
||||
availability: WeekdayAvailability;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
context: ResourceDailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
let hours = 0;
|
||||
const cursor = new Date(input.periodStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(input.periodEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
hours += calculateEffectiveDayAvailability({
|
||||
availability: input.availability,
|
||||
date: cursor,
|
||||
context: input.context,
|
||||
});
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
export function countEffectiveWorkingDays(input: {
|
||||
availability: WeekdayAvailability;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
context: ResourceDailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
let days = 0;
|
||||
const cursor = new Date(input.periodStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(input.periodEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
if (calculateEffectiveDayAvailability({
|
||||
availability: input.availability,
|
||||
date: cursor,
|
||||
context: input.context,
|
||||
}) > 0) {
|
||||
days += 1;
|
||||
}
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
export function calculateEffectiveBookedHours(input: {
|
||||
availability: WeekdayAvailability;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
context: ResourceDailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
const overlapStart = new Date(Math.max(input.startDate.getTime(), input.periodStart.getTime()));
|
||||
const overlapEnd = new Date(Math.min(input.endDate.getTime(), input.periodEnd.getTime()));
|
||||
|
||||
if (overlapStart > overlapEnd) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let hours = 0;
|
||||
const cursor = new Date(overlapStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(overlapEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const dayBaseHours = getAvailabilityHoursForDate(input.availability, cursor);
|
||||
if (dayBaseHours > 0) {
|
||||
hours += input.hoursPerDay * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
|
||||
}
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
export function calculateEffectiveAllocationHours(input: {
|
||||
availability: WeekdayAvailability;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
context: ResourceDailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
return calculateEffectiveBookedHours(input);
|
||||
}
|
||||
|
||||
export function calculateEffectiveAllocationCostCents(input: {
|
||||
availability: WeekdayAvailability;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
dailyCostCents: number;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
context: ResourceDailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
let costCents = 0;
|
||||
const overlapStart = new Date(
|
||||
Math.max(input.startDate.getTime(), input.periodStart.getTime()),
|
||||
);
|
||||
const overlapEnd = new Date(
|
||||
Math.min(input.endDate.getTime(), input.periodEnd.getTime()),
|
||||
);
|
||||
|
||||
if (overlapStart > overlapEnd) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const cursor = new Date(overlapStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(overlapEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const baseHours = getAvailabilityHoursForDate(input.availability, cursor);
|
||||
if (baseHours > 0) {
|
||||
costCents += input.dailyCostCents * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
|
||||
}
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return Math.round(costCents);
|
||||
}
|
||||
|
||||
export function enumerateIsoDates(
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): string[] {
|
||||
const dates: string[] = [];
|
||||
const cursor = new Date(periodStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(periodEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
dates.push(toIsoDate(cursor));
|
||||
cursor.setTime(cursor.getTime() + MILLISECONDS_PER_DAY);
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
@@ -1,459 +1,11 @@
|
||||
import { VacationStatus } from "@capakraken/db";
|
||||
import { getPublicHolidays, type WeekdayAvailability } from "@capakraken/shared";
|
||||
|
||||
const MILLISECONDS_PER_DAY = 86_400_000;
|
||||
|
||||
type CalendarScope = "COUNTRY" | "STATE" | "CITY";
|
||||
|
||||
type HolidayCalendarEntryRecord = {
|
||||
date: Date;
|
||||
isRecurringAnnual: boolean;
|
||||
};
|
||||
|
||||
type HolidayCalendarRecord = {
|
||||
entries: HolidayCalendarEntryRecord[];
|
||||
};
|
||||
|
||||
type VacationRecord = {
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
type: string;
|
||||
isHalfDay: boolean;
|
||||
};
|
||||
|
||||
type ResourceHolidayProfile = {
|
||||
id: string;
|
||||
availability: WeekdayAvailability;
|
||||
countryId: string | null | undefined;
|
||||
countryCode: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityId: string | null | undefined;
|
||||
metroCityName: string | null | undefined;
|
||||
};
|
||||
|
||||
type DashboardHolidayDbClient = {
|
||||
holidayCalendar?: {
|
||||
findMany: (args: {
|
||||
where: Record<string, unknown>;
|
||||
include: { entries: true };
|
||||
orderBy: Array<Record<string, "asc" | "desc">>;
|
||||
}) => Promise<unknown[]>;
|
||||
};
|
||||
vacation?: {
|
||||
findMany: (args: {
|
||||
where: Record<string, unknown>;
|
||||
select: Record<string, boolean | Record<string, boolean>>;
|
||||
}) => Promise<unknown[]>;
|
||||
};
|
||||
};
|
||||
|
||||
type DailyAvailabilityContext = {
|
||||
holidayDates: Set<string>;
|
||||
absenceFractionsByDate: Map<string, number>;
|
||||
};
|
||||
|
||||
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
];
|
||||
|
||||
const CITY_HOLIDAY_RULES: Array<{
|
||||
countryCode: string;
|
||||
cityName: string;
|
||||
resolveDates: (year: number) => string[];
|
||||
}> = [
|
||||
{
|
||||
countryCode: "DE",
|
||||
cityName: "Augsburg",
|
||||
resolveDates: (year) => [`${year}-08-08`],
|
||||
},
|
||||
];
|
||||
|
||||
function toIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function normalizeCityName(cityName?: string | null): string | null {
|
||||
const normalized = cityName?.trim().toLowerCase();
|
||||
return normalized && normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeStateCode(stateCode?: string | null): string | null {
|
||||
const normalized = stateCode?.trim().toUpperCase();
|
||||
return normalized && normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function getDailyAvailabilityHours(
|
||||
availability: WeekdayAvailability,
|
||||
date: Date,
|
||||
): number {
|
||||
const key = DAY_KEYS[date.getUTCDay()];
|
||||
return key ? (availability[key] ?? 0) : 0;
|
||||
}
|
||||
|
||||
function listBuiltinHolidayDates(input: {
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
countryCode: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityName: string | null | undefined;
|
||||
}): Set<string> {
|
||||
const dates = new Set<string>();
|
||||
const startIso = toIsoDate(input.periodStart);
|
||||
const endIso = toIsoDate(input.periodEnd);
|
||||
const startYear = input.periodStart.getUTCFullYear();
|
||||
const endYear = input.periodEnd.getUTCFullYear();
|
||||
|
||||
if (input.countryCode === "DE") {
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
for (const holiday of getPublicHolidays(year, input.federalState ?? undefined)) {
|
||||
if (holiday.date >= startIso && holiday.date <= endIso) {
|
||||
dates.add(holiday.date);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedCityName = normalizeCityName(input.metroCityName);
|
||||
if (input.countryCode && normalizedCityName) {
|
||||
for (const rule of CITY_HOLIDAY_RULES) {
|
||||
if (
|
||||
rule.countryCode === input.countryCode
|
||||
&& normalizeCityName(rule.cityName) === normalizedCityName
|
||||
) {
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
for (const date of rule.resolveDates(year)) {
|
||||
if (date >= startIso && date <= endIso) {
|
||||
dates.add(date);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
function resolveCalendarEntryDates(
|
||||
calendars: HolidayCalendarRecord[],
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Set<string> {
|
||||
const dates = new Set<string>();
|
||||
const startIso = toIsoDate(periodStart);
|
||||
const endIso = toIsoDate(periodEnd);
|
||||
const startYear = periodStart.getUTCFullYear();
|
||||
const endYear = periodEnd.getUTCFullYear();
|
||||
|
||||
for (const calendar of calendars) {
|
||||
for (const entry of calendar.entries) {
|
||||
const baseDate = new Date(entry.date);
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
const effectiveDate = entry.isRecurringAnnual
|
||||
? new Date(Date.UTC(year, baseDate.getUTCMonth(), baseDate.getUTCDate()))
|
||||
: baseDate;
|
||||
const isoDate = toIsoDate(effectiveDate);
|
||||
if (isoDate >= startIso && isoDate <= endIso) {
|
||||
dates.add(isoDate);
|
||||
}
|
||||
if (!entry.isRecurringAnnual) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
async function loadCustomHolidayDates(
|
||||
db: DashboardHolidayDbClient,
|
||||
input: {
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
countryId: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityId: string | null | undefined;
|
||||
},
|
||||
): Promise<Set<string>> {
|
||||
if (!input.countryId || typeof db.holidayCalendar?.findMany !== "function") {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const stateCode = normalizeStateCode(input.federalState);
|
||||
const metroCityId = input.metroCityId?.trim() || null;
|
||||
const calendars = await db.holidayCalendar.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
countryId: input.countryId,
|
||||
OR: [
|
||||
{ scopeType: "COUNTRY" as CalendarScope },
|
||||
...(stateCode ? [{ scopeType: "STATE" as CalendarScope, stateCode }] : []),
|
||||
...(metroCityId ? [{ scopeType: "CITY" as CalendarScope, metroCityId }] : []),
|
||||
],
|
||||
},
|
||||
include: { entries: true },
|
||||
orderBy: [{ priority: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
|
||||
return resolveCalendarEntryDates(
|
||||
calendars as HolidayCalendarRecord[],
|
||||
input.periodStart,
|
||||
input.periodEnd,
|
||||
);
|
||||
}
|
||||
|
||||
function buildHolidayProfileKey(profile: ResourceHolidayProfile): string {
|
||||
return JSON.stringify({
|
||||
countryId: profile.countryId ?? null,
|
||||
countryCode: profile.countryCode ?? null,
|
||||
federalState: profile.federalState ?? null,
|
||||
metroCityId: profile.metroCityId ?? null,
|
||||
metroCityName: profile.metroCityName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadDailyAvailabilityContexts(
|
||||
db: DashboardHolidayDbClient,
|
||||
resources: ResourceHolidayProfile[],
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Promise<Map<string, DailyAvailabilityContext>> {
|
||||
const profileHolidayCache = new Map<string, Promise<Set<string>>>();
|
||||
const resourceIds = resources.map((resource) => resource.id);
|
||||
|
||||
const vacations = resourceIds.length > 0 && typeof db.vacation?.findMany === "function"
|
||||
? await db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: { in: resourceIds },
|
||||
status: VacationStatus.APPROVED,
|
||||
startDate: { lte: periodEnd },
|
||||
endDate: { gte: periodStart },
|
||||
},
|
||||
select: {
|
||||
resourceId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
type: true,
|
||||
isHalfDay: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const vacationsByResourceId = new Map<string, VacationRecord[]>();
|
||||
for (const vacation of vacations as VacationRecord[]) {
|
||||
const items = vacationsByResourceId.get(vacation.resourceId) ?? [];
|
||||
items.push(vacation);
|
||||
vacationsByResourceId.set(vacation.resourceId, items);
|
||||
}
|
||||
|
||||
const contexts = new Map<string, DailyAvailabilityContext>();
|
||||
|
||||
for (const resource of resources) {
|
||||
const profileKey = buildHolidayProfileKey(resource);
|
||||
const holidayPromise = profileHolidayCache.get(profileKey)
|
||||
?? (async () => {
|
||||
const builtin = listBuiltinHolidayDates({
|
||||
periodStart,
|
||||
periodEnd,
|
||||
countryCode: resource.countryCode,
|
||||
federalState: resource.federalState,
|
||||
metroCityName: resource.metroCityName,
|
||||
});
|
||||
const custom = await loadCustomHolidayDates(db, {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
countryId: resource.countryId,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
});
|
||||
return new Set([...builtin, ...custom]);
|
||||
})();
|
||||
|
||||
if (!profileHolidayCache.has(profileKey)) {
|
||||
profileHolidayCache.set(profileKey, holidayPromise);
|
||||
}
|
||||
|
||||
const holidayDates = new Set(await holidayPromise);
|
||||
const absenceFractionsByDate = new Map<string, number>();
|
||||
const resourceVacations = vacationsByResourceId.get(resource.id) ?? [];
|
||||
|
||||
for (const vacation of resourceVacations) {
|
||||
const overlapStart = new Date(
|
||||
Math.max(vacation.startDate.getTime(), periodStart.getTime()),
|
||||
);
|
||||
const overlapEnd = new Date(
|
||||
Math.min(vacation.endDate.getTime(), periodEnd.getTime()),
|
||||
);
|
||||
|
||||
if (overlapStart > overlapEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cursor = new Date(overlapStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(overlapEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const isoDate = toIsoDate(cursor);
|
||||
const fraction = vacation.isHalfDay ? 0.5 : 1;
|
||||
|
||||
if (vacation.type === "PUBLIC_HOLIDAY") {
|
||||
holidayDates.add(isoDate);
|
||||
}
|
||||
|
||||
const existing = absenceFractionsByDate.get(isoDate) ?? 0;
|
||||
if (vacation.type === "PUBLIC_HOLIDAY" || !holidayDates.has(isoDate)) {
|
||||
absenceFractionsByDate.set(isoDate, Math.max(existing, fraction));
|
||||
}
|
||||
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const isoDate of holidayDates) {
|
||||
const existing = absenceFractionsByDate.get(isoDate) ?? 0;
|
||||
absenceFractionsByDate.set(isoDate, Math.max(existing, 1));
|
||||
}
|
||||
|
||||
contexts.set(resource.id, {
|
||||
holidayDates,
|
||||
absenceFractionsByDate,
|
||||
});
|
||||
}
|
||||
|
||||
return contexts;
|
||||
}
|
||||
|
||||
function calculateDayAvailabilityFraction(
|
||||
context: DailyAvailabilityContext | undefined,
|
||||
isoDate: string,
|
||||
): number {
|
||||
const fraction = context?.absenceFractionsByDate.get(isoDate) ?? 0;
|
||||
return Math.max(0, 1 - fraction);
|
||||
}
|
||||
|
||||
export function calculateEffectiveAvailableHours(input: {
|
||||
availability: WeekdayAvailability;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
context: DailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
let hours = 0;
|
||||
const cursor = new Date(input.periodStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(input.periodEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const baseHours = getDailyAvailabilityHours(input.availability, cursor);
|
||||
if (baseHours > 0) {
|
||||
hours += baseHours * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
|
||||
}
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
export function calculateEffectiveAllocationHours(input: {
|
||||
availability: WeekdayAvailability;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
context: DailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
let hours = 0;
|
||||
const overlapStart = new Date(
|
||||
Math.max(input.startDate.getTime(), input.periodStart.getTime()),
|
||||
);
|
||||
const overlapEnd = new Date(
|
||||
Math.min(input.endDate.getTime(), input.periodEnd.getTime()),
|
||||
);
|
||||
|
||||
if (overlapStart > overlapEnd) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const cursor = new Date(overlapStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(overlapEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const baseHours = getDailyAvailabilityHours(input.availability, cursor);
|
||||
if (baseHours > 0) {
|
||||
hours += input.hoursPerDay * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
|
||||
}
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
export function calculateEffectiveAllocationCostCents(input: {
|
||||
availability: WeekdayAvailability;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
dailyCostCents: number;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
context: DailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
let costCents = 0;
|
||||
const overlapStart = new Date(
|
||||
Math.max(input.startDate.getTime(), input.periodStart.getTime()),
|
||||
);
|
||||
const overlapEnd = new Date(
|
||||
Math.min(input.endDate.getTime(), input.periodEnd.getTime()),
|
||||
);
|
||||
|
||||
if (overlapStart > overlapEnd) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const cursor = new Date(overlapStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(overlapEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const baseHours = getDailyAvailabilityHours(input.availability, cursor);
|
||||
if (baseHours > 0) {
|
||||
costCents += input.dailyCostCents * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
|
||||
}
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return Math.round(costCents);
|
||||
}
|
||||
|
||||
export function enumerateIsoDates(
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): string[] {
|
||||
const dates: string[] = [];
|
||||
const cursor = new Date(periodStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(periodEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
dates.push(toIsoDate(cursor));
|
||||
cursor.setTime(cursor.getTime() + MILLISECONDS_PER_DAY);
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
export type { DailyAvailabilityContext, ResourceHolidayProfile };
|
||||
export {
|
||||
calculateEffectiveAllocationCostCents,
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours as calculateEffectiveAllocationHours,
|
||||
enumerateIsoDates,
|
||||
loadResourceDailyAvailabilityContexts as loadDailyAvailabilityContexts,
|
||||
} from "../../lib/resource-capacity.js";
|
||||
export type {
|
||||
ResourceCapacityProfile as ResourceHolidayProfile,
|
||||
ResourceDailyAvailabilityContext as DailyAvailabilityContext,
|
||||
} from "../../lib/resource-capacity.js";
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"db:push": "node ../../scripts/with-env.mjs prisma db push --schema ./prisma/schema.prisma",
|
||||
"db:migrate": "node ../../scripts/with-env.mjs prisma migrate dev --schema ./prisma/schema.prisma",
|
||||
"db:migrate:deploy": "node ../../scripts/with-env.mjs prisma migrate deploy --schema ./prisma/schema.prisma",
|
||||
"db:validate": "node ../../scripts/with-env.mjs prisma validate --schema ./prisma/schema.prisma",
|
||||
"db:seed": "node ../../scripts/with-env.mjs tsx src/seed.ts",
|
||||
"db:seed:holiday-demo-resources": "node ../../scripts/with-env.mjs tsx src/seed-holiday-demo-resources.ts",
|
||||
"db:seed:holidays": "node ../../scripts/with-env.mjs tsx src/seed-holiday-calendars.ts",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Planarchy — Prisma Schema
|
||||
// CapaKraken — Prisma Schema
|
||||
// All monetary values stored as integer cents to avoid float precision issues.
|
||||
|
||||
generator client {
|
||||
|
||||
@@ -86,11 +86,11 @@ test("assertDestructiveDbAllowed rejects missing destructive allow flag", () =>
|
||||
);
|
||||
});
|
||||
|
||||
test("assertSafeSeedTarget rejects legacy planarchy disposable databases", () => {
|
||||
test("assertSafeSeedTarget rejects unexpected legacy disposable databases", () => {
|
||||
setEnv({
|
||||
DATABASE_URL: "postgresql://tester:secret@localhost:5432/planarchy_test",
|
||||
DATABASE_URL: "postgresql://tester:secret@localhost:5432/legacy_test",
|
||||
ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
|
||||
CONFIRM_DESTRUCTIVE_DB_NAME: "planarchy_test",
|
||||
CONFIRM_DESTRUCTIVE_DB_NAME: "legacy_test",
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
|
||||
@@ -6,7 +6,7 @@ interface DestructiveGuardOptions {
|
||||
requireConfirmation?: boolean;
|
||||
}
|
||||
|
||||
const PROTECTED_DATABASE_NAMES = new Set(["capakraken", "planarchy"]);
|
||||
const PROTECTED_DATABASE_NAMES = new Set(["capakraken"]);
|
||||
|
||||
function parseDatabaseUrl(rawUrl: string) {
|
||||
const parsed = new URL(rawUrl);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Generate samples/PlanarchyExamples.xlsx from the live database.
|
||||
* Generate samples/CapaKrakenExamples.xlsx from the live database.
|
||||
*
|
||||
* Run from repo root:
|
||||
* DATABASE_URL=postgresql://capakraken:capakraken_dev@localhost:5433/capakraken \
|
||||
@@ -334,7 +334,7 @@ async function buildSummarySheet(wb: ExcelJS.Workbook) {
|
||||
] as ExcelJS.Column[];
|
||||
|
||||
const title = ws.getCell("A1");
|
||||
title.value = "Planarchy — Seed Data Summary";
|
||||
title.value = "CapaKraken — Seed Data Summary";
|
||||
title.font = { bold: true, size: 14, color: { argb: COLORS.headerBg } };
|
||||
ws.mergeCells("A1:B1");
|
||||
ws.getRow(1).height = 28;
|
||||
@@ -367,7 +367,7 @@ async function main() {
|
||||
console.log("Connecting to database...");
|
||||
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = "Planarchy";
|
||||
wb.creator = "CapaKraken";
|
||||
wb.created = new Date();
|
||||
wb.modified = new Date();
|
||||
|
||||
@@ -377,7 +377,7 @@ async function main() {
|
||||
await buildProjectsSheet(wb);
|
||||
await buildAllocationsSheet(wb);
|
||||
|
||||
const outPath = path.resolve(__dirname, "../../../samples/PlanarchyExamples.xlsx");
|
||||
const outPath = path.resolve(__dirname, "../../../samples/CapaKrakenExamples.xlsx");
|
||||
await wb.xlsx.writeFile(outPath);
|
||||
console.log(`Excel written to: ${outPath}`);
|
||||
}
|
||||
|
||||
@@ -420,7 +420,7 @@ export async function runImportDispoBatch(options: ImportDispoBatchOptions) {
|
||||
ensureCommitAllowed(options, stageResult.readiness);
|
||||
|
||||
console.log("");
|
||||
console.log("Committing staged rows into live Planarchy tables...");
|
||||
console.log("Committing staged rows into live CapaKraken tables...");
|
||||
|
||||
const commitResult = await dispoImport.commitDispoImportBatch(prisma, {
|
||||
allowTbdUnresolved: options.allowTbdUnresolved,
|
||||
|
||||
@@ -28,7 +28,7 @@ function parseArgs(argv: string[]): ResetOptions {
|
||||
backupDir: DEFAULT_BACKUP_DIR,
|
||||
adminEmail: "admin@capakraken.dev",
|
||||
adminPassword: "admin123",
|
||||
adminName: "Planarchy Admin",
|
||||
adminName: "CapaKraken Admin",
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Updates PlanarchyExamples.xlsx with missing columns and documentation.
|
||||
* Adds: Display Name, Email, Skills, Planarchy Notes columns to EID sheet.
|
||||
* Adds: Start Date, End Date, Status, Planarchy Notes columns to Projects sheet.
|
||||
* Updates CapaKrakenExamples.xlsx with missing columns and documentation.
|
||||
* Adds: Display Name, Email, Skills, CapaKraken Notes columns to EID sheet.
|
||||
* Adds: Start Date, End Date, Status, CapaKraken Notes columns to Projects sheet.
|
||||
*/
|
||||
|
||||
import ExcelJS from "exceljs";
|
||||
@@ -11,7 +11,7 @@ import { dirname, join } from "path";
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const EXCEL_PATH = join(__dirname, "../../../samples/PlanarchyExamples.xlsx");
|
||||
const EXCEL_PATH = join(__dirname, "../../../samples/CapaKrakenExamples.xlsx");
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,7 +23,7 @@ function toDisplayName(eid) {
|
||||
}
|
||||
|
||||
function toEmail(eid) {
|
||||
return `${eid}@planarchy.example`;
|
||||
return `${eid}@capakraken.example`;
|
||||
}
|
||||
|
||||
function computeSkillLabel(chapter, typeOfWork) {
|
||||
@@ -36,8 +36,8 @@ function computeSkillLabel(chapter, typeOfWork) {
|
||||
return typeOfWork;
|
||||
}
|
||||
|
||||
function computePlanarchyEid(eid) {
|
||||
// In Planarchy the EID stays as firstname.lastname (unique key)
|
||||
function computeCapaKrakenEid(eid) {
|
||||
// In CapaKraken the EID stays as firstname.lastname (unique key)
|
||||
return eid;
|
||||
}
|
||||
|
||||
@@ -139,28 +139,28 @@ async function main() {
|
||||
// Column N: Display Name
|
||||
// Column O: Email
|
||||
// Column P: Skills (derived)
|
||||
// Column Q: Description / Notes for Planarchy
|
||||
// Column Q: Description / Notes for CapaKraken
|
||||
|
||||
const newEidCols = [
|
||||
{
|
||||
col: 14, // N
|
||||
header: "Display Name\n(auto-generated)",
|
||||
doc: "Full display name derived from EID (firstname.lastname → Firstname Lastname). Used as the person's name in Planarchy.",
|
||||
doc: "Full display name derived from EID (firstname.lastname → Firstname Lastname). Used as the person's name in CapaKraken.",
|
||||
},
|
||||
{
|
||||
col: 15, // O
|
||||
header: "Email\n(generated)",
|
||||
doc: "Generated email: firstname.lastname@planarchy.example. Required unique field in Planarchy. Replace with real email in production.",
|
||||
doc: "Generated email: firstname.lastname@capakraken.example. Required unique field in CapaKraken. Replace with real email in production.",
|
||||
},
|
||||
{
|
||||
col: 16, // P
|
||||
header: "Skills\n(derived from Chapter)",
|
||||
doc: "Skill tags assigned based on Chapter + Type of Work. Format: 'SkillA | SkillB'. Stored as JSON array in Planarchy with proficiency 1-5. Senior (LCR ≥ 118) → 5, Mid-Senior (LCR ≥ 95) → 4, Mid → 3.",
|
||||
doc: "Skill tags assigned based on Chapter + Type of Work. Format: 'SkillA | SkillB'. Stored as JSON array in CapaKraken with proficiency 1-5. Senior (LCR >= 118) -> 5, Mid-Senior (LCR >= 95) -> 4, Mid -> 3.",
|
||||
},
|
||||
{
|
||||
col: 17, // Q
|
||||
header: "Planarchy Notes",
|
||||
doc: "How data maps to Planarchy:\n• EID = unique key (col A)\n• Chapter = chapter field\n• LCR / UCR → multiply by 100 for integer cents (€85.00 → 8500)\n• Hours fraction × 8 = daily availability hours\n• Chargeability → multiply by 100 for % (0.75 → 75%)\n• Employee type, City, Client Unit → stored in dynamicFields JSONB",
|
||||
header: "CapaKraken Notes",
|
||||
doc: "How data maps to CapaKraken:\n- EID = unique key (col A)\n- Chapter = chapter field\n- LCR / UCR -> multiply by 100 for integer cents (EUR85.00 -> 8500)\n- Hours fraction x 8 = daily availability hours\n- Chargeability -> multiply by 100 for % (0.75 -> 75%)\n- Employee type, City, Client Unit -> stored in dynamicFields JSONB",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -214,18 +214,18 @@ async function main() {
|
||||
|
||||
// Also add doc notes to existing header columns A-M in row 1
|
||||
const eidExistingDocs = [
|
||||
"Unique identifier. Used as EID in Planarchy (no EMP-XXX prefix needed). e.g. steve.rogers",
|
||||
"Team / department. Maps to 'chapter' field in Planarchy.",
|
||||
"Unique identifier. Used as EID in CapaKraken (no EMP-XXX prefix needed). e.g. steve.rogers",
|
||||
"Team / department. Maps to 'chapter' field in CapaKraken.",
|
||||
"Specialization within chapter. Stored in dynamicFields.workType.",
|
||||
"Assigned client account. Stored in dynamicFields.clientUnit.",
|
||||
"Unit-specific field. Currently unused — can be stored in dynamicFields.",
|
||||
"Office city location. Stored in dynamicFields.city.",
|
||||
"Employment type: Employee or Freelancer. Stored in dynamicFields.employeeType.",
|
||||
"Loaded Cost Rate (LCR) in EUR/h. Multiply × 100 for Planarchy cents. e.g. 133.77 → 13377",
|
||||
"Loaded Cost Rate (LCR) in EUR/h. Multiply × 100 for CapaKraken cents. e.g. 133.77 → 13377",
|
||||
"Unloaded/Utilization Cost Rate (UCR) in EUR/h. Multiply × 100 for cents.",
|
||||
"FTE fraction (1.0 = 40h/week, 0.8 = 4 days, 0.5 = 20h/week). Combined with col K for availability JSON.",
|
||||
"Available weekdays. 'all' = Mon-Fri. Specific days listed = only those days active at 8h.",
|
||||
"Chargeability target as decimal. Multiply × 100 for Planarchy % (0.75 → 75%).",
|
||||
"Chargeability target as decimal. Multiply × 100 for CapaKraken % (0.75 → 75%).",
|
||||
"(unused)",
|
||||
];
|
||||
for (let c = 1; c <= 13; c++) {
|
||||
@@ -254,12 +254,12 @@ async function main() {
|
||||
{
|
||||
col: 19, // S
|
||||
header: "Status\n(derived)",
|
||||
doc: "Planarchy status derived from 'is ordered' + win probability + date:\n• COMPLETED: ordered + 100% + past dates\n• ACTIVE: ordered + 100% + current/future\n• ON_HOLD: ordered but paused\n• DRAFT: not ordered or low win probability",
|
||||
doc: "CapaKraken status derived from 'is ordered' + win probability + date:\n• COMPLETED: ordered + 100% + past dates\n• ACTIVE: ordered + 100% + current/future\n• ON_HOLD: ordered but paused\n• DRAFT: not ordered or low win probability",
|
||||
},
|
||||
{
|
||||
col: 20, // T
|
||||
header: "Planarchy Notes",
|
||||
doc: "How data maps to Planarchy:\n• Col C (short code) → shortCode (unique key)\n• Col B → name\n• BD/CH/UN → OrderType: BD / CHARGEABLE / INTERNAL\n• Internal/External → allocationType: INT / EXT\n• Resource Costs (col I) × 100 = budgetCents in Planarchy\n• Col H (chargability %) → stored in dynamicFields.chargeabilityPercent\n• Col J (person hours) → stored in dynamicFields.personHoursSold\n• Col O (classification) → stored in dynamicFields.classification",
|
||||
header: "CapaKraken Notes",
|
||||
doc: "How data maps to CapaKraken:\n• Col C (short code) → shortCode (unique key)\n• Col B → name\n• BD/CH/UN → OrderType: BD / CHARGEABLE / INTERNAL\n• Internal/External → allocationType: INT / EXT\n• Resource Costs (col I) × 100 = budgetCents in CapaKraken\n• Col H (chargability %) → stored in dynamicFields.chargeabilityPercent\n• Col J (person hours) → stored in dynamicFields.personHoursSold\n• Col O (classification) → stored in dynamicFields.classification",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -286,21 +286,21 @@ async function main() {
|
||||
|
||||
const projExistingDocs = [
|
||||
"Client Unit tag. e.g. [DAI]=Daimler, [PAG]=Porsche AG, [BMW], [JLR]=Jaguar Land Rover. Stored in dynamicFields.clientUnit.",
|
||||
"Full project name → maps to Planarchy 'name' field.",
|
||||
"Short internal project code (5-6 chars) → maps to Planarchy 'shortCode' (unique key). e.g. JLFJFL",
|
||||
"Full project name → maps to CapaKraken 'name' field.",
|
||||
"Short internal project code (5-6 chars) → maps to CapaKraken 'shortCode' (unique key). e.g. JLFJFL",
|
||||
"'yes'/'no' — whether the project is formally ordered. Drives status: yes+100% → ACTIVE/COMPLETED.",
|
||||
"Order type: BD=Business Development, CH=Chargeable, UN=Internal/Unordered. Maps to Planarchy OrderType.",
|
||||
"Win probability 0-100. Used in Planarchy 'winProbability' field for pipeline forecasting.",
|
||||
"Allocation type: Internal → INT, External → EXT. Maps to Planarchy 'allocationType' field.",
|
||||
"Order type: BD=Business Development, CH=Chargeable, UN=Internal/Unordered. Maps to CapaKraken OrderType.",
|
||||
"Win probability 0-100. Used in CapaKraken 'winProbability' field for pipeline forecasting.",
|
||||
"Allocation type: Internal → INT, External → EXT. Maps to CapaKraken 'allocationType' field.",
|
||||
"Chargeability % as decimal. Stored in dynamicFields.chargeabilityPercent.",
|
||||
"Planned resource cost in EUR. Multiply × 100 for Planarchy budgetCents. e.g. 78799 → 7879900 cents.",
|
||||
"Planned resource cost in EUR. Multiply × 100 for CapaKraken budgetCents. e.g. 78799 → 7879900 cents.",
|
||||
"Person hours planned/sold. Stored in dynamicFields.personHoursSold for budget tracking.",
|
||||
"Team staffing (empty in source). Would list assigned EIDs. Handled by Allocations in Planarchy.",
|
||||
"Team staffing (empty in source). Would list assigned EIDs. Handled by Allocations in CapaKraken.",
|
||||
"Day project sold (empty in source). Could be stored as dynamicFields.dateSold.",
|
||||
"Project start date (empty in source → synthesized in col Q). Maps to Planarchy 'startDate'.",
|
||||
"Project end date (empty in source → synthesized in col R). Maps to Planarchy 'endDate'.",
|
||||
"Project start date (empty in source → synthesized in col Q). Maps to CapaKraken 'startDate'.",
|
||||
"Project end date (empty in source → synthesized in col R). Maps to CapaKraken 'endDate'.",
|
||||
"Confidentiality: Confidential / Not Confidential. Stored in dynamicFields.classification.",
|
||||
"Responsible EID / Owner (empty in source). Would map to a PM allocation in Planarchy.",
|
||||
"Responsible EID / Owner (empty in source). Would map to a PM allocation in CapaKraken.",
|
||||
];
|
||||
for (let c = 1; c <= 16; c++) {
|
||||
const doc = projExistingDocs[c - 1];
|
||||
@@ -329,15 +329,15 @@ async function main() {
|
||||
projSheet.getColumn(19).width = 16;
|
||||
projSheet.getColumn(20).width = 50;
|
||||
|
||||
// ─── Add a "Planarchy Data Model" sheet ──────────────────────────────────
|
||||
// ─── Add a "CapaKraken Data Model" sheet ──────────────────────────────────
|
||||
|
||||
let modelSheet = workbook.getWorksheet("Planarchy Data Model");
|
||||
let modelSheet = workbook.getWorksheet("CapaKraken Data Model");
|
||||
if (!modelSheet) {
|
||||
modelSheet = workbook.addWorksheet("Planarchy Data Model");
|
||||
modelSheet = workbook.addWorksheet("CapaKraken Data Model");
|
||||
}
|
||||
|
||||
const modelData = [
|
||||
["Planarchy Data Model — Field Reference", "", "", "", ""],
|
||||
["CapaKraken Data Model — Field Reference", "", "", "", ""],
|
||||
["", "", "", "", ""],
|
||||
["RESOURCE FIELDS", "", "", "", ""],
|
||||
["Field", "Type", "Required", "Example", "Description"],
|
||||
@@ -393,7 +393,7 @@ async function main() {
|
||||
// Style title row
|
||||
const titleCell = modelSheet.getCell(1, 1);
|
||||
titleCell.font = { bold: true, size: 14, color: { argb: "FF4F46E5" } };
|
||||
titleCell.value = "Planarchy Data Model — Field Reference";
|
||||
titleCell.value = "CapaKraken Data Model — Field Reference";
|
||||
|
||||
// Style section headers and field headers
|
||||
const sectionRows = [3, 17, 31];
|
||||
@@ -421,7 +421,7 @@ async function main() {
|
||||
console.log(`✅ Excel updated: ${EXCEL_PATH}`);
|
||||
console.log(" - EID_Informationen: added Display Name, Email, Skills, Notes columns");
|
||||
console.log(" - Projektinfomartionen: added Start Date, End Date, Status, Notes columns");
|
||||
console.log(" - Added new sheet: 'Planarchy Data Model' (field reference)");
|
||||
console.log(" - Added new sheet: 'CapaKraken Data Model' (field reference)");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
Reference in New Issue
Block a user