feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
"./router": "./src/router/index.ts",
|
||||
"./trpc": "./src/trpc.ts",
|
||||
"./sse": "./src/sse/event-bus.ts",
|
||||
"./lib/audit": "./src/lib/audit.ts",
|
||||
"./lib/reminder-scheduler": "./src/lib/reminder-scheduler.ts",
|
||||
"./lib/logger": "./src/lib/logger.ts",
|
||||
"./middleware/rate-limit": "./src/middleware/rate-limit.ts"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { AllocationStatus, SystemRole } from "@capakraken/shared";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { allocationRouter } from "../router/allocation.js";
|
||||
import { emitAllocationCreated, emitAllocationDeleted } from "../sse/event-bus.js";
|
||||
import { emitAllocationCreated, emitAllocationDeleted, emitNotificationCreated } from "../sse/event-bus.js";
|
||||
import { createCallerFactory } from "../trpc.js";
|
||||
|
||||
vi.mock("../sse/event-bus.js", () => ({
|
||||
emitAllocationCreated: vi.fn(),
|
||||
emitAllocationDeleted: vi.fn(),
|
||||
emitAllocationUpdated: vi.fn(),
|
||||
emitNotificationCreated: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/budget-alerts.js", () => ({
|
||||
@@ -18,6 +19,10 @@ vi.mock("../lib/cache.js", () => ({
|
||||
invalidateDashboardCache: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/webhook-dispatcher.js", () => ({
|
||||
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const createCaller = createCallerFactory(allocationRouter);
|
||||
|
||||
function createManagerCaller(db: Record<string, unknown>) {
|
||||
@@ -35,7 +40,100 @@ function createManagerCaller(db: Record<string, unknown>) {
|
||||
});
|
||||
}
|
||||
|
||||
function createDemandWorkflowDb(overrides: Record<string, unknown> = {}) {
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "project_1", name: "Project One" }),
|
||||
},
|
||||
role: {
|
||||
findUnique: vi.fn().mockResolvedValue({ name: "FX Artist" }),
|
||||
},
|
||||
user: {
|
||||
findMany: vi.fn().mockResolvedValue([{ id: "mgr_1" }, { id: "admin_1" }]),
|
||||
},
|
||||
notification: {
|
||||
create: vi.fn().mockImplementation(async ({ data }: { data: { userId: string } }) => ({
|
||||
id: `notif_${data.userId}`,
|
||||
})),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...db,
|
||||
...overrides,
|
||||
project: { ...db.project, ...(overrides.project as Record<string, unknown> | undefined) },
|
||||
role: { ...db.role, ...(overrides.role as Record<string, unknown> | undefined) },
|
||||
user: { ...db.user, ...(overrides.user as Record<string, unknown> | undefined) },
|
||||
notification: {
|
||||
...db.notification,
|
||||
...(overrides.notification as Record<string, unknown> | undefined),
|
||||
},
|
||||
auditLog: { ...db.auditLog, ...(overrides.auditLog as Record<string, unknown> | undefined) },
|
||||
};
|
||||
}
|
||||
|
||||
describe("allocation entry resolution router", () => {
|
||||
it("excludes regional holidays from resource availability coverage", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "resource_1",
|
||||
displayName: "Bruce Banner",
|
||||
eid: "E-001",
|
||||
fte: 1,
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { dailyWorkingHours: 8, code: "DE" },
|
||||
metroCity: null,
|
||||
}),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "assignment_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
project: { name: "Gamma", shortCode: "GAM" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.checkResourceAvailability({
|
||||
resourceId: "resource_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
dailyCapacity: 8,
|
||||
totalWorkingDays: 1,
|
||||
availableDays: 0,
|
||||
partialDays: 0,
|
||||
conflictDays: 1,
|
||||
totalAvailableHours: 0,
|
||||
totalRequestedHours: 8,
|
||||
coveragePercent: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates an open demand through allocation.create without requiring isPlaceholder", async () => {
|
||||
const createdDemandRequirement = {
|
||||
id: "demand_1",
|
||||
@@ -187,6 +285,7 @@ describe("allocation entry resolution router", () => {
|
||||
|
||||
it("creates an explicit demand requirement without dual-writing a legacy allocation row", async () => {
|
||||
vi.mocked(emitAllocationCreated).mockClear();
|
||||
vi.mocked(emitNotificationCreated).mockClear();
|
||||
|
||||
const createdDemandRequirement = {
|
||||
id: "demand_explicit_1",
|
||||
@@ -206,18 +305,14 @@ describe("allocation entry resolution router", () => {
|
||||
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
|
||||
};
|
||||
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
|
||||
},
|
||||
const db = createDemandWorkflowDb({
|
||||
demandRequirement: {
|
||||
create: vi.fn().mockResolvedValue(createdDemandRequirement),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
Object.assign(db, {
|
||||
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.createDemandRequirement({
|
||||
@@ -247,6 +342,8 @@ describe("allocation entry resolution router", () => {
|
||||
projectId: "project_1",
|
||||
resourceId: null,
|
||||
});
|
||||
expect(db.notification.create).toHaveBeenCalledTimes(2);
|
||||
expect(emitNotificationCreated).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("creates an explicit assignment without dual-writing a legacy allocation row", async () => {
|
||||
@@ -730,4 +827,3 @@ describe("allocation entry resolution router", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAssistantInsight } from "../router/assistant-insights.js";
|
||||
|
||||
describe("assistant insights", () => {
|
||||
it("builds a transparent chargeability insight from holiday-aware payloads", () => {
|
||||
const insight = buildAssistantInsight("get_chargeability", {
|
||||
resource: "Bruce Banner",
|
||||
month: "2026-01",
|
||||
chargeability: "42.9%",
|
||||
chargeabilityPct: 42.9,
|
||||
targetPct: 80,
|
||||
availableHours: 168,
|
||||
bookedHours: 72,
|
||||
unassignedHours: 96,
|
||||
targetHours: 134.4,
|
||||
baseWorkingDays: 23,
|
||||
workingDays: 21,
|
||||
baseAvailableHours: 184,
|
||||
locationContext: { country: "Deutschland", federalState: "BY", metroCity: "Augsburg" },
|
||||
holidaySummary: { count: 2, workdayCount: 2, hoursDeduction: 16 },
|
||||
absenceSummary: { dayEquivalent: 0.5, hoursDeduction: 4 },
|
||||
});
|
||||
|
||||
expect(insight).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "chargeability",
|
||||
title: "Bruce Banner · 2026-01",
|
||||
metrics: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "Chargeability", value: "42.9%", tone: "warn" }),
|
||||
expect.objectContaining({ label: "Available", value: "168 h" }),
|
||||
expect.objectContaining({ label: "Target", value: "134.4 h" }),
|
||||
]),
|
||||
sections: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "Basis",
|
||||
metrics: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "Location", value: "Augsburg, BY, Deutschland" }),
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "Deductions",
|
||||
metrics: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "Holiday deduction", value: "16 h" }),
|
||||
expect.objectContaining({ label: "Absence deduction", value: "4 h" }),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("builds a holiday comparison insight with regional scope counts", () => {
|
||||
const insight = buildAssistantInsight("list_holidays_by_region", {
|
||||
locationContext: { countryCode: "DE", federalState: "BY" },
|
||||
count: 14,
|
||||
periodStart: "2026-01-01",
|
||||
periodEnd: "2026-12-31",
|
||||
summary: {
|
||||
byScope: [
|
||||
{ scope: "NATIONAL", count: 9 },
|
||||
{ scope: "STATE", count: 5 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(insight).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "holiday_region",
|
||||
title: "BY, DE",
|
||||
metrics: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "Resolved holidays", value: "14" }),
|
||||
]),
|
||||
sections: [
|
||||
expect.objectContaining({
|
||||
title: "Scopes",
|
||||
metrics: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "STATE", value: "5" }),
|
||||
]),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("builds a best-resource insight from staffing recommendations", () => {
|
||||
const insight = buildAssistantInsight("find_best_project_resource", {
|
||||
project: { name: "Gelddruckmaschine", shortCode: "GDM" },
|
||||
period: { startDate: "2026-04-01", endDate: "2026-04-21", minHoursPerDay: 3, rankingMode: "lowest_lcr" },
|
||||
candidateCount: 4,
|
||||
bestMatch: {
|
||||
name: "Jane Doe",
|
||||
role: "TD",
|
||||
chapter: "Lighting",
|
||||
country: "Deutschland",
|
||||
federalState: "BY",
|
||||
metroCity: "Muenchen",
|
||||
lcr: "€85.00",
|
||||
remainingHours: 74,
|
||||
remainingHoursPerDay: 3.5,
|
||||
availableHours: 120,
|
||||
baseAvailableHours: 136,
|
||||
holidaySummary: { hoursDeduction: 8 },
|
||||
absenceSummary: { hoursDeduction: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(insight).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "resource_match",
|
||||
title: "GDM staffing",
|
||||
metrics: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "Best match", value: "Jane Doe" }),
|
||||
expect.objectContaining({ label: "Remaining", value: "74 h", tone: "good" }),
|
||||
]),
|
||||
sections: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "Selection",
|
||||
metrics: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "Location", value: "Muenchen, BY, Deutschland" }),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PermissionKey, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
|
||||
import { getAvailableAssistantTools } from "../router/assistant.js";
|
||||
|
||||
function getToolNames(permissions: PermissionKeyValue[]) {
|
||||
return getAvailableAssistantTools(new Set(permissions)).map((tool) => tool.function.name);
|
||||
}
|
||||
|
||||
describe("assistant router tool gating", () => {
|
||||
it("hides advanced tools unless the dedicated assistant permission is granted", () => {
|
||||
const withoutAdvanced = getToolNames([PermissionKey.VIEW_COSTS]);
|
||||
const withAdvanced = getToolNames([
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
]);
|
||||
|
||||
expect(withoutAdvanced).not.toContain("find_best_project_resource");
|
||||
expect(withAdvanced).toContain("find_best_project_resource");
|
||||
});
|
||||
|
||||
it("keeps user administration tools behind manageUsers", () => {
|
||||
const withoutManageUsers = getToolNames([]);
|
||||
const withManageUsers = getToolNames([PermissionKey.MANAGE_USERS]);
|
||||
|
||||
expect(withoutManageUsers).not.toContain("list_users");
|
||||
expect(withManageUsers).toContain("list_users");
|
||||
});
|
||||
|
||||
it("continues to hide cost-aware advanced tools when viewCosts is missing", () => {
|
||||
const names = getToolNames([PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS]);
|
||||
|
||||
expect(names).not.toContain("find_best_project_resource");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PermissionKey } from "@capakraken/shared";
|
||||
|
||||
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
||||
return {
|
||||
...actual,
|
||||
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
|
||||
getDashboardPeakTimes: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
|
||||
|
||||
function createToolContext(
|
||||
db: Record<string, unknown>,
|
||||
permissions: PermissionKey[] = [],
|
||||
): ToolContext {
|
||||
return {
|
||||
db: db as ToolContext["db"],
|
||||
userId: "user_1",
|
||||
userRole: "ADMIN",
|
||||
permissions: new Set(permissions),
|
||||
};
|
||||
}
|
||||
|
||||
describe("assistant advanced tools and scoping", () => {
|
||||
it("finds the best project resource with holiday-aware remaining capacity and LCR ranking", async () => {
|
||||
const assignmentFindMany = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
resourceId: "res_carol",
|
||||
hoursPerDay: 2,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||
status: "PROPOSED",
|
||||
resource: {
|
||||
id: "res_carol",
|
||||
eid: "carol.danvers",
|
||||
displayName: "Carol Danvers",
|
||||
chapter: "Delivery",
|
||||
lcrCents: 7664,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "HH",
|
||||
metroCityId: "city_hamburg",
|
||||
country: { code: "DE", name: "Deutschland" },
|
||||
metroCity: { name: "Hamburg" },
|
||||
areaRole: { name: "Artist" },
|
||||
},
|
||||
},
|
||||
{
|
||||
resourceId: "res_steve",
|
||||
hoursPerDay: 4,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||
status: "CONFIRMED",
|
||||
resource: {
|
||||
id: "res_steve",
|
||||
eid: "steve.rogers",
|
||||
displayName: "Steve Rogers",
|
||||
chapter: "Delivery",
|
||||
lcrCents: 13377,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_augsburg",
|
||||
country: { code: "DE", name: "Deutschland" },
|
||||
metroCity: { name: "Augsburg" },
|
||||
areaRole: { name: "Artist" },
|
||||
},
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
resourceId: "res_carol",
|
||||
projectId: "project_lari",
|
||||
hoursPerDay: 2,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||
status: "PROPOSED",
|
||||
project: { name: "Gelddruckmaschine", shortCode: "LARI" },
|
||||
},
|
||||
{
|
||||
resourceId: "res_steve",
|
||||
projectId: "project_lari",
|
||||
hoursPerDay: 4,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||
status: "CONFIRMED",
|
||||
project: { name: "Gelddruckmaschine", shortCode: "LARI" },
|
||||
},
|
||||
]);
|
||||
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
project: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({
|
||||
id: "project_lari",
|
||||
name: "Gelddruckmaschine",
|
||||
shortCode: "LARI",
|
||||
status: "ACTIVE",
|
||||
responsiblePerson: "Larissa Joos",
|
||||
}),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
assignment: {
|
||||
findMany: assignmentFindMany,
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
[PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"find_best_project_resource",
|
||||
JSON.stringify({
|
||||
projectIdentifier: "LARI",
|
||||
startDate: "2026-01-05",
|
||||
endDate: "2026-01-16",
|
||||
minHoursPerDay: 3,
|
||||
rankingMode: "lowest_lcr",
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
project: { shortCode: string };
|
||||
candidateCount: number;
|
||||
bestMatch: {
|
||||
name: string;
|
||||
remainingHoursPerDay: number;
|
||||
lcrCents: number | null;
|
||||
federalState: string | null;
|
||||
metroCity: string | null;
|
||||
baseAvailableHours: number;
|
||||
holidaySummary: { count: number };
|
||||
};
|
||||
candidates: Array<{
|
||||
name: string;
|
||||
remainingHoursPerDay: number;
|
||||
workingDays: number;
|
||||
baseAvailableHours: number;
|
||||
holidaySummary: { count: number; hoursDeduction: number };
|
||||
capacityBreakdown: { holidayHoursDeduction: number };
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(parsed.project.shortCode).toBe("LARI");
|
||||
expect(parsed.candidateCount).toBe(2);
|
||||
expect(parsed.bestMatch).toEqual(
|
||||
expect.objectContaining({
|
||||
name: "Carol Danvers",
|
||||
remainingHoursPerDay: 6,
|
||||
lcrCents: 7664,
|
||||
federalState: "HH",
|
||||
metroCity: "Hamburg",
|
||||
baseAvailableHours: 80,
|
||||
holidaySummary: expect.objectContaining({ count: 0 }),
|
||||
}),
|
||||
);
|
||||
expect(parsed.candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "Carol Danvers",
|
||||
remainingHoursPerDay: 6,
|
||||
workingDays: 10,
|
||||
baseAvailableHours: 80,
|
||||
holidaySummary: expect.objectContaining({ count: 0, hoursDeduction: 0 }),
|
||||
capacityBreakdown: expect.objectContaining({ holidayHoursDeduction: 0 }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "Steve Rogers",
|
||||
remainingHoursPerDay: 4,
|
||||
workingDays: 9,
|
||||
baseAvailableHours: 80,
|
||||
holidaySummary: expect.objectContaining({ count: 1, hoursDeduction: 8 }),
|
||||
capacityBreakdown: expect.objectContaining({ holidayHoursDeduction: 8 }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires the dedicated advanced assistant permission for the high-level resource tool", async () => {
|
||||
const ctx = createToolContext({}, [PermissionKey.VIEW_COSTS]);
|
||||
|
||||
const result = await executeTool(
|
||||
"find_best_project_resource",
|
||||
JSON.stringify({ projectIdentifier: "LARI" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
error: expect.stringContaining(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("scopes assistant notification listing to the current user", async () => {
|
||||
const findMany = vi.fn().mockResolvedValue([]);
|
||||
const ctx = createToolContext({
|
||||
notification: {
|
||||
findMany,
|
||||
},
|
||||
});
|
||||
|
||||
await executeTool("list_notifications", JSON.stringify({ unreadOnly: true }), ctx);
|
||||
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
userId: "user_1",
|
||||
readAt: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects marking notifications that do not belong to the current user", async () => {
|
||||
const update = vi.fn();
|
||||
const ctx = createToolContext({
|
||||
notification: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "notif_1", userId: "someone_else" }),
|
||||
update,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"mark_notification_read",
|
||||
JSON.stringify({ notificationId: "notif_1" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: "Access denied: this notification does not belong to you",
|
||||
});
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires manageUsers before listing users through the assistant", async () => {
|
||||
const findMany = vi.fn();
|
||||
const ctx = createToolContext({
|
||||
user: {
|
||||
findMany,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool("list_users", JSON.stringify({ limit: 10 }), ctx);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
error: expect.stringContaining(PermissionKey.MANAGE_USERS),
|
||||
}),
|
||||
);
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,575 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
||||
return {
|
||||
...actual,
|
||||
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
|
||||
|
||||
function createToolContext(
|
||||
db: Record<string, unknown>,
|
||||
permissions: string[] = [],
|
||||
): ToolContext {
|
||||
return {
|
||||
db: db as ToolContext["db"],
|
||||
userId: "user_1",
|
||||
userRole: "ADMIN",
|
||||
permissions: new Set(permissions) as ToolContext["permissions"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("assistant holiday tools", () => {
|
||||
it("lists regional holidays and distinguishes Bavaria from Hamburg", async () => {
|
||||
const ctx = createToolContext({});
|
||||
|
||||
const bavaria = await executeTool(
|
||||
"list_holidays_by_region",
|
||||
JSON.stringify({ countryCode: "DE", federalState: "BY", year: 2026 }),
|
||||
ctx,
|
||||
);
|
||||
const hamburg = await executeTool(
|
||||
"list_holidays_by_region",
|
||||
JSON.stringify({ countryCode: "DE", federalState: "HH", year: 2026 }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const bavariaResult = JSON.parse(bavaria.content) as {
|
||||
count: number;
|
||||
locationContext: { federalState: string | null };
|
||||
summary: { byScope: Array<{ scope: string; count: number }> };
|
||||
holidays: Array<{ name: string; date: string }>;
|
||||
};
|
||||
const hamburgResult = JSON.parse(hamburg.content) as {
|
||||
count: number;
|
||||
locationContext: { federalState: string | null };
|
||||
holidays: Array<{ name: string; date: string }>;
|
||||
};
|
||||
|
||||
expect(bavariaResult.count).toBeGreaterThan(hamburgResult.count);
|
||||
expect(bavariaResult.locationContext.federalState).toBe("BY");
|
||||
expect(bavariaResult.summary.byScope).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ scope: "STATE" })]),
|
||||
);
|
||||
expect(bavariaResult.holidays).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
|
||||
]),
|
||||
);
|
||||
expect(hamburgResult.holidays).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves resource-specific holidays including city-local dates", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: "res_1", eid: "bruce.banner", displayName: "Bruce Banner", federalState: "BY", countryId: "country_de", metroCityId: "city_augsburg", country: { code: "DE", name: "Deutschland" }, metroCity: { name: "Augsburg" } }),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db);
|
||||
|
||||
const result = await executeTool(
|
||||
"get_resource_holidays",
|
||||
JSON.stringify({ identifier: "bruce.banner", year: 2026 }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
resource: { eid: string; federalState: string | null; metroCity: string | null };
|
||||
summary: { byScope: Array<{ scope: string; count: number }> };
|
||||
holidays: Array<{ name: string; date: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.resource).toEqual(
|
||||
expect.objectContaining({
|
||||
eid: "bruce.banner",
|
||||
federalState: "BY",
|
||||
metroCity: "Augsburg",
|
||||
}),
|
||||
);
|
||||
expect(parsed.holidays).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "Augsburger Friedensfest", date: "2026-08-08" }),
|
||||
]),
|
||||
);
|
||||
expect(parsed.summary.byScope).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ scope: "CITY" })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("calculates chargeability with regional holidays excluded from booked and available hours", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: "res_1",
|
||||
displayName: "Bruce Banner",
|
||||
eid: "bruce.banner",
|
||||
fte: 1,
|
||||
chargeabilityTarget: 80,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE", dailyWorkingHours: 8 },
|
||||
metroCity: null,
|
||||
}),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
status: "CONFIRMED",
|
||||
project: { name: "Gamma", shortCode: "GAM" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db);
|
||||
|
||||
const result = await executeTool(
|
||||
"get_chargeability",
|
||||
JSON.stringify({ resourceId: "res_1", month: "2026-01" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
baseWorkingDays: number;
|
||||
baseAvailableHours: number;
|
||||
availableHours: number;
|
||||
bookedHours: number;
|
||||
workingDays: number;
|
||||
targetHours: number;
|
||||
unassignedHours: number;
|
||||
holidaySummary: { count: number; workdayCount: number; hoursDeduction: number };
|
||||
capacityBreakdown: { formula: string; holidayHoursDeduction: number; absenceHoursDeduction: number };
|
||||
locationContext: { federalState: string | null };
|
||||
allocations: Array<{ hours: number }>;
|
||||
};
|
||||
|
||||
expect(parsed.bookedHours).toBe(8);
|
||||
expect(parsed.allocations).toEqual([expect.objectContaining({ hours: 8 })]);
|
||||
expect(parsed.baseWorkingDays).toBe(23);
|
||||
expect(parsed.baseAvailableHours).toBe(184);
|
||||
expect(parsed.availableHours).toBe(168);
|
||||
expect(parsed.workingDays).toBe(21);
|
||||
expect(parsed.targetHours).toBe(134.4);
|
||||
expect(parsed.unassignedHours).toBe(160);
|
||||
expect(parsed.locationContext.federalState).toBe("BY");
|
||||
expect(parsed.holidaySummary).toEqual(
|
||||
expect.objectContaining({
|
||||
count: 2,
|
||||
workdayCount: 2,
|
||||
hoursDeduction: 16,
|
||||
}),
|
||||
);
|
||||
expect(parsed.capacityBreakdown).toEqual(
|
||||
expect.objectContaining({
|
||||
formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours",
|
||||
holidayHoursDeduction: 16,
|
||||
absenceHoursDeduction: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns holiday-aware budget forecast data from the dashboard use-case", async () => {
|
||||
const { getDashboardBudgetForecast } = await import("@capakraken/application");
|
||||
vi.mocked(getDashboardBudgetForecast).mockResolvedValue([
|
||||
{
|
||||
projectId: "project_1",
|
||||
projectName: "Gelddruckmaschine",
|
||||
shortCode: "GDM",
|
||||
budgetCents: 100_000,
|
||||
spentCents: 60_000,
|
||||
burnRate: 5_000,
|
||||
pctUsed: 60,
|
||||
estimatedExhaustionDate: "2026-02-20",
|
||||
},
|
||||
]);
|
||||
|
||||
const ctx = createToolContext({}, ["viewCosts"]);
|
||||
const result = await executeTool("get_budget_forecast", "{}", ctx);
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
forecasts: Array<{
|
||||
projectName: string;
|
||||
shortCode: string;
|
||||
budgetCents: number;
|
||||
spentCents: number;
|
||||
remainingCents: number;
|
||||
projectedCents: number;
|
||||
burnRateCents: number;
|
||||
burnStatus: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(getDashboardBudgetForecast).toHaveBeenCalled();
|
||||
expect(parsed.forecasts).toEqual([
|
||||
expect.objectContaining({
|
||||
projectName: "Gelddruckmaschine",
|
||||
shortCode: "GDM",
|
||||
budgetCents: 100_000,
|
||||
spentCents: 60_000,
|
||||
remainingCents: 40_000,
|
||||
projectedCents: 100_000,
|
||||
burnRateCents: 5_000,
|
||||
burnStatus: "on_track",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("checks resource availability with regional holidays excluded from capacity", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: "res_1",
|
||||
displayName: "Bruce Banner",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
}),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
status: "CONFIRMED",
|
||||
project: { name: "Gamma", shortCode: "GAM" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db);
|
||||
|
||||
const result = await executeTool(
|
||||
"check_resource_availability",
|
||||
JSON.stringify({ resourceId: "res_1", startDate: "2026-01-05", endDate: "2026-01-06" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
workingDays: number;
|
||||
periodAvailableHours: number;
|
||||
periodBookedHours: number;
|
||||
periodRemainingHours: number;
|
||||
availableHoursPerDay: number;
|
||||
isFullyAvailable: boolean;
|
||||
};
|
||||
|
||||
expect(parsed.workingDays).toBe(1);
|
||||
expect(parsed.periodAvailableHours).toBe(8);
|
||||
expect(parsed.periodBookedHours).toBe(8);
|
||||
expect(parsed.periodRemainingHours).toBe(0);
|
||||
expect(parsed.availableHoursPerDay).toBe(0);
|
||||
expect(parsed.isFullyAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps scenario simulation flat when a proposed change falls on a local holiday", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "project_1",
|
||||
name: "Holiday Project",
|
||||
budgetCents: 500_000,
|
||||
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-31T00:00:00.000Z"),
|
||||
}),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "assignment_1",
|
||||
resourceId: "res_1",
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
status: "CONFIRMED",
|
||||
resource: {
|
||||
id: "res_1",
|
||||
displayName: "Bruce Banner",
|
||||
lcrCents: 100,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
chargeabilityTarget: 80,
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE", dailyWorkingHours: 8 },
|
||||
metroCity: null,
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_1",
|
||||
displayName: "Bruce Banner",
|
||||
lcrCents: 100,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
chargeabilityTarget: 80,
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE", dailyWorkingHours: 8 },
|
||||
metroCity: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db, ["manageAllocations"]);
|
||||
|
||||
const result = await executeTool(
|
||||
"simulate_scenario",
|
||||
JSON.stringify({
|
||||
projectId: "project_1",
|
||||
changes: [
|
||||
{
|
||||
resourceId: "res_1",
|
||||
startDate: "2026-01-06",
|
||||
endDate: "2026-01-06",
|
||||
hoursPerDay: 8,
|
||||
},
|
||||
],
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
baseline: { totalHours: number; totalCostCents: number };
|
||||
scenario: { totalHours: number; totalCostCents: number };
|
||||
delta: { hours: number; costCents: number };
|
||||
};
|
||||
|
||||
expect(parsed.baseline).toEqual(
|
||||
expect.objectContaining({
|
||||
totalHours: 8,
|
||||
totalCostCents: 800,
|
||||
}),
|
||||
);
|
||||
expect(parsed.scenario).toEqual(
|
||||
expect.objectContaining({
|
||||
totalHours: 8,
|
||||
totalCostCents: 800,
|
||||
}),
|
||||
);
|
||||
expect(parsed.delta).toEqual(
|
||||
expect.objectContaining({
|
||||
hours: 0,
|
||||
costCents: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers resources without a local holiday in staffing suggestions", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
id: "project_1",
|
||||
name: "Holiday Project",
|
||||
shortCode: "HP",
|
||||
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
}),
|
||||
},
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_by",
|
||||
displayName: "Bavaria",
|
||||
eid: "BY-1",
|
||||
fte: 1,
|
||||
lcrCents: 10000,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
areaRole: { name: "Consultant" },
|
||||
chapter: "CGI",
|
||||
assignments: [],
|
||||
},
|
||||
{
|
||||
id: "res_hh",
|
||||
displayName: "Hamburg",
|
||||
eid: "HH-1",
|
||||
fte: 1,
|
||||
lcrCents: 10000,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "HH",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
areaRole: { name: "Consultant" },
|
||||
chapter: "CGI",
|
||||
assignments: [],
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db);
|
||||
|
||||
const result = await executeTool(
|
||||
"get_staffing_suggestions",
|
||||
JSON.stringify({ projectId: "project_1", limit: 5 }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
suggestions: Array<{ name: string; availableHours: number }>;
|
||||
};
|
||||
|
||||
expect(parsed.suggestions).toHaveLength(1);
|
||||
expect(parsed.suggestions[0]).toEqual(
|
||||
expect.objectContaining({ name: "Hamburg", availableHours: 8 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("finds capacity with local holidays respected", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_by",
|
||||
displayName: "Bavaria",
|
||||
eid: "BY-1",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
areaRole: { name: "Consultant" },
|
||||
chapter: "CGI",
|
||||
assignments: [],
|
||||
},
|
||||
{
|
||||
id: "res_hh",
|
||||
displayName: "Hamburg",
|
||||
eid: "HH-1",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "HH",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
areaRole: { name: "Consultant" },
|
||||
chapter: "CGI",
|
||||
assignments: [],
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db);
|
||||
|
||||
const result = await executeTool(
|
||||
"find_capacity",
|
||||
JSON.stringify({ startDate: "2026-01-06", endDate: "2026-01-06", minHoursPerDay: 1 }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
results: Array<{ name: string; availableHours: number; availableHoursPerDay: number }>;
|
||||
};
|
||||
|
||||
expect(parsed.results).toHaveLength(1);
|
||||
expect(parsed.results[0]).toEqual(
|
||||
expect.objectContaining({ name: "Hamburg", availableHours: 8, availableHoursPerDay: 8 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses holiday-aware assignment hours for assistant shoring ratio", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: "project_1",
|
||||
name: "Holiday Project",
|
||||
shortCode: "HP",
|
||||
shoringThreshold: 55,
|
||||
onshoreCountryCode: "DE",
|
||||
}),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
resourceId: "res_by",
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
resource: {
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
resourceId: "res_in",
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
resource: {
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_in",
|
||||
federalState: null,
|
||||
metroCityId: null,
|
||||
country: { code: "IN" },
|
||||
metroCity: null,
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db);
|
||||
|
||||
const result = await executeTool(
|
||||
"get_shoring_ratio",
|
||||
JSON.stringify({ projectId: "project_1" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.content).toContain("0% onshore (DE), 100% offshore");
|
||||
expect(result.content).toContain("IN 100% (1 people)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
||||
return {
|
||||
...actual,
|
||||
isChargeabilityActualBooking: actual.isChargeabilityActualBooking,
|
||||
listAssignmentBookings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { listAssignmentBookings } from "@capakraken/application";
|
||||
import { checkChargeabilityAlerts } from "../lib/chargeability-alerts.js";
|
||||
|
||||
describe("chargeability alerts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-15T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("creates an alert when a regional holiday reduces booked hours below threshold", async () => {
|
||||
const notifications: Array<{ userId: string; title: string; body?: string }> = [];
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_1",
|
||||
displayName: "Bruce Banner",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
metroCityId: null,
|
||||
federalState: "BY",
|
||||
chargeabilityTarget: 21,
|
||||
country: {
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
},
|
||||
managementLevelGroup: { targetPercentage: 0.21 },
|
||||
metroCity: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
notification: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockImplementation(async ({ data }) => {
|
||||
notifications.push(data);
|
||||
return { id: `notification_${notifications.length}`, userId: data.userId };
|
||||
}),
|
||||
},
|
||||
user: {
|
||||
findMany: vi.fn().mockResolvedValue([{ id: "manager_1" }]),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "assignment_1",
|
||||
projectId: "project_1",
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: {
|
||||
id: "project_1",
|
||||
name: "Gamma",
|
||||
shortCode: "GAM",
|
||||
status: "ACTIVE",
|
||||
orderType: "CLIENT",
|
||||
dynamicFields: null,
|
||||
},
|
||||
resource: { id: "res_1", displayName: "Bruce Banner", chapter: "CGI" },
|
||||
},
|
||||
]);
|
||||
|
||||
const alertCount = await checkChargeabilityAlerts(db);
|
||||
|
||||
expect(alertCount).toBe(1);
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0]?.title).toContain("Bruce Banner");
|
||||
expect(notifications[0]?.body).toContain("gap: 16pp");
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,10 @@ describe("chargeability report router", () => {
|
||||
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",
|
||||
@@ -143,6 +147,10 @@ describe("chargeability report router", () => {
|
||||
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",
|
||||
@@ -204,4 +212,217 @@ describe("chargeability report router", () => {
|
||||
expect(withProposed.resources[0]?.months[0]?.chg).toBeGreaterThan(0);
|
||||
expect(withProposed.groupTotals[0]?.chg).toBeGreaterThan(strict.groupTotals[0]?.chg ?? 0);
|
||||
});
|
||||
|
||||
it("reduces SAH for German public holidays based on the calendar", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "resource_de",
|
||||
eid: "E-001",
|
||||
displayName: "Alice",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: null,
|
||||
metroCityId: "city_1",
|
||||
chargeabilityTarget: 80,
|
||||
country: {
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
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: "Munich" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
project: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "project_full_month", utilizationCategory: { code: "Chg" } },
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "assignment_full_month",
|
||||
projectId: "project_full_month",
|
||||
resourceId: "resource_de",
|
||||
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-31T00:00:00.000Z"),
|
||||
hoursPerDay: 7,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: {
|
||||
id: "project_full_month",
|
||||
name: "Full Month Project",
|
||||
shortCode: "FMP",
|
||||
status: "ACTIVE",
|
||||
orderType: "CLIENT",
|
||||
dynamicFields: null,
|
||||
},
|
||||
resource: { id: "resource_de", displayName: "Alice", chapter: "CGI" },
|
||||
},
|
||||
]);
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const report = await caller.getReport({
|
||||
startMonth: "2026-01",
|
||||
endMonth: "2026-01",
|
||||
});
|
||||
|
||||
const month = report.resources[0]?.months[0];
|
||||
|
||||
expect(month).toBeDefined();
|
||||
expect(month?.sah).toBe(168);
|
||||
expect(month?.chg).toBeCloseTo(0.875, 5);
|
||||
});
|
||||
|
||||
it("applies city-specific public holidays to SAH", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "resource_augsburg",
|
||||
eid: "E-001",
|
||||
displayName: "Alice",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_1",
|
||||
chargeabilityTarget: 80,
|
||||
country: {
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
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: "Augsburg" },
|
||||
},
|
||||
{
|
||||
id: "resource_munich",
|
||||
eid: "E-002",
|
||||
displayName: "Bob",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_2",
|
||||
chargeabilityTarget: 80,
|
||||
country: {
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
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_2", name: "Munich" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
project: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([]);
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const report = await caller.getReport({
|
||||
startMonth: "2028-08",
|
||||
endMonth: "2028-08",
|
||||
});
|
||||
|
||||
const augsburg = report.resources.find((resource) => resource.city === "Augsburg");
|
||||
const munich = report.resources.find((resource) => resource.city === "Munich");
|
||||
|
||||
expect(augsburg?.months[0]?.sah).toBe((munich?.months[0]?.sah ?? 0) - 8);
|
||||
});
|
||||
|
||||
it("respects individual weekday availability when computing booked hours", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "resource_pt",
|
||||
eid: "E-003",
|
||||
displayName: "Carla",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 0 },
|
||||
countryId: "country_de",
|
||||
federalState: null,
|
||||
metroCityId: "city_3",
|
||||
chargeabilityTarget: 80,
|
||||
country: {
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
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_3", name: "Berlin" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
project: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "project_week", utilizationCategory: { code: "Chg" } },
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "assignment_week",
|
||||
projectId: "project_week",
|
||||
resourceId: "resource_pt",
|
||||
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_week",
|
||||
name: "Week Project",
|
||||
shortCode: "WP",
|
||||
status: "ACTIVE",
|
||||
orderType: "CLIENT",
|
||||
dynamicFields: null,
|
||||
},
|
||||
resource: { id: "resource_pt", displayName: "Carla", chapter: "CGI" },
|
||||
},
|
||||
]);
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const report = await caller.getReport({
|
||||
startMonth: "2026-03",
|
||||
endMonth: "2026-03",
|
||||
});
|
||||
|
||||
const month = report.resources[0]?.months[0];
|
||||
|
||||
expect(month).toBeDefined();
|
||||
expect(month?.chg).toBeCloseTo(16 / 144, 5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { computationGraphRouter } from "../router/computation-graph.js";
|
||||
import { createCallerFactory } from "../trpc.js";
|
||||
|
||||
const createCaller = createCallerFactory(computationGraphRouter);
|
||||
|
||||
type ResourceGraphMeta = {
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
resolvedHolidays: Array<{
|
||||
date: string;
|
||||
name: string;
|
||||
scope: "COUNTRY" | "STATE" | "CITY";
|
||||
calendarName: string | null;
|
||||
}>;
|
||||
factors: {
|
||||
baseAvailableHours: number;
|
||||
effectiveAvailableHours: number;
|
||||
publicHolidayCount: number;
|
||||
publicHolidayWorkdayCount: number;
|
||||
publicHolidayHoursDeduction: number;
|
||||
absenceDayCount: number;
|
||||
absenceHoursDeduction: number;
|
||||
};
|
||||
};
|
||||
|
||||
function createControllerCaller(db: Record<string, unknown>) {
|
||||
return createCaller({
|
||||
session: {
|
||||
user: { email: "controller@example.com", name: "Controller", image: null },
|
||||
expires: "2026-03-14T00:00:00.000Z",
|
||||
},
|
||||
db: db as never,
|
||||
dbUser: {
|
||||
id: "user_controller",
|
||||
systemRole: SystemRole.CONTROLLER,
|
||||
permissionOverrides: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createDb(resourceFindImpl: ReturnType<typeof vi.fn>) {
|
||||
return {
|
||||
resource: {
|
||||
findUniqueOrThrow: resourceFindImpl,
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
calculationRule: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildResource(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "resource_1",
|
||||
displayName: "Bruce Banner",
|
||||
eid: "bruce.banner",
|
||||
fte: 1,
|
||||
lcrCents: 5_000,
|
||||
chargeabilityTarget: 80,
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
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: null,
|
||||
managementLevelGroup: {
|
||||
id: "mlg_1",
|
||||
name: "Senior",
|
||||
targetPercentage: 0.8,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("computation graph router", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("exposes location context and city-local holidays in the resource graph", async () => {
|
||||
const db = createDb(vi.fn().mockResolvedValue(buildResource({
|
||||
id: "resource_augsburg",
|
||||
metroCityId: "city_augsburg",
|
||||
metroCity: { id: "city_augsburg", name: "Augsburg" },
|
||||
})));
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.getResourceData({
|
||||
resourceId: "resource_augsburg",
|
||||
month: "2026-08",
|
||||
});
|
||||
const meta = result.meta as ResourceGraphMeta;
|
||||
const nodeIds = result.nodes.map((node) => node.id);
|
||||
const holidayExamples = result.nodes.find((node) => node.id === "input.holidayExamples");
|
||||
|
||||
expect(new Set(nodeIds).size).toBe(nodeIds.length);
|
||||
expect(nodeIds).toEqual(expect.arrayContaining([
|
||||
"input.country",
|
||||
"input.state",
|
||||
"input.city",
|
||||
"input.holidayContext",
|
||||
"input.holidayExamples",
|
||||
"sah.baseHours",
|
||||
"sah.publicHolidayHours",
|
||||
"sah.absenceHours",
|
||||
]));
|
||||
expect(meta).toMatchObject({
|
||||
countryCode: "DE",
|
||||
countryName: "Deutschland",
|
||||
federalState: "BY",
|
||||
metroCityName: "Augsburg",
|
||||
});
|
||||
expect(meta.resolvedHolidays).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
date: "2026-08-08",
|
||||
name: "Augsburger Friedensfest",
|
||||
scope: "CITY",
|
||||
}),
|
||||
]));
|
||||
expect(meta.factors.publicHolidayCount).toBeGreaterThan(0);
|
||||
expect(meta.factors.publicHolidayWorkdayCount).toBe(0);
|
||||
expect(holidayExamples?.value).toEqual(expect.stringContaining("Augsburger Friedensfest"));
|
||||
});
|
||||
|
||||
it("derives different effective SAH values for Bavaria and Hamburg", async () => {
|
||||
const db = createDb(vi.fn()
|
||||
.mockResolvedValueOnce(buildResource({
|
||||
id: "resource_by",
|
||||
federalState: "BY",
|
||||
managementLevelGroup: null,
|
||||
}))
|
||||
.mockResolvedValueOnce(buildResource({
|
||||
id: "resource_hh",
|
||||
federalState: "HH",
|
||||
managementLevelGroup: null,
|
||||
})));
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const bavaria = await caller.getResourceData({
|
||||
resourceId: "resource_by",
|
||||
month: "2026-01",
|
||||
});
|
||||
const hamburg = await caller.getResourceData({
|
||||
resourceId: "resource_hh",
|
||||
month: "2026-01",
|
||||
});
|
||||
|
||||
const bavariaMeta = bavaria.meta as ResourceGraphMeta;
|
||||
const hamburgMeta = hamburg.meta as ResourceGraphMeta;
|
||||
|
||||
expect(bavariaMeta.federalState).toBe("BY");
|
||||
expect(hamburgMeta.federalState).toBe("HH");
|
||||
expect(bavariaMeta.factors.baseAvailableHours).toBe(176);
|
||||
expect(hamburgMeta.factors.baseAvailableHours).toBe(176);
|
||||
expect(bavariaMeta.factors.effectiveAvailableHours).toBe(160);
|
||||
expect(hamburgMeta.factors.effectiveAvailableHours).toBe(168);
|
||||
expect(bavariaMeta.factors.publicHolidayWorkdayCount).toBe(2);
|
||||
expect(hamburgMeta.factors.publicHolidayWorkdayCount).toBe(1);
|
||||
expect(bavariaMeta.factors.publicHolidayHoursDeduction).toBe(16);
|
||||
expect(hamburgMeta.factors.publicHolidayHoursDeduction).toBe(8);
|
||||
expect(bavariaMeta.resolvedHolidays).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06", scope: "STATE" }),
|
||||
]));
|
||||
expect(hamburgMeta.resolvedHolidays).not.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
getDashboardDemand: vi.fn(),
|
||||
getDashboardTopValueResources: vi.fn(),
|
||||
getDashboardChargeabilityOverview: vi.fn(),
|
||||
getDashboardBudgetForecast: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
getDashboardDemand,
|
||||
getDashboardTopValueResources,
|
||||
getDashboardChargeabilityOverview,
|
||||
getDashboardBudgetForecast,
|
||||
} from "@capakraken/application";
|
||||
import { dashboardRouter } from "../router/dashboard.js";
|
||||
import { createCallerFactory } from "../trpc.js";
|
||||
@@ -302,4 +304,52 @@ describe("dashboard router", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBudgetForecast", () => {
|
||||
it("returns budget forecast rows with calendar location context", async () => {
|
||||
vi.mocked(getDashboardBudgetForecast).mockResolvedValue([
|
||||
{
|
||||
projectId: "project_1",
|
||||
projectName: "Alpha",
|
||||
shortCode: "ALPHA",
|
||||
clientId: "client_1",
|
||||
clientName: "Client One",
|
||||
budgetCents: 100_000,
|
||||
spentCents: 40_000,
|
||||
remainingCents: 60_000,
|
||||
burnRate: 10_000,
|
||||
estimatedExhaustionDate: "2026-06-30",
|
||||
pctUsed: 40,
|
||||
activeAssignmentCount: 2,
|
||||
calendarLocations: [
|
||||
{
|
||||
countryCode: "DE",
|
||||
countryName: "Germany",
|
||||
federalState: "BY",
|
||||
metroCityName: "Munich",
|
||||
activeAssignmentCount: 2,
|
||||
burnRateCents: 10_000,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const caller = createProtectedCaller({});
|
||||
const result = await caller.getBudgetForecast();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
projectName: "Alpha",
|
||||
activeAssignmentCount: 2,
|
||||
calendarLocations: [
|
||||
expect.objectContaining({
|
||||
countryCode: "DE",
|
||||
federalState: "BY",
|
||||
metroCityName: "Munich",
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(getDashboardBudgetForecast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,6 +150,7 @@ describe("effortRule.create", () => {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -180,6 +181,7 @@ describe("effortRule.create", () => {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -212,6 +214,7 @@ describe("effortRule.update", () => {
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -236,6 +239,7 @@ describe("effortRule.update", () => {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 2 }),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -281,6 +285,7 @@ describe("effortRule.delete", () => {
|
||||
findUnique: vi.fn().mockResolvedValue(existing),
|
||||
delete: vi.fn().mockResolvedValue(existing),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
@@ -16,7 +16,17 @@ const createCaller = createCallerFactory(entitlementRouter);
|
||||
/** Injects a default resource ownership mock so the ownership check in getBalance passes */
|
||||
function createProtectedCaller(db: Record<string, unknown>) {
|
||||
const withResourceOwnership = {
|
||||
resource: { findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }) },
|
||||
resource: {
|
||||
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
|
||||
const select = args?.select ?? {};
|
||||
return {
|
||||
...(select.userId ? { userId: "user_1" } : {}),
|
||||
...(select.federalState ? { federalState: "BY" } : {}),
|
||||
...(select.country ? { country: { code: "DE" } } : {}),
|
||||
...(select.metroCity ? { metroCity: null } : {}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
...db,
|
||||
};
|
||||
return createCaller({
|
||||
@@ -80,6 +90,14 @@ function sampleEntitlement(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function mockEntitlementFindUniqueByYear(
|
||||
entitlementsByYear: Record<number, ReturnType<typeof sampleEntitlement> | null>,
|
||||
) {
|
||||
return vi.fn().mockImplementation(async ({ where }: { where: { resourceId_year: { year: number } } }) => (
|
||||
entitlementsByYear[where.resourceId_year.year] ?? null
|
||||
));
|
||||
}
|
||||
|
||||
// ─── getBalance ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("entitlement.getBalance", () => {
|
||||
@@ -90,7 +108,7 @@ describe("entitlement.getBalance", () => {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: vi.fn().mockResolvedValue(entitlement),
|
||||
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||
update: vi.fn().mockResolvedValue(entitlement),
|
||||
},
|
||||
vacation: {
|
||||
@@ -129,10 +147,9 @@ describe("entitlement.getBalance", () => {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null) // current year not found
|
||||
.mockResolvedValueOnce(prevEntitlement), // previous year found
|
||||
findUnique: mockEntitlementFindUniqueByYear({
|
||||
2025: prevEntitlement,
|
||||
}),
|
||||
create: vi.fn().mockResolvedValue(createdEntitlement),
|
||||
update: vi.fn().mockResolvedValue(createdEntitlement),
|
||||
},
|
||||
@@ -164,7 +181,7 @@ describe("entitlement.getBalance", () => {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: vi.fn().mockResolvedValue(entitlement),
|
||||
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||
update: vi.fn().mockResolvedValue(entitlement),
|
||||
},
|
||||
vacation: {
|
||||
@@ -185,12 +202,14 @@ describe("entitlement.getBalance", () => {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: vi.fn().mockResolvedValue(entitlement),
|
||||
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||
update: vi.fn().mockResolvedValue(entitlement),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
// Public holiday vacations for holiday context
|
||||
.mockResolvedValueOnce([])
|
||||
// First call: balance-type vacations (for syncEntitlement)
|
||||
.mockResolvedValueOnce([])
|
||||
// Second call: sick days
|
||||
@@ -209,19 +228,169 @@ describe("entitlement.getBalance", () => {
|
||||
|
||||
expect(result.sickDays).toBe(3);
|
||||
});
|
||||
|
||||
it("does not deduct city-specific public holidays from leave balance", async () => {
|
||||
const entitlement = sampleEntitlement({ usedDays: 0, pendingDays: 0, entitledDays: 30, carryoverDays: 0 });
|
||||
const db = {
|
||||
systemSettings: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
userId: "user_1",
|
||||
federalState: "BY",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Augsburg" },
|
||||
}),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: mockEntitlementFindUniqueByYear({ 2028: entitlement }),
|
||||
update: vi.fn().mockImplementation(async ({ data }) => ({
|
||||
...entitlement,
|
||||
...data,
|
||||
})),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
startDate: new Date("2028-08-08T00:00:00.000Z"),
|
||||
endDate: new Date("2028-08-08T00:00:00.000Z"),
|
||||
status: "APPROVED",
|
||||
isHalfDay: false,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.getBalance({ resourceId: "res_1", year: 2028 });
|
||||
|
||||
expect(result.usedDays).toBe(0);
|
||||
expect(result.remainingDays).toBe(30);
|
||||
});
|
||||
|
||||
it("recomputes carryover from the previous year when the next year already exists", async () => {
|
||||
const entitlements = new Map([
|
||||
[2025, sampleEntitlement({
|
||||
id: "ent_2025",
|
||||
year: 2025,
|
||||
entitledDays: 28,
|
||||
carryoverDays: 0,
|
||||
usedDays: 8,
|
||||
pendingDays: 0,
|
||||
})],
|
||||
[2026, sampleEntitlement({
|
||||
id: "ent_2026",
|
||||
year: 2026,
|
||||
entitledDays: 28,
|
||||
carryoverDays: 0,
|
||||
usedDays: 0,
|
||||
pendingDays: 0,
|
||||
})],
|
||||
]);
|
||||
const db = {
|
||||
systemSettings: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
userId: "user_1",
|
||||
federalState: "BY",
|
||||
countryId: "country_de",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
}),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: vi.fn().mockImplementation(async ({ where }: { where: { resourceId_year: { year: number } } }) => (
|
||||
entitlements.get(where.resourceId_year.year) ?? null
|
||||
)),
|
||||
create: vi.fn(),
|
||||
update: vi.fn().mockImplementation(async ({ where, data }: {
|
||||
where: { id: string };
|
||||
data: Record<string, number>;
|
||||
}) => {
|
||||
const current = [...entitlements.values()].find((entry) => entry.id === where.id);
|
||||
if (!current) {
|
||||
throw new Error(`Unknown entitlement ${where.id}`);
|
||||
}
|
||||
const updated = { ...current, ...data };
|
||||
entitlements.set(updated.year, updated);
|
||||
return updated;
|
||||
}),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
// 2025 holiday context
|
||||
.mockResolvedValueOnce([])
|
||||
// 2025 balance vacations
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
startDate: new Date("2025-06-10T00:00:00.000Z"),
|
||||
endDate: new Date("2025-06-17T00:00:00.000Z"),
|
||||
status: "APPROVED",
|
||||
isHalfDay: false,
|
||||
},
|
||||
])
|
||||
// 2026 holiday context
|
||||
.mockResolvedValueOnce([])
|
||||
// 2026 balance vacations
|
||||
.mockResolvedValueOnce([])
|
||||
// 2026 sick days
|
||||
.mockResolvedValueOnce([]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
|
||||
|
||||
expect(result.carryoverDays).toBe(20);
|
||||
expect(result.entitledDays).toBe(48);
|
||||
expect(db.vacationEntitlement.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "ent_2026" },
|
||||
data: expect.objectContaining({
|
||||
carryoverDays: 20,
|
||||
entitledDays: 48,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── get ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("entitlement.get", () => {
|
||||
it("returns existing entitlement (manager role)", async () => {
|
||||
const entitlement = sampleEntitlement();
|
||||
const entitlement = sampleEntitlement({
|
||||
entitledDays: 30,
|
||||
carryoverDays: 0,
|
||||
usedDays: 0,
|
||||
pendingDays: 0,
|
||||
});
|
||||
const db = {
|
||||
systemSettings: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 30 }),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: vi.fn().mockResolvedValue(entitlement),
|
||||
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||
update: vi.fn().mockImplementation(async ({ data }: { data: Record<string, number> }) => ({
|
||||
...entitlement,
|
||||
...data,
|
||||
})),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -259,6 +428,7 @@ describe("entitlement.set", () => {
|
||||
update: vi.fn().mockResolvedValue(updated),
|
||||
create: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -286,6 +456,7 @@ describe("entitlement.set", () => {
|
||||
update: vi.fn(),
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -324,6 +495,7 @@ describe("entitlement.bulkSet", () => {
|
||||
vacationEntitlement: {
|
||||
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createAdminCaller(db);
|
||||
@@ -350,6 +522,7 @@ describe("entitlement.bulkSet", () => {
|
||||
vacationEntitlement: {
|
||||
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createAdminCaller(db);
|
||||
@@ -396,10 +569,15 @@ describe("entitlement.getYearSummary", () => {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
federalState: "BY",
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
}),
|
||||
findMany: vi.fn().mockResolvedValue(resources),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: vi.fn().mockResolvedValue(entitlement),
|
||||
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||
update: vi.fn().mockResolvedValue(entitlement),
|
||||
},
|
||||
vacation: {
|
||||
|
||||
@@ -24,10 +24,12 @@ vi.mock("ioredis", () => {
|
||||
describe("event-bus debounce", () => {
|
||||
let received: SseEvent[];
|
||||
let unsubscribe: () => void;
|
||||
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
received = [];
|
||||
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
unsubscribe = eventBus.subscribe((event) => {
|
||||
received.push(event);
|
||||
});
|
||||
@@ -36,6 +38,7 @@ describe("event-bus debounce", () => {
|
||||
afterEach(() => {
|
||||
unsubscribe();
|
||||
cancelPendingEvents();
|
||||
consoleWarnSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
|
||||
@@ -174,6 +174,7 @@ describe("experienceMultiplier.create", () => {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -203,6 +204,7 @@ describe("experienceMultiplier.create", () => {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -235,6 +237,7 @@ describe("experienceMultiplier.update", () => {
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -259,6 +262,7 @@ describe("experienceMultiplier.update", () => {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 2 }),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -308,6 +312,7 @@ describe("experienceMultiplier.delete", () => {
|
||||
findUnique: vi.fn().mockResolvedValue(existing),
|
||||
delete: vi.fn().mockResolvedValue(existing),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createCallerFactory } from "../trpc.js";
|
||||
import { holidayCalendarRouter } from "../router/holiday-calendar.js";
|
||||
|
||||
vi.mock("../lib/audit.js", () => ({
|
||||
createAuditEntry: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const createCaller = createCallerFactory(holidayCalendarRouter);
|
||||
|
||||
function createProtectedCaller(db: Record<string, unknown>) {
|
||||
return createCaller({
|
||||
session: {
|
||||
user: { email: "user@example.com", name: "User", image: null },
|
||||
expires: "2026-12-31T00:00:00.000Z",
|
||||
},
|
||||
db: db as never,
|
||||
dbUser: {
|
||||
id: "user_1",
|
||||
systemRole: SystemRole.USER,
|
||||
permissionOverrides: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createAdminCaller(db: Record<string, unknown>) {
|
||||
return createCaller({
|
||||
session: {
|
||||
user: { email: "admin@example.com", name: "Admin", image: null },
|
||||
expires: "2026-12-31T00:00:00.000Z",
|
||||
},
|
||||
db: db as never,
|
||||
dbUser: {
|
||||
id: "admin_1",
|
||||
systemRole: SystemRole.ADMIN,
|
||||
permissionOverrides: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("holiday calendar router", () => {
|
||||
it("merges built-in and scoped custom holidays in preview", async () => {
|
||||
const db = {
|
||||
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: 10,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
entries: [
|
||||
{
|
||||
date: new Date("2020-01-01T00:00:00.000Z"),
|
||||
name: "Augsburg Neujahr",
|
||||
isRecurringAnnual: true,
|
||||
},
|
||||
{
|
||||
date: new Date("2020-08-08T00:00:00.000Z"),
|
||||
name: "Friedensfest lokal",
|
||||
isRecurringAnnual: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.previewResolvedHolidays({
|
||||
countryId: "country_de",
|
||||
metroCityId: "city_augsburg",
|
||||
year: 2026,
|
||||
});
|
||||
|
||||
expect(db.holidayCalendar.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
countryId: "country_de",
|
||||
isActive: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
date: "2026-01-01",
|
||||
name: "Augsburg Neujahr",
|
||||
scopeType: "CITY",
|
||||
calendarName: "Augsburg lokal",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
date: "2026-08-08",
|
||||
name: "Friedensfest lokal",
|
||||
scopeType: "CITY",
|
||||
calendarName: "Augsburg lokal",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects duplicate calendar scopes on create", async () => {
|
||||
const db = {
|
||||
country: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: "country_de", name: "Deutschland" })
|
||||
.mockResolvedValueOnce({ id: "country_de", name: "Deutschland" }),
|
||||
},
|
||||
metroCity: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "existing_scope" }),
|
||||
create: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createAdminCaller(db);
|
||||
|
||||
await expect(caller.createCalendar({
|
||||
name: "Deutschland Standard",
|
||||
scopeType: "COUNTRY",
|
||||
countryId: "country_de",
|
||||
priority: 0,
|
||||
isActive: true,
|
||||
})).rejects.toThrow("A holiday calendar for this exact scope already exists");
|
||||
|
||||
expect(db.holidayCalendar.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects duplicate entry dates within the same calendar", async () => {
|
||||
const db = {
|
||||
holidayCalendar: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "cal_1", name: "Deutschland Standard" }),
|
||||
},
|
||||
holidayCalendarEntry: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "entry_existing" }),
|
||||
create: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createAdminCaller(db);
|
||||
|
||||
await expect(caller.createEntry({
|
||||
holidayCalendarId: "cal_1",
|
||||
date: new Date("2026-12-24T00:00:00.000Z"),
|
||||
name: "Heiligabend lokal",
|
||||
isRecurringAnnual: true,
|
||||
source: "manual",
|
||||
})).rejects.toThrow("A holiday entry for this calendar and date already exists");
|
||||
|
||||
expect(db.holidayCalendarEntry.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -187,6 +187,7 @@ describe("notification.create", () => {
|
||||
{
|
||||
notification: {
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
findUnique: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
},
|
||||
"user_mgr",
|
||||
@@ -209,6 +210,7 @@ describe("notification.create", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(db.notification.findUnique).toHaveBeenCalledWith({ where: { id: "notif_1" } });
|
||||
});
|
||||
|
||||
it("creates a notification with optional fields", async () => {
|
||||
@@ -222,6 +224,7 @@ describe("notification.create", () => {
|
||||
{
|
||||
notification: {
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
findUnique: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
},
|
||||
"user_mgr",
|
||||
|
||||
@@ -134,12 +134,14 @@ describe("project router", () => {
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
webhook: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.create({
|
||||
shortCode: "PRJ-001",
|
||||
name: "Test Project",
|
||||
responsiblePerson: "Alice",
|
||||
orderType: OrderType.CHARGEABLE,
|
||||
allocationType: AllocationType.INT,
|
||||
winProbability: 80,
|
||||
@@ -167,6 +169,7 @@ describe("project router", () => {
|
||||
caller.create({
|
||||
shortCode: "PRJ-001",
|
||||
name: "Duplicate",
|
||||
responsiblePerson: "Alice",
|
||||
orderType: OrderType.CHARGEABLE,
|
||||
allocationType: AllocationType.INT,
|
||||
budgetCents: 100_00,
|
||||
@@ -189,6 +192,7 @@ describe("project router", () => {
|
||||
caller.create({
|
||||
shortCode: "PRJ-002",
|
||||
name: "Blocked",
|
||||
responsiblePerson: "Alice",
|
||||
orderType: OrderType.CHARGEABLE,
|
||||
allocationType: AllocationType.INT,
|
||||
budgetCents: 100_00,
|
||||
@@ -239,6 +243,64 @@ describe("project router", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getShoringRatio", () => {
|
||||
it("excludes regional holidays from shoring weighting", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "project_1",
|
||||
name: "Test Project",
|
||||
shoringThreshold: 55,
|
||||
onshoreCountryCode: "DE",
|
||||
}),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "a1",
|
||||
resourceId: "res_de",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
resource: {
|
||||
id: "res_de",
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
|
||||
country: { id: "country_de", code: "DE" },
|
||||
metroCity: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "a2",
|
||||
resourceId: "res_es",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
resource: {
|
||||
id: "res_es",
|
||||
countryId: "country_es",
|
||||
federalState: null,
|
||||
metroCityId: null,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
|
||||
country: { id: "country_es", code: "ES" },
|
||||
metroCity: null,
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.getShoringRatio({ projectId: "project_1" });
|
||||
|
||||
expect(result.totalHours).toBe(24);
|
||||
expect(result.onshoreRatio).toBe(33);
|
||||
expect(result.offshoreRatio).toBe(67);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── update ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("update", () => {
|
||||
@@ -294,6 +356,7 @@ describe("project router", () => {
|
||||
project: {
|
||||
update: vi.fn().mockResolvedValue(updated),
|
||||
},
|
||||
webhook: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@capakraken/application", () => ({
|
||||
isChargeabilityActualBooking: vi.fn(() => false),
|
||||
isChargeabilityRelevantProject: vi.fn(() => false),
|
||||
listAssignmentBookings: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/resource-capacity.js", () => ({
|
||||
calculateEffectiveAvailableHours: vi.fn(({ context }: { context?: unknown }) => (context ? 156 : 168)),
|
||||
calculateEffectiveBookedHours: vi.fn(() => 0),
|
||||
countEffectiveWorkingDays: vi.fn(({ context }: { context?: unknown }) => (context ? 19.5 : 21)),
|
||||
getAvailabilityHoursForDate: vi.fn(() => 8),
|
||||
loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map([
|
||||
[
|
||||
"res_1",
|
||||
{
|
||||
holidayDates: new Set(["2026-04-10"]),
|
||||
vacationFractionsByDate: new Map([["2026-04-14", 0.5]]),
|
||||
},
|
||||
],
|
||||
])),
|
||||
}));
|
||||
|
||||
import { reportRouter } from "../router/report.js";
|
||||
import { createCallerFactory } from "../trpc.js";
|
||||
|
||||
const createCaller = createCallerFactory(reportRouter);
|
||||
|
||||
function createControllerCaller(db: Record<string, unknown>) {
|
||||
return createCaller({
|
||||
session: {
|
||||
user: { email: "controller@example.com", name: "Controller", image: null },
|
||||
expires: "2099-01-01T00:00:00.000Z",
|
||||
},
|
||||
db: db as never,
|
||||
dbUser: {
|
||||
id: "user_controller",
|
||||
systemRole: SystemRole.CONTROLLER,
|
||||
permissionOverrides: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("report router", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("lists the new resource month transparency columns", async () => {
|
||||
const caller = createControllerCaller({});
|
||||
const columns = await caller.getAvailableColumns({ entity: "resource_month" });
|
||||
|
||||
expect(columns).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ key: "monthlyPublicHolidayCount", label: "Holiday Dates" }),
|
||||
expect.objectContaining({ key: "monthlyTargetHours", label: "Target Hours" }),
|
||||
expect.objectContaining({ key: "monthlyUnassignedHours", label: "Unassigned Hours" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("exports resource month basis and computed columns in CSV", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_1",
|
||||
eid: "alice",
|
||||
displayName: "Alice",
|
||||
email: "alice@example.com",
|
||||
chapter: "VFX",
|
||||
resourceType: "EMPLOYEE",
|
||||
isActive: true,
|
||||
chgResponsibility: false,
|
||||
rolledOff: false,
|
||||
departed: false,
|
||||
lcrCents: 7500,
|
||||
ucrCents: 10000,
|
||||
currency: "EUR",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
chargeabilityTarget: 80,
|
||||
federalState: "BY",
|
||||
countryId: "country_de",
|
||||
metroCityId: null,
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: null,
|
||||
orgUnit: { name: "Delivery" },
|
||||
managementLevelGroup: null,
|
||||
managementLevel: { name: "Senior" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.exportReport({
|
||||
entity: "resource_month",
|
||||
columns: [
|
||||
"displayName",
|
||||
"countryCode",
|
||||
"monthlyPublicHolidayCount",
|
||||
"monthlyPublicHolidayHoursDeduction",
|
||||
"monthlyAbsenceHoursDeduction",
|
||||
"monthlySahHours",
|
||||
"monthlyTargetHours",
|
||||
"monthlyUnassignedHours",
|
||||
],
|
||||
filters: [],
|
||||
periodMonth: "2026-04",
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
expect(result.rowCount).toBe(1);
|
||||
expect(result.csv).toContain("Name,Country Code,Holiday Dates,Holiday Hours Deduction,Absence Hours Deduction,SAH,Target Hours,Unassigned Hours");
|
||||
expect(result.csv).toContain("Alice,DE,1,8,4,156,124.8,156");
|
||||
});
|
||||
});
|
||||
@@ -86,6 +86,10 @@ describe("resource router", () => {
|
||||
valueScoreBreakdown: null,
|
||||
valueScoreUpdatedAt: null,
|
||||
userId: null,
|
||||
countryId: "country_de",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
};
|
||||
const db = {
|
||||
resource: {
|
||||
@@ -158,6 +162,165 @@ describe("resource router", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("calculates utilization with regional holidays removed from available hours", async () => {
|
||||
const resource = {
|
||||
id: "resource_1",
|
||||
eid: "E-001",
|
||||
displayName: "Alice",
|
||||
email: "alice@example.com",
|
||||
chapter: "CGI",
|
||||
lcrCents: 5000,
|
||||
ucrCents: 9000,
|
||||
currency: "EUR",
|
||||
chargeabilityTarget: 80,
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
skills: [],
|
||||
dynamicFields: {},
|
||||
blueprintId: null,
|
||||
isActive: true,
|
||||
createdAt: new Date("2026-03-01"),
|
||||
updatedAt: new Date("2026-03-01"),
|
||||
roleId: null,
|
||||
portfolioUrl: null,
|
||||
postalCode: null,
|
||||
federalState: "BY",
|
||||
countryId: "country_de",
|
||||
metroCityId: null,
|
||||
valueScore: null,
|
||||
valueScoreBreakdown: null,
|
||||
valueScoreUpdatedAt: null,
|
||||
userId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
};
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([resource]),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "assignment_confirmed",
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: {
|
||||
id: "project_1",
|
||||
name: "Project 1",
|
||||
shortCode: "P1",
|
||||
status: "ACTIVE",
|
||||
orderType: "CLIENT",
|
||||
dynamicFields: null,
|
||||
},
|
||||
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
|
||||
},
|
||||
]);
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.listWithUtilization({
|
||||
startDate: "2026-01-05T00:00:00.000Z",
|
||||
endDate: "2026-01-06T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
bookingCount: 1,
|
||||
bookedHours: 8,
|
||||
availableHours: 8,
|
||||
utilizationPercent: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("shifts marketplace availability when a local holiday blocks today", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-06T10:00:00.000Z"));
|
||||
|
||||
try {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "resource_by",
|
||||
displayName: "Bavaria Artist",
|
||||
eid: "E-BY",
|
||||
chapter: "CGI",
|
||||
skills: [{ skill: "Houdini", proficiency: 5 }],
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
chargeabilityTarget: 80,
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
},
|
||||
{
|
||||
id: "resource_hh",
|
||||
displayName: "Hamburg Artist",
|
||||
eid: "E-HH",
|
||||
chapter: "CGI",
|
||||
skills: [{ skill: "Houdini", proficiency: 5 }],
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
chargeabilityTarget: 80,
|
||||
countryId: "country_de",
|
||||
federalState: "HH",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
demandRequirement: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.getSkillMarketplace({
|
||||
searchSkill: "houdini",
|
||||
availableOnly: true,
|
||||
});
|
||||
|
||||
const bavaria = result.searchResults.find((resource) => resource.id === "resource_by");
|
||||
const hamburg = result.searchResults.find((resource) => resource.id === "resource_hh");
|
||||
|
||||
expect(bavaria?.availableFrom).toBe("2026-01-07T00:00:00.000Z");
|
||||
expect(hamburg?.availableFrom).toBe("2026-01-06T00:00:00.000Z");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a composite displayName/id cursor for stable pagination", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
@@ -314,6 +477,84 @@ describe("resource router", () => {
|
||||
expect(withProposed[0]?.expectedChargeability).toBe(strict[0]?.expectedChargeability);
|
||||
});
|
||||
|
||||
it("excludes regional public holidays from chargeability stats", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "resource_by",
|
||||
eid: "E-BY",
|
||||
displayName: "Bavaria",
|
||||
chapter: "CGI",
|
||||
chargeabilityTarget: 80,
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_munich",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Munich" },
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "assignment_holiday",
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_by",
|
||||
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: {
|
||||
id: "project_1",
|
||||
name: "Project 1",
|
||||
shortCode: "P1",
|
||||
status: "ACTIVE",
|
||||
orderType: "CLIENT",
|
||||
dynamicFields: null,
|
||||
},
|
||||
resource: { id: "resource_by", displayName: "Bavaria", chapter: "CGI" },
|
||||
},
|
||||
]);
|
||||
|
||||
const RealDate = Date;
|
||||
class MockDate extends Date {
|
||||
constructor(...args: ConstructorParameters<typeof Date>) {
|
||||
if (args.length === 0) {
|
||||
super("2026-01-15T00:00:00.000Z");
|
||||
return;
|
||||
}
|
||||
super(...args);
|
||||
}
|
||||
static now() {
|
||||
return new RealDate("2026-01-15T00:00:00.000Z").getTime();
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("Date", MockDate);
|
||||
|
||||
try {
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.getChargeabilityStats({});
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
actualChargeability: 0,
|
||||
expectedChargeability: 0,
|
||||
availableHours: 168,
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies country filters including explicit no-country toggle", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
|
||||
@@ -17,23 +17,6 @@ vi.mock("@capakraken/staffing", () => ({
|
||||
},
|
||||
})),
|
||||
),
|
||||
analyzeUtilization: vi.fn().mockReturnValue({
|
||||
resourceId: "res_1",
|
||||
displayName: "Alice",
|
||||
totalDays: 20,
|
||||
allocatedDays: 15,
|
||||
utilizationPercent: 75,
|
||||
chargeablePercent: 60,
|
||||
overallocatedDays: 0,
|
||||
dailyBreakdown: [],
|
||||
}),
|
||||
findCapacityWindows: vi.fn().mockReturnValue([
|
||||
{
|
||||
startDate: new Date("2026-04-01"),
|
||||
endDate: new Date("2026-04-10"),
|
||||
availableHoursPerDay: 6,
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
vi.mock("@capakraken/application", () => ({
|
||||
@@ -76,6 +59,11 @@ function sampleResource(overrides: Record<string, unknown> = {}) {
|
||||
isActive: true,
|
||||
valueScore: 85,
|
||||
chapter: "VFX",
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -105,6 +93,30 @@ describe("staffing.getSuggestions", () => {
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toHaveProperty("resourceId");
|
||||
expect(result[0]).toHaveProperty("score");
|
||||
expect(result[0]).toMatchObject({
|
||||
resourceName: "Alice",
|
||||
eid: "alice",
|
||||
location: {
|
||||
countryCode: "DE",
|
||||
federalState: "BY",
|
||||
},
|
||||
capacity: expect.objectContaining({
|
||||
requestedHoursPerDay: 8,
|
||||
baseAvailableHours: expect.any(Number),
|
||||
effectiveAvailableHours: expect.any(Number),
|
||||
remainingHoursPerDay: expect.any(Number),
|
||||
holidayHoursDeduction: expect.any(Number),
|
||||
}),
|
||||
conflicts: {
|
||||
count: expect.any(Number),
|
||||
conflictDays: expect.any(Array),
|
||||
details: expect.any(Array),
|
||||
},
|
||||
ranking: expect.objectContaining({
|
||||
rank: 1,
|
||||
components: expect.any(Array),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("filters resources by chapter when provided", async () => {
|
||||
@@ -175,6 +187,58 @@ describe("staffing.getSuggestions", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses value score as a transparent tiebreaker within two score points", async () => {
|
||||
const resources = [
|
||||
sampleResource({ id: "res_1", displayName: "Alice", eid: "alice", valueScore: 60 }),
|
||||
sampleResource({ id: "res_2", displayName: "Bob", eid: "bob", valueScore: 95 }),
|
||||
];
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue(resources),
|
||||
},
|
||||
};
|
||||
|
||||
const { rankResources } = await import("@capakraken/staffing");
|
||||
vi.mocked(rankResources).mockImplementationOnce((input: { resources: Array<{ id: string }> }) => ([
|
||||
{
|
||||
resourceId: input.resources[0]!.id,
|
||||
score: 80,
|
||||
breakdown: {
|
||||
skillScore: 80,
|
||||
availabilityScore: 80,
|
||||
costScore: 80,
|
||||
utilizationScore: 80,
|
||||
},
|
||||
},
|
||||
{
|
||||
resourceId: input.resources[1]!.id,
|
||||
score: 79,
|
||||
breakdown: {
|
||||
skillScore: 79,
|
||||
availabilityScore: 79,
|
||||
costScore: 79,
|
||||
utilizationScore: 79,
|
||||
},
|
||||
},
|
||||
]));
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.getSuggestions({
|
||||
requiredSkills: ["Compositing"],
|
||||
startDate: new Date("2026-04-01"),
|
||||
endDate: new Date("2026-04-30"),
|
||||
hoursPerDay: 8,
|
||||
});
|
||||
|
||||
expect(result[0]?.resourceId).toBe("res_2");
|
||||
expect(result[0]?.ranking).toMatchObject({
|
||||
rank: 1,
|
||||
baseRank: 2,
|
||||
tieBreakerApplied: true,
|
||||
});
|
||||
expect(result[0]?.ranking.tieBreakerReason).toContain("value score");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── analyzeUtilization ──────────────────────────────────────────────────────
|
||||
@@ -186,6 +250,11 @@ describe("staffing.analyzeUtilization", () => {
|
||||
displayName: "Alice",
|
||||
chargeabilityTarget: 80,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
};
|
||||
const db = {
|
||||
resource: {
|
||||
@@ -200,10 +269,56 @@ describe("staffing.analyzeUtilization", () => {
|
||||
endDate: new Date("2026-04-30"),
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty("utilizationPercent");
|
||||
expect(result).toHaveProperty("currentChargeability");
|
||||
expect(result.resourceId).toBe("res_1");
|
||||
});
|
||||
|
||||
it("excludes Bavarian public holidays from chargeability analysis", async () => {
|
||||
const resource = {
|
||||
id: "res_1",
|
||||
displayName: "Alice",
|
||||
chargeabilityTarget: 80,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
};
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(resource),
|
||||
},
|
||||
};
|
||||
|
||||
const { listAssignmentBookings } = await import("@capakraken/application");
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "a1",
|
||||
projectId: "project_1",
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: { id: "project_1", name: "Chargeable", shortCode: "CHG", status: "ACTIVE", orderType: "CHARGEABLE", clientId: null, dynamicFields: null },
|
||||
resource: { id: "res_1", displayName: "Alice", chapter: "VFX" },
|
||||
},
|
||||
]);
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.analyzeUtilization({
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.currentChargeability).toBe(100);
|
||||
expect(result.overallocatedDays).toEqual([]);
|
||||
expect(result.underutilizedDays).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when resource does not exist", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
@@ -230,6 +345,11 @@ describe("staffing.findCapacity", () => {
|
||||
id: "res_1",
|
||||
displayName: "Alice",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
};
|
||||
const db = {
|
||||
resource: {
|
||||
@@ -244,8 +364,53 @@ describe("staffing.findCapacity", () => {
|
||||
endDate: new Date("2026-04-30"),
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result[0]).toHaveProperty("availableHoursPerDay");
|
||||
expect(result.every((window) => window.availableHoursPerDay > 0)).toBe(true);
|
||||
expect(result.reduce((sum, window) => sum + window.availableDays, 0)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("splits capacity windows around Bavarian public holidays", async () => {
|
||||
const resource = {
|
||||
id: "res_1",
|
||||
displayName: "Alice",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
};
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(resource),
|
||||
},
|
||||
};
|
||||
|
||||
const { listAssignmentBookings } = await import("@capakraken/application");
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([]);
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.findCapacity({
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-07T00:00:00.000Z"),
|
||||
minAvailableHoursPerDay: 4,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
}),
|
||||
);
|
||||
expect(result[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
startDate: new Date("2026-01-07T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-07T00:00:00.000Z"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when resource does not exist", async () => {
|
||||
@@ -265,11 +430,16 @@ describe("staffing.findCapacity", () => {
|
||||
).rejects.toThrow("Resource not found");
|
||||
});
|
||||
|
||||
it("passes minAvailableHoursPerDay to engine", async () => {
|
||||
it("honors minAvailableHoursPerDay when computing holiday-aware windows", async () => {
|
||||
const resource = {
|
||||
id: "res_1",
|
||||
displayName: "Alice",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
};
|
||||
const db = {
|
||||
resource: {
|
||||
@@ -277,21 +447,30 @@ describe("staffing.findCapacity", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const { findCapacityWindows } = await import("@capakraken/staffing");
|
||||
const { listAssignmentBookings } = await import("@capakraken/application");
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "a1",
|
||||
projectId: "project_1",
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-04-30T00:00:00.000Z"),
|
||||
hoursPerDay: 3,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: { id: "project_1", name: "Project", shortCode: "PRJ", status: "ACTIVE", orderType: "CHARGEABLE", clientId: null, dynamicFields: null },
|
||||
resource: { id: "res_1", displayName: "Alice", chapter: "VFX" },
|
||||
},
|
||||
]);
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await caller.findCapacity({
|
||||
const result = await caller.findCapacity({
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-04-01"),
|
||||
endDate: new Date("2026-04-30"),
|
||||
minAvailableHoursPerDay: 6,
|
||||
});
|
||||
|
||||
expect(findCapacityWindows).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.any(Date),
|
||||
expect.any(Date),
|
||||
6,
|
||||
);
|
||||
expect(result.every((window) => window.availableHoursPerDay >= 6)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,4 +290,83 @@ describe("timeline allocation entry resolution", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns resolved holiday overlays for assigned resources", async () => {
|
||||
const db = {
|
||||
demandRequirement: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "assignment_1",
|
||||
kind: "assignment",
|
||||
resourceId: "resource_by",
|
||||
projectId: "project_1",
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-01-31"),
|
||||
hoursPerDay: 8,
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
metadata: {},
|
||||
project: {
|
||||
id: "project_1",
|
||||
name: "Project One",
|
||||
shortCode: "PRJ",
|
||||
status: "ACTIVE",
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-03-31"),
|
||||
orderType: "CHARGEABLE",
|
||||
clientId: null,
|
||||
},
|
||||
resource: {
|
||||
id: "resource_by",
|
||||
displayName: "Alice",
|
||||
eid: "E-001",
|
||||
chapter: null,
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "resource_by",
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
project: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
country: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
metroCity: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const overlays = await caller.getHolidayOverlays({
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-01-31"),
|
||||
});
|
||||
|
||||
expect(overlays).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
resourceId: "resource_by",
|
||||
type: "PUBLIC_HOLIDAY",
|
||||
note: "Heilige Drei Könige",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,12 +9,30 @@ vi.mock("../sse/event-bus.js", () => ({
|
||||
emitVacationUpdated: vi.fn(),
|
||||
emitVacationDeleted: vi.fn(),
|
||||
emitNotificationCreated: vi.fn(),
|
||||
emitTaskAssigned: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/email.js", () => ({
|
||||
sendEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/create-notification.js", () => ({
|
||||
createNotification: vi.fn().mockResolvedValue("notif_1"),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/vacation-conflicts.js", () => ({
|
||||
checkVacationConflicts: vi.fn().mockResolvedValue({ warnings: [] }),
|
||||
checkBatchVacationConflicts: vi.fn().mockResolvedValue(new Map()),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/webhook-dispatcher.js", () => ({
|
||||
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/audit.js", () => ({
|
||||
createAuditEntry: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const createCaller = createCallerFactory(vacationRouter);
|
||||
|
||||
function createProtectedCaller(db: Record<string, unknown>) {
|
||||
@@ -91,6 +109,56 @@ const sampleVacation = {
|
||||
approvedBy: null,
|
||||
};
|
||||
|
||||
function createVacationDb(overrides: Record<string, unknown> = {}) {
|
||||
const db = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
findMany: vi.fn().mockResolvedValue([{ id: "mgr_1" }, { id: "admin_1" }]),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
|
||||
const select = args?.select ?? {};
|
||||
return {
|
||||
...(select.userId ? { userId: "user_1" } : {}),
|
||||
...(select.displayName ? { displayName: "Alice" } : {}),
|
||||
...(select.user ? { user: null } : {}),
|
||||
...(select.federalState ? { federalState: "BY" } : {}),
|
||||
...(select.country ? { country: { code: "DE", name: "Germany" } } : {}),
|
||||
...(select.metroCity ? { metroCity: null } : {}),
|
||||
};
|
||||
}),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn().mockResolvedValue(sampleVacation),
|
||||
update: vi.fn().mockResolvedValue(sampleVacation),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
notification: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...db,
|
||||
...overrides,
|
||||
user: { ...db.user, ...(overrides.user as Record<string, unknown> | undefined) },
|
||||
resource: { ...db.resource, ...(overrides.resource as Record<string, unknown> | undefined) },
|
||||
vacation: { ...db.vacation, ...(overrides.vacation as Record<string, unknown> | undefined) },
|
||||
notification: {
|
||||
...db.notification,
|
||||
...(overrides.notification as Record<string, unknown> | undefined),
|
||||
},
|
||||
auditLog: { ...db.auditLog, ...(overrides.auditLog as Record<string, unknown> | undefined) },
|
||||
};
|
||||
}
|
||||
|
||||
describe("vacation router", () => {
|
||||
describe("list", () => {
|
||||
it("returns vacations with default filters", async () => {
|
||||
@@ -199,18 +267,11 @@ describe("vacation router", () => {
|
||||
status: VacationStatus.PENDING,
|
||||
};
|
||||
|
||||
const db = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
|
||||
},
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue(createdVacation),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.create({
|
||||
@@ -239,15 +300,14 @@ describe("vacation router", () => {
|
||||
approvedById: "mgr_1",
|
||||
};
|
||||
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
|
||||
},
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue(createdVacation),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.create({
|
||||
@@ -269,17 +329,11 @@ describe("vacation router", () => {
|
||||
});
|
||||
|
||||
it("rejects overlapping vacation", async () => {
|
||||
const db = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
|
||||
},
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "existing_vac" }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(
|
||||
@@ -293,10 +347,10 @@ describe("vacation router", () => {
|
||||
});
|
||||
|
||||
it("rejects when end date is before start date", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
user: { findUnique: vi.fn() },
|
||||
vacation: { findFirst: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(
|
||||
@@ -316,18 +370,11 @@ describe("vacation router", () => {
|
||||
halfDayPart: "MORNING",
|
||||
};
|
||||
|
||||
const db = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
|
||||
},
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue(createdVacation),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.create({
|
||||
@@ -349,6 +396,235 @@ describe("vacation router", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects multi-day half-day vacations", async () => {
|
||||
const db = createVacationDb();
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.create({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-06-01"),
|
||||
endDate: new Date("2026-06-02"),
|
||||
isHalfDay: true,
|
||||
halfDayPart: "MORNING",
|
||||
})).rejects.toThrow();
|
||||
|
||||
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects half-day vacations without a half-day part", async () => {
|
||||
const db = createVacationDb();
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.create({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-06-01"),
|
||||
endDate: new Date("2026-06-01"),
|
||||
isHalfDay: true,
|
||||
})).rejects.toThrow();
|
||||
|
||||
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects half-day parts on full-day vacations", async () => {
|
||||
const db = createVacationDb();
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.create({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-06-01"),
|
||||
endDate: new Date("2026-06-01"),
|
||||
halfDayPart: "AFTERNOON",
|
||||
})).rejects.toThrow();
|
||||
|
||||
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects leave requests that only hit public holidays", async () => {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.create({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
})).rejects.toThrow("does not deduct any vacation days");
|
||||
|
||||
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewRequest", () => {
|
||||
it("shows public holidays as non-deductible leave days", async () => {
|
||||
const db = createVacationDb({
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
userId: "user_1",
|
||||
federalState: "BY",
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Augsburg" },
|
||||
}),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.previewRequest({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2028-08-08T00:00:00.000Z"),
|
||||
endDate: new Date("2028-08-08T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.requestedDays).toBe(1);
|
||||
expect(result.effectiveDays).toBe(0);
|
||||
expect(result.deductedDays).toBe(0);
|
||||
expect(result.publicHolidayDates).toContain("2028-08-08");
|
||||
expect(result.holidayContext).toEqual({
|
||||
countryCode: "DE",
|
||||
countryName: "Germany",
|
||||
federalState: "BY",
|
||||
metroCityName: "Augsburg",
|
||||
sources: {
|
||||
hasCalendarHolidays: true,
|
||||
hasLegacyPublicHolidayEntries: false,
|
||||
},
|
||||
});
|
||||
expect(result.holidayDetails).toContainEqual({
|
||||
date: "2028-08-08",
|
||||
source: "CALENDAR",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses custom city holiday calendars for non-deductible leave days", async () => {
|
||||
const db = createVacationDb({
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
userId: "user_1",
|
||||
countryId: "country_de",
|
||||
metroCityId: "city_muc",
|
||||
federalState: "BY",
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Muenchen" },
|
||||
}),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "cal_muc",
|
||||
name: "Muenchen lokal",
|
||||
scopeType: "CITY",
|
||||
priority: 10,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
entries: [
|
||||
{
|
||||
date: new Date("2020-11-15T00:00:00.000Z"),
|
||||
name: "Lokaler Stadtfeiertag",
|
||||
isRecurringAnnual: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.previewRequest({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-11-15T00:00:00.000Z"),
|
||||
endDate: new Date("2026-11-15T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.requestedDays).toBe(1);
|
||||
expect(result.effectiveDays).toBe(0);
|
||||
expect(result.publicHolidayDates).toContain("2026-11-15");
|
||||
expect(result.holidayContext.countryName).toBe("Germany");
|
||||
expect(result.holidayContext.metroCityName).toBe("Muenchen");
|
||||
expect(db.holidayCalendar.findMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks legacy public holiday entries as a separate preview source", async () => {
|
||||
const db = createVacationDb({
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
userId: "user_1",
|
||||
federalState: "HH",
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Hamburg" },
|
||||
}),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
startDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.previewRequest({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.publicHolidayDates).toContain("2026-05-01");
|
||||
expect(result.holidayContext.sources).toEqual({
|
||||
hasCalendarHolidays: true,
|
||||
hasLegacyPublicHolidayEntries: true,
|
||||
});
|
||||
expect(result.holidayDetails).toContainEqual({
|
||||
date: "2026-05-01",
|
||||
source: "CALENDAR_AND_LEGACY",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects multi-day half-day previews", async () => {
|
||||
const db = createVacationDb();
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.previewRequest({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-06-01"),
|
||||
endDate: new Date("2026-06-02"),
|
||||
isHalfDay: true,
|
||||
})).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("create manual public holiday handling", () => {
|
||||
it("rejects manual public holiday creation requests", async () => {
|
||||
const db = createVacationDb();
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
await expect(caller.create({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.PUBLIC_HOLIDAY,
|
||||
startDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
})).rejects.toThrow("Public holidays must be managed via Holiday Calendars or the legacy holiday import");
|
||||
|
||||
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("approve", () => {
|
||||
@@ -359,7 +635,7 @@ describe("vacation router", () => {
|
||||
approvedById: "mgr_1",
|
||||
};
|
||||
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
||||
update: vi.fn().mockResolvedValue(updatedVacation),
|
||||
@@ -370,7 +646,7 @@ describe("vacation router", () => {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.approve({ id: "vac_1" });
|
||||
@@ -388,25 +664,25 @@ describe("vacation router", () => {
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND for missing vacation", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
await expect(caller.approve({ id: "missing" })).rejects.toThrow("Vacation not found");
|
||||
});
|
||||
|
||||
it("rejects approving an already APPROVED vacation", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
...sampleVacation,
|
||||
status: VacationStatus.APPROVED,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow(
|
||||
@@ -429,7 +705,7 @@ describe("vacation router", () => {
|
||||
rejectionReason: "Team conflict",
|
||||
};
|
||||
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
||||
update: vi.fn().mockResolvedValue(updatedVacation),
|
||||
@@ -437,7 +713,7 @@ describe("vacation router", () => {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.reject({ id: "vac_1", rejectionReason: "Team conflict" });
|
||||
@@ -454,14 +730,14 @@ describe("vacation router", () => {
|
||||
});
|
||||
|
||||
it("throws when rejecting non-PENDING vacation", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
...sampleVacation,
|
||||
status: VacationStatus.APPROVED,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
await expect(caller.reject({ id: "vac_1" })).rejects.toThrow(
|
||||
@@ -477,15 +753,12 @@ describe("vacation router", () => {
|
||||
status: VacationStatus.CANCELLED,
|
||||
};
|
||||
|
||||
const db = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
},
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
||||
update: vi.fn().mockResolvedValue(updatedVacation),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.cancel({ id: "vac_1" });
|
||||
@@ -494,25 +767,25 @@ describe("vacation router", () => {
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND for missing vacation", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(caller.cancel({ id: "missing" })).rejects.toThrow("Vacation not found");
|
||||
});
|
||||
|
||||
it("throws when already cancelled", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
...sampleVacation,
|
||||
status: VacationStatus.CANCELLED,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(caller.cancel({ id: "vac_1" })).rejects.toThrow("Already cancelled");
|
||||
@@ -521,7 +794,7 @@ describe("vacation router", () => {
|
||||
|
||||
describe("batchApprove", () => {
|
||||
it("approves multiple pending vacations", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
|
||||
},
|
||||
@@ -535,7 +808,7 @@ describe("vacation router", () => {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.batchApprove({ ids: ["vac_1", "vac_2"] });
|
||||
@@ -552,7 +825,7 @@ describe("vacation router", () => {
|
||||
});
|
||||
|
||||
it("only approves PENDING vacations from the requested set", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
|
||||
},
|
||||
@@ -565,7 +838,7 @@ describe("vacation router", () => {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.batchApprove({ ids: ["vac_1", "vac_already_approved"] });
|
||||
@@ -581,7 +854,10 @@ describe("vacation router", () => {
|
||||
|
||||
describe("batchReject", () => {
|
||||
it("rejects multiple pending vacations with optional reason", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "vac_1", resourceId: "res_1" },
|
||||
@@ -591,7 +867,7 @@ describe("vacation router", () => {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.batchReject({
|
||||
@@ -731,8 +1007,8 @@ describe("vacation router", () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "res_1" },
|
||||
{ id: "res_2" },
|
||||
{ id: "res_1", federalState: "BY", country: { code: "DE" }, metroCity: null },
|
||||
{ id: "res_2", federalState: "BY", country: { code: "DE" }, metroCity: null },
|
||||
]),
|
||||
},
|
||||
user: {
|
||||
@@ -759,7 +1035,9 @@ describe("vacation router", () => {
|
||||
it("skips already existing holidays", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([{ id: "res_1" }]),
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "res_1", federalState: "BY", country: { code: "DE" }, metroCity: null },
|
||||
]),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "admin_1" }),
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { listAssignmentBookings } from "@capakraken/application";
|
||||
import { rankResources } from "@capakraken/staffing";
|
||||
import type { SkillEntry } from "@capakraken/shared";
|
||||
import type { SkillEntry, WeekdayAvailability } from "@capakraken/shared";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "./resource-capacity.js";
|
||||
import { createNotificationsForUsers } from "./create-notification.js";
|
||||
|
||||
/**
|
||||
@@ -58,6 +63,11 @@ type DbClient = Parameters<typeof listAssignmentBookings>[0] & {
|
||||
chargeabilityTarget: number;
|
||||
availability: unknown;
|
||||
valueScore: number | null;
|
||||
countryId: string | null;
|
||||
federalState: string | null;
|
||||
metroCityId: string | null;
|
||||
country: { code: string | null } | null;
|
||||
metroCity: { name: string | null } | null;
|
||||
}>>;
|
||||
};
|
||||
notification: {
|
||||
@@ -154,27 +164,54 @@ export async function generateAutoSuggestions(
|
||||
endDate: demand.endDate,
|
||||
resourceIds: resources.map((r) => r.id),
|
||||
});
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
db as Parameters<typeof loadResourceDailyAvailabilityContexts>[0],
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
demand.startDate,
|
||||
demand.endDate,
|
||||
);
|
||||
|
||||
// 5. Enrich resources with utilization data for the demand's date range
|
||||
const enrichedResources = resources.map((resource) => {
|
||||
const avail = resource.availability as
|
||||
| { monday?: number; tuesday?: number; wednesday?: number; thursday?: number; friday?: number }
|
||||
| null;
|
||||
const totalAvailableHours = avail?.monday ?? 8;
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(resource.id);
|
||||
const resourceBookings = bookings.filter((b) => b.resourceId === resource.id);
|
||||
|
||||
const allocatedHoursPerDay = resourceBookings.reduce(
|
||||
(sum, b) => sum + b.hoursPerDay,
|
||||
const totalAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: demand.startDate,
|
||||
periodEnd: demand.endDate,
|
||||
context,
|
||||
});
|
||||
const allocatedHours = resourceBookings.reduce(
|
||||
(sum, booking) =>
|
||||
sum + calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart: demand.startDate,
|
||||
periodEnd: demand.endDate,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
const utilizationPercent =
|
||||
totalAvailableHours > 0
|
||||
? Math.min(100, (allocatedHoursPerDay / totalAvailableHours) * 100)
|
||||
? Math.min(100, (allocatedHours / totalAvailableHours) * 100)
|
||||
: 0;
|
||||
|
||||
const wouldExceedCapacity =
|
||||
allocatedHoursPerDay + demand.hoursPerDay > totalAvailableHours;
|
||||
const wouldExceedCapacity = totalAvailableHours > 0
|
||||
? allocatedHours + demand.hoursPerDay > totalAvailableHours
|
||||
: demand.hoursPerDay > 0;
|
||||
|
||||
return {
|
||||
id: resource.id,
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import {
|
||||
deriveResourceForecast,
|
||||
getMonthRange,
|
||||
countWorkingDaysInOverlap,
|
||||
calculateSAH,
|
||||
type AssignmentSlice,
|
||||
} from "@capakraken/engine";
|
||||
import type { SpainScheduleRule } from "@capakraken/shared";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
|
||||
import { VacationStatus } from "@capakraken/db";
|
||||
import { createNotificationsForUsers } from "./create-notification.js";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "./resource-capacity.js";
|
||||
|
||||
/**
|
||||
* Minimal DB client type for chargeability alerts.
|
||||
@@ -24,23 +26,19 @@ type DbClient = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
fte: number;
|
||||
availability: unknown;
|
||||
countryId: string | null;
|
||||
metroCityId: string | null;
|
||||
federalState: string | null;
|
||||
chargeabilityTarget: number;
|
||||
country: { dailyWorkingHours: number | null; scheduleRules: unknown } | null;
|
||||
country: {
|
||||
id?: string | null;
|
||||
code: string | null;
|
||||
dailyWorkingHours: number | null;
|
||||
scheduleRules: unknown;
|
||||
} | null;
|
||||
managementLevelGroup: { targetPercentage: number | null } | null;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
vacation: {
|
||||
findMany: (args: {
|
||||
where: Record<string, unknown>;
|
||||
select: Record<string, unknown>;
|
||||
}) => Promise<
|
||||
Array<{
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
type: string;
|
||||
isHalfDay: boolean;
|
||||
metroCity: { id?: string | null; name: string | null } | null;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
@@ -105,9 +103,14 @@ export async function checkChargeabilityAlerts(
|
||||
id: true,
|
||||
displayName: true,
|
||||
fte: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
metroCityId: true,
|
||||
federalState: true,
|
||||
chargeabilityTarget: true,
|
||||
country: { select: { dailyWorkingHours: true, scheduleRules: true } },
|
||||
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
|
||||
managementLevelGroup: { select: { targetPercentage: true } },
|
||||
metroCity: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -121,56 +124,32 @@ export async function checkChargeabilityAlerts(
|
||||
endDate: monthEnd,
|
||||
resourceIds,
|
||||
});
|
||||
|
||||
// Fetch vacations for the current month
|
||||
const vacations = await (db as DbClient).vacation.findMany({
|
||||
where: {
|
||||
resourceId: { in: resourceIds },
|
||||
status: VacationStatus.APPROVED,
|
||||
startDate: { lte: monthEnd },
|
||||
endDate: { gte: monthStart },
|
||||
},
|
||||
select: {
|
||||
resourceId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
type: true,
|
||||
isHalfDay: true,
|
||||
},
|
||||
});
|
||||
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
|
||||
db as Parameters<typeof loadResourceDailyAvailabilityContexts>[0],
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
monthStart,
|
||||
monthEnd,
|
||||
);
|
||||
|
||||
// Compute chargeability per resource
|
||||
const underperformers: Array<{ resource: typeof resources[0]; chg: number; target: number; gap: number }> = [];
|
||||
|
||||
for (const resource of resources) {
|
||||
const dailyHours = resource.country?.dailyWorkingHours ?? 8;
|
||||
|
||||
// Compute absence dates for SAH
|
||||
const resourceVacations = vacations.filter((v) => v.resourceId === resource.id);
|
||||
const absenceDates: string[] = [];
|
||||
for (const v of resourceVacations) {
|
||||
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
|
||||
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
|
||||
if (vStart > vEnd) continue;
|
||||
const cursor = new Date(vStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const endNorm = new Date(vEnd);
|
||||
endNorm.setUTCHours(0, 0, 0, 0);
|
||||
while (cursor <= endNorm) {
|
||||
absenceDates.push(cursor.toISOString().slice(0, 10));
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleRules = (resource.country?.scheduleRules ?? null) as SpainScheduleRule | null;
|
||||
const sahResult = calculateSAH({
|
||||
dailyWorkingHours: dailyHours,
|
||||
scheduleRules,
|
||||
fte: resource.fte,
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const context = availabilityContexts.get(resource.id);
|
||||
const availableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
publicHolidays: [],
|
||||
absenceDays: absenceDates,
|
||||
context,
|
||||
});
|
||||
|
||||
// Build assignment slices
|
||||
@@ -178,12 +157,24 @@ export async function checkChargeabilityAlerts(
|
||||
(b) => b.resourceId === resource.id && isChargeabilityActualBooking(b, false),
|
||||
);
|
||||
|
||||
const slices: AssignmentSlice[] = resourceBookings.map((b) => {
|
||||
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, b.startDate, b.endDate);
|
||||
const slices: AssignmentSlice[] = resourceBookings.flatMap((b) => {
|
||||
const totalChargeableHours = calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: b.startDate,
|
||||
endDate: b.endDate,
|
||||
hoursPerDay: b.hoursPerDay,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
context,
|
||||
});
|
||||
if (totalChargeableHours <= 0) {
|
||||
return [];
|
||||
}
|
||||
return {
|
||||
hoursPerDay: b.hoursPerDay,
|
||||
workingDays,
|
||||
workingDays: 0,
|
||||
categoryCode: "Chg", // simplified — treat all actual bookings as chargeable
|
||||
totalChargeableHours,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -194,7 +185,7 @@ export async function checkChargeabilityAlerts(
|
||||
fte: resource.fte,
|
||||
targetPercentage: targetPct,
|
||||
assignments: slices,
|
||||
sah: sahResult.standardAvailableHours,
|
||||
sah: availableHours,
|
||||
});
|
||||
|
||||
const chgPct = forecast.chg * 100;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* Duplicate-safe: skips holidays that already exist (by date + type + resourceId).
|
||||
*/
|
||||
|
||||
import { getPublicHolidays } from "@capakraken/shared";
|
||||
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "./holiday-availability.js";
|
||||
|
||||
interface MinimalVacation {
|
||||
resourceId: string;
|
||||
@@ -19,14 +19,20 @@ interface MinimalVacation {
|
||||
|
||||
interface AutoImportDb {
|
||||
resource: {
|
||||
findMany: (args: {
|
||||
where: { isActive: boolean };
|
||||
select: { id: string; federalState: string };
|
||||
}) => Promise<Array<{ id: string; federalState: string | null }>>;
|
||||
findMany: (args: any) => any;
|
||||
};
|
||||
country?: {
|
||||
findUnique: (args: any) => any;
|
||||
};
|
||||
metroCity?: {
|
||||
findUnique: (args: any) => any;
|
||||
};
|
||||
holidayCalendar?: {
|
||||
findMany: (args: any) => any;
|
||||
};
|
||||
vacation: {
|
||||
findMany: (args: unknown) => Promise<MinimalVacation[]>;
|
||||
createMany: (args: { data: unknown[]; skipDuplicates?: boolean }) => Promise<{ count: number }>;
|
||||
findMany: (args: any) => any;
|
||||
createMany: (args: any) => any;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,34 +48,60 @@ export interface AutoImportResult {
|
||||
* Returns the number of holiday vacation records created.
|
||||
*/
|
||||
export async function autoImportPublicHolidays(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
db: any,
|
||||
db: AutoImportDb,
|
||||
year: number,
|
||||
): Promise<AutoImportResult> {
|
||||
const resources: Array<{ id: string; federalState: string | null }> = await db.resource.findMany({
|
||||
const resources = await db.resource.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true, federalState: true },
|
||||
select: {
|
||||
id: true,
|
||||
federalState: true,
|
||||
countryId: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (resources.length === 0) {
|
||||
return { year, holidaysCreated: 0, resourcesProcessed: 0, skippedExisting: 0 };
|
||||
}
|
||||
|
||||
// Group resources by federal state (null = federal-only holidays)
|
||||
const byState = new Map<string | null, string[]>();
|
||||
const nextYearStart = new Date(`${year}-01-01T00:00:00.000Z`);
|
||||
const nextYearEnd = new Date(`${year}-12-31T00:00:00.000Z`);
|
||||
const byHolidayProfile = new Map<string, typeof resources>();
|
||||
|
||||
for (const resource of resources) {
|
||||
const state = resource.federalState ?? null;
|
||||
const group = byState.get(state) ?? [];
|
||||
group.push(resource.id);
|
||||
byState.set(state, group);
|
||||
const profileKey = JSON.stringify({
|
||||
countryCode: resource.country?.code ?? null,
|
||||
federalState: resource.federalState ?? null,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
});
|
||||
const group = byHolidayProfile.get(profileKey) ?? [];
|
||||
group.push(resource);
|
||||
byHolidayProfile.set(profileKey, group);
|
||||
}
|
||||
|
||||
let totalCreated = 0;
|
||||
let totalSkipped = 0;
|
||||
|
||||
for (const [state, resourceIds] of byState) {
|
||||
const holidays = getPublicHolidays(year, state ?? undefined);
|
||||
for (const [, groupedResources] of byHolidayProfile) {
|
||||
const sample = groupedResources[0];
|
||||
if (!sample) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), {
|
||||
periodStart: nextYearStart,
|
||||
periodEnd: nextYearEnd,
|
||||
countryId: sample.countryId,
|
||||
countryCode: sample.country?.code ?? null,
|
||||
federalState: sample.federalState,
|
||||
metroCityId: sample.metroCityId,
|
||||
metroCityName: sample.metroCity?.name ?? null,
|
||||
});
|
||||
if (holidays.length === 0) continue;
|
||||
const resourceIds = groupedResources.map((resource: { id: string }) => resource.id);
|
||||
|
||||
for (const holiday of holidays) {
|
||||
const holidayDate = new Date(holiday.date);
|
||||
@@ -86,13 +118,13 @@ export async function autoImportPublicHolidays(
|
||||
});
|
||||
|
||||
const existingResourceIds = new Set(existing.map((v: MinimalVacation) => v.resourceId));
|
||||
const newResourceIds = resourceIds.filter((id) => !existingResourceIds.has(id));
|
||||
const newResourceIds = resourceIds.filter((id: string) => !existingResourceIds.has(id));
|
||||
|
||||
totalSkipped += existingResourceIds.size;
|
||||
|
||||
if (newResourceIds.length === 0) continue;
|
||||
|
||||
const records = newResourceIds.map((resourceId) => ({
|
||||
const records = newResourceIds.map((resourceId: string) => ({
|
||||
resourceId,
|
||||
type: "PUBLIC_HOLIDAY",
|
||||
status: "APPROVED",
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
import { getPublicHolidays, type AbsenceDay } from "@capakraken/shared";
|
||||
|
||||
type VacationLike = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
type: string;
|
||||
isHalfDay: boolean;
|
||||
};
|
||||
|
||||
type HolidayAvailabilityInput = {
|
||||
vacations: VacationLike[];
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
countryCode?: string | null | undefined;
|
||||
federalState?: string | null | undefined;
|
||||
metroCityName?: string | null | undefined;
|
||||
resolvedHolidayStrings?: string[] | undefined;
|
||||
};
|
||||
|
||||
type HolidayAvailabilityResult = {
|
||||
absenceDateStrings: string[];
|
||||
publicHolidayStrings: string[];
|
||||
absenceDays: AbsenceDay[];
|
||||
};
|
||||
|
||||
export type CalendarHoliday = {
|
||||
date: string;
|
||||
name: string;
|
||||
scope: "COUNTRY" | "STATE" | "CITY";
|
||||
};
|
||||
|
||||
type CalendarScope = CalendarHoliday["scope"];
|
||||
|
||||
type HolidayCalendarEntryRecord = {
|
||||
date: Date;
|
||||
name: string;
|
||||
isRecurringAnnual: boolean;
|
||||
};
|
||||
|
||||
type HolidayCalendarRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
scopeType: CalendarScope;
|
||||
priority: number;
|
||||
createdAt?: Date;
|
||||
entries: HolidayCalendarEntryRecord[];
|
||||
};
|
||||
|
||||
type HolidayResolverDb = {
|
||||
[key: string]: unknown;
|
||||
country?: {
|
||||
findUnique: (args: any) => any;
|
||||
};
|
||||
metroCity?: {
|
||||
findUnique: (args: any) => any;
|
||||
};
|
||||
holidayCalendar?: {
|
||||
findMany: (args: any) => any;
|
||||
};
|
||||
};
|
||||
|
||||
type ResolvedHoliday = CalendarHoliday & {
|
||||
calendarName: string;
|
||||
priority: number;
|
||||
sourceType: "BUILTIN" | "CUSTOM";
|
||||
};
|
||||
|
||||
export function asHolidayResolverDb(db: unknown): HolidayResolverDb {
|
||||
return db as HolidayResolverDb;
|
||||
}
|
||||
|
||||
export function toIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
type CityHolidayRule = {
|
||||
countryCode: string;
|
||||
cityName: string;
|
||||
resolveDates: (year: number) => string[];
|
||||
};
|
||||
|
||||
const CITY_HOLIDAY_RULES: CityHolidayRule[] = [
|
||||
{
|
||||
countryCode: "DE",
|
||||
cityName: "Augsburg",
|
||||
resolveDates: (year) => [`${year}-08-08`],
|
||||
},
|
||||
];
|
||||
|
||||
const SCOPE_WEIGHT: Record<CalendarScope, number> = {
|
||||
COUNTRY: 1,
|
||||
STATE: 2,
|
||||
CITY: 3,
|
||||
};
|
||||
|
||||
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 resolveCalendarEntries(
|
||||
calendars: HolidayCalendarRecord[],
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): ResolvedHoliday[] {
|
||||
const startYear = periodStart.getUTCFullYear();
|
||||
const endYear = periodEnd.getUTCFullYear();
|
||||
const startIso = toIsoDate(periodStart);
|
||||
const endIso = toIsoDate(periodEnd);
|
||||
const resolved = new Map<string, ResolvedHoliday>();
|
||||
|
||||
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 key = toIsoDate(effectiveDate);
|
||||
|
||||
if (key < startIso || key > endIso) {
|
||||
if (!entry.isRecurringAnnual) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate: ResolvedHoliday = {
|
||||
date: key,
|
||||
name: entry.name,
|
||||
scope: calendar.scopeType,
|
||||
calendarName: calendar.name,
|
||||
priority: calendar.priority,
|
||||
sourceType: "CUSTOM",
|
||||
};
|
||||
const existing = resolved.get(key);
|
||||
|
||||
if (
|
||||
!existing
|
||||
|| SCOPE_WEIGHT[candidate.scope] > SCOPE_WEIGHT[existing.scope]
|
||||
|| (
|
||||
SCOPE_WEIGHT[candidate.scope] === SCOPE_WEIGHT[existing.scope]
|
||||
&& candidate.priority > existing.priority
|
||||
)
|
||||
|| (
|
||||
SCOPE_WEIGHT[candidate.scope] === SCOPE_WEIGHT[existing.scope]
|
||||
&& candidate.priority === existing.priority
|
||||
&& existing.sourceType === "BUILTIN"
|
||||
)
|
||||
) {
|
||||
resolved.set(key, candidate);
|
||||
}
|
||||
|
||||
if (!entry.isRecurringAnnual) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...resolved.values()].sort((left, right) => left.date.localeCompare(right.date));
|
||||
}
|
||||
|
||||
function mergeResolvedHolidays(
|
||||
builtInHolidays: CalendarHoliday[],
|
||||
customHolidays: ResolvedHoliday[],
|
||||
): ResolvedHoliday[] {
|
||||
const merged = new Map<string, ResolvedHoliday>();
|
||||
|
||||
for (const holiday of builtInHolidays) {
|
||||
merged.set(holiday.date, {
|
||||
...holiday,
|
||||
calendarName: "System",
|
||||
priority: Number.MIN_SAFE_INTEGER,
|
||||
sourceType: "BUILTIN",
|
||||
});
|
||||
}
|
||||
|
||||
for (const holiday of customHolidays) {
|
||||
const existing = merged.get(holiday.date);
|
||||
if (
|
||||
!existing
|
||||
|| SCOPE_WEIGHT[holiday.scope] > SCOPE_WEIGHT[existing.scope]
|
||||
|| (
|
||||
SCOPE_WEIGHT[holiday.scope] === SCOPE_WEIGHT[existing.scope]
|
||||
&& holiday.priority >= existing.priority
|
||||
)
|
||||
) {
|
||||
merged.set(holiday.date, holiday);
|
||||
}
|
||||
}
|
||||
|
||||
return [...merged.values()].sort((left, right) => left.date.localeCompare(right.date));
|
||||
}
|
||||
|
||||
async function loadScopedHolidayCalendars(
|
||||
db: HolidayResolverDb,
|
||||
input: {
|
||||
countryId?: string | null | undefined;
|
||||
stateCode?: string | null | undefined;
|
||||
metroCityId?: string | null | undefined;
|
||||
},
|
||||
): Promise<HolidayCalendarRecord[]> {
|
||||
if (!input.countryId || typeof db.holidayCalendar?.findMany !== "function") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stateCode = normalizeStateCode(input.stateCode);
|
||||
const metroCityId = input.metroCityId?.trim() || null;
|
||||
|
||||
return db.holidayCalendar.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
countryId: input.countryId,
|
||||
OR: [
|
||||
{ scopeType: "COUNTRY" },
|
||||
...(stateCode ? [{ scopeType: "STATE" as const, stateCode }] : []),
|
||||
...(metroCityId ? [{ scopeType: "CITY" as const, metroCityId }] : []),
|
||||
],
|
||||
},
|
||||
include: { entries: true },
|
||||
orderBy: [{ priority: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export function getCalendarHolidayStrings(
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
countryCode?: string | null,
|
||||
federalState?: string | null,
|
||||
metroCityName?: string | null,
|
||||
): string[] {
|
||||
return getCalendarHolidays(
|
||||
periodStart,
|
||||
periodEnd,
|
||||
countryCode,
|
||||
federalState,
|
||||
metroCityName,
|
||||
).map((holiday) => holiday.date);
|
||||
}
|
||||
|
||||
export function getCalendarHolidays(
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
countryCode?: string | null,
|
||||
federalState?: string | null,
|
||||
metroCityName?: string | null,
|
||||
): CalendarHoliday[] {
|
||||
const startYear = periodStart.getUTCFullYear();
|
||||
const endYear = periodEnd.getUTCFullYear();
|
||||
const holidays = new Map<string, CalendarHoliday>();
|
||||
|
||||
if (countryCode === "DE") {
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
for (const holiday of getPublicHolidays(year, federalState ?? undefined)) {
|
||||
if (holiday.date >= toIsoDate(periodStart) && holiday.date <= toIsoDate(periodEnd)) {
|
||||
holidays.set(holiday.date, {
|
||||
date: holiday.date,
|
||||
name: holiday.name,
|
||||
scope: holiday.federal ? "COUNTRY" : "STATE",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedCityName = normalizeCityName(metroCityName);
|
||||
if (countryCode && normalizedCityName) {
|
||||
for (const rule of CITY_HOLIDAY_RULES) {
|
||||
if (
|
||||
rule.countryCode === countryCode
|
||||
&& normalizeCityName(rule.cityName) === normalizedCityName
|
||||
) {
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
for (const holidayDate of rule.resolveDates(year)) {
|
||||
if (holidayDate >= toIsoDate(periodStart) && holidayDate <= toIsoDate(periodEnd)) {
|
||||
holidays.set(holidayDate, {
|
||||
date: holidayDate,
|
||||
name: "Augsburger Friedensfest",
|
||||
scope: "CITY",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...holidays.values()].sort((left, right) => left.date.localeCompare(right.date));
|
||||
}
|
||||
|
||||
export async function getResolvedCalendarHolidays(
|
||||
db: HolidayResolverDb,
|
||||
input: {
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
countryId?: string | null | undefined;
|
||||
countryCode?: string | null | undefined;
|
||||
federalState?: string | null | undefined;
|
||||
metroCityId?: string | null | undefined;
|
||||
metroCityName?: string | null | undefined;
|
||||
},
|
||||
): Promise<ResolvedHoliday[]> {
|
||||
let countryCode = input.countryCode ?? null;
|
||||
if (!countryCode && input.countryId && typeof db.country?.findUnique === "function") {
|
||||
const country = await db.country.findUnique({
|
||||
where: { id: input.countryId },
|
||||
select: { code: true },
|
||||
});
|
||||
countryCode = country?.code ?? null;
|
||||
}
|
||||
|
||||
let metroCityName = input.metroCityName ?? null;
|
||||
if (!metroCityName && input.metroCityId && typeof db.metroCity?.findUnique === "function") {
|
||||
const metroCity = await db.metroCity.findUnique({
|
||||
where: { id: input.metroCityId },
|
||||
select: { name: true },
|
||||
});
|
||||
metroCityName = metroCity?.name ?? null;
|
||||
}
|
||||
|
||||
const builtIn = getCalendarHolidays(
|
||||
input.periodStart,
|
||||
input.periodEnd,
|
||||
countryCode,
|
||||
input.federalState,
|
||||
metroCityName,
|
||||
);
|
||||
const calendars = await loadScopedHolidayCalendars(db, {
|
||||
countryId: input.countryId,
|
||||
stateCode: input.federalState,
|
||||
metroCityId: input.metroCityId,
|
||||
});
|
||||
const custom = resolveCalendarEntries(calendars, input.periodStart, input.periodEnd);
|
||||
|
||||
return mergeResolvedHolidays(builtIn, custom);
|
||||
}
|
||||
|
||||
export async function getResolvedCalendarHolidayStrings(
|
||||
db: HolidayResolverDb,
|
||||
input: {
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
countryId?: string | null | undefined;
|
||||
countryCode?: string | null | undefined;
|
||||
federalState?: string | null | undefined;
|
||||
metroCityId?: string | null | undefined;
|
||||
metroCityName?: string | null | undefined;
|
||||
},
|
||||
): Promise<string[]> {
|
||||
const holidays = await getResolvedCalendarHolidays(db, input);
|
||||
return holidays.map((holiday) => holiday.date);
|
||||
}
|
||||
|
||||
export function collectHolidayAvailability(
|
||||
input: HolidayAvailabilityInput,
|
||||
): HolidayAvailabilityResult {
|
||||
const periodStartIso = toIsoDate(input.periodStart);
|
||||
const periodEndIso = toIsoDate(input.periodEnd);
|
||||
const publicHolidaySet = new Set(
|
||||
input.resolvedHolidayStrings
|
||||
? input.resolvedHolidayStrings.filter((date) => date >= periodStartIso && date <= periodEndIso)
|
||||
: getCalendarHolidayStrings(
|
||||
input.periodStart,
|
||||
input.periodEnd,
|
||||
input.countryCode,
|
||||
input.federalState,
|
||||
input.metroCityName,
|
||||
),
|
||||
);
|
||||
const absenceDateSet = new Set<string>();
|
||||
const absenceDayMap = new Map<string, AbsenceDay>();
|
||||
|
||||
for (const isoDate of publicHolidaySet) {
|
||||
absenceDayMap.set(isoDate, {
|
||||
date: new Date(`${isoDate}T00:00:00.000Z`),
|
||||
type: "PUBLIC_HOLIDAY",
|
||||
});
|
||||
}
|
||||
|
||||
for (const vacation of input.vacations) {
|
||||
if (vacation.type !== "PUBLIC_HOLIDAY") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const overlapStart = new Date(
|
||||
Math.max(vacation.startDate.getTime(), input.periodStart.getTime()),
|
||||
);
|
||||
const overlapEnd = new Date(
|
||||
Math.min(vacation.endDate.getTime(), input.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);
|
||||
publicHolidaySet.add(isoDate);
|
||||
absenceDayMap.set(isoDate, {
|
||||
date: new Date(cursor),
|
||||
type: "PUBLIC_HOLIDAY",
|
||||
...(vacation.isHalfDay ? { isHalfDay: true } : {}),
|
||||
});
|
||||
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const vacation of input.vacations) {
|
||||
if (vacation.type === "PUBLIC_HOLIDAY") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const overlapStart = new Date(
|
||||
Math.max(vacation.startDate.getTime(), input.periodStart.getTime()),
|
||||
);
|
||||
const overlapEnd = new Date(
|
||||
Math.min(vacation.endDate.getTime(), input.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);
|
||||
|
||||
const triggerType = vacation.type === "SICK" ? "SICK" : "VACATION";
|
||||
|
||||
while (cursor <= end) {
|
||||
const isoDate = toIsoDate(cursor);
|
||||
if (!publicHolidaySet.has(isoDate)) {
|
||||
absenceDateSet.add(isoDate);
|
||||
absenceDayMap.set(isoDate, {
|
||||
date: new Date(cursor),
|
||||
type: triggerType,
|
||||
...(vacation.isHalfDay ? { isHalfDay: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
absenceDateStrings: [...absenceDateSet].sort(),
|
||||
publicHolidayStrings: [...publicHolidaySet].sort(),
|
||||
absenceDays: [...absenceDayMap.values()],
|
||||
};
|
||||
}
|
||||
@@ -3,23 +3,24 @@ import pino from "pino";
|
||||
const isProduction = process.env["NODE_ENV"] === "production";
|
||||
|
||||
const LOG_LEVEL = process.env["LOG_LEVEL"] ?? "info";
|
||||
const devDestination = pino.destination({ dest: 1, sync: true });
|
||||
|
||||
export const logger = pino({
|
||||
level: LOG_LEVEL,
|
||||
base: { service: "capakraken-api" },
|
||||
...(isProduction
|
||||
? {}
|
||||
: {
|
||||
transport: {
|
||||
target: "pino/file",
|
||||
options: { destination: 1 }, // stdout
|
||||
},
|
||||
export const logger = isProduction
|
||||
? pino({
|
||||
level: LOG_LEVEL,
|
||||
base: { service: "capakraken-api" },
|
||||
})
|
||||
: pino(
|
||||
{
|
||||
level: LOG_LEVEL,
|
||||
base: { service: "capakraken-api" },
|
||||
formatters: {
|
||||
level(label: string) {
|
||||
return { level: label };
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
devDestination,
|
||||
);
|
||||
|
||||
export type Logger = typeof logger;
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { VacationStatus, VacationType } from "@capakraken/db";
|
||||
import { getResolvedCalendarHolidayStrings, toIsoDate } from "./holiday-availability.js";
|
||||
|
||||
type ResourceHolidayContextDb = {
|
||||
resource: {
|
||||
findUnique: (args: any) => any;
|
||||
};
|
||||
country?: {
|
||||
findUnique: (args: any) => any;
|
||||
};
|
||||
metroCity?: {
|
||||
findUnique: (args: any) => any;
|
||||
};
|
||||
holidayCalendar?: {
|
||||
findMany: (args: any) => any;
|
||||
};
|
||||
vacation: {
|
||||
findMany: (args: any) => any;
|
||||
};
|
||||
};
|
||||
|
||||
export type ResourceHolidayContext = {
|
||||
countryId?: string | null;
|
||||
countryCode?: string | null;
|
||||
countryName?: string | null;
|
||||
federalState?: string | null;
|
||||
metroCityId?: string | null;
|
||||
metroCityName?: string | null;
|
||||
calendarHolidayStrings: string[];
|
||||
publicHolidayStrings: string[];
|
||||
};
|
||||
|
||||
function clampToDay(value: Date): Date {
|
||||
const date = new Date(value);
|
||||
date.setUTCHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
export async function loadResourceHolidayContext(
|
||||
db: ResourceHolidayContextDb,
|
||||
resourceId: string,
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Promise<ResourceHolidayContext> {
|
||||
const resource = typeof db.resource?.findUnique === "function"
|
||||
? await db.resource.findUnique({
|
||||
where: { id: resourceId },
|
||||
select: {
|
||||
federalState: true,
|
||||
countryId: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true, name: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const holidayVacations = typeof db.vacation?.findMany === "function"
|
||||
? await db.vacation.findMany({
|
||||
where: {
|
||||
resourceId,
|
||||
type: VacationType.PUBLIC_HOLIDAY,
|
||||
status: VacationStatus.APPROVED,
|
||||
startDate: { lte: periodEnd },
|
||||
endDate: { gte: periodStart },
|
||||
},
|
||||
select: { startDate: true, endDate: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const calendarHolidayStrings = await getResolvedCalendarHolidayStrings(db, {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
countryId: resource?.countryId ?? null,
|
||||
countryCode: resource?.country?.code ?? null,
|
||||
federalState: resource?.federalState ?? null,
|
||||
metroCityId: resource?.metroCityId ?? null,
|
||||
metroCityName: resource?.metroCity?.name ?? null,
|
||||
});
|
||||
const publicHolidayStrings = new Set<string>();
|
||||
|
||||
for (const holiday of holidayVacations) {
|
||||
const cursor = clampToDay(new Date(Math.max(holiday.startDate.getTime(), periodStart.getTime())));
|
||||
const end = clampToDay(new Date(Math.min(holiday.endDate.getTime(), periodEnd.getTime())));
|
||||
|
||||
while (cursor <= end) {
|
||||
publicHolidayStrings.add(toIsoDate(cursor));
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
countryId: resource?.countryId ?? null,
|
||||
countryCode: resource?.country?.code ?? null,
|
||||
countryName: resource?.country?.name ?? null,
|
||||
federalState: resource?.federalState ?? null,
|
||||
metroCityId: resource?.metroCityId ?? null,
|
||||
metroCityName: resource?.metroCity?.name ?? null,
|
||||
calendarHolidayStrings,
|
||||
publicHolidayStrings: [...publicHolidayStrings].sort(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { getCalendarHolidayStrings, toIsoDate } from "./holiday-availability.js";
|
||||
|
||||
type VacationSpan = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
isHalfDay: boolean;
|
||||
};
|
||||
|
||||
type HolidayContext = {
|
||||
countryCode?: string | null | undefined;
|
||||
federalState?: string | null | undefined;
|
||||
metroCityName?: string | null | undefined;
|
||||
calendarHolidayStrings?: string[] | undefined;
|
||||
publicHolidayStrings?: string[] | undefined;
|
||||
};
|
||||
|
||||
type CountVacationChargeableDaysInput = HolidayContext & {
|
||||
vacation: VacationSpan;
|
||||
periodStart?: Date | undefined;
|
||||
periodEnd?: Date | undefined;
|
||||
};
|
||||
|
||||
function clampToDay(value: Date): Date {
|
||||
const date = new Date(value);
|
||||
date.setUTCHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
function getOverlapRange(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
periodStart?: Date,
|
||||
periodEnd?: Date,
|
||||
): { start: Date; end: Date } | null {
|
||||
const startBoundary = clampToDay(periodStart ?? startDate);
|
||||
const endBoundary = clampToDay(periodEnd ?? endDate);
|
||||
const overlapStart = clampToDay(new Date(Math.max(startDate.getTime(), startBoundary.getTime())));
|
||||
const overlapEnd = clampToDay(new Date(Math.min(endDate.getTime(), endBoundary.getTime())));
|
||||
|
||||
if (overlapStart > overlapEnd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { start: overlapStart, end: overlapEnd };
|
||||
}
|
||||
|
||||
export function countCalendarDaysInPeriod(
|
||||
vacation: VacationSpan,
|
||||
periodStart?: Date,
|
||||
periodEnd?: Date,
|
||||
): number {
|
||||
const overlap = getOverlapRange(vacation.startDate, vacation.endDate, periodStart, periodEnd);
|
||||
if (!overlap) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (vacation.isHalfDay) {
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
const ms = overlap.end.getTime() - overlap.start.getTime();
|
||||
return Math.round(ms / 86_400_000) + 1;
|
||||
}
|
||||
|
||||
export function countVacationChargeableDays(
|
||||
input: CountVacationChargeableDaysInput,
|
||||
): number {
|
||||
const overlap = getOverlapRange(
|
||||
input.vacation.startDate,
|
||||
input.vacation.endDate,
|
||||
input.periodStart,
|
||||
input.periodEnd,
|
||||
);
|
||||
if (!overlap) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const holidaySet = new Set(
|
||||
input.calendarHolidayStrings
|
||||
? input.calendarHolidayStrings.filter((isoDate) => isoDate >= toIsoDate(overlap.start) && isoDate <= toIsoDate(overlap.end))
|
||||
: getCalendarHolidayStrings(
|
||||
overlap.start,
|
||||
overlap.end,
|
||||
input.countryCode,
|
||||
input.federalState,
|
||||
input.metroCityName,
|
||||
),
|
||||
);
|
||||
|
||||
for (const isoDate of input.publicHolidayStrings ?? []) {
|
||||
if (isoDate >= toIsoDate(overlap.start) && isoDate <= toIsoDate(overlap.end)) {
|
||||
holidaySet.add(isoDate);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.vacation.isHalfDay) {
|
||||
return holidaySet.has(toIsoDate(overlap.start)) ? 0 : 0.5;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
const cursor = new Date(overlap.start);
|
||||
|
||||
while (cursor <= overlap.end) {
|
||||
if (!holidaySet.has(toIsoDate(cursor))) {
|
||||
total += 1;
|
||||
}
|
||||
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FillDemandRequirementSchema,
|
||||
FillOpenDemandByAllocationSchema,
|
||||
PermissionKey,
|
||||
type WeekdayAvailability,
|
||||
UpdateAssignmentSchema,
|
||||
UpdateAllocationSchema,
|
||||
UpdateDemandRequirementSchema,
|
||||
@@ -34,6 +35,13 @@ import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
||||
import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js";
|
||||
import { generateAutoSuggestions } from "../lib/auto-staffing.js";
|
||||
import { invalidateDashboardCache } from "../lib/cache.js";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours,
|
||||
calculateEffectiveDayAvailability,
|
||||
countEffectiveWorkingDays,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "../lib/resource-capacity.js";
|
||||
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||
import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js";
|
||||
|
||||
@@ -328,12 +336,26 @@ export const allocationRouter = createTRPCRouter({
|
||||
where: { id: input.resourceId },
|
||||
select: {
|
||||
id: true, displayName: true, eid: true, fte: true,
|
||||
country: { select: { dailyWorkingHours: true } },
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { dailyWorkingHours: true, code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
if (!resource) throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
||||
|
||||
const dailyCapacity = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1);
|
||||
const fallbackDailyHours = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1);
|
||||
const availability = (resource.availability as WeekdayAvailability | null) ?? {
|
||||
monday: fallbackDailyHours,
|
||||
tuesday: fallbackDailyHours,
|
||||
wednesday: fallbackDailyHours,
|
||||
thursday: fallbackDailyHours,
|
||||
friday: fallbackDailyHours,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
};
|
||||
|
||||
// Get existing assignments in the date range
|
||||
const existingAssignments = await ctx.db.assignment.findMany({
|
||||
@@ -350,19 +372,29 @@ export const allocationRouter = createTRPCRouter({
|
||||
orderBy: { startDate: "asc" },
|
||||
});
|
||||
|
||||
// Get vacations in the date range
|
||||
const vacations = await ctx.db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: input.resourceId,
|
||||
status: "APPROVED",
|
||||
startDate: { lte: input.endDate },
|
||||
endDate: { gte: input.startDate },
|
||||
},
|
||||
select: { startDate: true, endDate: true, isHalfDay: true },
|
||||
});
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
[{
|
||||
id: resource.id,
|
||||
availability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
}],
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
);
|
||||
const context = contexts.get(resource.id);
|
||||
|
||||
// Calculate day-by-day availability
|
||||
let totalWorkingDays = 0;
|
||||
const totalWorkingDays = countEffectiveWorkingDays({
|
||||
availability,
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
context,
|
||||
});
|
||||
let availableDays = 0;
|
||||
let conflictDays = 0;
|
||||
let partialDays = 0;
|
||||
@@ -372,36 +404,27 @@ export const allocationRouter = createTRPCRouter({
|
||||
const d = new Date(input.startDate);
|
||||
const end = new Date(input.endDate);
|
||||
while (d <= end) {
|
||||
const dow = d.getDay();
|
||||
if (dow !== 0 && dow !== 6) {
|
||||
totalWorkingDays++;
|
||||
const effectiveDayCapacity = calculateEffectiveDayAvailability({
|
||||
availability,
|
||||
date: d,
|
||||
context,
|
||||
});
|
||||
|
||||
// Check vacation
|
||||
const isVacation = vacations.some((v) => {
|
||||
const vs = new Date(v.startDate); vs.setHours(0, 0, 0, 0);
|
||||
const ve = new Date(v.endDate); ve.setHours(0, 0, 0, 0);
|
||||
const dc = new Date(d); dc.setHours(0, 0, 0, 0);
|
||||
return dc >= vs && dc <= ve;
|
||||
});
|
||||
|
||||
if (isVacation) {
|
||||
conflictDays++;
|
||||
d.setDate(d.getDate() + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sum existing hours on this day
|
||||
if (effectiveDayCapacity > 0) {
|
||||
let bookedHours = 0;
|
||||
for (const a of existingAssignments) {
|
||||
const as2 = new Date(a.startDate); as2.setHours(0, 0, 0, 0);
|
||||
const ae = new Date(a.endDate); ae.setHours(0, 0, 0, 0);
|
||||
const dc = new Date(d); dc.setHours(0, 0, 0, 0);
|
||||
if (dc >= as2 && dc <= ae) {
|
||||
bookedHours += a.hoursPerDay;
|
||||
}
|
||||
bookedHours += calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
periodStart: d,
|
||||
periodEnd: d,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
const remainingCapacity = Math.max(0, dailyCapacity - bookedHours);
|
||||
const remainingCapacity = Math.max(0, effectiveDayCapacity - bookedHours);
|
||||
if (remainingCapacity >= requestedHpd) {
|
||||
availableDays++;
|
||||
totalAvailableHours += requestedHpd;
|
||||
@@ -416,6 +439,15 @@ export const allocationRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const totalRequestedHours = totalWorkingDays * requestedHpd;
|
||||
const totalPeriodCapacity = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
context,
|
||||
});
|
||||
const dailyCapacity = totalWorkingDays > 0
|
||||
? Math.round((totalPeriodCapacity / totalWorkingDays) * 10) / 10
|
||||
: 0;
|
||||
|
||||
return {
|
||||
resource: { id: resource.id, name: resource.displayName, eid: resource.eid },
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
export interface AssistantInsightMetric {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "neutral" | "good" | "warn" | "danger" | "info";
|
||||
}
|
||||
|
||||
export interface AssistantInsightSection {
|
||||
title: string;
|
||||
metrics: AssistantInsightMetric[];
|
||||
}
|
||||
|
||||
export interface AssistantInsight {
|
||||
kind: "chargeability" | "resource_match" | "holiday_region" | "resource_holidays";
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
metrics: AssistantInsightMetric[];
|
||||
sections?: AssistantInsightSection[];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function formatHours(value: unknown): string | null {
|
||||
const num = asNumber(value);
|
||||
return num == null ? null : `${num.toFixed(num % 1 === 0 ? 0 : 1)} h`;
|
||||
}
|
||||
|
||||
function formatDays(value: unknown): string | null {
|
||||
const num = asNumber(value);
|
||||
return num == null ? null : `${num.toFixed(num % 1 === 0 ? 0 : 1)} d`;
|
||||
}
|
||||
|
||||
function pushMetric(
|
||||
metrics: AssistantInsightMetric[],
|
||||
label: string,
|
||||
value: string | null,
|
||||
tone?: AssistantInsightMetric["tone"],
|
||||
) {
|
||||
if (!value) return;
|
||||
metrics.push({ label, value, ...(tone ? { tone } : {}) });
|
||||
}
|
||||
|
||||
function createLocationLabel(locationContext: Record<string, unknown> | undefined): string | null {
|
||||
if (!locationContext) return null;
|
||||
const parts = [
|
||||
asString(locationContext.metroCity),
|
||||
asString(locationContext.federalState),
|
||||
asString(locationContext.country),
|
||||
asString(locationContext.countryCode),
|
||||
].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(", ") : null;
|
||||
}
|
||||
|
||||
function buildChargeabilityInsight(data: Record<string, unknown>): AssistantInsight | null {
|
||||
const resource = asString(data.resource);
|
||||
const month = asString(data.month);
|
||||
if (!resource || !month) return null;
|
||||
|
||||
const holidaySummary = isRecord(data.holidaySummary) ? data.holidaySummary : undefined;
|
||||
const absenceSummary = isRecord(data.absenceSummary) ? data.absenceSummary : undefined;
|
||||
const capacityBreakdown = isRecord(data.capacityBreakdown) ? data.capacityBreakdown : undefined;
|
||||
const locationContext = isRecord(data.locationContext) ? data.locationContext : undefined;
|
||||
const chargeabilityPct = asNumber(data.chargeabilityPct);
|
||||
const targetPct = asNumber(data.targetPct);
|
||||
|
||||
const metrics: AssistantInsightMetric[] = [];
|
||||
pushMetric(metrics, "Chargeability", asString(data.chargeability), chargeabilityPct == null || targetPct == null
|
||||
? "info"
|
||||
: chargeabilityPct >= targetPct ? "good" : "warn");
|
||||
pushMetric(metrics, "Available", formatHours(data.availableHours));
|
||||
pushMetric(metrics, "Booked", formatHours(data.bookedHours));
|
||||
pushMetric(metrics, "Unassigned", formatHours(data.unassignedHours));
|
||||
pushMetric(metrics, "Target", formatHours(data.targetHours));
|
||||
pushMetric(metrics, "Holidays", formatDays(holidaySummary?.workdayCount ?? holidaySummary?.count));
|
||||
|
||||
const sections: AssistantInsightSection[] = [];
|
||||
|
||||
const basisMetrics: AssistantInsightMetric[] = [];
|
||||
pushMetric(basisMetrics, "Location", createLocationLabel(locationContext), "info");
|
||||
pushMetric(basisMetrics, "Base working days", formatDays(data.baseWorkingDays));
|
||||
pushMetric(basisMetrics, "Effective working days", formatDays(data.workingDays));
|
||||
pushMetric(basisMetrics, "Base capacity", formatHours(data.baseAvailableHours));
|
||||
if (basisMetrics.length > 0) {
|
||||
sections.push({ title: "Basis", metrics: basisMetrics });
|
||||
}
|
||||
|
||||
const deductionMetrics: AssistantInsightMetric[] = [];
|
||||
pushMetric(deductionMetrics, "Holiday deduction", formatHours(holidaySummary?.hoursDeduction ?? capacityBreakdown?.holidayHoursDeduction), "warn");
|
||||
pushMetric(deductionMetrics, "Absence deduction", formatHours(absenceSummary?.hoursDeduction ?? capacityBreakdown?.absenceHoursDeduction), "warn");
|
||||
pushMetric(deductionMetrics, "Absence days", formatDays(absenceSummary?.dayEquivalent));
|
||||
if (deductionMetrics.length > 0) {
|
||||
sections.push({ title: "Deductions", metrics: deductionMetrics });
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "chargeability",
|
||||
title: `${resource} · ${month}`,
|
||||
subtitle: "Holiday-aware monthly capacity",
|
||||
metrics,
|
||||
...(sections.length > 0 ? { sections } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildHolidayRegionInsight(data: Record<string, unknown>): AssistantInsight | null {
|
||||
const locationContext = isRecord(data.locationContext) ? data.locationContext : undefined;
|
||||
const periodStart = asString(data.periodStart);
|
||||
const periodEnd = asString(data.periodEnd);
|
||||
|
||||
const metrics: AssistantInsightMetric[] = [];
|
||||
pushMetric(metrics, "Region", createLocationLabel(locationContext), "info");
|
||||
pushMetric(metrics, "Resolved holidays", asNumber(data.count)?.toString() ?? null);
|
||||
pushMetric(metrics, "Period", periodStart && periodEnd ? `${periodStart} to ${periodEnd}` : null);
|
||||
|
||||
const summary = isRecord(data.summary) ? data.summary : undefined;
|
||||
const scopeItems = Array.isArray(summary?.byScope) ? summary.byScope : [];
|
||||
const scopeMetrics = scopeItems
|
||||
.map((item) => {
|
||||
if (!isRecord(item)) return null;
|
||||
const scope = asString(item.scope);
|
||||
const count = asNumber(item.count);
|
||||
if (!scope || count == null) return null;
|
||||
return { label: scope, value: String(count) } satisfies AssistantInsightMetric;
|
||||
})
|
||||
.filter((item): item is AssistantInsightMetric => item !== null);
|
||||
|
||||
return {
|
||||
kind: "holiday_region",
|
||||
title: createLocationLabel(locationContext) ?? "Regional holidays",
|
||||
subtitle: "Resolved public holiday set",
|
||||
metrics,
|
||||
...(scopeMetrics.length > 0 ? { sections: [{ title: "Scopes", metrics: scopeMetrics }] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildResourceHolidayInsight(data: Record<string, unknown>): AssistantInsight | null {
|
||||
const resource = isRecord(data.resource) ? data.resource : undefined;
|
||||
const summary = isRecord(data.summary) ? data.summary : undefined;
|
||||
const periodStart = asString(data.periodStart);
|
||||
const periodEnd = asString(data.periodEnd);
|
||||
|
||||
const metrics: AssistantInsightMetric[] = [];
|
||||
pushMetric(metrics, "Employee", asString(resource?.name) ?? asString(resource?.eid));
|
||||
pushMetric(metrics, "Location", createLocationLabel(resource), "info");
|
||||
pushMetric(metrics, "Resolved holidays", asNumber(data.count)?.toString() ?? null);
|
||||
pushMetric(metrics, "Period", periodStart && periodEnd ? `${periodStart} to ${periodEnd}` : null);
|
||||
|
||||
const scopeItems = Array.isArray(summary?.byScope) ? summary.byScope : [];
|
||||
const scopeMetrics = scopeItems
|
||||
.map((item) => {
|
||||
if (!isRecord(item)) return null;
|
||||
const scope = asString(item.scope);
|
||||
const count = asNumber(item.count);
|
||||
if (!scope || count == null) return null;
|
||||
return { label: scope, value: String(count) } satisfies AssistantInsightMetric;
|
||||
})
|
||||
.filter((item): item is AssistantInsightMetric => item !== null);
|
||||
|
||||
return {
|
||||
kind: "resource_holidays",
|
||||
title: `${asString(resource?.name) ?? "Resource"} holidays`,
|
||||
subtitle: "Location-specific holiday resolution",
|
||||
metrics,
|
||||
...(scopeMetrics.length > 0 ? { sections: [{ title: "Scopes", metrics: scopeMetrics }] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildResourceMatchInsight(data: Record<string, unknown>): AssistantInsight | null {
|
||||
const project = isRecord(data.project) ? data.project : undefined;
|
||||
const period = isRecord(data.period) ? data.period : undefined;
|
||||
const bestMatch = isRecord(data.bestMatch) ? data.bestMatch : undefined;
|
||||
if (!project || !period || !bestMatch) return null;
|
||||
|
||||
const remainingHours = asNumber(bestMatch.remainingHours);
|
||||
const remainingHoursPerDay = asNumber(bestMatch.remainingHoursPerDay);
|
||||
const lcr = asString(bestMatch.lcr);
|
||||
const holidaySummary = isRecord(bestMatch.holidaySummary) ? bestMatch.holidaySummary : undefined;
|
||||
const absenceSummary = isRecord(bestMatch.absenceSummary) ? bestMatch.absenceSummary : undefined;
|
||||
const capacityBreakdown = isRecord(bestMatch.capacityBreakdown) ? bestMatch.capacityBreakdown : undefined;
|
||||
|
||||
const metrics: AssistantInsightMetric[] = [];
|
||||
pushMetric(metrics, "Best match", asString(bestMatch.name) ?? asString(bestMatch.eid), "good");
|
||||
pushMetric(metrics, "Project", asString(project.name) ?? asString(project.shortCode));
|
||||
pushMetric(metrics, "Remaining", formatHours(remainingHours), remainingHours != null && remainingHours > 0 ? "good" : "warn");
|
||||
pushMetric(metrics, "Per workday", formatHours(remainingHoursPerDay));
|
||||
pushMetric(metrics, "LCR", lcr);
|
||||
pushMetric(metrics, "Holiday deduction", formatHours(holidaySummary?.hoursDeduction), "warn");
|
||||
|
||||
const sections: AssistantInsightSection[] = [];
|
||||
|
||||
const profileMetrics: AssistantInsightMetric[] = [];
|
||||
pushMetric(profileMetrics, "Role", asString(bestMatch.role));
|
||||
pushMetric(profileMetrics, "Chapter", asString(bestMatch.chapter));
|
||||
pushMetric(profileMetrics, "Location", createLocationLabel(bestMatch), "info");
|
||||
pushMetric(profileMetrics, "Candidate pool", asNumber(data.candidateCount)?.toString() ?? null);
|
||||
if (profileMetrics.length > 0) {
|
||||
sections.push({ title: "Selection", metrics: profileMetrics });
|
||||
}
|
||||
|
||||
const basisMetrics: AssistantInsightMetric[] = [];
|
||||
pushMetric(basisMetrics, "Window", asString(period.startDate) && asString(period.endDate) ? `${asString(period.startDate)} to ${asString(period.endDate)}` : null);
|
||||
pushMetric(basisMetrics, "Ranking", asString(period.rankingMode));
|
||||
pushMetric(basisMetrics, "Min/day", formatHours(period.minHoursPerDay));
|
||||
pushMetric(basisMetrics, "Base capacity", formatHours(capacityBreakdown?.baseAvailableHours ?? bestMatch.baseAvailableHours));
|
||||
pushMetric(basisMetrics, "Effective capacity", formatHours(bestMatch.availableHours));
|
||||
pushMetric(basisMetrics, "Absence deduction", formatHours(absenceSummary?.hoursDeduction ?? capacityBreakdown?.absenceHoursDeduction), "warn");
|
||||
if (basisMetrics.length > 0) {
|
||||
sections.push({ title: "Capacity basis", metrics: basisMetrics });
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "resource_match",
|
||||
title: `${asString(project.shortCode) ?? asString(project.name) ?? "Project"} staffing`,
|
||||
subtitle: "Holiday-aware best-fit resource",
|
||||
metrics,
|
||||
...(sections.length > 0 ? { sections } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAssistantInsight(toolName: string, data: unknown): AssistantInsight | null {
|
||||
if (!isRecord(data)) return null;
|
||||
|
||||
switch (toolName) {
|
||||
case "get_chargeability":
|
||||
return buildChargeabilityInsight(data);
|
||||
case "find_best_project_resource":
|
||||
return buildResourceMatchInsight(data);
|
||||
case "list_holidays_by_region":
|
||||
return buildHolidayRegionInsight(data);
|
||||
case "get_resource_holidays":
|
||||
return buildResourceHolidayInsight(data);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,11 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { resolvePermissions, type PermissionOverrides, type SystemRole } from "@capakraken/shared";
|
||||
import { PermissionKey, resolvePermissions, type PermissionOverrides, type SystemRole } from "@capakraken/shared";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
|
||||
import { TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
|
||||
import { ADVANCED_ASSISTANT_TOOLS, TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
|
||||
import { buildAssistantInsight, type AssistantInsight } from "./assistant-insights.js";
|
||||
import { checkPromptInjection } from "../lib/prompt-guard.js";
|
||||
import { checkAiOutput } from "../lib/content-filter.js";
|
||||
import { createAuditEntry } from "../lib/audit.js";
|
||||
@@ -20,7 +21,7 @@ const SYSTEM_PROMPT = `Du bist der CapaKraken-Assistent — ein hilfreicher AI-A
|
||||
|
||||
Deine Fähigkeiten:
|
||||
- Fragen über Ressourcen, Projekte, Allokationen, Budget, Urlaub, Estimates, Org-Struktur, Rollen, Blueprints, Rate Cards beantworten
|
||||
- Chargeability-Analysen, Urlaubsübersichten, Budget-Analysen, Staffing-Vorschläge, Kapazitätssuche
|
||||
- Chargeability-Analysen, Urlaubsübersichten, Feiertagskalender nach Land/Bundesland/Stadt, Budget-Analysen, Staffing-Vorschläge, Kapazitätssuche
|
||||
- Ressourcen erstellen/aktualisieren/deaktivieren, Projekte erstellen/aktualisieren/löschen
|
||||
- Allokationen erstellen/stornieren, Demands erstellen/besetzen, Staffing-Vorschläge abrufen
|
||||
- Urlaub erstellen/genehmigen/ablehnen/stornieren, Ansprüche verwalten
|
||||
@@ -40,6 +41,12 @@ Wichtige Regeln:
|
||||
- Sei KURZ und DIREKT. Keine langen Erklärungen wenn nicht nötig. Antworte knapp und präzise.
|
||||
- Rufe Tools PARALLEL auf wenn möglich (z.B. search_resources + list_allocations gleichzeitig)
|
||||
- Fasse Ergebnisse kompakt zusammen — keine unnötigen Wiederholungen der Tool-Ergebnisse
|
||||
- Wenn Feiertage, SAH, Chargeability, Verfügbarkeit oder Ressourcenauswahl relevant sind, erkläre IMMER transparent:
|
||||
1. Standortkontext (Land/Bundesland/Stadt falls relevant)
|
||||
2. Feiertagsbasis bzw. Feiertagsanzahl
|
||||
3. Abzüge durch Feiertage/Abwesenheiten
|
||||
4. resultierende verfügbare Stunden / Zielstunden / Restkapazität
|
||||
- Wenn strukturierte UI-Karten vorhanden sind, wiederhole dort gezeigte Zahlen NICHT vollständig im Freitext. Gib nur die Kernaussage und die wichtigste Begründung an.
|
||||
- Wenn eine Suche keine Treffer ergibt, versuche einzelne Wörter aus der Anfrage als Suchbegriffe. Die Tools unterstützen automatisch wort-basierte Fuzzy-Suche — zeige dem User die Vorschläge wenn welche gefunden werden
|
||||
|
||||
Datenmodell:
|
||||
@@ -48,10 +55,12 @@ Datenmodell:
|
||||
- Allokationen (Assignments): resourceId + projectId, hoursPerDay, dailyCostCents, Zeitraum, Status (PROPOSED/CONFIRMED/ACTIVE/COMPLETED/CANCELLED)
|
||||
- Chargeability = gebuchte/verfügbare Stunden × 100%
|
||||
- Urlaub: Typen VACATION/SICK/PARENTAL/SPECIAL/PUBLIC_HOLIDAY, Status PENDING/APPROVED/REJECTED/CANCELLED
|
||||
- Feiertage: können je nach Land, Bundesland und Stadt unterschiedlich sein; nutze Feiertags-Tools statt zu raten
|
||||
`;
|
||||
|
||||
/** Map tool names to the permission required to use them */
|
||||
const TOOL_PERMISSION_MAP: Record<string, string> = {
|
||||
list_users: PermissionKey.MANAGE_USERS,
|
||||
// Resource management
|
||||
update_resource: "manageResources",
|
||||
create_resource: "manageResources",
|
||||
@@ -89,7 +98,36 @@ 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"]);
|
||||
const COST_TOOLS = new Set(["get_budget_status", "get_chargeability", "resolve_rate", "list_rate_cards", "get_estimate_detail", "find_best_project_resource"]);
|
||||
|
||||
export function getAvailableAssistantTools(permissions: Set<PermissionKey>) {
|
||||
return TOOL_DEFINITIONS.filter((tool) => {
|
||||
const toolName = tool.function.name;
|
||||
const requiredPerm = TOOL_PERMISSION_MAP[toolName];
|
||||
|
||||
if (requiredPerm && !permissions.has(requiredPerm as PermissionKey)) {
|
||||
return false;
|
||||
}
|
||||
if (COST_TOOLS.has(toolName) && !permissions.has(PermissionKey.VIEW_COSTS)) {
|
||||
return false;
|
||||
}
|
||||
if (ADVANCED_ASSISTANT_TOOLS.has(toolName) && !permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function mergeInsights(existing: AssistantInsight[], next: AssistantInsight): AssistantInsight[] {
|
||||
const duplicateIndex = existing.findIndex((item) => item.kind === next.kind && item.title === next.title && item.subtitle === next.subtitle);
|
||||
if (duplicateIndex >= 0) {
|
||||
const copy = [...existing];
|
||||
copy[duplicateIndex] = next;
|
||||
return copy;
|
||||
}
|
||||
return [...existing, next].slice(-6);
|
||||
}
|
||||
|
||||
export const assistantRouter = createTRPCRouter({
|
||||
chat: protectedProcedure
|
||||
@@ -176,26 +214,12 @@ export const assistantRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
// 4. Filter tools based on granular permissions
|
||||
const availableTools = TOOL_DEFINITIONS.filter((t) => {
|
||||
const toolName = t.function.name;
|
||||
|
||||
// Check write permission
|
||||
const requiredPerm = TOOL_PERMISSION_MAP[toolName];
|
||||
if (requiredPerm && !permissions.has(requiredPerm as import("@capakraken/shared").PermissionKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hide cost/budget tools if user lacks viewCosts
|
||||
if (COST_TOOLS.has(toolName) && !permissions.has("viewCosts" as import("@capakraken/shared").PermissionKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
const availableTools = getAvailableAssistantTools(permissions);
|
||||
|
||||
// 5. Function calling loop
|
||||
const toolCtx: ToolContext = { db: ctx.db, userId: ctx.dbUser!.id, userRole, permissions };
|
||||
const collectedActions: ToolAction[] = [];
|
||||
let collectedInsights: AssistantInsight[] = [];
|
||||
|
||||
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -240,6 +264,11 @@ export const assistantRouter = createTRPCRouter({
|
||||
toolCtx,
|
||||
);
|
||||
|
||||
const insight = buildAssistantInsight(toolCall.function.name, result.data);
|
||||
if (insight) {
|
||||
collectedInsights = mergeInsights(collectedInsights, insight);
|
||||
}
|
||||
|
||||
// Collect any actions (e.g. navigation)
|
||||
if (result.action) {
|
||||
collectedActions.push(result.action);
|
||||
@@ -298,6 +327,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
return {
|
||||
content: finalContent,
|
||||
role: "assistant" as const,
|
||||
...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
|
||||
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
|
||||
};
|
||||
}
|
||||
@@ -306,6 +336,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
return {
|
||||
content: "I had to stop after too many tool calls. Please try a simpler question.",
|
||||
role: "assistant" as const,
|
||||
...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
|
||||
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -5,19 +5,18 @@ import {
|
||||
sumFte,
|
||||
getMonthRange,
|
||||
getMonthKeys,
|
||||
countWorkingDaysInOverlap,
|
||||
calculateSAH,
|
||||
calculateAllocation,
|
||||
DEFAULT_CALCULATION_RULES,
|
||||
type AssignmentSlice,
|
||||
} from "@capakraken/engine";
|
||||
import type { CalculationRule, AbsenceDay } from "@capakraken/shared";
|
||||
import type { SpainScheduleRule } from "@capakraken/shared";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
|
||||
import { VacationStatus } from "@capakraken/db";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
||||
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "../lib/resource-capacity.js";
|
||||
|
||||
export const chargeabilityReportRouter = createTRPCRouter({
|
||||
getReport: controllerProcedure
|
||||
@@ -59,6 +58,10 @@ export const chargeabilityReportRouter = createTRPCRouter({
|
||||
eid: true,
|
||||
displayName: true,
|
||||
fte: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
chargeabilityTarget: true,
|
||||
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
|
||||
orgUnit: { select: { id: true, name: true } },
|
||||
@@ -90,6 +93,20 @@ export const chargeabilityReportRouter = createTRPCRouter({
|
||||
endDate: rangeEnd,
|
||||
resourceIds,
|
||||
});
|
||||
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
);
|
||||
|
||||
// Enrich with utilization category — fetch project util categories in bulk
|
||||
const projectIds = [...new Set(allBookings.map((b) => b.projectId))];
|
||||
@@ -118,152 +135,59 @@ export const chargeabilityReportRouter = createTRPCRouter({
|
||||
},
|
||||
}));
|
||||
|
||||
// Fetch vacations/absences in the range (including type for rules engine)
|
||||
const vacations = await ctx.db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: { in: resourceIds },
|
||||
status: VacationStatus.APPROVED,
|
||||
startDate: { lte: rangeEnd },
|
||||
endDate: { gte: rangeStart },
|
||||
},
|
||||
select: {
|
||||
resourceId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
type: true,
|
||||
isHalfDay: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Load calculation rules for chargeability adjustments
|
||||
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
|
||||
try {
|
||||
const dbRules = await ctx.db.calculationRule.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: [{ priority: "desc" }],
|
||||
});
|
||||
if (dbRules.length > 0) {
|
||||
calcRules = dbRules as unknown as CalculationRule[];
|
||||
}
|
||||
} catch {
|
||||
// table may not exist yet
|
||||
}
|
||||
|
||||
// Build per-resource, per-month forecasts
|
||||
const resourceRows = resources.map((resource) => {
|
||||
const resourceRows = await Promise.all(resources.map(async (resource) => {
|
||||
const resourceAssignments = assignments.filter((a) => a.resourceId === resource.id);
|
||||
const resourceVacations = vacations.filter((v) => v.resourceId === resource.id);
|
||||
// Prefer mgmt level group target; fall back to legacy chargeabilityTarget (0-100 → 0-1)
|
||||
const targetPct = resource.managementLevelGroup?.targetPercentage
|
||||
?? (resource.chargeabilityTarget / 100);
|
||||
const dailyHours = resource.country?.dailyWorkingHours ?? 8;
|
||||
const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null;
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const context = availabilityContexts.get(resource.id);
|
||||
|
||||
const months = monthKeys.map((key) => {
|
||||
const months = await Promise.all(monthKeys.map(async (key) => {
|
||||
const [y, m] = key.split("-").map(Number) as [number, number];
|
||||
const { start: monthStart, end: monthEnd } = getMonthRange(y, m);
|
||||
|
||||
// Compute absence days for SAH
|
||||
const absenceDates: string[] = [];
|
||||
for (const v of resourceVacations) {
|
||||
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
|
||||
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
|
||||
if (vStart > vEnd) continue;
|
||||
const cursor = new Date(vStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const endNorm = new Date(vEnd);
|
||||
endNorm.setUTCHours(0, 0, 0, 0);
|
||||
while (cursor <= endNorm) {
|
||||
absenceDates.push(cursor.toISOString().slice(0, 10));
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate SAH for this resource+month
|
||||
const sahResult = calculateSAH({
|
||||
dailyWorkingHours: dailyHours,
|
||||
scheduleRules,
|
||||
fte: resource.fte,
|
||||
const availableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
publicHolidays: [], // TODO: integrate public holidays from country
|
||||
absenceDays: absenceDates,
|
||||
context,
|
||||
});
|
||||
|
||||
// Build typed absence days for this resource in this month
|
||||
const monthAbsenceDays: AbsenceDay[] = [];
|
||||
for (const v of resourceVacations) {
|
||||
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
|
||||
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
|
||||
if (vStart > vEnd) continue;
|
||||
const absCursor = new Date(vStart);
|
||||
absCursor.setUTCHours(0, 0, 0, 0);
|
||||
const absEndNorm = new Date(vEnd);
|
||||
absEndNorm.setUTCHours(0, 0, 0, 0);
|
||||
const triggerType = v.type === "SICK" ? "SICK" as const
|
||||
: v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
|
||||
: "VACATION" as const;
|
||||
while (absCursor <= absEndNorm) {
|
||||
monthAbsenceDays.push({
|
||||
date: new Date(absCursor),
|
||||
type: triggerType,
|
||||
...(v.isHalfDay ? { isHalfDay: true } : {}),
|
||||
});
|
||||
absCursor.setUTCDate(absCursor.getUTCDate() + 1);
|
||||
const slices: AssignmentSlice[] = resourceAssignments.flatMap((a) => {
|
||||
const totalChargeableHours = calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
context,
|
||||
});
|
||||
if (totalChargeableHours <= 0) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Build assignment slices for this month, using rules to compute chargeable hours
|
||||
const slices: AssignmentSlice[] = [];
|
||||
for (const a of resourceAssignments) {
|
||||
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
|
||||
if (workingDays <= 0) continue;
|
||||
|
||||
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
|
||||
|
||||
// If there are absences and rules, compute rules-adjusted chargeable hours
|
||||
if (monthAbsenceDays.length > 0) {
|
||||
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
|
||||
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
|
||||
|
||||
const calcResult = calculateAllocation({
|
||||
lcrCents: 0, // we only need hours, not costs
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
startDate: overlapStart,
|
||||
endDate: overlapEnd,
|
||||
availability: { monday: dailyHours, tuesday: dailyHours, wednesday: dailyHours, thursday: dailyHours, friday: dailyHours, saturday: 0, sunday: 0 },
|
||||
absenceDays: monthAbsenceDays,
|
||||
calculationRules: calcRules,
|
||||
});
|
||||
|
||||
slices.push({
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
workingDays,
|
||||
categoryCode,
|
||||
...(calcResult.totalChargeableHours !== undefined ? { totalChargeableHours: calcResult.totalChargeableHours } : {}),
|
||||
});
|
||||
} else {
|
||||
slices.push({
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
workingDays,
|
||||
categoryCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
workingDays: 0,
|
||||
categoryCode: a.project.utilizationCategory?.code ?? "Chg",
|
||||
totalChargeableHours,
|
||||
};
|
||||
});
|
||||
|
||||
const forecast = deriveResourceForecast({
|
||||
fte: resource.fte,
|
||||
targetPercentage: targetPct,
|
||||
assignments: slices,
|
||||
sah: sahResult.standardAvailableHours,
|
||||
sah: availableHours,
|
||||
});
|
||||
|
||||
return {
|
||||
monthKey: key,
|
||||
sah: sahResult.standardAvailableHours,
|
||||
sah: availableHours,
|
||||
...forecast,
|
||||
};
|
||||
});
|
||||
}));
|
||||
|
||||
return {
|
||||
id: resource.id,
|
||||
@@ -278,7 +202,7 @@ export const chargeabilityReportRouter = createTRPCRouter({
|
||||
targetPct,
|
||||
months,
|
||||
};
|
||||
});
|
||||
}));
|
||||
|
||||
// Compute group totals per month
|
||||
const groupTotals = monthKeys.map((key, monthIdx) => {
|
||||
|
||||
@@ -4,18 +4,27 @@ import {
|
||||
deriveResourceForecast,
|
||||
computeBudgetStatus,
|
||||
getMonthRange,
|
||||
countWorkingDaysInOverlap,
|
||||
DEFAULT_CALCULATION_RULES,
|
||||
summarizeEstimateDemandLines,
|
||||
computeEvenSpread,
|
||||
distributeHoursToWeeks,
|
||||
type AssignmentSlice,
|
||||
} from "@capakraken/engine";
|
||||
import type { CalculationRule, AbsenceDay, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared";
|
||||
import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared";
|
||||
import { VacationStatus } from "@capakraken/db";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
||||
import { fmtEur } from "../lib/format-utils.js";
|
||||
import {
|
||||
asHolidayResolverDb,
|
||||
collectHolidayAvailability,
|
||||
getResolvedCalendarHolidays,
|
||||
} from "../lib/holiday-availability.js";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
countEffectiveWorkingDays,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "../lib/resource-capacity.js";
|
||||
|
||||
// ─── Graph Types (mirrored from client for API response) ────────────────────
|
||||
|
||||
@@ -62,6 +71,21 @@ function fmtNum(v: number, decimals = 1): string {
|
||||
return v.toFixed(decimals);
|
||||
}
|
||||
|
||||
function getAvailabilityHoursForDate(
|
||||
availability: WeekdayAvailability,
|
||||
date: Date,
|
||||
): number {
|
||||
const dayKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][date.getUTCDay()] as keyof WeekdayAvailability;
|
||||
return availability[dayKey] ?? 0;
|
||||
}
|
||||
|
||||
function sumAvailabilityHoursForDates(
|
||||
availability: WeekdayAvailability,
|
||||
dates: Date[],
|
||||
): number {
|
||||
return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0);
|
||||
}
|
||||
|
||||
// ─── Router ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const computationGraphRouter = createTRPCRouter({
|
||||
@@ -88,8 +112,12 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
fte: true,
|
||||
lcrCents: true,
|
||||
chargeabilityTarget: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
availability: true,
|
||||
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
|
||||
country: { select: { id: true, code: true, name: true, dailyWorkingHours: true, scheduleRules: true } },
|
||||
metroCity: { select: { id: true, name: true } },
|
||||
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
|
||||
},
|
||||
});
|
||||
@@ -133,7 +161,7 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
|
||||
// ── 3. Load absences ──
|
||||
// ── 3. Load absences + holiday context ──
|
||||
const vacations = await ctx.db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: input.resourceId,
|
||||
@@ -143,45 +171,47 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
},
|
||||
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
|
||||
});
|
||||
const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
});
|
||||
const holidayAvailability = collectHolidayAvailability({
|
||||
vacations,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
resolvedHolidayStrings: resolvedHolidays.map((holiday) => holiday.date),
|
||||
});
|
||||
const publicHolidayStrings = holidayAvailability.publicHolidayStrings;
|
||||
const absenceDateStrings = holidayAvailability.absenceDateStrings;
|
||||
const absenceDays = holidayAvailability.absenceDays;
|
||||
const halfDayCount = absenceDays.filter((absence) => absence.isHalfDay).length;
|
||||
const vacationDayCount = absenceDays.filter((absence) => absence.type === "VACATION").length;
|
||||
const sickDayCount = absenceDays.filter((absence) => absence.type === "SICK").length;
|
||||
const publicHolidayCount = resolvedHolidays.length;
|
||||
|
||||
// Build absence dates for SAH (ISO strings), separating public holidays
|
||||
const publicHolidayStrings: string[] = [];
|
||||
const absenceDateStrings: string[] = [];
|
||||
const absenceDays: AbsenceDay[] = [];
|
||||
let halfDayCount = 0;
|
||||
let vacationDayCount = 0;
|
||||
let sickDayCount = 0;
|
||||
let publicHolidayCount = 0;
|
||||
for (const v of vacations) {
|
||||
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
|
||||
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
|
||||
if (vStart > vEnd) continue;
|
||||
const cursor = new Date(vStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const endNorm = new Date(vEnd);
|
||||
endNorm.setUTCHours(0, 0, 0, 0);
|
||||
const triggerType = v.type === "SICK" ? "SICK" as const
|
||||
: v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
|
||||
: "VACATION" as const;
|
||||
while (cursor <= endNorm) {
|
||||
const isoDate = cursor.toISOString().slice(0, 10);
|
||||
if (triggerType === "PUBLIC_HOLIDAY") {
|
||||
publicHolidayStrings.push(isoDate);
|
||||
publicHolidayCount++;
|
||||
} else {
|
||||
absenceDateStrings.push(isoDate);
|
||||
if (triggerType === "VACATION") vacationDayCount++;
|
||||
if (triggerType === "SICK") sickDayCount++;
|
||||
}
|
||||
absenceDays.push({
|
||||
date: new Date(cursor),
|
||||
type: triggerType,
|
||||
...(v.isHalfDay ? { isHalfDay: true } : {}),
|
||||
});
|
||||
if (v.isHalfDay) halfDayCount++;
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
[{
|
||||
id: resource.id,
|
||||
availability: weeklyAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
}],
|
||||
monthStart,
|
||||
monthEnd,
|
||||
);
|
||||
const availabilityContext = contexts.get(resource.id);
|
||||
|
||||
// ── 4. Load calculation rules ──
|
||||
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
|
||||
@@ -197,7 +227,7 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
// table may not exist yet
|
||||
}
|
||||
|
||||
// ── 5. Calculate SAH ──
|
||||
// ── 5. Calculate SAH / effective capacity ──
|
||||
const sahResult = calculateSAH({
|
||||
dailyWorkingHours: dailyHours,
|
||||
scheduleRules,
|
||||
@@ -207,6 +237,60 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
publicHolidays: publicHolidayStrings,
|
||||
absenceDays: absenceDateStrings,
|
||||
});
|
||||
const baseWorkingDays = countEffectiveWorkingDays({
|
||||
availability: weeklyAvailability,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
context: undefined,
|
||||
});
|
||||
const effectiveWorkingDays = countEffectiveWorkingDays({
|
||||
availability: weeklyAvailability,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
context: availabilityContext,
|
||||
});
|
||||
const baseAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability: weeklyAvailability,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
context: undefined,
|
||||
});
|
||||
const effectiveAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability: weeklyAvailability,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
context: availabilityContext,
|
||||
});
|
||||
const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`));
|
||||
const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => (
|
||||
count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0)
|
||||
), 0);
|
||||
const publicHolidayHoursDeduction = sumAvailabilityHoursForDates(
|
||||
weeklyAvailability,
|
||||
publicHolidayDates,
|
||||
);
|
||||
const absenceHoursDeduction = absenceDays.reduce((sum, absence) => {
|
||||
if (absence.type === "PUBLIC_HOLIDAY") {
|
||||
return sum;
|
||||
}
|
||||
const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date);
|
||||
return sum + baseHours * (absence.isHalfDay ? 0.5 : 1);
|
||||
}, 0);
|
||||
const effectiveHoursPerWorkingDay = effectiveWorkingDays > 0
|
||||
? effectiveAvailableHours / effectiveWorkingDays
|
||||
: 0;
|
||||
const holidayScopeSummary = [
|
||||
resource.country?.code ?? "—",
|
||||
resource.federalState ?? "—",
|
||||
resource.metroCity?.name ?? "—",
|
||||
].join(" / ");
|
||||
const holidayExamples = resolvedHolidays.length > 0
|
||||
? resolvedHolidays.slice(0, 4).map((holiday) => `${holiday.date} ${holiday.name}`).join(", ")
|
||||
: "none";
|
||||
const holidayScopeBreakdown = resolvedHolidays.reduce<Record<string, number>>((counts, holiday) => {
|
||||
counts[holiday.scope] = (counts[holiday.scope] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
|
||||
// ── 6. Calculate allocations + chargeability slices ──
|
||||
const slices: AssignmentSlice[] = [];
|
||||
@@ -217,9 +301,6 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
let hasRulesEffect = false;
|
||||
|
||||
for (const a of assignments) {
|
||||
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
|
||||
if (workingDays <= 0) continue;
|
||||
|
||||
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
|
||||
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
|
||||
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
|
||||
@@ -233,6 +314,7 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
absenceDays,
|
||||
calculationRules: calcRules,
|
||||
});
|
||||
if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) continue;
|
||||
|
||||
totalAllocHours += calcResult.totalHours;
|
||||
totalAllocCostCents += calcResult.totalCostCents;
|
||||
@@ -247,7 +329,7 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
|
||||
slices.push({
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
workingDays,
|
||||
workingDays: calcResult.workingDays,
|
||||
categoryCode,
|
||||
...(calcResult.totalChargeableHours !== undefined
|
||||
? { totalChargeableHours: calcResult.totalChargeableHours }
|
||||
@@ -260,7 +342,7 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
fte: resource.fte,
|
||||
targetPercentage: targetPct,
|
||||
assignments: slices,
|
||||
sah: sahResult.standardAvailableHours,
|
||||
sah: effectiveAvailableHours,
|
||||
});
|
||||
|
||||
// ── 8. Build budget status for first project with budget ──
|
||||
@@ -319,7 +401,18 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
? assignments.reduce((sum, a) => sum + a.hoursPerDay, 0) / assignments.length
|
||||
: 0;
|
||||
const totalWorkingDaysInMonth = assignments.reduce((sum, a) => {
|
||||
return sum + countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
|
||||
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
|
||||
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
|
||||
const calcResult = calculateAllocation({
|
||||
lcrCents: resource.lcrCents,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
startDate: overlapStart,
|
||||
endDate: overlapEnd,
|
||||
availability: weeklyAvailability,
|
||||
absenceDays,
|
||||
calculationRules: calcRules,
|
||||
});
|
||||
return sum + calcResult.workingDays;
|
||||
}, 0);
|
||||
|
||||
// Format weekly availability for display
|
||||
@@ -332,9 +425,10 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
: weekdayLabels.map((d, i) => `${d}:${weekdayValues[i]}`).join(" ");
|
||||
|
||||
// Derived utilization ratio
|
||||
const utilizationPct = sahResult.standardAvailableHours > 0
|
||||
? (totalAllocHours / sahResult.standardAvailableHours) * 100
|
||||
const utilizationPct = effectiveAvailableHours > 0
|
||||
? (totalAllocHours / effectiveAvailableHours) * 100
|
||||
: 0;
|
||||
const chargeableHours = forecast.chg * effectiveAvailableHours;
|
||||
|
||||
// Has schedule rules (Spain variable hours)?
|
||||
const hasScheduleRules = !!scheduleRules;
|
||||
@@ -342,6 +436,11 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
const nodes: GraphNode[] = [
|
||||
// INPUT
|
||||
n("input.fte", "FTE", fmtNum(resource.fte, 2), "ratio", "INPUT", `Resource FTE factor`, 0),
|
||||
n("input.country", "Country", resource.country?.name ?? resource.country?.code ?? "—", "text", "INPUT", "Country used for base working-time and national holiday rules", 0),
|
||||
n("input.state", "State", resource.federalState ?? "—", "text", "INPUT", "Federal state / region used for regional holidays", 0),
|
||||
n("input.city", "City", resource.metroCity?.name ?? "—", "text", "INPUT", "City / metro used for local holidays", 0),
|
||||
n("input.holidayContext", "Holiday Context", holidayScopeSummary, "text", "INPUT", "Resolved holiday scope chain: country / state / city", 0),
|
||||
n("input.holidayExamples", "Holiday Dates", holidayExamples, "text", "INPUT", `Resolved holidays in ${input.month}; scopes: COUNTRY ${holidayScopeBreakdown.COUNTRY ?? 0}, STATE ${holidayScopeBreakdown.STATE ?? 0}, CITY ${holidayScopeBreakdown.CITY ?? 0}`, 0),
|
||||
n("input.dailyHours", "Country Hours", `${dailyHours} h`, "hours", "INPUT", `Base daily working hours (${resource.country?.code ?? "?"})`, 0),
|
||||
...(hasScheduleRules ? [
|
||||
n("input.scheduleRules", "Schedule Rules", "Spain", "—", "INPUT", "Variable daily hours (regular/friday/summer)", 0),
|
||||
@@ -350,7 +449,7 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
n("input.lcrCents", "LCR", fmtEur(resource.lcrCents), "cents/h", "INPUT", "Loaded Cost Rate per hour", 0),
|
||||
n("input.hoursPerDay", "Hours/Day", fmtNum(avgHoursPerDay), "hours", "INPUT", "Average hours/day across assignments", 0),
|
||||
n("input.absences", "Absences", `${absenceDays.length}`, "count", "INPUT", `Absence days in ${input.month} (${vacationDayCount} vacation, ${sickDayCount} sick${halfDayCount > 0 ? `, ${halfDayCount} half-day` : ""})`, 0),
|
||||
n("input.publicHolidays", "Public Holidays", `${publicHolidayCount}`, "count", "INPUT", `Public holidays in ${input.month}`, 0),
|
||||
n("input.publicHolidays", "Public Holidays", `${publicHolidayCount}`, "count", "INPUT", `Resolved holidays in ${input.month}; ${publicHolidayWorkdayCount} hit configured working days`, 0),
|
||||
n("input.calcRules", "Active Rules", `${calcRules.length}`, "count", "INPUT", "Active calculation rules", 0),
|
||||
n("input.targetPct", "Target", fmtPct(targetPct), "%", "INPUT", `Chargeability target (${resource.managementLevelGroup?.name ?? "legacy"})`, 0),
|
||||
n("input.assignmentCount", "Assignments", `${assignments.length}`, "count", "INPUT", `Active assignments in ${input.month}`, 0),
|
||||
@@ -358,12 +457,15 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
// SAH
|
||||
n("sah.calendarDays", "Calendar Days", `${sahResult.calendarDays}`, "days", "SAH", "Total calendar days in period", 1),
|
||||
n("sah.weekendDays", "Weekend Days", `${sahResult.weekendDays}`, "days", "SAH", "Saturday + Sunday count", 1),
|
||||
n("sah.grossWorkingDays", "Gross Work Days", `${sahResult.grossWorkingDays}`, "days", "SAH", "Calendar days minus weekends", 1, "calendarDays - weekendDays"),
|
||||
n("sah.publicHolidayDays", "Holiday Ded.", `${sahResult.publicHolidayDays}`, "days", "SAH", "Public holidays falling on working days", 1),
|
||||
n("sah.absenceDays", "Absence Ded.", `${sahResult.absenceDays}`, "days", "SAH", "Absences (vacation/sick) falling on working days", 1),
|
||||
n("sah.netWorkingDays", "Net Work Days", `${sahResult.netWorkingDays}`, "days", "SAH", "Working days after deductions", 2, "gross - holidays - absences"),
|
||||
n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(sahResult.effectiveHoursPerDay), "hours", "SAH", "Average effective hours per net working day (FTE-scaled)", 2, "Σ(dailyHours × FTE) / netDays"),
|
||||
n("sah.sah", "SAH", fmtNum(sahResult.standardAvailableHours), "hours", "SAH", "Standard Available Hours — chargeability denominator", 2, "Σ(dailyHours × FTE) per net day"),
|
||||
n("sah.grossWorkingDays", "Gross Work Days", `${baseWorkingDays}`, "days", "SAH", "Working days from the resource-specific weekly availability before holidays/absences", 1, "count(availability > 0)"),
|
||||
n("sah.baseHours", "Base Hours", fmtNum(baseAvailableHours), "hours", "SAH", "Available hours from weekly availability before holiday/absence deductions", 1, "Σ(daily availability)"),
|
||||
n("sah.publicHolidayDays", "Holiday Ded.", `${publicHolidayWorkdayCount}`, "days", "SAH", "Holiday workdays deducted after applying country/state/city scope and weekday availability", 1),
|
||||
n("sah.publicHolidayHours", "Holiday Hrs Ded.", fmtNum(publicHolidayHoursDeduction), "hours", "SAH", "Hours removed by resolved public holidays", 1, "Σ(availability on holiday dates)"),
|
||||
n("sah.absenceDays", "Absence Ded.", `${absenceDateStrings.length}`, "days", "SAH", "Vacation/sick days that hit working days and are not already public holidays", 1),
|
||||
n("sah.absenceHours", "Absence Hrs Ded.", fmtNum(absenceHoursDeduction), "hours", "SAH", "Hours removed by vacation/sick absences", 1, "Σ(availability × absence fraction)"),
|
||||
n("sah.netWorkingDays", "Net Work Days", `${effectiveWorkingDays}`, "days", "SAH", "Remaining working days after holiday and absence deductions", 2, "gross - holidays - absences"),
|
||||
n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(effectiveHoursPerWorkingDay), "hours", "SAH", "Average effective hours per remaining working day", 2, "SAH / net work days"),
|
||||
n("sah.sah", "SAH", fmtNum(effectiveAvailableHours), "hours", "SAH", "Effective available hours after weekly availability, local holidays and absences", 2, "base hours - holiday hours - absence hours"),
|
||||
|
||||
// ALLOCATION
|
||||
n("alloc.workingDays", "Work Days", `${totalWorkingDaysInMonth}`, "days", "ALLOCATION", "Working days covered by assignments in period", 1, "Σ(overlap workdays)"),
|
||||
@@ -387,24 +489,24 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
] : []),
|
||||
|
||||
// CHARGEABILITY — full breakdown from deriveResourceForecast
|
||||
n("chg.chgHours", "Chg Hours", fmtNum(forecast.chg * sahResult.standardAvailableHours), "hours", "CHARGEABILITY", "Total chargeable hours", 2, "Σ(Chg-category slices)"),
|
||||
n("chg.chgHours", "Chg Hours", fmtNum(chargeableHours), "hours", "CHARGEABILITY", "Total chargeable hours against effective SAH", 2, "chargeability × SAH"),
|
||||
n("chg.chg", "Chargeability", fmtPct(forecast.chg), "%", "CHARGEABILITY", "Chargeability ratio", 3, "chgHours / SAH"),
|
||||
...(forecast.bd > 0 ? [
|
||||
n("chg.bd", "BD Ratio", fmtPct(forecast.bd), "%", "CHARGEABILITY", `Business development: ${fmtNum(forecast.bd * sahResult.standardAvailableHours)}h`, 3, "bdHours / SAH"),
|
||||
n("chg.bd", "BD Ratio", fmtPct(forecast.bd), "%", "CHARGEABILITY", `Business development: ${fmtNum(forecast.bd * effectiveAvailableHours)}h`, 3, "bdHours / SAH"),
|
||||
] : []),
|
||||
...(forecast.mdi > 0 ? [
|
||||
n("chg.mdi", "MD&I Ratio", fmtPct(forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(forecast.mdi * sahResult.standardAvailableHours)}h`, 3, "mdiHours / SAH"),
|
||||
n("chg.mdi", "MD&I Ratio", fmtPct(forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(forecast.mdi * effectiveAvailableHours)}h`, 3, "mdiHours / SAH"),
|
||||
] : []),
|
||||
...(forecast.mo > 0 ? [
|
||||
n("chg.mo", "M&O Ratio", fmtPct(forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(forecast.mo * sahResult.standardAvailableHours)}h`, 3, "moHours / SAH"),
|
||||
n("chg.mo", "M&O Ratio", fmtPct(forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(forecast.mo * effectiveAvailableHours)}h`, 3, "moHours / SAH"),
|
||||
] : []),
|
||||
...(forecast.pdr > 0 ? [
|
||||
n("chg.pdr", "PD&R Ratio", fmtPct(forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(forecast.pdr * sahResult.standardAvailableHours)}h`, 3, "pdrHours / SAH"),
|
||||
n("chg.pdr", "PD&R Ratio", fmtPct(forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(forecast.pdr * effectiveAvailableHours)}h`, 3, "pdrHours / SAH"),
|
||||
] : []),
|
||||
...(forecast.absence > 0 ? [
|
||||
n("chg.absence", "Absence Ratio", fmtPct(forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(forecast.absence * sahResult.standardAvailableHours)}h`, 3, "absenceHours / SAH"),
|
||||
n("chg.absence", "Absence Ratio", fmtPct(forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(forecast.absence * effectiveAvailableHours)}h`, 3, "absenceHours / SAH"),
|
||||
] : []),
|
||||
n("chg.unassigned", "Unassigned", fmtPct(forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(forecast.unassigned * sahResult.standardAvailableHours)}h of ${fmtNum(sahResult.standardAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"),
|
||||
n("chg.unassigned", "Unassigned", fmtPct(forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(forecast.unassigned * effectiveAvailableHours)}h of ${fmtNum(effectiveAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"),
|
||||
n("chg.target", "Target", fmtPct(targetPct), "%", "CHARGEABILITY", "Chargeability target from management level", 3),
|
||||
n("chg.gap", "Gap to Target", `${forecast.chg - targetPct >= 0 ? "+" : ""}${((forecast.chg - targetPct) * 100).toFixed(1)} pp`, "pp", "CHARGEABILITY", `Chargeability (${fmtPct(forecast.chg)}) vs. target (${fmtPct(targetPct)})`, 3, "chargeability − target"),
|
||||
|
||||
@@ -414,7 +516,16 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
|
||||
const links: GraphLink[] = [
|
||||
// INPUT → SAH
|
||||
l("input.country", "input.holidayContext", "holiday base", 1),
|
||||
l("input.state", "input.holidayContext", "regional scope", 1),
|
||||
l("input.city", "input.holidayContext", "local scope", 1),
|
||||
l("input.holidayContext", "input.holidayExamples", "resolve holidays", 1),
|
||||
l("input.dailyHours", "sah.grossWorkingDays", "base hours", 1),
|
||||
l("input.weeklyAvail", "sah.grossWorkingDays", "working-day pattern", 2),
|
||||
l("input.weeklyAvail", "sah.baseHours", "sum by weekday", 2),
|
||||
l("input.holidayExamples", "sah.publicHolidayDays", "resolved dates", 2),
|
||||
l("input.holidayExamples", "sah.publicHolidayHours", "remove matching day hours", 2),
|
||||
l("input.absences", "sah.absenceHours", "remove absence fractions", 1),
|
||||
...(hasScheduleRules ? [
|
||||
l("input.scheduleRules", "sah.effectiveHoursPerDay", "variable h/day", 1),
|
||||
] : []),
|
||||
@@ -422,14 +533,14 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
l("sah.weekendDays", "sah.grossWorkingDays", "−", 1),
|
||||
l("input.publicHolidays", "sah.publicHolidayDays", "∩ workdays", 1),
|
||||
l("input.absences", "sah.absenceDays", "∩ workdays", 1),
|
||||
l("sah.grossWorkingDays", "sah.netWorkingDays", "−", 2),
|
||||
l("sah.grossWorkingDays", "sah.netWorkingDays", "− holiday/absence days", 2),
|
||||
l("sah.publicHolidayDays", "sah.netWorkingDays", "−", 1),
|
||||
l("sah.absenceDays", "sah.netWorkingDays", "−", 1),
|
||||
l("input.dailyHours", "sah.effectiveHoursPerDay", "×", 1),
|
||||
l("input.fte", "sah.effectiveHoursPerDay", "× FTE", 2),
|
||||
l("sah.baseHours", "sah.sah", "start from base capacity", 2),
|
||||
l("sah.publicHolidayHours", "sah.sah", "− holiday hours", 2),
|
||||
l("sah.absenceHours", "sah.sah", "− absence hours", 2),
|
||||
l("sah.sah", "sah.effectiveHoursPerDay", "÷", 1),
|
||||
l("sah.netWorkingDays", "sah.effectiveHoursPerDay", "÷", 1),
|
||||
l("sah.effectiveHoursPerDay", "sah.sah", "× netDays", 2),
|
||||
l("sah.netWorkingDays", "sah.sah", "×", 2),
|
||||
|
||||
// INPUT → ALLOCATION
|
||||
l("input.weeklyAvail", "alloc.totalHours", "caps h/day", 2),
|
||||
@@ -489,6 +600,30 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
resourceEid: resource.eid,
|
||||
month: input.month,
|
||||
assignmentCount: assignments.length,
|
||||
countryCode: resource.country?.code ?? null,
|
||||
countryName: resource.country?.name ?? null,
|
||||
federalState: resource.federalState ?? null,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
resolvedHolidays: resolvedHolidays.map((holiday) => ({
|
||||
date: holiday.date,
|
||||
name: holiday.name,
|
||||
scope: holiday.scope,
|
||||
calendarName: holiday.calendarName,
|
||||
})),
|
||||
factors: {
|
||||
weeklyAvailability,
|
||||
baseWorkingDays,
|
||||
effectiveWorkingDays,
|
||||
baseAvailableHours,
|
||||
effectiveAvailableHours,
|
||||
publicHolidayCount,
|
||||
publicHolidayWorkdayCount,
|
||||
publicHolidayHoursDeduction,
|
||||
absenceDayCount: absenceDateStrings.length,
|
||||
absenceHoursDeduction,
|
||||
chargeableHours,
|
||||
utilizationPct,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -9,19 +9,19 @@ import { z } from "zod";
|
||||
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
|
||||
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
|
||||
import { createAuditEntry } from "../lib/audit.js";
|
||||
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
|
||||
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
|
||||
|
||||
/** Types that consume from annual leave balance */
|
||||
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
|
||||
|
||||
/**
|
||||
* Count calendar days between two dates (inclusive).
|
||||
* Half-day vacations count as 0.5.
|
||||
*/
|
||||
function countDays(startDate: Date, endDate: Date, isHalfDay: boolean): number {
|
||||
if (isHalfDay) return 0.5;
|
||||
const ms = endDate.getTime() - startDate.getTime();
|
||||
return Math.round(ms / 86_400_000) + 1;
|
||||
}
|
||||
type EntitlementSnapshot = {
|
||||
id: string;
|
||||
entitledDays: number;
|
||||
carryoverDays: number;
|
||||
usedDays: number;
|
||||
pendingDays: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get or create an entitlement record, applying carryover from previous year if needed.
|
||||
@@ -61,6 +61,14 @@ async function getOrCreateEntitlement(
|
||||
return entitlement;
|
||||
}
|
||||
|
||||
function calculateCarryoverDays(entitlement: {
|
||||
entitledDays: number;
|
||||
usedDays: number;
|
||||
pendingDays: number;
|
||||
}): number {
|
||||
return Math.max(0, entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute used/pending days from actual vacation records and update the cached values.
|
||||
*/
|
||||
@@ -69,14 +77,57 @@ async function syncEntitlement(
|
||||
resourceId: string,
|
||||
year: number,
|
||||
defaultDays: number,
|
||||
) {
|
||||
visitedYears: Set<number> = new Set(),
|
||||
): Promise<EntitlementSnapshot> {
|
||||
if (visitedYears.has(year)) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Detected recursive entitlement sync for year ${year}`,
|
||||
});
|
||||
}
|
||||
visitedYears.add(year);
|
||||
|
||||
let previousYearEntitlement: EntitlementSnapshot | null = await db.vacationEntitlement.findUnique({
|
||||
where: { resourceId_year: { resourceId, year: year - 1 } },
|
||||
});
|
||||
|
||||
if (previousYearEntitlement) {
|
||||
previousYearEntitlement = await syncEntitlement(
|
||||
db,
|
||||
resourceId,
|
||||
year - 1,
|
||||
defaultDays,
|
||||
visitedYears,
|
||||
);
|
||||
}
|
||||
|
||||
const entitlement = await getOrCreateEntitlement(db, resourceId, year, defaultDays);
|
||||
const carryoverDays = previousYearEntitlement
|
||||
? calculateCarryoverDays(previousYearEntitlement)
|
||||
: 0;
|
||||
const expectedEntitledDays = defaultDays + carryoverDays;
|
||||
const entitlementWithCarryover = (
|
||||
entitlement.carryoverDays !== carryoverDays
|
||||
|| entitlement.entitledDays !== expectedEntitledDays
|
||||
)
|
||||
? await db.vacationEntitlement.update({
|
||||
where: { id: entitlement.id },
|
||||
data: {
|
||||
carryoverDays,
|
||||
entitledDays: expectedEntitledDays,
|
||||
},
|
||||
})
|
||||
: entitlement;
|
||||
const yearStart = new Date(`${year}-01-01T00:00:00.000Z`);
|
||||
const yearEnd = new Date(`${year}-12-31T00:00:00.000Z`);
|
||||
const holidayContext = await loadResourceHolidayContext(db, resourceId, yearStart, yearEnd);
|
||||
|
||||
const vacations = await db.vacation.findMany({
|
||||
where: {
|
||||
resourceId,
|
||||
type: { in: BALANCE_TYPES },
|
||||
startDate: { gte: new Date(`${year}-01-01`), lte: new Date(`${year}-12-31`) },
|
||||
startDate: { lte: yearEnd },
|
||||
endDate: { gte: yearStart },
|
||||
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
|
||||
},
|
||||
select: { startDate: true, endDate: true, status: true, isHalfDay: true },
|
||||
@@ -86,13 +137,22 @@ async function syncEntitlement(
|
||||
let pendingDays = 0;
|
||||
|
||||
for (const v of vacations) {
|
||||
const days = countDays(v.startDate, v.endDate, v.isHalfDay);
|
||||
const days = countVacationChargeableDays({
|
||||
vacation: v,
|
||||
periodStart: yearStart,
|
||||
periodEnd: yearEnd,
|
||||
countryCode: holidayContext.countryCode,
|
||||
federalState: holidayContext.federalState,
|
||||
metroCityName: holidayContext.metroCityName,
|
||||
calendarHolidayStrings: holidayContext.calendarHolidayStrings,
|
||||
publicHolidayStrings: holidayContext.publicHolidayStrings,
|
||||
});
|
||||
if (v.status === VacationStatus.APPROVED) usedDays += days;
|
||||
else pendingDays += days;
|
||||
}
|
||||
|
||||
return db.vacationEntitlement.update({
|
||||
where: { id: entitlement.id },
|
||||
where: { id: entitlementWithCarryover.id },
|
||||
data: { usedDays, pendingDays },
|
||||
});
|
||||
}
|
||||
@@ -134,17 +194,23 @@ export const entitlementRouter = createTRPCRouter({
|
||||
const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
|
||||
|
||||
// Also count sick days (informational)
|
||||
const sickVacations = await ctx.db.vacation.findMany({
|
||||
const sickVacationsResult = await ctx.db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: input.resourceId,
|
||||
type: VacationType.SICK,
|
||||
status: VacationStatus.APPROVED,
|
||||
startDate: { gte: new Date(`${input.year}-01-01`), lte: new Date(`${input.year}-12-31`) },
|
||||
startDate: { lte: new Date(`${input.year}-12-31T00:00:00.000Z`) },
|
||||
endDate: { gte: new Date(`${input.year}-01-01T00:00:00.000Z`) },
|
||||
},
|
||||
select: { startDate: true, endDate: true, isHalfDay: true },
|
||||
});
|
||||
const sickVacations = Array.isArray(sickVacationsResult) ? sickVacationsResult : [];
|
||||
const sickDays = sickVacations.reduce(
|
||||
(sum, v) => sum + countDays(v.startDate, v.endDate, v.isHalfDay),
|
||||
(sum, v) => sum + countCalendarDaysInPeriod(
|
||||
v,
|
||||
new Date(`${input.year}-01-01T00:00:00.000Z`),
|
||||
new Date(`${input.year}-12-31T00:00:00.000Z`),
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -171,7 +237,7 @@ export const entitlementRouter = createTRPCRouter({
|
||||
.query(async ({ ctx, input }) => {
|
||||
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
||||
const defaultDays = settings?.vacationDefaultDays ?? 28;
|
||||
return getOrCreateEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
|
||||
return syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
import {
|
||||
CreateHolidayCalendarEntrySchema,
|
||||
CreateHolidayCalendarSchema,
|
||||
type HolidayCalendarScopeInput,
|
||||
PreviewResolvedHolidaysSchema,
|
||||
UpdateHolidayCalendarEntrySchema,
|
||||
UpdateHolidayCalendarSchema,
|
||||
} from "@capakraken/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import { createAuditEntry } from "../lib/audit.js";
|
||||
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
|
||||
import { createTRPCRouter, adminProcedure, protectedProcedure, type TRPCContext } from "../trpc.js";
|
||||
|
||||
type HolidayCalendarScope = HolidayCalendarScopeInput;
|
||||
|
||||
const HOLIDAY_SCOPE = {
|
||||
COUNTRY: "COUNTRY",
|
||||
STATE: "STATE",
|
||||
CITY: "CITY",
|
||||
} as const satisfies Record<HolidayCalendarScope, HolidayCalendarScope>;
|
||||
|
||||
type HolidayCalendarDb = TRPCContext["db"] & {
|
||||
holidayCalendar: {
|
||||
findFirst: (args: unknown) => Promise<{ id: string } | null>;
|
||||
findMany: (args: unknown) => Promise<any[]>;
|
||||
findUnique: (args: unknown) => Promise<any | null>;
|
||||
create: (args: unknown) => Promise<any>;
|
||||
update: (args: unknown) => Promise<any>;
|
||||
delete: (args: unknown) => Promise<any>;
|
||||
};
|
||||
holidayCalendarEntry: {
|
||||
findFirst: (args: unknown) => Promise<{ id: string } | null>;
|
||||
findUnique: (args: unknown) => Promise<any | null>;
|
||||
create: (args: unknown) => Promise<any>;
|
||||
update: (args: unknown) => Promise<any>;
|
||||
delete: (args: unknown) => Promise<any>;
|
||||
};
|
||||
};
|
||||
|
||||
function asHolidayCalendarDb(db: TRPCContext["db"]): HolidayCalendarDb {
|
||||
return db as unknown as HolidayCalendarDb;
|
||||
}
|
||||
|
||||
function clampDate(date: Date): Date {
|
||||
const value = new Date(date);
|
||||
value.setUTCHours(0, 0, 0, 0);
|
||||
return value;
|
||||
}
|
||||
|
||||
async function assertEntryDateAvailable(
|
||||
db: HolidayCalendarDb,
|
||||
input: {
|
||||
holidayCalendarId: string;
|
||||
date: Date;
|
||||
},
|
||||
ignoreId?: string,
|
||||
) {
|
||||
const existing = await db.holidayCalendarEntry.findFirst({
|
||||
where: {
|
||||
holidayCalendarId: input.holidayCalendarId,
|
||||
date: clampDate(input.date),
|
||||
...(ignoreId ? { id: { not: ignoreId } } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "A holiday entry for this calendar and date already exists",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function assertScopeConsistency(
|
||||
db: HolidayCalendarDb,
|
||||
input: {
|
||||
scopeType: HolidayCalendarScope;
|
||||
countryId: string;
|
||||
stateCode?: string | null;
|
||||
metroCityId?: string | null;
|
||||
},
|
||||
ignoreId?: string,
|
||||
) {
|
||||
if (input.scopeType === HOLIDAY_SCOPE.COUNTRY) {
|
||||
if (input.stateCode || input.metroCityId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Country calendars may not define a state or metro city",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (input.scopeType === HOLIDAY_SCOPE.STATE) {
|
||||
if (!input.stateCode) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "State calendars require a state code",
|
||||
});
|
||||
}
|
||||
if (input.metroCityId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "State calendars may not define a metro city",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (input.scopeType === HOLIDAY_SCOPE.CITY) {
|
||||
if (!input.metroCityId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "City calendars require a metro city",
|
||||
});
|
||||
}
|
||||
|
||||
const metroCity = await findUniqueOrThrow(
|
||||
db.metroCity.findUnique({
|
||||
where: { id: input.metroCityId },
|
||||
select: { id: true, countryId: true },
|
||||
}),
|
||||
"Metro city",
|
||||
);
|
||||
|
||||
if (metroCity.countryId !== input.countryId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Metro city must belong to the selected country",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await db.holidayCalendar.findFirst({
|
||||
where: {
|
||||
countryId: input.countryId,
|
||||
scopeType: input.scopeType,
|
||||
...(input.scopeType === HOLIDAY_SCOPE.STATE ? { stateCode: input.stateCode ?? null } : {}),
|
||||
...(input.scopeType === HOLIDAY_SCOPE.CITY ? { metroCityId: input.metroCityId ?? null } : {}),
|
||||
...(ignoreId ? { id: { not: ignoreId } } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "A holiday calendar for this exact scope already exists",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const holidayCalendarRouter = createTRPCRouter({
|
||||
listCalendars: protectedProcedure
|
||||
.input(z.object({ includeInactive: z.boolean().optional() }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const db = asHolidayCalendarDb(ctx.db);
|
||||
const where = input?.includeInactive ? undefined : { isActive: true };
|
||||
|
||||
return db.holidayCalendar.findMany({
|
||||
...(where ? { where } : {}),
|
||||
include: {
|
||||
country: { select: { id: true, code: true, name: true } },
|
||||
metroCity: { select: { id: true, name: true } },
|
||||
_count: { select: { entries: true } },
|
||||
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
|
||||
},
|
||||
orderBy: [
|
||||
{ country: { name: "asc" } },
|
||||
{ scopeType: "asc" },
|
||||
{ priority: "desc" },
|
||||
{ name: "asc" },
|
||||
],
|
||||
});
|
||||
}),
|
||||
|
||||
getCalendarById: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const db = asHolidayCalendarDb(ctx.db);
|
||||
|
||||
return findUniqueOrThrow(
|
||||
db.holidayCalendar.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
country: { select: { id: true, code: true, name: true } },
|
||||
metroCity: { select: { id: true, name: true } },
|
||||
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
|
||||
},
|
||||
}),
|
||||
"Holiday calendar",
|
||||
);
|
||||
}),
|
||||
|
||||
createCalendar: adminProcedure
|
||||
.input(CreateHolidayCalendarSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const db = asHolidayCalendarDb(ctx.db);
|
||||
|
||||
await findUniqueOrThrow(
|
||||
ctx.db.country.findUnique({
|
||||
where: { id: input.countryId },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
"Country",
|
||||
);
|
||||
|
||||
await assertScopeConsistency(db, {
|
||||
scopeType: input.scopeType,
|
||||
countryId: input.countryId,
|
||||
stateCode: input.stateCode?.trim().toUpperCase() ?? null,
|
||||
metroCityId: input.metroCityId ?? null,
|
||||
});
|
||||
|
||||
const created = await db.holidayCalendar.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
scopeType: input.scopeType,
|
||||
countryId: input.countryId,
|
||||
...(input.stateCode ? { stateCode: input.stateCode.trim().toUpperCase() } : {}),
|
||||
...(input.metroCityId ? { metroCityId: input.metroCityId } : {}),
|
||||
isActive: input.isActive ?? true,
|
||||
priority: input.priority ?? 0,
|
||||
},
|
||||
include: {
|
||||
country: { select: { id: true, code: true, name: true } },
|
||||
metroCity: { select: { id: true, name: true } },
|
||||
entries: true,
|
||||
},
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "HolidayCalendar",
|
||||
entityId: created.id,
|
||||
entityName: created.name,
|
||||
action: "CREATE",
|
||||
userId: ctx.dbUser?.id,
|
||||
after: created as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
});
|
||||
|
||||
return created;
|
||||
}),
|
||||
|
||||
updateCalendar: adminProcedure
|
||||
.input(z.object({ id: z.string(), data: UpdateHolidayCalendarSchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const db = asHolidayCalendarDb(ctx.db);
|
||||
const existing = await findUniqueOrThrow<any>(
|
||||
db.holidayCalendar.findUnique({ where: { id: input.id } }),
|
||||
"Holiday calendar",
|
||||
);
|
||||
|
||||
const stateCode = input.data.stateCode === undefined
|
||||
? existing.stateCode
|
||||
: input.data.stateCode?.trim().toUpperCase() ?? null;
|
||||
const metroCityId = input.data.metroCityId === undefined
|
||||
? existing.metroCityId
|
||||
: input.data.metroCityId ?? null;
|
||||
|
||||
await assertScopeConsistency(db, {
|
||||
scopeType: existing.scopeType,
|
||||
countryId: existing.countryId,
|
||||
stateCode,
|
||||
metroCityId,
|
||||
}, existing.id);
|
||||
|
||||
const updated = await db.holidayCalendar.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
...(input.data.name !== undefined ? { name: input.data.name } : {}),
|
||||
...(input.data.stateCode !== undefined ? { stateCode } : {}),
|
||||
...(input.data.metroCityId !== undefined ? { metroCityId } : {}),
|
||||
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
|
||||
...(input.data.priority !== undefined ? { priority: input.data.priority } : {}),
|
||||
},
|
||||
include: {
|
||||
country: { select: { id: true, code: true, name: true } },
|
||||
metroCity: { select: { id: true, name: true } },
|
||||
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
|
||||
},
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "HolidayCalendar",
|
||||
entityId: updated.id,
|
||||
entityName: updated.name,
|
||||
action: "UPDATE",
|
||||
userId: ctx.dbUser?.id,
|
||||
before: existing as unknown as Record<string, unknown>,
|
||||
after: updated as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
});
|
||||
|
||||
return updated;
|
||||
}),
|
||||
|
||||
deleteCalendar: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const db = asHolidayCalendarDb(ctx.db);
|
||||
const existing = await findUniqueOrThrow<any>(
|
||||
db.holidayCalendar.findUnique({
|
||||
where: { id: input.id },
|
||||
include: { entries: true },
|
||||
}),
|
||||
"Holiday calendar",
|
||||
);
|
||||
|
||||
await db.holidayCalendar.delete({ where: { id: input.id } });
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "HolidayCalendar",
|
||||
entityId: existing.id,
|
||||
entityName: existing.name,
|
||||
action: "DELETE",
|
||||
userId: ctx.dbUser?.id,
|
||||
before: existing as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
createEntry: adminProcedure
|
||||
.input(CreateHolidayCalendarEntrySchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const db = asHolidayCalendarDb(ctx.db);
|
||||
|
||||
await findUniqueOrThrow(
|
||||
db.holidayCalendar.findUnique({
|
||||
where: { id: input.holidayCalendarId },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
"Holiday calendar",
|
||||
);
|
||||
|
||||
await assertEntryDateAvailable(db, {
|
||||
holidayCalendarId: input.holidayCalendarId,
|
||||
date: input.date,
|
||||
});
|
||||
|
||||
const created = await db.holidayCalendarEntry.create({
|
||||
data: {
|
||||
holidayCalendarId: input.holidayCalendarId,
|
||||
date: clampDate(input.date),
|
||||
name: input.name,
|
||||
isRecurringAnnual: input.isRecurringAnnual ?? false,
|
||||
...(input.source ? { source: input.source } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "HolidayCalendarEntry",
|
||||
entityId: created.id,
|
||||
entityName: created.name,
|
||||
action: "CREATE",
|
||||
userId: ctx.dbUser?.id,
|
||||
after: created as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
});
|
||||
|
||||
return created;
|
||||
}),
|
||||
|
||||
updateEntry: adminProcedure
|
||||
.input(z.object({ id: z.string(), data: UpdateHolidayCalendarEntrySchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const db = asHolidayCalendarDb(ctx.db);
|
||||
const existing = await findUniqueOrThrow<any>(
|
||||
db.holidayCalendarEntry.findUnique({ where: { id: input.id } }),
|
||||
"Holiday calendar entry",
|
||||
);
|
||||
const nextDate = input.data.date !== undefined ? clampDate(input.data.date) : existing.date;
|
||||
|
||||
await assertEntryDateAvailable(db, {
|
||||
holidayCalendarId: existing.holidayCalendarId,
|
||||
date: nextDate,
|
||||
}, existing.id);
|
||||
|
||||
const updated = await db.holidayCalendarEntry.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
...(input.data.date !== undefined ? { date: nextDate } : {}),
|
||||
...(input.data.name !== undefined ? { name: input.data.name } : {}),
|
||||
...(input.data.isRecurringAnnual !== undefined ? { isRecurringAnnual: input.data.isRecurringAnnual } : {}),
|
||||
...(input.data.source !== undefined ? { source: input.data.source ?? null } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "HolidayCalendarEntry",
|
||||
entityId: updated.id,
|
||||
entityName: updated.name,
|
||||
action: "UPDATE",
|
||||
userId: ctx.dbUser?.id,
|
||||
before: existing as unknown as Record<string, unknown>,
|
||||
after: updated as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
});
|
||||
|
||||
return updated;
|
||||
}),
|
||||
|
||||
deleteEntry: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const db = asHolidayCalendarDb(ctx.db);
|
||||
const existing = await findUniqueOrThrow<any>(
|
||||
db.holidayCalendarEntry.findUnique({ where: { id: input.id } }),
|
||||
"Holiday calendar entry",
|
||||
);
|
||||
|
||||
await db.holidayCalendarEntry.delete({ where: { id: input.id } });
|
||||
|
||||
void createAuditEntry({
|
||||
db: ctx.db,
|
||||
entityType: "HolidayCalendarEntry",
|
||||
entityId: existing.id,
|
||||
entityName: existing.name,
|
||||
action: "DELETE",
|
||||
userId: ctx.dbUser?.id,
|
||||
before: existing as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
previewResolvedHolidays: protectedProcedure
|
||||
.input(PreviewResolvedHolidaysSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const country = await findUniqueOrThrow(
|
||||
ctx.db.country.findUnique({
|
||||
where: { id: input.countryId },
|
||||
select: { code: true },
|
||||
}),
|
||||
"Country",
|
||||
);
|
||||
|
||||
const metroCity = input.metroCityId
|
||||
? await ctx.db.metroCity.findUnique({
|
||||
where: { id: input.metroCityId },
|
||||
select: { name: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
|
||||
periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`),
|
||||
periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`),
|
||||
countryId: input.countryId,
|
||||
countryCode: country.code,
|
||||
federalState: input.stateCode?.trim().toUpperCase() ?? null,
|
||||
metroCityId: input.metroCityId ?? null,
|
||||
metroCityName: metroCity?.name ?? null,
|
||||
});
|
||||
|
||||
return resolved.map((holiday) => ({
|
||||
date: holiday.date,
|
||||
name: holiday.name,
|
||||
scopeType: holiday.scope,
|
||||
calendarName: holiday.calendarName,
|
||||
}));
|
||||
}),
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { effortRuleRouter } from "./effort-rule.js";
|
||||
import { experienceMultiplierRouter } from "./experience-multiplier.js";
|
||||
import { estimateRouter } from "./estimate.js";
|
||||
import { entitlementRouter } from "./entitlement.js";
|
||||
import { holidayCalendarRouter } from "./holiday-calendar.js";
|
||||
import { importExportRouter } from "./import-export.js";
|
||||
import { insightsRouter } from "./insights.js";
|
||||
import { managementLevelRouter } from "./management-level.js";
|
||||
@@ -55,6 +56,7 @@ export const appRouter = createTRPCRouter({
|
||||
insights: insightsRouter,
|
||||
vacation: vacationRouter,
|
||||
entitlement: entitlementRouter,
|
||||
holidayCalendar: holidayCalendarRouter,
|
||||
notification: notificationRouter,
|
||||
settings: settingsRouter,
|
||||
country: countryRouter,
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
countPlanningEntries,
|
||||
listAssignmentBookings,
|
||||
} from "@capakraken/application";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@capakraken/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
@@ -17,6 +18,10 @@ import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../ge
|
||||
import { invalidateDashboardCache } from "../lib/cache.js";
|
||||
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
||||
import { validateImageDataUrl } from "../lib/image-validation.js";
|
||||
import {
|
||||
calculateEffectiveBookedHours,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "../lib/resource-capacity.js";
|
||||
|
||||
const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload)
|
||||
|
||||
@@ -127,20 +132,53 @@ export const projectRouter = createTRPCRouter({
|
||||
|
||||
const assignments = await ctx.db.assignment.findMany({
|
||||
where: { projectId: input.projectId, status: { not: "CANCELLED" } },
|
||||
include: { resource: { include: { country: { select: { code: true } } } } },
|
||||
include: {
|
||||
resource: {
|
||||
include: {
|
||||
country: { select: { id: true, code: true } },
|
||||
metroCity: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const periodStart = assignments.length > 0
|
||||
? new Date(Math.min(...assignments.map((assignment) => assignment.startDate.getTime())))
|
||||
: new Date();
|
||||
const periodEnd = assignments.length > 0
|
||||
? new Date(Math.max(...assignments.map((assignment) => assignment.endDate.getTime())))
|
||||
: new Date();
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
assignments.map((assignment) => ({
|
||||
id: assignment.resource.id,
|
||||
availability: assignment.resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: assignment.resource.country?.id ?? assignment.resource.countryId,
|
||||
countryCode: assignment.resource.country?.code,
|
||||
federalState: assignment.resource.federalState,
|
||||
metroCityId: assignment.resource.metroCity?.id ?? assignment.resource.metroCityId,
|
||||
metroCityName: assignment.resource.metroCity?.name,
|
||||
})),
|
||||
periodStart,
|
||||
periodEnd,
|
||||
);
|
||||
|
||||
const mapped: ShoringAssignment[] = assignments.map((a) => {
|
||||
const start = new Date(a.startDate);
|
||||
const end = new Date(a.endDate);
|
||||
const diffMs = end.getTime() - start.getTime();
|
||||
const diffDays = Math.max(1, Math.round(diffMs / (1000 * 60 * 60 * 24)) + 1);
|
||||
const workingDays = Math.round(diffDays / 7 * 5);
|
||||
const workingDays = a.hoursPerDay > 0
|
||||
? calculateEffectiveBookedHours({
|
||||
availability: a.resource.availability as unknown as WeekdayAvailability,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context: contexts.get(a.resourceId ?? a.resource.id),
|
||||
}) / a.hoursPerDay
|
||||
: 0;
|
||||
return {
|
||||
resourceId: a.resourceId,
|
||||
countryCode: a.resource.country?.code ?? null,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
workingDays: Math.max(1, workingDays),
|
||||
workingDays: Math.max(0, workingDays),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@capakraken/db";
|
||||
import {
|
||||
isChargeabilityActualBooking,
|
||||
isChargeabilityRelevantProject,
|
||||
listAssignmentBookings,
|
||||
} from "@capakraken/application";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours,
|
||||
countEffectiveWorkingDays,
|
||||
getAvailabilityHoursForDate,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "../lib/resource-capacity.js";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
// ─── Column Definitions ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -30,6 +44,7 @@ const RESOURCE_COLUMNS: ColumnDef[] = [
|
||||
{ key: "departed", label: "Departed", dataType: "boolean" },
|
||||
{ key: "postalCode", label: "Postal Code", dataType: "string" },
|
||||
{ key: "federalState", label: "Federal State", dataType: "string" },
|
||||
{ key: "country.code", label: "Country Code", dataType: "string", prismaPath: "country" },
|
||||
{ key: "country.name", label: "Country", dataType: "string", prismaPath: "country" },
|
||||
{ key: "metroCity.name", label: "Metro City", dataType: "string", prismaPath: "metroCity" },
|
||||
{ key: "orgUnit.name", label: "Org Unit", dataType: "string", prismaPath: "orgUnit" },
|
||||
@@ -49,6 +64,7 @@ const PROJECT_COLUMNS: ColumnDef[] = [
|
||||
{ key: "status", label: "Status", dataType: "string" },
|
||||
{ key: "winProbability", label: "Win Probability (%)", dataType: "number" },
|
||||
{ key: "budgetCents", label: "Budget (cents)", dataType: "number" },
|
||||
{ key: "clientId", label: "Client ID", dataType: "string" },
|
||||
{ key: "startDate", label: "Start Date", dataType: "date" },
|
||||
{ key: "endDate", label: "End Date", dataType: "date" },
|
||||
{ key: "responsiblePerson", label: "Responsible Person", dataType: "string" },
|
||||
@@ -61,10 +77,19 @@ const PROJECT_COLUMNS: ColumnDef[] = [
|
||||
|
||||
const ASSIGNMENT_COLUMNS: ColumnDef[] = [
|
||||
{ key: "id", label: "ID", dataType: "string" },
|
||||
{ key: "resourceId", label: "Resource ID", dataType: "string" },
|
||||
{ key: "projectId", label: "Project ID", dataType: "string" },
|
||||
{ key: "resource.displayName", label: "Resource", dataType: "string", prismaPath: "resource" },
|
||||
{ key: "resource.eid", label: "Resource EID", dataType: "string", prismaPath: "resource" },
|
||||
{ key: "resource.chapter", label: "Resource Chapter", dataType: "string", prismaPath: "resource" },
|
||||
{ key: "resource.country.code", label: "Resource Country Code", dataType: "string", prismaPath: "resource" },
|
||||
{ key: "resource.federalState", label: "Resource State", dataType: "string", prismaPath: "resource" },
|
||||
{ key: "resource.country.name", label: "Resource Country", dataType: "string", prismaPath: "resource" },
|
||||
{ key: "resource.metroCity.name", label: "Resource City", dataType: "string", prismaPath: "resource" },
|
||||
{ key: "project.name", label: "Project", dataType: "string", prismaPath: "project" },
|
||||
{ key: "project.shortCode", label: "Project Code", dataType: "string", prismaPath: "project" },
|
||||
{ key: "project.status", label: "Project Status", dataType: "string", prismaPath: "project" },
|
||||
{ key: "project.client.name", label: "Project Client", dataType: "string", prismaPath: "project" },
|
||||
{ key: "startDate", label: "Start Date", dataType: "date" },
|
||||
{ key: "endDate", label: "End Date", dataType: "date" },
|
||||
{ key: "hoursPerDay", label: "Hours/Day", dataType: "number" },
|
||||
@@ -77,10 +102,55 @@ const ASSIGNMENT_COLUMNS: ColumnDef[] = [
|
||||
{ key: "updatedAt", label: "Updated At", dataType: "date" },
|
||||
];
|
||||
|
||||
const RESOURCE_MONTH_COLUMNS: ColumnDef[] = [
|
||||
{ key: "id", label: "Row ID", dataType: "string" },
|
||||
{ key: "resourceId", label: "Resource ID", dataType: "string" },
|
||||
{ key: "monthKey", label: "Month", dataType: "string" },
|
||||
{ key: "periodStart", label: "Period Start", dataType: "date" },
|
||||
{ key: "periodEnd", label: "Period End", dataType: "date" },
|
||||
{ key: "eid", label: "Employee ID", dataType: "string" },
|
||||
{ key: "displayName", label: "Name", dataType: "string" },
|
||||
{ key: "email", label: "Email", dataType: "string" },
|
||||
{ key: "chapter", label: "Chapter", dataType: "string" },
|
||||
{ key: "resourceType", label: "Resource Type", dataType: "string" },
|
||||
{ key: "isActive", label: "Active", dataType: "boolean" },
|
||||
{ key: "chgResponsibility", label: "Chg Responsibility", dataType: "boolean" },
|
||||
{ key: "rolledOff", label: "Rolled Off", dataType: "boolean" },
|
||||
{ key: "departed", label: "Departed", dataType: "boolean" },
|
||||
{ key: "countryCode", label: "Country Code", dataType: "string" },
|
||||
{ key: "countryName", label: "Country", dataType: "string" },
|
||||
{ key: "federalState", label: "Federal State", dataType: "string" },
|
||||
{ key: "metroCityName", label: "Metro City", dataType: "string" },
|
||||
{ key: "orgUnitName", label: "Org Unit", dataType: "string" },
|
||||
{ key: "managementLevelGroupName", label: "Mgmt Level Group", dataType: "string" },
|
||||
{ key: "managementLevelName", label: "Mgmt Level", dataType: "string" },
|
||||
{ key: "fte", label: "FTE", dataType: "number" },
|
||||
{ key: "lcrCents", label: "LCR (cents)", dataType: "number" },
|
||||
{ key: "ucrCents", label: "UCR (cents)", dataType: "number" },
|
||||
{ key: "currency", label: "Currency", dataType: "string" },
|
||||
{ key: "monthlyChargeabilityTargetPct", label: "Target Chargeability (%)", dataType: "number" },
|
||||
{ key: "monthlyTargetHours", label: "Target Hours", dataType: "number" },
|
||||
{ key: "monthlyBaseWorkingDays", label: "Base Working Days", dataType: "number" },
|
||||
{ key: "monthlyEffectiveWorkingDays", label: "Effective Working Days", dataType: "number" },
|
||||
{ key: "monthlyBaseAvailableHours", label: "Base Available Hours", dataType: "number" },
|
||||
{ key: "monthlySahHours", label: "SAH", dataType: "number" },
|
||||
{ key: "monthlyPublicHolidayCount", label: "Holiday Dates", dataType: "number" },
|
||||
{ key: "monthlyPublicHolidayWorkdayCount", label: "Holiday Workdays", dataType: "number" },
|
||||
{ key: "monthlyPublicHolidayHoursDeduction", label: "Holiday Hours Deduction", dataType: "number" },
|
||||
{ key: "monthlyAbsenceDayEquivalent", label: "Absence Day Equivalent", dataType: "number" },
|
||||
{ key: "monthlyAbsenceHoursDeduction", label: "Absence Hours Deduction", dataType: "number" },
|
||||
{ key: "monthlyActualBookedHours", label: "Actual Booked Hours", dataType: "number" },
|
||||
{ key: "monthlyExpectedBookedHours", label: "Expected Booked Hours", dataType: "number" },
|
||||
{ key: "monthlyActualChargeabilityPct", label: "Actual Chargeability (%)", dataType: "number" },
|
||||
{ key: "monthlyExpectedChargeabilityPct", label: "Expected Chargeability (%)", dataType: "number" },
|
||||
{ key: "monthlyUnassignedHours", label: "Unassigned Hours", dataType: "number" },
|
||||
];
|
||||
|
||||
const COLUMN_MAP: Record<EntityKey, ColumnDef[]> = {
|
||||
resource: RESOURCE_COLUMNS,
|
||||
project: PROJECT_COLUMNS,
|
||||
assignment: ASSIGNMENT_COLUMNS,
|
||||
resource_month: RESOURCE_MONTH_COLUMNS,
|
||||
};
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -89,6 +159,7 @@ const ENTITY_MAP = {
|
||||
resource: "resource",
|
||||
project: "project",
|
||||
assignment: "assignment",
|
||||
resource_month: "resource_month",
|
||||
} as const;
|
||||
|
||||
type EntityKey = keyof typeof ENTITY_MAP;
|
||||
@@ -110,6 +181,7 @@ const ALLOWED_SCALAR_FIELDS: Record<EntityKey, Set<string>> = {
|
||||
"id", "startDate", "endDate", "hoursPerDay", "percentage",
|
||||
"role", "dailyCostCents", "status", "createdAt", "updatedAt",
|
||||
]),
|
||||
resource_month: new Set(RESOURCE_MONTH_COLUMNS.map((column) => column.key)),
|
||||
};
|
||||
|
||||
function getValidScalarField(entity: EntityKey, field: string): string | null {
|
||||
@@ -132,15 +204,14 @@ function buildSelect(entity: EntityKey, columns: string[]): Record<string, unkno
|
||||
if (!def) continue;
|
||||
|
||||
if (colKey.includes(".")) {
|
||||
// Relation column, e.g. "country.name" => select: { country: { select: { name: true } } }
|
||||
const relationName = def.prismaPath ?? colKey.split(".")[0]!;
|
||||
const fieldName = colKey.split(".").slice(1).join(".");
|
||||
const existing = select[relationName];
|
||||
if (existing && typeof existing === "object" && existing !== null && "select" in existing) {
|
||||
(existing as { select: Record<string, boolean> }).select[fieldName] = true;
|
||||
} else {
|
||||
select[relationName] = { select: { [fieldName]: true } };
|
||||
}
|
||||
const fieldSegments = colKey.split(".").slice(1);
|
||||
const relationSelect = existing && typeof existing === "object" && existing !== null && "select" in existing
|
||||
? (existing as { select: Record<string, unknown> }).select
|
||||
: {};
|
||||
mergeSelectPath(relationSelect, fieldSegments);
|
||||
select[relationName] = { select: relationSelect };
|
||||
} else {
|
||||
select[colKey] = true;
|
||||
}
|
||||
@@ -149,6 +220,29 @@ function buildSelect(entity: EntityKey, columns: string[]): Record<string, unkno
|
||||
return select;
|
||||
}
|
||||
|
||||
function mergeSelectPath(
|
||||
target: Record<string, unknown>,
|
||||
segments: string[],
|
||||
): void {
|
||||
const [head, ...tail] = segments;
|
||||
if (!head) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tail.length === 0) {
|
||||
target[head] = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = target[head];
|
||||
const nestedSelect = existing && typeof existing === "object" && existing !== null && "select" in existing
|
||||
? (existing as { select: Record<string, unknown> }).select
|
||||
: {};
|
||||
|
||||
mergeSelectPath(nestedSelect, tail);
|
||||
target[head] = { select: nestedSelect };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Prisma `where` from the filter array.
|
||||
* Only scalar top-level fields are allowed for safety.
|
||||
@@ -246,6 +340,8 @@ function csvEscape(value: unknown): string {
|
||||
|
||||
// ─── Input Schema ───────────────────────────────────────────────────────────
|
||||
|
||||
const reportEntitySchema = z.enum(["resource", "project", "assignment", "resource_month"]);
|
||||
|
||||
const FilterSchema = z.object({
|
||||
field: z.string().min(1),
|
||||
op: z.enum(["eq", "neq", "gt", "lt", "gte", "lte", "contains", "in"]),
|
||||
@@ -253,24 +349,171 @@ const FilterSchema = z.object({
|
||||
});
|
||||
|
||||
const ReportInputSchema = z.object({
|
||||
entity: z.enum(["resource", "project", "assignment"]),
|
||||
entity: reportEntitySchema,
|
||||
columns: z.array(z.string()).min(1),
|
||||
filters: z.array(FilterSchema).default([]),
|
||||
groupBy: z.string().optional(),
|
||||
sortBy: z.string().optional(),
|
||||
sortDir: z.enum(["asc", "desc"]).default("asc"),
|
||||
periodMonth: z.string().regex(/^\d{4}-\d{2}$/).optional(),
|
||||
limit: z.number().int().min(1).max(5000).default(50),
|
||||
offset: z.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
const ReportTemplateConfigSchema = ReportInputSchema.omit({ limit: true, offset: true });
|
||||
|
||||
const ReportTemplateEntity = {
|
||||
RESOURCE: "RESOURCE",
|
||||
PROJECT: "PROJECT",
|
||||
ASSIGNMENT: "ASSIGNMENT",
|
||||
RESOURCE_MONTH: "RESOURCE_MONTH",
|
||||
} as const;
|
||||
|
||||
type ReportTemplateEntity = (typeof ReportTemplateEntity)[keyof typeof ReportTemplateEntity];
|
||||
|
||||
type ReportTemplateRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
entity: ReportTemplateEntity;
|
||||
config: unknown;
|
||||
isShared: boolean;
|
||||
ownerId: string;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
function getReportTemplateDelegate(db: unknown) {
|
||||
return (db as {
|
||||
reportTemplate: {
|
||||
findMany: (args: unknown) => Promise<ReportTemplateRecord[]>;
|
||||
findUnique: (args: unknown) => Promise<{ ownerId: string } | null>;
|
||||
update: (args: unknown) => Promise<{ id: string; updatedAt: Date }>;
|
||||
upsert: (args: unknown) => Promise<{ id: string; updatedAt: Date }>;
|
||||
delete: (args: unknown) => Promise<unknown>;
|
||||
};
|
||||
}).reportTemplate;
|
||||
}
|
||||
|
||||
// ─── Router ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const reportRouter = createTRPCRouter({
|
||||
listTemplates: controllerProcedure.query(async ({ ctx }) => {
|
||||
const reportTemplate = getReportTemplateDelegate(ctx.db);
|
||||
const templates = await reportTemplate.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: ctx.dbUser!.id },
|
||||
{ isShared: true },
|
||||
],
|
||||
},
|
||||
orderBy: [{ name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
entity: true,
|
||||
config: true,
|
||||
isShared: true,
|
||||
ownerId: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return templates.map((template: ReportTemplateRecord) => ({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
entity: fromTemplateEntity(template.entity),
|
||||
config: ReportTemplateConfigSchema.parse(template.config),
|
||||
isShared: template.isShared,
|
||||
isOwner: template.ownerId === ctx.dbUser!.id,
|
||||
updatedAt: template.updatedAt,
|
||||
}));
|
||||
}),
|
||||
|
||||
saveTemplate: controllerProcedure
|
||||
.input(z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().max(500).optional(),
|
||||
isShared: z.boolean().default(false),
|
||||
config: ReportTemplateConfigSchema,
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const reportTemplate = getReportTemplateDelegate(ctx.db);
|
||||
const payload = input.config as unknown as Prisma.InputJsonValue;
|
||||
const entity = toTemplateEntity(input.config.entity);
|
||||
|
||||
if (input.id) {
|
||||
const existing = await reportTemplate.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
|
||||
if (!existing || existing.ownerId !== ctx.dbUser!.id) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Template cannot be updated" });
|
||||
}
|
||||
|
||||
return reportTemplate.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
entity,
|
||||
config: payload,
|
||||
isShared: input.isShared,
|
||||
},
|
||||
select: { id: true, updatedAt: true },
|
||||
});
|
||||
}
|
||||
|
||||
return reportTemplate.upsert({
|
||||
where: {
|
||||
ownerId_name: {
|
||||
ownerId: ctx.dbUser!.id,
|
||||
name: input.name,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
description: input.description,
|
||||
entity,
|
||||
config: payload,
|
||||
isShared: input.isShared,
|
||||
},
|
||||
create: {
|
||||
ownerId: ctx.dbUser!.id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
entity,
|
||||
config: payload,
|
||||
isShared: input.isShared,
|
||||
},
|
||||
select: { id: true, updatedAt: true },
|
||||
});
|
||||
}),
|
||||
|
||||
deleteTemplate: controllerProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const reportTemplate = getReportTemplateDelegate(ctx.db);
|
||||
const existing = await reportTemplate.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
|
||||
if (!existing || existing.ownerId !== ctx.dbUser!.id) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Template cannot be deleted" });
|
||||
}
|
||||
|
||||
await reportTemplate.delete({ where: { id: input.id } });
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Return available columns for a given entity type.
|
||||
*/
|
||||
getAvailableColumns: controllerProcedure
|
||||
.input(z.object({ entity: z.enum(["resource", "project", "assignment"]) }))
|
||||
.input(z.object({ entity: reportEntitySchema }))
|
||||
.query(({ input }) => {
|
||||
const columns = COLUMN_MAP[input.entity];
|
||||
if (!columns) {
|
||||
@@ -285,40 +528,7 @@ export const reportRouter = createTRPCRouter({
|
||||
getReportData: controllerProcedure
|
||||
.input(ReportInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { entity, columns, filters, sortBy, sortDir, limit, offset } = input;
|
||||
|
||||
const select = buildSelect(entity, columns);
|
||||
const where = buildWhere(entity, filters);
|
||||
|
||||
// Build orderBy (only scalar fields)
|
||||
let orderBy: Record<string, string> | undefined;
|
||||
if (sortBy) {
|
||||
const validField = getValidScalarField(entity, sortBy);
|
||||
if (validField) {
|
||||
orderBy = { [validField]: sortDir };
|
||||
}
|
||||
}
|
||||
|
||||
const modelDelegate = getModelDelegate(ctx.db, entity);
|
||||
|
||||
const [rawRows, totalCount] = await Promise.all([
|
||||
(modelDelegate as any).findMany({
|
||||
select,
|
||||
where,
|
||||
...(orderBy ? { orderBy } : {}),
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
(modelDelegate as any).count({ where }),
|
||||
]);
|
||||
|
||||
// Flatten nested relations into dot-notation keys
|
||||
const rows = (rawRows as Record<string, unknown>[]).map((row) => flattenRow(row));
|
||||
|
||||
// Ensure column order matches request (plus id)
|
||||
const outputColumns = ["id", ...columns.filter((c) => c !== "id")];
|
||||
|
||||
return { rows, columns: outputColumns, totalCount };
|
||||
return executeReportQuery(ctx.db, input);
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -329,33 +539,12 @@ export const reportRouter = createTRPCRouter({
|
||||
limit: z.number().int().min(1).max(50000).default(5000),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { entity, columns, filters, sortBy, sortDir, limit } = input;
|
||||
|
||||
const select = buildSelect(entity, columns);
|
||||
const where = buildWhere(entity, filters);
|
||||
|
||||
let orderBy: Record<string, string> | undefined;
|
||||
if (sortBy) {
|
||||
const validField = getValidScalarField(entity, sortBy);
|
||||
if (validField) {
|
||||
orderBy = { [validField]: sortDir };
|
||||
}
|
||||
}
|
||||
|
||||
const modelDelegate = getModelDelegate(ctx.db, entity);
|
||||
|
||||
const rawRows = await (modelDelegate as any).findMany({
|
||||
select,
|
||||
where,
|
||||
...(orderBy ? { orderBy } : {}),
|
||||
take: limit,
|
||||
});
|
||||
|
||||
const rows = (rawRows as Record<string, unknown>[]).map((row) => flattenRow(row));
|
||||
const outputColumns = ["id", ...columns.filter((c) => c !== "id")];
|
||||
const result = await executeReportQuery(ctx.db, { ...input, offset: 0 });
|
||||
const rows = result.rows;
|
||||
const outputColumns = result.columns;
|
||||
|
||||
// Build CSV
|
||||
const entityColumns = COLUMN_MAP[entity];
|
||||
const entityColumns = COLUMN_MAP[input.entity];
|
||||
const headerLabels = outputColumns.map((key) => {
|
||||
const def = entityColumns.find((c) => c.key === key);
|
||||
return def?.label ?? key;
|
||||
@@ -372,6 +561,385 @@ export const reportRouter = createTRPCRouter({
|
||||
}),
|
||||
});
|
||||
|
||||
type ReportInput = z.infer<typeof ReportInputSchema>;
|
||||
type FilterInput = z.infer<typeof FilterSchema>;
|
||||
|
||||
async function executeReportQuery(
|
||||
db: any,
|
||||
input: ReportInput,
|
||||
): Promise<{ rows: Record<string, unknown>[]; columns: string[]; totalCount: number }> {
|
||||
if (input.entity === "resource_month") {
|
||||
return executeResourceMonthReport(db, input);
|
||||
}
|
||||
|
||||
const { entity, columns, filters, sortBy, sortDir, limit, offset } = input;
|
||||
const select = buildSelect(entity, columns);
|
||||
const where = buildWhere(entity, filters);
|
||||
|
||||
let orderBy: Record<string, string> | undefined;
|
||||
if (sortBy) {
|
||||
const validField = getValidScalarField(entity, sortBy);
|
||||
if (validField) {
|
||||
orderBy = { [validField]: sortDir };
|
||||
}
|
||||
}
|
||||
|
||||
const modelDelegate = getModelDelegate(db, entity);
|
||||
const [rawRows, totalCount] = await Promise.all([
|
||||
(modelDelegate as any).findMany({
|
||||
select,
|
||||
where,
|
||||
...(orderBy ? { orderBy } : {}),
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
(modelDelegate as any).count({ where }),
|
||||
]);
|
||||
|
||||
const rows = (rawRows as Record<string, unknown>[]).map((row) => flattenRow(row));
|
||||
const outputColumns = ["id", ...columns.filter((column) => column !== "id")];
|
||||
|
||||
return {
|
||||
rows: rows.map((row) => pickColumns(row, outputColumns)),
|
||||
columns: outputColumns,
|
||||
totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function executeResourceMonthReport(
|
||||
db: any,
|
||||
input: ReportInput,
|
||||
): Promise<{ rows: Record<string, unknown>[]; columns: string[]; totalCount: number }> {
|
||||
const periodMonth = input.periodMonth ?? new Date().toISOString().slice(0, 7);
|
||||
const [year, month] = periodMonth.split("-").map(Number) as [number, number];
|
||||
const periodStart = new Date(Date.UTC(year, month - 1, 1));
|
||||
const periodEnd = new Date(Date.UTC(year, month, 0));
|
||||
|
||||
const resources = await db.resource.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
eid: true,
|
||||
displayName: true,
|
||||
email: true,
|
||||
chapter: true,
|
||||
resourceType: true,
|
||||
isActive: true,
|
||||
chgResponsibility: true,
|
||||
rolledOff: true,
|
||||
departed: true,
|
||||
lcrCents: true,
|
||||
ucrCents: true,
|
||||
currency: true,
|
||||
fte: true,
|
||||
availability: true,
|
||||
chargeabilityTarget: true,
|
||||
federalState: true,
|
||||
countryId: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true, name: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
orgUnit: { select: { name: true } },
|
||||
managementLevelGroup: { select: { name: true, targetPercentage: true } },
|
||||
managementLevel: { select: { name: true } },
|
||||
},
|
||||
orderBy: { displayName: "asc" },
|
||||
});
|
||||
|
||||
const resourceIds = resources.map((resource: any) => resource.id);
|
||||
const [bookings, contexts] = await Promise.all([
|
||||
resourceIds.length > 0
|
||||
? listAssignmentBookings(db, {
|
||||
startDate: periodStart,
|
||||
endDate: periodEnd,
|
||||
resourceIds,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
loadResourceDailyAvailabilityContexts(
|
||||
db,
|
||||
resources.map((resource: any) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
periodStart,
|
||||
periodEnd,
|
||||
),
|
||||
]);
|
||||
|
||||
const rows = resources.map((resource: any) => {
|
||||
const availability = resource.availability as WeekdayAvailability;
|
||||
const context = contexts.get(resource.id);
|
||||
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
|
||||
const baseWorkingDays = countEffectiveWorkingDays({
|
||||
availability,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context: undefined,
|
||||
});
|
||||
const effectiveWorkingDays = countEffectiveWorkingDays({
|
||||
availability,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context,
|
||||
});
|
||||
const baseAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context: undefined,
|
||||
});
|
||||
const sahHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context,
|
||||
});
|
||||
|
||||
const holidayDates = [...(context?.holidayDates ?? new Set<string>())];
|
||||
const publicHolidayWorkdayCount = holidayDates.reduce((count, isoDate) => (
|
||||
count + (getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0)
|
||||
), 0);
|
||||
const publicHolidayHoursDeduction = holidayDates.reduce((sum, isoDate) => (
|
||||
sum + getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`))
|
||||
), 0);
|
||||
|
||||
let absenceDayEquivalent = 0;
|
||||
let absenceHoursDeduction = 0;
|
||||
for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) {
|
||||
const dayHours = getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`));
|
||||
if (dayHours <= 0 || context?.holidayDates.has(isoDate)) {
|
||||
continue;
|
||||
}
|
||||
absenceDayEquivalent += fraction;
|
||||
absenceHoursDeduction += dayHours * fraction;
|
||||
}
|
||||
|
||||
const actualBookedHours = resourceBookings
|
||||
.filter((booking) => isChargeabilityActualBooking(booking, false))
|
||||
.reduce((sum, booking) => sum + calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context,
|
||||
}), 0);
|
||||
const expectedBookedHours = resourceBookings
|
||||
.filter((booking) => isChargeabilityRelevantProject(booking.project, true))
|
||||
.reduce((sum, booking) => sum + calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context,
|
||||
}), 0);
|
||||
|
||||
const targetPct = resource.managementLevelGroup?.targetPercentage != null
|
||||
? resource.managementLevelGroup.targetPercentage * 100
|
||||
: resource.chargeabilityTarget;
|
||||
|
||||
return {
|
||||
id: `${resource.id}:${periodMonth}`,
|
||||
resourceId: resource.id,
|
||||
monthKey: periodMonth,
|
||||
periodStart: periodStart.toISOString(),
|
||||
periodEnd: periodEnd.toISOString(),
|
||||
eid: resource.eid,
|
||||
displayName: resource.displayName,
|
||||
email: resource.email,
|
||||
chapter: resource.chapter,
|
||||
resourceType: resource.resourceType,
|
||||
isActive: resource.isActive,
|
||||
chgResponsibility: resource.chgResponsibility,
|
||||
rolledOff: resource.rolledOff,
|
||||
departed: resource.departed,
|
||||
countryCode: resource.country?.code ?? null,
|
||||
countryName: resource.country?.name ?? null,
|
||||
federalState: resource.federalState,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
orgUnitName: resource.orgUnit?.name ?? null,
|
||||
managementLevelGroupName: resource.managementLevelGroup?.name ?? null,
|
||||
managementLevelName: resource.managementLevel?.name ?? null,
|
||||
fte: roundMetric(resource.fte),
|
||||
lcrCents: resource.lcrCents,
|
||||
ucrCents: resource.ucrCents,
|
||||
currency: resource.currency,
|
||||
monthlyChargeabilityTargetPct: roundMetric(targetPct),
|
||||
monthlyTargetHours: roundMetric((sahHours * targetPct) / 100),
|
||||
monthlyBaseWorkingDays: roundMetric(baseWorkingDays),
|
||||
monthlyEffectiveWorkingDays: roundMetric(effectiveWorkingDays),
|
||||
monthlyBaseAvailableHours: roundMetric(baseAvailableHours),
|
||||
monthlySahHours: roundMetric(sahHours),
|
||||
monthlyPublicHolidayCount: holidayDates.length,
|
||||
monthlyPublicHolidayWorkdayCount: publicHolidayWorkdayCount,
|
||||
monthlyPublicHolidayHoursDeduction: roundMetric(publicHolidayHoursDeduction),
|
||||
monthlyAbsenceDayEquivalent: roundMetric(absenceDayEquivalent),
|
||||
monthlyAbsenceHoursDeduction: roundMetric(absenceHoursDeduction),
|
||||
monthlyActualBookedHours: roundMetric(actualBookedHours),
|
||||
monthlyExpectedBookedHours: roundMetric(expectedBookedHours),
|
||||
monthlyActualChargeabilityPct: roundMetric(sahHours > 0 ? (actualBookedHours / sahHours) * 100 : 0),
|
||||
monthlyExpectedChargeabilityPct: roundMetric(sahHours > 0 ? (expectedBookedHours / sahHours) * 100 : 0),
|
||||
monthlyUnassignedHours: roundMetric(Math.max(0, sahHours - actualBookedHours)),
|
||||
};
|
||||
});
|
||||
|
||||
const filteredRows = rows.filter((row: Record<string, unknown>) => input.filters.every((filter) => matchesInMemoryFilter(
|
||||
row,
|
||||
filter,
|
||||
RESOURCE_MONTH_COLUMNS,
|
||||
)));
|
||||
const sortedRows = sortInMemoryRows(filteredRows, input.sortBy, input.sortDir, RESOURCE_MONTH_COLUMNS);
|
||||
const totalCount = sortedRows.length;
|
||||
const pagedRows = sortedRows.slice(input.offset, input.offset + input.limit);
|
||||
const outputColumns = ["id", ...input.columns.filter((column) => column !== "id")];
|
||||
|
||||
return {
|
||||
rows: pagedRows.map((row) => pickColumns(row, outputColumns)),
|
||||
columns: outputColumns,
|
||||
totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
function parseFilterValue(def: ColumnDef | undefined, value: string): unknown {
|
||||
if (!def) return value;
|
||||
if (def.dataType === "number") {
|
||||
const parsed = Number(value);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
if (def.dataType === "boolean") {
|
||||
return value === "true";
|
||||
}
|
||||
if (def.dataType === "date") {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.getTime();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function matchesInMemoryFilter(
|
||||
row: Record<string, unknown>,
|
||||
filter: FilterInput,
|
||||
columns: ColumnDef[],
|
||||
): boolean {
|
||||
const def = columns.find((column) => column.key === filter.field);
|
||||
if (!def) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const rowValueRaw = row[filter.field];
|
||||
const rowValue = def.dataType === "date" && typeof rowValueRaw === "string"
|
||||
? new Date(rowValueRaw).getTime()
|
||||
: rowValueRaw;
|
||||
const parsedFilterValue = parseFilterValue(def, filter.value);
|
||||
|
||||
if (parsedFilterValue === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (filter.op) {
|
||||
case "eq":
|
||||
return rowValue === parsedFilterValue;
|
||||
case "neq":
|
||||
return rowValue !== parsedFilterValue;
|
||||
case "gt":
|
||||
return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue > parsedFilterValue;
|
||||
case "lt":
|
||||
return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue < parsedFilterValue;
|
||||
case "gte":
|
||||
return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue >= parsedFilterValue;
|
||||
case "lte":
|
||||
return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue <= parsedFilterValue;
|
||||
case "contains":
|
||||
return typeof rowValue === "string" && rowValue.toLowerCase().includes(filter.value.toLowerCase());
|
||||
case "in":
|
||||
return filter.value.split(",").map((value) => value.trim()).includes(String(rowValue ?? ""));
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function sortInMemoryRows(
|
||||
rows: Record<string, unknown>[],
|
||||
sortBy: string | undefined,
|
||||
sortDir: "asc" | "desc",
|
||||
columns: ColumnDef[],
|
||||
): Record<string, unknown>[] {
|
||||
if (!sortBy) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const def = columns.find((column) => column.key === sortBy);
|
||||
if (!def) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const direction = sortDir === "asc" ? 1 : -1;
|
||||
return [...rows].sort((left, right) => {
|
||||
const leftValue = left[sortBy];
|
||||
const rightValue = right[sortBy];
|
||||
|
||||
if (leftValue == null && rightValue == null) return 0;
|
||||
if (leftValue == null) return 1;
|
||||
if (rightValue == null) return -1;
|
||||
|
||||
if (def.dataType === "number") {
|
||||
return direction * (Number(leftValue) - Number(rightValue));
|
||||
}
|
||||
if (def.dataType === "boolean") {
|
||||
return direction * (Number(Boolean(leftValue)) - Number(Boolean(rightValue)));
|
||||
}
|
||||
if (def.dataType === "date") {
|
||||
return direction * (new Date(String(leftValue)).getTime() - new Date(String(rightValue)).getTime());
|
||||
}
|
||||
return direction * String(leftValue).localeCompare(String(rightValue), "de");
|
||||
});
|
||||
}
|
||||
|
||||
function pickColumns(row: Record<string, unknown>, columns: string[]): Record<string, unknown> {
|
||||
return Object.fromEntries(columns.map((column) => [column, row[column]]));
|
||||
}
|
||||
|
||||
function roundMetric(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function toTemplateEntity(entity: EntityKey): ReportTemplateEntity {
|
||||
switch (entity) {
|
||||
case "resource":
|
||||
return ReportTemplateEntity.RESOURCE;
|
||||
case "project":
|
||||
return ReportTemplateEntity.PROJECT;
|
||||
case "assignment":
|
||||
return ReportTemplateEntity.ASSIGNMENT;
|
||||
case "resource_month":
|
||||
return ReportTemplateEntity.RESOURCE_MONTH;
|
||||
default:
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${entity}` });
|
||||
}
|
||||
}
|
||||
|
||||
function fromTemplateEntity(entity: ReportTemplateEntity): EntityKey {
|
||||
switch (entity) {
|
||||
case ReportTemplateEntity.RESOURCE:
|
||||
return "resource";
|
||||
case ReportTemplateEntity.PROJECT:
|
||||
return "project";
|
||||
case ReportTemplateEntity.ASSIGNMENT:
|
||||
return "assignment";
|
||||
case ReportTemplateEntity.RESOURCE_MONTH:
|
||||
return "resource_month";
|
||||
default:
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${entity}` });
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the Prisma model delegate from entity key. */
|
||||
function getModelDelegate(db: any, entity: EntityKey) {
|
||||
switch (entity) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from "@capakraken/application";
|
||||
import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, ResourceType, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@capakraken/shared";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { computeChargeability } from "@capakraken/engine";
|
||||
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
||||
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
|
||||
import {
|
||||
@@ -17,6 +16,12 @@ import {
|
||||
getAnonymizationDirectory,
|
||||
resolveResourceIdsByDisplayedEids,
|
||||
} from "../lib/anonymization.js";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours,
|
||||
calculateEffectiveDayAvailability,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "../lib/resource-capacity.js";
|
||||
|
||||
export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool.
|
||||
|
||||
@@ -46,6 +51,50 @@ function parseResourceCursor(cursor: string | undefined): { displayName: string;
|
||||
return null;
|
||||
}
|
||||
|
||||
type BookingForCapacity = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
};
|
||||
|
||||
function toIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function buildDailyBookedHoursMap(
|
||||
bookings: BookingForCapacity[],
|
||||
availability: WeekdayAvailability,
|
||||
context: Parameters<typeof calculateEffectiveBookedHours>[0]["context"],
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Map<string, number> {
|
||||
const dailyBookedHours = new Map<string, number>();
|
||||
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) {
|
||||
const isoDate = toIsoDate(cursor);
|
||||
const bookedHours = bookings.reduce(
|
||||
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart: cursor,
|
||||
periodEnd: cursor,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
dailyBookedHours.set(isoDate, bookedHours);
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return dailyBookedHours;
|
||||
}
|
||||
|
||||
export const resourceRouter = createTRPCRouter({
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
@@ -1056,10 +1105,14 @@ export const resourceRouter = createTRPCRouter({
|
||||
portfolioUrl: true,
|
||||
postalCode: true,
|
||||
federalState: true,
|
||||
countryId: true,
|
||||
metroCityId: true,
|
||||
valueScore: true,
|
||||
valueScoreBreakdown: true,
|
||||
valueScoreUpdatedAt: true,
|
||||
userId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
const bookings = await listAssignmentBookings(ctx.db, {
|
||||
@@ -1067,30 +1120,67 @@ export const resourceRouter = createTRPCRouter({
|
||||
endDate: end,
|
||||
resourceIds: resources.map((resource) => resource.id),
|
||||
});
|
||||
const bookingsByResourceId = new Map<string, typeof bookings>();
|
||||
for (const booking of bookings) {
|
||||
if (!booking.resourceId) {
|
||||
continue;
|
||||
}
|
||||
const items = bookingsByResourceId.get(booking.resourceId) ?? [];
|
||||
items.push(booking);
|
||||
bookingsByResourceId.set(booking.resourceId, items);
|
||||
}
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
start,
|
||||
end,
|
||||
);
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
|
||||
return resources.map((r) => {
|
||||
const avail = r.availability as Record<string, number>;
|
||||
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
|
||||
const periodDays =
|
||||
(end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24) + 1;
|
||||
const availableHours = dailyAvailHours * periodDays * (5 / 7);
|
||||
|
||||
let bookedHours = 0;
|
||||
let isOverbooked = false;
|
||||
const resourceBookings = bookings.filter(
|
||||
const availability = r.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(r.id);
|
||||
const resourceBookings = (bookingsByResourceId.get(r.id) ?? []).filter(
|
||||
(booking) =>
|
||||
booking.resourceId === r.id &&
|
||||
(input.includeProposed || booking.status !== "PROPOSED"),
|
||||
);
|
||||
for (const a of resourceBookings) {
|
||||
const days =
|
||||
(new Date(a.endDate).getTime() - new Date(a.startDate).getTime()) /
|
||||
(1000 * 60 * 60 * 24) +
|
||||
1;
|
||||
bookedHours += a.hoursPerDay * days;
|
||||
if (a.hoursPerDay > dailyAvailHours) isOverbooked = true;
|
||||
}
|
||||
const availableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
});
|
||||
const bookedHours = resourceBookings.reduce(
|
||||
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const dailyBookedHours = buildDailyBookedHoursMap(resourceBookings, availability, context, start, end);
|
||||
const isOverbooked = Array.from(dailyBookedHours.entries()).some(([isoDate, hours]) => {
|
||||
const date = new Date(`${isoDate}T00:00:00.000Z`);
|
||||
const dayCapacity = calculateEffectiveDayAvailability({
|
||||
availability,
|
||||
date,
|
||||
context,
|
||||
});
|
||||
return dayCapacity > 0 && hours > dayCapacity;
|
||||
});
|
||||
|
||||
const utilizationPercent =
|
||||
availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0;
|
||||
@@ -1125,6 +1215,11 @@ export const resourceRouter = createTRPCRouter({
|
||||
chapter: true,
|
||||
chargeabilityTarget: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
const bookings = await listAssignmentBookings(ctx.db, {
|
||||
@@ -1132,10 +1227,25 @@ export const resourceRouter = createTRPCRouter({
|
||||
endDate: end,
|
||||
resourceIds: resources.map((resource) => resource.id),
|
||||
});
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
start,
|
||||
end,
|
||||
);
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
|
||||
return resources.map((r) => {
|
||||
const avail = r.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(r.id);
|
||||
const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id);
|
||||
|
||||
const actualAllocs = resourceBookings.filter((booking) =>
|
||||
@@ -1146,8 +1256,42 @@ export const resourceRouter = createTRPCRouter({
|
||||
isChargeabilityRelevantProject(booking.project, true),
|
||||
);
|
||||
|
||||
const actual = computeChargeability(avail, actualAllocs, start, end);
|
||||
const expected = computeChargeability(avail, expectedAllocs, start, end);
|
||||
const availableHours = calculateEffectiveAvailableHours({
|
||||
availability: avail,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
});
|
||||
const actualBookedHours = actualAllocs.reduce(
|
||||
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||
availability: avail,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const expectedBookedHours = expectedAllocs.reduce(
|
||||
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||
availability: avail,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const actualChargeability = availableHours > 0
|
||||
? Math.round((actualBookedHours / availableHours) * 100)
|
||||
: 0;
|
||||
const expectedChargeability = availableHours > 0
|
||||
? Math.round((expectedBookedHours / availableHours) * 100)
|
||||
: 0;
|
||||
|
||||
return anonymizeResource({
|
||||
id: r.id,
|
||||
@@ -1155,9 +1299,9 @@ export const resourceRouter = createTRPCRouter({
|
||||
displayName: r.displayName,
|
||||
chapter: r.chapter,
|
||||
chargeabilityTarget: r.chargeabilityTarget,
|
||||
actualChargeability: actual.chargeability,
|
||||
expectedChargeability: expected.chargeability,
|
||||
availableHours: actual.availableHours,
|
||||
actualChargeability,
|
||||
expectedChargeability,
|
||||
availableHours: Math.round(availableHours),
|
||||
}, directory);
|
||||
});
|
||||
}),
|
||||
@@ -1208,7 +1352,10 @@ export const resourceRouter = createTRPCRouter({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const now = new Date();
|
||||
const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
const today = new Date(now);
|
||||
today.setUTCHours(0, 0, 0, 0);
|
||||
const thirtyDaysFromNow = new Date(today);
|
||||
thirtyDaysFromNow.setUTCDate(thirtyDaysFromNow.getUTCDate() + 29);
|
||||
|
||||
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
|
||||
|
||||
@@ -1223,6 +1370,11 @@ export const resourceRouter = createTRPCRouter({
|
||||
skills: true,
|
||||
availability: true,
|
||||
chargeabilityTarget: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1232,7 +1384,7 @@ export const resourceRouter = createTRPCRouter({
|
||||
where: {
|
||||
resourceId: { in: allResourceIds },
|
||||
status: { in: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED"] },
|
||||
endDate: { gte: now },
|
||||
endDate: { gte: today },
|
||||
startDate: { lte: thirtyDaysFromNow },
|
||||
},
|
||||
select: {
|
||||
@@ -1242,41 +1394,78 @@ export const resourceRouter = createTRPCRouter({
|
||||
hoursPerDay: true,
|
||||
},
|
||||
});
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
today,
|
||||
thirtyDaysFromNow,
|
||||
);
|
||||
const assignmentsByResourceId = new Map<string, typeof assignments>();
|
||||
for (const assignment of assignments) {
|
||||
const items = assignmentsByResourceId.get(assignment.resourceId) ?? [];
|
||||
items.push(assignment);
|
||||
assignmentsByResourceId.set(assignment.resourceId, items);
|
||||
}
|
||||
|
||||
// Build utilization map (simple: booked hours per day / available hours per day)
|
||||
// Build utilization map with holiday-aware daily capacity over the next 30 days.
|
||||
const utilizationMap = new Map<string, { utilizationPercent: number; earliestAvailableDate: Date | null }>();
|
||||
for (const r of resources) {
|
||||
const avail = r.availability as Record<string, number>;
|
||||
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
|
||||
const resourceAssignments = assignments.filter((a) => a.resourceId === r.id);
|
||||
const availability = r.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(r.id);
|
||||
const resourceAssignments = assignmentsByResourceId.get(r.id) ?? [];
|
||||
const todayAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: today,
|
||||
periodEnd: today,
|
||||
context,
|
||||
});
|
||||
const todayBookedHours = resourceAssignments.reduce(
|
||||
(sum, assignment) => sum + calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
periodStart: today,
|
||||
periodEnd: today,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const utilizationPercent = todayAvailableHours > 0
|
||||
? Math.round((todayBookedHours / todayAvailableHours) * 100)
|
||||
: 0;
|
||||
const dailyBookedHours = buildDailyBookedHoursMap(
|
||||
resourceAssignments,
|
||||
availability,
|
||||
context,
|
||||
today,
|
||||
thirtyDaysFromNow,
|
||||
);
|
||||
|
||||
// Current daily booked hours (assignments overlapping today)
|
||||
let todayBooked = 0;
|
||||
for (const a of resourceAssignments) {
|
||||
if (a.startDate <= now && a.endDate >= now) {
|
||||
todayBooked += a.hoursPerDay;
|
||||
}
|
||||
}
|
||||
const utilizationPercent = dailyAvailHours > 0 ? Math.round((todayBooked / dailyAvailHours) * 100) : 0;
|
||||
|
||||
// Find earliest date when resource has capacity (within 30 days)
|
||||
let earliestAvailableDate: Date | null = null;
|
||||
const checkDate = new Date(now);
|
||||
const checkDate = new Date(today);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const day = checkDate.getDay();
|
||||
if (day !== 0 && day !== 6) {
|
||||
let dayBooked = 0;
|
||||
for (const a of resourceAssignments) {
|
||||
if (a.startDate <= checkDate && a.endDate >= checkDate) {
|
||||
dayBooked += a.hoursPerDay;
|
||||
}
|
||||
}
|
||||
if (dayBooked < dailyAvailHours * 0.8) {
|
||||
const dayAvailableHours = calculateEffectiveDayAvailability({
|
||||
availability,
|
||||
date: checkDate,
|
||||
context,
|
||||
});
|
||||
if (dayAvailableHours > 0) {
|
||||
const dayBookedHours = dailyBookedHours.get(toIsoDate(checkDate)) ?? 0;
|
||||
if (dayBookedHours < dayAvailableHours * 0.8) {
|
||||
earliestAvailableDate = new Date(checkDate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
checkDate.setDate(checkDate.getDate() + 1);
|
||||
checkDate.setUTCDate(checkDate.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
utilizationMap.set(r.id, { utilizationPercent, earliestAvailableDate });
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { calculateAllocation, countWorkingDays } from "@capakraken/engine/allocation";
|
||||
import { calculateAllocation } from "@capakraken/engine/allocation";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createTRPCRouter, controllerProcedure, protectedProcedure } from "../trpc.js";
|
||||
import { createAuditEntry } from "../lib/audit.js";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "../lib/resource-capacity.js";
|
||||
|
||||
const DEFAULT_AVAILABILITY = {
|
||||
monday: 8,
|
||||
@@ -69,6 +75,11 @@ export const scenarioRouter = createTRPCRouter({
|
||||
availability: true,
|
||||
chargeabilityTarget: true,
|
||||
skills: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
roleEntity: { select: { id: true, name: true, color: true } },
|
||||
@@ -85,23 +96,53 @@ export const scenarioRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate baseline totals
|
||||
let totalCostCents = 0;
|
||||
let totalHours = 0;
|
||||
const assignmentRangeStart = assignments.length > 0
|
||||
? new Date(Math.min(...assignments.map((assignment) => assignment.startDate.getTime())))
|
||||
: project.startDate;
|
||||
const assignmentRangeEnd = assignments.length > 0
|
||||
? new Date(Math.max(...assignments.map((assignment) => assignment.endDate.getTime())))
|
||||
: project.endDate;
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
assignments
|
||||
.flatMap((assignment) => (assignment.resource ? [assignment.resource] : []))
|
||||
.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
assignmentRangeStart,
|
||||
assignmentRangeEnd,
|
||||
);
|
||||
|
||||
const baselineAllocations = assignments.map((a) => {
|
||||
const availability = (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
|
||||
const lcrCents = a.resource?.lcrCents ?? 0;
|
||||
const result = calculateAllocation({
|
||||
lcrCents,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
availability,
|
||||
});
|
||||
|
||||
totalCostCents += result.totalCostCents;
|
||||
totalHours += result.totalHours;
|
||||
const totalHours = a.resourceId
|
||||
? calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
periodStart: assignmentRangeStart,
|
||||
periodEnd: assignmentRangeEnd,
|
||||
context: contexts.get(a.resourceId),
|
||||
})
|
||||
: calculateAllocation({
|
||||
lcrCents,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
availability,
|
||||
}).totalHours;
|
||||
const costCents = Math.round(totalHours * lcrCents);
|
||||
const workingDays = a.hoursPerDay > 0
|
||||
? Math.round((totalHours / a.hoursPerDay) * 100) / 100
|
||||
: 0;
|
||||
|
||||
return {
|
||||
id: a.id,
|
||||
@@ -116,11 +157,13 @@ export const scenarioRouter = createTRPCRouter({
|
||||
endDate: a.endDate.toISOString(),
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
status: a.status,
|
||||
costCents: result.totalCostCents,
|
||||
totalHours: result.totalHours,
|
||||
workingDays: result.workingDays,
|
||||
costCents,
|
||||
totalHours,
|
||||
workingDays,
|
||||
};
|
||||
});
|
||||
const totalCostCents = baselineAllocations.reduce((sum, allocation) => sum + allocation.costCents, 0);
|
||||
const totalHours = baselineAllocations.reduce((sum, allocation) => sum + allocation.totalHours, 0);
|
||||
|
||||
const baselineDemands = demands.map((d) => ({
|
||||
id: d.id,
|
||||
@@ -175,27 +218,16 @@ export const scenarioRouter = createTRPCRouter({
|
||||
availability: true,
|
||||
chargeabilityTarget: true,
|
||||
skills: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Compute baseline totals
|
||||
let baselineCostCents = 0;
|
||||
let baselineHours = 0;
|
||||
for (const a of currentAssignments) {
|
||||
const availability = (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
|
||||
const result = calculateAllocation({
|
||||
lcrCents: a.resource?.lcrCents ?? 0,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
availability,
|
||||
});
|
||||
baselineCostCents += result.totalCostCents;
|
||||
baselineHours += result.totalHours;
|
||||
}
|
||||
|
||||
// Collect all resource IDs we need to look up (from changes)
|
||||
const resourceIds = new Set<string>();
|
||||
for (const c of changes) {
|
||||
@@ -217,6 +249,11 @@ export const scenarioRouter = createTRPCRouter({
|
||||
availability: true,
|
||||
chargeabilityTarget: true,
|
||||
skills: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
const resourceMap = new Map(resources.map((r) => [r.id, r]));
|
||||
@@ -287,22 +324,6 @@ export const scenarioRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Compute scenario totals
|
||||
let scenarioCostCents = 0;
|
||||
let scenarioHours = 0;
|
||||
|
||||
for (const entry of scenarioEntries) {
|
||||
const result = calculateAllocation({
|
||||
lcrCents: entry.lcrCents,
|
||||
hoursPerDay: entry.hoursPerDay,
|
||||
startDate: entry.startDate,
|
||||
endDate: entry.endDate,
|
||||
availability: entry.availability,
|
||||
});
|
||||
scenarioCostCents += result.totalCostCents;
|
||||
scenarioHours += result.totalHours;
|
||||
}
|
||||
|
||||
// Compute per-resource utilization impact
|
||||
// Load ALL assignments for affected resources (across all projects) to measure total utilization
|
||||
const affectedResourceIds = [...new Set(scenarioEntries.map((e) => e.resourceId).filter(Boolean))] as string[];
|
||||
@@ -341,13 +362,97 @@ export const scenarioRouter = createTRPCRouter({
|
||||
if (e.endDate > windowEnd) windowEnd = e.endDate;
|
||||
}
|
||||
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
windowStart,
|
||||
windowEnd,
|
||||
);
|
||||
|
||||
function calculateEntryHours(entry: {
|
||||
resourceId: string | null;
|
||||
lcrCents: number;
|
||||
hoursPerDay: number;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
availability: typeof DEFAULT_AVAILABILITY;
|
||||
}) {
|
||||
if (!entry.resourceId) {
|
||||
return calculateAllocation({
|
||||
lcrCents: entry.lcrCents,
|
||||
hoursPerDay: entry.hoursPerDay,
|
||||
startDate: entry.startDate,
|
||||
endDate: entry.endDate,
|
||||
availability: entry.availability,
|
||||
}).totalHours;
|
||||
}
|
||||
|
||||
return calculateEffectiveBookedHours({
|
||||
availability: entry.availability,
|
||||
startDate: entry.startDate,
|
||||
endDate: entry.endDate,
|
||||
hoursPerDay: entry.hoursPerDay,
|
||||
periodStart: windowStart,
|
||||
periodEnd: windowEnd,
|
||||
context: contexts.get(entry.resourceId),
|
||||
});
|
||||
}
|
||||
|
||||
// Compute scenario totals
|
||||
let scenarioCostCents = 0;
|
||||
let scenarioHours = 0;
|
||||
|
||||
for (const entry of scenarioEntries) {
|
||||
const totalHours = calculateEntryHours(entry);
|
||||
scenarioCostCents += Math.round(totalHours * entry.lcrCents);
|
||||
scenarioHours += totalHours;
|
||||
}
|
||||
|
||||
let baselineCostCents = 0;
|
||||
let baselineHours = 0;
|
||||
for (const assignment of currentAssignments) {
|
||||
const availability = (assignment.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
|
||||
const totalHours = assignment.resourceId
|
||||
? calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
periodStart: windowStart,
|
||||
periodEnd: windowEnd,
|
||||
context: contexts.get(assignment.resourceId),
|
||||
})
|
||||
: calculateAllocation({
|
||||
lcrCents: assignment.resource?.lcrCents ?? 0,
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
availability,
|
||||
}).totalHours;
|
||||
baselineHours += totalHours;
|
||||
baselineCostCents += Math.round(totalHours * (assignment.resource?.lcrCents ?? 0));
|
||||
}
|
||||
|
||||
const resourceImpacts = affectedResourceIds.map((resId) => {
|
||||
const resource = resourceMap.get(resId);
|
||||
if (!resource) return null;
|
||||
|
||||
const availability = (resource.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
|
||||
const totalWorkDays = countWorkingDays(windowStart, windowEnd, availability);
|
||||
const totalAvailableHours = totalWorkDays * (availability.monday ?? 8);
|
||||
const context = contexts.get(resId);
|
||||
const totalAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: windowStart,
|
||||
periodEnd: windowEnd,
|
||||
context,
|
||||
});
|
||||
|
||||
// Current utilization on this project
|
||||
const currentProjectAssignments = (assignmentsByResource.get(resId) ?? []).filter(
|
||||
@@ -355,28 +460,30 @@ export const scenarioRouter = createTRPCRouter({
|
||||
);
|
||||
let currentProjectHours = 0;
|
||||
for (const a of currentProjectAssignments) {
|
||||
const r = calculateAllocation({
|
||||
lcrCents: 0,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
currentProjectHours += calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
availability,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
periodStart: windowStart,
|
||||
periodEnd: windowEnd,
|
||||
context,
|
||||
});
|
||||
currentProjectHours += r.totalHours;
|
||||
}
|
||||
|
||||
// Scenario hours for this resource on this project
|
||||
const scenarioResourceEntries = scenarioEntries.filter((e) => e.resourceId === resId);
|
||||
let scenarioProjectHours = 0;
|
||||
for (const e of scenarioResourceEntries) {
|
||||
const r = calculateAllocation({
|
||||
lcrCents: 0,
|
||||
hoursPerDay: e.hoursPerDay,
|
||||
scenarioProjectHours += calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: e.startDate,
|
||||
endDate: e.endDate,
|
||||
availability,
|
||||
hoursPerDay: e.hoursPerDay,
|
||||
periodStart: windowStart,
|
||||
periodEnd: windowEnd,
|
||||
context,
|
||||
});
|
||||
scenarioProjectHours += r.totalHours;
|
||||
}
|
||||
|
||||
// Total hours across all projects (excluding this project's current, adding scenario)
|
||||
@@ -385,14 +492,15 @@ export const scenarioRouter = createTRPCRouter({
|
||||
);
|
||||
let otherProjectsHours = 0;
|
||||
for (const a of otherProjectAssignments) {
|
||||
const r = calculateAllocation({
|
||||
lcrCents: 0,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
otherProjectsHours += calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
availability,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
periodStart: windowStart,
|
||||
periodEnd: windowEnd,
|
||||
context,
|
||||
});
|
||||
otherProjectsHours += r.totalHours;
|
||||
}
|
||||
|
||||
const currentTotalHours = otherProjectsHours + currentProjectHours;
|
||||
|
||||
@@ -1,10 +1,106 @@
|
||||
import { analyzeUtilization, findCapacityWindows, rankResources } from "@capakraken/staffing";
|
||||
import { rankResources } from "@capakraken/staffing";
|
||||
import { listAssignmentBookings } from "@capakraken/application";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours,
|
||||
countEffectiveWorkingDays,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
type ResourceDailyAvailabilityContext,
|
||||
} from "../lib/resource-capacity.js";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||
|
||||
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
];
|
||||
const ACTIVE_STATUSES = new Set(["PROPOSED", "CONFIRMED", "ACTIVE"]);
|
||||
|
||||
function toIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function round1(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function getBaseDayAvailability(
|
||||
availability: WeekdayAvailability,
|
||||
date: Date,
|
||||
): number {
|
||||
const key = DAY_KEYS[date.getUTCDay()];
|
||||
return key ? (availability[key] ?? 0) : 0;
|
||||
}
|
||||
|
||||
function getEffectiveDayAvailability(
|
||||
availability: WeekdayAvailability,
|
||||
date: Date,
|
||||
context: ResourceDailyAvailabilityContext | undefined,
|
||||
): number {
|
||||
const key = DAY_KEYS[date.getUTCDay()];
|
||||
const baseHours = key ? (availability[key] ?? 0) : 0;
|
||||
if (baseHours <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const fraction = context?.absenceFractionsByDate.get(toIsoDate(date)) ?? 0;
|
||||
return Math.max(0, baseHours * (1 - fraction));
|
||||
}
|
||||
|
||||
function overlapsDateRange(startDate: Date, endDate: Date, date: Date): boolean {
|
||||
return date >= startDate && date <= endDate;
|
||||
}
|
||||
|
||||
function averagePerWorkingDay(totalHours: number, workingDays: number): number {
|
||||
if (workingDays <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return round1(totalHours / workingDays);
|
||||
}
|
||||
|
||||
function createLocationLabel(input: {
|
||||
countryCode?: string | null;
|
||||
federalState?: string | null;
|
||||
metroCityName?: string | null;
|
||||
}): string {
|
||||
return [
|
||||
input.countryCode ?? null,
|
||||
input.federalState ?? null,
|
||||
input.metroCityName ?? null,
|
||||
].filter((value): value is string => Boolean(value && value.trim().length > 0)).join(" / ");
|
||||
}
|
||||
|
||||
function calculateAllocatedHoursForDay(input: {
|
||||
bookings: Array<{ startDate: Date; endDate: Date; hoursPerDay: number; status: string; isChargeable?: boolean }>;
|
||||
date: Date;
|
||||
context: ResourceDailyAvailabilityContext | undefined;
|
||||
}): { allocatedHours: number; chargeableHours: number } {
|
||||
const isoDate = toIsoDate(input.date);
|
||||
const dayFraction = Math.max(0, 1 - (input.context?.absenceFractionsByDate.get(isoDate) ?? 0));
|
||||
|
||||
return input.bookings.reduce(
|
||||
(acc, booking) => {
|
||||
if (!ACTIVE_STATUSES.has(booking.status) || !overlapsDateRange(booking.startDate, booking.endDate, input.date)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const effectiveHours = booking.hoursPerDay * dayFraction;
|
||||
acc.allocatedHours += effectiveHours;
|
||||
if (booking.isChargeable) {
|
||||
acc.chargeableHours += effectiveHours;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ allocatedHours: 0, chargeableHours: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
export const staffingRouter = createTRPCRouter({
|
||||
/**
|
||||
* Get ranked resource suggestions for a staffing requirement.
|
||||
@@ -32,31 +128,169 @@ export const staffingRouter = createTRPCRouter({
|
||||
isActive: true,
|
||||
...(chapter ? { chapter } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
eid: true,
|
||||
skills: true,
|
||||
lcrCents: true,
|
||||
chargeabilityTarget: true,
|
||||
availability: true,
|
||||
valueScore: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true, name: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
const bookings = await listAssignmentBookings(ctx.db, {
|
||||
startDate,
|
||||
endDate,
|
||||
resourceIds: resources.map((resource) => resource.id),
|
||||
});
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
startDate,
|
||||
endDate,
|
||||
);
|
||||
const bookingsByResourceId = new Map<string, typeof bookings>();
|
||||
for (const booking of bookings) {
|
||||
if (!booking.resourceId) {
|
||||
continue;
|
||||
}
|
||||
const items = bookingsByResourceId.get(booking.resourceId) ?? [];
|
||||
items.push(booking);
|
||||
bookingsByResourceId.set(booking.resourceId, items);
|
||||
}
|
||||
|
||||
// Compute utilization percent for each resource in the requested period
|
||||
const enrichedResources = resources.map((resource) => {
|
||||
const totalAvailableHours =
|
||||
(resource.availability as { monday?: number; tuesday?: number; wednesday?: number; thursday?: number; friday?: number }).monday ?? 8;
|
||||
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
|
||||
|
||||
const allocatedHoursPerDay = resourceBookings.reduce(
|
||||
(sum, a) => sum + a.hoursPerDay,
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(resource.id);
|
||||
const resourceBookings = bookingsByResourceId.get(resource.id) ?? [];
|
||||
const activeBookings = resourceBookings.filter((booking) => ACTIVE_STATUSES.has(booking.status));
|
||||
const baseAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: startDate,
|
||||
periodEnd: endDate,
|
||||
context: undefined,
|
||||
});
|
||||
const totalAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: startDate,
|
||||
periodEnd: endDate,
|
||||
context,
|
||||
});
|
||||
const baseWorkingDays = countEffectiveWorkingDays({
|
||||
availability,
|
||||
periodStart: startDate,
|
||||
periodEnd: endDate,
|
||||
context: undefined,
|
||||
});
|
||||
const effectiveWorkingDays = countEffectiveWorkingDays({
|
||||
availability,
|
||||
periodStart: startDate,
|
||||
periodEnd: endDate,
|
||||
context,
|
||||
});
|
||||
const allocatedHours = activeBookings.reduce(
|
||||
(sum, booking) =>
|
||||
sum + calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart: startDate,
|
||||
periodEnd: endDate,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const holidayDates = [...(context?.holidayDates ?? new Set<string>())].sort();
|
||||
const holidayWorkdayCount = holidayDates.reduce((count, isoDate) => (
|
||||
count + (getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0)
|
||||
), 0);
|
||||
const holidayHoursDeduction = holidayDates.reduce((sum, isoDate) => (
|
||||
sum + getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`))
|
||||
), 0);
|
||||
let absenceDayEquivalent = 0;
|
||||
let absenceHoursDeduction = 0;
|
||||
for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) {
|
||||
const dayHours = getBaseDayAvailability(availability, new Date(`${isoDate}T00:00:00.000Z`));
|
||||
if (dayHours <= 0 || context?.holidayDates.has(isoDate)) {
|
||||
continue;
|
||||
}
|
||||
absenceDayEquivalent += fraction;
|
||||
absenceHoursDeduction += dayHours * fraction;
|
||||
}
|
||||
const conflictDays: string[] = [];
|
||||
const conflictDetails: Array<{
|
||||
date: string;
|
||||
baseHours: number;
|
||||
effectiveHours: number;
|
||||
allocatedHours: number;
|
||||
remainingHours: number;
|
||||
requestedHours: number;
|
||||
shortageHours: number;
|
||||
absenceFraction: number;
|
||||
isHoliday: boolean;
|
||||
}> = [];
|
||||
const cursor = new Date(startDate);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const periodEndAtMidnight = new Date(endDate);
|
||||
periodEndAtMidnight.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= periodEndAtMidnight) {
|
||||
const isoDate = toIsoDate(cursor);
|
||||
const baseHoursForDay = getBaseDayAvailability(availability, cursor);
|
||||
const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context);
|
||||
const isHoliday = context?.holidayDates.has(isoDate) ?? false;
|
||||
const absenceFraction = Math.min(
|
||||
1,
|
||||
Math.max(0, context?.absenceFractionsByDate.get(isoDate) ?? 0),
|
||||
);
|
||||
if (availableHoursForDay > 0) {
|
||||
const { allocatedHours: allocatedHoursForDay } = calculateAllocatedHoursForDay({
|
||||
bookings: activeBookings,
|
||||
date: cursor,
|
||||
context,
|
||||
});
|
||||
if (allocatedHoursForDay + hoursPerDay > availableHoursForDay) {
|
||||
const remainingHoursForDay = Math.max(0, availableHoursForDay - allocatedHoursForDay);
|
||||
conflictDays.push(isoDate);
|
||||
conflictDetails.push({
|
||||
date: isoDate,
|
||||
baseHours: round1(baseHoursForDay),
|
||||
effectiveHours: round1(availableHoursForDay),
|
||||
allocatedHours: round1(allocatedHoursForDay),
|
||||
remainingHours: round1(remainingHoursForDay),
|
||||
requestedHours: round1(hoursPerDay),
|
||||
shortageHours: round1(Math.max(0, hoursPerDay - remainingHoursForDay)),
|
||||
absenceFraction: round1(absenceFraction),
|
||||
isHoliday,
|
||||
});
|
||||
}
|
||||
}
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
const remainingHours = Math.max(0, totalAvailableHours - allocatedHours);
|
||||
const remainingHoursPerDay = averagePerWorkingDay(remainingHours, effectiveWorkingDays);
|
||||
const utilizationPercent =
|
||||
totalAvailableHours > 0
|
||||
? Math.min(100, (allocatedHoursPerDay / totalAvailableHours) * 100)
|
||||
? Math.min(100, (allocatedHours / totalAvailableHours) * 100)
|
||||
: 0;
|
||||
|
||||
const wouldExceedCapacity = allocatedHoursPerDay + hoursPerDay > totalAvailableHours;
|
||||
|
||||
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
|
||||
let skills = resource.skills as unknown as SkillRow[];
|
||||
|
||||
@@ -73,9 +307,43 @@ export const staffingRouter = createTRPCRouter({
|
||||
lcrCents: resource.lcrCents,
|
||||
chargeabilityTarget: resource.chargeabilityTarget,
|
||||
currentUtilizationPercent: utilizationPercent,
|
||||
hasAvailabilityConflicts: wouldExceedCapacity,
|
||||
conflictDays: wouldExceedCapacity ? ["(multiple days)"] : [],
|
||||
hasAvailabilityConflicts: conflictDays.length > 0,
|
||||
conflictDays,
|
||||
valueScore: resource.valueScore ?? 0,
|
||||
transparency: {
|
||||
location: {
|
||||
countryCode: resource.country?.code ?? null,
|
||||
countryName: resource.country?.name ?? null,
|
||||
federalState: resource.federalState ?? null,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
label: createLocationLabel({
|
||||
countryCode: resource.country?.code ?? null,
|
||||
federalState: resource.federalState,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
}),
|
||||
},
|
||||
capacity: {
|
||||
requestedHoursPerDay: round1(hoursPerDay),
|
||||
requestedHoursTotal: round1(effectiveWorkingDays * hoursPerDay),
|
||||
baseWorkingDays: round1(baseWorkingDays),
|
||||
effectiveWorkingDays: round1(effectiveWorkingDays),
|
||||
baseAvailableHours: round1(baseAvailableHours),
|
||||
effectiveAvailableHours: round1(totalAvailableHours),
|
||||
bookedHours: round1(allocatedHours),
|
||||
remainingHours: round1(remainingHours),
|
||||
remainingHoursPerDay,
|
||||
holidayCount: holidayDates.length,
|
||||
holidayWorkdayCount,
|
||||
holidayHoursDeduction: round1(holidayHoursDeduction),
|
||||
absenceDayEquivalent: round1(absenceDayEquivalent),
|
||||
absenceHoursDeduction: round1(absenceHoursDeduction),
|
||||
},
|
||||
conflicts: {
|
||||
count: conflictDays.length,
|
||||
conflictDays,
|
||||
details: conflictDetails,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -85,15 +353,95 @@ export const staffingRouter = createTRPCRouter({
|
||||
resources: enrichedResources,
|
||||
budgetLcrCentsPerHour,
|
||||
} as unknown as Parameters<typeof rankResources>[0]);
|
||||
const baseRankIndex = new Map(ranked.map((suggestion, index) => [suggestion.resourceId, index]));
|
||||
|
||||
// Value-score tiebreaker: within 2 points, prefer higher valueScore
|
||||
return ranked.sort((a, b) => {
|
||||
return [...ranked].sort((a, b) => {
|
||||
if (Math.abs(a.score - b.score) <= 2) {
|
||||
const aVal = (enrichedResources.find((r) => r.id === a.resourceId)?.valueScore ?? 0);
|
||||
const bVal = (enrichedResources.find((r) => r.id === b.resourceId)?.valueScore ?? 0);
|
||||
return bVal - aVal;
|
||||
}
|
||||
return 0;
|
||||
}).map((suggestion, index) => {
|
||||
const resource = enrichedResources.find((item) => item.id === suggestion.resourceId);
|
||||
const fallbackBreakdown = "breakdown" in suggestion
|
||||
? (suggestion as { breakdown?: { skillScore: number; availabilityScore: number; costScore: number; utilizationScore: number } }).breakdown
|
||||
: undefined;
|
||||
const scoreBreakdown = suggestion.scoreBreakdown ?? {
|
||||
skillScore: fallbackBreakdown?.skillScore ?? 0,
|
||||
availabilityScore: fallbackBreakdown?.availabilityScore ?? 0,
|
||||
costScore: fallbackBreakdown?.costScore ?? 0,
|
||||
utilizationScore: fallbackBreakdown?.utilizationScore ?? 0,
|
||||
total: suggestion.score,
|
||||
};
|
||||
const baseRank = (baseRankIndex.get(suggestion.resourceId) ?? index) + 1;
|
||||
const tieBreakerApplied = baseRank !== index + 1;
|
||||
|
||||
return {
|
||||
...suggestion,
|
||||
resourceName: suggestion.resourceName ?? resource?.displayName ?? "",
|
||||
eid: suggestion.eid ?? resource?.eid ?? "",
|
||||
scoreBreakdown,
|
||||
matchedSkills: suggestion.matchedSkills ?? requiredSkills.filter((skill) =>
|
||||
resource?.skills.some((entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase()),
|
||||
),
|
||||
missingSkills: suggestion.missingSkills ?? requiredSkills.filter((skill) =>
|
||||
!resource?.skills.some((entry) => entry.skill.toLowerCase() === skill.trim().toLowerCase()),
|
||||
),
|
||||
availabilityConflicts: suggestion.availabilityConflicts ?? resource?.conflictDays ?? [],
|
||||
estimatedDailyCostCents: suggestion.estimatedDailyCostCents ?? ((resource?.lcrCents ?? 0) * 8),
|
||||
currentUtilization: suggestion.currentUtilization ?? round1(resource?.currentUtilizationPercent ?? 0),
|
||||
valueScore: resource?.valueScore ?? 0,
|
||||
location: resource?.transparency.location ?? {
|
||||
countryCode: null,
|
||||
countryName: null,
|
||||
federalState: null,
|
||||
metroCityName: null,
|
||||
label: "",
|
||||
},
|
||||
capacity: resource?.transparency.capacity ?? {
|
||||
requestedHoursPerDay: round1(hoursPerDay),
|
||||
requestedHoursTotal: 0,
|
||||
baseWorkingDays: 0,
|
||||
effectiveWorkingDays: 0,
|
||||
baseAvailableHours: 0,
|
||||
effectiveAvailableHours: 0,
|
||||
bookedHours: 0,
|
||||
remainingHours: 0,
|
||||
remainingHoursPerDay: 0,
|
||||
holidayCount: 0,
|
||||
holidayWorkdayCount: 0,
|
||||
holidayHoursDeduction: 0,
|
||||
absenceDayEquivalent: 0,
|
||||
absenceHoursDeduction: 0,
|
||||
},
|
||||
conflicts: resource?.transparency.conflicts ?? {
|
||||
count: 0,
|
||||
conflictDays: [],
|
||||
details: [],
|
||||
},
|
||||
ranking: {
|
||||
rank: index + 1,
|
||||
baseRank,
|
||||
tieBreakerApplied,
|
||||
tieBreakerReason: tieBreakerApplied
|
||||
? "Within 2 score points, higher value score moves the candidate up."
|
||||
: null,
|
||||
model: "Composite ranking across skill fit, availability, cost, and utilization.",
|
||||
components: [
|
||||
{ key: "skillScore", label: "Skills", score: scoreBreakdown.skillScore },
|
||||
{ key: "availabilityScore", label: "Availability", score: scoreBreakdown.availabilityScore },
|
||||
{ key: "costScore", label: "Cost", score: scoreBreakdown.costScore },
|
||||
{ key: "utilizationScore", label: "Utilization", score: scoreBreakdown.utilizationScore },
|
||||
],
|
||||
},
|
||||
remainingHoursPerDay: resource?.transparency.capacity.remainingHoursPerDay ?? 0,
|
||||
remainingHours: resource?.transparency.capacity.remainingHours ?? 0,
|
||||
effectiveAvailableHours: resource?.transparency.capacity.effectiveAvailableHours ?? 0,
|
||||
baseAvailableHours: resource?.transparency.capacity.baseAvailableHours ?? 0,
|
||||
holidayHoursDeduction: resource?.transparency.capacity.holidayHoursDeduction ?? 0,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -117,6 +465,11 @@ export const staffingRouter = createTRPCRouter({
|
||||
displayName: true,
|
||||
chargeabilityTarget: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
"Resource",
|
||||
@@ -128,24 +481,83 @@ export const staffingRouter = createTRPCRouter({
|
||||
resourceIds: [resource.id],
|
||||
});
|
||||
|
||||
return analyzeUtilization({
|
||||
resource: {
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
[{
|
||||
id: resource.id,
|
||||
displayName: resource.displayName,
|
||||
chargeabilityTarget: resource.chargeabilityTarget,
|
||||
availability: resource.availability as unknown as import("@capakraken/shared").WeekdayAvailability,
|
||||
},
|
||||
allocations: resourceBookings.map((booking) => ({
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
status: booking.status,
|
||||
projectName: booking.project.name,
|
||||
isChargeable: booking.project.orderType === "CHARGEABLE",
|
||||
})) as unknown as Parameters<typeof analyzeUtilization>[0]["allocations"],
|
||||
analysisStart: input.startDate,
|
||||
analysisEnd: input.endDate,
|
||||
});
|
||||
availability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
}],
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
);
|
||||
const context = contexts.get(resource.id);
|
||||
const activeBookings = resourceBookings.map((booking) => ({
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
status: booking.status,
|
||||
projectName: booking.project.name,
|
||||
isChargeable: booking.project.orderType === "CHARGEABLE",
|
||||
}));
|
||||
|
||||
const overallocatedDays: string[] = [];
|
||||
const underutilizedDays: string[] = [];
|
||||
let totalAvailableHours = 0;
|
||||
let totalChargeableHours = 0;
|
||||
const cursor = new Date(input.startDate);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(input.endDate);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context);
|
||||
if (availableHoursForDay > 0) {
|
||||
const { allocatedHours, chargeableHours } = calculateAllocatedHoursForDay({
|
||||
bookings: activeBookings,
|
||||
date: cursor,
|
||||
context,
|
||||
});
|
||||
totalAvailableHours += availableHoursForDay;
|
||||
totalChargeableHours += chargeableHours;
|
||||
|
||||
if (allocatedHours > availableHoursForDay) {
|
||||
overallocatedDays.push(toIsoDate(cursor));
|
||||
} else if (allocatedHours < availableHoursForDay * 0.5) {
|
||||
underutilizedDays.push(toIsoDate(cursor));
|
||||
}
|
||||
}
|
||||
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
const currentChargeability = totalAvailableHours > 0
|
||||
? (totalChargeableHours / totalAvailableHours) * 100
|
||||
: 0;
|
||||
|
||||
return {
|
||||
resourceId: resource.id,
|
||||
resourceName: resource.displayName,
|
||||
chargeabilityTarget: resource.chargeabilityTarget,
|
||||
currentChargeability,
|
||||
chargeabilityGap: resource.chargeabilityTarget - currentChargeability,
|
||||
allocations: activeBookings
|
||||
.filter((booking) => ACTIVE_STATUSES.has(booking.status))
|
||||
.map((booking) => ({
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
projectName: booking.projectName,
|
||||
isChargeable: booking.isChargeable,
|
||||
})),
|
||||
overallocatedDays,
|
||||
underutilizedDays,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -168,6 +580,11 @@ export const staffingRouter = createTRPCRouter({
|
||||
id: true,
|
||||
displayName: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
"Resource",
|
||||
@@ -179,21 +596,98 @@ export const staffingRouter = createTRPCRouter({
|
||||
resourceIds: [resource.id],
|
||||
});
|
||||
|
||||
return findCapacityWindows(
|
||||
{
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
[{
|
||||
id: resource.id,
|
||||
displayName: resource.displayName,
|
||||
availability: resource.availability as unknown as import("@capakraken/shared").WeekdayAvailability,
|
||||
},
|
||||
resourceBookings.map((booking) => ({
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
status: booking.status,
|
||||
})) as Pick<import("@capakraken/shared").Allocation, "startDate" | "endDate" | "hoursPerDay" | "status">[],
|
||||
availability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
}],
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
input.minAvailableHoursPerDay,
|
||||
);
|
||||
const context = contexts.get(resource.id);
|
||||
const windows: Array<{
|
||||
resourceId: string;
|
||||
resourceName: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
availableHoursPerDay: number;
|
||||
availableDays: number;
|
||||
totalAvailableHours: number;
|
||||
}> = [];
|
||||
|
||||
let windowStart: Date | null = null;
|
||||
let windowAvailableDays = 0;
|
||||
let windowTotalHours = 0;
|
||||
let windowMinHours = Number.POSITIVE_INFINITY;
|
||||
|
||||
const closeWindow = (closeDate: Date) => {
|
||||
if (windowStart && windowAvailableDays > 0) {
|
||||
const previousDay = new Date(closeDate);
|
||||
previousDay.setUTCDate(previousDay.getUTCDate() - 1);
|
||||
windows.push({
|
||||
resourceId: resource.id,
|
||||
resourceName: resource.displayName,
|
||||
startDate: new Date(windowStart),
|
||||
endDate: previousDay,
|
||||
availableHoursPerDay: Number.isFinite(windowMinHours) ? windowMinHours : 0,
|
||||
availableDays: windowAvailableDays,
|
||||
totalAvailableHours: Math.round(windowTotalHours * 10) / 10,
|
||||
});
|
||||
}
|
||||
windowStart = null;
|
||||
windowAvailableDays = 0;
|
||||
windowTotalHours = 0;
|
||||
windowMinHours = Number.POSITIVE_INFINITY;
|
||||
};
|
||||
|
||||
const cursor = new Date(input.startDate);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(input.endDate);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const availableHoursForDay = getEffectiveDayAvailability(availability, cursor, context);
|
||||
if (availableHoursForDay <= 0) {
|
||||
closeWindow(cursor);
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { allocatedHours } = calculateAllocatedHoursForDay({
|
||||
bookings: resourceBookings.map((booking) => ({
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
status: booking.status,
|
||||
})),
|
||||
date: cursor,
|
||||
context,
|
||||
});
|
||||
const freeHours = Math.max(0, availableHoursForDay - allocatedHours);
|
||||
|
||||
if (freeHours >= input.minAvailableHoursPerDay) {
|
||||
if (!windowStart) {
|
||||
windowStart = new Date(cursor);
|
||||
}
|
||||
windowAvailableDays += 1;
|
||||
windowTotalHours += freeHours;
|
||||
windowMinHours = Math.min(windowMinHours, freeHours);
|
||||
} else {
|
||||
closeWindow(cursor);
|
||||
}
|
||||
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
closeWindow(new Date(end.getTime() + 86_400_000));
|
||||
|
||||
return windows;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
emitAllocationUpdated,
|
||||
emitProjectShifted,
|
||||
} from "../sse/event-bus.js";
|
||||
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
|
||||
import { buildTimelineShiftPlan } from "./timeline-shift-planning.js";
|
||||
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||
@@ -37,7 +38,7 @@ type ShiftDbClient = Pick<
|
||||
|
||||
type TimelineEntriesDbClient = Pick<
|
||||
PrismaClient,
|
||||
"demandRequirement" | "assignment" | "resource" | "project"
|
||||
"demandRequirement" | "assignment" | "resource" | "project" | "holidayCalendar" | "country" | "metroCity"
|
||||
>;
|
||||
|
||||
type TimelineEntriesFilters = {
|
||||
@@ -325,6 +326,116 @@ export const timelineRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
getHolidayOverlays: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
resourceIds: z.array(z.string()).optional(),
|
||||
projectIds: z.array(z.string()).optional(),
|
||||
clientIds: z.array(z.string()).optional(),
|
||||
chapters: z.array(z.string()).optional(),
|
||||
eids: z.array(z.string()).optional(),
|
||||
countryCodes: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const readModel = await loadTimelineEntriesReadModel(ctx.db, input);
|
||||
const resourceIds = [...new Set(
|
||||
readModel.assignments
|
||||
.map((assignment) => assignment.resourceId)
|
||||
.filter((resourceId): resourceId is string => typeof resourceId === "string" && resourceId.length > 0),
|
||||
)];
|
||||
|
||||
if (input.resourceIds && input.resourceIds.length > 0) {
|
||||
for (const resourceId of input.resourceIds) {
|
||||
if (resourceId && !resourceIds.includes(resourceId)) {
|
||||
resourceIds.push(resourceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasResourceFilters =
|
||||
(input.chapters?.length ?? 0) > 0 ||
|
||||
(input.eids?.length ?? 0) > 0 ||
|
||||
(input.countryCodes?.length ?? 0) > 0;
|
||||
|
||||
if (hasResourceFilters) {
|
||||
const andConditions: Record<string, unknown>[] = [];
|
||||
if (input.chapters && input.chapters.length > 0) {
|
||||
andConditions.push({ chapter: { in: input.chapters } });
|
||||
}
|
||||
if (input.eids && input.eids.length > 0) {
|
||||
andConditions.push({ eid: { in: input.eids } });
|
||||
}
|
||||
if (input.countryCodes && input.countryCodes.length > 0) {
|
||||
andConditions.push({ country: { code: { in: input.countryCodes } } });
|
||||
}
|
||||
|
||||
const matchingResources = await ctx.db.resource.findMany({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
where: (andConditions.length === 1 ? andConditions[0]! : { AND: andConditions }) as any,
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
for (const resource of matchingResources) {
|
||||
if (!resourceIds.includes(resource.id)) {
|
||||
resourceIds.push(resource.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: { id: { in: resourceIds } },
|
||||
select: {
|
||||
id: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const overlays = await Promise.all(
|
||||
resources.map(async (resource) => {
|
||||
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code ?? null,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
});
|
||||
|
||||
return holidays.map((holiday) => {
|
||||
const holidayDate = new Date(`${holiday.date}T00:00:00.000Z`);
|
||||
return {
|
||||
id: `calendar-holiday:${resource.id}:${holiday.date}`,
|
||||
resourceId: resource.id,
|
||||
type: VacationType.PUBLIC_HOLIDAY,
|
||||
status: "APPROVED" as const,
|
||||
startDate: holidayDate,
|
||||
endDate: holidayDate,
|
||||
note: holiday.name,
|
||||
};
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return overlays.flat().sort((left, right) => {
|
||||
if (left.resourceId !== right.resourceId) {
|
||||
return left.resourceId.localeCompare(right.resourceId);
|
||||
}
|
||||
return left.startDate.getTime() - right.startDate.getTime();
|
||||
});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get full project context for a project:
|
||||
* - project with staffingReqs and budget
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UpdateVacationStatusSchema, getPublicHolidays, buildTaskAction } from "@capakraken/shared";
|
||||
import { UpdateVacationStatusSchema, buildTaskAction } from "@capakraken/shared";
|
||||
import { VacationStatus, VacationType } from "@capakraken/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
@@ -12,9 +12,82 @@ import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../
|
||||
import { checkVacationConflicts, checkBatchVacationConflicts } from "../lib/vacation-conflicts.js";
|
||||
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
||||
import { createAuditEntry } from "../lib/audit.js";
|
||||
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
|
||||
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
|
||||
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
|
||||
|
||||
/** Types that consume from annual leave balance */
|
||||
const BALANCE_TYPES = [VacationType.ANNUAL, VacationType.OTHER];
|
||||
const BALANCE_TYPES = new Set<VacationType>([VacationType.ANNUAL, VacationType.OTHER]);
|
||||
|
||||
function isSameUtcDay(left: Date, right: Date): boolean {
|
||||
return left.toISOString().slice(0, 10) === right.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const PreviewVacationRequestSchema = z.object({
|
||||
resourceId: z.string(),
|
||||
type: z.nativeEnum(VacationType),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
isHalfDay: z.boolean().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.endDate < data.startDate) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "End date must be after start date",
|
||||
path: ["endDate"],
|
||||
});
|
||||
}
|
||||
|
||||
if (data.isHalfDay && !isSameUtcDay(data.startDate, data.endDate)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Half-day requests must start and end on the same day",
|
||||
path: ["isHalfDay"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const CreateVacationRequestSchema = z.object({
|
||||
resourceId: z.string(),
|
||||
type: z.nativeEnum(VacationType),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
note: z.string().max(500).optional(),
|
||||
isHalfDay: z.boolean().optional(),
|
||||
halfDayPart: z.enum(["MORNING", "AFTERNOON"]).optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.endDate < data.startDate) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "End date must be after start date",
|
||||
path: ["endDate"],
|
||||
});
|
||||
}
|
||||
|
||||
if (data.isHalfDay && !isSameUtcDay(data.startDate, data.endDate)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Half-day requests must start and end on the same day",
|
||||
path: ["isHalfDay"],
|
||||
});
|
||||
}
|
||||
|
||||
if (data.isHalfDay && !data.halfDayPart) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Half-day requests require a half-day part",
|
||||
path: ["halfDayPart"],
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.isHalfDay && data.halfDayPart) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Half-day part is only allowed for half-day requests",
|
||||
path: ["halfDayPart"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function anonymizeVacationRecord<T extends {
|
||||
resource?: { id: string } | null;
|
||||
@@ -78,6 +151,64 @@ async function notifyVacationStatus(
|
||||
}
|
||||
|
||||
export const vacationRouter = createTRPCRouter({
|
||||
previewRequest: protectedProcedure
|
||||
.input(PreviewVacationRequestSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const holidayContext = await loadResourceHolidayContext(
|
||||
ctx.db,
|
||||
input.resourceId,
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
);
|
||||
const vacation = {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
isHalfDay: input.isHalfDay ?? false,
|
||||
};
|
||||
const requestedDays = countCalendarDaysInPeriod(vacation);
|
||||
const effectiveDays = BALANCE_TYPES.has(input.type)
|
||||
? countVacationChargeableDays({
|
||||
vacation,
|
||||
countryCode: holidayContext.countryCode,
|
||||
federalState: holidayContext.federalState,
|
||||
metroCityName: holidayContext.metroCityName,
|
||||
calendarHolidayStrings: holidayContext.calendarHolidayStrings,
|
||||
publicHolidayStrings: holidayContext.publicHolidayStrings,
|
||||
})
|
||||
: requestedDays;
|
||||
const publicHolidayDates = [...new Set([
|
||||
...holidayContext.calendarHolidayStrings,
|
||||
...holidayContext.publicHolidayStrings,
|
||||
])].sort();
|
||||
const holidayDetails = publicHolidayDates.map((date) => ({
|
||||
date,
|
||||
source:
|
||||
holidayContext.calendarHolidayStrings.includes(date) && holidayContext.publicHolidayStrings.includes(date)
|
||||
? "CALENDAR_AND_LEGACY"
|
||||
: holidayContext.calendarHolidayStrings.includes(date)
|
||||
? "CALENDAR"
|
||||
: "LEGACY_PUBLIC_HOLIDAY",
|
||||
}));
|
||||
|
||||
return {
|
||||
requestedDays,
|
||||
effectiveDays,
|
||||
deductedDays: BALANCE_TYPES.has(input.type) ? effectiveDays : 0,
|
||||
publicHolidayDates,
|
||||
holidayDetails,
|
||||
holidayContext: {
|
||||
countryCode: holidayContext.countryCode ?? null,
|
||||
countryName: holidayContext.countryName ?? null,
|
||||
federalState: holidayContext.federalState ?? null,
|
||||
metroCityName: holidayContext.metroCityName ?? null,
|
||||
sources: {
|
||||
hasCalendarHolidays: holidayContext.calendarHolidayStrings.length > 0,
|
||||
hasLegacyPublicHolidayEntries: holidayContext.publicHolidayStrings.length > 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* List vacations with optional filters.
|
||||
*/
|
||||
@@ -141,21 +272,15 @@ export const vacationRouter = createTRPCRouter({
|
||||
* Adds isHalfDay + halfDayPart support.
|
||||
*/
|
||||
create: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
resourceId: z.string(),
|
||||
type: z.nativeEnum(VacationType),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
note: z.string().max(500).optional(),
|
||||
isHalfDay: z.boolean().optional(),
|
||||
halfDayPart: z.enum(["MORNING", "AFTERNOON"]).optional(),
|
||||
}).refine((d) => d.endDate >= d.startDate, {
|
||||
message: "End date must be after start date",
|
||||
path: ["endDate"],
|
||||
}),
|
||||
)
|
||||
.input(CreateVacationRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.type === VacationType.PUBLIC_HOLIDAY) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Public holidays must be managed via Holiday Calendars or the legacy holiday import, not via manual vacation requests",
|
||||
});
|
||||
}
|
||||
|
||||
const userRecord = await ctx.db.user.findUnique({
|
||||
where: { email: ctx.session.user?.email ?? "" },
|
||||
select: { id: true, systemRole: true },
|
||||
@@ -186,6 +311,9 @@ export const vacationRouter = createTRPCRouter({
|
||||
status: { in: ["APPROVED", "PENDING"] },
|
||||
startDate: { lte: input.endDate },
|
||||
endDate: { gte: input.startDate },
|
||||
...(BALANCE_TYPES.has(input.type)
|
||||
? { type: { not: VacationType.PUBLIC_HOLIDAY } }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
if (overlapping) {
|
||||
@@ -195,6 +323,35 @@ export const vacationRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
let effectiveDays: number | null = null;
|
||||
if (BALANCE_TYPES.has(input.type)) {
|
||||
const holidayContext = await loadResourceHolidayContext(
|
||||
ctx.db,
|
||||
input.resourceId,
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
);
|
||||
effectiveDays = countVacationChargeableDays({
|
||||
vacation: {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
isHalfDay: input.isHalfDay ?? false,
|
||||
},
|
||||
countryCode: holidayContext.countryCode,
|
||||
federalState: holidayContext.federalState,
|
||||
metroCityName: holidayContext.metroCityName,
|
||||
calendarHolidayStrings: holidayContext.calendarHolidayStrings,
|
||||
publicHolidayStrings: holidayContext.publicHolidayStrings,
|
||||
});
|
||||
|
||||
if (effectiveDays <= 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Selected vacation period only contains public holidays and does not deduct any vacation days",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const status = isManager ? VacationStatus.APPROVED : VacationStatus.PENDING;
|
||||
|
||||
const vacation = await ctx.db.vacation.create({
|
||||
@@ -265,7 +422,8 @@ export const vacationRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
return anonymizeVacationRecord(vacation, directory);
|
||||
const result = anonymizeVacationRecord(vacation, directory);
|
||||
return effectiveDays === null ? result : { ...result, effectiveDays };
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -698,19 +856,25 @@ export const vacationRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const holidays = getPublicHolidays(input.year, input.federalState);
|
||||
if (holidays.length === 0) {
|
||||
return { created: 0 };
|
||||
}
|
||||
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
...(input.chapter ? { chapter: input.chapter } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
select: {
|
||||
id: true,
|
||||
federalState: true,
|
||||
countryId: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (resources.length === 0) {
|
||||
return { created: 0 };
|
||||
}
|
||||
|
||||
const adminUser = await ctx.db.user.findUnique({
|
||||
where: { email: ctx.session.user?.email ?? "" },
|
||||
select: { id: true },
|
||||
@@ -718,8 +882,19 @@ export const vacationRouter = createTRPCRouter({
|
||||
if (!adminUser) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
let created = 0;
|
||||
let holidayCount = 0;
|
||||
|
||||
for (const resource of resources) {
|
||||
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
|
||||
periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`),
|
||||
periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`),
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: input.federalState ?? resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
});
|
||||
holidayCount += holidays.length;
|
||||
for (const holiday of holidays) {
|
||||
const startDate = new Date(holiday.date);
|
||||
const endDate = new Date(holiday.date);
|
||||
@@ -771,12 +946,12 @@ export const vacationRouter = createTRPCRouter({
|
||||
entityName: `Public Holidays ${input.year}${input.federalState ? ` (${input.federalState})` : ""}`,
|
||||
action: "CREATE",
|
||||
userId: adminUser.id,
|
||||
after: { created, holidays: holidays.length, resources: resources.length, year: input.year, federalState: input.federalState } as unknown as Record<string, unknown>,
|
||||
after: { created, holidays: holidayCount, resources: resources.length, year: input.year, federalState: input.federalState } as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: `Batch created ${created} public holidays for ${resources.length} resources (${input.year})`,
|
||||
});
|
||||
|
||||
return { created, holidays: holidays.length, resources: resources.length };
|
||||
return { created, holidays: holidayCount, resources: resources.length };
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -89,6 +89,7 @@ export const publicProcedure = t.procedure;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const withLogging = t.middleware(loggingMiddleware as any);
|
||||
const isE2eTestMode = process.env["E2E_TEST_MODE"] === "true";
|
||||
|
||||
/**
|
||||
* Protected procedure — requires authenticated session AND a valid DB user record.
|
||||
@@ -103,12 +104,14 @@ export const protectedProcedure = t.procedure.use(withLogging).use(({ ctx, next
|
||||
}
|
||||
|
||||
// Rate limit by user ID
|
||||
const rateLimitResult = apiRateLimiter(ctx.dbUser.id);
|
||||
if (!rateLimitResult.allowed) {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: `Rate limit exceeded. Try again after ${rateLimitResult.resetAt.toISOString()}`,
|
||||
});
|
||||
if (!isE2eTestMode) {
|
||||
const rateLimitResult = apiRateLimiter(ctx.dbUser.id);
|
||||
if (!rateLimitResult.allowed) {
|
||||
throw new TRPCError({
|
||||
code: "TOO_MANY_REQUESTS",
|
||||
message: `Rate limit exceeded. Try again after ${rateLimitResult.resetAt.toISOString()}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return next({
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getDashboardBudgetForecast,
|
||||
getDashboardChargeabilityOverview,
|
||||
getDashboardDemand,
|
||||
getDashboardOverview,
|
||||
getDashboardPeakTimes,
|
||||
getDashboardProjectHealth,
|
||||
getDashboardTopValueResources,
|
||||
} from "../index.js";
|
||||
|
||||
@@ -285,8 +287,28 @@ describe("dashboard use-cases", () => {
|
||||
},
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 } },
|
||||
{ availability: { monday: 6, tuesday: 6, wednesday: 6, thursday: 6, friday: 6 } },
|
||||
{
|
||||
id: "res_1",
|
||||
displayName: "Alice",
|
||||
chapter: "CGI",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: null,
|
||||
metroCityId: "city_1",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Munich" },
|
||||
},
|
||||
{
|
||||
id: "res_2",
|
||||
displayName: "Bob",
|
||||
chapter: "Lighting",
|
||||
availability: { monday: 6, tuesday: 6, wednesday: 6, thursday: 6, friday: 6 },
|
||||
countryId: "country_de",
|
||||
federalState: null,
|
||||
metroCityId: "city_2",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Hamburg" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
@@ -299,15 +321,113 @@ describe("dashboard use-cases", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
expect.objectContaining({
|
||||
period: "2026-03",
|
||||
groups: [
|
||||
{ name: "ALPHA", hours: 8 },
|
||||
{ name: "ALPHA", hours: 4 },
|
||||
{ name: "BRAVO", hours: 3 },
|
||||
],
|
||||
totalHours: 11,
|
||||
capacityHours: 308,
|
||||
totalHours: 7,
|
||||
capacityHours: 28,
|
||||
derivation: expect.objectContaining({
|
||||
bookedHours: 7,
|
||||
capacityHours: 28,
|
||||
remainingCapacityHours: 21,
|
||||
overbookedHours: 0,
|
||||
utilizationPct: 25,
|
||||
groupCount: 2,
|
||||
resourceCount: 2,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("provides department capacity and utilization details for chapter grouping", async () => {
|
||||
const db = {
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "assign_1",
|
||||
projectId: "proj_1",
|
||||
resourceId: "res_1",
|
||||
status: "ACTIVE",
|
||||
startDate: new Date("2026-03-02T00:00:00.000Z"),
|
||||
endDate: new Date("2026-03-02T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
project: { id: "proj_1", name: "Alpha", shortCode: "ALPHA", status: "ACTIVE", orderType: "FIXED" },
|
||||
resource: { id: "res_1", displayName: "Alice", chapter: "CGI" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_1",
|
||||
displayName: "Alice",
|
||||
chapter: "CGI",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: null,
|
||||
metroCityId: "city_1",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Munich" },
|
||||
},
|
||||
{
|
||||
id: "res_2",
|
||||
displayName: "Bob",
|
||||
chapter: "Lighting",
|
||||
availability: { monday: 4, tuesday: 4, wednesday: 4, thursday: 4, friday: 4 },
|
||||
countryId: "country_de",
|
||||
federalState: null,
|
||||
metroCityId: "city_2",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Hamburg" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getDashboardPeakTimes(db as never, {
|
||||
startDate: new Date("2026-03-02T00:00:00.000Z"),
|
||||
endDate: new Date("2026-03-02T00:00:00.000Z"),
|
||||
granularity: "month",
|
||||
groupBy: "chapter",
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
period: "2026-03",
|
||||
groups: [
|
||||
expect.objectContaining({
|
||||
name: "CGI",
|
||||
hours: 8,
|
||||
capacityHours: 8,
|
||||
remainingHours: 0,
|
||||
overbookedHours: 0,
|
||||
utilizationPct: 100,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "Lighting",
|
||||
hours: 0,
|
||||
capacityHours: 4,
|
||||
remainingHours: 4,
|
||||
overbookedHours: 0,
|
||||
utilizationPct: 0,
|
||||
}),
|
||||
],
|
||||
totalHours: 8,
|
||||
capacityHours: 12,
|
||||
derivation: expect.objectContaining({
|
||||
bookedHours: 8,
|
||||
capacityHours: 12,
|
||||
remainingCapacityHours: 4,
|
||||
overbookedHours: 0,
|
||||
utilizationPct: 67,
|
||||
groupCount: 2,
|
||||
resourceCount: 2,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -513,6 +633,396 @@ describe("dashboard use-cases", () => {
|
||||
expect(withProposed.top[0]?.expectedChargeability).toBe(5);
|
||||
});
|
||||
|
||||
it("excludes regional public holidays from dashboard chargeability availability and bookings", async () => {
|
||||
const db = {
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "assign_holiday",
|
||||
projectId: "proj_1",
|
||||
resourceId: "res_by",
|
||||
status: "CONFIRMED",
|
||||
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
project: {
|
||||
id: "proj_1",
|
||||
name: "Alpha",
|
||||
shortCode: "ALPHA",
|
||||
status: "ACTIVE",
|
||||
orderType: "CLIENT",
|
||||
dynamicFields: null,
|
||||
},
|
||||
resource: {
|
||||
id: "res_by",
|
||||
displayName: "Bruce",
|
||||
chapter: "CGI",
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_by",
|
||||
eid: "bruce.banner",
|
||||
displayName: "Bruce",
|
||||
chapter: "CGI",
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_augsburg",
|
||||
departed: false,
|
||||
chargeabilityTarget: 80,
|
||||
country: {
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
},
|
||||
metroCity: {
|
||||
id: "city_augsburg",
|
||||
name: "Augsburg",
|
||||
},
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getDashboardChargeabilityOverview(db as never, {
|
||||
now: new Date("2026-01-15T00:00:00.000Z"),
|
||||
topN: 10,
|
||||
watchlistThreshold: 15,
|
||||
});
|
||||
|
||||
expect(result.top[0]?.actualChargeability).toBe(0);
|
||||
expect(result.top[0]?.expectedChargeability).toBe(0);
|
||||
expect(result.top[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
countryCode: "DE",
|
||||
federalState: "BY",
|
||||
metroCityName: "Augsburg",
|
||||
derivation: expect.objectContaining({
|
||||
weeklyAvailabilityHours: 40,
|
||||
baseAvailableHours: 184,
|
||||
effectiveAvailableHours: 168,
|
||||
publicHolidayCount: 2,
|
||||
publicHolidayWorkdayCount: 2,
|
||||
publicHolidayHoursDeduction: 16,
|
||||
absenceHoursDeduction: 0,
|
||||
actualBookedHours: 0,
|
||||
expectedBookedHours: 0,
|
||||
targetBookedHours: 134.4,
|
||||
unassignedHours: 168,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses holiday-aware capacity in peak times for regional calendars", async () => {
|
||||
const db = {
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "assign_1",
|
||||
projectId: "proj_1",
|
||||
resourceId: "res_by",
|
||||
status: "CONFIRMED",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
project: { id: "proj_1", name: "Alpha", shortCode: "ALPHA", status: "ACTIVE", orderType: "FIXED" },
|
||||
resource: { id: "res_by", displayName: "Bruce", chapter: "CGI" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_by",
|
||||
displayName: "Bruce",
|
||||
chapter: "CGI",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_munich",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Munich" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getDashboardPeakTimes(db as never, {
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
granularity: "month",
|
||||
groupBy: "project",
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
period: "2026-01",
|
||||
groups: [{ name: "ALPHA", hours: 8 }],
|
||||
totalHours: 8,
|
||||
capacityHours: 8,
|
||||
derivation: expect.objectContaining({
|
||||
bookedHours: 8,
|
||||
capacityHours: 8,
|
||||
remainingCapacityHours: 0,
|
||||
overbookedHours: 0,
|
||||
utilizationPct: 100,
|
||||
groupCount: 1,
|
||||
resourceCount: 1,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not burn budget on regional public holidays", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "proj_1",
|
||||
name: "Alpha",
|
||||
shortCode: "ALPHA",
|
||||
budgetCents: 10_000,
|
||||
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-12-31T00:00:00.000Z"),
|
||||
clientId: null,
|
||||
client: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
projectId: "proj_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
dailyCostCents: 1_000,
|
||||
resource: {
|
||||
id: "res_by",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_munich",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Munich" },
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getDashboardBudgetForecast(db as never);
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
projectId: "proj_1",
|
||||
shortCode: "ALPHA",
|
||||
spentCents: 1_000,
|
||||
remainingCents: 9_000,
|
||||
activeAssignmentCount: 0,
|
||||
calendarLocations: [],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns burn derivation and calendar basis for active project forecasts", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-15T00:00:00.000Z"));
|
||||
|
||||
try {
|
||||
const db = {
|
||||
project: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "proj_1",
|
||||
name: "Gelddruckmaschine",
|
||||
shortCode: "GDM",
|
||||
budgetCents: 100_000,
|
||||
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-12-31T00:00:00.000Z"),
|
||||
clientId: "client_1",
|
||||
client: { name: "ACME" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
projectId: "proj_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
dailyCostCents: 1_000,
|
||||
resource: {
|
||||
id: "res_by",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_munich",
|
||||
country: { code: "DE", name: "Deutschland" },
|
||||
metroCity: { name: "Munich" },
|
||||
},
|
||||
},
|
||||
{
|
||||
projectId: "proj_1",
|
||||
startDate: new Date("2026-01-15T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||
dailyCostCents: 2_000,
|
||||
resource: {
|
||||
id: "res_hh",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "HH",
|
||||
metroCityId: "city_hamburg",
|
||||
country: { code: "DE", name: "Deutschland" },
|
||||
metroCity: { name: "Hamburg" },
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getDashboardBudgetForecast(db as never);
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
projectId: "proj_1",
|
||||
shortCode: "GDM",
|
||||
budgetCents: 100_000,
|
||||
spentCents: 5_000,
|
||||
remainingCents: 95_000,
|
||||
burnRate: 4_000,
|
||||
activeAssignmentCount: 1,
|
||||
calendarLocations: [
|
||||
expect.objectContaining({
|
||||
countryCode: "DE",
|
||||
countryName: "Deutschland",
|
||||
federalState: "HH",
|
||||
metroCityName: "Hamburg",
|
||||
activeAssignmentCount: 1,
|
||||
burnRateCents: 4_000,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("excludes regional public holidays from overview budget totals", async () => {
|
||||
const db = {
|
||||
assignment: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
dailyCostCents: 1_000,
|
||||
resource: {
|
||||
id: "res_by",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_munich",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Munich" },
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
resource: {
|
||||
count: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockResolvedValueOnce(1),
|
||||
findMany: vi.fn().mockResolvedValue([{ chapter: "CGI", chargeabilityTarget: 80 }]),
|
||||
},
|
||||
project: {
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
findMany: vi.fn().mockResolvedValue([{ status: "ACTIVE", budgetCents: 10_000 }]),
|
||||
},
|
||||
demandRequirement: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
auditLog: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getDashboardOverview(db as never);
|
||||
|
||||
expect(result.budgetSummary).toEqual({
|
||||
totalBudgetCents: 10_000,
|
||||
totalCostCents: 1_000,
|
||||
avgUtilizationPercent: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes regional public holidays from project health budget usage", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "proj_1",
|
||||
name: "Alpha",
|
||||
shortCode: "ALPHA",
|
||||
budgetCents: 10_000,
|
||||
endDate: new Date("2026-12-31T00:00:00.000Z"),
|
||||
clientId: null,
|
||||
client: null,
|
||||
demandRequirements: [],
|
||||
},
|
||||
]),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
projectId: "proj_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
dailyCostCents: 1_000,
|
||||
resource: {
|
||||
id: "res_by",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_munich",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Munich" },
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await getDashboardProjectHealth(db as never);
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
shortCode: "ALPHA",
|
||||
budgetHealth: 90,
|
||||
spentCents: 1_000,
|
||||
budgetUtilizationPercent: 10,
|
||||
calendarLocations: [
|
||||
expect.objectContaining({
|
||||
countryCode: "DE",
|
||||
federalState: "BY",
|
||||
metroCityName: "Munich",
|
||||
assignmentCount: 1,
|
||||
spentCents: 1_000,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns distinct resource counts for chapter demand grouping", async () => {
|
||||
const db = {
|
||||
demandRequirement: {
|
||||
@@ -606,14 +1116,21 @@ describe("dashboard use-cases", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
expect.objectContaining({
|
||||
id: "CGI",
|
||||
name: "CGI",
|
||||
shortCode: "CGI",
|
||||
allocatedHours: 19,
|
||||
requiredFTEs: 0,
|
||||
resourceCount: 2,
|
||||
},
|
||||
derivation: expect.objectContaining({
|
||||
periodWorkingHoursBase: 176,
|
||||
requiredHours: null,
|
||||
fillPct: null,
|
||||
demandSource: "NONE",
|
||||
calendarLocations: [],
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -738,14 +1255,21 @@ describe("dashboard use-cases", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
expect.objectContaining({
|
||||
id: "proj_1",
|
||||
name: "Alpha",
|
||||
shortCode: "ALPHA",
|
||||
allocatedHours: 18,
|
||||
requiredFTEs: 3.5,
|
||||
resourceCount: 3,
|
||||
},
|
||||
derivation: expect.objectContaining({
|
||||
periodWorkingHoursBase: 176,
|
||||
requiredHours: 616,
|
||||
fillPct: 3,
|
||||
demandSource: "DEMAND_REQUIREMENTS",
|
||||
calendarLocations: [],
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -802,14 +1326,20 @@ describe("dashboard use-cases", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
expect.objectContaining({
|
||||
id: "proj_1",
|
||||
name: "Alpha",
|
||||
shortCode: "ALPHA",
|
||||
allocatedHours: 8,
|
||||
requiredFTEs: 2,
|
||||
resourceCount: 1,
|
||||
},
|
||||
derivation: expect.objectContaining({
|
||||
periodWorkingHoursBase: 176,
|
||||
requiredHours: 352,
|
||||
fillPct: 2,
|
||||
demandSource: "DEMAND_REQUIREMENTS",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -865,14 +1395,20 @@ describe("dashboard use-cases", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
expect.objectContaining({
|
||||
id: "proj_1",
|
||||
name: "Alpha",
|
||||
shortCode: "ALPHA",
|
||||
allocatedHours: 16,
|
||||
requiredFTEs: 2,
|
||||
resourceCount: 1,
|
||||
},
|
||||
derivation: expect.objectContaining({
|
||||
periodWorkingHoursBase: 176,
|
||||
requiredHours: 352,
|
||||
fillPct: 5,
|
||||
demandSource: "PROJECT_STAFFING_REQS",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -270,6 +270,7 @@ describe("demand and assignment use-cases", () => {
|
||||
|
||||
const db = {
|
||||
demandRequirement: { findUnique: demandRequirementFindUnique },
|
||||
assignment: { findMany: assignmentFindMany },
|
||||
resource: { findUnique: resourceFindUnique },
|
||||
$transaction: vi.fn(async (callback) =>
|
||||
callback({
|
||||
@@ -437,6 +438,7 @@ describe("demand and assignment use-cases", () => {
|
||||
|
||||
const db = {
|
||||
demandRequirement: { findUnique: demandRequirementFindUnique },
|
||||
assignment: { findMany: assignmentFindMany },
|
||||
resource: { findUnique: resourceFindUnique },
|
||||
$transaction: vi.fn(async (callback) =>
|
||||
callback({
|
||||
@@ -555,6 +557,7 @@ describe("demand and assignment use-cases", () => {
|
||||
|
||||
const db = {
|
||||
demandRequirement: { findUnique: demandRequirementFindUnique },
|
||||
assignment: { findMany: assignmentFindMany },
|
||||
$transaction: vi.fn(async (callback) =>
|
||||
callback({
|
||||
project: { findUnique: projectFindUnique },
|
||||
@@ -685,7 +688,7 @@ describe("demand and assignment use-cases", () => {
|
||||
|
||||
const db = {
|
||||
demandRequirement: { findUnique: demandRequirementFindUnique },
|
||||
assignment: { findUnique: assignmentFindUnique },
|
||||
assignment: { findUnique: assignmentFindUnique, findMany: assignmentFindMany },
|
||||
resource: { findUnique: resourceFindUnique },
|
||||
$transaction: vi.fn(async (callback) =>
|
||||
callback({
|
||||
@@ -805,7 +808,7 @@ describe("demand and assignment use-cases", () => {
|
||||
findMany: allocationFindMany,
|
||||
},
|
||||
demandRequirement: { findUnique: demandRequirementFindUnique },
|
||||
assignment: { findUnique: assignmentFindUnique },
|
||||
assignment: { findUnique: assignmentFindUnique, findMany: assignmentFindMany },
|
||||
resource: { findUnique: resourceFindUnique },
|
||||
$transaction: vi.fn(async (callback) =>
|
||||
callback({
|
||||
@@ -966,7 +969,7 @@ describe("demand and assignment use-cases", () => {
|
||||
findMany: allocationFindMany,
|
||||
},
|
||||
demandRequirement: { findUnique: demandRequirementFindUnique },
|
||||
assignment: { findUnique: assignmentFindUnique },
|
||||
assignment: { findUnique: assignmentFindUnique, findMany: assignmentFindMany },
|
||||
resource: { findUnique: resourceFindUnique },
|
||||
$transaction: vi.fn(async (callback) =>
|
||||
callback({
|
||||
@@ -1102,7 +1105,7 @@ describe("demand and assignment use-cases", () => {
|
||||
demandRequirement: {
|
||||
findUnique: demandRequirementFindUnique,
|
||||
},
|
||||
assignment: { findUnique: assignmentFindUnique },
|
||||
assignment: { findUnique: assignmentFindUnique, findMany: assignmentFindMany },
|
||||
auditLog: { create: auditLogCreate },
|
||||
$transaction: vi.fn(async (callback) =>
|
||||
callback({
|
||||
@@ -1227,7 +1230,7 @@ describe("demand and assignment use-cases", () => {
|
||||
demandRequirement: {
|
||||
findUnique: demandRequirementFindUnique,
|
||||
},
|
||||
assignment: { findUnique: assignmentFindUnique },
|
||||
assignment: { findUnique: assignmentFindUnique, findMany: assignmentFindMany },
|
||||
auditLog: { create: auditLogCreate },
|
||||
$transaction: vi.fn(async (callback) =>
|
||||
callback({
|
||||
|
||||
@@ -80,8 +80,16 @@ export {
|
||||
type GetDashboardTopValueResourcesInput,
|
||||
type GetDashboardDemandInput,
|
||||
type GetDashboardChargeabilityOverviewInput,
|
||||
type DashboardChargeabilityDerivation,
|
||||
type DashboardChargeabilityRow,
|
||||
getDashboardBudgetForecast,
|
||||
type BudgetForecastRow,
|
||||
type BudgetForecastLocationSummary,
|
||||
type PeakTimesPeriodDerivation,
|
||||
type PeakTimesPeriodRow,
|
||||
type DemandCalendarLocationSummary,
|
||||
type DemandRowDerivation,
|
||||
type DashboardDemandRow,
|
||||
getDashboardSkillGaps,
|
||||
type SkillGapRow,
|
||||
getDashboardProjectHealth,
|
||||
|
||||
@@ -1,16 +1,57 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { calculateInclusiveDays, MILLISECONDS_PER_DAY } from "./shared.js";
|
||||
import {
|
||||
calculateEffectiveAllocationCostCents,
|
||||
loadDailyAvailabilityContexts,
|
||||
} from "./holiday-capacity.js";
|
||||
|
||||
export interface BudgetForecastLocationSummary {
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
activeAssignmentCount: number;
|
||||
burnRateCents: number;
|
||||
}
|
||||
|
||||
export interface BudgetForecastRow {
|
||||
projectId?: string;
|
||||
projectName: string;
|
||||
shortCode: string;
|
||||
clientId: string | null;
|
||||
clientName: string | null;
|
||||
budgetCents: number;
|
||||
spentCents: number;
|
||||
remainingCents?: number;
|
||||
burnRate: number;
|
||||
estimatedExhaustionDate: string | null;
|
||||
pctUsed: number;
|
||||
activeAssignmentCount?: number;
|
||||
calendarLocations?: BudgetForecastLocationSummary[];
|
||||
}
|
||||
|
||||
function hasAvailability<T extends { availability?: unknown }>(
|
||||
resource: T | null | undefined,
|
||||
): resource is T & { availability: WeekdayAvailability } {
|
||||
return resource !== null
|
||||
&& resource !== undefined
|
||||
&& resource.availability !== null
|
||||
&& resource.availability !== undefined;
|
||||
}
|
||||
|
||||
function buildLocationKey(input: {
|
||||
countryCode: string | null | undefined;
|
||||
countryName: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityName: string | null | undefined;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
countryCode: input.countryCode ?? null,
|
||||
countryName: input.countryName ?? null,
|
||||
federalState: input.federalState ?? null,
|
||||
metroCityName: input.metroCityName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDashboardBudgetForecast(
|
||||
@@ -32,7 +73,7 @@ export async function getDashboardBudgetForecast(
|
||||
|
||||
if (projects.length === 0) return [];
|
||||
|
||||
const projectIds = projects.map((p) => p.id);
|
||||
const projectIds = projects.map((project) => project.id);
|
||||
|
||||
const assignments = await db.assignment.findMany({
|
||||
where: {
|
||||
@@ -44,42 +85,142 @@ export async function getDashboardBudgetForecast(
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
dailyCostCents: true,
|
||||
resource: {
|
||||
select: {
|
||||
id: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: {
|
||||
select: {
|
||||
code: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
metroCity: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
const contextStart = assignments.length > 0
|
||||
? new Date(
|
||||
Math.min(
|
||||
...assignments.map((assignment) => assignment.startDate.getTime()),
|
||||
monthStart.getTime(),
|
||||
),
|
||||
)
|
||||
: monthStart;
|
||||
const contextEnd = assignments.length > 0
|
||||
? new Date(
|
||||
Math.max(
|
||||
...assignments.map((assignment) => assignment.endDate.getTime()),
|
||||
monthEnd.getTime(),
|
||||
),
|
||||
)
|
||||
: monthEnd;
|
||||
const contexts = await loadDailyAvailabilityContexts(
|
||||
db,
|
||||
assignments
|
||||
.map((assignment) => assignment.resource)
|
||||
.filter(hasAvailability)
|
||||
.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
contextStart,
|
||||
contextEnd,
|
||||
);
|
||||
const spentByProject = new Map<string, number>();
|
||||
const monthlyBurnByProject = new Map<string, number>();
|
||||
const activeAssignmentCountByProject = new Map<string, number>();
|
||||
const activeLocationsByProject = new Map<string, Map<string, BudgetForecastLocationSummary>>();
|
||||
|
||||
for (const a of assignments) {
|
||||
const days = calculateInclusiveDays(a.startDate, a.endDate);
|
||||
const totalCost = (a.dailyCostCents ?? 0) * days;
|
||||
for (const assignment of assignments) {
|
||||
const totalCost = hasAvailability(assignment.resource)
|
||||
? calculateEffectiveAllocationCostCents({
|
||||
availability: assignment.resource.availability as unknown as WeekdayAvailability,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
dailyCostCents: assignment.dailyCostCents ?? 0,
|
||||
periodStart: assignment.startDate,
|
||||
periodEnd: assignment.endDate,
|
||||
context: contexts.get(assignment.resource.id),
|
||||
})
|
||||
: (assignment.dailyCostCents ?? 0)
|
||||
* calculateInclusiveDays(assignment.startDate, assignment.endDate);
|
||||
|
||||
spentByProject.set(
|
||||
a.projectId,
|
||||
(spentByProject.get(a.projectId) ?? 0) + totalCost,
|
||||
assignment.projectId,
|
||||
(spentByProject.get(assignment.projectId) ?? 0) + totalCost,
|
||||
);
|
||||
|
||||
// Approximate monthly burn from active assignments that overlap today
|
||||
if (a.startDate <= now && a.endDate >= now) {
|
||||
// ~22 working days per month
|
||||
const monthlyContribution = (a.dailyCostCents ?? 0) * 22;
|
||||
if (assignment.startDate <= now && assignment.endDate >= now) {
|
||||
const monthlyContribution = hasAvailability(assignment.resource)
|
||||
? calculateEffectiveAllocationCostCents({
|
||||
availability: assignment.resource.availability as unknown as WeekdayAvailability,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
dailyCostCents: assignment.dailyCostCents ?? 0,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
context: contexts.get(assignment.resource.id),
|
||||
})
|
||||
: (assignment.dailyCostCents ?? 0) * 22;
|
||||
monthlyBurnByProject.set(
|
||||
a.projectId,
|
||||
(monthlyBurnByProject.get(a.projectId) ?? 0) + monthlyContribution,
|
||||
assignment.projectId,
|
||||
(monthlyBurnByProject.get(assignment.projectId) ?? 0) + monthlyContribution,
|
||||
);
|
||||
activeAssignmentCountByProject.set(
|
||||
assignment.projectId,
|
||||
(activeAssignmentCountByProject.get(assignment.projectId) ?? 0) + 1,
|
||||
);
|
||||
|
||||
const locationSummaries = activeLocationsByProject.get(assignment.projectId) ?? new Map();
|
||||
const locationKey = buildLocationKey({
|
||||
countryCode: assignment.resource.country?.code,
|
||||
countryName: assignment.resource.country?.name,
|
||||
federalState: assignment.resource.federalState,
|
||||
metroCityName: assignment.resource.metroCity?.name,
|
||||
});
|
||||
const summary = locationSummaries.get(locationKey) ?? {
|
||||
countryCode: assignment.resource.country?.code ?? null,
|
||||
countryName: assignment.resource.country?.name ?? null,
|
||||
federalState: assignment.resource.federalState ?? null,
|
||||
metroCityName: assignment.resource.metroCity?.name ?? null,
|
||||
activeAssignmentCount: 0,
|
||||
burnRateCents: 0,
|
||||
};
|
||||
summary.activeAssignmentCount += 1;
|
||||
summary.burnRateCents += monthlyContribution;
|
||||
locationSummaries.set(locationKey, summary);
|
||||
activeLocationsByProject.set(assignment.projectId, locationSummaries);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: BudgetForecastRow[] = projects.map((p) => {
|
||||
const spentCents = spentByProject.get(p.id) ?? 0;
|
||||
const burnRate = monthlyBurnByProject.get(p.id) ?? 0;
|
||||
const pctUsed =
|
||||
p.budgetCents > 0 ? Math.round((spentCents / p.budgetCents) * 100) : 0;
|
||||
const rows: BudgetForecastRow[] = projects.map((project) => {
|
||||
const spentCents = spentByProject.get(project.id) ?? 0;
|
||||
const burnRate = monthlyBurnByProject.get(project.id) ?? 0;
|
||||
const remainingCents = Math.max(0, project.budgetCents - spentCents);
|
||||
const pctUsed = project.budgetCents > 0
|
||||
? Math.round((spentCents / project.budgetCents) * 100)
|
||||
: 0;
|
||||
|
||||
let estimatedExhaustionDate: string | null = null;
|
||||
if (burnRate > 0 && p.budgetCents > spentCents) {
|
||||
const remainingCents = p.budgetCents - spentCents;
|
||||
if (burnRate > 0 && project.budgetCents > spentCents) {
|
||||
const monthsRemaining = remainingCents / burnRate;
|
||||
const exhaustionDate = new Date(
|
||||
now.getTime() + monthsRemaining * 30 * MILLISECONDS_PER_DAY,
|
||||
@@ -88,18 +229,23 @@ export async function getDashboardBudgetForecast(
|
||||
}
|
||||
|
||||
return {
|
||||
projectName: p.name,
|
||||
shortCode: p.shortCode,
|
||||
clientId: p.clientId,
|
||||
clientName: p.client?.name ?? null,
|
||||
budgetCents: p.budgetCents,
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
shortCode: project.shortCode,
|
||||
clientId: project.clientId,
|
||||
clientName: project.client?.name ?? null,
|
||||
budgetCents: project.budgetCents,
|
||||
spentCents,
|
||||
remainingCents,
|
||||
burnRate,
|
||||
estimatedExhaustionDate,
|
||||
pctUsed,
|
||||
activeAssignmentCount: activeAssignmentCountByProject.get(project.id) ?? 0,
|
||||
calendarLocations: Array.from(activeLocationsByProject.get(project.id)?.values() ?? [])
|
||||
.sort((left, right) => right.burnRateCents - left.burnRateCents),
|
||||
};
|
||||
});
|
||||
|
||||
rows.sort((a, b) => b.pctUsed - a.pctUsed);
|
||||
rows.sort((left, right) => right.pctUsed - left.pctUsed);
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import { computeChargeability } from "@capakraken/engine";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import {
|
||||
isChargeabilityActualBooking,
|
||||
isChargeabilityRelevantProject,
|
||||
} from "../allocation/chargeability-bookings.js";
|
||||
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
|
||||
import {
|
||||
calculateEffectiveAllocationHours,
|
||||
calculateEffectiveAvailableHours,
|
||||
loadDailyAvailabilityContexts,
|
||||
type DailyAvailabilityContext,
|
||||
} from "./holiday-capacity.js";
|
||||
|
||||
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
];
|
||||
|
||||
export interface GetDashboardChargeabilityOverviewInput {
|
||||
includeProposed?: boolean;
|
||||
@@ -16,6 +31,132 @@ export interface GetDashboardChargeabilityOverviewInput {
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export interface DashboardChargeabilityDerivation {
|
||||
weeklyAvailabilityHours: number;
|
||||
baseWorkingDays: number;
|
||||
effectiveWorkingDayEquivalent: number;
|
||||
baseAvailableHours: number;
|
||||
effectiveAvailableHours: number;
|
||||
publicHolidayCount: number;
|
||||
publicHolidayWorkdayCount: number;
|
||||
publicHolidayHoursDeduction: number;
|
||||
absenceDayEquivalent: number;
|
||||
absenceHoursDeduction: number;
|
||||
actualBookedHours: number;
|
||||
expectedBookedHours: number;
|
||||
targetBookedHours: number;
|
||||
unassignedHours: number;
|
||||
}
|
||||
|
||||
export interface DashboardChargeabilityRow {
|
||||
id: string;
|
||||
eid: string;
|
||||
displayName: string;
|
||||
chapter: string | null;
|
||||
countryId?: string | null;
|
||||
countryCode?: string | null;
|
||||
countryName?: string | null;
|
||||
federalState?: string | null;
|
||||
metroCityName?: string | null;
|
||||
departed: boolean | null;
|
||||
chargeabilityTarget: number;
|
||||
actualChargeability: number;
|
||||
expectedChargeability: number;
|
||||
derivation?: DashboardChargeabilityDerivation;
|
||||
}
|
||||
|
||||
function toIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function getDailyAvailabilityHours(
|
||||
availability: WeekdayAvailability,
|
||||
date: Date,
|
||||
): number {
|
||||
const dayKey = DAY_KEYS[date.getUTCDay()];
|
||||
return dayKey ? (availability[dayKey] ?? 0) : 0;
|
||||
}
|
||||
|
||||
function summarizeDerivation(
|
||||
availability: WeekdayAvailability,
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
context: DailyAvailabilityContext | undefined,
|
||||
actualBookedHours: number,
|
||||
expectedBookedHours: number,
|
||||
chargeabilityTarget: number,
|
||||
): DashboardChargeabilityDerivation {
|
||||
let baseWorkingDays = 0;
|
||||
let effectiveWorkingDayEquivalent = 0;
|
||||
let publicHolidayWorkdayCount = 0;
|
||||
let publicHolidayHoursDeduction = 0;
|
||||
let absenceDayEquivalent = 0;
|
||||
let absenceHoursDeduction = 0;
|
||||
|
||||
const weeklyAvailabilityHours = Object.values(availability).reduce(
|
||||
(sum, hours) => sum + (hours ?? 0),
|
||||
0,
|
||||
);
|
||||
const baseAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context: undefined,
|
||||
});
|
||||
const effectiveAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context,
|
||||
});
|
||||
|
||||
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) {
|
||||
const isoDate = toIsoDate(cursor);
|
||||
const baseHours = getDailyAvailabilityHours(availability, cursor);
|
||||
const absenceFraction = Math.min(
|
||||
1,
|
||||
Math.max(0, context?.absenceFractionsByDate.get(isoDate) ?? 0),
|
||||
);
|
||||
const isHoliday = context?.holidayDates.has(isoDate) ?? false;
|
||||
|
||||
if (baseHours > 0) {
|
||||
baseWorkingDays += 1;
|
||||
if (isHoliday) {
|
||||
publicHolidayWorkdayCount += 1;
|
||||
publicHolidayHoursDeduction += baseHours;
|
||||
} else {
|
||||
absenceDayEquivalent += absenceFraction;
|
||||
absenceHoursDeduction += baseHours * absenceFraction;
|
||||
effectiveWorkingDayEquivalent += Math.max(0, 1 - absenceFraction);
|
||||
}
|
||||
}
|
||||
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
weeklyAvailabilityHours,
|
||||
baseWorkingDays,
|
||||
effectiveWorkingDayEquivalent,
|
||||
baseAvailableHours,
|
||||
effectiveAvailableHours,
|
||||
publicHolidayCount: context?.holidayDates.size ?? 0,
|
||||
publicHolidayWorkdayCount,
|
||||
publicHolidayHoursDeduction,
|
||||
absenceDayEquivalent,
|
||||
absenceHoursDeduction,
|
||||
actualBookedHours,
|
||||
expectedBookedHours,
|
||||
targetBookedHours: Math.round((effectiveAvailableHours * chargeabilityTarget) / 10) / 10,
|
||||
unassignedHours: Math.max(0, effectiveAvailableHours - expectedBookedHours),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDashboardChargeabilityOverview(
|
||||
db: PrismaClient,
|
||||
input: GetDashboardChargeabilityOverviewInput,
|
||||
@@ -38,8 +179,23 @@ export async function getDashboardChargeabilityOverview(
|
||||
displayName: true,
|
||||
chapter: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
departed: true,
|
||||
chargeabilityTarget: true,
|
||||
country: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
metroCity: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
availability: true,
|
||||
},
|
||||
});
|
||||
@@ -48,9 +204,24 @@ export async function getDashboardChargeabilityOverview(
|
||||
endDate: end,
|
||||
resourceIds: resources.map((resource) => resource.id),
|
||||
});
|
||||
const contexts = await loadDailyAvailabilityContexts(
|
||||
db,
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
start,
|
||||
end,
|
||||
);
|
||||
|
||||
const stats = resources.map((resource) => {
|
||||
const stats: DashboardChargeabilityRow[] = resources.map((resource) => {
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(resource.id);
|
||||
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
|
||||
const actualAllocations = resourceBookings.filter((booking) =>
|
||||
isChargeabilityActualBooking(booking, input.includeProposed === true),
|
||||
@@ -58,18 +229,43 @@ export async function getDashboardChargeabilityOverview(
|
||||
const expectedAllocations = resourceBookings.filter(
|
||||
(booking) => isChargeabilityRelevantProject(booking.project, true),
|
||||
);
|
||||
const actual = computeChargeability(
|
||||
const availableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
actualAllocations,
|
||||
start,
|
||||
end,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
});
|
||||
const actualBookedHours = actualAllocations.reduce(
|
||||
(sum, allocation) => sum + calculateEffectiveAllocationHours({
|
||||
availability,
|
||||
startDate: allocation.startDate,
|
||||
endDate: allocation.endDate,
|
||||
hoursPerDay: allocation.hoursPerDay,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const expected = computeChargeability(
|
||||
availability,
|
||||
expectedAllocations,
|
||||
start,
|
||||
end,
|
||||
const expectedBookedHours = expectedAllocations.reduce(
|
||||
(sum, allocation) => sum + calculateEffectiveAllocationHours({
|
||||
availability,
|
||||
startDate: allocation.startDate,
|
||||
endDate: allocation.endDate,
|
||||
hoursPerDay: allocation.hoursPerDay,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const actualChargeability = availableHours > 0
|
||||
? Math.min(100, Math.round((actualBookedHours / availableHours) * 100))
|
||||
: 0;
|
||||
const expectedChargeability = availableHours > 0
|
||||
? Math.min(100, Math.round((expectedBookedHours / availableHours) * 100))
|
||||
: 0;
|
||||
const chargeabilityTarget = resource.chargeabilityTarget ?? 0;
|
||||
|
||||
return {
|
||||
id: resource.id,
|
||||
@@ -77,10 +273,23 @@ export async function getDashboardChargeabilityOverview(
|
||||
displayName: resource.displayName,
|
||||
chapter: resource.chapter,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code ?? null,
|
||||
countryName: resource.country?.name ?? null,
|
||||
federalState: resource.federalState ?? null,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
departed: resource.departed,
|
||||
chargeabilityTarget: resource.chargeabilityTarget,
|
||||
actualChargeability: actual.chargeability,
|
||||
expectedChargeability: expected.chargeability,
|
||||
chargeabilityTarget,
|
||||
actualChargeability,
|
||||
expectedChargeability,
|
||||
derivation: summarizeDerivation(
|
||||
availability,
|
||||
start,
|
||||
end,
|
||||
context,
|
||||
actualBookedHours,
|
||||
expectedBookedHours,
|
||||
chargeabilityTarget,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { loadDashboardPlanningReadModel } from "./load-dashboard-planning-read-model.js";
|
||||
import { calculateAllocationHours } from "./shared.js";
|
||||
import {
|
||||
calculateEffectiveAllocationHours,
|
||||
calculateEffectiveAvailableHours,
|
||||
loadDailyAvailabilityContexts,
|
||||
} from "./holiday-capacity.js";
|
||||
|
||||
export interface GetDashboardDemandInput {
|
||||
startDate: Date;
|
||||
@@ -8,6 +14,35 @@ export interface GetDashboardDemandInput {
|
||||
groupBy: "project" | "person" | "chapter";
|
||||
}
|
||||
|
||||
export interface DemandCalendarLocationSummary {
|
||||
countryCode: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
resourceCount: number;
|
||||
allocatedHours: number;
|
||||
}
|
||||
|
||||
export interface DemandRowDerivation {
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
periodWorkingHoursBase: number;
|
||||
requiredHours: number | null;
|
||||
requiredFTEs: number;
|
||||
fillPct: number | null;
|
||||
demandSource: "DEMAND_REQUIREMENTS" | "PROJECT_STAFFING_REQS" | "NONE";
|
||||
calendarLocations: DemandCalendarLocationSummary[];
|
||||
}
|
||||
|
||||
export interface DashboardDemandRow {
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode: string;
|
||||
allocatedHours: number;
|
||||
requiredFTEs: number;
|
||||
resourceCount: number;
|
||||
derivation?: DemandRowDerivation;
|
||||
}
|
||||
|
||||
interface ProjectSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -15,6 +50,12 @@ interface ProjectSummary {
|
||||
staffingReqs: unknown;
|
||||
}
|
||||
|
||||
function hasAvailability<T extends { availability?: unknown }>(
|
||||
resource: T | null | undefined,
|
||||
): resource is T & { availability: WeekdayAvailability } {
|
||||
return resource !== null && resource !== undefined && resource.availability !== null && resource.availability !== undefined;
|
||||
}
|
||||
|
||||
function getDemandFteFactor(hoursPerDay: number, percentage: number): number {
|
||||
const normalizedPercentage = percentage > 0 ? percentage : (hoursPerDay / 8) * 100;
|
||||
return normalizedPercentage / 100;
|
||||
@@ -24,6 +65,22 @@ function toDate(value: Date | string): Date {
|
||||
return value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
|
||||
function toIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function buildLocationKey(input: {
|
||||
countryCode: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityName: string | null | undefined;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
countryCode: input.countryCode ?? null,
|
||||
federalState: input.federalState ?? null,
|
||||
metroCityName: input.metroCityName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function getProjectRequiredFTEs(staffingReqs: unknown): number {
|
||||
const requirements = Array.isArray(staffingReqs) ? staffingReqs : [];
|
||||
return requirements.reduce((sum, requirement) => {
|
||||
@@ -40,10 +97,84 @@ function getProjectRequiredFTEs(staffingReqs: unknown): number {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const FULL_TIME_AVAILABILITY: WeekdayAvailability = {
|
||||
sunday: 0,
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
};
|
||||
|
||||
function summarizeCalendarLocations(
|
||||
assignments: Array<{
|
||||
resource?: { id?: string | null } | null;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
hoursPerDay: number;
|
||||
}>,
|
||||
resourceInfoById: Map<string, {
|
||||
id: string;
|
||||
availability: WeekdayAvailability;
|
||||
countryCode: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityName: string | null | undefined;
|
||||
}>,
|
||||
contexts: Map<string, Awaited<ReturnType<typeof loadDailyAvailabilityContexts>> extends Map<string, infer T> ? T : never>,
|
||||
input: GetDashboardDemandInput,
|
||||
): DemandCalendarLocationSummary[] {
|
||||
const locationMap = new Map<string, DemandCalendarLocationSummary & { resourceIds: Set<string> }>();
|
||||
|
||||
for (const assignment of assignments) {
|
||||
const resourceId = assignment.resource?.id ?? undefined;
|
||||
const resource = resourceId ? resourceInfoById.get(resourceId) : undefined;
|
||||
if (!resource) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hours = calculateEffectiveAllocationHours({
|
||||
availability: resource.availability,
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
context: contexts.get(resource.id),
|
||||
});
|
||||
|
||||
const locationKey = buildLocationKey({
|
||||
countryCode: resource.countryCode,
|
||||
federalState: resource.federalState,
|
||||
metroCityName: resource.metroCityName,
|
||||
});
|
||||
const existing = locationMap.get(locationKey) ?? {
|
||||
countryCode: resource.countryCode ?? null,
|
||||
federalState: resource.federalState ?? null,
|
||||
metroCityName: resource.metroCityName ?? null,
|
||||
resourceCount: 0,
|
||||
allocatedHours: 0,
|
||||
resourceIds: new Set<string>(),
|
||||
};
|
||||
|
||||
existing.allocatedHours += hours;
|
||||
existing.resourceIds.add(resource.id);
|
||||
existing.resourceCount = existing.resourceIds.size;
|
||||
locationMap.set(locationKey, existing);
|
||||
}
|
||||
|
||||
return [...locationMap.values()]
|
||||
.map(({ resourceIds: _resourceIds, ...summary }) => ({
|
||||
...summary,
|
||||
allocatedHours: Math.round(summary.allocatedHours * 10) / 10,
|
||||
}))
|
||||
.sort((left, right) => right.allocatedHours - left.allocatedHours);
|
||||
}
|
||||
|
||||
export async function getDashboardDemand(
|
||||
db: PrismaClient,
|
||||
input: GetDashboardDemandInput,
|
||||
) {
|
||||
): Promise<DashboardDemandRow[]> {
|
||||
const { demandRequirements, assignments, projects, readModel } =
|
||||
await loadDashboardPlanningReadModel(db, {
|
||||
startDate: input.startDate,
|
||||
@@ -58,6 +189,27 @@ export async function getDashboardDemand(
|
||||
);
|
||||
const normalizedAssignments = readModel.assignments;
|
||||
const normalizedDemands = readModel.demands;
|
||||
const resourceProfiles = assignments
|
||||
.map((assignment) => assignment.resource)
|
||||
.filter(hasAvailability)
|
||||
.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
}));
|
||||
const resourceInfoById = new Map(
|
||||
resourceProfiles.map((resource) => [resource.id, resource]),
|
||||
);
|
||||
const contexts = await loadDailyAvailabilityContexts(
|
||||
db,
|
||||
[...new Map(resourceProfiles.map((resource) => [resource.id, resource])).values()],
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
);
|
||||
|
||||
const projectMap = new Map<string, ProjectSummary>(
|
||||
projects.map((project) => [project.id, project]),
|
||||
@@ -87,6 +239,13 @@ export async function getDashboardDemand(
|
||||
);
|
||||
}
|
||||
|
||||
const periodWorkingHoursBase = calculateEffectiveAvailableHours({
|
||||
availability: FULL_TIME_AVAILABILITY,
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
context: undefined,
|
||||
});
|
||||
|
||||
if (input.groupBy === "project") {
|
||||
const projectIds = new Set<string>([
|
||||
...projectMap.keys(),
|
||||
@@ -110,13 +269,28 @@ export async function getDashboardDemand(
|
||||
);
|
||||
|
||||
const allocatedHours = projectAssignments.reduce(
|
||||
(sum, assignment) =>
|
||||
sum +
|
||||
calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
}),
|
||||
(sum, assignment) => {
|
||||
const resource = assignment.resource?.id
|
||||
? resourceInfoById.get(assignment.resource.id)
|
||||
: undefined;
|
||||
return sum + (
|
||||
resource
|
||||
? calculateEffectiveAllocationHours({
|
||||
availability: resource.availability,
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
context: contexts.get(resource.id),
|
||||
})
|
||||
: calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
})
|
||||
);
|
||||
},
|
||||
0,
|
||||
);
|
||||
const requiredFTEs =
|
||||
@@ -139,6 +313,18 @@ export async function getDashboardDemand(
|
||||
return sum + plannedHeadcount * demandFteFactor;
|
||||
}, 0)
|
||||
: getProjectRequiredFTEs(project.staffingReqs);
|
||||
const requiredHours = requiredFTEs > 0
|
||||
? Math.round(requiredFTEs * periodWorkingHoursBase * 10) / 10
|
||||
: null;
|
||||
const fillPct = requiredHours && requiredHours > 0
|
||||
? Math.round((allocatedHours / requiredHours) * 100)
|
||||
: null;
|
||||
const calendarLocations = summarizeCalendarLocations(
|
||||
projectAssignments,
|
||||
resourceInfoById,
|
||||
contexts,
|
||||
input,
|
||||
);
|
||||
|
||||
return {
|
||||
id: project.id,
|
||||
@@ -149,6 +335,18 @@ export async function getDashboardDemand(
|
||||
resourceCount: new Set(
|
||||
projectAssignments.map((assignment) => assignment.resource?.id).filter(Boolean),
|
||||
).size,
|
||||
derivation: {
|
||||
periodStart: toIsoDate(input.startDate),
|
||||
periodEnd: toIsoDate(input.endDate),
|
||||
periodWorkingHoursBase,
|
||||
requiredHours,
|
||||
requiredFTEs: Math.round(requiredFTEs * 100) / 100,
|
||||
fillPct,
|
||||
demandSource: projectDemands.length > 0
|
||||
? "DEMAND_REQUIREMENTS"
|
||||
: "PROJECT_STAFFING_REQS",
|
||||
calendarLocations,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -161,6 +359,9 @@ export async function getDashboardDemand(
|
||||
|
||||
for (const assignment of normalizedAssignments) {
|
||||
const chapter = assignment.resource?.chapter ?? "Unassigned";
|
||||
const resource = assignment.resource?.id
|
||||
? resourceInfoById.get(assignment.resource.id)
|
||||
: undefined;
|
||||
const existing = chapterMap.get(chapter) ?? {
|
||||
allocatedHours: 0,
|
||||
resourceIds: new Set<string>(),
|
||||
@@ -170,23 +371,54 @@ export async function getDashboardDemand(
|
||||
existing.resourceIds.add(assignment.resource.id);
|
||||
}
|
||||
|
||||
existing.allocatedHours += calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
});
|
||||
existing.allocatedHours += resource
|
||||
? calculateEffectiveAllocationHours({
|
||||
availability: resource.availability,
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
context: contexts.get(resource.id),
|
||||
})
|
||||
: calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
});
|
||||
|
||||
chapterMap.set(chapter, existing);
|
||||
}
|
||||
|
||||
return [...chapterMap.entries()].map(([chapter, data]) => ({
|
||||
id: chapter,
|
||||
name: chapter,
|
||||
shortCode: chapter,
|
||||
allocatedHours: Math.round(data.allocatedHours),
|
||||
requiredFTEs: 0,
|
||||
resourceCount: data.resourceIds.size,
|
||||
}));
|
||||
return [...chapterMap.entries()].map(([chapter, data]) => {
|
||||
const chapterAssignments = normalizedAssignments.filter(
|
||||
(assignment) => (assignment.resource?.chapter ?? "Unassigned") === chapter,
|
||||
);
|
||||
|
||||
return {
|
||||
id: chapter,
|
||||
name: chapter,
|
||||
shortCode: chapter,
|
||||
allocatedHours: Math.round(data.allocatedHours),
|
||||
requiredFTEs: 0,
|
||||
resourceCount: data.resourceIds.size,
|
||||
derivation: {
|
||||
periodStart: toIsoDate(input.startDate),
|
||||
periodEnd: toIsoDate(input.endDate),
|
||||
periodWorkingHoursBase,
|
||||
requiredHours: null,
|
||||
requiredFTEs: 0,
|
||||
fillPct: null,
|
||||
demandSource: "NONE",
|
||||
calendarLocations: summarizeCalendarLocations(
|
||||
chapterAssignments,
|
||||
resourceInfoById,
|
||||
contexts,
|
||||
input,
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const personMap = new Map<
|
||||
@@ -210,23 +442,55 @@ export async function getDashboardDemand(
|
||||
allocatedHours: 0,
|
||||
projectIds: new Set<string>(),
|
||||
};
|
||||
const resource = resourceInfoById.get(assignment.resource.id);
|
||||
|
||||
existing.allocatedHours += calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
});
|
||||
existing.allocatedHours += resource
|
||||
? calculateEffectiveAllocationHours({
|
||||
availability: resource.availability,
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
context: contexts.get(resource.id),
|
||||
})
|
||||
: calculateAllocationHours({
|
||||
startDate: toDate(assignment.startDate),
|
||||
endDate: toDate(assignment.endDate),
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
});
|
||||
existing.projectIds.add(assignment.projectId);
|
||||
|
||||
personMap.set(assignment.resource.id, existing);
|
||||
}
|
||||
|
||||
return [...personMap.entries()].map(([id, data]) => ({
|
||||
id,
|
||||
name: data.name,
|
||||
shortCode: data.chapter ?? "",
|
||||
allocatedHours: Math.round(data.allocatedHours),
|
||||
requiredFTEs: 0,
|
||||
resourceCount: data.projectIds.size,
|
||||
}));
|
||||
return [...personMap.entries()].map(([id, data]) => {
|
||||
const personAssignments = normalizedAssignments.filter(
|
||||
(assignment) => assignment.resource?.id === id,
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
name: data.name,
|
||||
shortCode: data.chapter ?? "",
|
||||
allocatedHours: Math.round(data.allocatedHours),
|
||||
requiredFTEs: 0,
|
||||
resourceCount: data.projectIds.size,
|
||||
derivation: {
|
||||
periodStart: toIsoDate(input.startDate),
|
||||
periodEnd: toIsoDate(input.endDate),
|
||||
periodWorkingHoursBase,
|
||||
requiredHours: null,
|
||||
requiredFTEs: 0,
|
||||
fillPct: null,
|
||||
demandSource: "NONE",
|
||||
calendarLocations: summarizeCalendarLocations(
|
||||
personAssignments,
|
||||
resourceInfoById,
|
||||
contexts,
|
||||
input,
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import { AllocationStatus } from "@capakraken/shared";
|
||||
import { buildSplitAllocationReadModel } from "../allocation/build-split-allocation-read-model.js";
|
||||
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
|
||||
import { calculateInclusiveDays } from "./shared.js";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import {
|
||||
calculateEffectiveAllocationCostCents,
|
||||
loadDailyAvailabilityContexts,
|
||||
} from "./holiday-capacity.js";
|
||||
|
||||
function hasAvailability<T extends { availability?: unknown }>(
|
||||
resource: T | null | undefined,
|
||||
): resource is T & { availability: WeekdayAvailability } {
|
||||
return resource !== null && resource !== undefined && resource.availability !== null && resource.availability !== undefined;
|
||||
}
|
||||
|
||||
export async function getDashboardOverview(db: PrismaClient) {
|
||||
const [
|
||||
@@ -12,7 +22,7 @@ export async function getDashboardOverview(db: PrismaClient) {
|
||||
allProjects,
|
||||
allDemandRequirements,
|
||||
allAssignments,
|
||||
budgetBookings,
|
||||
budgetAssignments,
|
||||
recentActivity,
|
||||
allResources,
|
||||
] = await Promise.all([
|
||||
@@ -58,7 +68,25 @@ export async function getDashboardOverview(db: PrismaClient) {
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
listAssignmentBookings(db, {}),
|
||||
db.assignment.findMany({
|
||||
where: { status: { not: "CANCELLED" } },
|
||||
select: {
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
dailyCostCents: true,
|
||||
resource: {
|
||||
select: {
|
||||
id: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
db.auditLog.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
@@ -77,12 +105,46 @@ export async function getDashboardOverview(db: PrismaClient) {
|
||||
const activeAllocations = planningReadModel.allocations.filter(
|
||||
(allocation) => allocation.status !== AllocationStatus.CANCELLED,
|
||||
).length;
|
||||
const contextStart = budgetAssignments.length > 0
|
||||
? new Date(Math.min(...budgetAssignments.map((assignment) => assignment.startDate.getTime())))
|
||||
: new Date();
|
||||
const contextEnd = budgetAssignments.length > 0
|
||||
? new Date(Math.max(...budgetAssignments.map((assignment) => assignment.endDate.getTime())))
|
||||
: new Date();
|
||||
const contexts = await loadDailyAvailabilityContexts(
|
||||
db,
|
||||
budgetAssignments
|
||||
.map((assignment) => assignment.resource)
|
||||
.filter(hasAvailability)
|
||||
.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
contextStart,
|
||||
contextEnd,
|
||||
);
|
||||
|
||||
const totalCostCents = budgetBookings.reduce(
|
||||
(sum, booking) =>
|
||||
sum +
|
||||
(booking.dailyCostCents ?? 0) *
|
||||
calculateInclusiveDays(booking.startDate, booking.endDate),
|
||||
const totalCostCents = budgetAssignments.reduce(
|
||||
(sum, assignment) =>
|
||||
sum + (
|
||||
hasAvailability(assignment.resource)
|
||||
? calculateEffectiveAllocationCostCents({
|
||||
availability: assignment.resource.availability as unknown as WeekdayAvailability,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
dailyCostCents: assignment.dailyCostCents ?? 0,
|
||||
periodStart: assignment.startDate,
|
||||
periodEnd: assignment.endDate,
|
||||
context: contexts.get(assignment.resource.id),
|
||||
})
|
||||
: (assignment.dailyCostCents ?? 0) *
|
||||
calculateInclusiveDays(assignment.startDate, assignment.endDate)
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -90,6 +152,12 @@ export async function getDashboardOverview(db: PrismaClient) {
|
||||
(sum, project) => sum + (project.budgetCents ?? 0),
|
||||
0,
|
||||
);
|
||||
const activeProjectCount = allProjects.filter((project) => project.status === "ACTIVE").length;
|
||||
const inactiveResourceCount = Math.max(totalResources - activeResources, 0);
|
||||
const inactiveProjectCount = Math.max(totalProjects - activeProjectCount, 0);
|
||||
const cancelledAllocations = Math.max(totalAllocations - activeAllocations, 0);
|
||||
const budgetedProjects = allProjects.filter((project) => (project.budgetCents ?? 0) > 0).length;
|
||||
const remainingBudgetCents = totalBudgetCents - totalCostCents;
|
||||
|
||||
const avgUtilizationPercent =
|
||||
totalBudgetCents > 0
|
||||
@@ -125,16 +193,26 @@ export async function getDashboardOverview(db: PrismaClient) {
|
||||
return {
|
||||
totalResources,
|
||||
activeResources,
|
||||
inactiveResources: inactiveResourceCount,
|
||||
totalProjects,
|
||||
activeProjects: allProjects.filter((project) => project.status === "ACTIVE")
|
||||
.length,
|
||||
activeProjects: activeProjectCount,
|
||||
inactiveProjects: inactiveProjectCount,
|
||||
totalAllocations,
|
||||
activeAllocations,
|
||||
cancelledAllocations,
|
||||
budgetSummary: {
|
||||
totalBudgetCents,
|
||||
totalCostCents,
|
||||
avgUtilizationPercent,
|
||||
},
|
||||
budgetBasis: {
|
||||
remainingBudgetCents,
|
||||
budgetedProjects,
|
||||
unbudgetedProjects: Math.max(totalProjects - budgetedProjects, 0),
|
||||
trackedAssignmentCount: budgetAssignments.length,
|
||||
windowStart: budgetAssignments.length > 0 ? contextStart : null,
|
||||
windowEnd: budgetAssignments.length > 0 ? contextEnd : null,
|
||||
},
|
||||
recentActivity: recentActivity.map((activity) => ({
|
||||
id: activity.id,
|
||||
entityType: activity.entityType,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
|
||||
import { getAverageDailyAvailabilityHours, getMonthBucketKey, getWeekBucketKey } from "./shared.js";
|
||||
import { getMonthBucketKey, getWeekBucketKey } from "./shared.js";
|
||||
import {
|
||||
calculateEffectiveAllocationHours,
|
||||
calculateEffectiveAvailableHours,
|
||||
enumerateIsoDates,
|
||||
loadDailyAvailabilityContexts,
|
||||
} from "./holiday-capacity.js";
|
||||
|
||||
export interface GetDashboardPeakTimesInput {
|
||||
startDate: Date;
|
||||
@@ -9,67 +16,253 @@ export interface GetDashboardPeakTimesInput {
|
||||
groupBy: "project" | "chapter" | "resource";
|
||||
}
|
||||
|
||||
export interface PeakTimesPeriodDerivation {
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
resourceCount: number;
|
||||
groupCount: number;
|
||||
bookedHours: number;
|
||||
capacityHours: number;
|
||||
remainingCapacityHours: number;
|
||||
overbookedHours: number;
|
||||
utilizationPct: number;
|
||||
}
|
||||
|
||||
export interface PeakTimesGroupRow {
|
||||
name: string;
|
||||
hours: number;
|
||||
capacityHours: number | undefined;
|
||||
remainingHours: number | undefined;
|
||||
overbookedHours: number | undefined;
|
||||
utilizationPct: number | undefined;
|
||||
}
|
||||
|
||||
export interface PeakTimesPeriodRow {
|
||||
period: string;
|
||||
groups: PeakTimesGroupRow[];
|
||||
totalHours: number;
|
||||
capacityHours: number;
|
||||
periodStart?: string;
|
||||
periodEnd?: string;
|
||||
bookedHours?: number;
|
||||
remainingHours?: number;
|
||||
overbookedHours?: number;
|
||||
utilizationPct?: number;
|
||||
groupCount?: number;
|
||||
resourceCount?: number;
|
||||
derivation: PeakTimesPeriodDerivation;
|
||||
}
|
||||
|
||||
export async function getDashboardPeakTimes(
|
||||
db: PrismaClient,
|
||||
input: GetDashboardPeakTimesInput,
|
||||
) {
|
||||
const allocations = await listAssignmentBookings(db, {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
});
|
||||
): Promise<PeakTimesPeriodRow[]> {
|
||||
const [allocations, resources] = await Promise.all([
|
||||
listAssignmentBookings(db, {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
}),
|
||||
db.resource.findMany({
|
||||
where: { isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
chapter: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: {
|
||||
select: {
|
||||
code: true,
|
||||
},
|
||||
},
|
||||
metroCity: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const buckets = new Map<string, Map<string, number>>();
|
||||
|
||||
const groupCapacityBuckets = new Map<string, Map<string, number>>();
|
||||
const getBucketKey = input.granularity === "week" ? getWeekBucketKey : getMonthBucketKey;
|
||||
const resourceMap = new Map(
|
||||
resources.map((resource) => [
|
||||
resource.id,
|
||||
{
|
||||
...resource,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const contexts = await loadDailyAvailabilityContexts(
|
||||
db,
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
);
|
||||
const bucketPeriods = new Map<string, { start: Date; end: Date }>();
|
||||
|
||||
for (const isoDate of enumerateIsoDates(input.startDate, input.endDate)) {
|
||||
const date = new Date(`${isoDate}T00:00:00.000Z`);
|
||||
const bucketKey = getBucketKey(date);
|
||||
const existing = bucketPeriods.get(bucketKey);
|
||||
if (!existing) {
|
||||
bucketPeriods.set(bucketKey, { start: date, end: date });
|
||||
continue;
|
||||
}
|
||||
if (date < existing.start) {
|
||||
existing.start = date;
|
||||
}
|
||||
if (date > existing.end) {
|
||||
existing.end = date;
|
||||
}
|
||||
}
|
||||
for (const bucketKey of bucketPeriods.keys()) {
|
||||
buckets.set(bucketKey, new Map());
|
||||
groupCapacityBuckets.set(bucketKey, new Map());
|
||||
}
|
||||
|
||||
for (const allocation of allocations) {
|
||||
const allocStart = new Date(
|
||||
Math.max(allocation.startDate.getTime(), input.startDate.getTime()),
|
||||
);
|
||||
const allocEnd = new Date(
|
||||
Math.min(allocation.endDate.getTime(), input.endDate.getTime()),
|
||||
);
|
||||
const resource = allocation.resourceId ? resourceMap.get(allocation.resourceId) : undefined;
|
||||
const group =
|
||||
input.groupBy === "project"
|
||||
? allocation.project.shortCode
|
||||
: input.groupBy === "chapter"
|
||||
? allocation.resource?.chapter ?? "Unassigned"
|
||||
: allocation.resource?.displayName ?? "Unknown";
|
||||
|
||||
const cursor = new Date(allocStart);
|
||||
while (cursor <= allocEnd) {
|
||||
const bucketKey = getBucketKey(cursor);
|
||||
if (!buckets.has(bucketKey)) {
|
||||
buckets.set(bucketKey, new Map());
|
||||
for (const [bucketKey, bucketPeriod] of bucketPeriods.entries()) {
|
||||
const hours = resource
|
||||
? calculateEffectiveAllocationHours({
|
||||
availability: resource.availability,
|
||||
startDate: allocation.startDate,
|
||||
endDate: allocation.endDate,
|
||||
hoursPerDay: allocation.hoursPerDay,
|
||||
periodStart: bucketPeriod.start,
|
||||
periodEnd: bucketPeriod.end,
|
||||
context: contexts.get(resource.id),
|
||||
})
|
||||
: 0;
|
||||
if (hours <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const bucket = buckets.get(bucketKey)!;
|
||||
bucket.set(group, (bucket.get(group) ?? 0) + allocation.hoursPerDay);
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
bucket.set(group, (bucket.get(group) ?? 0) + hours);
|
||||
}
|
||||
}
|
||||
const capacityByBucket = new Map<string, number>();
|
||||
for (const [bucketKey, bucketPeriod] of bucketPeriods.entries()) {
|
||||
let capacityHours = 0;
|
||||
for (const resource of resourceMap.values()) {
|
||||
const effectiveAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability: resource.availability,
|
||||
periodStart: bucketPeriod.start,
|
||||
periodEnd: bucketPeriod.end,
|
||||
context: contexts.get(resource.id),
|
||||
});
|
||||
capacityHours += effectiveAvailableHours;
|
||||
|
||||
const resources = await db.resource.findMany({
|
||||
where: { isActive: true },
|
||||
select: { availability: true },
|
||||
});
|
||||
if (input.groupBy !== "project" && effectiveAvailableHours > 0) {
|
||||
const group =
|
||||
input.groupBy === "chapter"
|
||||
? resource.chapter ?? "Unassigned"
|
||||
: resource.displayName ?? "Unknown";
|
||||
const groupCapacityBucket = groupCapacityBuckets.get(bucketKey)!;
|
||||
groupCapacityBucket.set(
|
||||
group,
|
||||
(groupCapacityBucket.get(group) ?? 0) + effectiveAvailableHours,
|
||||
);
|
||||
}
|
||||
}
|
||||
capacityByBucket.set(bucketKey, capacityHours);
|
||||
}
|
||||
|
||||
const dailyCapacityHours = resources.reduce(
|
||||
(sum, resource) =>
|
||||
sum +
|
||||
getAverageDailyAvailabilityHours(
|
||||
resource.availability as Record<string, number | null | undefined>,
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
return [...buckets.entries()]
|
||||
return [...bucketPeriods.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([period, groups]) => ({
|
||||
period,
|
||||
groups: [...groups.entries()].map(([name, hours]) => ({ name, hours })),
|
||||
totalHours: [...groups.values()].reduce((sum, hours) => sum + hours, 0),
|
||||
capacityHours:
|
||||
dailyCapacityHours * (input.granularity === "week" ? 5 : 22),
|
||||
}));
|
||||
.map(([period, bucketPeriod]) => {
|
||||
const groups = buckets.get(period) ?? new Map<string, number>();
|
||||
const groupCapacities = groupCapacityBuckets.get(period) ?? new Map<string, number>();
|
||||
const groupNames = new Set<string>([
|
||||
...groups.keys(),
|
||||
...(input.groupBy === "project" ? [] : groupCapacities.keys()),
|
||||
]);
|
||||
const groupRows = [...groupNames]
|
||||
.map((name) => {
|
||||
const hours = groups.get(name) ?? 0;
|
||||
const groupCapacityHours =
|
||||
input.groupBy === "project" ? undefined : groupCapacities.get(name) ?? 0;
|
||||
const remainingHours =
|
||||
groupCapacityHours === undefined
|
||||
? undefined
|
||||
: Math.max(0, groupCapacityHours - hours);
|
||||
const overbookedHours =
|
||||
groupCapacityHours === undefined
|
||||
? undefined
|
||||
: Math.max(0, hours - groupCapacityHours);
|
||||
return {
|
||||
name,
|
||||
hours,
|
||||
capacityHours: groupCapacityHours,
|
||||
remainingHours,
|
||||
overbookedHours,
|
||||
utilizationPct:
|
||||
groupCapacityHours && groupCapacityHours > 0
|
||||
? Math.round((hours / groupCapacityHours) * 100)
|
||||
: groupCapacityHours === 0
|
||||
? 0
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
(right.utilizationPct ?? -1) - (left.utilizationPct ?? -1) ||
|
||||
right.hours - left.hours ||
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
const totalHours = [...groups.values()].reduce((sum, hours) => sum + hours, 0);
|
||||
const capacityHours = capacityByBucket.get(period) ?? 0;
|
||||
const remainingCapacityHours = Math.max(0, capacityHours - totalHours);
|
||||
const overbookedHours = Math.max(0, totalHours - capacityHours);
|
||||
|
||||
return {
|
||||
period,
|
||||
groups: groupRows,
|
||||
totalHours,
|
||||
capacityHours,
|
||||
periodStart: bucketPeriod.start.toISOString().slice(0, 10),
|
||||
periodEnd: bucketPeriod.end.toISOString().slice(0, 10),
|
||||
bookedHours: totalHours,
|
||||
remainingHours: remainingCapacityHours,
|
||||
overbookedHours,
|
||||
utilizationPct: capacityHours > 0
|
||||
? Math.round((totalHours / capacityHours) * 100)
|
||||
: 0,
|
||||
groupCount: groupRows.length,
|
||||
resourceCount: resourceMap.size,
|
||||
derivation: {
|
||||
periodStart: bucketPeriod.start.toISOString().slice(0, 10),
|
||||
periodEnd: bucketPeriod.end.toISOString().slice(0, 10),
|
||||
resourceCount: resourceMap.size,
|
||||
groupCount: groupRows.length,
|
||||
bookedHours: totalHours,
|
||||
capacityHours,
|
||||
remainingCapacityHours,
|
||||
overbookedHours,
|
||||
utilizationPct: capacityHours > 0
|
||||
? Math.round((totalHours / capacityHours) * 100)
|
||||
: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { calculateInclusiveDays } from "./shared.js";
|
||||
import {
|
||||
calculateEffectiveAllocationCostCents,
|
||||
loadDailyAvailabilityContexts,
|
||||
} from "./holiday-capacity.js";
|
||||
|
||||
export interface ProjectHealthRow {
|
||||
id: string;
|
||||
@@ -11,6 +16,53 @@ export interface ProjectHealthRow {
|
||||
staffingHealth: number;
|
||||
timelineHealth: number;
|
||||
compositeScore: number;
|
||||
budgetCents?: number | null;
|
||||
spentCents?: number;
|
||||
remainingBudgetCents?: number | null;
|
||||
budgetUtilizationPercent?: number | null;
|
||||
demandHeadcountTotal?: number;
|
||||
demandHeadcountFilled?: number;
|
||||
demandHeadcountOpen?: number;
|
||||
demandRequirementCount?: number;
|
||||
plannedEndDate?: Date | null;
|
||||
daysUntilEndDate?: number | null;
|
||||
timelineStatus?: "ON_TRACK" | "DUE_SOON" | "OVERDUE" | "UNSCHEDULED";
|
||||
calendarLocations?: Array<{
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
assignmentCount: number;
|
||||
spentCents: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
function hasAvailability<T extends { availability?: unknown }>(
|
||||
resource: T | null | undefined,
|
||||
): resource is T & { availability: WeekdayAvailability } {
|
||||
return resource !== null && resource !== undefined && resource.availability !== null && resource.availability !== undefined;
|
||||
}
|
||||
|
||||
function toUtcDayStart(value: Date): Date {
|
||||
return new Date(Date.UTC(
|
||||
value.getUTCFullYear(),
|
||||
value.getUTCMonth(),
|
||||
value.getUTCDate(),
|
||||
));
|
||||
}
|
||||
|
||||
function buildLocationKey(input: {
|
||||
countryCode: string | null | undefined;
|
||||
countryName: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityName: string | null | undefined;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
countryCode: input.countryCode ?? null,
|
||||
countryName: input.countryName ?? null,
|
||||
federalState: input.federalState ?? null,
|
||||
metroCityName: input.metroCityName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDashboardProjectHealth(
|
||||
@@ -55,14 +107,79 @@ export async function getDashboardProjectHealth(
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
dailyCostCents: true,
|
||||
},
|
||||
resource: {
|
||||
select: {
|
||||
id: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true, name: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const contextStart = assignments.length > 0
|
||||
? new Date(Math.min(...assignments.map((assignment) => assignment.startDate.getTime())))
|
||||
: new Date();
|
||||
const contextEnd = assignments.length > 0
|
||||
? new Date(Math.max(...assignments.map((assignment) => assignment.endDate.getTime())))
|
||||
: new Date();
|
||||
const contexts = await loadDailyAvailabilityContexts(
|
||||
db,
|
||||
assignments
|
||||
.map((assignment) => assignment.resource)
|
||||
.filter(hasAvailability)
|
||||
.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
contextStart,
|
||||
contextEnd,
|
||||
);
|
||||
const spentByProject = new Map<string, number>();
|
||||
const calendarLocationsByProject = new Map<string, Map<string, NonNullable<ProjectHealthRow["calendarLocations"]>[number]>>();
|
||||
for (const a of assignments) {
|
||||
const days = calculateInclusiveDays(a.startDate, a.endDate);
|
||||
const cost = (a.dailyCostCents ?? 0) * days;
|
||||
const cost = hasAvailability(a.resource)
|
||||
? calculateEffectiveAllocationCostCents({
|
||||
availability: a.resource.availability as unknown as WeekdayAvailability,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
dailyCostCents: a.dailyCostCents ?? 0,
|
||||
periodStart: a.startDate,
|
||||
periodEnd: a.endDate,
|
||||
context: contexts.get(a.resource.id),
|
||||
})
|
||||
: (a.dailyCostCents ?? 0) * calculateInclusiveDays(a.startDate, a.endDate);
|
||||
spentByProject.set(a.projectId, (spentByProject.get(a.projectId) ?? 0) + cost);
|
||||
if (a.resource) {
|
||||
const projectLocations = calendarLocationsByProject.get(a.projectId) ?? new Map();
|
||||
const locationKey = buildLocationKey({
|
||||
countryCode: a.resource.country?.code,
|
||||
countryName: a.resource.country?.name,
|
||||
federalState: a.resource.federalState,
|
||||
metroCityName: a.resource.metroCity?.name,
|
||||
});
|
||||
const summary = projectLocations.get(locationKey) ?? {
|
||||
countryCode: a.resource.country?.code ?? null,
|
||||
countryName: a.resource.country?.name ?? null,
|
||||
federalState: a.resource.federalState ?? null,
|
||||
metroCityName: a.resource.metroCity?.name ?? null,
|
||||
assignmentCount: 0,
|
||||
spentCents: 0,
|
||||
};
|
||||
summary.assignmentCount += 1;
|
||||
summary.spentCents += cost;
|
||||
projectLocations.set(locationKey, summary);
|
||||
calendarLocationsByProject.set(a.projectId, projectLocations);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
@@ -71,7 +188,7 @@ export async function getDashboardProjectHealth(
|
||||
// Budget health: 100 - pctUsed (capped at 100)
|
||||
const spentCents = spentByProject.get(p.id) ?? 0;
|
||||
const pctUsed =
|
||||
p.budgetCents > 0
|
||||
(p.budgetCents ?? 0) > 0
|
||||
? Math.round((spentCents / p.budgetCents) * 100)
|
||||
: 0;
|
||||
const budgetHealth = Math.max(0, 100 - Math.min(pctUsed, 100));
|
||||
@@ -87,12 +204,29 @@ export async function getDashboardProjectHealth(
|
||||
totalDemands > 0 ? Math.round((filledDemands / totalDemands) * 100) : 100;
|
||||
|
||||
// Timeline health: 100 if end date > today, else 0
|
||||
const timelineHealth = p.endDate > now ? 100 : 0;
|
||||
const endDate = p.endDate ? toUtcDayStart(p.endDate) : null;
|
||||
const today = toUtcDayStart(now);
|
||||
const daysUntilEndDate = endDate
|
||||
? Math.round((endDate.getTime() - today.getTime()) / 86_400_000)
|
||||
: null;
|
||||
const timelineStatus = endDate === null
|
||||
? "UNSCHEDULED"
|
||||
: daysUntilEndDate! < 0
|
||||
? "OVERDUE"
|
||||
: daysUntilEndDate! <= 14
|
||||
? "DUE_SOON"
|
||||
: "ON_TRACK";
|
||||
const timelineHealth = endDate === null
|
||||
? 100
|
||||
: endDate > today
|
||||
? 100
|
||||
: 0;
|
||||
|
||||
// Composite = average of 3 dimensions
|
||||
const compositeScore = Math.round(
|
||||
(budgetHealth + staffingHealth + timelineHealth) / 3,
|
||||
);
|
||||
const remainingBudgetCents = p.budgetCents == null ? null : p.budgetCents - spentCents;
|
||||
|
||||
return {
|
||||
id: p.id,
|
||||
@@ -104,6 +238,19 @@ export async function getDashboardProjectHealth(
|
||||
staffingHealth,
|
||||
timelineHealth,
|
||||
compositeScore,
|
||||
budgetCents: p.budgetCents ?? null,
|
||||
spentCents,
|
||||
remainingBudgetCents,
|
||||
budgetUtilizationPercent: p.budgetCents && p.budgetCents > 0 ? pctUsed : null,
|
||||
demandHeadcountTotal: totalDemands,
|
||||
demandHeadcountFilled: filledDemands,
|
||||
demandHeadcountOpen: Math.max(totalDemands - filledDemands, 0),
|
||||
demandRequirementCount: p.demandRequirements.length,
|
||||
plannedEndDate: p.endDate ?? null,
|
||||
daysUntilEndDate,
|
||||
timelineStatus,
|
||||
calendarLocations: Array.from(calendarLocationsByProject.get(p.id)?.values() ?? [])
|
||||
.sort((left, right) => right.spentCents - left.spentCents),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
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 };
|
||||
@@ -5,6 +5,8 @@ export {
|
||||
export {
|
||||
getDashboardPeakTimes,
|
||||
type GetDashboardPeakTimesInput,
|
||||
type PeakTimesPeriodDerivation,
|
||||
type PeakTimesPeriodRow,
|
||||
} from "./get-peak-times.js";
|
||||
|
||||
export {
|
||||
@@ -15,16 +17,22 @@ export {
|
||||
export {
|
||||
getDashboardDemand,
|
||||
type GetDashboardDemandInput,
|
||||
type DemandCalendarLocationSummary,
|
||||
type DemandRowDerivation,
|
||||
type DashboardDemandRow,
|
||||
} from "./get-demand.js";
|
||||
|
||||
export {
|
||||
getDashboardChargeabilityOverview,
|
||||
type GetDashboardChargeabilityOverviewInput,
|
||||
type DashboardChargeabilityDerivation,
|
||||
type DashboardChargeabilityRow,
|
||||
} from "./get-chargeability-overview.js";
|
||||
|
||||
export {
|
||||
getDashboardBudgetForecast,
|
||||
type BudgetForecastRow,
|
||||
type BudgetForecastLocationSummary,
|
||||
} from "./get-budget-forecast.js";
|
||||
|
||||
export {
|
||||
|
||||
@@ -18,6 +18,20 @@ export const DASHBOARD_PLANNING_ALLOCATION_INCLUDE = {
|
||||
chapter: true,
|
||||
eid: true,
|
||||
lcrCents: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: {
|
||||
select: {
|
||||
code: true,
|
||||
},
|
||||
},
|
||||
metroCity: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import XLSX from "xlsx";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
export type WorksheetCellValue = boolean | Date | number | string | null;
|
||||
export type WorksheetMatrix = WorksheetCellValue[][];
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import { computeValueScore } from "@capakraken/staffing";
|
||||
import { VALUE_SCORE_WEIGHTS } from "@capakraken/shared";
|
||||
import { VALUE_SCORE_WEIGHTS, type WeekdayAvailability } from "@capakraken/shared";
|
||||
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
|
||||
import {
|
||||
calculateEffectiveAllocationHours,
|
||||
calculateEffectiveAvailableHours,
|
||||
loadDailyAvailabilityContexts,
|
||||
} from "../dashboard/holiday-capacity.js";
|
||||
|
||||
type ResourceValueScoreDbClient = Partial<Pick<PrismaClient, "systemSettings">> &
|
||||
type ResourceValueScoreDbClient = Partial<Pick<PrismaClient, "systemSettings" | "holidayCalendar" | "vacation">> &
|
||||
Pick<PrismaClient, "assignment" | "resource" | "$transaction">;
|
||||
|
||||
export interface RecomputeResourceValueScoresInput {
|
||||
@@ -30,6 +35,12 @@ export async function recomputeResourceValueScores(
|
||||
skills: true,
|
||||
lcrCents: true,
|
||||
chargeabilityTarget: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
db.systemSettings?.findUnique?.({ where: { id: "singleton" } }) ?? Promise.resolve(null),
|
||||
@@ -39,11 +50,27 @@ export async function recomputeResourceValueScores(
|
||||
return { updated: 0 };
|
||||
}
|
||||
|
||||
const periodStart = new Date(Date.now() - daysBack * 24 * 60 * 60 * 1000);
|
||||
const periodEnd = new Date();
|
||||
const bookings = await listAssignmentBookings(db, {
|
||||
startDate: new Date(Date.now() - daysBack * 24 * 60 * 60 * 1000),
|
||||
endDate: new Date(),
|
||||
startDate: periodStart,
|
||||
endDate: periodEnd,
|
||||
resourceIds: resources.map((resource) => resource.id),
|
||||
});
|
||||
const contexts = await loadDailyAvailabilityContexts(
|
||||
db,
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
periodStart,
|
||||
periodEnd,
|
||||
);
|
||||
|
||||
const defaultWeights = {
|
||||
skillDepth: VALUE_SCORE_WEIGHTS.SKILL_DEPTH,
|
||||
@@ -65,20 +92,29 @@ export async function recomputeResourceValueScores(
|
||||
yearsExperience?: number;
|
||||
};
|
||||
|
||||
const totalWorkDays = daysBack * (5 / 7);
|
||||
const availableHours = totalWorkDays * 8;
|
||||
|
||||
const updates = resources.map((resource) => {
|
||||
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
|
||||
const bookedHours = resourceBookings.reduce((sum, booking) => {
|
||||
const days = Math.max(
|
||||
0,
|
||||
(new Date(booking.endDate).getTime() - new Date(booking.startDate).getTime()) /
|
||||
(1000 * 60 * 60 * 24) +
|
||||
1,
|
||||
);
|
||||
return sum + booking.hoursPerDay * days;
|
||||
}, 0);
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(resource.id);
|
||||
const availableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context,
|
||||
});
|
||||
const bookedHours = resourceBookings.reduce(
|
||||
(sum, booking) =>
|
||||
sum + calculateEffectiveAllocationHours({
|
||||
availability,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const currentChargeability =
|
||||
availableHours > 0 ? Math.min(100, (bookedHours / availableHours) * 100) : 0;
|
||||
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
|
||||
|
||||
+14
-11
@@ -8,17 +8,20 @@
|
||||
"./client": "./src/client.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"db:push": "prisma db push --schema ./prisma/schema.prisma",
|
||||
"db:migrate": "prisma migrate dev --schema ./prisma/schema.prisma",
|
||||
"db:migrate:deploy": "prisma migrate deploy --schema ./prisma/schema.prisma",
|
||||
"db:seed": "tsx src/seed.ts",
|
||||
"db:seed:dispo-v2": "tsx src/seed-dispo-v2.ts",
|
||||
"db:seed:vacations": "dotenv -e ../../.env -- tsx src/seed-vacations.ts",
|
||||
"db:reset:dispo": "tsx src/reset-dispo-import.ts",
|
||||
"db:import:dispo": "tsx src/import-dispo-batch.ts",
|
||||
"db:excel": "tsx src/generate-excel.ts",
|
||||
"db:studio": "prisma studio --schema ./prisma/schema.prisma",
|
||||
"db:generate": "prisma generate --schema ./prisma/schema.prisma",
|
||||
"db:doctor": "node ../../scripts/db-doctor.mjs capakraken",
|
||||
"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: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",
|
||||
"db:seed:dispo-v2": "node ../../scripts/with-env.mjs tsx src/seed-dispo-v2.ts",
|
||||
"db:seed:vacations": "node ../../scripts/with-env.mjs tsx src/seed-vacations.ts",
|
||||
"db:reset:dispo": "node ../../scripts/with-env.mjs tsx src/reset-dispo-import.ts",
|
||||
"db:import:dispo": "node ../../scripts/with-env.mjs tsx src/import-dispo-batch.ts",
|
||||
"db:excel": "node ../../scripts/with-env.mjs tsx src/generate-excel.ts",
|
||||
"db:studio": "node ../../scripts/with-env.mjs prisma studio --schema ./prisma/schema.prisma",
|
||||
"db:generate": "node ../../scripts/with-env.mjs prisma generate --schema ./prisma/schema.prisma",
|
||||
"test:unit": "tsx --test src/*.test.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -106,6 +106,12 @@ enum VacationStatus {
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum HolidayCalendarScope {
|
||||
COUNTRY
|
||||
STATE
|
||||
CITY
|
||||
}
|
||||
|
||||
enum ImportBatchStatus {
|
||||
DRAFT
|
||||
STAGING
|
||||
@@ -194,6 +200,7 @@ model User {
|
||||
broadcasts NotificationBroadcast[] @relation("broadcastSender")
|
||||
comments Comment[]
|
||||
activeSessions ActiveSession[]
|
||||
reportTemplates ReportTemplate[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -201,6 +208,32 @@ model User {
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
enum ReportTemplateEntity {
|
||||
RESOURCE
|
||||
PROJECT
|
||||
ASSIGNMENT
|
||||
RESOURCE_MONTH
|
||||
}
|
||||
|
||||
model ReportTemplate {
|
||||
id String @id @default(cuid())
|
||||
ownerId String
|
||||
name String
|
||||
description String?
|
||||
entity ReportTemplateEntity
|
||||
config Json @db.JsonB
|
||||
isShared Boolean @default(false)
|
||||
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([ownerId, updatedAt])
|
||||
@@unique([ownerId, name])
|
||||
@@map("report_templates")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
@@ -529,6 +562,7 @@ model Country {
|
||||
isActive Boolean @default(true)
|
||||
|
||||
metroCities MetroCity[]
|
||||
holidayCalendars HolidayCalendar[]
|
||||
resources Resource[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
@@ -543,7 +577,8 @@ model MetroCity {
|
||||
countryId String
|
||||
country Country @relation(fields: [countryId], references: [id])
|
||||
|
||||
resources Resource[]
|
||||
resources Resource[]
|
||||
holidayCalendars HolidayCalendar[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -552,6 +587,46 @@ model MetroCity {
|
||||
@@map("metro_cities")
|
||||
}
|
||||
|
||||
model HolidayCalendar {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
scopeType HolidayCalendarScope
|
||||
countryId String
|
||||
stateCode String?
|
||||
metroCityId String?
|
||||
isActive Boolean @default(true)
|
||||
priority Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
country Country @relation(fields: [countryId], references: [id], onDelete: Cascade)
|
||||
metroCity MetroCity? @relation(fields: [metroCityId], references: [id], onDelete: Cascade)
|
||||
entries HolidayCalendarEntry[]
|
||||
|
||||
@@index([countryId, scopeType])
|
||||
@@index([countryId, stateCode])
|
||||
@@index([metroCityId])
|
||||
// Scope uniqueness is enforced via partial unique indexes in SQL migrations.
|
||||
@@map("holiday_calendars")
|
||||
}
|
||||
|
||||
model HolidayCalendarEntry {
|
||||
id String @id @default(cuid())
|
||||
holidayCalendarId String
|
||||
date DateTime @db.Date
|
||||
name String
|
||||
isRecurringAnnual Boolean @default(false)
|
||||
source String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
holidayCalendar HolidayCalendar @relation(fields: [holidayCalendarId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([holidayCalendarId, date])
|
||||
@@index([date])
|
||||
@@map("holiday_calendar_entries")
|
||||
}
|
||||
|
||||
// ─── Org Unit Hierarchy ─────────────────────────────────────────────────────
|
||||
|
||||
model OrgUnit {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { assertDestructiveDbAllowed } from "./destructive-db-guard.js";
|
||||
import { assertSafeSeedTarget } from "./safe-destructive-env.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
function setEnv(values: Record<string, string | undefined>) {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
test.afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
test("assertDestructiveDbAllowed allows an explicitly confirmed disposable capakraken test database", () => {
|
||||
setEnv({
|
||||
DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken_test",
|
||||
ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
|
||||
CONFIRM_DESTRUCTIVE_DB_NAME: "capakraken_test",
|
||||
});
|
||||
|
||||
const target = assertDestructiveDbAllowed({
|
||||
commandName: "db:test",
|
||||
allowedDatabaseNames: ["capakraken_test"],
|
||||
});
|
||||
|
||||
assert.equal(target.databaseName, "capakraken_test");
|
||||
assert.equal(target.hostname, "localhost");
|
||||
});
|
||||
|
||||
test("assertDestructiveDbAllowed rejects protected live database names even if allowlisted", () => {
|
||||
setEnv({
|
||||
DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken",
|
||||
ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
|
||||
CONFIRM_DESTRUCTIVE_DB_NAME: "capakraken",
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => assertDestructiveDbAllowed({
|
||||
commandName: "db:test",
|
||||
allowedDatabaseNames: ["capakraken"],
|
||||
}),
|
||||
/explicitly protected/u,
|
||||
);
|
||||
});
|
||||
|
||||
test("assertDestructiveDbAllowed rejects missing confirmation", () => {
|
||||
setEnv({
|
||||
DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken_e2e",
|
||||
ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
|
||||
CONFIRM_DESTRUCTIVE_DB_NAME: "wrong_db",
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => assertDestructiveDbAllowed({
|
||||
commandName: "db:test",
|
||||
allowedDatabaseNames: ["capakraken_e2e"],
|
||||
}),
|
||||
/CONFIRM_DESTRUCTIVE_DB_NAME=capakraken_e2e/u,
|
||||
);
|
||||
});
|
||||
|
||||
test("assertDestructiveDbAllowed rejects missing destructive allow flag", () => {
|
||||
setEnv({
|
||||
DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken_ci",
|
||||
ALLOW_DESTRUCTIVE_DB_TOOLS: undefined,
|
||||
CONFIRM_DESTRUCTIVE_DB_NAME: "capakraken_ci",
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => assertDestructiveDbAllowed({
|
||||
commandName: "db:test",
|
||||
allowedDatabaseNames: ["capakraken_ci"],
|
||||
}),
|
||||
/ALLOW_DESTRUCTIVE_DB_TOOLS=true/u,
|
||||
);
|
||||
});
|
||||
|
||||
test("assertSafeSeedTarget rejects legacy planarchy disposable databases", () => {
|
||||
setEnv({
|
||||
DATABASE_URL: "postgresql://tester:secret@localhost:5432/planarchy_test",
|
||||
ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
|
||||
CONFIRM_DESTRUCTIVE_DB_NAME: "planarchy_test",
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => assertSafeSeedTarget("db:seed"),
|
||||
/not in the destructive-tool allowlist/u,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { URL } from "node:url";
|
||||
|
||||
interface DestructiveGuardOptions {
|
||||
commandName: string;
|
||||
allowedDatabaseNames?: string[];
|
||||
requireConfirmation?: boolean;
|
||||
}
|
||||
|
||||
const PROTECTED_DATABASE_NAMES = new Set(["capakraken", "planarchy"]);
|
||||
|
||||
function parseDatabaseUrl(rawUrl: string) {
|
||||
const parsed = new URL(rawUrl);
|
||||
const databaseName = parsed.pathname.replace(/^\/+/, "");
|
||||
|
||||
return {
|
||||
protocol: parsed.protocol,
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
databaseName,
|
||||
username: decodeURIComponent(parsed.username),
|
||||
};
|
||||
}
|
||||
|
||||
function formatTarget(target: ReturnType<typeof parseDatabaseUrl>) {
|
||||
const port = target.port ? `:${target.port}` : "";
|
||||
return `${target.protocol}//${target.username}@${target.hostname}${port}/${target.databaseName}`;
|
||||
}
|
||||
|
||||
export function assertDestructiveDbAllowed({
|
||||
commandName,
|
||||
allowedDatabaseNames = [],
|
||||
requireConfirmation = true,
|
||||
}: DestructiveGuardOptions) {
|
||||
const rawUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!rawUrl) {
|
||||
throw new Error(`${commandName} aborted: DATABASE_URL is not configured.`);
|
||||
}
|
||||
|
||||
const target = parseDatabaseUrl(rawUrl);
|
||||
const allowFlag = process.env.ALLOW_DESTRUCTIVE_DB_TOOLS === "true";
|
||||
const confirmationDb = process.env.CONFIRM_DESTRUCTIVE_DB_NAME;
|
||||
const allowlisted = allowedDatabaseNames.includes(target.databaseName);
|
||||
|
||||
if (PROTECTED_DATABASE_NAMES.has(target.databaseName)) {
|
||||
throw new Error(
|
||||
`${commandName} aborted: database '${target.databaseName}' is explicitly protected from destructive tooling. Target=${formatTarget(target)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!allowlisted) {
|
||||
throw new Error(
|
||||
`${commandName} aborted: database '${target.databaseName}' is not in the destructive-tool allowlist (${allowedDatabaseNames.join(", ") || "none"}). Target=${formatTarget(target)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!allowFlag) {
|
||||
throw new Error(
|
||||
`${commandName} aborted: set ALLOW_DESTRUCTIVE_DB_TOOLS=true to allow destructive database operations. Target=${formatTarget(target)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (requireConfirmation && confirmationDb !== target.databaseName) {
|
||||
throw new Error(
|
||||
`${commandName} aborted: set CONFIRM_DESTRUCTIVE_DB_NAME=${target.databaseName} to confirm the destructive target. Current value=${confirmationDb ?? "<unset>"}`,
|
||||
);
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
@@ -215,12 +215,63 @@ async function buildProjectsSheet(wb: ExcelJS.Workbook) {
|
||||
}
|
||||
|
||||
async function buildAllocationsSheet(wb: ExcelJS.Workbook) {
|
||||
const allocations = await prisma.allocation.findMany({
|
||||
orderBy: [{ project: { startDate: "asc" } }, { resource: { displayName: "asc" } }],
|
||||
include: {
|
||||
resource: { select: { eid: true, displayName: true, chapter: true } },
|
||||
project: { select: { shortCode: true, name: true } },
|
||||
},
|
||||
const [assignments, demandRequirements] = await Promise.all([
|
||||
prisma.assignment.findMany({
|
||||
orderBy: [{ project: { startDate: "asc" } }, { resource: { displayName: "asc" } }],
|
||||
include: {
|
||||
resource: { select: { eid: true, displayName: true, chapter: true } },
|
||||
project: { select: { shortCode: true, name: true, startDate: true } },
|
||||
},
|
||||
}),
|
||||
prisma.demandRequirement.findMany({
|
||||
orderBy: [{ project: { startDate: "asc" } }, { role: "asc" }],
|
||||
include: {
|
||||
project: { select: { shortCode: true, name: true, startDate: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const allocations = [
|
||||
...assignments.map((assignment) => ({
|
||||
projectStartDate: assignment.project.startDate,
|
||||
projectCode: assignment.project.shortCode,
|
||||
projectName: assignment.project.name,
|
||||
eid: assignment.resource?.eid ?? "",
|
||||
resourceName: assignment.resource?.displayName ?? "",
|
||||
chapter: assignment.resource?.chapter ?? "",
|
||||
role: assignment.role ?? "",
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
dailyCostCents: assignment.dailyCostCents,
|
||||
status: assignment.status,
|
||||
})),
|
||||
...demandRequirements.map((demandRequirement) => ({
|
||||
projectStartDate: demandRequirement.project.startDate,
|
||||
projectCode: demandRequirement.project.shortCode,
|
||||
projectName: demandRequirement.project.name,
|
||||
eid: "",
|
||||
resourceName: "",
|
||||
chapter: "",
|
||||
role: demandRequirement.role ?? "",
|
||||
startDate: demandRequirement.startDate,
|
||||
endDate: demandRequirement.endDate,
|
||||
hoursPerDay: demandRequirement.hoursPerDay,
|
||||
dailyCostCents: 0,
|
||||
status: demandRequirement.status,
|
||||
})),
|
||||
].sort((left, right) => {
|
||||
const startDiff = left.projectStartDate.getTime() - right.projectStartDate.getTime();
|
||||
if (startDiff !== 0) {
|
||||
return startDiff;
|
||||
}
|
||||
|
||||
const resourceDiff = left.resourceName.localeCompare(right.resourceName);
|
||||
if (resourceDiff !== 0) {
|
||||
return resourceDiff;
|
||||
}
|
||||
|
||||
return left.role.localeCompare(right.role);
|
||||
});
|
||||
|
||||
const ws = wb.addWorksheet("Allocations", {
|
||||
@@ -245,12 +296,12 @@ async function buildAllocationsSheet(wb: ExcelJS.Workbook) {
|
||||
for (const alloc of allocations) {
|
||||
const row: AnyRow = ws.getRow(rowIdx);
|
||||
row.values = [
|
||||
alloc.project.shortCode,
|
||||
alloc.project.name,
|
||||
alloc.resource?.eid ?? "",
|
||||
alloc.resource?.displayName ?? "",
|
||||
alloc.resource?.chapter ?? "",
|
||||
alloc.role ?? "",
|
||||
alloc.projectCode,
|
||||
alloc.projectName,
|
||||
alloc.eid,
|
||||
alloc.resourceName,
|
||||
alloc.chapter,
|
||||
alloc.role,
|
||||
fmtDate(alloc.startDate),
|
||||
fmtDate(alloc.endDate),
|
||||
alloc.hoursPerDay,
|
||||
@@ -268,11 +319,13 @@ async function buildAllocationsSheet(wb: ExcelJS.Workbook) {
|
||||
}
|
||||
|
||||
async function buildSummarySheet(wb: ExcelJS.Workbook) {
|
||||
const [resourceCount, projectCount, allocationCount] = await Promise.all([
|
||||
const [resourceCount, projectCount, assignmentCount, demandRequirementCount] = await Promise.all([
|
||||
prisma.resource.count(),
|
||||
prisma.project.count(),
|
||||
prisma.allocation.count(),
|
||||
prisma.assignment.count(),
|
||||
prisma.demandRequirement.count(),
|
||||
]);
|
||||
const allocationCount = assignmentCount + demandRequirementCount;
|
||||
|
||||
const ws = wb.addWorksheet("Summary");
|
||||
ws.columns = [
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildHolidayCalendarSeedDefinitions } from "./holiday-calendar-seed-data.js";
|
||||
|
||||
function getDefinition(
|
||||
definitions: ReturnType<typeof buildHolidayCalendarSeedDefinitions>,
|
||||
predicate: (definition: ReturnType<typeof buildHolidayCalendarSeedDefinitions>[number]) => boolean,
|
||||
) {
|
||||
const definition = definitions.find(predicate);
|
||||
assert.ok(definition, "expected holiday seed definition to exist");
|
||||
return definition;
|
||||
}
|
||||
|
||||
test("builds country, state, and city holiday seeds for available profiles", () => {
|
||||
const definitions = buildHolidayCalendarSeedDefinitions({
|
||||
availableCountryCodes: ["DE", "ES", "IN", "US"],
|
||||
availableCitiesByCountry: {
|
||||
DE: ["Augsburg", "Berlin", "Hamburg", "Muenchen", "Stuttgart"],
|
||||
ES: ["Barcelona", "Madrid"],
|
||||
IN: ["Bangalore", "Mumbai"],
|
||||
US: ["Los Angeles", "New York"],
|
||||
},
|
||||
activeGermanStates: ["BW", "BY", "HH"],
|
||||
years: [2026, 2027],
|
||||
});
|
||||
|
||||
const byCalendar = getDefinition(
|
||||
definitions,
|
||||
(definition) => definition.scopeType === "STATE" && definition.countryCode === "DE" && definition.stateCode === "BY",
|
||||
);
|
||||
const nwCalendar = getDefinition(
|
||||
definitions,
|
||||
(definition) => definition.scopeType === "STATE" && definition.countryCode === "DE" && definition.stateCode === "NW",
|
||||
);
|
||||
const madridCalendar = getDefinition(
|
||||
definitions,
|
||||
(definition) => definition.scopeType === "CITY" && definition.countryCode === "ES" && definition.cityName === "Madrid",
|
||||
);
|
||||
const augsburgCalendar = getDefinition(
|
||||
definitions,
|
||||
(definition) => definition.scopeType === "CITY" && definition.countryCode === "DE" && definition.cityName === "Augsburg",
|
||||
);
|
||||
const usCalendar = getDefinition(
|
||||
definitions,
|
||||
(definition) => definition.scopeType === "COUNTRY" && definition.countryCode === "US",
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
byCalendar.entries.some((entry) => entry.date === "2026-01-06" && entry.name === "Heilige Drei Könige"),
|
||||
);
|
||||
assert.ok(
|
||||
byCalendar.entries.some((entry) => entry.date === "2027-08-15" && entry.name === "Mariä Himmelfahrt"),
|
||||
);
|
||||
assert.ok(
|
||||
nwCalendar.entries.some((entry) => entry.date === "2026-06-04" && entry.name === "Fronleichnam"),
|
||||
);
|
||||
assert.ok(
|
||||
!nwCalendar.entries.some((entry) => entry.date === "2026-01-06"),
|
||||
);
|
||||
assert.ok(
|
||||
madridCalendar.entries.some((entry) => entry.date === "2026-05-15" && entry.name === "San Isidro"),
|
||||
);
|
||||
assert.ok(
|
||||
augsburgCalendar.entries.some((entry) => entry.date === "2027-08-08" && entry.name === "Augsburger Friedensfest"),
|
||||
);
|
||||
assert.ok(
|
||||
usCalendar.entries.some((entry) => entry.date === "2026-07-03" && entry.name === "Independence Day"),
|
||||
);
|
||||
assert.ok(
|
||||
usCalendar.entries.some((entry) => entry.date === "2027-12-24" && entry.name === "Christmas Day"),
|
||||
);
|
||||
});
|
||||
|
||||
test("only includes city calendars for cities that exist in the database", () => {
|
||||
const definitions = buildHolidayCalendarSeedDefinitions({
|
||||
availableCountryCodes: ["DE", "ES"],
|
||||
availableCitiesByCountry: {
|
||||
DE: ["Berlin"],
|
||||
ES: ["Madrid"],
|
||||
},
|
||||
activeGermanStates: ["BY"],
|
||||
years: [2026, 2027],
|
||||
});
|
||||
|
||||
assert.ok(definitions.some((definition) => definition.countryCode === "DE" && definition.scopeType === "STATE"));
|
||||
assert.ok(definitions.some((definition) => definition.countryCode === "ES" && definition.scopeType === "CITY" && definition.cityName === "Madrid"));
|
||||
assert.ok(!definitions.some((definition) => definition.countryCode === "ES" && definition.scopeType === "CITY" && definition.cityName === "Barcelona"));
|
||||
assert.ok(!definitions.some((definition) => definition.countryCode === "US"));
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import { getPublicHolidays } from "@capakraken/shared";
|
||||
|
||||
export type HolidayCalendarSeedScope = "COUNTRY" | "STATE" | "CITY";
|
||||
|
||||
export type HolidayCalendarSeedEntry = {
|
||||
date: string;
|
||||
name: string;
|
||||
isRecurringAnnual: boolean;
|
||||
};
|
||||
|
||||
export type HolidayCalendarSeedDefinition = {
|
||||
name: string;
|
||||
scopeType: HolidayCalendarSeedScope;
|
||||
countryCode: string;
|
||||
stateCode?: string;
|
||||
cityName?: string;
|
||||
priority: number;
|
||||
entries: HolidayCalendarSeedEntry[];
|
||||
};
|
||||
|
||||
type SeedContext = {
|
||||
availableCountryCodes: string[];
|
||||
availableCitiesByCountry: Record<string, string[]>;
|
||||
activeGermanStates?: string[];
|
||||
years: number[];
|
||||
};
|
||||
|
||||
type SimpleHolidayDefinition = {
|
||||
name: string;
|
||||
resolveDate: (year: number) => string;
|
||||
};
|
||||
|
||||
const COUNTRY_PRIORITY = 10;
|
||||
const STATE_PRIORITY = 20;
|
||||
const CITY_PRIORITY = 30;
|
||||
|
||||
function toIsoDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function dateUtc(year: number, month: number, day: number): Date {
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
|
||||
function addDays(date: Date, amount: number): Date {
|
||||
const next = new Date(date);
|
||||
next.setUTCDate(next.getUTCDate() + amount);
|
||||
return next;
|
||||
}
|
||||
|
||||
function computeEasterSunday(year: number): Date {
|
||||
const a = year % 19;
|
||||
const b = Math.floor(year / 100);
|
||||
const c = year % 100;
|
||||
const d = Math.floor(b / 4);
|
||||
const e = b % 4;
|
||||
const f = Math.floor((b + 8) / 25);
|
||||
const g = Math.floor((b - f + 1) / 3);
|
||||
const h = (19 * a + b - d - g + 15) % 30;
|
||||
const i = Math.floor(c / 4);
|
||||
const k = c % 4;
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
||||
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
||||
return dateUtc(year, month, day);
|
||||
}
|
||||
|
||||
function nthWeekdayOfMonth(year: number, month: number, weekday: number, occurrence: number): string {
|
||||
const firstDay = dateUtc(year, month, 1);
|
||||
const delta = (weekday - firstDay.getUTCDay() + 7) % 7;
|
||||
const day = 1 + delta + ((occurrence - 1) * 7);
|
||||
return toIsoDate(dateUtc(year, month, day));
|
||||
}
|
||||
|
||||
function lastWeekdayOfMonth(year: number, month: number, weekday: number): string {
|
||||
const lastDay = dateUtc(year, month + 1, 0);
|
||||
const delta = (lastDay.getUTCDay() - weekday + 7) % 7;
|
||||
lastDay.setUTCDate(lastDay.getUTCDate() - delta);
|
||||
return toIsoDate(lastDay);
|
||||
}
|
||||
|
||||
function observedUsFixedHoliday(year: number, month: number, day: number): string {
|
||||
const holiday = dateUtc(year, month, day);
|
||||
const weekday = holiday.getUTCDay();
|
||||
|
||||
if (weekday === 6) {
|
||||
return toIsoDate(addDays(holiday, -1));
|
||||
}
|
||||
if (weekday === 0) {
|
||||
return toIsoDate(addDays(holiday, 1));
|
||||
}
|
||||
|
||||
return toIsoDate(holiday);
|
||||
}
|
||||
|
||||
function buildEntries(years: number[], definitions: SimpleHolidayDefinition[]): HolidayCalendarSeedEntry[] {
|
||||
const entries = new Map<string, HolidayCalendarSeedEntry>();
|
||||
|
||||
for (const year of years) {
|
||||
for (const definition of definitions) {
|
||||
const date = definition.resolveDate(year);
|
||||
entries.set(date, {
|
||||
date,
|
||||
name: definition.name,
|
||||
isRecurringAnnual: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...entries.values()].sort((left, right) => left.date.localeCompare(right.date));
|
||||
}
|
||||
|
||||
function buildGermanCountryEntries(years: number[]): HolidayCalendarSeedEntry[] {
|
||||
return buildEntries(
|
||||
years,
|
||||
years.flatMap((year) =>
|
||||
getPublicHolidays(year)
|
||||
.filter((holiday) => holiday.federal)
|
||||
.map((holiday) => ({
|
||||
name: holiday.name,
|
||||
resolveDate: () => holiday.date,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function buildGermanStateEntries(years: number[], stateCode: string): HolidayCalendarSeedEntry[] {
|
||||
return buildEntries(
|
||||
years,
|
||||
years.flatMap((year) =>
|
||||
getPublicHolidays(year, stateCode)
|
||||
.filter((holiday) => !holiday.federal)
|
||||
.map((holiday) => ({
|
||||
name: holiday.name,
|
||||
resolveDate: () => holiday.date,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function buildSpanishCountryEntries(years: number[]): HolidayCalendarSeedEntry[] {
|
||||
return buildEntries(years, [
|
||||
{ name: "Ano Nuevo", resolveDate: (year) => toIsoDate(dateUtc(year, 1, 1)) },
|
||||
{ name: "Epifania del Senor", resolveDate: (year) => toIsoDate(dateUtc(year, 1, 6)) },
|
||||
{ name: "Viernes Santo", resolveDate: (year) => toIsoDate(addDays(computeEasterSunday(year), -2)) },
|
||||
{ name: "Fiesta del Trabajo", resolveDate: (year) => toIsoDate(dateUtc(year, 5, 1)) },
|
||||
{ name: "Asuncion de la Virgen", resolveDate: (year) => toIsoDate(dateUtc(year, 8, 15)) },
|
||||
{ name: "Fiesta Nacional de Espana", resolveDate: (year) => toIsoDate(dateUtc(year, 10, 12)) },
|
||||
{ name: "Todos los Santos", resolveDate: (year) => toIsoDate(dateUtc(year, 11, 1)) },
|
||||
{ name: "Dia de la Constitucion", resolveDate: (year) => toIsoDate(dateUtc(year, 12, 6)) },
|
||||
{ name: "Inmaculada Concepcion", resolveDate: (year) => toIsoDate(dateUtc(year, 12, 8)) },
|
||||
{ name: "Navidad", resolveDate: (year) => toIsoDate(dateUtc(year, 12, 25)) },
|
||||
]);
|
||||
}
|
||||
|
||||
function buildIndianCountryEntries(years: number[]): HolidayCalendarSeedEntry[] {
|
||||
return buildEntries(years, [
|
||||
{ name: "Republic Day", resolveDate: (year) => toIsoDate(dateUtc(year, 1, 26)) },
|
||||
{ name: "Good Friday", resolveDate: (year) => toIsoDate(addDays(computeEasterSunday(year), -2)) },
|
||||
{ name: "Independence Day", resolveDate: (year) => toIsoDate(dateUtc(year, 8, 15)) },
|
||||
{ name: "Gandhi Jayanti", resolveDate: (year) => toIsoDate(dateUtc(year, 10, 2)) },
|
||||
]);
|
||||
}
|
||||
|
||||
function buildUsCountryEntries(years: number[]): HolidayCalendarSeedEntry[] {
|
||||
return buildEntries(years, [
|
||||
{ name: "New Year's Day", resolveDate: (year) => observedUsFixedHoliday(year, 1, 1) },
|
||||
{ name: "Martin Luther King Jr. Day", resolveDate: (year) => nthWeekdayOfMonth(year, 1, 1, 3) },
|
||||
{ name: "Memorial Day", resolveDate: (year) => lastWeekdayOfMonth(year, 5, 1) },
|
||||
{ name: "Independence Day", resolveDate: (year) => observedUsFixedHoliday(year, 7, 4) },
|
||||
{ name: "Labor Day", resolveDate: (year) => nthWeekdayOfMonth(year, 9, 1, 1) },
|
||||
{ name: "Thanksgiving Day", resolveDate: (year) => nthWeekdayOfMonth(year, 11, 4, 4) },
|
||||
{ name: "Christmas Day", resolveDate: (year) => observedUsFixedHoliday(year, 12, 25) },
|
||||
]);
|
||||
}
|
||||
|
||||
function normalizeCountryCodes(countryCodes: string[]): Set<string> {
|
||||
return new Set(countryCodes.map((countryCode) => countryCode.trim().toUpperCase()));
|
||||
}
|
||||
|
||||
function normalizeCityLookup(availableCitiesByCountry: Record<string, string[]>): Map<string, Set<string>> {
|
||||
const lookup = new Map<string, Set<string>>();
|
||||
|
||||
for (const [countryCode, cityNames] of Object.entries(availableCitiesByCountry)) {
|
||||
lookup.set(countryCode.trim().toUpperCase(), new Set(cityNames));
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function hasCity(cityLookup: Map<string, Set<string>>, countryCode: string, cityName: string): boolean {
|
||||
return cityLookup.get(countryCode)?.has(cityName) ?? false;
|
||||
}
|
||||
|
||||
function germanStateDisplayName(stateCode: string): string {
|
||||
switch (stateCode) {
|
||||
case "BW":
|
||||
return "Baden-Wuerttemberg";
|
||||
case "BY":
|
||||
return "Bayern";
|
||||
case "HH":
|
||||
return "Hamburg";
|
||||
case "NW":
|
||||
return "Nordrhein-Westfalen";
|
||||
default:
|
||||
return stateCode;
|
||||
}
|
||||
}
|
||||
|
||||
function buildCityEntries(years: number[], definitions: SimpleHolidayDefinition[]): HolidayCalendarSeedEntry[] {
|
||||
return buildEntries(years, definitions);
|
||||
}
|
||||
|
||||
export function buildHolidayCalendarSeedDefinitions(
|
||||
context: SeedContext,
|
||||
): HolidayCalendarSeedDefinition[] {
|
||||
const availableCountries = normalizeCountryCodes(context.availableCountryCodes);
|
||||
const cityLookup = normalizeCityLookup(context.availableCitiesByCountry);
|
||||
const definitions: HolidayCalendarSeedDefinition[] = [];
|
||||
|
||||
if (availableCountries.has("DE")) {
|
||||
definitions.push({
|
||||
name: "Referenzfeiertage Deutschland 2026-2027",
|
||||
scopeType: "COUNTRY",
|
||||
countryCode: "DE",
|
||||
priority: COUNTRY_PRIORITY,
|
||||
entries: buildGermanCountryEntries(context.years),
|
||||
});
|
||||
|
||||
const germanStates = new Set(
|
||||
[...(context.activeGermanStates ?? []), "BY", "NW"]
|
||||
.map((stateCode) => stateCode.trim().toUpperCase())
|
||||
.filter((stateCode) => ["BW", "BY", "HH", "NW"].includes(stateCode)),
|
||||
);
|
||||
|
||||
for (const stateCode of [...germanStates].sort()) {
|
||||
const entries = buildGermanStateEntries(context.years, stateCode);
|
||||
if (entries.length === 0) {
|
||||
continue;
|
||||
}
|
||||
definitions.push({
|
||||
name: `Referenzfeiertage Deutschland - ${germanStateDisplayName(stateCode)} 2026-2027`,
|
||||
scopeType: "STATE",
|
||||
countryCode: "DE",
|
||||
stateCode,
|
||||
priority: STATE_PRIORITY,
|
||||
entries,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasCity(cityLookup, "DE", "Augsburg")) {
|
||||
definitions.push({
|
||||
name: "Referenzfeiertage Deutschland - Augsburg 2026-2027",
|
||||
scopeType: "CITY",
|
||||
countryCode: "DE",
|
||||
cityName: "Augsburg",
|
||||
priority: CITY_PRIORITY,
|
||||
entries: buildCityEntries(context.years, [
|
||||
{ name: "Augsburger Friedensfest", resolveDate: (year) => toIsoDate(dateUtc(year, 8, 8)) },
|
||||
]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (availableCountries.has("ES")) {
|
||||
definitions.push({
|
||||
name: "Referenzfeiertage Spanien 2026-2027",
|
||||
scopeType: "COUNTRY",
|
||||
countryCode: "ES",
|
||||
priority: COUNTRY_PRIORITY,
|
||||
entries: buildSpanishCountryEntries(context.years),
|
||||
});
|
||||
|
||||
if (hasCity(cityLookup, "ES", "Madrid")) {
|
||||
definitions.push({
|
||||
name: "Referenzfeiertage Spanien - Madrid 2026-2027",
|
||||
scopeType: "CITY",
|
||||
countryCode: "ES",
|
||||
cityName: "Madrid",
|
||||
priority: CITY_PRIORITY,
|
||||
entries: buildEntries(context.years, [
|
||||
{ name: "San Isidro", resolveDate: (year) => toIsoDate(dateUtc(year, 5, 15)) },
|
||||
{ name: "Nuestra Senora de la Almudena", resolveDate: (year) => toIsoDate(dateUtc(year, 11, 9)) },
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
if (hasCity(cityLookup, "ES", "Barcelona")) {
|
||||
definitions.push({
|
||||
name: "Referenzfeiertage Spanien - Barcelona 2026-2027",
|
||||
scopeType: "CITY",
|
||||
countryCode: "ES",
|
||||
cityName: "Barcelona",
|
||||
priority: CITY_PRIORITY,
|
||||
entries: buildEntries(context.years, [
|
||||
{ name: "La Merce", resolveDate: (year) => toIsoDate(dateUtc(year, 9, 24)) },
|
||||
{ name: "Santa Eulalia", resolveDate: (year) => toIsoDate(dateUtc(year, 2, 12)) },
|
||||
]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (availableCountries.has("IN")) {
|
||||
definitions.push({
|
||||
name: "Referenzfeiertage Indien 2026-2027",
|
||||
scopeType: "COUNTRY",
|
||||
countryCode: "IN",
|
||||
priority: COUNTRY_PRIORITY,
|
||||
entries: buildIndianCountryEntries(context.years),
|
||||
});
|
||||
}
|
||||
|
||||
if (availableCountries.has("US")) {
|
||||
definitions.push({
|
||||
name: "Referenzfeiertage USA 2026-2027",
|
||||
scopeType: "COUNTRY",
|
||||
countryCode: "US",
|
||||
priority: COUNTRY_PRIORITY,
|
||||
entries: buildUsCountryEntries(context.years),
|
||||
});
|
||||
}
|
||||
|
||||
return definitions;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { getHolidayDemoCityNamesByCountry, getHolidayDemoProfileForIndex } from "./holiday-demo-profiles.js";
|
||||
|
||||
test("getHolidayDemoProfileForIndex rotates across the demo holiday profiles", () => {
|
||||
assert.deepEqual(getHolidayDemoProfileForIndex(0), {
|
||||
countryCode: "DE",
|
||||
stateCode: "BW",
|
||||
cityName: "Stuttgart",
|
||||
});
|
||||
assert.deepEqual(getHolidayDemoProfileForIndex(5), {
|
||||
countryCode: "DE",
|
||||
stateCode: "BY",
|
||||
cityName: "Augsburg",
|
||||
});
|
||||
assert.deepEqual(getHolidayDemoProfileForIndex(12), {
|
||||
countryCode: "DE",
|
||||
stateCode: "BW",
|
||||
cityName: "Stuttgart",
|
||||
});
|
||||
});
|
||||
|
||||
test("getHolidayDemoCityNamesByCountry exposes the required demo cities", () => {
|
||||
assert.deepEqual(getHolidayDemoCityNamesByCountry(), {
|
||||
DE: ["Augsburg", "Berlin", "Hamburg", "Koeln", "Muenchen", "Stuttgart"],
|
||||
ES: ["Barcelona", "Madrid"],
|
||||
IN: ["Bangalore", "Mumbai"],
|
||||
US: ["Los Angeles", "New York"],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
export type HolidayDemoProfile = {
|
||||
countryCode: "DE" | "ES" | "IN" | "US";
|
||||
stateCode: string | null;
|
||||
cityName: string;
|
||||
};
|
||||
|
||||
export const HOLIDAY_DEMO_PROFILES: HolidayDemoProfile[] = [
|
||||
{ countryCode: "DE", stateCode: "BW", cityName: "Stuttgart" },
|
||||
{ countryCode: "DE", stateCode: "BY", cityName: "Muenchen" },
|
||||
{ countryCode: "DE", stateCode: "NW", cityName: "Koeln" },
|
||||
{ countryCode: "DE", stateCode: "HH", cityName: "Hamburg" },
|
||||
{ countryCode: "DE", stateCode: "BE", cityName: "Berlin" },
|
||||
{ countryCode: "DE", stateCode: "BY", cityName: "Augsburg" },
|
||||
{ countryCode: "ES", stateCode: "MD", cityName: "Madrid" },
|
||||
{ countryCode: "ES", stateCode: "CT", cityName: "Barcelona" },
|
||||
{ countryCode: "IN", stateCode: "KA", cityName: "Bangalore" },
|
||||
{ countryCode: "IN", stateCode: "MH", cityName: "Mumbai" },
|
||||
{ countryCode: "US", stateCode: "CA", cityName: "Los Angeles" },
|
||||
{ countryCode: "US", stateCode: "NY", cityName: "New York" },
|
||||
];
|
||||
|
||||
export function getHolidayDemoProfileForIndex(index: number): HolidayDemoProfile {
|
||||
return HOLIDAY_DEMO_PROFILES[index % HOLIDAY_DEMO_PROFILES.length]!;
|
||||
}
|
||||
|
||||
export function getHolidayDemoCityNamesByCountry() {
|
||||
const grouped = new Map<string, Set<string>>();
|
||||
|
||||
for (const profile of HOLIDAY_DEMO_PROFILES) {
|
||||
const cityNames = grouped.get(profile.countryCode) ?? new Set<string>();
|
||||
cityNames.add(profile.cityName);
|
||||
grouped.set(profile.countryCode, cityNames);
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
[...grouped.entries()]
|
||||
.map(([countryCode, cityNames]) => [countryCode, [...cityNames].sort()] as const),
|
||||
) as Record<HolidayDemoProfile["countryCode"], string[]>;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { resolve } from "node:path";
|
||||
import { hash } from "@node-rs/argon2";
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { assertDestructiveDbAllowed } from "./destructive-db-guard.js";
|
||||
import { loadWorkspaceEnv, resolveWorkspacePath } from "./load-workspace-env.js";
|
||||
|
||||
loadWorkspaceEnv();
|
||||
@@ -143,6 +144,10 @@ async function bootstrapPlatform(adminEmail: string, adminPassword: string, admi
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const target = assertDestructiveDbAllowed({
|
||||
commandName: "db:reset:dispo",
|
||||
allowedDatabaseNames: ["capakraken_test", "capakraken_e2e", "capakraken_ci"],
|
||||
});
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!options.force) {
|
||||
@@ -153,6 +158,8 @@ async function main() {
|
||||
throw new Error("DATABASE_URL is not configured.");
|
||||
}
|
||||
|
||||
console.warn(`Resetting disposable database '${target.databaseName}'.`);
|
||||
|
||||
let backupPath: string | null = null;
|
||||
|
||||
if (options.skipBackup) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { assertDestructiveDbAllowed } from "./destructive-db-guard.js";
|
||||
|
||||
const TEST_DATABASE_NAMES = [
|
||||
"capakraken_test",
|
||||
"capakraken_e2e",
|
||||
"capakraken_ci",
|
||||
];
|
||||
|
||||
export function assertSafeSeedTarget(commandName: string) {
|
||||
return assertDestructiveDbAllowed({
|
||||
commandName,
|
||||
allowedDatabaseNames: TEST_DATABASE_NAMES,
|
||||
});
|
||||
}
|
||||
@@ -9,17 +9,22 @@ import {
|
||||
DISPO_UTILIZATION_CATEGORIES,
|
||||
} from "@capakraken/shared";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { loadWorkspaceEnv } from "./load-workspace-env.js";
|
||||
import { assertSafeSeedTarget } from "./safe-destructive-env.js";
|
||||
|
||||
loadWorkspaceEnv();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
assertSafeSeedTarget("db:seed:dispo-v2");
|
||||
console.log("Seeding Dispo v2 reference data...");
|
||||
|
||||
// ─── Countries + Metro Cities ─────────────────────────────────────────────
|
||||
|
||||
const countries = [
|
||||
{ code: "CR", name: "Costa Rica", dailyWorkingHours: 8, cities: ["Costa Rica"] },
|
||||
{ code: "DE", name: "Germany", dailyWorkingHours: 8, cities: ["Bonn", "Frankfurt", "Hamburg", "Munich", "Stuttgart"] },
|
||||
{ code: "DE", name: "Germany", dailyWorkingHours: 8, cities: ["Augsburg", "Berlin", "Bonn", "Frankfurt", "Hamburg", "Koeln", "Muenchen", "Stuttgart"] },
|
||||
{ code: "HU", name: "Hungary", dailyWorkingHours: 8, cities: ["Hungary"] },
|
||||
{ code: "IN", name: "India", dailyWorkingHours: 9, cities: ["India"] },
|
||||
{ code: "IT", name: "Italy", dailyWorkingHours: 8, cities: ["Italy"] },
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { PrismaClient, type HolidayCalendarEntry } from "@prisma/client";
|
||||
import { buildHolidayCalendarSeedDefinitions } from "./holiday-calendar-seed-data.js";
|
||||
import { loadWorkspaceEnv } from "./load-workspace-env.js";
|
||||
|
||||
loadWorkspaceEnv();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const YEARS = [2026, 2027];
|
||||
const SEED_SOURCE = "seed:holiday-calendars:2026-2027";
|
||||
|
||||
type ExistingCalendar = {
|
||||
id: string;
|
||||
entries: Pick<HolidayCalendarEntry, "id" | "date" | "source">[];
|
||||
};
|
||||
|
||||
function toUtcDate(isoDate: string): Date {
|
||||
return new Date(`${isoDate}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function dateKey(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function findScopedCalendar(input: {
|
||||
countryId: string;
|
||||
scopeType: "COUNTRY" | "STATE" | "CITY";
|
||||
stateCode?: string | undefined;
|
||||
metroCityId?: string | undefined;
|
||||
}): Promise<ExistingCalendar | null> {
|
||||
return prisma.holidayCalendar.findFirst({
|
||||
where: {
|
||||
countryId: input.countryId,
|
||||
scopeType: input.scopeType,
|
||||
stateCode: input.scopeType === "STATE" ? input.stateCode ?? null : null,
|
||||
metroCityId: input.scopeType === "CITY" ? input.metroCityId ?? null : null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
entries: {
|
||||
select: {
|
||||
id: true,
|
||||
date: true,
|
||||
source: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Seeding holiday calendars for 2026-2027...");
|
||||
|
||||
const countries = await prisma.country.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
metroCities: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { code: "asc" },
|
||||
});
|
||||
|
||||
const activeGermanStatesRows = await prisma.resource.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
country: { code: "DE" },
|
||||
federalState: { not: null },
|
||||
},
|
||||
select: { federalState: true },
|
||||
distinct: ["federalState"],
|
||||
});
|
||||
|
||||
const definitions = buildHolidayCalendarSeedDefinitions({
|
||||
availableCountryCodes: countries.map((country) => country.code),
|
||||
availableCitiesByCountry: Object.fromEntries(
|
||||
countries.map((country) => [
|
||||
country.code,
|
||||
country.metroCities.map((city) => city.name),
|
||||
]),
|
||||
),
|
||||
activeGermanStates: activeGermanStatesRows
|
||||
.map((row) => row.federalState)
|
||||
.filter((stateCode): stateCode is string => Boolean(stateCode)),
|
||||
years: YEARS,
|
||||
});
|
||||
|
||||
const countryByCode = new Map(countries.map((country) => [country.code, country]));
|
||||
const cityByCountryAndName = new Map(
|
||||
countries.flatMap((country) =>
|
||||
country.metroCities.map((city) => [`${country.code}:${city.name}`, city] as const),
|
||||
),
|
||||
);
|
||||
|
||||
let createdCalendars = 0;
|
||||
let reusedCalendars = 0;
|
||||
let createdEntries = 0;
|
||||
let updatedEntries = 0;
|
||||
let skippedManualEntries = 0;
|
||||
|
||||
for (const definition of definitions) {
|
||||
const country = countryByCode.get(definition.countryCode);
|
||||
if (!country) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const metroCity = definition.cityName
|
||||
? cityByCountryAndName.get(`${definition.countryCode}:${definition.cityName}`)
|
||||
: null;
|
||||
|
||||
if (definition.scopeType === "CITY" && !metroCity) {
|
||||
console.warn(
|
||||
`Skipping city calendar ${definition.name}: city ${definition.cityName ?? "?"} not found.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let calendar = await findScopedCalendar({
|
||||
countryId: country.id,
|
||||
scopeType: definition.scopeType,
|
||||
stateCode: definition.stateCode,
|
||||
metroCityId: metroCity?.id,
|
||||
});
|
||||
|
||||
if (!calendar) {
|
||||
calendar = await prisma.holidayCalendar.create({
|
||||
data: {
|
||||
name: definition.name,
|
||||
scopeType: definition.scopeType,
|
||||
countryId: country.id,
|
||||
stateCode: definition.scopeType === "STATE" ? definition.stateCode ?? null : null,
|
||||
metroCityId: definition.scopeType === "CITY" ? metroCity?.id ?? null : null,
|
||||
priority: definition.priority,
|
||||
isActive: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
entries: {
|
||||
select: {
|
||||
id: true,
|
||||
date: true,
|
||||
source: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
createdCalendars += 1;
|
||||
} else {
|
||||
reusedCalendars += 1;
|
||||
}
|
||||
|
||||
const entriesByDate = new Map(calendar.entries.map((entry) => [dateKey(entry.date), entry]));
|
||||
|
||||
for (const entry of definition.entries) {
|
||||
const existingEntry = entriesByDate.get(entry.date);
|
||||
|
||||
if (!existingEntry) {
|
||||
await prisma.holidayCalendarEntry.create({
|
||||
data: {
|
||||
holidayCalendarId: calendar.id,
|
||||
date: toUtcDate(entry.date),
|
||||
name: entry.name,
|
||||
isRecurringAnnual: entry.isRecurringAnnual,
|
||||
source: SEED_SOURCE,
|
||||
},
|
||||
});
|
||||
createdEntries += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingEntry.source && existingEntry.source !== SEED_SOURCE) {
|
||||
skippedManualEntries += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.holidayCalendarEntry.update({
|
||||
where: { id: existingEntry.id },
|
||||
data: {
|
||||
name: entry.name,
|
||||
isRecurringAnnual: entry.isRecurringAnnual,
|
||||
source: SEED_SOURCE,
|
||||
},
|
||||
});
|
||||
updatedEntries += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` calendars created: ${createdCalendars}`);
|
||||
console.log(` calendars reused: ${reusedCalendars}`);
|
||||
console.log(` entries created: ${createdEntries}`);
|
||||
console.log(` entries updated: ${updatedEntries}`);
|
||||
console.log(` manual entries preserved: ${skippedManualEntries}`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,125 @@
|
||||
import { PrismaClient, type Prisma } from "@prisma/client";
|
||||
import { getHolidayDemoCityNamesByCountry, getHolidayDemoProfileForIndex } from "./holiday-demo-profiles.js";
|
||||
import { loadWorkspaceEnv } from "./load-workspace-env.js";
|
||||
|
||||
loadWorkspaceEnv();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
type CountryRecord = {
|
||||
id: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
function asJsonObject(value: Prisma.JsonValue | null | undefined): Record<string, Prisma.JsonValue> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return value as Record<string, Prisma.JsonValue>;
|
||||
}
|
||||
|
||||
async function ensureCities(countryByCode: Map<string, CountryRecord>) {
|
||||
const cityNamesByCountry = getHolidayDemoCityNamesByCountry();
|
||||
const cityMap = new Map<string, { id: string }>();
|
||||
|
||||
for (const [countryCode, cityNames] of Object.entries(cityNamesByCountry)) {
|
||||
const country = countryByCode.get(countryCode);
|
||||
if (!country) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const cityName of cityNames) {
|
||||
const city = await prisma.metroCity.upsert({
|
||||
where: {
|
||||
countryId_name: {
|
||||
countryId: country.id,
|
||||
name: cityName,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
countryId: country.id,
|
||||
name: cityName,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
cityMap.set(`${countryCode}:${cityName}`, city);
|
||||
}
|
||||
}
|
||||
|
||||
return cityMap;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Normalizing active resources for holiday demo profiles...");
|
||||
|
||||
const countrySeeds = [
|
||||
{ code: "DE", name: "Germany", dailyWorkingHours: 8 },
|
||||
{ code: "ES", name: "Spain", dailyWorkingHours: 8 },
|
||||
{ code: "IN", name: "India", dailyWorkingHours: 9 },
|
||||
{ code: "US", name: "United States", dailyWorkingHours: 8 },
|
||||
] as const;
|
||||
const countries = [];
|
||||
|
||||
for (const countrySeed of countrySeeds) {
|
||||
countries.push(await prisma.country.upsert({
|
||||
where: { code: countrySeed.code },
|
||||
update: { name: countrySeed.name, dailyWorkingHours: countrySeed.dailyWorkingHours },
|
||||
create: countrySeed,
|
||||
select: { id: true, code: true },
|
||||
}));
|
||||
}
|
||||
|
||||
const countryByCode = new Map(countries.map((country) => [country.code, country] as const));
|
||||
|
||||
const cityByProfile = await ensureCities(countryByCode);
|
||||
const resources = await prisma.resource.findMany({
|
||||
where: { isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
eid: true,
|
||||
dynamicFields: true,
|
||||
},
|
||||
orderBy: { eid: "asc" },
|
||||
});
|
||||
|
||||
let updated = 0;
|
||||
|
||||
for (const [index, resource] of resources.entries()) {
|
||||
const profile = getHolidayDemoProfileForIndex(index);
|
||||
const country = countryByCode.get(profile.countryCode)!;
|
||||
const metroCity = cityByProfile.get(`${profile.countryCode}:${profile.cityName}`);
|
||||
|
||||
if (!metroCity) {
|
||||
throw new Error(`Missing metro city for profile ${profile.countryCode}/${profile.cityName}`);
|
||||
}
|
||||
|
||||
const dynamicFields = asJsonObject(resource.dynamicFields);
|
||||
|
||||
await prisma.resource.update({
|
||||
where: { id: resource.id },
|
||||
data: {
|
||||
countryId: country.id,
|
||||
metroCityId: metroCity.id,
|
||||
federalState: profile.stateCode,
|
||||
dynamicFields: {
|
||||
...dynamicFields,
|
||||
city: profile.cityName,
|
||||
holidayCountryCode: profile.countryCode,
|
||||
holidayStateCode: profile.stateCode,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
updated += 1;
|
||||
}
|
||||
|
||||
console.log(`Updated ${updated} active resources.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => void prisma.$disconnect());
|
||||
@@ -8,6 +8,10 @@
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { loadWorkspaceEnv } from "./load-workspace-env.js";
|
||||
import { assertSafeSeedTarget } from "./safe-destructive-env.js";
|
||||
|
||||
loadWorkspaceEnv();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -75,6 +79,7 @@ const YEARS = [2025, 2026, 2027];
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
assertSafeSeedTarget("db:seed:vacations");
|
||||
// Get admin user to act as approver (fall back to manager, then any user)
|
||||
const admin =
|
||||
(await prisma.user.findFirst({ where: { systemRole: "ADMIN" }, select: { id: true } })) ??
|
||||
|
||||
+32
-10
@@ -12,6 +12,11 @@ import {
|
||||
} from "@capakraken/shared";
|
||||
import { PrismaClient, type Prisma, type Resource, type Project } from "@prisma/client";
|
||||
import { hash } from "@node-rs/argon2";
|
||||
import { getHolidayDemoProfileForIndex } from "./holiday-demo-profiles.js";
|
||||
import { loadWorkspaceEnv } from "./load-workspace-env.js";
|
||||
import { assertSafeSeedTarget } from "./safe-destructive-env.js";
|
||||
|
||||
loadWorkspaceEnv();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -273,10 +278,11 @@ function parseAllocationType(s: string): AllocationType {
|
||||
// ─── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
console.warn("Seeding Planarchy with 3D studio example data...");
|
||||
const target = assertSafeSeedTarget("db:seed");
|
||||
console.warn(`Seeding CapaKraken example data into ${target.databaseName} (${target.hostname}${target.port ? `:${target.port}` : ""})...`);
|
||||
|
||||
// ── 1. Delete all data (keep users) ────────────────────────────────────────
|
||||
console.warn("Deleting existing data...");
|
||||
console.warn(`Deleting existing data from disposable seed target '${target.databaseName}'...`);
|
||||
await prisma.auditLog.deleteMany({});
|
||||
await prisma.notification.deleteMany({});
|
||||
// Estimates (deep hierarchy)
|
||||
@@ -362,7 +368,7 @@ async function main() {
|
||||
|
||||
const cityMap = new Map<string, string>(); // cityName → id
|
||||
for (const [countryId, cities] of [
|
||||
[countryDE.id, ["Stuttgart", "Hamburg", "Muenchen", "Berlin"]],
|
||||
[countryDE.id, ["Augsburg", "Berlin", "Hamburg", "Koeln", "Muenchen", "Stuttgart"]],
|
||||
[countryIN.id, ["Bangalore", "Mumbai"]],
|
||||
[countryES.id, ["Madrid", "Barcelona"]],
|
||||
[countryUS.id, ["New York", "Los Angeles"]],
|
||||
@@ -871,8 +877,16 @@ async function main() {
|
||||
// ── 5. Create resources ────────────────────────────────────────────────────
|
||||
const resourceMap = new Map<string, Resource>();
|
||||
|
||||
for (const row of RESOURCE_DATA) {
|
||||
const [eid, chapter, typeOfWork, clientUnit, city, employeeType, lcr, ucr, fraction, availDays, chargeability] = row;
|
||||
const countryIdByCode = new Map<string, string>([
|
||||
["DE", countryDE.id],
|
||||
["ES", countryES.id],
|
||||
["IN", countryIN.id],
|
||||
["US", countryUS.id],
|
||||
]);
|
||||
|
||||
for (const [index, row] of RESOURCE_DATA.entries()) {
|
||||
const [eid, chapter, typeOfWork, clientUnit, , employeeType, lcr, ucr, fraction, availDays, chargeability] = row;
|
||||
const holidayProfile = getHolidayDemoProfileForIndex(index);
|
||||
|
||||
const displayName = eid
|
||||
.split(".")
|
||||
@@ -886,8 +900,8 @@ async function main() {
|
||||
const skills = computeSkills(chapter, typeOfWork, lcr);
|
||||
|
||||
// Dispo v2: resolve FKs
|
||||
const resCountryId = countryDE.id; // all seed resources are Germany
|
||||
const resMetroCityId = cityMap.get(city) ?? null;
|
||||
const resCountryId = countryIdByCode.get(holidayProfile.countryCode) ?? countryDE.id;
|
||||
const resMetroCityId = cityMap.get(holidayProfile.cityName) ?? null;
|
||||
|
||||
// chapter → orgUnit mapping
|
||||
const chapterToOrgUnit: Record<string, string> = {
|
||||
@@ -943,9 +957,17 @@ async function main() {
|
||||
chargeabilityTarget: chargeability * 100,
|
||||
availability: availability as unknown as Prisma.InputJsonValue,
|
||||
skills: skills as unknown as Prisma.InputJsonValue,
|
||||
dynamicFields: { clientUnit, workType: typeOfWork, city, employeeType },
|
||||
dynamicFields: {
|
||||
clientUnit,
|
||||
workType: typeOfWork,
|
||||
city: holidayProfile.cityName,
|
||||
employeeType,
|
||||
holidayCountryCode: holidayProfile.countryCode,
|
||||
holidayStateCode: holidayProfile.stateCode,
|
||||
},
|
||||
blueprintId: resourceBlueprint.id,
|
||||
countryId: resCountryId,
|
||||
federalState: holidayProfile.stateCode,
|
||||
...(resMetroCityId ? { metroCityId: resMetroCityId } : {}),
|
||||
...(resOrgUnitId ? { orgUnitId: resOrgUnitId } : {}),
|
||||
...(resMgmtGroupId ? { managementLevelGroupId: resMgmtGroupId } : {}),
|
||||
@@ -1037,7 +1059,7 @@ async function main() {
|
||||
hoursPerDay: number;
|
||||
percentage: number;
|
||||
headcount: number;
|
||||
status: string;
|
||||
status: AllocationStatus;
|
||||
}
|
||||
|
||||
const DEMAND_SEEDS: DemandSeed[] = [
|
||||
@@ -1092,7 +1114,7 @@ async function main() {
|
||||
end: string;
|
||||
hoursPerDay: number;
|
||||
percentage: number;
|
||||
status: string;
|
||||
status: AllocationStatus;
|
||||
}
|
||||
|
||||
const ASSIGNMENT_SEEDS: AssignmentSeed[] = [
|
||||
|
||||
@@ -113,7 +113,7 @@ describe("getRecurringHoursForDay", () => {
|
||||
it("returns 0 before pattern startDate", () => {
|
||||
const patternWithStart: RecurrencePattern = {
|
||||
...weeklyPattern,
|
||||
startDate: new Date("2026-03-16"),
|
||||
startDate: "2026-03-16",
|
||||
};
|
||||
expect(getRecurringHoursForDay(monday, patternWithStart, 8, allocationStart)).toBe(0);
|
||||
});
|
||||
@@ -121,7 +121,7 @@ describe("getRecurringHoursForDay", () => {
|
||||
it("returns 0 after pattern endDate", () => {
|
||||
const patternWithEnd: RecurrencePattern = {
|
||||
...weeklyPattern,
|
||||
endDate: new Date("2026-03-08"),
|
||||
endDate: "2026-03-08",
|
||||
};
|
||||
expect(getRecurringHoursForDay(monday, patternWithEnd, 8, allocationStart)).toBe(0);
|
||||
});
|
||||
@@ -129,8 +129,8 @@ describe("getRecurringHoursForDay", () => {
|
||||
it("returns hours within pattern date bounds", () => {
|
||||
const bounded: RecurrencePattern = {
|
||||
...weeklyPattern,
|
||||
startDate: new Date("2026-03-01"),
|
||||
endDate: new Date("2026-03-31"),
|
||||
startDate: "2026-03-01",
|
||||
endDate: "2026-03-31",
|
||||
};
|
||||
expect(getRecurringHoursForDay(monday, bounded, 8, allocationStart)).toBe(8);
|
||||
});
|
||||
|
||||
@@ -85,12 +85,12 @@ describe("calculateShoringRatio", () => {
|
||||
make("r3", "ES", 4, 10),
|
||||
make("r4", "IN", 8, 10),
|
||||
]);
|
||||
expect(r.byCountry["DE"].resourceCount).toBe(1);
|
||||
expect(r.byCountry["ES"].resourceCount).toBe(2);
|
||||
expect(r.byCountry["IN"].resourceCount).toBe(1);
|
||||
expect(r.byCountry["DE"].hours).toBe(80);
|
||||
expect(r.byCountry["ES"].hours).toBe(120);
|
||||
expect(r.byCountry["IN"].hours).toBe(80);
|
||||
expect(r.byCountry["DE"]!.resourceCount).toBe(1);
|
||||
expect(r.byCountry["ES"]!.resourceCount).toBe(2);
|
||||
expect(r.byCountry["IN"]!.resourceCount).toBe(1);
|
||||
expect(r.byCountry["DE"]!.hours).toBe(80);
|
||||
expect(r.byCountry["ES"]!.hours).toBe(120);
|
||||
expect(r.byCountry["IN"]!.hours).toBe(80);
|
||||
});
|
||||
|
||||
it("exactly at threshold is above", () => {
|
||||
|
||||
@@ -44,9 +44,10 @@ describe("dashboard layout normalization", () => {
|
||||
|
||||
expect(layout.version).toBe(DASHBOARD_LAYOUT_VERSION);
|
||||
expect(layout.widgets).toHaveLength(2);
|
||||
expect(layout.widgets[0]?.config).toEqual({});
|
||||
expect(layout.widgets[0]?.config).toEqual({ showDetails: false });
|
||||
expect(layout.widgets[1]?.y).toBe(3);
|
||||
expect(layout.widgets[1]?.config).toEqual({
|
||||
showDetails: false,
|
||||
granularity: "week",
|
||||
groupBy: "chapter",
|
||||
});
|
||||
|
||||
@@ -36,40 +36,44 @@ function clamp(value: number, min: number, max: number): number {
|
||||
|
||||
const dashboardWidgetTypeSchema = z.enum(DASHBOARD_WIDGET_TYPES);
|
||||
|
||||
const resourceTableWidgetConfigSchema = z.object({
|
||||
const widgetChromeConfigSchema = z.object({
|
||||
showDetails: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const resourceTableWidgetConfigSchema = widgetChromeConfigSchema.extend({
|
||||
chapter: z.preprocess(toNonEmptyString, z.string().optional()),
|
||||
});
|
||||
|
||||
const projectTableWidgetConfigSchema = z.object({
|
||||
const projectTableWidgetConfigSchema = widgetChromeConfigSchema.extend({
|
||||
search: z.preprocess(toNonEmptyString, z.string().optional()),
|
||||
status: z.nativeEnum(ProjectStatus).optional(),
|
||||
});
|
||||
|
||||
const peakTimesWidgetConfigSchema = z.object({
|
||||
const peakTimesWidgetConfigSchema = widgetChromeConfigSchema.extend({
|
||||
granularity: z.enum(["week", "month"]).optional(),
|
||||
groupBy: z.enum(["project", "chapter", "resource"]).optional(),
|
||||
});
|
||||
|
||||
const demandWidgetConfigSchema = z.object({
|
||||
const demandWidgetConfigSchema = widgetChromeConfigSchema.extend({
|
||||
groupBy: z.enum(["project", "person", "chapter"]).optional(),
|
||||
});
|
||||
|
||||
const topValueWidgetConfigSchema = z.object({
|
||||
const topValueWidgetConfigSchema = widgetChromeConfigSchema.extend({
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
const chargeabilityWidgetConfigSchema = z.object({
|
||||
const chargeabilityWidgetConfigSchema = widgetChromeConfigSchema.extend({
|
||||
topN: z.number().int().min(1).max(100).optional(),
|
||||
watchlistThreshold: z.number().int().min(0).max(100).optional(),
|
||||
});
|
||||
|
||||
const myProjectsWidgetConfigSchema = z.object({
|
||||
const myProjectsWidgetConfigSchema = widgetChromeConfigSchema.extend({
|
||||
showFavorites: z.boolean().optional(),
|
||||
showResponsible: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const dashboardWidgetConfigSchemas = {
|
||||
"stat-cards": z.object({}),
|
||||
"stat-cards": widgetChromeConfigSchema,
|
||||
"resource-table": resourceTableWidgetConfigSchema,
|
||||
"project-table": projectTableWidgetConfigSchema,
|
||||
"peak-times-chart": peakTimesWidgetConfigSchema,
|
||||
@@ -77,9 +81,9 @@ export const dashboardWidgetConfigSchemas = {
|
||||
"top-value-resources": topValueWidgetConfigSchema,
|
||||
"chargeability-overview": chargeabilityWidgetConfigSchema,
|
||||
"my-projects": myProjectsWidgetConfigSchema,
|
||||
"budget-forecast": z.object({}),
|
||||
"skill-gap": z.object({}),
|
||||
"project-health": z.object({}),
|
||||
"budget-forecast": widgetChromeConfigSchema,
|
||||
"skill-gap": widgetChromeConfigSchema,
|
||||
"project-health": widgetChromeConfigSchema,
|
||||
} as const;
|
||||
|
||||
type DashboardWidgetConfigSchemaMap = typeof dashboardWidgetConfigSchemas;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const HolidayCalendarScopeSchema = z.enum(["COUNTRY", "STATE", "CITY"]);
|
||||
|
||||
export const CreateHolidayCalendarSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
scopeType: HolidayCalendarScopeSchema,
|
||||
countryId: z.string(),
|
||||
stateCode: z.string().trim().min(1).max(16).optional(),
|
||||
metroCityId: z.string().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
priority: z.number().int().min(-100).max(100).optional(),
|
||||
});
|
||||
|
||||
export const UpdateHolidayCalendarSchema = z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
stateCode: z.string().trim().min(1).max(16).nullable().optional(),
|
||||
metroCityId: z.string().nullable().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
priority: z.number().int().min(-100).max(100).optional(),
|
||||
});
|
||||
|
||||
export const CreateHolidayCalendarEntrySchema = z.object({
|
||||
holidayCalendarId: z.string(),
|
||||
date: z.coerce.date(),
|
||||
name: z.string().min(1).max(120),
|
||||
isRecurringAnnual: z.boolean().optional(),
|
||||
source: z.string().max(120).optional(),
|
||||
});
|
||||
|
||||
export const UpdateHolidayCalendarEntrySchema = z.object({
|
||||
date: z.coerce.date().optional(),
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
isRecurringAnnual: z.boolean().optional(),
|
||||
source: z.string().max(120).nullable().optional(),
|
||||
});
|
||||
|
||||
export const PreviewResolvedHolidaysSchema = z.object({
|
||||
countryId: z.string(),
|
||||
stateCode: z.string().trim().min(1).max(16).optional(),
|
||||
metroCityId: z.string().optional(),
|
||||
year: z.number().int().min(2000).max(2100),
|
||||
});
|
||||
|
||||
export type HolidayCalendarScopeInput = z.infer<typeof HolidayCalendarScopeSchema>;
|
||||
export type CreateHolidayCalendarInput = z.infer<typeof CreateHolidayCalendarSchema>;
|
||||
export type UpdateHolidayCalendarInput = z.infer<typeof UpdateHolidayCalendarSchema>;
|
||||
export type CreateHolidayCalendarEntryInput = z.infer<typeof CreateHolidayCalendarEntrySchema>;
|
||||
export type UpdateHolidayCalendarEntryInput = z.infer<typeof UpdateHolidayCalendarEntrySchema>;
|
||||
export type PreviewResolvedHolidaysInput = z.infer<typeof PreviewResolvedHolidaysSchema>;
|
||||
@@ -7,6 +7,7 @@ export * from "./role.schema.js";
|
||||
export * from "./dashboard.schema.js";
|
||||
export * from "./estimate.schema.js";
|
||||
export * from "./country.schema.js";
|
||||
export * from "./holiday-calendar.schema.js";
|
||||
export * from "./org-unit.schema.js";
|
||||
export * from "./utilization-category.schema.js";
|
||||
export * from "./client.schema.js";
|
||||
|
||||
@@ -8,45 +8,49 @@ export interface DashboardWidgetSize {
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface StatCardsWidgetConfig {}
|
||||
export interface DashboardWidgetBaseConfig {
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
export interface ResourceTableWidgetConfig {
|
||||
export interface StatCardsWidgetConfig extends DashboardWidgetBaseConfig {}
|
||||
|
||||
export interface ResourceTableWidgetConfig extends DashboardWidgetBaseConfig {
|
||||
chapter?: string;
|
||||
}
|
||||
|
||||
export interface ProjectTableWidgetConfig {
|
||||
export interface ProjectTableWidgetConfig extends DashboardWidgetBaseConfig {
|
||||
search?: string;
|
||||
status?: ProjectStatus;
|
||||
}
|
||||
|
||||
export interface PeakTimesWidgetConfig {
|
||||
export interface PeakTimesWidgetConfig extends DashboardWidgetBaseConfig {
|
||||
granularity?: "week" | "month";
|
||||
groupBy?: "project" | "chapter" | "resource";
|
||||
}
|
||||
|
||||
export interface DemandWidgetConfig {
|
||||
export interface DemandWidgetConfig extends DashboardWidgetBaseConfig {
|
||||
groupBy?: "project" | "person" | "chapter";
|
||||
}
|
||||
|
||||
export interface TopValueResourcesWidgetConfig {
|
||||
export interface TopValueResourcesWidgetConfig extends DashboardWidgetBaseConfig {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ChargeabilityOverviewWidgetConfig {
|
||||
export interface ChargeabilityOverviewWidgetConfig extends DashboardWidgetBaseConfig {
|
||||
topN?: number;
|
||||
watchlistThreshold?: number;
|
||||
}
|
||||
|
||||
export interface MyProjectsWidgetConfig {
|
||||
export interface MyProjectsWidgetConfig extends DashboardWidgetBaseConfig {
|
||||
showFavorites?: boolean;
|
||||
showResponsible?: boolean;
|
||||
}
|
||||
|
||||
export interface BudgetForecastWidgetConfig {}
|
||||
export interface BudgetForecastWidgetConfig extends DashboardWidgetBaseConfig {}
|
||||
|
||||
export interface SkillGapWidgetConfig {}
|
||||
export interface SkillGapWidgetConfig extends DashboardWidgetBaseConfig {}
|
||||
|
||||
export interface ProjectHealthWidgetConfig {}
|
||||
export interface ProjectHealthWidgetConfig extends DashboardWidgetBaseConfig {}
|
||||
|
||||
export interface DashboardWidgetConfigMap {
|
||||
"stat-cards": StatCardsWidgetConfig;
|
||||
@@ -116,7 +120,7 @@ export const DASHBOARD_WIDGET_CATALOG = [
|
||||
icon: "📊",
|
||||
defaultSize: { w: 12, h: 3 },
|
||||
minSize: { w: 6, h: 2 },
|
||||
defaultConfig: {},
|
||||
defaultConfig: { showDetails: false },
|
||||
},
|
||||
{
|
||||
type: "resource-table",
|
||||
@@ -125,7 +129,7 @@ export const DASHBOARD_WIDGET_CATALOG = [
|
||||
icon: "👥",
|
||||
defaultSize: { w: 8, h: 6 },
|
||||
minSize: { w: 4, h: 4 },
|
||||
defaultConfig: {},
|
||||
defaultConfig: { showDetails: false },
|
||||
},
|
||||
{
|
||||
type: "project-table",
|
||||
@@ -134,7 +138,7 @@ export const DASHBOARD_WIDGET_CATALOG = [
|
||||
icon: "📋",
|
||||
defaultSize: { w: 8, h: 6 },
|
||||
minSize: { w: 4, h: 4 },
|
||||
defaultConfig: {},
|
||||
defaultConfig: { showDetails: false },
|
||||
},
|
||||
{
|
||||
type: "peak-times-chart",
|
||||
@@ -144,6 +148,7 @@ export const DASHBOARD_WIDGET_CATALOG = [
|
||||
defaultSize: { w: 8, h: 5 },
|
||||
minSize: { w: 4, h: 4 },
|
||||
defaultConfig: {
|
||||
showDetails: false,
|
||||
granularity: "month",
|
||||
groupBy: "project",
|
||||
},
|
||||
@@ -156,6 +161,7 @@ export const DASHBOARD_WIDGET_CATALOG = [
|
||||
defaultSize: { w: 6, h: 5 },
|
||||
minSize: { w: 4, h: 4 },
|
||||
defaultConfig: {
|
||||
showDetails: false,
|
||||
groupBy: "project",
|
||||
},
|
||||
},
|
||||
@@ -167,6 +173,7 @@ export const DASHBOARD_WIDGET_CATALOG = [
|
||||
defaultSize: { w: 6, h: 5 },
|
||||
minSize: { w: 4, h: 4 },
|
||||
defaultConfig: {
|
||||
showDetails: false,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
@@ -178,6 +185,7 @@ export const DASHBOARD_WIDGET_CATALOG = [
|
||||
defaultSize: { w: 6, h: 8 },
|
||||
minSize: { w: 4, h: 6 },
|
||||
defaultConfig: {
|
||||
showDetails: false,
|
||||
topN: 10,
|
||||
watchlistThreshold: 15,
|
||||
},
|
||||
@@ -190,6 +198,7 @@ export const DASHBOARD_WIDGET_CATALOG = [
|
||||
defaultSize: { w: 6, h: 6 },
|
||||
minSize: { w: 4, h: 3 },
|
||||
defaultConfig: {
|
||||
showDetails: false,
|
||||
showFavorites: true,
|
||||
showResponsible: true,
|
||||
},
|
||||
@@ -201,7 +210,7 @@ export const DASHBOARD_WIDGET_CATALOG = [
|
||||
icon: "💰",
|
||||
defaultSize: { w: 6, h: 5 },
|
||||
minSize: { w: 4, h: 4 },
|
||||
defaultConfig: {},
|
||||
defaultConfig: { showDetails: false },
|
||||
},
|
||||
{
|
||||
type: "skill-gap",
|
||||
@@ -210,7 +219,7 @@ export const DASHBOARD_WIDGET_CATALOG = [
|
||||
icon: "🎯",
|
||||
defaultSize: { w: 6, h: 5 },
|
||||
minSize: { w: 4, h: 4 },
|
||||
defaultConfig: {},
|
||||
defaultConfig: { showDetails: false },
|
||||
},
|
||||
{
|
||||
type: "project-health",
|
||||
@@ -219,6 +228,6 @@ export const DASHBOARD_WIDGET_CATALOG = [
|
||||
icon: "🏥",
|
||||
defaultSize: { w: 6, h: 5 },
|
||||
minSize: { w: 4, h: 4 },
|
||||
defaultConfig: {},
|
||||
defaultConfig: { showDetails: false },
|
||||
},
|
||||
] as const satisfies readonly DashboardWidgetCatalogEntry[];
|
||||
|
||||
@@ -2,6 +2,7 @@ import { SystemRole } from "./enums.js";
|
||||
|
||||
export const PermissionKey = {
|
||||
VIEW_COSTS: "viewCosts",
|
||||
USE_ASSISTANT_ADVANCED_TOOLS: "useAssistantAdvancedTools",
|
||||
EXPORT_DATA: "exportData",
|
||||
IMPORT_DATA: "importData",
|
||||
APPROVE_VACATIONS: "approveVacations",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AllocationStatus } from "@capakraken/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { analyzeUtilization, findCapacityWindows } from "../capacity-analyzer.js";
|
||||
import type { CapacityAnalysisInput } from "../capacity-analyzer.js";
|
||||
@@ -28,7 +29,7 @@ describe("analyzeUtilization", () => {
|
||||
startDate: new Date("2026-03-09"), // Monday
|
||||
endDate: new Date("2026-03-13"), // Friday
|
||||
hoursPerDay: 8,
|
||||
status: "CONFIRMED",
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
projectName: "Project A",
|
||||
isChargeable: true,
|
||||
},
|
||||
@@ -53,7 +54,7 @@ describe("analyzeUtilization", () => {
|
||||
startDate: new Date("2026-03-09"),
|
||||
endDate: new Date("2026-03-09"),
|
||||
hoursPerDay: 5,
|
||||
status: "CONFIRMED",
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
projectName: "A",
|
||||
isChargeable: true,
|
||||
},
|
||||
@@ -61,7 +62,7 @@ describe("analyzeUtilization", () => {
|
||||
startDate: new Date("2026-03-09"),
|
||||
endDate: new Date("2026-03-09"),
|
||||
hoursPerDay: 5,
|
||||
status: "CONFIRMED",
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
projectName: "B",
|
||||
isChargeable: true,
|
||||
},
|
||||
@@ -82,7 +83,7 @@ describe("analyzeUtilization", () => {
|
||||
startDate: new Date("2026-03-09"),
|
||||
endDate: new Date("2026-03-09"),
|
||||
hoursPerDay: 2,
|
||||
status: "CONFIRMED",
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
projectName: "A",
|
||||
isChargeable: true,
|
||||
},
|
||||
@@ -103,7 +104,7 @@ describe("analyzeUtilization", () => {
|
||||
startDate: new Date("2026-03-09"),
|
||||
endDate: new Date("2026-03-13"),
|
||||
hoursPerDay: 8,
|
||||
status: "CANCELLED",
|
||||
status: AllocationStatus.CANCELLED,
|
||||
projectName: "Cancelled",
|
||||
isChargeable: true,
|
||||
},
|
||||
@@ -125,7 +126,7 @@ describe("analyzeUtilization", () => {
|
||||
startDate: new Date("2026-03-09"),
|
||||
endDate: new Date("2026-03-09"),
|
||||
hoursPerDay: 4,
|
||||
status: "CONFIRMED",
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
projectName: "Chargeable",
|
||||
isChargeable: true,
|
||||
},
|
||||
@@ -133,7 +134,7 @@ describe("analyzeUtilization", () => {
|
||||
startDate: new Date("2026-03-09"),
|
||||
endDate: new Date("2026-03-09"),
|
||||
hoursPerDay: 4,
|
||||
status: "CONFIRMED",
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
projectName: "Internal",
|
||||
isChargeable: false,
|
||||
},
|
||||
@@ -190,7 +191,7 @@ describe("findCapacityWindows", () => {
|
||||
startDate: new Date("2026-03-09"),
|
||||
endDate: new Date("2026-03-13"),
|
||||
hoursPerDay: 4,
|
||||
status: "CONFIRMED",
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
},
|
||||
],
|
||||
new Date("2026-03-09"),
|
||||
@@ -209,7 +210,7 @@ describe("findCapacityWindows", () => {
|
||||
startDate: new Date("2026-03-09"),
|
||||
endDate: new Date("2026-03-13"),
|
||||
hoursPerDay: 8,
|
||||
status: "CONFIRMED",
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
},
|
||||
],
|
||||
new Date("2026-03-09"),
|
||||
@@ -226,7 +227,7 @@ describe("findCapacityWindows", () => {
|
||||
startDate: new Date("2026-03-09"),
|
||||
endDate: new Date("2026-03-13"),
|
||||
hoursPerDay: 8,
|
||||
status: "CANCELLED",
|
||||
status: AllocationStatus.CANCELLED,
|
||||
},
|
||||
],
|
||||
new Date("2026-03-09"),
|
||||
@@ -244,7 +245,7 @@ describe("findCapacityWindows", () => {
|
||||
startDate: new Date("2026-03-11"), // Wed only fully booked
|
||||
endDate: new Date("2026-03-11"),
|
||||
hoursPerDay: 8,
|
||||
status: "CONFIRMED",
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
},
|
||||
],
|
||||
new Date("2026-03-09"), // Mon
|
||||
@@ -264,7 +265,7 @@ describe("findCapacityWindows", () => {
|
||||
startDate: new Date("2026-03-09"),
|
||||
endDate: new Date("2026-03-09"),
|
||||
hoursPerDay: 6,
|
||||
status: "CONFIRMED",
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
},
|
||||
],
|
||||
new Date("2026-03-09"),
|
||||
|
||||
Reference in New Issue
Block a user