chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@planarchy/api",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./router": "./src/router/index.ts",
"./trpc": "./src/trpc.ts",
"./sse": "./src/sse/event-bus.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test:unit": "vitest run"
},
"dependencies": {
"@node-rs/argon2": "^2.0.2",
"@planarchy/application": "workspace:*",
"@planarchy/db": "workspace:*",
"@planarchy/engine": "workspace:*",
"@planarchy/shared": "workspace:*",
"@planarchy/staffing": "workspace:*",
"@trpc/server": "^11.0.0",
"@types/nodemailer": "^7.0.11",
"ioredis": "^5.10.0",
"nodemailer": "^8.0.1",
"openai": "^6.27.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@planarchy/tsconfig": "workspace:*",
"@types/node": "^22.10.2",
"typescript": "^5.6.3",
"vitest": "^2.1.8"
}
}
@@ -0,0 +1,725 @@
import { AllocationStatus, SystemRole } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { allocationRouter } from "../router/allocation.js";
import { emitAllocationCreated, emitAllocationDeleted } 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(),
}));
const createCaller = createCallerFactory(allocationRouter);
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "manager@example.com", name: "Manager", image: null },
expires: "2026-03-13T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
describe("allocation entry resolution router", () => {
it("creates an open demand through allocation.create without requiring isPlaceholder", async () => {
const createdDemandRequirement = {
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "FX Artist",
roleId: "role_fx",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
};
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
},
demandRequirement: {
create: vi.fn().mockResolvedValue(createdDemandRequirement),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.create({
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "FX Artist",
roleId: "role_fx",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
});
expect(result.id).toBe("demand_1");
expect(result.isPlaceholder).toBe(true);
expect(db.demandRequirement.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
headcount: 2,
}),
}),
);
});
it("creates an assignment through allocation.create without requiring isPlaceholder", async () => {
const createdAssignment = {
id: "assignment_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 40000,
status: AllocationStatus.ACTIVE,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 5000,
},
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: null,
};
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 5000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
}),
},
allocation: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn(),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(createdAssignment),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.create({
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_comp",
status: AllocationStatus.ACTIVE,
metadata: {},
});
expect(result.id).toBe("assignment_1");
expect(result.isPlaceholder).toBe(false);
expect(db.allocation.create).not.toHaveBeenCalled();
expect(db.assignment.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
resourceId: "resource_1",
}),
}),
);
});
it("creates an explicit demand requirement without dual-writing a legacy allocation row", async () => {
vi.mocked(emitAllocationCreated).mockClear();
const createdDemandRequirement = {
id: "demand_explicit_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "FX Artist",
roleId: "role_fx",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
};
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
},
demandRequirement: {
create: vi.fn().mockResolvedValue(createdDemandRequirement),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.createDemandRequirement({
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "FX Artist",
roleId: "role_fx",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
});
expect(result.id).toBe("demand_explicit_1");
expect((db as { allocation?: { create?: unknown } }).allocation?.create).toBeUndefined();
expect(db.demandRequirement.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
headcount: 2,
}),
}),
);
expect(emitAllocationCreated).toHaveBeenCalledWith({
id: "demand_explicit_1",
projectId: "project_1",
resourceId: null,
});
});
it("creates an explicit assignment without dual-writing a legacy allocation row", async () => {
vi.mocked(emitAllocationCreated).mockClear();
const createdAssignment = {
id: "assignment_explicit_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 40000,
status: AllocationStatus.ACTIVE,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 5000,
},
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: null,
};
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
lcrCents: 5000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
}),
},
allocation: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn(),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(createdAssignment),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.createAssignment({
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_comp",
status: AllocationStatus.ACTIVE,
metadata: {},
});
expect(result.id).toBe("assignment_explicit_1");
expect(db.allocation.create).not.toHaveBeenCalled();
expect(db.assignment.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
resourceId: "resource_1",
}),
}),
);
expect(emitAllocationCreated).toHaveBeenCalledWith({
id: "assignment_explicit_1",
projectId: "project_1",
resourceId: "resource_1",
});
});
it("deletes an explicit demand requirement without routing through allocation.delete", async () => {
vi.mocked(emitAllocationDeleted).mockClear();
const existingDemandRequirement = {
id: "demand_explicit_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "FX Artist",
roleId: "role_fx",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
project: {
id: "project_1",
name: "Project One",
shortCode: "PRJ",
status: "ACTIVE",
endDate: new Date("2026-03-20"),
},
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
assignments: [],
};
const db = {
demandRequirement: {
findUnique: vi.fn().mockResolvedValue(existingDemandRequirement),
delete: vi.fn().mockResolvedValue(existingDemandRequirement),
},
assignment: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
allocation: {
findUnique: vi.fn().mockResolvedValue(null),
delete: vi.fn(),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.deleteDemandRequirement({ id: "demand_explicit_1" });
expect(result).toEqual({ success: true });
expect(db.assignment.updateMany).toHaveBeenCalledWith({
where: { demandRequirementId: "demand_explicit_1" },
data: { demandRequirementId: null },
});
expect(db.demandRequirement.delete).toHaveBeenCalledWith({
where: { id: "demand_explicit_1" },
});
expect(db.allocation.delete).not.toHaveBeenCalled();
expect(emitAllocationDeleted).toHaveBeenCalledWith("demand_explicit_1", "project_1");
});
it("deletes an explicit assignment without routing through allocation.delete", async () => {
vi.mocked(emitAllocationDeleted).mockClear();
const existingAssignment = {
id: "assignment_explicit_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 40000,
status: AllocationStatus.ACTIVE,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 5000,
},
project: {
id: "project_1",
name: "Project One",
shortCode: "PRJ",
status: "ACTIVE",
endDate: new Date("2026-03-20"),
},
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: null,
};
const db = {
assignment: {
findUnique: vi.fn().mockResolvedValue(existingAssignment),
delete: vi.fn().mockResolvedValue(existingAssignment),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.deleteAssignment({ id: "assignment_explicit_1" });
expect(result).toEqual({ success: true });
expect(db.assignment.delete).toHaveBeenCalledWith({
where: { id: "assignment_explicit_1" },
});
expect(emitAllocationDeleted).toHaveBeenCalledWith("assignment_explicit_1", "project_1");
});
it("updates an explicit demand row through allocation.update", async () => {
const existingDemand = {
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "FX Artist",
roleId: "role_fx",
headcount: 1,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
};
const updatedDemand = {
...existingDemand,
headcount: 2,
status: AllocationStatus.CONFIRMED,
metadata: { source: "router-test" },
updatedAt: new Date("2026-03-14"),
};
const db = {
allocation: {
findUnique: vi.fn().mockResolvedValue(null),
},
demandRequirement: {
findUnique: vi.fn().mockResolvedValue(existingDemand),
update: vi.fn().mockResolvedValue(updatedDemand),
},
assignment: {
findUnique: vi.fn().mockResolvedValue(null),
update: vi.fn(),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.update({
id: "demand_1",
data: {
headcount: 2,
status: AllocationStatus.CONFIRMED,
metadata: { source: "router-test" },
},
});
expect(result.id).toBe("demand_1");
expect(result.isPlaceholder).toBe(true);
expect(result.headcount).toBe(2);
expect(db.demandRequirement.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "demand_1" },
}),
);
});
it("updates a demand row by its direct id", async () => {
const existingDemand = {
id: "demand_stale",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "FX Artist",
roleId: "role_fx",
headcount: 1,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
};
const updatedDemand = {
...existingDemand,
headcount: 2,
status: AllocationStatus.CONFIRMED,
updatedAt: new Date("2026-03-14"),
};
const db = {
demandRequirement: {
findUnique: vi.fn().mockImplementation(
({ where }: { where: { id?: string } }) => {
if (where.id === "demand_stale") {
return existingDemand;
}
return null;
},
),
update: vi.fn().mockResolvedValue(updatedDemand),
},
assignment: {
findUnique: vi.fn().mockResolvedValue(null),
update: vi.fn(),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.update({
id: "demand_stale",
data: {
headcount: 2,
status: AllocationStatus.CONFIRMED,
},
});
expect(result.id).toBe("demand_stale");
expect(result.isPlaceholder).toBe(true);
expect(db.demandRequirement.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "demand_stale" },
}),
);
});
it("batch deletes explicit demand and assignment rows through allocation.batchDelete", async () => {
const explicitDemand = {
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "FX Artist",
roleId: "role_fx",
headcount: 1,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
};
const explicitAssignment = {
id: "assignment_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 32000,
status: AllocationStatus.ACTIVE,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: { id: "resource_1", displayName: "Alice", eid: "E-001", lcrCents: 5000 },
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: null,
};
const db = {
allocation: {
findUnique: vi.fn().mockResolvedValue(null),
},
demandRequirement: {
findUnique: vi.fn().mockImplementation(({ where }: { where: { id: string } }) =>
where.id === "demand_1" ? explicitDemand : null,
),
delete: vi.fn().mockResolvedValue({}),
},
assignment: {
findUnique: vi.fn().mockImplementation(({ where }: { where: { id: string } }) =>
where.id === "assignment_1" ? explicitAssignment : null,
),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
delete: vi.fn().mockResolvedValue({}),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.batchDelete({
ids: ["demand_1", "assignment_1"],
});
expect(result.count).toBe(2);
expect(db.assignment.updateMany).toHaveBeenCalledWith({
where: { demandRequirementId: "demand_1" },
data: { demandRequirementId: null },
});
expect(db.demandRequirement.delete).toHaveBeenCalledWith({
where: { id: "demand_1" },
});
expect(db.assignment.delete).toHaveBeenCalledWith({
where: { id: "assignment_1" },
});
});
it("deletes an assignment through allocation.delete by its direct id", async () => {
const existingAssignment = {
id: "assignment_stale",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 32000,
status: AllocationStatus.ACTIVE,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: { id: "resource_1", displayName: "Alice", eid: "E-001", lcrCents: 5000 },
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: null,
};
const db = {
demandRequirement: {
findUnique: vi.fn().mockResolvedValue(null),
},
assignment: {
findUnique: vi.fn().mockImplementation(
({ where }: { where: { id?: string } }) => {
if (where.id === "assignment_stale") {
return existingAssignment;
}
return null;
},
),
delete: vi.fn().mockResolvedValue({}),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.delete({
id: "assignment_stale",
});
expect(result).toEqual({ success: true });
expect(db.assignment.delete).toHaveBeenCalledWith({
where: { id: "assignment_stale" },
});
});
});
@@ -0,0 +1,101 @@
import { BlueprintTarget, FieldType, type BlueprintFieldDefinition } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { describe, expect, it, vi } from "vitest";
import { assertBlueprintDynamicFields } from "../router/blueprint-validation.js";
function createDbMock(result: { fieldDefs: unknown; target: BlueprintTarget } | null) {
return {
blueprint: {
findUnique: vi.fn().mockResolvedValue(result),
},
};
}
describe("assertBlueprintDynamicFields", () => {
it("returns early when no blueprint is set", async () => {
const db = createDbMock(null);
await expect(
assertBlueprintDynamicFields({
db,
blueprintId: undefined,
dynamicFields: {},
target: BlueprintTarget.PROJECT,
}),
).resolves.toBeUndefined();
expect(db.blueprint.findUnique).not.toHaveBeenCalled();
});
it("rejects a missing blueprint", async () => {
const db = createDbMock(null);
await expect(
assertBlueprintDynamicFields({
db,
blueprintId: "bp_missing",
dynamicFields: {},
target: BlueprintTarget.PROJECT,
}),
).rejects.toMatchObject({ code: "NOT_FOUND" } satisfies Partial<TRPCError>);
});
it("rejects a blueprint with the wrong target", async () => {
const db = createDbMock({ fieldDefs: [], target: BlueprintTarget.RESOURCE });
await expect(
assertBlueprintDynamicFields({
db,
blueprintId: "bp_resource",
dynamicFields: {},
target: BlueprintTarget.PROJECT,
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" } satisfies Partial<TRPCError>);
});
it("rejects invalid dynamic field values", async () => {
const fieldDefs: BlueprintFieldDefinition[] = [
{
id: "cost-center",
key: "costCenter",
label: "Cost Center",
order: 0,
type: FieldType.NUMBER,
required: true,
},
];
const db = createDbMock({ fieldDefs, target: BlueprintTarget.PROJECT });
await expect(
assertBlueprintDynamicFields({
db,
blueprintId: "bp_project",
dynamicFields: { costCenter: "abc" },
target: BlueprintTarget.PROJECT,
}),
).rejects.toMatchObject({ code: "UNPROCESSABLE_CONTENT" } satisfies Partial<TRPCError>);
});
it("accepts valid dynamic field values", async () => {
const fieldDefs: BlueprintFieldDefinition[] = [
{
id: "cost-center",
key: "costCenter",
label: "Cost Center",
order: 0,
type: FieldType.NUMBER,
required: true,
},
];
const db = createDbMock({ fieldDefs, target: BlueprintTarget.PROJECT });
await expect(
assertBlueprintDynamicFields({
db,
blueprintId: "bp_project",
dynamicFields: { costCenter: 42 },
target: BlueprintTarget.PROJECT,
}),
).resolves.toBeUndefined();
});
});
@@ -0,0 +1,30 @@
import { FieldType } from "@planarchy/shared";
import { describe, expect, it } from "vitest";
import { buildDynamicFieldWhereClauses } from "../router/custom-field-filters.js";
describe("buildDynamicFieldWhereClauses", () => {
it("builds prisma-style clauses for supported field types", () => {
expect(
buildDynamicFieldWhereClauses([
{ key: "isRemote", value: "true", type: FieldType.BOOLEAN },
{ key: "seniority", value: "3.5", type: FieldType.NUMBER },
{ key: "tools", value: "houdini", type: FieldType.MULTI_SELECT },
{ key: "notes", value: "lead", type: FieldType.TEXT },
]),
).toEqual([
{ path: ["isRemote"], equals: true },
{ path: ["seniority"], equals: 3.5 },
{ path: ["tools"], array_contains: "houdini" },
{ path: ["notes"], string_contains: "lead" },
]);
});
it("skips empty and invalid numeric filters", () => {
expect(
buildDynamicFieldWhereClauses([
{ key: "empty", value: "", type: FieldType.TEXT },
{ key: "invalidNumber", value: "abc", type: FieldType.NUMBER },
]),
).toEqual([]);
});
});
@@ -0,0 +1,93 @@
import { AllocationStatus } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { loadProjectPlanningReadModel } from "../router/project-planning-read-model.js";
describe("loadProjectPlanningReadModel", () => {
it("applies active-only filters to demand and assignment loaders", async () => {
const demandFindMany = vi.fn().mockResolvedValue([]);
const assignmentFindMany = vi.fn().mockResolvedValue([]);
await loadProjectPlanningReadModel(
{
demandRequirement: { findMany: demandFindMany },
assignment: { findMany: assignmentFindMany },
} as never,
{ projectId: "project_1", activeOnly: true },
);
expect(demandFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { projectId: "project_1", status: { not: AllocationStatus.CANCELLED } },
}),
);
expect(assignmentFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { projectId: "project_1", status: { not: AllocationStatus.CANCELLED } },
}),
);
});
it("builds a split read model from demand and assignment rows", async () => {
const result = await loadProjectPlanningReadModel(
{
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-18"),
hoursPerDay: 8,
percentage: 100,
role: "FX",
roleId: "role_fx",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assignment_1",
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-18"),
hoursPerDay: 8,
percentage: 100,
role: "Lead",
roleId: "role_lead",
dailyCostCents: 32000,
status: AllocationStatus.ACTIVE,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
chapter: "CGI",
lcrCents: 5000,
availability: { monday: 8 },
},
},
]),
},
} as never,
{ projectId: "project_1" },
);
expect(result.readModel.demands.map((entry) => entry.sourceAllocationId)).toEqual(["demand_1"]);
expect(result.readModel.assignments.map((entry) => entry.sourceAllocationId)).toEqual([
"assignment_1",
]);
expect(result.readModel.allocations.map((entry) => entry.id)).toEqual([
"demand_1",
"assignment_1",
]);
});
});
@@ -0,0 +1,117 @@
import { AllocationStatus } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { projectRouter } from "../router/project.js";
import { createCallerFactory } from "../trpc.js";
const createCaller = createCallerFactory(projectRouter);
function createProtectedCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2026-03-13T00:00:00.000Z",
},
db: db as never,
dbUser: null,
});
}
describe("project router planning counts", () => {
it("returns planning entry counts in project.list", async () => {
const db = {
project: {
findMany: vi.fn().mockResolvedValue([
{
id: "project_1",
shortCode: "PRJ",
name: "Project One",
orderType: "CHARGEABLE",
allocationType: "PROJECT",
winProbability: 100,
budgetCents: 100000,
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-28"),
status: "ACTIVE",
responsiblePerson: null,
dynamicFields: {},
staffingReqs: [],
blueprintId: null,
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
_count: { allocations: 1 },
},
]),
count: vi.fn().mockResolvedValue(1),
},
allocation: {
findMany: vi.fn().mockResolvedValue([
{
id: "legacy_demand",
resourceId: null,
projectId: "project_1",
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-18"),
hoursPerDay: 8,
percentage: 100,
role: "FX",
roleId: "role_fx",
isPlaceholder: true,
headcount: 2,
dailyCostCents: 0,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
]),
},
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-18"),
hoursPerDay: 8,
percentage: 100,
role: "FX",
roleId: "role_fx",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assignment_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-19"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "FX Lead",
roleId: "role_fx",
dailyCostCents: 32000,
status: AllocationStatus.ACTIVE,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.list({ limit: 50, page: 1 });
expect(result.total).toBe(1);
expect(result.projects).toHaveLength(1);
expect(result.projects[0]?._count.allocations).toBe(2);
});
});
@@ -0,0 +1,175 @@
import { describe, expect, it, vi } from "vitest";
import type { inferProcedureInput } from "@trpc/server";
import type { AppRouter } from "../router/index.js";
// Minimal mock helpers
function mockCtx(overrides: Record<string, unknown> = {}) {
return {
ctx: {
session: { user: { id: "user_1", systemRole: "MANAGER" } },
db: overrides,
},
};
}
describe("rateCard router", () => {
describe("list", () => {
it("returns rate cards with line counts", async () => {
const findMany = vi.fn().mockResolvedValue([
{ id: "rc_1", name: "Standard 2026", currency: "EUR", isActive: true, _count: { lines: 5 } },
{ id: "rc_2", name: "India Rates", currency: "INR", isActive: true, _count: { lines: 3 } },
]);
const result = await findMany({
where: {},
include: { _count: { select: { lines: true } } },
orderBy: [{ isActive: "desc" }, { effectiveFrom: "desc" }, { name: "asc" }],
});
expect(result).toHaveLength(2);
expect(result[0]._count.lines).toBe(5);
});
});
describe("create", () => {
it("creates a rate card with lines", async () => {
const create = vi.fn().mockResolvedValue({
id: "rc_new",
name: "Q1 2026 Rates",
currency: "EUR",
isActive: true,
lines: [
{ id: "rcl_1", costRateCents: 9500, billRateCents: 14000, chapter: "Digital Content Production" },
],
});
const result = await create({
data: {
name: "Q1 2026 Rates",
currency: "EUR",
lines: {
create: [{ costRateCents: 9500, billRateCents: 14000, chapter: "Digital Content Production" }],
},
},
});
expect(result.id).toBe("rc_new");
expect(result.lines).toHaveLength(1);
expect(result.lines[0].costRateCents).toBe(9500);
});
});
describe("resolveRate", () => {
it("returns the most specific matching line", () => {
const lines = [
{ id: "rcl_1", roleId: null, chapter: "Digital Content Production", costRateCents: 7000, billRateCents: 12000 },
{ id: "rcl_2", roleId: "role_3d", chapter: "Digital Content Production", costRateCents: 9500, billRateCents: 14000 },
{ id: "rcl_3", roleId: null, chapter: null, costRateCents: 6000, billRateCents: 10000 },
];
const criteria = { roleId: "role_3d", chapter: "Digital Content Production" };
const scored = lines.map((line) => {
let score = 0;
let mismatch = false;
if (criteria.roleId && line.roleId) {
if (line.roleId === criteria.roleId) score += 4;
else mismatch = true;
}
if (criteria.chapter && line.chapter) {
if (line.chapter === criteria.chapter) score += 2;
else mismatch = true;
}
return { line, score, mismatch };
});
const candidates = scored
.filter((s) => !s.mismatch)
.sort((a, b) => b.score - a.score);
const best = candidates[0];
const result = best ? best.line : null;
// Most specific match: role + chapter = score 6
expect(result?.id).toBe("rcl_2");
expect(result?.costRateCents).toBe(9500);
});
it("returns null when no lines match", () => {
const lines = [
{ id: "rcl_1", roleId: "role_pm", chapter: "Project Management", costRateCents: 7000 },
];
const criteria = { roleId: "role_3d", chapter: "Digital Content Production" };
const scored = lines.map((line) => {
let score = 0;
let mismatch = false;
if (criteria.roleId && line.roleId) {
if (line.roleId === criteria.roleId) score += 4;
else mismatch = true;
}
if (criteria.chapter && line.chapter) {
if (line.chapter === criteria.chapter) score += 2;
else mismatch = true;
}
return { line, score, mismatch };
});
const candidates = scored.filter((s) => !s.mismatch);
const best = candidates[0];
expect(best).toBeUndefined();
});
it("falls back to generic line when specific criteria don't match", () => {
const lines = [
{ id: "rcl_1", roleId: null, chapter: null, costRateCents: 6000 },
{ id: "rcl_2", roleId: "role_pm", chapter: "Project Management", costRateCents: 8000 },
];
const criteria = { roleId: "role_3d", chapter: "Digital Content Production" };
const scored = lines.map((line) => {
let score = 0;
let mismatch = false;
if (criteria.roleId && line.roleId) {
if (line.roleId === criteria.roleId) score += 4;
else mismatch = true;
}
if (criteria.chapter && line.chapter) {
if (line.chapter === criteria.chapter) score += 2;
else mismatch = true;
}
return { line, score, mismatch };
});
const candidates = scored
.filter((s) => !s.mismatch)
.sort((a, b) => b.score - a.score);
const best = candidates[0];
const result = best ? best.line : null;
// Generic line (no criteria set) should match as fallback
expect(result?.id).toBe("rcl_1");
expect(result?.costRateCents).toBe(6000);
});
});
describe("replaceLines", () => {
it("deletes all lines and creates new ones in a transaction", async () => {
const deleteMany = vi.fn().mockResolvedValue({ count: 3 });
const createLine = vi.fn()
.mockResolvedValueOnce({ id: "rcl_new_1", costRateCents: 8000 })
.mockResolvedValueOnce({ id: "rcl_new_2", costRateCents: 9500 });
await deleteMany({ where: { rateCardId: "rc_1" } });
const line1 = await createLine({ data: { rateCardId: "rc_1", costRateCents: 8000 } });
const line2 = await createLine({ data: { rateCardId: "rc_1", costRateCents: 9500 } });
expect(deleteMany).toHaveBeenCalledWith({ where: { rateCardId: "rc_1" } });
expect(line1.id).toBe("rcl_new_1");
expect(line2.id).toBe("rcl_new_2");
});
});
});
@@ -0,0 +1,169 @@
import { AllocationStatus, SystemRole } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { describe, expect, it, vi } from "vitest";
import { roleRouter } from "../router/role.js";
import { createCallerFactory } from "../trpc.js";
vi.mock("../sse/event-bus.js", () => ({
emitRoleCreated: vi.fn(),
emitRoleDeleted: vi.fn(),
emitRoleUpdated: vi.fn(),
}));
const createCaller = createCallerFactory(roleRouter);
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "manager@example.com", name: "Manager", image: null },
expires: "2026-03-13T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
describe("role router planning counts", () => {
it("reports planning entry counts for roles", async () => {
const db = {
role: {
findMany: vi.fn().mockResolvedValue([
{
id: "role_fx",
name: "FX",
description: null,
color: "#111111",
isActive: true,
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
_count: { resourceRoles: 2 },
},
]),
},
allocation: {
findMany: vi.fn().mockResolvedValue([
{
id: "legacy_demand",
resourceId: null,
projectId: "project_1",
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-18"),
hoursPerDay: 8,
percentage: 100,
role: "FX",
roleId: "role_fx",
isPlaceholder: true,
headcount: 2,
dailyCostCents: 0,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
]),
},
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-18"),
hoursPerDay: 8,
percentage: 100,
role: "FX",
roleId: "role_fx",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assignment_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-19"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "FX Lead",
roleId: "role_fx",
dailyCostCents: 32000,
status: AllocationStatus.ACTIVE,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
]),
},
};
const caller = createManagerCaller(db);
const result = await caller.list({});
expect(result).toHaveLength(1);
expect(result[0]?._count.resourceRoles).toBe(2);
expect(result[0]?._count.allocations).toBe(2);
});
it("blocks deleting a role that is only used by explicit demand or assignment rows", async () => {
const db = {
role: {
findUnique: vi.fn().mockResolvedValue({
id: "role_fx",
name: "FX",
description: null,
color: "#111111",
isActive: true,
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
_count: { resourceRoles: 0 },
}),
delete: vi.fn(),
},
allocation: {
findMany: vi.fn().mockResolvedValue([]),
},
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-18"),
hoursPerDay: 8,
percentage: 100,
role: "FX",
roleId: "role_fx",
headcount: 1,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createManagerCaller(db);
await expect(caller.delete({ id: "role_fx" })).rejects.toMatchObject({
code: "PRECONDITION_FAILED",
} satisfies Partial<TRPCError>);
expect(db.role.delete).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,285 @@
import { AllocationStatus, SystemRole } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { timelineRouter } from "../router/timeline.js";
import { createCallerFactory } from "../trpc.js";
vi.mock("../sse/event-bus.js", () => ({
emitAllocationCreated: vi.fn(),
emitAllocationDeleted: vi.fn(),
emitAllocationUpdated: vi.fn(),
emitProjectShifted: vi.fn(),
}));
const createCaller = createCallerFactory(timelineRouter);
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "manager@example.com", name: "Manager", image: null },
expires: "2026-03-13T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
describe("timeline allocation entry resolution", () => {
it("creates a quick assignment without dual-writing a legacy allocation row", async () => {
const createdAssignment = {
id: "assignment_quick_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "Team Member",
roleId: null,
dailyCostCents: 40000,
status: AllocationStatus.PROPOSED,
metadata: { source: "quickAssign" },
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 5000,
},
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: null,
demandRequirement: null,
};
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
lcrCents: 5000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
}),
},
allocation: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn(),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(createdAssignment),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.quickAssign({
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
role: "Team Member",
status: AllocationStatus.PROPOSED,
});
expect(result.id).toBe("assignment_quick_1");
expect(result.isPlaceholder).toBe(false);
expect(db.allocation.create).not.toHaveBeenCalled();
expect(db.assignment.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
resourceId: "resource_1",
metadata: { source: "quickAssign" },
}),
}),
);
});
it("updates an explicit assignment through updateAllocationInline", async () => {
const existingAssignment = {
id: "assignment_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 20000,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 5000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
},
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: null,
};
const updatedAssignment = {
...existingAssignment,
hoursPerDay: 6,
endDate: new Date("2026-03-21"),
percentage: 75,
dailyCostCents: 30000,
metadata: { includeSaturday: true },
updatedAt: new Date("2026-03-14"),
};
const db = {
allocation: {
findUnique: vi.fn().mockResolvedValue(null),
},
demandRequirement: {
findUnique: vi.fn().mockResolvedValue(null),
},
assignment: {
findUnique: vi.fn().mockResolvedValue(existingAssignment),
update: vi.fn().mockResolvedValue(updatedAssignment),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
lcrCents: 5000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
}),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.updateAllocationInline({
allocationId: "assignment_1",
hoursPerDay: 6,
endDate: new Date("2026-03-21"),
includeSaturday: true,
});
expect(result.id).toBe("assignment_1");
expect(result.hoursPerDay).toBe(6);
expect(db.assignment.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "assignment_1" },
}),
);
});
it("updates an explicit demand row through updateAllocationInline", async () => {
const existingDemand = {
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "FX Artist",
roleId: "role_fx",
headcount: 1,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
};
const updatedDemand = {
...existingDemand,
hoursPerDay: 6,
endDate: new Date("2026-03-21"),
percentage: 50,
metadata: { includeSaturday: true },
updatedAt: new Date("2026-03-14"),
};
const db = {
allocation: {
findUnique: vi.fn().mockResolvedValue(null),
},
demandRequirement: {
findUnique: vi.fn().mockResolvedValue(existingDemand),
update: vi.fn().mockResolvedValue(updatedDemand),
},
assignment: {
findUnique: vi.fn().mockResolvedValue(null),
update: vi.fn(),
},
resource: {
findUnique: vi.fn(),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
const caller = createManagerCaller(db);
const result = await caller.updateAllocationInline({
allocationId: "demand_1",
hoursPerDay: 6,
endDate: new Date("2026-03-21"),
includeSaturday: true,
});
expect(result.id).toBe("demand_1");
expect(result.hoursPerDay).toBe(6);
expect(db.resource.findUnique).not.toHaveBeenCalled();
expect(db.demandRequirement.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "demand_1" },
}),
);
});
});
@@ -0,0 +1,80 @@
import { AllocationStatus } from "@planarchy/shared";
import { describe, expect, it } from "vitest";
import { buildTimelineShiftPlan } from "../router/timeline-shift-planning.js";
describe("buildTimelineShiftPlan", () => {
it("builds validation assignments from explicit assignments", () => {
const result = buildTimelineShiftPlan({
demandRequirements: [
{
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 8,
percentage: 100,
role: "FX",
roleId: "role_fx",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
],
assignments: [
{
id: "assignment_1",
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 6,
percentage: 75,
role: "Comp",
roleId: "role_comp",
dailyCostCents: 30000,
status: AllocationStatus.ACTIVE,
metadata: { includeSaturday: true },
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 5000,
availability: { monday: 8 },
},
},
],
allAssignmentWindows: [
{
id: "assignment_1",
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 6,
status: AllocationStatus.ACTIVE,
},
],
});
expect(result.validationAllocations).toHaveLength(1);
expect(result.validationAllocations.map((entry) => entry.sourceAllocationId)).toEqual([
"assignment_1",
]);
expect(result.validationAllocations[0]?.includeSaturday).toBe(true);
expect(result.validationAllocations[0]?.allAllocationsForResource).toEqual([
{
id: "assignment_1",
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 6,
status: AllocationStatus.ACTIVE,
},
]);
});
});
+65
View File
@@ -0,0 +1,65 @@
import OpenAI, { AzureOpenAI } from "openai";
type AiSettings = {
aiProvider?: string | null;
azureOpenAiEndpoint?: string | null;
azureOpenAiDeployment?: string | null;
azureOpenAiApiKey?: string | null;
azureApiVersion?: string | null;
aiMaxCompletionTokens?: number | null;
aiTemperature?: number | null;
};
/** Returns true if the settings have enough information to make an API call. */
export function isAiConfigured(settings: AiSettings | null | undefined): boolean {
if (!settings?.azureOpenAiApiKey || !settings.azureOpenAiDeployment) return false;
if (settings.aiProvider === "azure" && !settings.azureOpenAiEndpoint) return false;
return true;
}
/** Instantiates the right OpenAI client based on the stored provider setting. */
export function createAiClient(settings: AiSettings): OpenAI {
if (settings.aiProvider === "azure") {
return new AzureOpenAI({
endpoint: settings.azureOpenAiEndpoint!,
apiKey: settings.azureOpenAiApiKey!,
apiVersion: settings.azureApiVersion ?? "2025-01-01-preview",
deployment: settings.azureOpenAiDeployment!,
});
}
// Default: regular OpenAI (sk-... key)
return new OpenAI({ apiKey: settings.azureOpenAiApiKey! });
}
/** Turns raw API errors into actionable human-readable messages. */
export function parseAiError(err: unknown): string {
const msg = err instanceof Error ? err.message : String(err);
const lower = msg.toLowerCase();
if (lower.includes("401") || lower.includes("unauthorized") || lower.includes("invalid_api_key") || lower.includes("incorrect api key")) {
return "Invalid API key — make sure you copied it correctly from your provider's dashboard.";
}
if (lower.includes("insufficient_quota") || lower.includes("exceeded your current quota") || lower.includes("billing")) {
return "Account quota exceeded or billing issue — check your usage limits at platform.openai.com.";
}
if (lower.includes("403") || lower.includes("forbidden")) {
return "Access denied — your key may not have permission to use this model/deployment.";
}
if (lower.includes("deploymentnotfound") || lower.includes("model_not_found") || (lower.includes("404") && lower.includes("deployment"))) {
return "Deployment not found — check the deployment name matches exactly what's configured in Azure.";
}
if (lower.includes("404") || lower.includes("not found")) {
return "Model not found — verify the model name (e.g. gpt-4o-mini) is correct and available on your account.";
}
if (lower.includes("429") || lower.includes("rate limit") || lower.includes("ratelimiterror")) {
return "Rate limit exceeded — wait a moment and try again.";
}
if (lower.includes("econnrefused") || lower.includes("enotfound") || lower.includes("fetch failed") || lower.includes("failed to fetch")) {
return "Cannot reach the API endpoint — check the endpoint URL and your network connection.";
}
if (lower.includes("context_length_exceeded") || lower.includes("maximum context")) {
return "Request too large — the prompt exceeded the model's context limit.";
}
// Fall back to the raw message but strip noise
return msg.replace(/^Error: /, "").slice(0, 300);
}
+3
View File
@@ -0,0 +1,3 @@
export { appRouter, type AppRouter } from "./router/index.js";
export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedure, protectedProcedure, managerProcedure, controllerProcedure, adminProcedure, requirePermission } from "./trpc.js";
export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning } from "./sse/event-bus.js";
+81
View File
@@ -0,0 +1,81 @@
/**
* Email sending utility using nodemailer.
* Non-blocking — errors are logged, not thrown.
*/
import nodemailer from "nodemailer";
import { prisma as db } from "@planarchy/db";
interface EmailPayload {
to: string | string[];
subject: string;
text: string;
html?: string;
}
async function getSmtpConfig() {
const settings = await db.systemSettings.findUnique({ where: { id: "singleton" } });
if (!settings?.smtpHost) return null;
return {
host: settings.smtpHost,
port: settings.smtpPort ?? 587,
secure: settings.smtpTls === false ? false : true,
auth:
settings.smtpUser && settings.smtpPassword
? { user: settings.smtpUser, pass: settings.smtpPassword }
: undefined,
from: settings.smtpFrom ?? settings.smtpUser ?? "noreply@planarchy.app",
};
}
/**
* Send an email. Swallows errors so calling code is never blocked.
* Returns true if sent successfully.
*/
export async function sendEmail(payload: EmailPayload): Promise<boolean> {
try {
const config = await getSmtpConfig();
if (!config) return false;
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.auth,
});
await transporter.sendMail({
from: config.from,
to: Array.isArray(payload.to) ? payload.to.join(", ") : payload.to,
subject: payload.subject,
text: payload.text,
html: payload.html,
});
return true;
} catch (err) {
console.error("[email] Failed to send email:", err);
return false;
}
}
/**
* Test SMTP connection. Returns { ok: boolean; error?: string }.
*/
export async function testSmtpConnection(): Promise<{ ok: boolean; error?: string }> {
try {
const config = await getSmtpConfig();
if (!config) return { ok: false, error: "SMTP not configured" };
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.auth,
});
await transporter.verify();
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
+610
View File
@@ -0,0 +1,610 @@
import {
buildSplitAllocationReadModel,
createAssignment,
createDemandRequirement,
deleteAssignment,
deleteAllocationEntry,
deleteDemandRequirement,
fillDemandRequirement,
fillOpenDemand,
loadAllocationEntry,
updateAllocationEntry,
updateAssignment,
updateDemandRequirement,
} from "@planarchy/application";
import {
AllocationStatus,
CreateAllocationSchema,
CreateAssignmentSchema,
CreateDemandRequirementSchema,
FillDemandRequirementSchema,
FillOpenDemandByAllocationSchema,
PermissionKey,
UpdateAssignmentSchema,
UpdateAllocationSchema,
UpdateDemandRequirementSchema,
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated } from "../sse/event-bus.js";
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
const DEMAND_INCLUDE = {
project: { select: { id: true, name: true, shortCode: true, status: true, endDate: true } },
roleEntity: { select: { id: true, name: true, color: true } },
assignments: {
include: {
resource: { select: { id: true, displayName: true, eid: true, lcrCents: true } },
project: { select: { id: true, name: true, shortCode: true, status: true, endDate: true } },
roleEntity: { select: { id: true, name: true, color: true } },
},
},
} as const;
const ASSIGNMENT_INCLUDE = {
resource: { select: { id: true, displayName: true, eid: true, lcrCents: true } },
project: { select: { id: true, name: true, shortCode: true, status: true, endDate: true } },
roleEntity: { select: { id: true, name: true, color: true } },
demandRequirement: {
select: {
id: true,
projectId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
percentage: true,
role: true,
roleId: true,
headcount: true,
status: true,
},
},
} as const;
type AllocationListFilters = {
projectId?: string | undefined;
resourceId?: string | undefined;
status?: AllocationStatus | undefined;
};
type AllocationEntryUpdateInput = z.infer<typeof UpdateAllocationSchema>;
function toDemandRequirementUpdateInput(input: AllocationEntryUpdateInput) {
return {
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
...(input.startDate !== undefined ? { startDate: input.startDate } : {}),
...(input.endDate !== undefined ? { endDate: input.endDate } : {}),
...(input.hoursPerDay !== undefined ? { hoursPerDay: input.hoursPerDay } : {}),
...(input.percentage !== undefined ? { percentage: input.percentage } : {}),
...(input.role !== undefined ? { role: input.role } : {}),
...(input.roleId !== undefined ? { roleId: input.roleId } : {}),
...(input.headcount !== undefined ? { headcount: input.headcount } : {}),
...(input.status !== undefined ? { status: input.status } : {}),
...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
};
}
function toAssignmentUpdateInput(input: AllocationEntryUpdateInput) {
return {
...(input.resourceId !== undefined ? { resourceId: input.resourceId } : {}),
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
...(input.startDate !== undefined ? { startDate: input.startDate } : {}),
...(input.endDate !== undefined ? { endDate: input.endDate } : {}),
...(input.hoursPerDay !== undefined ? { hoursPerDay: input.hoursPerDay } : {}),
...(input.percentage !== undefined ? { percentage: input.percentage } : {}),
...(input.role !== undefined ? { role: input.role } : {}),
...(input.roleId !== undefined ? { roleId: input.roleId } : {}),
...(input.status !== undefined ? { status: input.status } : {}),
...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
};
}
async function loadAllocationReadModel(
db: Pick<import("@planarchy/db").PrismaClient, "demandRequirement" | "assignment">,
input: AllocationListFilters,
) {
const [demandRequirements, assignments] = await Promise.all([
input.resourceId
? Promise.resolve([])
: db.demandRequirement.findMany({
where: {
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.status ? { status: input.status } : {}),
},
include: DEMAND_INCLUDE,
orderBy: { startDate: "asc" },
}),
db.assignment.findMany({
where: {
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.resourceId ? { resourceId: input.resourceId } : {}),
...(input.status ? { status: input.status } : {}),
},
include: ASSIGNMENT_INCLUDE,
orderBy: { startDate: "asc" },
}),
]);
return buildSplitAllocationReadModel({ demandRequirements, assignments });
}
async function findAllocationEntryOrNull(
db: Pick<import("@planarchy/db").PrismaClient, "demandRequirement" | "assignment">,
id: string,
) {
try {
return await loadAllocationEntry(db, id);
} catch (error) {
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
return null;
}
throw error;
}
}
export const allocationRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
projectId: z.string().optional(),
resourceId: z.string().optional(),
status: z.nativeEnum(AllocationStatus).optional(),
}),
)
.query(async ({ ctx, input }) => {
const readModel = await loadAllocationReadModel(ctx.db, input);
return readModel.allocations;
}),
listView: protectedProcedure
.input(
z.object({
projectId: z.string().optional(),
resourceId: z.string().optional(),
status: z.nativeEnum(AllocationStatus).optional(),
}),
)
.query(async ({ ctx, input }) => loadAllocationReadModel(ctx.db, input)),
create: managerProcedure
.input(CreateAllocationSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const allocation = await ctx.db.$transaction(async (tx) => {
if (!input.resourceId) {
const demandRequirement = await createDemandRequirement(
tx as unknown as Parameters<typeof createDemandRequirement>[0],
{
projectId: input.projectId,
startDate: input.startDate,
endDate: input.endDate,
hoursPerDay: input.hoursPerDay,
percentage: input.percentage,
role: input.role,
roleId: input.roleId,
headcount: input.headcount,
status: input.status,
metadata: input.metadata,
},
);
return buildSplitAllocationReadModel({
demandRequirements: [demandRequirement],
assignments: [],
}).allocations[0]!;
}
const assignment = await createAssignment(
tx as unknown as Parameters<typeof createAssignment>[0],
{
resourceId: input.resourceId,
projectId: input.projectId,
startDate: input.startDate,
endDate: input.endDate,
hoursPerDay: input.hoursPerDay,
percentage: input.percentage,
role: input.role,
roleId: input.roleId,
status: input.status,
metadata: input.metadata,
},
);
return buildSplitAllocationReadModel({
demandRequirements: [],
assignments: [assignment],
}).allocations[0]!;
});
emitAllocationCreated({
id: allocation.id,
projectId: allocation.projectId,
resourceId: allocation.resourceId,
});
return allocation;
}),
listDemands: protectedProcedure
.input(
z.object({
projectId: z.string().optional(),
status: z.nativeEnum(AllocationStatus).optional(),
roleId: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
return ctx.db.demandRequirement.findMany({
where: {
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.status ? { status: input.status } : {}),
...(input.roleId ? { roleId: input.roleId } : {}),
},
include: DEMAND_INCLUDE,
orderBy: { startDate: "asc" },
});
}),
listAssignments: protectedProcedure
.input(
z.object({
projectId: z.string().optional(),
resourceId: z.string().optional(),
status: z.nativeEnum(AllocationStatus).optional(),
demandRequirementId: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
return ctx.db.assignment.findMany({
where: {
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.resourceId ? { resourceId: input.resourceId } : {}),
...(input.status ? { status: input.status } : {}),
...(input.demandRequirementId ? { demandRequirementId: input.demandRequirementId } : {}),
},
include: ASSIGNMENT_INCLUDE,
orderBy: { startDate: "asc" },
});
}),
createDemandRequirement: managerProcedure
.input(CreateDemandRequirementSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const demandRequirement = await ctx.db.$transaction(async (tx) => {
return createDemandRequirement(
tx as unknown as Parameters<typeof createDemandRequirement>[0],
input,
);
});
emitAllocationCreated({
id: demandRequirement.id,
projectId: demandRequirement.projectId,
resourceId: null,
});
return demandRequirement;
}),
updateDemandRequirement: managerProcedure
.input(z.object({ id: z.string(), data: UpdateDemandRequirementSchema }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const updated = await ctx.db.$transaction(async (tx) => {
return updateDemandRequirement(
tx as unknown as Parameters<typeof updateDemandRequirement>[0],
input.id,
input.data,
);
});
emitAllocationUpdated({
id: updated.id,
projectId: updated.projectId,
resourceId: null,
});
return updated;
}),
createAssignment: managerProcedure
.input(CreateAssignmentSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const assignment = await ctx.db.$transaction(async (tx) => {
return createAssignment(
tx as unknown as Parameters<typeof createAssignment>[0],
input,
);
});
emitAllocationCreated({
id: assignment.id,
projectId: assignment.projectId,
resourceId: assignment.resourceId,
});
return assignment;
}),
updateAssignment: managerProcedure
.input(z.object({ id: z.string(), data: UpdateAssignmentSchema }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const updated = await ctx.db.$transaction(async (tx) => {
return updateAssignment(
tx as unknown as Parameters<typeof updateAssignment>[0],
input.id,
input.data,
);
});
emitAllocationUpdated({
id: updated.id,
projectId: updated.projectId,
resourceId: updated.resourceId,
});
return updated;
}),
deleteDemandRequirement: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const existing = await ctx.db.demandRequirement.findUnique({
where: { id: input.id },
include: DEMAND_INCLUDE,
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Demand requirement not found" });
}
await ctx.db.$transaction(async (tx) => {
await deleteDemandRequirement(
tx as unknown as Parameters<typeof deleteDemandRequirement>[0],
input.id,
);
await tx.auditLog.create({
data: {
entityType: "DemandRequirement",
entityId: input.id,
action: "DELETE",
changes: { before: existing } as unknown as import("@planarchy/db").Prisma.InputJsonValue,
},
});
});
emitAllocationDeleted(existing.id, existing.projectId);
return { success: true };
}),
fillDemandRequirement: managerProcedure
.input(FillDemandRequirementSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const result = await fillDemandRequirement(ctx.db, input);
emitAllocationCreated({
id: result.assignment.id,
projectId: result.assignment.projectId,
resourceId: result.assignment.resourceId,
});
emitAllocationUpdated({
id: result.updatedDemandRequirement.id,
projectId: result.updatedDemandRequirement.projectId,
resourceId: null,
});
return result;
}),
fillOpenDemandByAllocation: managerProcedure
.input(FillOpenDemandByAllocationSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const result = await fillOpenDemand(ctx.db, input);
emitAllocationCreated(result.createdAllocation);
if (result.updatedAllocation) {
emitAllocationUpdated(result.updatedAllocation);
}
return result;
}),
update: managerProcedure
.input(z.object({ id: z.string(), data: UpdateAllocationSchema }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const existing = await loadAllocationEntry(ctx.db, input.id);
const updated = await ctx.db.$transaction(async (tx) => {
const { allocation: updatedAllocation } = await updateAllocationEntry(
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
{
id: input.id,
demandRequirementUpdate:
existing.kind === "assignment" ? {} : toDemandRequirementUpdateInput(input.data),
assignmentUpdate:
existing.kind === "demand" ? {} : toAssignmentUpdateInput(input.data),
},
);
await tx.auditLog.create({
data: {
entityType: "Allocation",
entityId: input.id,
action: "UPDATE",
changes: {
before: existing.entry,
after: updatedAllocation,
} as unknown as import("@planarchy/db").Prisma.InputJsonValue,
},
});
return updatedAllocation;
});
emitAllocationUpdated({
id: updated.id,
projectId: updated.projectId,
resourceId: updated.resourceId,
});
return updated;
}),
deleteAssignment: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const existing = await ctx.db.assignment.findUnique({
where: { id: input.id },
include: ASSIGNMENT_INCLUDE,
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Assignment not found" });
}
await ctx.db.$transaction(async (tx) => {
await deleteAssignment(
tx as unknown as Parameters<typeof deleteAssignment>[0],
input.id,
);
await tx.auditLog.create({
data: {
entityType: "Assignment",
entityId: input.id,
action: "DELETE",
changes: { before: existing } as unknown as import("@planarchy/db").Prisma.InputJsonValue,
},
});
});
emitAllocationDeleted(existing.id, existing.projectId);
return { success: true };
}),
delete: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const existing = await loadAllocationEntry(ctx.db, input.id);
await ctx.db.$transaction(async (tx) => {
await deleteAllocationEntry(
tx as unknown as Parameters<typeof deleteAllocationEntry>[0],
existing,
);
await tx.auditLog.create({
data: {
entityType: "Allocation",
entityId: input.id,
action: "DELETE",
changes: { before: existing.entry } as unknown as import("@planarchy/db").Prisma.InputJsonValue,
},
});
});
emitAllocationDeleted(existing.entry.id, existing.projectId);
return { success: true };
}),
batchDelete: managerProcedure
.input(z.object({ ids: z.array(z.string()).min(1).max(100) }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const existing = (
await Promise.all(input.ids.map(async (id) => findAllocationEntryOrNull(ctx.db, id)))
).filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
await ctx.db.$transaction(async (tx) => {
for (const allocation of existing) {
await deleteAllocationEntry(
tx as unknown as Parameters<typeof deleteAllocationEntry>[0],
allocation,
);
}
await tx.auditLog.create({
data: {
entityType: "Allocation",
entityId: input.ids.join(","),
action: "DELETE",
changes: {
before: existing.map((a) => ({ id: a.entry.id, projectId: a.projectId })),
} as unknown as import("@planarchy/db").Prisma.InputJsonValue,
},
});
});
for (const a of existing) {
emitAllocationDeleted(a.entry.id, a.projectId);
}
return { count: existing.length };
}),
batchUpdateStatus: managerProcedure
.input(
z.object({
ids: z.array(z.string()).min(1).max(100),
status: z.nativeEnum(AllocationStatus),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const updated = await ctx.db.$transaction(async (tx) => {
const updatedAllocations = await Promise.all(
input.ids.map(async (id) =>
(
await updateAllocationEntry(
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
{
id,
demandRequirementUpdate: { status: input.status },
assignmentUpdate: { status: input.status },
},
)
).allocation,
),
);
return updatedAllocations;
});
await ctx.db.auditLog.create({
data: {
entityType: "Allocation",
entityId: input.ids.join(","),
action: "UPDATE",
changes: { after: { status: input.status, ids: input.ids } },
},
});
for (const a of updated) {
emitAllocationUpdated({ id: a.id, projectId: a.projectId, resourceId: a.resourceId });
}
return { count: updated.length };
}),
});
@@ -0,0 +1,54 @@
import { validateCustomFields } from "@planarchy/engine";
import { BlueprintTarget, type BlueprintFieldDefinition } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
interface BlueprintLookup {
blueprint: {
findUnique: (args: {
where: { id: string };
select: { fieldDefs: true; target: true };
}) => Promise<{ fieldDefs: unknown; target: string } | null>;
};
}
interface AssertBlueprintDynamicFieldsInput {
db: BlueprintLookup;
blueprintId: string | undefined;
dynamicFields: Record<string, unknown>;
target: BlueprintTarget;
}
export async function assertBlueprintDynamicFields({
db,
blueprintId,
dynamicFields,
target,
}: AssertBlueprintDynamicFieldsInput): Promise<void> {
if (!blueprintId) return;
const blueprint = await db.blueprint.findUnique({
where: { id: blueprintId },
select: { fieldDefs: true, target: true },
});
if (!blueprint) {
throw new TRPCError({ code: "NOT_FOUND", message: "Blueprint not found" });
}
if (blueprint.target !== target) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${target} entities require a ${target.toLowerCase()} blueprint`,
});
}
const fieldDefs = blueprint.fieldDefs as BlueprintFieldDefinition[];
const errors = validateCustomFields(fieldDefs, dynamicFields);
if (errors.length > 0) {
throw new TRPCError({
code: "UNPROCESSABLE_CONTENT",
message: errors.map((error) => error.message).join("; "),
});
}
}
+129
View File
@@ -0,0 +1,129 @@
import { BlueprintTarget, CreateBlueprintSchema, UpdateBlueprintSchema, type BlueprintFieldDefinition } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
export const blueprintRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
target: z.nativeEnum(BlueprintTarget).optional(),
isActive: z.boolean().optional().default(true),
}),
)
.query(async ({ ctx, input }) => {
return ctx.db.blueprint.findMany({
where: {
...(input.target ? { target: input.target } : {}),
isActive: input.isActive,
},
orderBy: { name: "asc" },
});
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const blueprint = await ctx.db.blueprint.findUnique({ where: { id: input.id } });
if (!blueprint) {
throw new TRPCError({ code: "NOT_FOUND", message: "Blueprint not found" });
}
return blueprint;
}),
create: adminProcedure
.input(CreateBlueprintSchema)
.mutation(async ({ ctx, input }) => {
return ctx.db.blueprint.create({
data: {
name: input.name,
target: input.target,
description: input.description,
fieldDefs: input.fieldDefs as unknown as import("@planarchy/db").Prisma.InputJsonValue,
defaults: input.defaults as unknown as import("@planarchy/db").Prisma.InputJsonValue,
validationRules: input.validationRules as unknown as import("@planarchy/db").Prisma.InputJsonValue,
} as unknown as Parameters<typeof ctx.db.blueprint.create>[0]["data"],
});
}),
update: adminProcedure
.input(z.object({ id: z.string(), data: UpdateBlueprintSchema }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.blueprint.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Blueprint not found" });
}
return ctx.db.blueprint.update({
where: { id: input.id },
data: {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.description !== undefined ? { description: input.data.description } : {}),
...(input.data.fieldDefs !== undefined ? { fieldDefs: input.data.fieldDefs as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
...(input.data.defaults !== undefined ? { defaults: input.data.defaults as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
...(input.data.validationRules !== undefined ? { validationRules: input.data.validationRules as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
} as unknown as Parameters<typeof ctx.db.blueprint.update>[0]["data"],
});
}),
/** Dedicated mutation for saving role presets — separate from field defs to avoid Zod depth issues */
updateRolePresets: adminProcedure
.input(z.object({ id: z.string(), rolePresets: z.array(z.unknown()) }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.blueprint.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Blueprint not found" });
}
return ctx.db.blueprint.update({
where: { id: input.id },
data: { rolePresets: input.rolePresets as unknown as import("@planarchy/db").Prisma.InputJsonValue },
});
}),
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
// Soft delete — mark as inactive
return ctx.db.blueprint.update({
where: { id: input.id },
data: { isActive: false },
});
}),
batchDelete: adminProcedure
.input(z.object({ ids: z.array(z.string()).min(1).max(100) }))
.mutation(async ({ ctx, input }) => {
// Soft delete
const updated = await ctx.db.$transaction(
input.ids.map((id) =>
ctx.db.blueprint.update({ where: { id }, data: { isActive: false } }),
),
);
return { count: updated.length };
}),
getGlobalFieldDefs: protectedProcedure
.input(z.object({ target: z.nativeEnum(BlueprintTarget) }))
.query(async ({ ctx, input }) => {
const blueprints = await ctx.db.blueprint.findMany({
where: { target: input.target, isGlobal: true, isActive: true },
select: { id: true, name: true, fieldDefs: true },
});
return blueprints.flatMap((b) =>
(b.fieldDefs as unknown as BlueprintFieldDefinition[]).map((f) => ({
...f,
blueprintId: b.id,
blueprintName: b.name,
})),
);
}),
setGlobal: adminProcedure
.input(z.object({ id: z.string(), isGlobal: z.boolean() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.blueprint.update({
where: { id: input.id },
data: { isGlobal: input.isGlobal },
});
}),
});
@@ -0,0 +1,245 @@
import {
deriveResourceForecast,
calculateGroupChargeability,
calculateGroupTarget,
sumFte,
getMonthRange,
getMonthKeys,
countWorkingDaysInOverlap,
calculateSAH,
type AssignmentSlice,
} from "@planarchy/engine";
import type { SpainScheduleRule } from "@planarchy/shared";
import { listAssignmentBookings } from "@planarchy/application";
import { VacationStatus } from "@planarchy/db";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
export const chargeabilityReportRouter = createTRPCRouter({
getReport: controllerProcedure
.input(
z.object({
startMonth: z.string().regex(/^\d{4}-\d{2}$/), // "2026-01"
endMonth: z.string().regex(/^\d{4}-\d{2}$/),
orgUnitId: z.string().optional(),
managementLevelGroupId: z.string().optional(),
countryId: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const { startMonth, endMonth } = input;
// Parse month range
const [startYear, startMo] = startMonth.split("-").map(Number) as [number, number];
const [endYear, endMo] = endMonth.split("-").map(Number) as [number, number];
const rangeStart = getMonthRange(startYear, startMo).start;
const rangeEnd = getMonthRange(endYear, endMo).end;
const monthKeys = getMonthKeys(rangeStart, rangeEnd);
// Fetch resources with filters
const resourceWhere = {
isActive: true,
chgResponsibility: true,
departed: false,
rolledOff: false,
...(input.orgUnitId ? { orgUnitId: input.orgUnitId } : {}),
...(input.managementLevelGroupId ? { managementLevelGroupId: input.managementLevelGroupId } : {}),
...(input.countryId ? { countryId: input.countryId } : {}),
};
const resources = await ctx.db.resource.findMany({
where: resourceWhere,
select: {
id: true,
eid: true,
displayName: true,
fte: true,
chargeabilityTarget: true,
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
orgUnit: { select: { id: true, name: true } },
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
managementLevel: { select: { id: true, name: true } },
metroCity: { select: { id: true, name: true } },
},
orderBy: { displayName: "asc" },
});
if (resources.length === 0) {
return {
monthKeys,
resources: [],
groupTotals: monthKeys.map((key) => ({
monthKey: key,
totalFte: 0,
chg: 0,
target: 0,
gap: 0,
})),
};
}
// Fetch all bookings (assignments + legacy allocations) in the date range
const resourceIds = resources.map((r) => r.id);
const allBookings = await listAssignmentBookings(ctx.db, {
startDate: rangeStart,
endDate: rangeEnd,
resourceIds,
});
// Enrich with utilization category — fetch project util categories in bulk
const projectIds = [...new Set(allBookings.map((b) => b.projectId))];
const projectUtilCats = projectIds.length > 0
? await ctx.db.project.findMany({
where: { id: { in: projectIds } },
select: { id: true, utilizationCategory: { select: { code: true } } },
})
: [];
const projectUtilCatMap = new Map(
projectUtilCats.map((p) => [p.id, p.utilizationCategory?.code ?? null]),
);
// Normalize bookings to a common shape
const assignments = allBookings
.filter((b) => b.resourceId !== null)
.map((b) => ({
resourceId: b.resourceId!,
startDate: b.startDate,
endDate: b.endDate,
hoursPerDay: b.hoursPerDay,
project: {
status: b.project.status,
utilizationCategory: { code: projectUtilCatMap.get(b.projectId) ?? null },
},
}));
// Fetch vacations/absences in the range
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,
},
});
// Build per-resource, per-month forecasts
const resourceRows = resources.map((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 months = monthKeys.map((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,
periodStart: monthStart,
periodEnd: monthEnd,
publicHolidays: [], // TODO: integrate public holidays from country
absenceDays: absenceDates,
});
// Build assignment slices for this month
const slices: AssignmentSlice[] = [];
for (const a of resourceAssignments) {
// Skip DRAFT projects
if (a.project.status === "DRAFT" || a.project.status === "CANCELLED") continue;
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
if (workingDays <= 0) continue;
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
slices.push({
hoursPerDay: a.hoursPerDay,
workingDays,
categoryCode,
});
}
const forecast = deriveResourceForecast({
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
sah: sahResult.standardAvailableHours,
});
return {
monthKey: key,
sah: sahResult.standardAvailableHours,
...forecast,
};
});
return {
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
fte: resource.fte,
country: resource.country?.code ?? null,
city: resource.metroCity?.name ?? null,
orgUnit: resource.orgUnit?.name ?? null,
mgmtGroup: resource.managementLevelGroup?.name ?? null,
mgmtLevel: resource.managementLevel?.name ?? null,
targetPct,
months,
};
});
// Compute group totals per month
const groupTotals = monthKeys.map((key, monthIdx) => {
const groupInputs = resourceRows.map((r) => ({
fte: r.fte,
chargeability: r.months[monthIdx]!.chg,
}));
const targetInputs = resourceRows.map((r) => ({
fte: r.fte,
targetPercentage: r.targetPct,
}));
const chg = calculateGroupChargeability(groupInputs);
const target = calculateGroupTarget(targetInputs);
return {
monthKey: key,
totalFte: sumFte(resourceRows),
chg,
target,
gap: chg - target,
};
});
return {
monthKeys,
resources: resourceRows,
groupTotals,
};
}),
});
+137
View File
@@ -0,0 +1,137 @@
import { CreateClientSchema, UpdateClientSchema } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js";
import type { ClientTree } from "@planarchy/shared";
interface FlatClient {
id: string;
name: string;
code: string | null;
parentId: string | null;
isActive: boolean;
sortOrder: number;
createdAt: Date;
updatedAt: Date;
}
function buildClientTree(flatItems: FlatClient[], parentId: string | null = null): ClientTree[] {
return flatItems
.filter((item) => item.parentId === parentId)
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
.map((item) => ({
...item,
children: buildClientTree(flatItems, item.id),
}));
}
export const clientRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
parentId: z.string().nullable().optional(),
isActive: z.boolean().optional(),
search: z.string().optional(),
}).optional(),
)
.query(async ({ ctx, input }) => {
return ctx.db.client.findMany({
where: {
...(input?.parentId !== undefined ? { parentId: input.parentId } : {}),
...(input?.isActive !== undefined ? { isActive: input.isActive } : {}),
...(input?.search
? { name: { contains: input.search, mode: "insensitive" as const } }
: {}),
},
include: { _count: { select: { children: true, projects: true } } },
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
});
}),
getTree: protectedProcedure
.input(z.object({ isActive: z.boolean().optional() }).optional())
.query(async ({ ctx, input }) => {
const all = await ctx.db.client.findMany({
where: {
...(input?.isActive !== undefined ? { isActive: input.isActive } : {}),
},
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
});
return buildClientTree(all);
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const client = await ctx.db.client.findUnique({
where: { id: input.id },
include: {
parent: true,
children: { orderBy: { sortOrder: "asc" } },
_count: { select: { projects: true, children: true } },
},
});
if (!client) throw new TRPCError({ code: "NOT_FOUND", message: "Client not found" });
return client;
}),
create: managerProcedure
.input(CreateClientSchema)
.mutation(async ({ ctx, input }) => {
if (input.parentId) {
const parent = await ctx.db.client.findUnique({ where: { id: input.parentId } });
if (!parent) throw new TRPCError({ code: "NOT_FOUND", message: "Parent client not found" });
}
if (input.code) {
const codeConflict = await ctx.db.client.findUnique({ where: { code: input.code } });
if (codeConflict) {
throw new TRPCError({ code: "CONFLICT", message: `Client code "${input.code}" already exists` });
}
}
return ctx.db.client.create({
data: {
name: input.name,
...(input.code ? { code: input.code } : {}),
...(input.parentId ? { parentId: input.parentId } : {}),
sortOrder: input.sortOrder,
},
});
}),
update: managerProcedure
.input(z.object({ id: z.string(), data: UpdateClientSchema }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.client.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Client not found" });
if (input.data.code && input.data.code !== existing.code) {
const conflict = await ctx.db.client.findUnique({ where: { code: input.data.code } });
if (conflict) {
throw new TRPCError({ code: "CONFLICT", message: `Client code "${input.data.code}" already exists` });
}
}
return ctx.db.client.update({
where: { id: input.id },
data: {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.code !== undefined ? { code: input.data.code } : {}),
...(input.data.sortOrder !== undefined ? { sortOrder: input.data.sortOrder } : {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
...(input.data.parentId !== undefined ? { parentId: input.data.parentId } : {}),
},
});
}),
deactivate: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.client.update({
where: { id: input.id },
data: { isActive: false },
});
}),
});
+128
View File
@@ -0,0 +1,128 @@
import {
CreateCountrySchema,
CreateMetroCitySchema,
UpdateCountrySchema,
UpdateMetroCitySchema,
} from "@planarchy/shared";
import { Prisma } from "@planarchy/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
/** Convert nullable JSON to Prisma-compatible value (null → Prisma.JsonNull). */
function jsonOrNull(val: unknown): Prisma.InputJsonValue | typeof Prisma.JsonNull {
if (val === null || val === undefined) return Prisma.JsonNull;
return val as Prisma.InputJsonValue;
}
export const countryRouter = createTRPCRouter({
list: protectedProcedure
.input(z.object({ isActive: z.boolean().optional() }).optional())
.query(async ({ ctx, input }) => {
return ctx.db.country.findMany({
where: {
...(input?.isActive !== undefined ? { isActive: input.isActive } : {}),
},
include: { metroCities: { orderBy: { name: "asc" } } },
orderBy: { name: "asc" },
});
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const country = await ctx.db.country.findUnique({
where: { id: input.id },
include: {
metroCities: { orderBy: { name: "asc" } },
_count: { select: { resources: true } },
},
});
if (!country) throw new TRPCError({ code: "NOT_FOUND", message: "Country not found" });
return country;
}),
create: adminProcedure
.input(CreateCountrySchema)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.country.findUnique({ where: { code: input.code } });
if (existing) {
throw new TRPCError({ code: "CONFLICT", message: `Country code "${input.code}" already exists` });
}
return ctx.db.country.create({
data: {
code: input.code,
name: input.name,
dailyWorkingHours: input.dailyWorkingHours,
...(input.scheduleRules !== undefined ? { scheduleRules: jsonOrNull(input.scheduleRules) } : {}),
},
include: { metroCities: true },
});
}),
update: adminProcedure
.input(z.object({ id: z.string(), data: UpdateCountrySchema }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.country.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Country not found" });
if (input.data.code && input.data.code !== existing.code) {
const conflict = await ctx.db.country.findUnique({ where: { code: input.data.code } });
if (conflict) {
throw new TRPCError({ code: "CONFLICT", message: `Country code "${input.data.code}" already exists` });
}
}
return ctx.db.country.update({
where: { id: input.id },
data: {
...(input.data.code !== undefined ? { code: input.data.code } : {}),
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.dailyWorkingHours !== undefined ? { dailyWorkingHours: input.data.dailyWorkingHours } : {}),
...(input.data.scheduleRules !== undefined ? { scheduleRules: jsonOrNull(input.data.scheduleRules) } : {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
},
include: { metroCities: true },
});
}),
// ─── Metro City ─────────────────────────────────────────────
createCity: adminProcedure
.input(CreateMetroCitySchema)
.mutation(async ({ ctx, input }) => {
const country = await ctx.db.country.findUnique({ where: { id: input.countryId } });
if (!country) throw new TRPCError({ code: "NOT_FOUND", message: "Country not found" });
return ctx.db.metroCity.create({
data: { name: input.name, countryId: input.countryId },
});
}),
updateCity: adminProcedure
.input(z.object({ id: z.string(), data: UpdateMetroCitySchema }))
.mutation(async ({ ctx, input }) => {
return ctx.db.metroCity.update({
where: { id: input.id },
data: { ...(input.data.name !== undefined ? { name: input.data.name } : {}) },
});
}),
deleteCity: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const city = await ctx.db.metroCity.findUnique({
where: { id: input.id },
include: { _count: { select: { resources: true } } },
});
if (!city) throw new TRPCError({ code: "NOT_FOUND", message: "Metro city not found" });
if (city._count.resources > 0) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Cannot delete metro city assigned to ${city._count.resources} resource(s)`,
});
}
await ctx.db.metroCity.delete({ where: { id: input.id } });
return { success: true };
}),
});
@@ -0,0 +1,46 @@
import { FieldType } from "@planarchy/shared";
export interface CustomFieldFilterInput {
key: string;
value: string;
type: FieldType;
}
export interface DynamicFieldWhereClause {
path: [string];
equals?: boolean | number;
array_contains?: string;
string_contains?: string;
}
export function buildDynamicFieldWhereClauses(
filters: readonly CustomFieldFilterInput[] | undefined,
): DynamicFieldWhereClause[] {
const conditions: DynamicFieldWhereClause[] = [];
for (const { key, value, type } of filters ?? []) {
if (!value) continue;
if (type === FieldType.BOOLEAN) {
conditions.push({ path: [key], equals: value === "true" });
continue;
}
if (type === FieldType.NUMBER) {
const parsed = Number.parseFloat(value);
if (!Number.isNaN(parsed)) {
conditions.push({ path: [key], equals: parsed });
}
continue;
}
if (type === FieldType.MULTI_SELECT) {
conditions.push({ path: [key], array_contains: value });
continue;
}
conditions.push({ path: [key], string_contains: value });
}
return conditions;
}
+71
View File
@@ -0,0 +1,71 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure, controllerProcedure } from "../trpc.js";
import {
getDashboardChargeabilityOverview,
getDashboardDemand,
getDashboardOverview,
getDashboardPeakTimes,
getDashboardTopValueResources,
} from "@planarchy/application";
export const dashboardRouter = createTRPCRouter({
getOverview: protectedProcedure.query(({ ctx }) => getDashboardOverview(ctx.db)),
getPeakTimes: protectedProcedure
.input(
z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
granularity: z.enum(["week", "month"]).default("month"),
groupBy: z.enum(["project", "chapter", "resource"]).default("project"),
}),
)
.query(({ ctx, input }) =>
getDashboardPeakTimes(ctx.db, {
startDate: new Date(input.startDate),
endDate: new Date(input.endDate),
granularity: input.granularity,
groupBy: input.groupBy,
}),
),
getTopValueResources: protectedProcedure
.input(z.object({ limit: z.number().int().min(1).max(50).default(10) }))
.query(({ ctx, input }) =>
getDashboardTopValueResources(ctx.db, {
limit: input.limit,
userRole:
(ctx.session.user as { role?: string } | undefined)?.role ?? "USER",
}),
),
getDemand: protectedProcedure
.input(
z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
groupBy: z.enum(["project", "person", "chapter"]).default("project"),
}),
)
.query(({ ctx, input }) =>
getDashboardDemand(ctx.db, {
startDate: new Date(input.startDate),
endDate: new Date(input.endDate),
groupBy: input.groupBy,
}),
),
getChargeabilityOverview: controllerProcedure
.input(
z.object({
topN: z.number().int().min(1).max(50).default(10),
watchlistThreshold: z.number().default(15),
}),
)
.query(({ ctx, input }) =>
getDashboardChargeabilityOverview(ctx.db, {
topN: input.topN,
watchlistThreshold: input.watchlistThreshold,
}),
),
});
+296
View File
@@ -0,0 +1,296 @@
import {
expandScopeToEffort,
aggregateByDiscipline,
type EffortRuleInput,
type ScopeItemInput,
} from "@planarchy/engine";
import {
CreateEffortRuleSetSchema,
UpdateEffortRuleSetSchema,
ApplyEffortRulesSchema,
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
const ruleInclude = {
rules: { orderBy: { sortOrder: "asc" as const } },
} as const;
export const effortRuleRouter = createTRPCRouter({
list: controllerProcedure.query(async ({ ctx }) => {
return ctx.db.effortRuleSet.findMany({
include: ruleInclude,
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
});
}),
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const ruleSet = await ctx.db.effortRuleSet.findUnique({
where: { id: input.id },
include: ruleInclude,
});
if (!ruleSet) throw new TRPCError({ code: "NOT_FOUND", message: "Effort rule set not found" });
return ruleSet;
}),
create: managerProcedure
.input(CreateEffortRuleSetSchema)
.mutation(async ({ ctx, input }) => {
// If this is set as default, unset other defaults
if (input.isDefault) {
await ctx.db.effortRuleSet.updateMany({
where: { isDefault: true },
data: { isDefault: false },
});
}
return ctx.db.effortRuleSet.create({
data: {
name: input.name,
...(input.description ? { description: input.description } : {}),
isDefault: input.isDefault,
rules: {
create: input.rules.map((r, i) => ({
scopeType: r.scopeType,
discipline: r.discipline,
...(r.chapter ? { chapter: r.chapter } : {}),
unitMode: r.unitMode,
hoursPerUnit: r.hoursPerUnit,
...(r.description ? { description: r.description } : {}),
sortOrder: r.sortOrder ?? i,
})),
},
},
include: ruleInclude,
});
}),
update: managerProcedure
.input(UpdateEffortRuleSetSchema)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.effortRuleSet.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Effort rule set not found" });
// If setting as default, unset others
if (input.isDefault) {
await ctx.db.effortRuleSet.updateMany({
where: { isDefault: true, id: { not: input.id } },
data: { isDefault: false },
});
}
// If rules are provided, replace all existing rules
if (input.rules) {
await ctx.db.effortRule.deleteMany({ where: { ruleSetId: input.id } });
await ctx.db.effortRule.createMany({
data: input.rules.map((r, i) => ({
ruleSetId: input.id,
scopeType: r.scopeType,
discipline: r.discipline,
...(r.chapter ? { chapter: r.chapter } : {}),
unitMode: r.unitMode,
hoursPerUnit: r.hoursPerUnit,
...(r.description ? { description: r.description } : {}),
sortOrder: r.sortOrder ?? i,
})),
});
}
return ctx.db.effortRuleSet.update({
where: { id: input.id },
data: {
...(input.name !== undefined ? { name: input.name } : {}),
...(input.description !== undefined ? { description: input.description } : {}),
...(input.isDefault !== undefined ? { isDefault: input.isDefault } : {}),
},
include: ruleInclude,
});
}),
delete: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.effortRuleSet.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Effort rule set not found" });
await ctx.db.effortRuleSet.delete({ where: { id: input.id } });
return { id: input.id };
}),
/** Preview the expansion result without persisting */
preview: controllerProcedure
.input(z.object({
estimateId: z.string(),
ruleSetId: z.string(),
}))
.query(async ({ ctx, input }) => {
const [estimate, ruleSet] = await Promise.all([
ctx.db.estimate.findUnique({
where: { id: input.estimateId },
include: {
versions: {
orderBy: { versionNumber: "desc" },
take: 1,
include: { scopeItems: { orderBy: { sortOrder: "asc" } } },
},
},
}),
ctx.db.effortRuleSet.findUnique({
where: { id: input.ruleSetId },
include: ruleInclude,
}),
]);
if (!estimate) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
if (!ruleSet) throw new TRPCError({ code: "NOT_FOUND", message: "Effort rule set not found" });
const version = estimate.versions[0];
if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" });
const scopeItems: ScopeItemInput[] = version.scopeItems.map((s) => ({
name: s.name,
scopeType: s.scopeType,
frameCount: s.frameCount,
itemCount: s.itemCount,
unitMode: s.unitMode,
}));
const rules: EffortRuleInput[] = ruleSet.rules.map((r) => ({
scopeType: r.scopeType,
discipline: r.discipline,
chapter: r.chapter,
unitMode: r.unitMode as "per_frame" | "per_item" | "flat",
hoursPerUnit: r.hoursPerUnit,
sortOrder: r.sortOrder,
}));
const result = expandScopeToEffort(scopeItems, rules);
const aggregated = aggregateByDiscipline(result.lines);
return {
...result,
aggregated,
scopeItemCount: scopeItems.length,
ruleCount: rules.length,
};
}),
/** Apply effort rules to generate demand lines on the working version */
apply: managerProcedure
.input(ApplyEffortRulesSchema)
.mutation(async ({ ctx, input }) => {
const [estimate, ruleSet] = await Promise.all([
ctx.db.estimate.findUnique({
where: { id: input.estimateId },
include: {
versions: {
orderBy: { versionNumber: "desc" },
take: 1,
include: {
scopeItems: { orderBy: { sortOrder: "asc" } },
demandLines: true,
},
},
},
}),
ctx.db.effortRuleSet.findUnique({
where: { id: input.ruleSetId },
include: ruleInclude,
}),
]);
if (!estimate) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
if (!ruleSet) throw new TRPCError({ code: "NOT_FOUND", message: "Effort rule set not found" });
const version = estimate.versions[0];
if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" });
if (version.status !== "WORKING") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Can only apply rules to a WORKING version" });
}
const scopeItems: ScopeItemInput[] = version.scopeItems.map((s) => ({
name: s.name,
scopeType: s.scopeType,
frameCount: s.frameCount,
itemCount: s.itemCount,
unitMode: s.unitMode,
}));
const rules: EffortRuleInput[] = ruleSet.rules.map((r) => ({
scopeType: r.scopeType,
discipline: r.discipline,
chapter: r.chapter,
unitMode: r.unitMode as "per_frame" | "per_item" | "flat",
hoursPerUnit: r.hoursPerUnit,
sortOrder: r.sortOrder,
}));
const result = expandScopeToEffort(scopeItems, rules);
// In replace mode, delete existing demand lines first
if (input.mode === "replace") {
await ctx.db.estimateDemandLine.deleteMany({
where: { estimateVersionId: version.id },
});
}
// Create demand lines from expanded results
if (result.lines.length > 0) {
await ctx.db.estimateDemandLine.createMany({
data: result.lines.map((line) => ({
estimateVersionId: version.id,
lineType: "LABOR",
name: `${line.discipline}${line.scopeItemName}`,
...(line.chapter ? { chapter: line.chapter } : {}),
hours: line.hours,
costRateCents: 0,
billRateCents: 0,
currency: estimate.baseCurrency,
costTotalCents: 0,
priceTotalCents: 0,
monthlySpread: {},
staffingAttributes: {},
metadata: {
effortRule: {
ruleSetId: ruleSet.id,
ruleSetName: ruleSet.name,
discipline: line.discipline,
unitMode: line.unitMode,
unitCount: line.unitCount,
hoursPerUnit: line.hoursPerUnit,
},
},
})),
});
}
// Log audit
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
effortRulesApplied: {
ruleSetId: ruleSet.id,
ruleSetName: ruleSet.name,
mode: input.mode,
linesGenerated: result.lines.length,
warnings: result.warnings,
},
},
},
},
});
return {
linesGenerated: result.lines.length,
warnings: result.warnings,
unmatchedScopeItems: result.unmatchedScopeItems,
};
}),
});
+278
View File
@@ -0,0 +1,278 @@
/**
* Vacation entitlement & balance router.
* Tracks annual leave quotas per resource per year.
* Balance is computed lazily: carryover from previous year is applied on first access.
*/
import { VacationType, VacationStatus } from "@planarchy/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.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;
}
/**
* Get or create an entitlement record, applying carryover from previous year if needed.
*/
async function getOrCreateEntitlement(
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
resourceId: string,
year: number,
defaultDays: number,
) {
let entitlement = await db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId, year } },
});
if (!entitlement) {
// Check previous year for carryover
const prevYear = await db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId, year: year - 1 } },
});
const carryover = prevYear
? Math.max(0, prevYear.entitledDays - prevYear.usedDays - prevYear.pendingDays)
: 0;
entitlement = await db.vacationEntitlement.create({
data: {
resourceId,
year,
entitledDays: defaultDays + carryover,
carryoverDays: carryover,
usedDays: 0,
pendingDays: 0,
},
});
}
return entitlement;
}
/**
* Recompute used/pending days from actual vacation records and update the cached values.
*/
async function syncEntitlement(
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
resourceId: string,
year: number,
defaultDays: number,
) {
const entitlement = await getOrCreateEntitlement(db, resourceId, year, defaultDays);
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`) },
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
},
select: { startDate: true, endDate: true, status: true, isHalfDay: true },
});
let usedDays = 0;
let pendingDays = 0;
for (const v of vacations) {
const days = countDays(v.startDate, v.endDate, v.isHalfDay);
if (v.status === VacationStatus.APPROVED) usedDays += days;
else pendingDays += days;
}
return db.vacationEntitlement.update({
where: { id: entitlement.id },
data: { usedDays, pendingDays },
});
}
export const entitlementRouter = createTRPCRouter({
/**
* Get vacation balance for a resource in a year.
* Creates the entitlement record if it doesn't exist (with carryover).
*/
getBalance: protectedProcedure
.input(
z.object({
resourceId: z.string(),
year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()),
}),
)
.query(async ({ ctx, input }) => {
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
// Sync from real vacation records
const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
// Also count sick days (informational)
const sickVacations = 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`) },
},
select: { startDate: true, endDate: true, isHalfDay: true },
});
const sickDays = sickVacations.reduce(
(sum, v) => sum + countDays(v.startDate, v.endDate, v.isHalfDay),
0,
);
return {
year: input.year,
resourceId: input.resourceId,
entitledDays: entitlement.entitledDays,
carryoverDays: entitlement.carryoverDays,
usedDays: entitlement.usedDays,
pendingDays: entitlement.pendingDays,
remainingDays: Math.max(
0,
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
),
sickDays,
};
}),
/**
* Get entitlement record for a resource/year (admin/manager only).
*/
get: managerProcedure
.input(z.object({ resourceId: z.string(), year: z.number().int() }))
.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);
}),
/**
* Set entitlement for a resource/year (admin/manager only).
*/
set: managerProcedure
.input(
z.object({
resourceId: z.string(),
year: z.number().int(),
entitledDays: z.number().min(0).max(365),
}),
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId: input.resourceId, year: input.year } },
});
if (existing) {
return ctx.db.vacationEntitlement.update({
where: { id: existing.id },
data: { entitledDays: input.entitledDays },
});
}
return ctx.db.vacationEntitlement.create({
data: {
resourceId: input.resourceId,
year: input.year,
entitledDays: input.entitledDays,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
},
});
}),
/**
* Bulk-set entitlements for multiple resources (admin only).
* Useful for setting the default entitlement for a new year.
*/
bulkSet: adminProcedure
.input(
z.object({
year: z.number().int(),
entitledDays: z.number().min(0).max(365),
resourceIds: z.array(z.string()).optional(), // if omitted, applies to all active resources
}),
)
.mutation(async ({ ctx, input }) => {
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.resourceIds ? { id: { in: input.resourceIds } } : {}),
},
select: { id: true },
});
let updated = 0;
for (const r of resources) {
await ctx.db.vacationEntitlement.upsert({
where: { resourceId_year: { resourceId: r.id, year: input.year } },
create: {
resourceId: r.id,
year: input.year,
entitledDays: input.entitledDays,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
},
update: { entitledDays: input.entitledDays },
});
updated++;
}
return { updated };
}),
/**
* Get year summary: all resources with their balance for a given year.
* Manager/admin only.
*/
getYearSummary: managerProcedure
.input(
z.object({
year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()),
chapter: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.chapter ? { chapter: input.chapter } : {}),
},
select: { id: true, displayName: true, eid: true, chapter: true },
orderBy: [{ chapter: "asc" }, { displayName: "asc" }],
});
const results = await Promise.all(
resources.map(async (r) => {
const entitlement = await syncEntitlement(ctx.db, r.id, input.year, defaultDays);
return {
resourceId: r.id,
displayName: r.displayName,
eid: r.eid,
chapter: r.chapter,
entitledDays: entitlement.entitledDays,
carryoverDays: entitlement.carryoverDays,
usedDays: entitlement.usedDays,
pendingDays: entitlement.pendingDays,
remainingDays: Math.max(
0,
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
),
};
}),
);
return results;
}),
});
+757
View File
@@ -0,0 +1,757 @@
import {
approveEstimateVersion,
cloneEstimate,
createEstimateExport,
createEstimate,
createEstimatePlanningHandoff,
createEstimateRevision,
getEstimateById,
listEstimates,
submitEstimateVersion,
updateEstimateDraft,
} from "@planarchy/application";
import type { Prisma } from "@planarchy/db";
import {
normalizeEstimateDemandLine,
summarizeEstimateDemandLines,
generateWeekRange,
distributeHoursToWeeks,
aggregateWeeklyToMonthly,
aggregateWeeklyByChapter,
} from "@planarchy/engine";
import {
ApproveEstimateVersionSchema,
CloneEstimateSchema,
CreateEstimateExportSchema,
CreateEstimatePlanningHandoffSchema,
CreateEstimateSchema,
CreateEstimateRevisionSchema,
EstimateListFiltersSchema,
GenerateWeeklyPhasingSchema,
PermissionKey,
SubmitEstimateVersionSchema,
UpdateEstimateDraftSchema,
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import {
controllerProcedure,
createTRPCRouter,
managerProcedure,
protectedProcedure,
requirePermission,
} from "../trpc.js";
import { emitAllocationCreated } from "../sse/event-bus.js";
function buildComputedMetrics(
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"],
) {
const summary = summarizeEstimateDemandLines(demandLines);
return [
{
key: "total_hours",
label: "Total Hours",
metricGroup: "summary",
valueDecimal: summary.totalHours,
metadata: {},
},
{
key: "total_cost",
label: "Total Cost",
metricGroup: "summary",
valueDecimal: summary.totalCostCents / 100,
valueCents: summary.totalCostCents,
currency: demandLines[0]?.currency ?? "EUR",
metadata: {},
},
{
key: "total_price",
label: "Total Price",
metricGroup: "summary",
valueDecimal: summary.totalPriceCents / 100,
valueCents: summary.totalPriceCents,
currency: demandLines[0]?.currency ?? "EUR",
metadata: {},
},
{
key: "margin",
label: "Margin",
metricGroup: "summary",
valueDecimal: summary.marginCents / 100,
valueCents: summary.marginCents,
currency: demandLines[0]?.currency ?? "EUR",
metadata: {},
},
{
key: "margin_percent",
label: "Margin %",
metricGroup: "summary",
valueDecimal: summary.marginPercent,
metadata: {},
},
];
}
function normalizeDemandLines<
T extends {
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"];
resourceSnapshots: z.infer<typeof CreateEstimateSchema>["resourceSnapshots"];
},
>(input: T, baseCurrency: string) {
const snapshotsByResourceId = new Map(
input.resourceSnapshots
.filter(
(snapshot): snapshot is (typeof input.resourceSnapshots)[number] & {
resourceId: string;
} => typeof snapshot.resourceId === "string" && snapshot.resourceId.length > 0,
)
.map((snapshot) => [snapshot.resourceId, snapshot]),
);
return input.demandLines.map((line) =>
normalizeEstimateDemandLine(line, {
resourceSnapshot:
line.resourceId != null ? snapshotsByResourceId.get(line.resourceId) : null,
defaultCurrency: baseCurrency,
}),
);
}
function withComputedMetrics<
T extends {
demandLines: z.infer<typeof CreateEstimateSchema>["demandLines"];
resourceSnapshots: z.infer<typeof CreateEstimateSchema>["resourceSnapshots"];
metrics: z.infer<typeof CreateEstimateSchema>["metrics"];
},
>(input: T, baseCurrency: string) {
const normalizedDemandLines = normalizeDemandLines(input, baseCurrency);
const computedMetrics = buildComputedMetrics(normalizedDemandLines);
const computedKeys = new Set(computedMetrics.map((metric) => metric.key));
return {
...input,
demandLines: normalizedDemandLines,
metrics: [
...input.metrics.filter((metric) => !computedKeys.has(metric.key)),
...computedMetrics,
],
};
}
export const estimateRouter = createTRPCRouter({
list: protectedProcedure
.input(EstimateListFiltersSchema.default({}))
.query(async ({ ctx, input }) =>
listEstimates(
ctx.db as unknown as Parameters<typeof listEstimates>[0],
input,
)),
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const estimate = await getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.id,
);
if (!estimate) {
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
}
return estimate;
}),
create: managerProcedure
.input(CreateEstimateSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
if (input.projectId) {
const project = await ctx.db.project.findUnique({
where: { id: input.projectId },
select: { id: true },
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
}
const estimate = await createEstimate(
ctx.db as unknown as Parameters<typeof createEstimate>[0],
withComputedMetrics(input, input.baseCurrency),
);
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "CREATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
name: estimate.name,
status: estimate.status,
projectId: estimate.projectId,
latestVersionNumber: estimate.latestVersionNumber,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
clone: managerProcedure
.input(CloneEstimateSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await cloneEstimate(
ctx.db as unknown as Parameters<typeof cloneEstimate>[0],
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Source estimate not found" ||
error.message === "Source estimate has no versions"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
}
throw error;
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "CREATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
name: estimate.name,
clonedFrom: input.sourceEstimateId,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
updateDraft: managerProcedure
.input(UpdateEstimateDraftSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
if (input.projectId) {
const project = await ctx.db.project.findUnique({
where: { id: input.projectId },
select: { id: true },
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
}
let estimate;
try {
estimate = await updateEstimateDraft(
ctx.db as unknown as Parameters<typeof updateEstimateDraft>[0],
withComputedMetrics(input, input.baseCurrency ?? "EUR"),
);
} catch (error) {
if (error instanceof Error && error.message === "Estimate not found") {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error instanceof Error &&
error.message === "Estimate has no working version"
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
throw error;
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
name: estimate.name,
status: estimate.status,
latestVersionNumber: estimate.latestVersionNumber,
workingVersionId: estimate.versions.find(
(version) => version.status === "WORKING",
)?.id,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
submitVersion: managerProcedure
.input(SubmitEstimateVersionSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await submitEstimateVersion(
ctx.db as unknown as Parameters<typeof submitEstimateVersion>[0],
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error.message === "Estimate has no working version" ||
error.message === "Only working versions can be submitted"
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
}
throw error;
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
status: estimate.status,
submittedVersionId: estimate.versions.find(
(version) => version.status === "SUBMITTED",
)?.id,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
approveVersion: managerProcedure
.input(ApproveEstimateVersionSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await approveEstimateVersion(
ctx.db as unknown as Parameters<typeof approveEstimateVersion>[0],
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error.message === "Estimate has no submitted version" ||
error.message === "Only submitted versions can be approved"
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
}
throw error;
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
status: estimate.status,
approvedVersionId: estimate.versions.find(
(version) => version.status === "APPROVED",
)?.id,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
createRevision: managerProcedure
.input(CreateEstimateRevisionSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await createEstimateRevision(
ctx.db as unknown as Parameters<typeof createEstimateRevision>[0],
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error.message === "Estimate already has a working version" ||
error.message === "Estimate has no locked version to revise" ||
error.message === "Source version must be locked before creating a revision"
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
}
throw error;
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
status: estimate.status,
latestVersionNumber: estimate.latestVersionNumber,
workingVersionId: estimate.versions.find(
(version) => version.status === "WORKING",
)?.id,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
createExport: managerProcedure
.input(CreateEstimateExportSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
let estimate;
try {
estimate = await createEstimateExport(
ctx.db as unknown as Parameters<typeof createEstimateExport>[0],
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found" ||
error.message === "Estimate has no version to export"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
}
throw error;
}
const exportedVersion = input.versionId
? estimate.versions.find((version) => version.id === input.versionId)
: estimate.versions[0];
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
id: estimate.id,
exportFormat: input.format,
exportCount: exportedVersion?.exports.length ?? null,
versionId: exportedVersion?.id ?? null,
},
} as Prisma.InputJsonValue,
},
});
return estimate;
}),
createPlanningHandoff: managerProcedure
.input(CreateEstimatePlanningHandoffSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
let result;
try {
result = await createEstimatePlanningHandoff(
ctx.db as unknown as Parameters<typeof createEstimatePlanningHandoff>[0],
input,
);
} catch (error) {
if (error instanceof Error) {
if (
error.message === "Estimate not found" ||
error.message === "Estimate version not found" ||
error.message === "Linked project not found"
) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
if (
error.message === "Estimate has no approved version" ||
error.message === "Only approved versions can be handed off to planning" ||
error.message === "Estimate must be linked to a project before planning handoff" ||
error.message === "Planning handoff already exists for this approved version" ||
error.message === "Linked project has an invalid date range" ||
error.message.startsWith("Project window has no working days for demand line")
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: error.message,
});
}
}
throw error;
}
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: result.estimateId,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
planningHandoff: {
versionId: result.estimateVersionId,
versionNumber: result.estimateVersionNumber,
projectId: result.projectId,
createdCount: result.createdCount,
assignedCount: result.assignedCount,
placeholderCount: result.placeholderCount,
fallbackPlaceholderCount: result.fallbackPlaceholderCount,
},
},
} as Prisma.InputJsonValue,
},
});
for (const allocation of result.allocations) {
emitAllocationCreated({
id: allocation.id,
projectId: allocation.projectId,
resourceId: allocation.resourceId ?? null,
});
}
return result;
}),
generateWeeklyPhasing: managerProcedure
.input(GenerateWeeklyPhasingSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const estimate = await getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.estimateId,
);
if (!estimate) {
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
}
const workingVersion = estimate.versions.find(
(v) => v.status === "WORKING",
);
if (!workingVersion) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Estimate has no working version",
});
}
const pattern = input.pattern ?? "even";
// Distribute hours for each demand line and update DB
const updates: Array<{ id: string; monthlySpread: Record<string, number>; metadata: Record<string, unknown> }> = [];
for (const line of workingVersion.demandLines) {
const result = distributeHoursToWeeks({
totalHours: line.hours,
startDate: input.startDate,
endDate: input.endDate,
pattern,
});
const monthlySpread = aggregateWeeklyToMonthly(result.weeklyHours);
const existingMetadata = (line.metadata ?? {}) as Record<string, unknown>;
const metadata = {
...existingMetadata,
weeklyPhasing: {
startDate: input.startDate,
endDate: input.endDate,
pattern,
weeklyHours: result.weeklyHours,
generatedAt: new Date().toISOString(),
},
};
updates.push({ id: line.id, monthlySpread, metadata });
}
// Batch update all demand lines
await Promise.all(
updates.map((update) =>
ctx.db.estimateDemandLine.update({
where: { id: update.id },
data: {
monthlySpread: update.monthlySpread as Prisma.InputJsonValue,
metadata: update.metadata as Prisma.InputJsonValue,
},
}),
),
);
return {
estimateId: input.estimateId,
versionId: workingVersion.id,
linesUpdated: updates.length,
startDate: input.startDate,
endDate: input.endDate,
pattern,
};
}),
getWeeklyPhasing: controllerProcedure
.input(z.object({ estimateId: z.string() }))
.query(async ({ ctx, input }) => {
const estimate = await getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.estimateId,
);
if (!estimate) {
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
}
// Get the latest version (first in the sorted array)
const version = estimate.versions[0];
if (!version) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Estimate has no versions",
});
}
// Extract weekly phasing from each demand line's metadata
type WeeklyPhasingMeta = {
startDate: string;
endDate: string;
pattern: string;
weeklyHours: Record<string, number>;
generatedAt: string;
};
const linesWithPhasing: Array<{
id: string;
name: string;
chapter: string | null;
hours: number;
weeklyHours: Record<string, number>;
}> = [];
let phasingConfig: { startDate: string; endDate: string; pattern: string } | null = null;
for (const line of version.demandLines) {
const meta = (line.metadata ?? {}) as Record<string, unknown>;
const phasing = meta["weeklyPhasing"] as WeeklyPhasingMeta | undefined;
if (phasing) {
if (!phasingConfig) {
phasingConfig = {
startDate: phasing.startDate,
endDate: phasing.endDate,
pattern: phasing.pattern,
};
}
linesWithPhasing.push({
id: line.id,
name: line.name,
chapter: line.chapter ?? null,
hours: line.hours,
weeklyHours: phasing.weeklyHours,
});
}
}
if (!phasingConfig || linesWithPhasing.length === 0) {
return {
estimateId: input.estimateId,
versionId: version.id,
hasPhasing: false as const,
config: null,
weeks: [],
lines: [],
chapterAggregation: {},
};
}
const weeks = generateWeekRange(phasingConfig.startDate, phasingConfig.endDate);
const chapterAggregation = aggregateWeeklyByChapter(linesWithPhasing);
return {
estimateId: input.estimateId,
versionId: version.id,
hasPhasing: true as const,
config: phasingConfig,
weeks,
lines: linesWithPhasing,
chapterAggregation,
};
}),
});
@@ -0,0 +1,343 @@
import {
applyExperienceMultipliers,
applyExperienceMultipliersBatch,
type ExperienceMultiplierRule as EngineRule,
} from "@planarchy/engine";
import {
CreateExperienceMultiplierSetSchema,
UpdateExperienceMultiplierSetSchema,
ApplyExperienceMultipliersSchema,
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
const ruleInclude = {
rules: { orderBy: { sortOrder: "asc" as const } },
} as const;
function toEngineRules(
dbRules: Array<{
chapter: string | null;
location: string | null;
level: string | null;
costMultiplier: number;
billMultiplier: number;
shoringRatio: number | null;
additionalEffortRatio: number | null;
description: string | null;
}>,
): EngineRule[] {
return dbRules.map((r) => ({
...(r.chapter != null ? { chapter: r.chapter } : {}),
...(r.location != null ? { location: r.location } : {}),
...(r.level != null ? { level: r.level } : {}),
costMultiplier: r.costMultiplier,
billMultiplier: r.billMultiplier,
...(r.shoringRatio != null ? { shoringRatio: r.shoringRatio } : {}),
...(r.additionalEffortRatio != null ? { additionalEffortRatio: r.additionalEffortRatio } : {}),
...(r.description != null ? { description: r.description } : {}),
}));
}
export const experienceMultiplierRouter = createTRPCRouter({
list: controllerProcedure.query(async ({ ctx }) => {
return ctx.db.experienceMultiplierSet.findMany({
include: ruleInclude,
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
});
}),
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const set = await ctx.db.experienceMultiplierSet.findUnique({
where: { id: input.id },
include: ruleInclude,
});
if (!set) throw new TRPCError({ code: "NOT_FOUND", message: "Experience multiplier set not found" });
return set;
}),
create: managerProcedure
.input(CreateExperienceMultiplierSetSchema)
.mutation(async ({ ctx, input }) => {
if (input.isDefault) {
await ctx.db.experienceMultiplierSet.updateMany({
where: { isDefault: true },
data: { isDefault: false },
});
}
return ctx.db.experienceMultiplierSet.create({
data: {
name: input.name,
...(input.description ? { description: input.description } : {}),
isDefault: input.isDefault,
rules: {
create: input.rules.map((r, i) => ({
...(r.chapter ? { chapter: r.chapter } : {}),
...(r.location ? { location: r.location } : {}),
...(r.level ? { level: r.level } : {}),
costMultiplier: r.costMultiplier,
billMultiplier: r.billMultiplier,
...(r.shoringRatio !== undefined ? { shoringRatio: r.shoringRatio } : {}),
...(r.additionalEffortRatio !== undefined ? { additionalEffortRatio: r.additionalEffortRatio } : {}),
...(r.description ? { description: r.description } : {}),
sortOrder: r.sortOrder ?? i,
})),
},
},
include: ruleInclude,
});
}),
update: managerProcedure
.input(UpdateExperienceMultiplierSetSchema)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Experience multiplier set not found" });
if (input.isDefault) {
await ctx.db.experienceMultiplierSet.updateMany({
where: { isDefault: true, id: { not: input.id } },
data: { isDefault: false },
});
}
if (input.rules) {
await ctx.db.experienceMultiplierRule.deleteMany({ where: { multiplierSetId: input.id } });
await ctx.db.experienceMultiplierRule.createMany({
data: input.rules.map((r, i) => ({
multiplierSetId: input.id,
...(r.chapter ? { chapter: r.chapter } : {}),
...(r.location ? { location: r.location } : {}),
...(r.level ? { level: r.level } : {}),
costMultiplier: r.costMultiplier,
billMultiplier: r.billMultiplier,
...(r.shoringRatio !== undefined ? { shoringRatio: r.shoringRatio } : {}),
...(r.additionalEffortRatio !== undefined ? { additionalEffortRatio: r.additionalEffortRatio } : {}),
...(r.description ? { description: r.description } : {}),
sortOrder: r.sortOrder ?? i,
})),
});
}
return ctx.db.experienceMultiplierSet.update({
where: { id: input.id },
data: {
...(input.name !== undefined ? { name: input.name } : {}),
...(input.description !== undefined ? { description: input.description } : {}),
...(input.isDefault !== undefined ? { isDefault: input.isDefault } : {}),
},
include: ruleInclude,
});
}),
delete: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Experience multiplier set not found" });
await ctx.db.experienceMultiplierSet.delete({ where: { id: input.id } });
return { id: input.id };
}),
/** Preview the rate adjustment without persisting */
preview: controllerProcedure
.input(z.object({
estimateId: z.string(),
multiplierSetId: z.string(),
}))
.query(async ({ ctx, input }) => {
const [estimate, multiplierSet] = await Promise.all([
ctx.db.estimate.findUnique({
where: { id: input.estimateId },
include: {
versions: {
orderBy: { versionNumber: "desc" },
take: 1,
include: { demandLines: { orderBy: { createdAt: "asc" } } },
},
},
}),
ctx.db.experienceMultiplierSet.findUnique({
where: { id: input.multiplierSetId },
include: ruleInclude,
}),
]);
if (!estimate) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
if (!multiplierSet) throw new TRPCError({ code: "NOT_FOUND", message: "Experience multiplier set not found" });
const version = estimate.versions[0];
if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" });
const engineRules = toEngineRules(multiplierSet.rules);
const demandLines = version.demandLines;
const previews = demandLines.map((line) => {
const result = applyExperienceMultipliers(
{
costRateCents: line.costRateCents,
billRateCents: line.billRateCents,
hours: line.hours,
...(line.chapter != null ? { chapter: line.chapter } : {}),
...(line.metadata != null && typeof line.metadata === "object" && "location" in (line.metadata as Record<string, unknown>)
? { location: (line.metadata as Record<string, unknown>).location as string }
: {}),
...(line.staffingAttributes != null && typeof line.staffingAttributes === "object" && "level" in (line.staffingAttributes as Record<string, unknown>)
? { level: (line.staffingAttributes as Record<string, unknown>).level as string }
: {}),
},
engineRules,
);
return {
demandLineId: line.id,
name: line.name,
chapter: line.chapter,
originalCostRateCents: line.costRateCents,
originalBillRateCents: line.billRateCents,
originalHours: line.hours,
adjustedCostRateCents: result.adjustedCostRateCents,
adjustedBillRateCents: result.adjustedBillRateCents,
adjustedHours: result.adjustedHours,
appliedRules: result.appliedRules,
hasChanges:
result.adjustedCostRateCents !== line.costRateCents ||
result.adjustedBillRateCents !== line.billRateCents ||
result.adjustedHours !== line.hours,
};
});
const linesChanged = previews.filter((p) => p.hasChanges).length;
const totalOriginalCostCents = demandLines.reduce((s, l) => s + l.costRateCents * l.hours, 0);
const totalAdjustedCostCents = previews.reduce((s, p) => s + p.adjustedCostRateCents * p.adjustedHours, 0);
return {
previews,
demandLineCount: demandLines.length,
linesChanged,
totalOriginalCostCents: Math.round(totalOriginalCostCents),
totalAdjustedCostCents: Math.round(totalAdjustedCostCents),
multiplierSetName: multiplierSet.name,
ruleCount: multiplierSet.rules.length,
};
}),
/** Apply multipliers to demand lines on the working version */
apply: managerProcedure
.input(ApplyExperienceMultipliersSchema)
.mutation(async ({ ctx, input }) => {
const [estimate, multiplierSet] = await Promise.all([
ctx.db.estimate.findUnique({
where: { id: input.estimateId },
include: {
versions: {
orderBy: { versionNumber: "desc" },
take: 1,
include: { demandLines: true },
},
},
}),
ctx.db.experienceMultiplierSet.findUnique({
where: { id: input.multiplierSetId },
include: ruleInclude,
}),
]);
if (!estimate) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
if (!multiplierSet) throw new TRPCError({ code: "NOT_FOUND", message: "Experience multiplier set not found" });
const version = estimate.versions[0];
if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" });
if (version.status !== "WORKING") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Can only apply multipliers to a WORKING version" });
}
const engineRules = toEngineRules(multiplierSet.rules);
const demandLines = version.demandLines;
const inputs = demandLines.map((line) => ({
costRateCents: line.costRateCents,
billRateCents: line.billRateCents,
hours: line.hours,
...(line.chapter != null ? { chapter: line.chapter } : {}),
...(line.metadata != null && typeof line.metadata === "object" && "location" in (line.metadata as Record<string, unknown>)
? { location: (line.metadata as Record<string, unknown>).location as string }
: {}),
...(line.staffingAttributes != null && typeof line.staffingAttributes === "object" && "level" in (line.staffingAttributes as Record<string, unknown>)
? { level: (line.staffingAttributes as Record<string, unknown>).level as string }
: {}),
}));
const batch = applyExperienceMultipliersBatch(inputs, engineRules);
// Update each demand line that changed
let updatedCount = 0;
for (let i = 0; i < demandLines.length; i++) {
const line = demandLines[i]!;
const result = batch.results[i]!;
if (
result.adjustedCostRateCents !== line.costRateCents ||
result.adjustedBillRateCents !== line.billRateCents ||
result.adjustedHours !== line.hours
) {
const newCostTotal = Math.round(result.adjustedCostRateCents * result.adjustedHours);
const newPriceTotal = Math.round(result.adjustedBillRateCents * result.adjustedHours);
await ctx.db.estimateDemandLine.update({
where: { id: line.id },
data: {
costRateCents: result.adjustedCostRateCents,
billRateCents: result.adjustedBillRateCents,
hours: result.adjustedHours,
costTotalCents: newCostTotal,
priceTotalCents: newPriceTotal,
metadata: {
...(typeof line.metadata === "object" && line.metadata !== null ? line.metadata as Record<string, unknown> : {}),
experienceMultiplier: {
setId: multiplierSet.id,
setName: multiplierSet.name,
appliedRules: result.appliedRules,
originalCostRateCents: line.costRateCents,
originalBillRateCents: line.billRateCents,
originalHours: line.hours,
},
},
},
});
updatedCount++;
}
}
// Audit log
await ctx.db.auditLog.create({
data: {
entityType: "Estimate",
entityId: estimate.id,
action: "UPDATE",
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
changes: {
after: {
experienceMultipliersApplied: {
setId: multiplierSet.id,
setName: multiplierSet.name,
linesUpdated: updatedCount,
totalOriginalHours: batch.totalOriginalHours,
totalAdjustedHours: batch.totalAdjustedHours,
},
},
},
},
});
return {
linesUpdated: updatedCount,
totalOriginalHours: batch.totalOriginalHours,
totalAdjustedHours: batch.totalAdjustedHours,
};
}),
});
+157
View File
@@ -0,0 +1,157 @@
import { BlueprintTarget, PermissionKey } from "@planarchy/shared";
import type { BlueprintFieldDefinition } from "@planarchy/shared";
import { z } from "zod";
import { controllerProcedure, createTRPCRouter, managerProcedure, requirePermission } from "../trpc.js";
export const importExportRouter = createTRPCRouter({
/**
* Export resources as CSV.
*/
exportResourcesCSV: controllerProcedure.query(async ({ ctx }) => {
const [resources, globalBlueprints] = await Promise.all([
ctx.db.resource.findMany({
where: { isActive: true },
orderBy: { eid: "asc" },
}),
ctx.db.blueprint.findMany({
where: { target: BlueprintTarget.RESOURCE, isGlobal: true, isActive: true },
select: { fieldDefs: true },
}),
]);
// Collect all custom field defs that should appear in exports (showInList = true)
const customDefs = globalBlueprints
.flatMap((b) => b.fieldDefs as unknown as BlueprintFieldDefinition[])
.filter((f) => f.showInList);
function escapeCSV(v: unknown): string {
const s = v === null || v === undefined ? "" : String(v);
return s.includes(",") || s.includes('"') || s.includes("\n")
? `"${s.replace(/"/g, '""')}"`
: s;
}
const builtinHeaders = ["eid", "displayName", "email", "chapter", "lcrCents", "ucrCents", "currency", "chargeabilityTarget"];
const customHeaders = customDefs.map((f) => f.label);
const headers = [...builtinHeaders, ...customHeaders];
const rows = resources.map((r) => {
const df = r.dynamicFields as unknown as Record<string, unknown> ?? {};
const builtins = [r.eid, r.displayName, r.email, r.chapter ?? "", r.lcrCents, r.ucrCents, r.currency, r.chargeabilityTarget];
const customs = customDefs.map((f) => df[f.key] ?? "");
return [...builtins, ...customs].map(escapeCSV).join(",");
});
return [headers.map(escapeCSV).join(","), ...rows].join("\n");
}),
/**
* Export projects as CSV.
*/
exportProjectsCSV: controllerProcedure.query(async ({ ctx }) => {
const [projects, globalBlueprints] = await Promise.all([
ctx.db.project.findMany({ orderBy: { shortCode: "asc" } }),
ctx.db.blueprint.findMany({
where: { target: BlueprintTarget.PROJECT, isGlobal: true, isActive: true },
select: { fieldDefs: true },
}),
]);
const customDefs = globalBlueprints
.flatMap((b) => b.fieldDefs as unknown as BlueprintFieldDefinition[])
.filter((f) => f.showInList);
function escapeCSV(v: unknown): string {
const s = v === null || v === undefined ? "" : String(v);
return s.includes(",") || s.includes('"') || s.includes("\n")
? `"${s.replace(/"/g, '""')}"`
: s;
}
const builtinHeaders = ["shortCode", "name", "orderType", "status", "budgetCents", "startDate", "endDate", "winProbability"];
const headers = [...builtinHeaders, ...customDefs.map((f) => f.label)];
const rows = projects.map((p) => {
const df = p.dynamicFields as unknown as Record<string, unknown> ?? {};
const builtins = [
p.shortCode, p.name, p.orderType, p.status, p.budgetCents,
p.startDate.toISOString().split("T")[0],
p.endDate.toISOString().split("T")[0],
p.winProbability,
];
return [...builtins, ...customDefs.map((f) => df[f.key] ?? "")].map(escapeCSV).join(",");
});
return [headers.map(escapeCSV).join(","), ...rows].join("\n");
}),
/**
* Import resources from CSV data (parsed client-side).
*/
importCSV: managerProcedure
.input(
z.object({
entityType: z.enum(["resources", "projects", "allocations"]),
rows: z.array(z.record(z.string(), z.string())),
dryRun: z.boolean().default(true),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.IMPORT_DATA);
const { entityType, rows, dryRun } = input;
const results = {
total: rows.length,
created: 0,
updated: 0,
errors: [] as { row: number; message: string }[],
dryRun,
};
if (dryRun) {
// Validate without committing
return { ...results, message: `Dry run: ${rows.length} rows validated` };
}
// Basic import logic per entity type
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row) continue;
try {
if (entityType === "resources") {
const existing = await ctx.db.resource.findFirst({
where: { eid: row["eid"] ?? "" },
});
if (existing) {
await ctx.db.resource.update({
where: { id: existing.id },
data: {
displayName: row["displayName"] ?? existing.displayName,
email: row["email"] ?? existing.email,
chapter: row["chapter"] ?? existing.chapter,
lcrCents: row["lcrCents"] ? parseInt(row["lcrCents"]) : existing.lcrCents,
},
});
results.updated++;
} else {
results.errors.push({ row: i + 1, message: "New resource creation via import requires full data" });
}
}
} catch (err) {
results.errors.push({ row: i + 1, message: err instanceof Error ? err.message : "Unknown error" });
}
}
await ctx.db.auditLog.create({
data: {
entityType: entityType,
entityId: "bulk-import",
action: "IMPORT",
changes: { summary: results },
},
});
return results;
}),
});
+54
View File
@@ -0,0 +1,54 @@
import { createTRPCRouter } from "../trpc.js";
import { allocationRouter } from "./allocation.js";
import { blueprintRouter } from "./blueprint.js";
import { chargeabilityReportRouter } from "./chargeability-report.js";
import { clientRouter } from "./client.js";
import { countryRouter } from "./country.js";
import { dashboardRouter } from "./dashboard.js";
import { effortRuleRouter } from "./effort-rule.js";
import { experienceMultiplierRouter } from "./experience-multiplier.js";
import { estimateRouter } from "./estimate.js";
import { entitlementRouter } from "./entitlement.js";
import { importExportRouter } from "./import-export.js";
import { managementLevelRouter } from "./management-level.js";
import { notificationRouter } from "./notification.js";
import { orgUnitRouter } from "./org-unit.js";
import { projectRouter } from "./project.js";
import { rateCardRouter } from "./rate-card.js";
import { resourceRouter } from "./resource.js";
import { roleRouter } from "./role.js";
import { settingsRouter } from "./settings.js";
import { staffingRouter } from "./staffing.js";
import { timelineRouter } from "./timeline.js";
import { userRouter } from "./user.js";
import { utilizationCategoryRouter } from "./utilization-category.js";
import { vacationRouter } from "./vacation.js";
export const appRouter = createTRPCRouter({
dashboard: dashboardRouter,
effortRule: effortRuleRouter,
experienceMultiplier: experienceMultiplierRouter,
estimate: estimateRouter,
resource: resourceRouter,
project: projectRouter,
allocation: allocationRouter,
timeline: timelineRouter,
staffing: staffingRouter,
blueprint: blueprintRouter,
role: roleRouter,
user: userRouter,
importExport: importExportRouter,
vacation: vacationRouter,
entitlement: entitlementRouter,
notification: notificationRouter,
settings: settingsRouter,
country: countryRouter,
orgUnit: orgUnitRouter,
utilizationCategory: utilizationCategoryRouter,
clientEntity: clientRouter,
managementLevel: managementLevelRouter,
rateCard: rateCardRouter,
chargeabilityReport: chargeabilityReportRouter,
});
export type AppRouter = typeof appRouter;
+133
View File
@@ -0,0 +1,133 @@
import {
CreateManagementLevelGroupSchema,
CreateManagementLevelSchema,
UpdateManagementLevelGroupSchema,
UpdateManagementLevelSchema,
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
export const managementLevelRouter = createTRPCRouter({
// ─── Groups ─────────────────────────────────────────────
listGroups: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.managementLevelGroup.findMany({
include: { levels: { orderBy: { name: "asc" } } },
orderBy: { sortOrder: "asc" },
});
}),
getGroupById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const group = await ctx.db.managementLevelGroup.findUnique({
where: { id: input.id },
include: {
levels: { orderBy: { name: "asc" } },
_count: { select: { resources: true } },
},
});
if (!group) throw new TRPCError({ code: "NOT_FOUND", message: "Management level group not found" });
return group;
}),
createGroup: adminProcedure
.input(CreateManagementLevelGroupSchema)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.managementLevelGroup.findUnique({ where: { name: input.name } });
if (existing) {
throw new TRPCError({ code: "CONFLICT", message: `Group "${input.name}" already exists` });
}
return ctx.db.managementLevelGroup.create({
data: {
name: input.name,
targetPercentage: input.targetPercentage,
sortOrder: input.sortOrder,
},
include: { levels: true },
});
}),
updateGroup: adminProcedure
.input(z.object({ id: z.string(), data: UpdateManagementLevelGroupSchema }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.managementLevelGroup.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Group not found" });
if (input.data.name && input.data.name !== existing.name) {
const conflict = await ctx.db.managementLevelGroup.findUnique({ where: { name: input.data.name } });
if (conflict) {
throw new TRPCError({ code: "CONFLICT", message: `Group "${input.data.name}" already exists` });
}
}
return ctx.db.managementLevelGroup.update({
where: { id: input.id },
data: {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.targetPercentage !== undefined ? { targetPercentage: input.data.targetPercentage } : {}),
...(input.data.sortOrder !== undefined ? { sortOrder: input.data.sortOrder } : {}),
},
include: { levels: true },
});
}),
// ─── Levels ─────────────────────────────────────────────
createLevel: adminProcedure
.input(CreateManagementLevelSchema)
.mutation(async ({ ctx, input }) => {
const group = await ctx.db.managementLevelGroup.findUnique({ where: { id: input.groupId } });
if (!group) throw new TRPCError({ code: "NOT_FOUND", message: "Group not found" });
const existing = await ctx.db.managementLevel.findUnique({ where: { name: input.name } });
if (existing) {
throw new TRPCError({ code: "CONFLICT", message: `Level "${input.name}" already exists` });
}
return ctx.db.managementLevel.create({
data: { name: input.name, groupId: input.groupId },
});
}),
updateLevel: adminProcedure
.input(z.object({ id: z.string(), data: UpdateManagementLevelSchema }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.managementLevel.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Level not found" });
if (input.data.name && input.data.name !== existing.name) {
const conflict = await ctx.db.managementLevel.findUnique({ where: { name: input.data.name } });
if (conflict) {
throw new TRPCError({ code: "CONFLICT", message: `Level "${input.data.name}" already exists` });
}
}
return ctx.db.managementLevel.update({
where: { id: input.id },
data: {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.groupId !== undefined ? { groupId: input.data.groupId } : {}),
},
});
}),
deleteLevel: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const level = await ctx.db.managementLevel.findUnique({
where: { id: input.id },
include: { _count: { select: { resources: true } } },
});
if (!level) throw new TRPCError({ code: "NOT_FOUND", message: "Level not found" });
if (level._count.resources > 0) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Cannot delete level assigned to ${level._count.resources} resource(s)`,
});
}
await ctx.db.managementLevel.delete({ where: { id: input.id } });
return { success: true };
}),
});
+92
View File
@@ -0,0 +1,92 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js";
import { emitNotificationCreated } from "../sse/event-bus.js";
/** Resolve the DB user id from the session email. Throws UNAUTHORIZED if not found. */
async function resolveUserId(ctx: {
db: { user: { findUnique: (args: { where: { email: string }; select: { id: true } }) => Promise<{ id: string } | null> } };
session: { user?: { email?: string | null } | null };
}): Promise<string> {
const email = ctx.session.user?.email;
if (!email) throw new TRPCError({ code: "UNAUTHORIZED" });
const user = await ctx.db.user.findUnique({ where: { email }, select: { id: true } });
if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });
return user.id;
}
export const notificationRouter = createTRPCRouter({
/** List notifications for the current user */
list: protectedProcedure
.input(
z.object({
unreadOnly: z.boolean().optional(),
limit: z.number().min(1).max(100).default(50),
}),
)
.query(async ({ ctx, input }) => {
const userId = await resolveUserId(ctx);
return ctx.db.notification.findMany({
where: {
userId,
...(input.unreadOnly ? { readAt: null } : {}),
},
orderBy: { createdAt: "desc" },
take: input.limit,
});
}),
/** Count unread notifications */
unreadCount: protectedProcedure.query(async ({ ctx }) => {
const userId = await resolveUserId(ctx);
return ctx.db.notification.count({
where: { userId, readAt: null },
});
}),
/** Mark one or all as read */
markRead: protectedProcedure
.input(z.object({ id: z.string().optional() }))
.mutation(async ({ ctx, input }) => {
const userId = await resolveUserId(ctx);
const now = new Date();
if (input.id) {
await ctx.db.notification.update({
where: { id: input.id, userId },
data: { readAt: now },
});
} else {
await ctx.db.notification.updateMany({
where: { userId, readAt: null },
data: { readAt: now },
});
}
}),
/** Create a notification — restricted to managers and admins */
create: managerProcedure
.input(
z.object({
userId: z.string(),
type: z.string(),
title: z.string(),
body: z.string().optional(),
entityId: z.string().optional(),
entityType: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const n = await ctx.db.notification.create({
data: {
userId: input.userId,
type: input.type,
title: input.title,
...(input.body !== undefined ? { body: input.body } : {}),
...(input.entityId !== undefined ? { entityId: input.entityId } : {}),
...(input.entityType !== undefined ? { entityType: input.entityType } : {}),
},
});
emitNotificationCreated(input.userId, n.id);
return n;
}),
});
+128
View File
@@ -0,0 +1,128 @@
import { CreateOrgUnitSchema, UpdateOrgUnitSchema } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
import type { OrgUnitTree } from "@planarchy/shared";
interface FlatOrgUnit {
id: string;
name: string;
shortName: string | null;
level: number;
parentId: string | null;
sortOrder: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
function buildTree(flatItems: FlatOrgUnit[], parentId: string | null = null): OrgUnitTree[] {
return flatItems
.filter((item) => item.parentId === parentId)
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
.map((item) => ({
...item,
children: buildTree(flatItems, item.id),
}));
}
export const orgUnitRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
level: z.number().int().min(5).max(7).optional(),
parentId: z.string().optional(),
isActive: z.boolean().optional(),
}).optional(),
)
.query(async ({ ctx, input }) => {
return ctx.db.orgUnit.findMany({
where: {
...(input?.level !== undefined ? { level: input.level } : {}),
...(input?.parentId !== undefined ? { parentId: input.parentId } : {}),
...(input?.isActive !== undefined ? { isActive: input.isActive } : {}),
},
orderBy: [{ level: "asc" }, { sortOrder: "asc" }, { name: "asc" }],
});
}),
getTree: protectedProcedure
.input(z.object({ isActive: z.boolean().optional() }).optional())
.query(async ({ ctx, input }) => {
const all = await ctx.db.orgUnit.findMany({
where: {
...(input?.isActive !== undefined ? { isActive: input.isActive } : {}),
},
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
});
return buildTree(all);
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const unit = await ctx.db.orgUnit.findUnique({
where: { id: input.id },
include: {
parent: true,
children: { orderBy: { sortOrder: "asc" } },
_count: { select: { resources: true } },
},
});
if (!unit) throw new TRPCError({ code: "NOT_FOUND", message: "Org unit not found" });
return unit;
}),
create: adminProcedure
.input(CreateOrgUnitSchema)
.mutation(async ({ ctx, input }) => {
if (input.parentId) {
const parent = await ctx.db.orgUnit.findUnique({ where: { id: input.parentId } });
if (!parent) throw new TRPCError({ code: "NOT_FOUND", message: "Parent org unit not found" });
if (parent.level >= input.level) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Child level (${input.level}) must be greater than parent level (${parent.level})`,
});
}
}
return ctx.db.orgUnit.create({
data: {
name: input.name,
...(input.shortName !== undefined ? { shortName: input.shortName } : {}),
level: input.level,
...(input.parentId ? { parentId: input.parentId } : {}),
sortOrder: input.sortOrder,
},
});
}),
update: adminProcedure
.input(z.object({ id: z.string(), data: UpdateOrgUnitSchema }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.orgUnit.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Org unit not found" });
return ctx.db.orgUnit.update({
where: { id: input.id },
data: {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.shortName !== undefined ? { shortName: input.data.shortName } : {}),
...(input.data.sortOrder !== undefined ? { sortOrder: input.data.sortOrder } : {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
...(input.data.parentId !== undefined ? { parentId: input.data.parentId } : {}),
},
});
}),
deactivate: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.orgUnit.update({
where: { id: input.id },
data: { isActive: false },
});
}),
});
@@ -0,0 +1,92 @@
import { buildSplitAllocationReadModel } from "@planarchy/application";
import type { PrismaClient } from "@planarchy/db";
import { AllocationStatus } from "@planarchy/shared";
export const PROJECT_PLANNING_ALLOCATION_INCLUDE = {
resource: {
select: {
id: true,
displayName: true,
eid: true,
chapter: true,
lcrCents: true,
availability: true,
},
},
project: {
select: {
id: true,
name: true,
shortCode: true,
orderType: true,
budgetCents: true,
winProbability: true,
status: true,
startDate: true,
endDate: true,
staffingReqs: true,
responsiblePerson: true,
},
},
roleEntity: {
select: { id: true, name: true, color: true },
},
} as const;
export const PROJECT_PLANNING_DEMAND_INCLUDE = {
project: PROJECT_PLANNING_ALLOCATION_INCLUDE.project,
roleEntity: PROJECT_PLANNING_ALLOCATION_INCLUDE.roleEntity,
} as const;
export const PROJECT_PLANNING_ASSIGNMENT_INCLUDE = {
resource: PROJECT_PLANNING_ALLOCATION_INCLUDE.resource,
project: PROJECT_PLANNING_ALLOCATION_INCLUDE.project,
roleEntity: PROJECT_PLANNING_ALLOCATION_INCLUDE.roleEntity,
} as const;
type ProjectPlanningReadDbClient = Pick<
PrismaClient,
"demandRequirement" | "assignment"
>;
export interface LoadProjectPlanningReadModelInput {
projectId: string;
activeOnly?: boolean;
}
export async function loadProjectPlanningReadModel(
db: ProjectPlanningReadDbClient,
input: LoadProjectPlanningReadModelInput,
) {
const statusFilter = input.activeOnly
? { status: { not: AllocationStatus.CANCELLED } }
: {};
const [demandRequirements, assignments] = await Promise.all([
db.demandRequirement.findMany({
where: {
projectId: input.projectId,
...statusFilter,
},
include: PROJECT_PLANNING_DEMAND_INCLUDE,
orderBy: [{ startDate: "asc" }, { projectId: "asc" }],
}),
db.assignment.findMany({
where: {
projectId: input.projectId,
...statusFilter,
},
include: PROJECT_PLANNING_ASSIGNMENT_INCLUDE,
orderBy: [{ startDate: "asc" }, { resourceId: "asc" }],
}),
]);
return {
demandRequirements,
assignments,
readModel: buildSplitAllocationReadModel({
demandRequirements,
assignments,
}),
};
}
+316
View File
@@ -0,0 +1,316 @@
import {
countPlanningEntries,
listAssignmentBookings,
} from "@planarchy/application";
import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
import { controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
export const projectRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
status: z.nativeEnum(ProjectStatus).optional(),
search: z.string().optional(),
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(500).default(50),
// Cursor-based pagination (additive — page/limit still supported)
cursor: z.string().optional(),
// Custom field JSONB filters
customFieldFilters: z.array(z.object({
key: z.string(),
value: z.string(),
type: z.nativeEnum(FieldType),
})).optional(),
}),
)
.query(async ({ ctx, input }) => {
const { status, search, page, limit, cursor, customFieldFilters } = input;
const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = {
...(status ? { status } : {}),
...(search
? {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ shortCode: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
...(cfConditions.length > 0 ? { AND: cfConditions } : {}),
};
const skip = cursor ? 0 : (page - 1) * limit;
const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where;
const [rawProjects, total] = await Promise.all([
ctx.db.project.findMany({
where: whereWithCursor,
skip,
take: limit + 1,
orderBy: [{ startDate: "asc" }, { id: "asc" }],
}),
ctx.db.project.count({ where }),
]);
const hasMore = rawProjects.length > limit;
const projects = hasMore ? rawProjects.slice(0, limit) : rawProjects;
const nextCursor = hasMore ? projects[projects.length - 1]!.id : null;
const { countsByProjectId } = await countPlanningEntries(ctx.db, {
projectIds: projects.map((project) => project.id),
});
return {
projects: projects.map((project) => ({
...project,
_count: {
allocations: countsByProjectId.get(project.id) ?? 0,
},
})),
total,
page,
limit,
nextCursor,
};
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const [project, planningRead] = await Promise.all([
ctx.db.project.findUnique({
where: { id: input.id },
include: { blueprint: true },
}),
loadProjectPlanningReadModel(ctx.db, { projectId: input.id }),
]);
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
return {
...project,
allocations: planningRead.readModel.assignments,
demands: planningRead.readModel.demands,
assignments: planningRead.readModel.assignments,
};
}),
create: managerProcedure
.input(CreateProjectSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const existing = await ctx.db.project.findUnique({
where: { shortCode: input.shortCode },
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: `Project with short code "${input.shortCode}" already exists`,
});
}
await assertBlueprintDynamicFields({
db: ctx.db,
blueprintId: input.blueprintId,
dynamicFields: input.dynamicFields,
target: BlueprintTarget.PROJECT,
});
const project = await ctx.db.project.create({
data: {
shortCode: input.shortCode,
name: input.name,
orderType: input.orderType,
allocationType: input.allocationType,
winProbability: input.winProbability,
budgetCents: input.budgetCents,
startDate: input.startDate,
endDate: input.endDate,
status: input.status,
responsiblePerson: input.responsiblePerson,
staffingReqs: input.staffingReqs as unknown as import("@planarchy/db").Prisma.InputJsonValue,
dynamicFields: input.dynamicFields as unknown as import("@planarchy/db").Prisma.InputJsonValue,
blueprintId: input.blueprintId,
...(input.utilizationCategoryId !== undefined ? { utilizationCategoryId: input.utilizationCategoryId || null } : {}),
...(input.clientId !== undefined ? { clientId: input.clientId || null } : {}),
} as unknown as Parameters<typeof ctx.db.project.create>[0]["data"],
});
await ctx.db.auditLog.create({
data: {
entityType: "Project",
entityId: project.id,
action: "CREATE",
changes: { after: project },
},
});
return project;
}),
update: managerProcedure
.input(z.object({ id: z.string(), data: UpdateProjectSchema }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const existing = await ctx.db.project.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record<string, unknown>;
await assertBlueprintDynamicFields({
db: ctx.db,
blueprintId: nextBlueprintId,
dynamicFields: nextDynamicFields,
target: BlueprintTarget.PROJECT,
});
const updated = await ctx.db.project.update({
where: { id: input.id },
data: {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.orderType !== undefined ? { orderType: input.data.orderType } : {}),
...(input.data.allocationType !== undefined ? { allocationType: input.data.allocationType } : {}),
...(input.data.winProbability !== undefined ? { winProbability: input.data.winProbability } : {}),
...(input.data.budgetCents !== undefined ? { budgetCents: input.data.budgetCents } : {}),
...(input.data.startDate !== undefined ? { startDate: input.data.startDate } : {}),
...(input.data.endDate !== undefined ? { endDate: input.data.endDate } : {}),
...(input.data.status !== undefined ? { status: input.data.status } : {}),
...(input.data.responsiblePerson !== undefined ? { responsiblePerson: input.data.responsiblePerson } : {}),
...(input.data.staffingReqs !== undefined ? { staffingReqs: input.data.staffingReqs as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}),
...(input.data.utilizationCategoryId !== undefined ? { utilizationCategoryId: input.data.utilizationCategoryId || null } : {}),
...(input.data.clientId !== undefined ? { clientId: input.data.clientId || null } : {}),
} as unknown as Parameters<typeof ctx.db.project.update>[0]["data"],
});
await ctx.db.auditLog.create({
data: {
entityType: "Project",
entityId: input.id,
action: "UPDATE",
changes: { before: existing, after: updated },
},
});
return updated;
}),
updateStatus: managerProcedure
.input(z.object({ id: z.string(), status: z.nativeEnum(ProjectStatus) }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
return ctx.db.project.update({
where: { id: input.id },
data: { status: input.status },
});
}),
batchUpdateStatus: managerProcedure
.input(
z.object({
ids: z.array(z.string()).min(1).max(100),
status: z.nativeEnum(ProjectStatus),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const updated = await ctx.db.$transaction(
input.ids.map((id) =>
ctx.db.project.update({ where: { id }, data: { status: input.status } }),
),
);
await ctx.db.auditLog.create({
data: {
entityType: "Project",
entityId: input.ids.join(","),
action: "UPDATE",
changes: { after: { status: input.status, ids: input.ids } },
},
});
return { count: updated.length };
}),
listWithCosts: controllerProcedure
.input(
z.object({
status: z.nativeEnum(ProjectStatus).optional(),
search: z.string().optional(),
limit: z.number().int().min(1).max(500).default(50),
cursor: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const { status, search, limit, cursor } = input;
const where = {
...(status ? { status } : {}),
...(search
? {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ shortCode: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
};
const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where;
const rawProjects = await ctx.db.project.findMany({
where: whereWithCursor,
take: limit + 1,
orderBy: [{ startDate: "asc" }, { id: "asc" }],
});
const hasMore = rawProjects.length > limit;
const projectsRaw = hasMore ? rawProjects.slice(0, limit) : rawProjects;
const nextCursor = hasMore ? projectsRaw[projectsRaw.length - 1]!.id : null;
const projectIds = projectsRaw.map((project) => project.id);
const bookings = projectIds.length
? await listAssignmentBookings(ctx.db, {
startDate: new Date("1900-01-01T00:00:00.000Z"),
endDate: new Date("2100-12-31T23:59:59.999Z"),
projectIds,
})
: [];
// Compute cost + person days per project
const projects = projectsRaw.map((p) => {
const projectBookings = bookings.filter((booking) => booking.projectId === p.id);
let totalCostCents = 0;
let totalPersonDays = 0;
for (const a of projectBookings) {
const days =
(new Date(a.endDate).getTime() - new Date(a.startDate).getTime()) /
(1000 * 60 * 60 * 24) +
1;
totalCostCents += a.dailyCostCents * days;
totalPersonDays += (a.hoursPerDay * days) / 8;
}
const utilizationPercent = p.budgetCents > 0
? Math.round((totalCostCents / p.budgetCents) * 100)
: 0;
return {
...p,
totalCostCents: Math.round(totalCostCents),
totalPersonDays: Math.round(totalPersonDays * 10) / 10,
utilizationPercent,
};
});
return { projects, nextCursor };
}),
});
+316
View File
@@ -0,0 +1,316 @@
import type { Prisma } from "@planarchy/db";
import {
CreateRateCardLineSchema,
CreateRateCardSchema,
UpdateRateCardLineSchema,
UpdateRateCardSchema,
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
const lineSelect = {
id: true,
rateCardId: true,
roleId: true,
chapter: true,
location: true,
seniority: true,
workType: true,
serviceGroup: true,
costRateCents: true,
billRateCents: true,
machineRateCents: true,
attributes: true,
role: { select: { id: true, name: true, color: true } },
createdAt: true,
updatedAt: true,
} as const;
export const rateCardRouter = createTRPCRouter({
list: controllerProcedure
.input(
z.object({
isActive: z.boolean().optional(),
search: z.string().optional(),
clientId: z.string().optional(),
effectiveAt: z.coerce.date().optional(),
}).optional(),
)
.query(async ({ ctx, input }) => {
return ctx.db.rateCard.findMany({
where: {
...(input?.isActive !== undefined ? { isActive: input.isActive } : {}),
...(input?.clientId !== undefined ? { clientId: input.clientId } : {}),
...(input?.search
? { name: { contains: input.search, mode: "insensitive" as const } }
: {}),
...(input?.effectiveAt
? {
OR: [
{ effectiveFrom: null },
{ effectiveFrom: { lte: input.effectiveAt } },
],
AND: [
{
OR: [
{ effectiveTo: null },
{ effectiveTo: { gte: input.effectiveAt } },
],
},
],
}
: {}),
},
include: {
_count: { select: { lines: true } },
client: { select: { id: true, name: true, code: true } },
},
orderBy: [{ isActive: "desc" }, { effectiveFrom: "desc" }, { name: "asc" }],
});
}),
getById: controllerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const rateCard = await ctx.db.rateCard.findUnique({
where: { id: input.id },
include: {
client: { select: { id: true, name: true, code: true } },
lines: {
select: lineSelect,
orderBy: [{ chapter: "asc" }, { seniority: "asc" }, { createdAt: "asc" }],
},
},
});
if (!rateCard) throw new TRPCError({ code: "NOT_FOUND", message: "Rate card not found" });
return rateCard;
}),
create: managerProcedure
.input(CreateRateCardSchema)
.mutation(async ({ ctx, input }) => {
const { lines, ...cardData } = input;
return ctx.db.rateCard.create({
data: {
name: cardData.name,
currency: cardData.currency,
...(cardData.effectiveFrom !== undefined ? { effectiveFrom: cardData.effectiveFrom } : {}),
...(cardData.effectiveTo !== undefined ? { effectiveTo: cardData.effectiveTo } : {}),
...(cardData.source !== undefined ? { source: cardData.source } : {}),
...(cardData.clientId !== undefined ? { clientId: cardData.clientId } : {}),
lines: {
create: lines.map((line) => ({
...(line.roleId !== undefined ? { roleId: line.roleId } : {}),
...(line.chapter !== undefined ? { chapter: line.chapter } : {}),
...(line.location !== undefined ? { location: line.location } : {}),
...(line.seniority !== undefined ? { seniority: line.seniority } : {}),
...(line.workType !== undefined ? { workType: line.workType } : {}),
...(line.serviceGroup !== undefined ? { serviceGroup: line.serviceGroup } : {}),
costRateCents: line.costRateCents,
...(line.billRateCents !== undefined ? { billRateCents: line.billRateCents } : {}),
...(line.machineRateCents !== undefined ? { machineRateCents: line.machineRateCents } : {}),
attributes: line.attributes as Prisma.InputJsonValue,
})),
},
},
include: {
lines: { select: lineSelect },
},
});
}),
update: managerProcedure
.input(z.object({ id: z.string(), data: UpdateRateCardSchema }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.rateCard.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Rate card not found" });
return ctx.db.rateCard.update({
where: { id: input.id },
data: {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.currency !== undefined ? { currency: input.data.currency } : {}),
...(input.data.effectiveFrom !== undefined ? { effectiveFrom: input.data.effectiveFrom } : {}),
...(input.data.effectiveTo !== undefined ? { effectiveTo: input.data.effectiveTo } : {}),
...(input.data.source !== undefined ? { source: input.data.source } : {}),
...(input.data.clientId !== undefined ? { clientId: input.data.clientId } : {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
},
include: {
_count: { select: { lines: true } },
client: { select: { id: true, name: true, code: true } },
},
});
}),
deactivate: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.rateCard.update({
where: { id: input.id },
data: { isActive: false },
});
}),
// ─── Line CRUD ─────────────────────────────────────────────────────────────
addLine: managerProcedure
.input(z.object({ rateCardId: z.string(), line: CreateRateCardLineSchema }))
.mutation(async ({ ctx, input }) => {
const card = await ctx.db.rateCard.findUnique({ where: { id: input.rateCardId } });
if (!card) throw new TRPCError({ code: "NOT_FOUND", message: "Rate card not found" });
return ctx.db.rateCardLine.create({
data: {
rateCardId: input.rateCardId,
...(input.line.roleId !== undefined ? { roleId: input.line.roleId } : {}),
...(input.line.chapter !== undefined ? { chapter: input.line.chapter } : {}),
...(input.line.location !== undefined ? { location: input.line.location } : {}),
...(input.line.seniority !== undefined ? { seniority: input.line.seniority } : {}),
...(input.line.workType !== undefined ? { workType: input.line.workType } : {}),
...(input.line.serviceGroup !== undefined ? { serviceGroup: input.line.serviceGroup } : {}),
costRateCents: input.line.costRateCents,
...(input.line.billRateCents !== undefined ? { billRateCents: input.line.billRateCents } : {}),
...(input.line.machineRateCents !== undefined ? { machineRateCents: input.line.machineRateCents } : {}),
attributes: input.line.attributes as Prisma.InputJsonValue,
},
select: lineSelect,
});
}),
updateLine: managerProcedure
.input(z.object({ lineId: z.string(), data: UpdateRateCardLineSchema }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.rateCardLine.findUnique({ where: { id: input.lineId } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Rate card line not found" });
const updateData: Prisma.RateCardLineUpdateInput = {};
if (input.data.roleId !== undefined) updateData.role = input.data.roleId ? { connect: { id: input.data.roleId } } : { disconnect: true };
if (input.data.chapter !== undefined) updateData.chapter = input.data.chapter;
if (input.data.location !== undefined) updateData.location = input.data.location;
if (input.data.seniority !== undefined) updateData.seniority = input.data.seniority;
if (input.data.workType !== undefined) updateData.workType = input.data.workType;
if (input.data.serviceGroup !== undefined) updateData.serviceGroup = input.data.serviceGroup;
if (input.data.costRateCents !== undefined) updateData.costRateCents = input.data.costRateCents;
if (input.data.billRateCents !== undefined) updateData.billRateCents = input.data.billRateCents;
if (input.data.machineRateCents !== undefined) updateData.machineRateCents = input.data.machineRateCents;
if (input.data.attributes !== undefined) updateData.attributes = input.data.attributes as Prisma.InputJsonValue;
return ctx.db.rateCardLine.update({
where: { id: input.lineId },
data: updateData,
select: lineSelect,
});
}),
deleteLine: managerProcedure
.input(z.object({ lineId: z.string() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.rateCardLine.findUnique({ where: { id: input.lineId } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Rate card line not found" });
await ctx.db.rateCardLine.delete({ where: { id: input.lineId } });
return { deleted: true };
}),
// ─── Batch operations ──────────────────────────────────────────────────────
replaceLines: managerProcedure
.input(z.object({
rateCardId: z.string(),
lines: z.array(CreateRateCardLineSchema),
}))
.mutation(async ({ ctx, input }) => {
const card = await ctx.db.rateCard.findUnique({ where: { id: input.rateCardId } });
if (!card) throw new TRPCError({ code: "NOT_FOUND", message: "Rate card not found" });
return ctx.db.$transaction(async (tx) => {
await tx.rateCardLine.deleteMany({ where: { rateCardId: input.rateCardId } });
const created = await Promise.all(
input.lines.map((line) =>
tx.rateCardLine.create({
data: {
rateCardId: input.rateCardId,
...(line.roleId !== undefined ? { roleId: line.roleId } : {}),
...(line.chapter !== undefined ? { chapter: line.chapter } : {}),
...(line.location !== undefined ? { location: line.location } : {}),
...(line.seniority !== undefined ? { seniority: line.seniority } : {}),
...(line.workType !== undefined ? { workType: line.workType } : {}),
...(line.serviceGroup !== undefined ? { serviceGroup: line.serviceGroup } : {}),
costRateCents: line.costRateCents,
...(line.billRateCents !== undefined ? { billRateCents: line.billRateCents } : {}),
...(line.machineRateCents !== undefined ? { machineRateCents: line.machineRateCents } : {}),
attributes: line.attributes as Prisma.InputJsonValue,
},
select: lineSelect,
}),
),
);
return created;
});
}),
// ─── Rate resolution ───────────────────────────────────────────────────────
resolveRate: controllerProcedure
.input(z.object({
rateCardId: z.string(),
roleId: z.string().optional(),
chapter: z.string().optional(),
location: z.string().optional(),
seniority: z.string().optional(),
workType: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const { rateCardId, ...criteria } = input;
// Find the most specific matching line (most criteria matched wins)
const lines = await ctx.db.rateCardLine.findMany({
where: { rateCardId },
select: lineSelect,
});
if (lines.length === 0) return null;
// Score each line by number of matching criteria
const scored = lines.map((line) => {
let score = 0;
let mismatch = false;
if (criteria.roleId && line.roleId) {
if (line.roleId === criteria.roleId) score += 4;
else mismatch = true;
}
if (criteria.chapter && line.chapter) {
if (line.chapter === criteria.chapter) score += 2;
else mismatch = true;
}
if (criteria.location && line.location) {
if (line.location === criteria.location) score += 1;
else mismatch = true;
}
if (criteria.seniority && line.seniority) {
if (line.seniority === criteria.seniority) score += 1;
else mismatch = true;
}
if (criteria.workType && line.workType) {
if (line.workType === criteria.workType) score += 1;
else mismatch = true;
}
return { line, score, mismatch };
});
// Filter out mismatches and find best match
const candidates = scored
.filter((s) => !s.mismatch)
.sort((a, b) => b.score - a.score);
const best = candidates[0];
return best ? best.line : null;
}),
});
+999
View File
@@ -0,0 +1,999 @@
import { createAiClient, isAiConfigured } from "../ai-client.js";
import { listAssignmentBookings } from "@planarchy/application";
import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, SkillEntrySchema, UpdateResourceSchema, VALUE_SCORE_WEIGHTS, inferStateFromPostalCode } from "@planarchy/shared";
import type { WeekdayAvailability } from "@planarchy/shared";
import { computeValueScore } from "@planarchy/staffing";
import { computeChargeability } from "@planarchy/engine";
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool.
Artist profile:
- Role: {role}
- Chapter: {chapter}
- Main skills: {mainSkills}
- Top skills: {topSkills}
Write a 23 sentence professional bio. Be specific, use skill names. No fluff.`;
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
export const resourceRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
chapter: z.string().optional(),
isActive: z.boolean().optional().default(true),
search: z.string().optional(),
eids: z.array(z.string()).optional(),
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(500).default(50),
includeRoles: z.boolean().optional().default(false),
// Cursor-based pagination (additive — page/limit still supported)
cursor: z.string().optional(),
// Custom field JSONB filters
customFieldFilters: z.array(z.object({
key: z.string(),
value: z.string(),
type: z.nativeEnum(FieldType),
})).optional(),
}),
)
.query(async ({ ctx, input }) => {
const { chapter, isActive, search, eids, page, limit, includeRoles, cursor, customFieldFilters } = input;
const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = {
...(eids ? {} : { isActive }),
...(eids ? { eid: { in: eids } } : {}),
...(chapter ? { chapter } : {}),
...(search
? {
OR: [
{ displayName: { contains: search, mode: "insensitive" as const } },
{ eid: { contains: search, mode: "insensitive" as const } },
{ email: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
...(cfConditions.length > 0 ? { AND: cfConditions } : {}),
};
const skip = cursor ? 0 : (page - 1) * limit;
const orderBy = [{ displayName: "asc" as const }, { id: "asc" as const }];
// Apply cursor filter directly on where to avoid exactOptionalPropertyTypes issues
const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where;
const baseQuery = { where: whereWithCursor, skip, take: limit + 1, orderBy };
const [rawResources, total] = await Promise.all([
includeRoles
? ctx.db.resource.findMany({
...baseQuery,
include: {
resourceRoles: {
include: { role: { select: { id: true, name: true, color: true } } },
},
},
})
: ctx.db.resource.findMany(baseQuery),
ctx.db.resource.count({ where }),
]);
const hasMore = rawResources.length > limit;
const resources = hasMore ? rawResources.slice(0, limit) : rawResources;
const nextCursor = hasMore ? resources[resources.length - 1]!.id : null;
return { resources, total, page, limit, nextCursor };
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const resource = await ctx.db.resource.findUnique({
where: { id: input.id },
include: {
blueprint: true,
resourceRoles: {
include: { role: { select: { id: true, name: true, color: true } } },
},
areaRole: { select: { id: true, name: true } },
user: { select: { email: true } },
},
});
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
return resource;
}),
getByEid: protectedProcedure
.input(z.object({ eid: z.string() }))
.query(async ({ ctx, input }) => {
const resource = await ctx.db.resource.findUnique({ where: { eid: input.eid } });
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
return resource;
}),
create: managerProcedure
.input(CreateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const existing = await ctx.db.resource.findFirst({
where: { OR: [{ eid: input.eid }, { email: input.email }] },
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: `Resource with EID "${input.eid}" or email "${input.email}" already exists`,
});
}
await assertBlueprintDynamicFields({
db: ctx.db,
blueprintId: input.blueprintId,
dynamicFields: input.dynamicFields,
target: BlueprintTarget.RESOURCE,
});
// Enforce max 1 primary role
const primaryCount = (input.roles ?? []).filter((r) => r.isPrimary).length;
if (primaryCount > 1) {
throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" });
}
const resource = await ctx.db.resource.create({
data: {
eid: input.eid,
displayName: input.displayName,
email: input.email,
chapter: input.chapter,
lcrCents: input.lcrCents,
ucrCents: input.ucrCents,
currency: input.currency,
chargeabilityTarget: input.chargeabilityTarget,
availability: input.availability,
skills: input.skills as unknown as import("@planarchy/db").Prisma.InputJsonValue,
dynamicFields: input.dynamicFields as unknown as import("@planarchy/db").Prisma.InputJsonValue,
blueprintId: input.blueprintId,
portfolioUrl: input.portfolioUrl || undefined,
roleId: input.roleId || undefined,
...(input.postalCode !== undefined ? { postalCode: input.postalCode } : {}),
...(input.postalCode && !input.federalState
? { federalState: inferStateFromPostalCode(input.postalCode) }
: input.federalState !== undefined
? { federalState: input.federalState }
: {}),
...(input.countryId !== undefined ? { countryId: input.countryId || null } : {}),
...(input.metroCityId !== undefined ? { metroCityId: input.metroCityId || null } : {}),
...(input.orgUnitId !== undefined ? { orgUnitId: input.orgUnitId || null } : {}),
...(input.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.managementLevelGroupId || null } : {}),
...(input.managementLevelId !== undefined ? { managementLevelId: input.managementLevelId || null } : {}),
...(input.resourceType !== undefined ? { resourceType: input.resourceType } : {}),
...(input.chgResponsibility !== undefined ? { chgResponsibility: input.chgResponsibility } : {}),
...(input.rolledOff !== undefined ? { rolledOff: input.rolledOff } : {}),
...(input.departed !== undefined ? { departed: input.departed } : {}),
...(input.enterpriseId !== undefined ? { enterpriseId: input.enterpriseId || null } : {}),
...(input.clientUnitId !== undefined ? { clientUnitId: input.clientUnitId || null } : {}),
...(input.fte !== undefined ? { fte: input.fte } : {}),
resourceRoles: input.roles?.length
? {
create: input.roles.map((r) => ({
roleId: r.roleId,
isPrimary: r.isPrimary,
})),
}
: undefined,
} as unknown as Parameters<typeof ctx.db.resource.create>[0]["data"],
include: {
resourceRoles: { include: { role: { select: { id: true, name: true, color: true } } } },
},
});
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: resource.id,
action: "CREATE",
userId: ctx.dbUser?.id,
changes: { after: resource },
} as unknown as Parameters<typeof ctx.db.auditLog.create>[0]["data"],
});
return resource;
}),
update: managerProcedure
.input(z.object({ id: z.string(), data: UpdateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }) }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const existing = await ctx.db.resource.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record<string, unknown>;
await assertBlueprintDynamicFields({
db: ctx.db,
blueprintId: nextBlueprintId,
dynamicFields: nextDynamicFields,
target: BlueprintTarget.RESOURCE,
});
// Enforce max 1 primary role
if (input.data.roles !== undefined) {
const primaryCount = input.data.roles.filter((r) => r.isPrimary).length;
if (primaryCount > 1) {
throw new TRPCError({ code: "BAD_REQUEST", message: "A resource can have at most one primary role" });
}
}
const updated = await ctx.db.resource.update({
where: { id: input.id },
data: {
...(input.data.displayName !== undefined ? { displayName: input.data.displayName } : {}),
...(input.data.email !== undefined ? { email: input.data.email } : {}),
...(input.data.chapter !== undefined ? { chapter: input.data.chapter } : {}),
...(input.data.lcrCents !== undefined ? { lcrCents: input.data.lcrCents } : {}),
...(input.data.ucrCents !== undefined ? { ucrCents: input.data.ucrCents } : {}),
...(input.data.currency !== undefined ? { currency: input.data.currency } : {}),
...(input.data.chargeabilityTarget !== undefined ? { chargeabilityTarget: input.data.chargeabilityTarget } : {}),
...(input.data.availability !== undefined ? { availability: input.data.availability as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
...(input.data.skills !== undefined ? { skills: input.data.skills as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
...(input.data.dynamicFields !== undefined ? { dynamicFields: input.data.dynamicFields as unknown as import("@planarchy/db").Prisma.InputJsonValue } : {}),
...(input.data.blueprintId !== undefined ? { blueprintId: input.data.blueprintId } : {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
...(input.data.portfolioUrl !== undefined ? { portfolioUrl: input.data.portfolioUrl || null } : {}),
...(input.data.roleId !== undefined ? { roleId: input.data.roleId || null } : {}),
...(input.data.postalCode !== undefined ? { postalCode: input.data.postalCode } : {}),
...(input.data.postalCode && !input.data.federalState
? { federalState: inferStateFromPostalCode(input.data.postalCode) }
: input.data.federalState !== undefined
? { federalState: input.data.federalState }
: {}),
...(input.data.countryId !== undefined ? { countryId: input.data.countryId || null } : {}),
...(input.data.metroCityId !== undefined ? { metroCityId: input.data.metroCityId || null } : {}),
...(input.data.orgUnitId !== undefined ? { orgUnitId: input.data.orgUnitId || null } : {}),
...(input.data.managementLevelGroupId !== undefined ? { managementLevelGroupId: input.data.managementLevelGroupId || null } : {}),
...(input.data.managementLevelId !== undefined ? { managementLevelId: input.data.managementLevelId || null } : {}),
...(input.data.resourceType !== undefined ? { resourceType: input.data.resourceType } : {}),
...(input.data.chgResponsibility !== undefined ? { chgResponsibility: input.data.chgResponsibility } : {}),
...(input.data.rolledOff !== undefined ? { rolledOff: input.data.rolledOff } : {}),
...(input.data.departed !== undefined ? { departed: input.data.departed } : {}),
...(input.data.enterpriseId !== undefined ? { enterpriseId: input.data.enterpriseId || null } : {}),
...(input.data.clientUnitId !== undefined ? { clientUnitId: input.data.clientUnitId || null } : {}),
...(input.data.fte !== undefined ? { fte: input.data.fte } : {}),
} as unknown as Parameters<typeof ctx.db.resource.update>[0]["data"],
include: {
resourceRoles: { include: { role: { select: { id: true, name: true, color: true } } } },
},
});
// Replace roles if provided
if (input.data.roles !== undefined) {
await ctx.db.resourceRole.deleteMany({ where: { resourceId: input.id } });
if (input.data.roles.length > 0) {
await ctx.db.resourceRole.createMany({
data: input.data.roles.map((r) => ({
resourceId: input.id,
roleId: r.roleId,
isPrimary: r.isPrimary,
})),
});
}
}
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.id,
action: "UPDATE",
changes: { before: existing, after: updated },
},
});
return updated;
}),
deactivate: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const resource = await ctx.db.resource.update({
where: { id: input.id },
data: { isActive: false },
});
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.id,
action: "UPDATE",
changes: { after: { isActive: false } },
},
});
return resource;
}),
batchDeactivate: managerProcedure
.input(z.object({ ids: z.array(z.string()).min(1).max(100) }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const updated = await ctx.db.$transaction(
input.ids.map((id) =>
ctx.db.resource.update({ where: { id }, data: { isActive: false } }),
),
);
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.ids.join(","),
action: "UPDATE",
changes: { after: { isActive: false, ids: input.ids } },
},
});
return { count: updated.length };
}),
chapters: protectedProcedure.query(async ({ ctx }) => {
const resources = await ctx.db.resource.findMany({
where: { isActive: true, chapter: { not: null } },
select: { chapter: true },
distinct: ["chapter"],
orderBy: { chapter: "asc" },
});
return resources.map((r) => r.chapter as string);
}),
// ─── Skill Matrix Import ────────────────────────────────────────────────────
importSkillMatrix: protectedProcedure
.input(
z.object({
skills: z.array(SkillEntrySchema),
employeeInfo: z
.object({
roleId: z.string().optional(),
yearsOfExperience: z.number().optional(),
portfolioUrl: z.string().url().optional().or(z.literal("")),
})
.optional(),
}),
)
.mutation(async ({ ctx, input }) => {
// Find the resource linked to this user
const user = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
include: { resource: true },
});
if (!user?.resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "No resource linked to your account" });
}
const resourceId = user.resource.id;
await ctx.db.resource.update({
where: { id: resourceId },
data: {
skills: input.skills as unknown as import("@planarchy/db").Prisma.InputJsonValue,
skillMatrixUpdatedAt: new Date(),
...(input.employeeInfo?.portfolioUrl !== undefined
? { portfolioUrl: input.employeeInfo.portfolioUrl || null }
: {}),
...(input.employeeInfo?.roleId !== undefined ? { roleId: input.employeeInfo.roleId } : {}),
},
});
return { count: input.skills.length };
}),
importSkillMatrixForResource: managerProcedure
.input(
z.object({
resourceId: z.string(),
skills: z.array(SkillEntrySchema),
employeeInfo: z
.object({
roleId: z.string().optional(),
yearsOfExperience: z.number().optional(),
portfolioUrl: z.string().url().optional().or(z.literal("")),
})
.optional(),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
const existing = await ctx.db.resource.findUnique({ where: { id: input.resourceId } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
await ctx.db.resource.update({
where: { id: input.resourceId },
data: {
skills: input.skills as unknown as import("@planarchy/db").Prisma.InputJsonValue,
skillMatrixUpdatedAt: new Date(),
...(input.employeeInfo?.portfolioUrl !== undefined
? { portfolioUrl: input.employeeInfo.portfolioUrl || null }
: {}),
...(input.employeeInfo?.roleId !== undefined ? { roleId: input.employeeInfo.roleId } : {}),
},
});
return { count: input.skills.length };
}),
batchImportSkillMatrices: adminProcedure
.input(
z.object({
entries: z.array(
z.object({
eid: z.string(),
skills: z.array(SkillEntrySchema),
employeeInfo: z
.object({
roleId: z.string().optional(),
yearsOfExperience: z.number().optional(),
portfolioUrl: z.string().url().optional().or(z.literal("")),
})
.optional(),
}),
),
}),
)
.mutation(async ({ ctx, input }) => {
// Single findMany to avoid N+1 (was: findUnique per entry)
const eids = input.entries.map((e) => e.eid);
const existing = await ctx.db.resource.findMany({
where: { eid: { in: eids } },
select: { id: true, eid: true },
});
const eidToId = new Map(existing.map((r) => [r.eid, r.id]));
const notFound = input.entries.length - existing.length;
const now = new Date();
const updates = input.entries
.filter((entry) => eidToId.has(entry.eid))
.map((entry) =>
ctx.db.resource.update({
where: { id: eidToId.get(entry.eid)! },
data: {
skills: entry.skills as unknown as import("@planarchy/db").Prisma.InputJsonValue,
skillMatrixUpdatedAt: now,
...(entry.employeeInfo?.portfolioUrl !== undefined
? { portfolioUrl: entry.employeeInfo.portfolioUrl || null }
: {}),
...(entry.employeeInfo?.roleId !== undefined ? { roleId: entry.employeeInfo.roleId } : {}),
},
}),
);
await ctx.db.$transaction(updates);
return { updated: updates.length, notFound };
}),
// ─── AI Summary ─────────────────────────────────────────────────────────────
generateAiSummary: managerProcedure
.input(z.object({ resourceId: z.string() }))
.mutation(async ({ ctx, input }) => {
const [resource, settings] = await Promise.all([
ctx.db.resource.findUnique({
where: { id: input.resourceId },
include: { areaRole: { select: { name: true } } },
}),
ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }),
]);
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
if (!isAiConfigured(settings)) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "AI is not configured. Please set credentials in Admin → Settings.",
});
}
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
const mainSkills = skills.filter((s) => s.isMainSkill).map((s) => s.skill);
const top10 = [...skills]
.sort((a, b) => b.proficiency - a.proficiency)
.slice(0, 10)
.map((s) => `${s.skill} (${s.proficiency}/5)`);
const vars = {
role: resource.areaRole?.name ?? "Not specified",
chapter: resource.chapter ?? "Not specified",
mainSkills: mainSkills.length > 0 ? mainSkills.join(", ") : "Not specified",
topSkills: top10.join(", "),
};
const templateStr = settings!.aiSummaryPrompt ?? DEFAULT_SUMMARY_PROMPT;
const prompt = templateStr
.replace("{role}", vars.role)
.replace("{chapter}", vars.chapter)
.replace("{mainSkills}", vars.mainSkills)
.replace("{topSkills}", vars.topSkills);
const client = createAiClient(settings!);
const model = settings!.azureOpenAiDeployment!;
const maxTokens = settings!.aiMaxCompletionTokens ?? 300;
const temperature = settings!.aiTemperature ?? 1;
async function callChatCompletions(withTemperature: boolean) {
return client.chat.completions.create({
messages: [{ role: "user", content: prompt }],
max_completion_tokens: maxTokens,
model,
...(withTemperature && temperature !== 1 ? { temperature } : {}),
});
}
let summary = "";
try {
let completion;
try {
completion = await callChatCompletions(true);
console.log("[generateAiSummary] chat.completions response:", JSON.stringify({
choices: completion.choices?.map(c => ({ content: c.message?.content, finish_reason: c.finish_reason })),
}));
} catch (tempErr) {
const status = (tempErr as { status?: number }).status;
const msg = (tempErr as Error).message ?? "";
console.log("[generateAiSummary] chat.completions error:", status, msg.slice(0, 200));
if (status === 400 && msg.includes("temperature")) {
completion = await callChatCompletions(false);
} else if (status === 404) {
console.log("[generateAiSummary] falling back to responses API");
const resp = await client.responses.create({ model, input: prompt, max_output_tokens: maxTokens });
console.log("[generateAiSummary] responses output_text:", resp.output_text?.slice(0, 100));
summary = resp.output_text?.trim() ?? "";
completion = null;
} else {
throw tempErr;
}
}
if (completion) summary = completion.choices[0]?.message?.content?.trim() ?? "";
} catch (e) {
throw e;
}
await ctx.db.resource.update({
where: { id: input.resourceId },
data: { aiSummary: summary, aiSummaryUpdatedAt: new Date() },
});
return { summary };
}),
// ─── Skills Analytics ───────────────────────────────────────────────────────
getSkillsAnalytics: controllerProcedure.query(async ({ ctx }) => {
const resources = await ctx.db.resource.findMany({
where: { isActive: true },
select: { id: true, displayName: true, chapter: true, skills: true },
});
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
// Aggregate: { skillName, category, count, totalProficiency, chapters }
const skillMap = new Map<
string,
{ skill: string; category: string; count: number; totalProficiency: number; chapters: Set<string> }
>();
for (const resource of resources) {
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
for (const s of skills) {
const key = s.skill;
if (!skillMap.has(key)) {
skillMap.set(key, {
skill: s.skill,
category: s.category ?? "Uncategorized",
count: 0,
totalProficiency: 0,
chapters: new Set(),
});
}
const entry = skillMap.get(key)!;
entry.count++;
entry.totalProficiency += s.proficiency;
if (resource.chapter) entry.chapters.add(resource.chapter);
}
}
const aggregated = Array.from(skillMap.values())
.map((e) => ({
skill: e.skill,
category: e.category,
count: e.count,
avgProficiency: Math.round((e.totalProficiency / e.count) * 10) / 10,
chapters: Array.from(e.chapters),
}))
.sort((a, b) => b.count - a.count);
const categories = [...new Set(aggregated.map((e) => e.category))].sort();
const allChapters = [...new Set(resources.map((r) => r.chapter).filter(Boolean))].sort() as string[];
return {
totalResources: resources.length,
totalSkillEntries: aggregated.length,
aggregated,
categories,
allChapters,
};
}),
searchBySkills: controllerProcedure
.input(
z.object({
rules: z.array(
z.object({
skill: z.string().min(1),
minProficiency: z.number().int().min(1).max(5).default(1),
}),
),
chapter: z.string().optional(),
operator: z.enum(["AND", "OR"]).default("AND"),
}),
)
.query(async ({ ctx, input }) => {
const { rules, chapter, operator } = input;
const resources = await ctx.db.resource.findMany({
where: { isActive: true, ...(chapter ? { chapter } : {}) },
select: { id: true, eid: true, displayName: true, chapter: true, skills: true },
});
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
const results = resources
.map((r) => {
const skills = (r.skills as unknown as SkillRow[]) ?? [];
const matchFn = (rule: { skill: string; minProficiency: number }) => {
const s = skills.find((sk) => sk.skill.toLowerCase().includes(rule.skill.toLowerCase()));
return s && s.proficiency >= rule.minProficiency ? s : null;
};
const matched = rules.map(matchFn);
const passes =
operator === "AND" ? matched.every(Boolean) : matched.some(Boolean);
if (!passes) return null;
return {
id: r.id,
eid: r.eid,
displayName: r.displayName,
chapter: r.chapter,
matchedSkills: rules
.map((rule, i) => {
const s = matched[i];
return s ? { skill: s.skill, proficiency: s.proficiency, category: s.category ?? "" } : null;
})
.filter((s): s is { skill: string; proficiency: number; category: string } => s !== null),
};
})
.filter((r): r is NonNullable<typeof r> => r !== null)
.sort((a, b) => a.displayName.localeCompare(b.displayName));
return results;
}),
// ─── Self-service ────────────────────────────────────────────────────────────
/** Get the resource linked to the current user (for self-service pages). */
getMyResource: protectedProcedure.query(async ({ ctx }) => {
const email = ctx.session.user?.email;
if (!email) return null;
const user = await ctx.db.user.findUnique({
where: { email },
select: { resource: { select: { id: true, displayName: true, eid: true, chapter: true } } },
});
return user?.resource ?? null;
}),
// ─── Value Score ─────────────────────────────────────────────────────────────
getValueScores: protectedProcedure
.input(
z.object({
isActive: z.boolean().optional().default(true),
limit: z.number().int().min(1).max(500).default(100),
}),
)
.query(async ({ ctx, input }) => {
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const visibleRoles = (settings?.scoreVisibleRoles as unknown as string[]) ?? ["ADMIN", "MANAGER"];
const userRole = (ctx.session.user as { role?: string } | undefined)?.role ?? "USER";
if (!visibleRoles.includes(userRole)) return [];
const resources = await ctx.db.resource.findMany({
where: { isActive: input.isActive },
select: {
id: true,
eid: true,
displayName: true,
chapter: true,
lcrCents: true,
valueScore: true,
valueScoreBreakdown: true,
valueScoreUpdatedAt: true,
},
orderBy: [{ valueScore: "desc" }, { displayName: "asc" }],
take: input.limit,
});
return resources;
}),
recomputeValueScores: adminProcedure.mutation(async ({ ctx }) => {
const [resources, settings] = await Promise.all([
ctx.db.resource.findMany({
where: { isActive: true },
select: {
id: true,
skills: true,
lcrCents: true,
chargeabilityTarget: true,
},
}),
ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }),
]);
const bookings = await listAssignmentBookings(ctx.db, {
startDate: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
endDate: new Date(),
resourceIds: resources.map((resource) => resource.id),
});
const defaultWeights = {
skillDepth: VALUE_SCORE_WEIGHTS.SKILL_DEPTH,
skillBreadth: VALUE_SCORE_WEIGHTS.SKILL_BREADTH,
costEfficiency: VALUE_SCORE_WEIGHTS.COST_EFFICIENCY,
chargeability: VALUE_SCORE_WEIGHTS.CHARGEABILITY,
experience: VALUE_SCORE_WEIGHTS.EXPERIENCE,
};
const weights = (settings?.scoreWeights as unknown as typeof defaultWeights) ?? defaultWeights;
const maxLcrCents = resources.reduce((max, r) => Math.max(max, r.lcrCents), 0);
const now = new Date();
type SkillRow = { skill: string; category?: string; proficiency: number; yearsExperience?: number; isMainSkill?: boolean };
const totalWorkDays = 90 * (5 / 7); // approx working days
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 currentChargeability = availableHours > 0 ? Math.min(100, (bookedHours / availableHours) * 100) : 0;
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
const breakdown = computeValueScore(
{
skills: skills as unknown as import("@planarchy/shared").SkillEntry[],
lcrCents: resource.lcrCents,
chargeabilityTarget: resource.chargeabilityTarget,
currentChargeability,
maxLcrCents,
},
weights,
);
return ctx.db.resource.update({
where: { id: resource.id },
data: {
valueScore: breakdown.total,
valueScoreBreakdown: breakdown as unknown as import("@planarchy/db").Prisma.InputJsonValue,
valueScoreUpdatedAt: now,
},
});
});
await ctx.db.$transaction(updates);
const updated = updates.length;
return { updated };
}),
listWithUtilization: controllerProcedure
.input(
z.object({
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
chapter: z.string().optional(),
limit: z.number().int().min(1).max(500).default(100),
}),
)
.query(async ({ ctx, input }) => {
const now = new Date();
const start = input.startDate ? new Date(input.startDate) : new Date(now.getFullYear(), now.getMonth(), 1);
const end = input.endDate ? new Date(input.endDate) : new Date(now.getFullYear(), now.getMonth() + 3, 0);
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.chapter ? { chapter: input.chapter } : {}),
},
take: input.limit,
orderBy: { displayName: "asc" },
select: {
id: true,
eid: true,
displayName: true,
email: true,
chapter: true,
lcrCents: true,
ucrCents: true,
currency: true,
chargeabilityTarget: true,
availability: true,
skills: true,
dynamicFields: true,
blueprintId: true,
isActive: true,
createdAt: true,
updatedAt: true,
roleId: true,
portfolioUrl: true,
postalCode: true,
federalState: true,
valueScore: true,
valueScoreBreakdown: true,
valueScoreUpdatedAt: true,
userId: true,
},
});
const bookings = await listAssignmentBookings(ctx.db, {
startDate: start,
endDate: end,
resourceIds: resources.map((resource) => resource.id),
});
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((booking) => booking.resourceId === r.id);
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 utilizationPercent =
availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0;
return {
...r,
bookingCount: resourceBookings.length,
bookedHours: Math.round(bookedHours),
availableHours: Math.round(availableHours),
utilizationPercent,
isOverbooked,
};
});
}),
getChargeabilityStats: controllerProcedure
.input(z.object({ resourceId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(input.resourceId ? { id: input.resourceId } : {}),
},
select: {
id: true,
eid: true,
displayName: true,
chapter: true,
chargeabilityTarget: true,
availability: true,
},
});
const bookings = await listAssignmentBookings(ctx.db, {
startDate: start,
endDate: end,
resourceIds: resources.map((resource) => resource.id),
});
return resources.map((r) => {
const avail = r.availability as unknown as WeekdayAvailability;
const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id);
// Actual: CONFIRMED or ACTIVE allocations on non-DRAFT, non-CANCELLED projects
const actualAllocs = resourceBookings.filter(
(a) =>
(a.status === "CONFIRMED" || a.status === "ACTIVE") &&
a.project.status !== "DRAFT" &&
a.project.status !== "CANCELLED",
);
// Expected: all non-CANCELLED assignment-like bookings, all project statuses
const expectedAllocs = resourceBookings;
const actual = computeChargeability(avail, actualAllocs, start, end);
const expected = computeChargeability(avail, expectedAllocs, start, end);
return {
id: r.id,
eid: r.eid,
displayName: r.displayName,
chapter: r.chapter,
chargeabilityTarget: r.chargeabilityTarget,
actualChargeability: actual.chargeability,
expectedChargeability: expected.chargeability,
availableHours: actual.availableHours,
};
});
}),
/**
* Bulk-update dynamicFields on a set of resources (merges — does not overwrite other keys).
*/
batchUpdateCustomFields: managerProcedure
.input(z.object({
ids: z.array(z.string()).min(1).max(100),
fields: z.record(z.string(), z.unknown()),
}))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
await ctx.db.$transaction(
input.ids.map((id) =>
ctx.db.$executeRaw`
UPDATE "Resource"
SET "dynamicFields" = "dynamicFields" || ${JSON.stringify(input.fields)}::jsonb
WHERE id = ${id}
`,
),
);
await ctx.db.auditLog.create({
data: {
entityType: "Resource",
entityId: input.ids.join(","),
action: "UPDATE",
changes: { after: { dynamicFields: input.fields, ids: input.ids } } as unknown as import("@planarchy/db").Prisma.InputJsonValue,
},
});
return { updated: input.ids.length };
}),
});
+244
View File
@@ -0,0 +1,244 @@
import { countPlanningEntries } from "@planarchy/application";
import { CreateRoleSchema, PermissionKey, UpdateRoleSchema } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { emitRoleCreated, emitRoleDeleted, emitRoleUpdated } from "../sse/event-bus.js";
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
async function loadRolePlanningEntryCounts(
db: Pick<import("@planarchy/db").PrismaClient, "demandRequirement" | "assignment">,
roleIds: string[],
) {
const { countsByRoleId } = await countPlanningEntries(db, {
roleIds,
});
return countsByRoleId;
}
async function attachPlanningEntryCounts<
TRole extends {
id: string;
_count: { resourceRoles: number };
},
>(
db: Pick<import("@planarchy/db").PrismaClient, "demandRequirement" | "assignment">,
roles: TRole[],
): Promise<Array<TRole & { _count: { resourceRoles: number; allocations: number } }>> {
const countsByRoleId = await loadRolePlanningEntryCounts(
db,
roles.map((role) => role.id),
);
return roles.map((role) => ({
...role,
_count: {
...role._count,
allocations: countsByRoleId.get(role.id) ?? 0,
},
}));
}
async function attachSinglePlanningEntryCount<
TRole extends {
id: string;
_count: { resourceRoles: number };
},
>(
db: Pick<import("@planarchy/db").PrismaClient, "demandRequirement" | "assignment">,
role: TRole,
): Promise<TRole & { _count: { resourceRoles: number; allocations: number } }> {
return (await attachPlanningEntryCounts(db, [role]))[0]!;
}
export const roleRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
isActive: z.boolean().optional(),
search: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const roles = await ctx.db.role.findMany({
where: {
...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
...(input.search
? { name: { contains: input.search, mode: "insensitive" as const } }
: {}),
},
include: {
_count: {
select: { resourceRoles: true },
},
},
orderBy: { name: "asc" },
});
return attachPlanningEntryCounts(ctx.db, roles);
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const role = await ctx.db.role.findUnique({
where: { id: input.id },
include: {
_count: { select: { resourceRoles: true } },
resourceRoles: {
include: {
resource: { select: { id: true, displayName: true, eid: true } },
},
},
},
});
if (!role) {
throw new TRPCError({ code: "NOT_FOUND", message: "Role not found" });
}
return attachSinglePlanningEntryCount(ctx.db, role);
}),
create: managerProcedure
.input(CreateRoleSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ROLES);
const existing = await ctx.db.role.findUnique({ where: { name: input.name } });
if (existing) {
throw new TRPCError({ code: "CONFLICT", message: `Role "${input.name}" already exists` });
}
const role = await ctx.db.role.create({
data: {
name: input.name,
description: input.description ?? null,
color: input.color ?? null,
},
include: { _count: { select: { resourceRoles: true } } },
});
await ctx.db.auditLog.create({
data: {
entityType: "Role",
entityId: role.id,
action: "CREATE",
changes: { after: role },
},
});
emitRoleCreated({ id: role.id, name: role.name });
return {
...role,
_count: {
...role._count,
allocations: 0,
},
};
}),
update: managerProcedure
.input(z.object({ id: z.string(), data: UpdateRoleSchema }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ROLES);
const existing = await ctx.db.role.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Role not found" });
}
if (input.data.name && input.data.name !== existing.name) {
const nameConflict = await ctx.db.role.findUnique({ where: { name: input.data.name } });
if (nameConflict) {
throw new TRPCError({ code: "CONFLICT", message: `Role "${input.data.name}" already exists` });
}
}
const updated = await ctx.db.role.update({
where: { id: input.id },
data: {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.description !== undefined ? { description: input.data.description } : {}),
...(input.data.color !== undefined ? { color: input.data.color } : {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
},
include: { _count: { select: { resourceRoles: true } } },
});
await ctx.db.auditLog.create({
data: {
entityType: "Role",
entityId: input.id,
action: "UPDATE",
changes: { before: existing, after: updated },
},
});
emitRoleUpdated({ id: updated.id, name: updated.name });
return attachSinglePlanningEntryCount(ctx.db, updated);
}),
delete: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ROLES);
const role = await ctx.db.role.findUnique({
where: { id: input.id },
include: { _count: { select: { resourceRoles: true } } },
});
if (!role) {
throw new TRPCError({ code: "NOT_FOUND", message: "Role not found" });
}
const roleWithCounts = await attachSinglePlanningEntryCount(ctx.db, role);
if (
roleWithCounts._count.resourceRoles > 0 ||
roleWithCounts._count.allocations > 0
) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: `Cannot delete role assigned to ${roleWithCounts._count.resourceRoles} resource(s) and ${roleWithCounts._count.allocations} allocation(s). Deactivate it instead.`,
});
}
await ctx.db.role.delete({ where: { id: input.id } });
await ctx.db.auditLog.create({
data: {
entityType: "Role",
entityId: input.id,
action: "DELETE",
changes: { before: role },
},
});
emitRoleDeleted(input.id);
return { success: true };
}),
deactivate: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ROLES);
const role = await ctx.db.role.update({
where: { id: input.id },
data: { isActive: false },
include: { _count: { select: { resourceRoles: true } } },
});
await ctx.db.auditLog.create({
data: {
entityType: "Role",
entityId: input.id,
action: "UPDATE",
changes: { after: { isActive: false } },
},
});
emitRoleUpdated({ id: role.id, isActive: false });
return attachSinglePlanningEntryCount(ctx.db, role);
}),
});
+224
View File
@@ -0,0 +1,224 @@
import { z } from "zod";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
import { createAiClient, isAiConfigured, parseAiError } from "../ai-client.js";
import { DEFAULT_SUMMARY_PROMPT } from "./resource.js";
import { VALUE_SCORE_WEIGHTS } from "@planarchy/shared";
import { testSmtpConnection } from "../lib/email.js";
export const settingsRouter = createTRPCRouter({
getSystemSettings: adminProcedure.query(async ({ ctx }) => {
const settings = await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
});
const defaultWeights = {
skillDepth: VALUE_SCORE_WEIGHTS.SKILL_DEPTH,
skillBreadth: VALUE_SCORE_WEIGHTS.SKILL_BREADTH,
costEfficiency: VALUE_SCORE_WEIGHTS.COST_EFFICIENCY,
chargeability: VALUE_SCORE_WEIGHTS.CHARGEABILITY,
experience: VALUE_SCORE_WEIGHTS.EXPERIENCE,
};
return {
aiProvider: settings?.aiProvider ?? "openai",
azureOpenAiEndpoint: settings?.azureOpenAiEndpoint ?? null,
azureOpenAiDeployment: settings?.azureOpenAiDeployment ?? null,
azureApiVersion: settings?.azureApiVersion ?? "2025-01-01-preview",
aiMaxCompletionTokens: settings?.aiMaxCompletionTokens ?? 300,
aiTemperature: settings?.aiTemperature ?? 1,
aiSummaryPrompt: settings?.aiSummaryPrompt ?? null,
defaultSummaryPrompt: DEFAULT_SUMMARY_PROMPT,
hasApiKey: !!settings?.azureOpenAiApiKey,
scoreWeights: (settings?.scoreWeights as unknown as typeof defaultWeights) ?? defaultWeights,
scoreVisibleRoles: (settings?.scoreVisibleRoles as unknown as string[]) ?? ["ADMIN", "MANAGER"],
// SMTP
smtpHost: settings?.smtpHost ?? null,
smtpPort: settings?.smtpPort ?? 587,
smtpUser: settings?.smtpUser ?? null,
smtpFrom: settings?.smtpFrom ?? null,
smtpTls: settings?.smtpTls ?? true,
hasSmtpPassword: !!settings?.smtpPassword,
// Vacation defaults
vacationDefaultDays: settings?.vacationDefaultDays ?? 28,
};
}),
updateSystemSettings: adminProcedure
.input(
z.object({
aiProvider: z.enum(["openai", "azure"]).optional(),
azureOpenAiEndpoint: z.string().url().optional().or(z.literal("")),
azureOpenAiDeployment: z.string().optional(),
azureOpenAiApiKey: z.string().optional(),
azureApiVersion: z.string().optional(),
aiMaxCompletionTokens: z.number().int().min(50).max(4000).optional(),
aiTemperature: z.number().min(0).max(2).optional(),
aiSummaryPrompt: z.string().optional(),
scoreWeights: z.object({
skillDepth: z.number().min(0).max(1),
skillBreadth: z.number().min(0).max(1),
costEfficiency: z.number().min(0).max(1),
chargeability: z.number().min(0).max(1),
experience: z.number().min(0).max(1),
}).refine(
(w) => {
const sum = w.skillDepth + w.skillBreadth + w.costEfficiency + w.chargeability + w.experience;
return Math.abs(sum - 1.0) < 0.01;
},
{ message: "Score weights must sum to 1.0" },
).optional(),
scoreVisibleRoles: z.array(z.enum(["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"])).optional(),
// SMTP
smtpHost: z.string().optional(),
smtpPort: z.number().int().min(1).max(65535).optional(),
smtpUser: z.string().optional(),
smtpPassword: z.string().optional(),
smtpFrom: z.string().email().optional().or(z.literal("")),
smtpTls: z.boolean().optional(),
// Vacation
vacationDefaultDays: z.number().int().min(0).max(365).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const data: Record<string, unknown> = {};
if (input.aiProvider !== undefined) data.aiProvider = input.aiProvider;
if (input.azureOpenAiEndpoint !== undefined)
data.azureOpenAiEndpoint = input.azureOpenAiEndpoint || null;
if (input.azureOpenAiDeployment !== undefined)
data.azureOpenAiDeployment = input.azureOpenAiDeployment || null;
if (input.azureOpenAiApiKey !== undefined)
data.azureOpenAiApiKey = input.azureOpenAiApiKey || null;
if (input.azureApiVersion !== undefined)
data.azureApiVersion = input.azureApiVersion || null;
if (input.aiMaxCompletionTokens !== undefined)
data.aiMaxCompletionTokens = input.aiMaxCompletionTokens;
if (input.aiTemperature !== undefined)
data.aiTemperature = input.aiTemperature;
if (input.aiSummaryPrompt !== undefined)
data.aiSummaryPrompt = input.aiSummaryPrompt || null;
if (input.scoreWeights !== undefined)
data.scoreWeights = input.scoreWeights;
if (input.scoreVisibleRoles !== undefined)
data.scoreVisibleRoles = input.scoreVisibleRoles;
// SMTP
if (input.smtpHost !== undefined) data.smtpHost = input.smtpHost || null;
if (input.smtpPort !== undefined) data.smtpPort = input.smtpPort;
if (input.smtpUser !== undefined) data.smtpUser = input.smtpUser || null;
if (input.smtpPassword !== undefined) data.smtpPassword = input.smtpPassword || null;
if (input.smtpFrom !== undefined) data.smtpFrom = input.smtpFrom || null;
if (input.smtpTls !== undefined) data.smtpTls = input.smtpTls;
// Vacation
if (input.vacationDefaultDays !== undefined) data.vacationDefaultDays = input.vacationDefaultDays;
await ctx.db.systemSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", ...data },
update: data,
});
return { ok: true };
}),
testAiConnection: adminProcedure.mutation(async ({ ctx }) => {
const settings = await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
});
if (!isAiConfigured(settings)) {
const provider = settings?.aiProvider ?? "openai";
if (provider === "azure") {
return { ok: false, error: "Missing required fields: endpoint, deployment name, and API key are all required for Azure OpenAI." };
}
return { ok: false, error: "Missing required fields: model name and API key are required." };
}
const provider = settings!.aiProvider ?? "openai";
const apiKey = settings!.azureOpenAiApiKey!;
let url: string;
let headers: Record<string, string>;
if (provider === "azure") {
const endpoint = settings!.azureOpenAiEndpoint!.replace(/\/$/, "");
const deployment = settings!.azureOpenAiDeployment!;
const apiVersion = settings!.azureApiVersion ?? "2025-01-01-preview";
url = `${endpoint}/openai/deployments/${deployment}/chat/completions?api-version=${apiVersion}`;
headers = { "Content-Type": "application/json", "api-key": apiKey };
} else {
// Standard OpenAI API — deployment field holds the model name (e.g. "gpt-4o")
const model = settings!.azureOpenAiDeployment ?? "gpt-4o-mini";
url = "https://api.openai.com/v1/chat/completions";
headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` };
// Override body to include model field for OpenAI
try {
const resp = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
model,
messages: [{ role: "user", content: "ping" }],
max_completion_tokens: 5,
}),
});
const body = await resp.text();
if (resp.ok) return { ok: true, raw: null };
let msg = body;
try {
const parsed = JSON.parse(body) as { error?: { message?: string } };
if (parsed.error?.message) msg = parsed.error.message;
} catch { /* keep raw */ }
const raw = `HTTP ${resp.status}: ${msg}`;
return { ok: false, error: parseAiError(new Error(raw)), raw };
} catch (err) {
const raw = err instanceof Error ? err.message : String(err);
return { ok: false, error: parseAiError(err), raw };
}
}
try {
const resp = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
messages: [{ role: "user", content: "ping" }],
max_completion_tokens: 5,
}),
});
const body = await resp.text();
if (resp.ok) {
return { ok: true, raw: null };
}
let azureMessage = body;
try {
const parsed = JSON.parse(body) as { error?: { message?: string; code?: string } };
if (parsed.error?.message) azureMessage = parsed.error.message;
} catch { /* leave as raw text */ }
const raw = `HTTP ${resp.status}: ${azureMessage}`;
return { ok: false, error: parseAiError(new Error(raw)), raw };
} catch (err) {
const raw = err instanceof Error ? err.message : String(err);
return { ok: false, error: parseAiError(err), raw };
}
}),
testSmtpConnection: adminProcedure.mutation(async () => {
return testSmtpConnection();
}),
getAiConfigured: protectedProcedure.query(async ({ ctx }) => {
const settings = await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
select: {
aiProvider: true,
azureOpenAiEndpoint: true,
azureOpenAiDeployment: true,
azureOpenAiApiKey: true,
},
});
return { configured: isAiConfigured(settings) };
}),
});
+200
View File
@@ -0,0 +1,200 @@
import { analyzeUtilization, findCapacityWindows, rankResources } from "@planarchy/staffing";
import { listAssignmentBookings } from "@planarchy/application";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
export const staffingRouter = createTRPCRouter({
/**
* Get ranked resource suggestions for a staffing requirement.
*/
getSuggestions: protectedProcedure
.input(
z.object({
requiredSkills: z.array(z.string()),
preferredSkills: z.array(z.string()).optional(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0).max(24),
budgetLcrCentsPerHour: z.number().optional(),
chapter: z.string().optional(),
skillCategory: z.string().optional(),
mainSkillsOnly: z.boolean().optional(),
minProficiency: z.number().min(1).max(5).optional(),
}),
)
.query(async ({ ctx, input }) => {
const { requiredSkills, preferredSkills, startDate, endDate, hoursPerDay, budgetLcrCentsPerHour, chapter, skillCategory, mainSkillsOnly, minProficiency } = input;
const resources = await ctx.db.resource.findMany({
where: {
isActive: true,
...(chapter ? { chapter } : {}),
},
});
const bookings = await listAssignmentBookings(ctx.db, {
startDate,
endDate,
resourceIds: resources.map((resource) => resource.id),
});
// 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,
0,
);
const utilizationPercent =
totalAvailableHours > 0
? Math.min(100, (allocatedHoursPerDay / 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[];
// Apply skill filters before matching
if (mainSkillsOnly) skills = skills.filter((s) => s.isMainSkill);
if (skillCategory) skills = skills.filter((s) => s.category === skillCategory);
if (minProficiency) skills = skills.filter((s) => s.proficiency >= minProficiency);
return {
id: resource.id,
displayName: resource.displayName,
eid: resource.eid,
skills: skills as unknown as import("@planarchy/shared").SkillEntry[],
lcrCents: resource.lcrCents,
chargeabilityTarget: resource.chargeabilityTarget,
currentUtilizationPercent: utilizationPercent,
hasAvailabilityConflicts: wouldExceedCapacity,
conflictDays: wouldExceedCapacity ? ["(multiple days)"] : [],
valueScore: resource.valueScore ?? 0,
};
});
const ranked = rankResources({
requiredSkills,
preferredSkills: preferredSkills,
resources: enrichedResources,
budgetLcrCentsPerHour,
} as unknown as Parameters<typeof rankResources>[0]);
// Value-score tiebreaker: within 2 points, prefer higher valueScore
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;
});
}),
/**
* Analyze utilization for a specific resource over a date range.
*/
analyzeUtilization: protectedProcedure
.input(
z.object({
resourceId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
}),
)
.query(async ({ ctx, input }) => {
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: {
id: true,
displayName: true,
chargeabilityTarget: true,
availability: true,
},
});
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
const resourceBookings = await listAssignmentBookings(ctx.db, {
startDate: input.startDate,
endDate: input.endDate,
resourceIds: [resource.id],
});
return analyzeUtilization({
resource: {
id: resource.id,
displayName: resource.displayName,
chargeabilityTarget: resource.chargeabilityTarget,
availability: resource.availability as unknown as import("@planarchy/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,
});
}),
/**
* Find capacity windows for a resource.
*/
findCapacity: protectedProcedure
.input(
z.object({
resourceId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
minAvailableHoursPerDay: z.number().optional().default(4),
}),
)
.query(async ({ ctx, input }) => {
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: {
id: true,
displayName: true,
availability: true,
},
});
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
const resourceBookings = await listAssignmentBookings(ctx.db, {
startDate: input.startDate,
endDate: input.endDate,
resourceIds: [resource.id],
});
return findCapacityWindows(
{
id: resource.id,
displayName: resource.displayName,
availability: resource.availability as unknown as import("@planarchy/shared").WeekdayAvailability,
},
resourceBookings.map((booking) => ({
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
})) as Pick<import("@planarchy/shared").Allocation, "startDate" | "endDate" | "hoursPerDay" | "status">[],
input.startDate,
input.endDate,
input.minAvailableHoursPerDay,
);
}),
});
@@ -0,0 +1,60 @@
import {
buildSplitAllocationReadModel,
type SplitAssignmentRecord,
type SplitDemandRequirementRecord,
} from "@planarchy/application";
import type { ShiftInput } from "@planarchy/engine";
import type { WeekdayAvailability } from "@planarchy/shared";
export interface TimelineShiftWindow {
id: string;
resourceId: string;
projectId: string;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
status: string;
}
export interface BuildTimelineShiftPlanInput {
demandRequirements: SplitDemandRequirementRecord[];
assignments: SplitAssignmentRecord[];
allAssignmentWindows: TimelineShiftWindow[];
}
export interface TimelineShiftPlan {
validationAllocations: ShiftInput["allocations"];
}
export function buildTimelineShiftPlan({
demandRequirements,
assignments,
allAssignmentWindows,
}: BuildTimelineShiftPlanInput): TimelineShiftPlan {
const readModel = buildSplitAllocationReadModel({
demandRequirements,
assignments,
});
const validationAllocations = readModel.assignments
.filter((assignment) => assignment.resourceId !== null && assignment.resource)
.map((assignment) => {
const metadata = (assignment.metadata as Record<string, unknown> | null | undefined) ?? {};
return {
...assignment,
resource: {
...assignment.resource!,
availability: assignment.resource!.availability as WeekdayAvailability,
},
allAllocationsForResource: allAssignmentWindows.filter(
(window) => window.resourceId === assignment.resourceId,
),
includeSaturday: (metadata.includeSaturday as boolean | undefined) ?? false,
};
}) as unknown as ShiftInput["allocations"];
return {
validationAllocations,
};
}
+610
View File
@@ -0,0 +1,610 @@
import {
buildSplitAllocationReadModel,
createAssignment,
findAllocationEntry,
loadAllocationEntry,
listAssignmentBookings,
updateAssignment,
updateDemandRequirement,
updateAllocationEntry,
} from "@planarchy/application";
import type { PrismaClient } from "@planarchy/db";
import { calculateAllocation, computeBudgetStatus, validateShift } from "@planarchy/engine";
import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import {
loadProjectPlanningReadModel,
PROJECT_PLANNING_ASSIGNMENT_INCLUDE,
PROJECT_PLANNING_DEMAND_INCLUDE,
} from "./project-planning-read-model.js";
import {
emitAllocationCreated,
emitAllocationUpdated,
emitProjectShifted,
} from "../sse/event-bus.js";
import { buildTimelineShiftPlan } from "./timeline-shift-planning.js";
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
type ShiftDbClient = Pick<
PrismaClient,
"project" | "demandRequirement" | "assignment"
>;
type TimelineEntriesDbClient = Pick<
PrismaClient,
"demandRequirement" | "assignment"
>;
type TimelineEntriesFilters = {
startDate: Date;
endDate: Date;
resourceIds?: string[] | undefined;
projectIds?: string[] | undefined;
};
function getAssignmentResourceIds(
readModel: ReturnType<typeof buildSplitAllocationReadModel>,
): string[] {
return [
...new Set(
readModel.assignments
.map((assignment) => assignment.resourceId)
.filter((resourceId): resourceId is string => resourceId !== null),
),
];
}
async function loadTimelineEntriesReadModel(
db: TimelineEntriesDbClient,
input: TimelineEntriesFilters,
) {
const { startDate, endDate, resourceIds, projectIds } = input;
const [demandRequirements, assignments] = await Promise.all([
resourceIds && resourceIds.length > 0
? Promise.resolve([])
: db.demandRequirement.findMany({
where: {
status: { not: "CANCELLED" },
startDate: { lte: endDate },
endDate: { gte: startDate },
...(projectIds ? { projectId: { in: projectIds } } : {}),
},
include: PROJECT_PLANNING_DEMAND_INCLUDE,
orderBy: [{ startDate: "asc" }, { projectId: "asc" }],
}),
db.assignment.findMany({
where: {
status: { not: "CANCELLED" },
startDate: { lte: endDate },
endDate: { gte: startDate },
...(resourceIds ? { resourceId: { in: resourceIds } } : {}),
...(projectIds ? { projectId: { in: projectIds } } : {}),
},
include: PROJECT_PLANNING_ASSIGNMENT_INCLUDE,
orderBy: [{ startDate: "asc" }, { resourceId: "asc" }],
}),
]);
return buildSplitAllocationReadModel({ demandRequirements, assignments });
}
async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) {
const [project, planningRead] = await Promise.all([
db.project.findUnique({
where: { id: projectId },
select: {
id: true,
budgetCents: true,
winProbability: true,
startDate: true,
endDate: true,
},
}),
loadProjectPlanningReadModel(db, { projectId, activeOnly: true }),
]);
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const { demandRequirements, assignments, readModel: projectReadModel } = planningRead;
const resourceIds = getAssignmentResourceIds(projectReadModel);
const allAssignmentWindows =
resourceIds.length === 0
? []
: (
await listAssignmentBookings(db, {
resourceIds,
})
).map((booking) => ({
id: booking.id,
resourceId: booking.resourceId!,
projectId: booking.projectId,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
}));
const shiftPlan = buildTimelineShiftPlan({
demandRequirements,
assignments,
allAssignmentWindows,
});
return {
project,
demandRequirements,
assignments,
shiftPlan,
};
}
export const timelineRouter = createTRPCRouter({
/**
* Get all timeline entries (projects + allocations) for a date range.
* Includes project startDate, endDate, staffingReqs for demand overlay.
*/
getEntries: protectedProcedure
.input(
z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
}),
)
.query(async ({ ctx, input }) => {
const readModel = await loadTimelineEntriesReadModel(ctx.db, input);
return readModel.allocations;
}),
getEntriesView: protectedProcedure
.input(
z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
resourceIds: z.array(z.string()).optional(),
projectIds: z.array(z.string()).optional(),
}),
)
.query(async ({ ctx, input }) => loadTimelineEntriesReadModel(ctx.db, input)),
/**
* Get full project context for a project:
* - project with staffingReqs and budget
* - all active planning entries on this project
* - all assignment bookings for the same resources (for cross-project overlap display)
* Used when: drag starts or project panel opens.
*/
getProjectContext: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const [project, planningRead] = await Promise.all([
ctx.db.project.findUnique({
where: { id: input.projectId },
select: {
id: true,
name: true,
shortCode: true,
orderType: true,
budgetCents: true,
winProbability: true,
status: true,
startDate: true,
endDate: true,
staffingReqs: true,
},
}),
loadProjectPlanningReadModel(ctx.db, {
projectId: input.projectId,
activeOnly: true,
}),
]);
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const resourceIds = getAssignmentResourceIds(planningRead.readModel);
const allResourceAllocations =
resourceIds.length === 0
? []
: await listAssignmentBookings(ctx.db, {
resourceIds,
});
return {
project,
allocations: planningRead.readModel.allocations,
demands: planningRead.readModel.demands,
assignments: planningRead.readModel.assignments,
allResourceAllocations,
resourceIds,
};
}),
/**
* Inline update of an allocation's hours, dates, includeSaturday, or role.
* Recalculates dailyCostCents and emits SSE.
*/
updateAllocationInline: managerProcedure
.input(UpdateAllocationHoursSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const resolved = await loadAllocationEntry(ctx.db, input.allocationId);
const existing = resolved.entry;
const existingResource = resolved.resourceId
? await ctx.db.resource.findUnique({
where: { id: resolved.resourceId },
select: { id: true, lcrCents: true, availability: true },
})
: null;
const newHoursPerDay = input.hoursPerDay ?? existing.hoursPerDay;
const newStartDate = input.startDate ?? existing.startDate;
const newEndDate = input.endDate ?? existing.endDate;
if (newEndDate < newStartDate) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "End date must be after start date",
});
}
// Merge includeSaturday into metadata
const existingMeta = (existing.metadata as Record<string, unknown>) ?? {};
const newMeta: Record<string, unknown> = {
...existingMeta,
...(input.includeSaturday !== undefined
? { includeSaturday: input.includeSaturday }
: {}),
};
const includeSaturday =
input.includeSaturday ?? (existingMeta.includeSaturday as boolean | undefined) ?? false;
// For placeholder allocations (no resource), dailyCostCents stays 0
let newDailyCostCents = 0;
if (resolved.resourceId) {
if (!existingResource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
const availability =
existingResource.availability as unknown as import("@planarchy/shared").WeekdayAvailability;
// Load recurrence from merged metadata
const recurrence = (newMeta.recurrence as import("@planarchy/shared").RecurrencePattern | undefined);
// Load approved vacations for recalculation (graceful fallback if table not yet migrated)
const vacationDates: Date[] = [];
try {
const vacations = await ctx.db.vacation.findMany({
where: {
resourceId: resolved.resourceId,
status: "APPROVED",
startDate: { lte: newEndDate },
endDate: { gte: newStartDate },
},
select: { startDate: true, endDate: true },
});
for (const v of vacations) {
const cur = new Date(v.startDate);
cur.setHours(0, 0, 0, 0);
const vEnd = new Date(v.endDate);
vEnd.setHours(0, 0, 0, 0);
while (cur <= vEnd) {
vacationDates.push(new Date(cur));
cur.setDate(cur.getDate() + 1);
}
}
} catch {
// vacation table may not exist yet — proceed without vacation adjustment
}
newDailyCostCents = calculateAllocation({
lcrCents: existingResource.lcrCents,
hoursPerDay: newHoursPerDay,
startDate: newStartDate,
endDate: newEndDate,
availability,
includeSaturday,
...(recurrence ? { recurrence } : {}),
vacationDates,
}).dailyCostCents;
}
const updated = await ctx.db.$transaction(async (tx) => {
const { allocation: updatedAllocation } = await updateAllocationEntry(
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
{
id: input.allocationId,
demandRequirementUpdate: {
hoursPerDay: newHoursPerDay,
startDate: newStartDate,
endDate: newEndDate,
metadata: newMeta,
...(input.role !== undefined ? { role: input.role } : {}),
},
assignmentUpdate: {
hoursPerDay: newHoursPerDay,
startDate: newStartDate,
endDate: newEndDate,
dailyCostCents: newDailyCostCents,
metadata: newMeta,
...(input.role !== undefined ? { role: input.role } : {}),
},
},
);
await tx.auditLog.create({
data: {
entityType: "Allocation",
entityId: input.allocationId,
action: "UPDATE",
changes: {
before: {
id: resolved.entry.id,
hoursPerDay: existing.hoursPerDay,
startDate: existing.startDate,
endDate: existing.endDate,
},
after: {
id: updatedAllocation.id,
hoursPerDay: newHoursPerDay,
startDate: newStartDate,
endDate: newEndDate,
includeSaturday,
},
},
},
});
return updatedAllocation;
});
emitAllocationUpdated({
id: updated.id,
projectId: updated.projectId,
resourceId: updated.resourceId,
});
return updated;
}),
/**
* Preview a project shift — validate without committing.
* Returns cost impact, conflicts, warnings.
*/
previewShift: protectedProcedure
.input(ShiftProjectSchema)
.query(async ({ ctx, input }) => {
const { projectId, newStartDate, newEndDate } = input;
const { project, shiftPlan } = await loadProjectShiftContext(ctx.db, projectId);
return validateShift({
project: {
id: project.id,
budgetCents: project.budgetCents,
winProbability: project.winProbability,
startDate: project.startDate,
endDate: project.endDate,
},
newStartDate,
newEndDate,
allocations: shiftPlan.validationAllocations,
});
}),
/**
* Apply a project shift — validate, then commit all allocation date changes.
* Reads includeSaturday from each allocation's metadata.
*/
applyShift: managerProcedure
.input(ShiftProjectSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const { projectId, newStartDate, newEndDate } = input;
const { project, demandRequirements, assignments, shiftPlan } = await loadProjectShiftContext(
ctx.db,
projectId,
);
// Re-validate before committing
const validation = validateShift({
project: {
id: project.id,
budgetCents: project.budgetCents,
winProbability: project.winProbability,
startDate: project.startDate,
endDate: project.endDate,
},
newStartDate,
newEndDate,
allocations: shiftPlan.validationAllocations,
});
if (!validation.valid) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Shift validation failed: ${validation.errors.map((e) => e.message).join(", ")}`,
});
}
// Apply shift in a transaction
const updatedProject = await ctx.db.$transaction(async (tx) => {
// Update project dates
const proj = await tx.project.update({
where: { id: projectId },
data: { startDate: newStartDate, endDate: newEndDate },
});
for (const demandRequirement of demandRequirements) {
await updateDemandRequirement(
tx as unknown as Parameters<typeof updateDemandRequirement>[0],
demandRequirement.id,
{
startDate: newStartDate,
endDate: newEndDate,
},
);
}
for (const assignment of assignments) {
const metadata = (assignment.metadata as Record<string, unknown> | null | undefined) ?? {};
const includeSaturday = (metadata.includeSaturday as boolean | undefined) ?? false;
const newDailyCost = calculateAllocation({
lcrCents: assignment.resource!.lcrCents,
hoursPerDay: assignment.hoursPerDay,
startDate: newStartDate,
endDate: newEndDate,
availability:
assignment.resource!.availability as unknown as import("@planarchy/shared").WeekdayAvailability,
includeSaturday,
}).dailyCostCents;
await updateAssignment(
tx as unknown as Parameters<typeof updateAssignment>[0],
assignment.id,
{
startDate: newStartDate,
endDate: newEndDate,
dailyCostCents: newDailyCost,
},
);
}
// Write audit log
await tx.auditLog.create({
data: {
entityType: "Project",
entityId: projectId,
action: "SHIFT",
changes: {
before: { startDate: project.startDate, endDate: project.endDate },
after: { startDate: newStartDate, endDate: newEndDate },
costImpact: validation.costImpact,
} as unknown as import("@planarchy/db").Prisma.InputJsonValue,
},
});
return proj;
});
// Emit SSE event for live updates
emitProjectShifted({
projectId,
newStartDate: newStartDate.toISOString(),
newEndDate: newEndDate.toISOString(),
costDeltaCents: validation.costImpact.deltaCents,
});
return { project: updatedProject, validation };
}),
/**
* Quick-assign a resource to a project for a date range.
* Overbooking is intentionally allowed — no availability throw.
* For use from the timeline drag-to-assign UI.
*/
quickAssign: managerProcedure
.input(
z.object({
resourceId: z.string(),
projectId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0.5).max(24).default(8),
role: z.string().min(1).max(200).default("Team Member"),
roleId: z.string().optional(),
status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
if (input.endDate < input.startDate) {
throw new TRPCError({ code: "BAD_REQUEST", message: "End date must be after start date" });
}
const percentage = Math.min(100, Math.round((input.hoursPerDay / 8) * 100));
const metadata = { source: "quickAssign" } satisfies Record<string, unknown>;
const allocation = await ctx.db.$transaction(async (tx) => {
const assignment = await createAssignment(
tx as unknown as Parameters<typeof createAssignment>[0],
{
resourceId: input.resourceId,
projectId: input.projectId,
startDate: input.startDate,
endDate: input.endDate,
hoursPerDay: input.hoursPerDay,
percentage,
role: input.role,
roleId: input.roleId ?? undefined,
status: input.status,
metadata,
},
);
return buildSplitAllocationReadModel({
demandRequirements: [],
assignments: [assignment],
}).allocations[0]!;
});
emitAllocationCreated({
id: allocation.id,
projectId: allocation.projectId,
resourceId: allocation.resourceId,
});
return allocation;
}),
/**
* Get budget status for a project.
*/
getBudgetStatus: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const project = await ctx.db.project.findUnique({
where: { id: input.projectId },
select: {
id: true,
budgetCents: true,
winProbability: true,
startDate: true,
endDate: true,
},
});
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const bookings = await listAssignmentBookings(ctx.db, {
startDate: project.startDate,
endDate: project.endDate,
projectIds: [project.id],
});
return computeBudgetStatus(
project.budgetCents,
project.winProbability,
bookings.map((booking) => ({
status: booking.status,
dailyCostCents: booking.dailyCostCents,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
})) as unknown as Pick<import("@planarchy/shared").Allocation, "status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay">[],
project.startDate,
project.endDate,
);
}),
});
+211
View File
@@ -0,0 +1,211 @@
import {
PermissionOverrides,
SystemRole,
resolvePermissions,
type ColumnPreferences,
} from "@planarchy/shared/types";
import {
dashboardLayoutSchema,
normalizeDashboardLayout,
} from "@planarchy/shared/schemas";
import { Prisma } from "@planarchy/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
export const userRouter = createTRPCRouter({
list: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.user.findMany({
select: {
id: true,
name: true,
email: true,
systemRole: true,
createdAt: true,
},
orderBy: { name: "asc" },
});
}),
me: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
select: {
id: true,
name: true,
email: true,
systemRole: true,
permissionOverrides: true,
createdAt: true,
},
});
if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
return user;
}),
create: adminProcedure
.input(
z.object({
email: z.string().email(),
name: z.string().min(1),
systemRole: z.nativeEnum(SystemRole).default(SystemRole.USER),
password: z.string().min(8),
}),
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.user.findUnique({ where: { email: input.email } });
if (existing) {
throw new TRPCError({ code: "CONFLICT", message: "User with this email already exists" });
}
const { hash } = await import("@node-rs/argon2");
const passwordHash = await hash(input.password);
return ctx.db.user.create({
data: {
email: input.email,
name: input.name,
systemRole: input.systemRole,
passwordHash,
},
select: { id: true, name: true, email: true, systemRole: true },
});
}),
updateRole: adminProcedure
.input(
z.object({
id: z.string(),
systemRole: z.nativeEnum(SystemRole),
}),
)
.mutation(async ({ ctx, input }) => {
return ctx.db.user.update({
where: { id: input.id },
data: { systemRole: input.systemRole },
select: { id: true, name: true, email: true, systemRole: true },
});
}),
getDashboardLayout: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
select: { dashboardLayout: true, updatedAt: true },
});
return {
layout: user?.dashboardLayout ? normalizeDashboardLayout(user.dashboardLayout) : null,
updatedAt: user?.updatedAt ?? null,
};
}),
saveDashboardLayout: protectedProcedure
.input(z.object({ layout: dashboardLayoutSchema }))
.mutation(async ({ ctx, input }) => {
const updated = await ctx.db.user.update({
where: { email: ctx.session.user?.email ?? "" },
data: { dashboardLayout: input.layout as unknown as import("@planarchy/db").Prisma.InputJsonValue },
select: { updatedAt: true },
});
return { updatedAt: updated.updatedAt };
}),
setPermissions: adminProcedure
.input(
z.object({
userId: z.string(),
overrides: z
.object({
granted: z.array(z.string()).optional(),
denied: z.array(z.string()).optional(),
chapterIds: z.array(z.string()).optional(),
})
.nullable(),
}),
)
.mutation(async ({ ctx, input }) => {
const user = await ctx.db.user.update({
where: { id: input.userId },
data: { permissionOverrides: input.overrides ?? Prisma.DbNull },
});
return user;
}),
resetPermissions: adminProcedure
.input(z.object({ userId: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.user.update({
where: { id: input.userId },
data: { permissionOverrides: Prisma.DbNull },
});
}),
getColumnPreferences: protectedProcedure.query(async ({ ctx }) => {
const user = await ctx.db.user.findUnique({
where: { id: ctx.dbUser!.id },
select: { columnPreferences: true },
});
return (user?.columnPreferences ?? {}) as ColumnPreferences;
}),
setColumnPreferences: protectedProcedure
.input(z.object({
view: z.enum(["resources", "projects", "allocations", "vacations", "roles", "users", "blueprints"]),
visible: z.array(z.string()).optional(),
sort: z.object({ field: z.string(), dir: z.enum(["asc", "desc"]) }).nullable().optional(),
rowOrder: z.array(z.string()).nullable().optional(),
}))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.user.findUnique({
where: { id: ctx.dbUser!.id },
select: { columnPreferences: true },
});
const prefs = (existing?.columnPreferences ?? {}) as ColumnPreferences;
const prev = (prefs[input.view] as import("@planarchy/shared").ViewPreferences | undefined) ?? { visible: [] };
// Merge: only overwrite fields that were explicitly provided
const merged: import("@planarchy/shared").ViewPreferences = {
visible: input.visible ?? prev.visible,
};
// sort: null = clear, undefined = keep existing, value = set
if (input.sort !== null && input.sort !== undefined) {
merged.sort = input.sort;
} else if (input.sort === undefined && prev.sort != null) {
merged.sort = prev.sort;
}
// rowOrder: null = clear, undefined = keep existing, value = set
if (input.rowOrder !== null && input.rowOrder !== undefined) {
merged.rowOrder = input.rowOrder;
} else if (input.rowOrder === undefined && prev.rowOrder != null) {
merged.rowOrder = prev.rowOrder;
}
prefs[input.view] = merged;
await ctx.db.user.update({
where: { id: ctx.dbUser!.id },
data: { columnPreferences: prefs as Prisma.InputJsonValue },
});
return { ok: true };
}),
getEffectivePermissions: adminProcedure
.input(z.object({ userId: z.string() }))
.query(async ({ ctx, input }) => {
const user = await ctx.db.user.findUniqueOrThrow({
where: { id: input.userId },
select: { systemRole: true, permissionOverrides: true },
});
const permissions = resolvePermissions(
user.systemRole as SystemRole,
user.permissionOverrides as PermissionOverrides | null,
);
return {
systemRole: user.systemRole,
effectivePermissions: Array.from(permissions),
overrides: user.permissionOverrides as PermissionOverrides | null,
};
}),
});
@@ -0,0 +1,92 @@
import {
CreateUtilizationCategorySchema,
UpdateUtilizationCategorySchema,
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
export const utilizationCategoryRouter = createTRPCRouter({
list: protectedProcedure
.input(z.object({ isActive: z.boolean().optional() }).optional())
.query(async ({ ctx, input }) => {
return ctx.db.utilizationCategory.findMany({
where: {
...(input?.isActive !== undefined ? { isActive: input.isActive } : {}),
},
orderBy: { sortOrder: "asc" },
});
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const cat = await ctx.db.utilizationCategory.findUnique({
where: { id: input.id },
include: { _count: { select: { projects: true } } },
});
if (!cat) throw new TRPCError({ code: "NOT_FOUND", message: "Utilization category not found" });
return cat;
}),
create: adminProcedure
.input(CreateUtilizationCategorySchema)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.utilizationCategory.findUnique({ where: { code: input.code } });
if (existing) {
throw new TRPCError({ code: "CONFLICT", message: `Code "${input.code}" already exists` });
}
// If setting as default, unset the current default first
if (input.isDefault) {
await ctx.db.utilizationCategory.updateMany({
where: { isDefault: true },
data: { isDefault: false },
});
}
return ctx.db.utilizationCategory.create({
data: {
code: input.code,
name: input.name,
...(input.description !== undefined ? { description: input.description } : {}),
sortOrder: input.sortOrder,
isDefault: input.isDefault,
},
});
}),
update: adminProcedure
.input(z.object({ id: z.string(), data: UpdateUtilizationCategorySchema }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.utilizationCategory.findUnique({ where: { id: input.id } });
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Utilization category not found" });
if (input.data.code && input.data.code !== existing.code) {
const conflict = await ctx.db.utilizationCategory.findUnique({ where: { code: input.data.code } });
if (conflict) {
throw new TRPCError({ code: "CONFLICT", message: `Code "${input.data.code}" already exists` });
}
}
// If setting as default, unset others
if (input.data.isDefault) {
await ctx.db.utilizationCategory.updateMany({
where: { isDefault: true, id: { not: input.id } },
data: { isDefault: false },
});
}
return ctx.db.utilizationCategory.update({
where: { id: input.id },
data: {
...(input.data.code !== undefined ? { code: input.data.code } : {}),
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.description !== undefined ? { description: input.data.description } : {}),
...(input.data.sortOrder !== undefined ? { sortOrder: input.data.sortOrder } : {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
...(input.data.isDefault !== undefined ? { isDefault: input.data.isDefault } : {}),
},
});
}),
});
+549
View File
@@ -0,0 +1,549 @@
import { UpdateVacationStatusSchema, getPublicHolidays } from "@planarchy/shared";
import { VacationStatus, VacationType } from "@planarchy/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { emitVacationCreated, emitVacationUpdated, emitNotificationCreated } from "../sse/event-bus.js";
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
import { sendEmail } from "../lib/email.js";
/** Types that consume from annual leave balance */
const BALANCE_TYPES = [VacationType.ANNUAL, VacationType.OTHER];
/** Send in-app notification + optional email when vacation status changes */
async function notifyVacationStatus(
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
vacationId: string,
resourceId: string,
newStatus: VacationStatus,
rejectionReason?: string | null,
) {
// Find the resource's linked user
const resource = await db.resource.findUnique({
where: { id: resourceId },
select: {
displayName: true,
user: { select: { id: true, email: true, name: true } },
},
});
if (!resource?.user) return;
const statusLabel = newStatus === VacationStatus.APPROVED ? "approved" : "rejected";
const title = `Vacation request ${statusLabel}`;
const body = rejectionReason
? `Your vacation request was ${statusLabel}. Reason: ${rejectionReason}`
: `Your vacation request has been ${statusLabel}.`;
// In-app notification
const notification = await db.notification.create({
data: {
userId: resource.user.id,
type: `VACATION_${newStatus}`,
title,
body,
entityId: vacationId,
entityType: "vacation",
},
});
emitNotificationCreated(resource.user.id, notification.id);
// Email (non-blocking)
if (resource.user.email) {
void sendEmail({
to: resource.user.email,
subject: `Planarchy — ${title}`,
text: body,
});
}
}
export const vacationRouter = createTRPCRouter({
/**
* List vacations with optional filters.
*/
list: protectedProcedure
.input(
z.object({
resourceId: z.string().optional(),
status: z.nativeEnum(VacationStatus).optional(),
type: z.nativeEnum(VacationType).optional(),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
limit: z.number().min(1).max(500).default(100),
}),
)
.query(async ({ ctx, input }) => {
return ctx.db.vacation.findMany({
where: {
...(input.resourceId ? { resourceId: input.resourceId } : {}),
...(input.status ? { status: input.status } : {}),
...(input.type ? { type: input.type } : {}),
...(input.startDate ? { endDate: { gte: input.startDate } } : {}),
...(input.endDate ? { startDate: { lte: input.endDate } } : {}),
},
include: {
resource: { select: { id: true, displayName: true, eid: true } },
requestedBy: { select: { id: true, name: true, email: true } },
approvedBy: { select: { id: true, name: true, email: true } },
},
orderBy: { startDate: "asc" },
take: input.limit,
});
}),
/**
* Get a single vacation by ID.
*/
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const vacation = await ctx.db.vacation.findUnique({
where: { id: input.id },
include: {
resource: { select: { id: true, displayName: true, eid: true } },
requestedBy: { select: { id: true, name: true, email: true } },
approvedBy: { select: { id: true, name: true, email: true } },
},
});
if (!vacation) {
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
}
return vacation;
}),
/**
* Create a vacation request.
* - MANAGER/ADMIN → auto-approved
* - USER → PENDING
* 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"],
}),
)
.mutation(async ({ ctx, input }) => {
const userRecord = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
select: { id: true, systemRole: true },
});
if (!userRecord) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
// Check for overlapping APPROVED or PENDING vacations
const overlapping = await ctx.db.vacation.findFirst({
where: {
resourceId: input.resourceId,
status: { in: ["APPROVED", "PENDING"] },
startDate: { lte: input.endDate },
endDate: { gte: input.startDate },
},
});
if (overlapping) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Overlapping vacation already exists for this resource in the selected period",
});
}
const isManager = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER";
const status = isManager ? VacationStatus.APPROVED : VacationStatus.PENDING;
const vacation = await ctx.db.vacation.create({
data: {
resourceId: input.resourceId,
type: input.type,
status,
startDate: input.startDate,
endDate: input.endDate,
...(input.note !== undefined ? { note: input.note } : {}),
isHalfDay: input.isHalfDay ?? false,
...(input.halfDayPart !== undefined ? { halfDayPart: input.halfDayPart } : {}),
requestedById: userRecord.id,
...(isManager
? { approvedById: userRecord.id, approvedAt: new Date() }
: {}),
},
include: {
resource: { select: { id: true, displayName: true, eid: true } },
requestedBy: { select: { id: true, name: true, email: true } },
},
});
emitVacationCreated({ id: vacation.id, resourceId: vacation.resourceId, status: vacation.status });
return vacation;
}),
/**
* Approve a vacation (manager/admin only).
*/
approve: managerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.vacation.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
}
const approvableStatuses: string[] = [VacationStatus.PENDING, VacationStatus.CANCELLED, VacationStatus.REJECTED];
if (!approvableStatuses.includes(existing.status)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Only PENDING, CANCELLED, or REJECTED vacations can be approved" });
}
const userRecord = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
select: { id: true },
});
const updated = await ctx.db.vacation.update({
where: { id: input.id },
data: {
status: VacationStatus.APPROVED,
rejectionReason: null,
...(userRecord?.id ? { approvedById: userRecord.id } : {}),
approvedAt: new Date(),
},
});
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
if (existing.status === VacationStatus.PENDING) {
void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED);
}
return updated;
}),
/**
* Reject a vacation (manager/admin only).
*/
reject: managerProcedure
.input(z.object({ id: z.string(), rejectionReason: z.string().max(500).optional() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.vacation.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
}
if (existing.status !== VacationStatus.PENDING) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Only PENDING vacations can be rejected" });
}
const updated = await ctx.db.vacation.update({
where: { id: input.id },
data: {
status: VacationStatus.REJECTED,
...(input.rejectionReason !== undefined ? { rejectionReason: input.rejectionReason } : {}),
},
});
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.REJECTED, input.rejectionReason);
return updated;
}),
/**
* Batch approve multiple pending vacations (manager/admin only).
*/
batchApprove: managerProcedure
.input(z.object({ ids: z.array(z.string()).min(1).max(100) }))
.mutation(async ({ ctx, input }) => {
const userRecord = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
select: { id: true },
});
const vacations = await ctx.db.vacation.findMany({
where: { id: { in: input.ids }, status: VacationStatus.PENDING },
select: { id: true, resourceId: true },
});
await ctx.db.vacation.updateMany({
where: { id: { in: vacations.map((v) => v.id) } },
data: {
status: VacationStatus.APPROVED,
rejectionReason: null,
...(userRecord?.id ? { approvedById: userRecord.id } : {}),
approvedAt: new Date(),
},
});
for (const v of vacations) {
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.APPROVED });
void notifyVacationStatus(ctx.db, v.id, v.resourceId, VacationStatus.APPROVED);
}
return { approved: vacations.length };
}),
/**
* Batch reject multiple pending vacations (manager/admin only).
*/
batchReject: managerProcedure
.input(
z.object({
ids: z.array(z.string()).min(1).max(100),
rejectionReason: z.string().max(500).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const vacations = await ctx.db.vacation.findMany({
where: { id: { in: input.ids }, status: VacationStatus.PENDING },
select: { id: true, resourceId: true },
});
await ctx.db.vacation.updateMany({
where: { id: { in: vacations.map((v) => v.id) } },
data: {
status: VacationStatus.REJECTED,
...(input.rejectionReason !== undefined ? { rejectionReason: input.rejectionReason } : {}),
},
});
for (const v of vacations) {
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.REJECTED });
void notifyVacationStatus(ctx.db, v.id, v.resourceId, VacationStatus.REJECTED, input.rejectionReason);
}
return { rejected: vacations.length };
}),
/**
* Cancel a vacation (owner or manager).
*/
cancel: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.vacation.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
}
if (existing.status === VacationStatus.CANCELLED) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Already cancelled" });
}
const updated = await ctx.db.vacation.update({
where: { id: input.id },
data: { status: VacationStatus.CANCELLED },
});
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
return updated;
}),
/**
* Get all APPROVED vacations for a resource in a date range (used by calculator).
*/
getForResource: protectedProcedure
.input(
z.object({
resourceId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
}),
)
.query(async ({ ctx, input }) => {
return ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
status: VacationStatus.APPROVED,
startDate: { lte: input.endDate },
endDate: { gte: input.startDate },
},
select: {
id: true,
startDate: true,
endDate: true,
type: true,
status: true,
},
orderBy: { startDate: "asc" },
});
}),
/**
* Get all PENDING vacations awaiting approval (manager/admin only).
*/
getPendingApprovals: managerProcedure.query(async ({ ctx }) => {
return ctx.db.vacation.findMany({
where: { status: VacationStatus.PENDING },
include: {
resource: { select: { id: true, displayName: true, eid: true } },
requestedBy: { select: { id: true, name: true, email: true } },
},
orderBy: { startDate: "asc" },
});
}),
/**
* Get team overlap: other vacations in the same chapter for a given period.
* Used by the creation modal to warn the requester.
*/
getTeamOverlap: protectedProcedure
.input(
z.object({
resourceId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
}),
)
.query(async ({ ctx, input }) => {
// Find the chapter of the requesting resource
const resource = await ctx.db.resource.findUnique({
where: { id: input.resourceId },
select: { chapter: true },
});
if (!resource?.chapter) return [];
// Find team members in the same chapter who are off in this period
return ctx.db.vacation.findMany({
where: {
resource: { chapter: resource.chapter },
resourceId: { not: input.resourceId },
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
startDate: { lte: input.endDate },
endDate: { gte: input.startDate },
},
include: {
resource: { select: { id: true, displayName: true, eid: true } },
},
orderBy: { startDate: "asc" },
take: 20,
});
}),
/**
* Batch-create public holidays for all resources (or a chapter) for a given year+state.
* Admin-only. Creates as APPROVED automatically.
*/
batchCreatePublicHolidays: adminProcedure
.input(
z.object({
year: z.number().int().min(2000).max(2100),
federalState: z.string().optional(), // e.g. "BY"
chapter: z.string().optional(), // filter to a chapter
replaceExisting: z.boolean().default(false),
}),
)
.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 },
});
const adminUser = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
select: { id: true },
});
if (!adminUser) throw new TRPCError({ code: "UNAUTHORIZED" });
let created = 0;
for (const resource of resources) {
for (const holiday of holidays) {
const startDate = new Date(holiday.date);
const endDate = new Date(holiday.date);
if (input.replaceExisting) {
// Remove any existing public holiday on this exact date for this resource
await ctx.db.vacation.deleteMany({
where: {
resourceId: resource.id,
type: VacationType.PUBLIC_HOLIDAY,
startDate,
endDate,
},
});
}
// Check if one already exists
const exists = await ctx.db.vacation.findFirst({
where: {
resourceId: resource.id,
type: VacationType.PUBLIC_HOLIDAY,
startDate,
endDate,
},
});
if (exists) continue;
await ctx.db.vacation.create({
data: {
resourceId: resource.id,
type: VacationType.PUBLIC_HOLIDAY,
status: VacationStatus.APPROVED,
startDate,
endDate,
note: holiday.name,
requestedById: adminUser.id,
approvedById: adminUser.id,
approvedAt: new Date(),
},
});
created++;
}
}
return { created, holidays: holidays.length, resources: resources.length };
}),
/**
* Update vacation status (approve/reject/cancel via schema).
*/
updateStatus: protectedProcedure
.input(UpdateVacationStatusSchema)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.vacation.findUnique({ where: { id: input.id } });
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
}
const userRecord = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
select: { id: true, systemRole: true },
});
if (!userRecord) throw new TRPCError({ code: "UNAUTHORIZED" });
const isManager = userRecord.systemRole === "ADMIN" || userRecord.systemRole === "MANAGER";
if (input.status !== "CANCELLED" && !isManager) {
throw new TRPCError({ code: "FORBIDDEN", message: "Manager role required to approve/reject" });
}
const data: Record<string, unknown> = { status: input.status };
if (input.status === "APPROVED") {
data.approvedById = userRecord.id;
data.approvedAt = new Date();
data.rejectionReason = null;
}
if (input.note !== undefined) {
data.note = input.note;
}
const updated = await ctx.db.vacation.update({
where: { id: input.id },
data,
});
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
return updated;
}),
});
+133
View File
@@ -0,0 +1,133 @@
import { Redis } from "ioredis";
import { SSE_EVENT_TYPES, type SseEventType } from "@planarchy/shared";
export interface SseEvent {
type: SseEventType;
payload: Record<string, unknown>;
timestamp: string;
}
type Subscriber = (event: SseEvent) => void;
// Module-level subscriber registry (shared between EventBus and publishLocal)
const subscribers = new Set<Subscriber>();
// Redis connection — use env var REDIS_URL or fallback to default dev URL
const REDIS_URL = process.env["REDIS_URL"] ?? "redis://localhost:6380";
const CHANNEL = "planarchy:sse";
let publisher: Redis | null = null;
let subscriber: Redis | null = null;
function getPublisher(): Redis {
if (!publisher) {
publisher = new Redis(REDIS_URL, { lazyConnect: false, enableReadyCheck: false });
publisher.on("error", (e: unknown) => console.error("[Redis publisher]", e));
}
return publisher;
}
function setupSubscriber(): void {
if (subscriber) return;
try {
subscriber = new Redis(REDIS_URL, { lazyConnect: false, enableReadyCheck: false });
subscriber.on("error", (e: unknown) => console.error("[Redis subscriber]", e));
void subscriber.subscribe(CHANNEL).catch((err: unknown) => {
console.error("[Redis subscribe]", err);
});
subscriber.on("message", (_channel: string, message: string) => {
try {
const parsed = JSON.parse(message) as { type: SseEventType; payload: Record<string, unknown>; timestamp: string };
publishLocal({ type: parsed.type, payload: parsed.payload, timestamp: parsed.timestamp });
} catch { /* ignore parse errors */ }
});
} catch (e) {
console.warn("[Redis setupSubscriber] Redis unavailable, SSE will be local-only:", e);
}
}
/**
* SSE Event Bus with Redis Pub/Sub for multi-instance support.
* Gracefully degrades to in-memory delivery when Redis is unavailable.
*/
class EventBus {
subscribe(fn: Subscriber): () => void {
subscribers.add(fn);
return () => subscribers.delete(fn);
}
publish(event: SseEvent): void {
// Broadcast via Redis (all instances receive via subscriber.on("message"))
try {
const pub = getPublisher();
void pub.publish(CHANNEL, JSON.stringify({ type: event.type, payload: event.payload, timestamp: event.timestamp }));
} catch (e) {
console.warn("[Redis emit] fallback to local-only:", e);
// Deliver locally when Redis is unavailable
publishLocal(event);
}
}
emit(type: SseEventType, payload: Record<string, unknown>): void {
this.publish({
type,
payload,
timestamp: new Date().toISOString(),
});
}
get subscriberCount(): number {
return subscribers.size;
}
}
// Local delivery: deliver to subscribers connected to THIS instance (called from Redis subscriber)
function publishLocal(event: SseEvent): void {
for (const fn of subscribers) {
fn(event);
}
}
// Singleton event bus
export const eventBus = new EventBus();
// Start Redis subscriber once at module init (best-effort)
setupSubscriber();
// Helper emitters
export const emitAllocationCreated = (allocation: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, allocation);
export const emitAllocationUpdated = (allocation: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, allocation);
export const emitAllocationDeleted = (allocationId: string, projectId: string) =>
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_DELETED, { allocationId, projectId });
export const emitProjectShifted = (project: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.PROJECT_SHIFTED, project);
export const emitBudgetWarning = (projectId: string, payload: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.BUDGET_WARNING, { projectId, ...payload });
export const emitVacationCreated = (vacation: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.VACATION_CREATED, vacation);
export const emitVacationUpdated = (vacation: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.VACATION_UPDATED, vacation);
export const emitVacationDeleted = (vacationId: string, resourceId: string) =>
eventBus.emit(SSE_EVENT_TYPES.VACATION_DELETED, { vacationId, resourceId });
export const emitRoleCreated = (role: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.ROLE_CREATED, role);
export const emitRoleUpdated = (role: Record<string, unknown>) =>
eventBus.emit(SSE_EVENT_TYPES.ROLE_UPDATED, role);
export const emitRoleDeleted = (roleId: string) =>
eventBus.emit(SSE_EVENT_TYPES.ROLE_DELETED, { roleId });
export function emitNotificationCreated(userId: string, notificationId: string): void {
eventBus.emit(SSE_EVENT_TYPES.NOTIFICATION_CREATED, { userId, notificationId });
}
+129
View File
@@ -0,0 +1,129 @@
import { prisma } from "@planarchy/db";
import { resolvePermissions, PermissionKey, SystemRole } from "@planarchy/shared";
import { initTRPC, TRPCError } from "@trpc/server";
import { ZodError } from "zod";
// Minimal Session type to avoid next-auth peer-dep in this package
interface Session {
user?: { email?: string | null; name?: string | null; image?: string | null } | null;
expires: string;
}
// ─── Context ──────────────────────────────────────────────────────────────────
export interface TRPCContext {
session: Session | null;
db: typeof prisma;
dbUser: { id: string; systemRole: string; permissionOverrides: unknown } | null;
}
export function createTRPCContext(opts: {
session: Session | null;
dbUser?: { id: string; systemRole: string; permissionOverrides: unknown } | null;
}): TRPCContext {
return {
session: opts.session,
db: prisma,
dbUser: opts.dbUser ?? null,
};
}
// ─── tRPC Init ───────────────────────────────────────────────────────────────
const t = initTRPC.context<TRPCContext>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
// ─── Procedures ──────────────────────────────────────────────────────────────
export const createTRPCRouter = t.router;
export const createCallerFactory = t.createCallerFactory;
/**
* Public procedure — no authentication required.
*/
export const publicProcedure = t.procedure;
/**
* Protected procedure — requires any authenticated session.
*/
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" });
}
return next({
ctx: {
...ctx,
session: ctx.session,
user: ctx.session.user,
},
});
});
/**
* Manager procedure — requires MANAGER or ADMIN role.
*/
export const managerProcedure = protectedProcedure.use(({ ctx, next }) => {
const user = ctx.dbUser;
if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });
const allowedRoles: string[] = [SystemRole.ADMIN, SystemRole.MANAGER];
if (!allowedRoles.includes(user.systemRole)) {
throw new TRPCError({ code: "FORBIDDEN", message: "Manager or Admin role required" });
}
const permissions = resolvePermissions(
user.systemRole as SystemRole,
user.permissionOverrides as import("@planarchy/shared").PermissionOverrides | null
);
return next({ ctx: { ...ctx, user, permissions } });
});
/**
* Controller procedure — requires CONTROLLER, MANAGER, or ADMIN role.
* Grants read-only access to financial and export data.
*/
export const controllerProcedure = protectedProcedure.use(({ ctx, next }) => {
const user = ctx.dbUser;
if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });
const allowed: SystemRole[] = [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER];
if (!allowed.includes(user.systemRole as SystemRole)) {
throw new TRPCError({ code: "FORBIDDEN", message: "Controller access required" });
}
const permissions = resolvePermissions(
user.systemRole as SystemRole,
user.permissionOverrides as import("@planarchy/shared").PermissionOverrides | null
);
return next({ ctx: { ...ctx, user, permissions } });
});
/**
* Admin procedure — requires ADMIN role only.
*/
export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
const user = ctx.dbUser;
if (!user || user.systemRole !== SystemRole.ADMIN) {
throw new TRPCError({ code: "FORBIDDEN", message: "Admin role required" });
}
const permissions = resolvePermissions(SystemRole.ADMIN, null);
return next({ ctx: { ...ctx, user, permissions } });
});
/**
* requirePermission — throws FORBIDDEN if the ctx lacks the given permission.
*/
export function requirePermission(
ctx: { permissions: Set<PermissionKey> },
key: PermissionKey
): void {
if (!ctx.permissions.has(key)) {
throw new TRPCError({ code: "FORBIDDEN", message: `Permission required: ${key}` });
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@planarchy/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/__tests__"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
},
});
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@planarchy/application",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test:unit": "vitest run"
},
"dependencies": {
"@planarchy/db": "workspace:*",
"@planarchy/engine": "workspace:*",
"@planarchy/shared": "workspace:*",
"@trpc/server": "^11.0.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@planarchy/tsconfig": "workspace:*",
"@types/node": "^22.10.2",
"typescript": "^5.6.3",
"vitest": "^2.1.8"
}
}
@@ -0,0 +1,178 @@
import { AllocationStatus } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { updateAllocationEntry } from "../index.js";
describe("allocation entry resolution helpers", () => {
it("updates an explicit demand requirement", async () => {
const existingDemand = {
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "FX Artist",
roleId: "role_fx",
headcount: 1,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
};
const updatedDemand = {
...existingDemand,
headcount: 2,
status: AllocationStatus.CONFIRMED,
metadata: { source: "entry-test" },
updatedAt: new Date("2026-03-14"),
};
const demandRequirementFindUnique = vi.fn().mockResolvedValue(existingDemand);
const demandRequirementUpdate = vi.fn().mockResolvedValue(updatedDemand);
const assignmentFindUnique = vi.fn().mockResolvedValue(null);
const assignmentUpdate = vi.fn();
const auditLogCreate = vi.fn().mockResolvedValue({});
const result = await updateAllocationEntry(
{
demandRequirement: {
findUnique: demandRequirementFindUnique,
update: demandRequirementUpdate,
},
assignment: {
findUnique: assignmentFindUnique,
update: assignmentUpdate,
},
auditLog: { create: auditLogCreate },
} as never,
{
id: "demand_1",
demandRequirementUpdate: {
headcount: 2,
status: AllocationStatus.CONFIRMED,
metadata: { source: "entry-test" },
},
assignmentUpdate: {},
},
);
expect(result.strategy).toBe("explicit_demand");
expect(result.allocation.id).toBe("demand_1");
expect(result.allocation.isPlaceholder).toBe(true);
expect(result.allocation.headcount).toBe(2);
expect(demandRequirementUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "demand_1" },
data: expect.objectContaining({
headcount: 2,
status: AllocationStatus.CONFIRMED,
}),
}),
);
expect(assignmentUpdate).not.toHaveBeenCalled();
});
it("updates an explicit assignment", async () => {
const existingAssignment = {
id: "assignment_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 20000,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: {
id: "resource_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 5000,
},
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: null,
};
const updatedAssignment = {
...existingAssignment,
hoursPerDay: 6,
percentage: 75,
dailyCostCents: 30000,
status: AllocationStatus.ACTIVE,
updatedAt: new Date("2026-03-14"),
};
const demandRequirementFindUnique = vi.fn().mockResolvedValue(null);
const assignmentFindUnique = vi.fn().mockResolvedValue(existingAssignment);
const assignmentUpdate = vi.fn().mockResolvedValue(updatedAssignment);
const auditLogCreate = vi.fn().mockResolvedValue({});
const result = await updateAllocationEntry(
{
demandRequirement: {
findUnique: demandRequirementFindUnique,
},
assignment: {
findUnique: assignmentFindUnique,
update: assignmentUpdate,
},
auditLog: { create: auditLogCreate },
} as never,
{
id: "assignment_1",
demandRequirementUpdate: {},
assignmentUpdate: {
hoursPerDay: 6,
percentage: 75,
dailyCostCents: 30000,
status: AllocationStatus.ACTIVE,
},
},
);
expect(result.strategy).toBe("explicit_assignment");
expect(result.allocation.id).toBe("assignment_1");
expect(assignmentUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "assignment_1" },
}),
);
});
it("throws NOT_FOUND when the id does not match any demand or assignment", async () => {
const demandRequirementFindUnique = vi.fn().mockResolvedValue(null);
const assignmentFindUnique = vi.fn().mockResolvedValue(null);
await expect(
updateAllocationEntry(
{
demandRequirement: {
findUnique: demandRequirementFindUnique,
},
assignment: {
findUnique: assignmentFindUnique,
},
} as never,
{
id: "nonexistent_id",
demandRequirementUpdate: {
status: AllocationStatus.CONFIRMED,
},
assignmentUpdate: {},
},
),
).rejects.toMatchObject({
code: "NOT_FOUND",
message: "Allocation not found",
});
});
});
@@ -0,0 +1,237 @@
import { AllocationStatus } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import {
createAssignment,
updateAssignment,
updateDemandRequirement,
} from "../index.js";
describe("allocation entry update flows", () => {
it("excludes the current assignment from conflict checks during update", async () => {
const projectFindUnique = vi.fn().mockResolvedValue({ id: "project_1" });
const resourceFindUnique = vi.fn().mockResolvedValue({
id: "resource_1",
lcrCents: 5000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
});
const assignmentFindMany = vi.fn().mockResolvedValue([]);
const vacationFindMany = vi.fn().mockResolvedValue([]);
const assignmentCreate = vi.fn().mockResolvedValue({
id: "assignment_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-27"),
hoursPerDay: 4,
percentage: 50,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 20000,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
resource: { id: "resource_1", displayName: "Alice", eid: "E-001", lcrCents: 5000 },
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: null,
});
const auditLogCreate = vi.fn().mockResolvedValue({});
await createAssignment(
{
project: { findUnique: projectFindUnique },
resource: { findUnique: resourceFindUnique },
assignment: { findMany: assignmentFindMany, create: assignmentCreate },
vacation: { findMany: vacationFindMany },
auditLog: { create: auditLogCreate },
} as never,
{
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-27"),
hoursPerDay: 4,
percentage: 50,
role: "Compositor",
roleId: "role_comp",
status: AllocationStatus.PROPOSED,
metadata: {},
},
);
expect(assignmentFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
resourceId: { in: ["resource_1"] },
status: { not: "CANCELLED" },
}),
}),
);
});
it("updates a linked demand requirement directly", async () => {
const demandRequirementFindUnique = vi.fn().mockResolvedValue({
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-27"),
hoursPerDay: 4,
percentage: 50,
role: "FX Artist",
roleId: "role_fx",
headcount: 2,
status: AllocationStatus.PROPOSED,
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
});
const demandRequirementUpdate = vi.fn().mockResolvedValue({
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-27"),
hoursPerDay: 4,
percentage: 50,
role: "FX Artist",
roleId: "role_fx",
headcount: 1,
status: AllocationStatus.COMPLETED,
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
});
const auditLogCreate = vi.fn().mockResolvedValue({});
const result = await updateDemandRequirement(
{
demandRequirement: {
findUnique: demandRequirementFindUnique,
update: demandRequirementUpdate,
},
auditLog: { create: auditLogCreate },
} as never,
"demand_1",
{
headcount: 1,
status: AllocationStatus.COMPLETED,
metadata: { source: "update-test" },
},
);
expect(result.headcount).toBe(1);
expect(auditLogCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
entityType: "DemandRequirement",
entityId: "demand_1",
}),
}),
);
});
it("updates a linked assignment directly", async () => {
const existingAssignment = {
id: "assignment_1",
demandRequirementId: "demand_1",
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-27"),
hoursPerDay: 4,
percentage: 50,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 20000,
status: AllocationStatus.PROPOSED,
resource: { id: "resource_1", displayName: "Alice", eid: "E-001", lcrCents: 5000 },
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
demandRequirement: {
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-27"),
hoursPerDay: 4,
percentage: 50,
role: "Compositor",
roleId: "role_comp",
headcount: 1,
status: AllocationStatus.PROPOSED,
},
};
const assignmentFindUnique = vi.fn().mockResolvedValue(existingAssignment);
const assignmentUpdate = vi.fn().mockResolvedValue({
...existingAssignment,
id: "assignment_1",
demandRequirementId: "demand_1",
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-28"),
hoursPerDay: 6,
percentage: 75,
role: "Lead Compositor",
roleId: "role_lead_comp",
dailyCostCents: 32000,
status: AllocationStatus.CONFIRMED,
metadata: { source: "update-test" },
resource: { id: "resource_1", displayName: "Alice", eid: "E-001", lcrCents: 5000 },
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
roleEntity: { id: "role_lead_comp", name: "Lead Compositor", color: "#444444" },
demandRequirement: {
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-16"),
endDate: new Date("2026-03-27"),
hoursPerDay: 4,
percentage: 50,
role: "Compositor",
roleId: "role_comp",
headcount: 1,
status: AllocationStatus.PROPOSED,
},
});
const auditLogCreate = vi.fn().mockResolvedValue({});
const result = await updateAssignment(
{
assignment: {
findUnique: assignmentFindUnique,
update: assignmentUpdate,
},
auditLog: { create: auditLogCreate },
} as never,
"assignment_1",
{
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-28"),
hoursPerDay: 6,
percentage: 75,
role: "Lead Compositor",
roleId: "role_lead_comp",
dailyCostCents: 32000,
status: AllocationStatus.CONFIRMED,
metadata: { source: "update-test" },
},
);
expect(result.dailyCostCents).toBe(32000);
expect(auditLogCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
entityType: "Assignment",
entityId: "assignment_1",
}),
}),
);
});
});
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import { AllocationStatus, type AllocationWithDetails } from "@planarchy/shared";
import { buildAllocationReadModel } from "../index.js";
function makeAllocation(overrides: Partial<AllocationWithDetails>): AllocationWithDetails {
return {
id: "alloc_1",
resourceId: "res_1",
projectId: "proj_1",
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-05T00:00:00.000Z"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_1",
isPlaceholder: false,
headcount: 1,
dailyCostCents: 12_000,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-01T00:00:00.000Z"),
updatedAt: new Date("2026-03-01T00:00:00.000Z"),
resource: {
id: "res_1",
displayName: "Alice",
eid: "E-001",
lcrCents: 15_000,
},
project: {
id: "proj_1",
name: "Project One",
shortCode: "P1",
status: "ACTIVE",
endDate: new Date("2026-03-31T00:00:00.000Z"),
},
roleEntity: {
id: "role_1",
name: "Compositor",
color: "#111111",
},
...overrides,
};
}
describe("allocation read model", () => {
it("splits assignments from placeholder demand rows", () => {
const input: AllocationWithDetails[] = [
makeAllocation({ id: "assign_1" }),
makeAllocation({
id: "demand_1",
resourceId: null,
isPlaceholder: true,
headcount: 3,
dailyCostCents: 0,
resource: null,
}),
];
const result = buildAllocationReadModel(input);
expect(result.allocations).toHaveLength(2);
expect(result.assignments).toHaveLength(1);
expect(result.demands).toHaveLength(1);
expect(result.assignments[0]).toMatchObject({
id: "assign_1",
kind: "assignment",
sourceAllocationId: "assign_1",
resourceId: "res_1",
isPlaceholder: false,
});
expect(result.demands[0]).toMatchObject({
id: "demand_1",
kind: "demand",
sourceAllocationId: "demand_1",
resourceId: null,
isPlaceholder: true,
requestedHeadcount: 3,
unfilledHeadcount: 3,
});
});
it("treats null-resource rows as demand for legacy safety", () => {
const input: AllocationWithDetails[] = [
makeAllocation({
id: "legacy_1",
resourceId: null,
isPlaceholder: false,
headcount: 2,
dailyCostCents: 0,
resource: null,
}),
];
const result = buildAllocationReadModel(input);
expect(result.assignments).toEqual([]);
expect(result.demands).toHaveLength(1);
expect(result.demands[0]?.sourceAllocationId).toBe("legacy_1");
});
});
@@ -0,0 +1,197 @@
import { describe, expect, it, vi } from "vitest";
import { listAssignmentBookings } from "../index.js";
describe("listAssignmentBookings", () => {
it("returns assignments with cost metadata", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "asn_1",
projectId: "proj_1",
resourceId: "res_1",
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-02T00:00:00.000Z"),
hoursPerDay: 6,
dailyCostCents: 900,
status: "CONFIRMED",
project: {
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
status: "ACTIVE",
orderType: "CHARGEABLE",
},
resource: {
id: "res_1",
displayName: "Alice",
chapter: "CGI",
},
},
{
id: "asn_2",
projectId: "proj_2",
resourceId: "res_2",
startDate: new Date("2026-03-03T00:00:00.000Z"),
endDate: new Date("2026-03-03T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 500,
status: "PROPOSED",
project: {
id: "proj_2",
name: "Bravo",
shortCode: "BRAVO",
status: "DRAFT",
orderType: "INTERNAL",
},
resource: {
id: "res_2",
displayName: "Bob",
chapter: "Lighting",
},
},
]),
},
};
const result = await listAssignmentBookings(db as never, {
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
});
expect(result).toEqual([
{
id: "asn_1",
projectId: "proj_1",
resourceId: "res_1",
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-02T00:00:00.000Z"),
hoursPerDay: 6,
dailyCostCents: 900,
status: "CONFIRMED",
project: {
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
status: "ACTIVE",
orderType: "CHARGEABLE",
},
resource: {
id: "res_1",
displayName: "Alice",
chapter: "CGI",
},
},
{
id: "asn_2",
projectId: "proj_2",
resourceId: "res_2",
startDate: new Date("2026-03-03T00:00:00.000Z"),
endDate: new Date("2026-03-03T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 500,
status: "PROPOSED",
project: {
id: "proj_2",
name: "Bravo",
shortCode: "BRAVO",
status: "DRAFT",
orderType: "INTERNAL",
},
resource: {
id: "res_2",
displayName: "Bob",
chapter: "Lighting",
},
},
]);
});
it("supports unbounded resource context queries when no date window is provided", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
};
await listAssignmentBookings(db as never, {
resourceIds: ["res_1", "res_2"],
});
expect(db.assignment.findMany).toHaveBeenCalledWith({
where: {
status: { not: "CANCELLED" },
resourceId: { in: ["res_1", "res_2"] },
},
select: {
id: true,
projectId: true,
resourceId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
dailyCostCents: true,
status: true,
project: {
select: { id: true, name: true, shortCode: true, status: true, orderType: true },
},
resource: {
select: { id: true, displayName: true, chapter: true },
},
},
});
});
it("rejects partial date bounds", async () => {
const db = {
assignment: {
findMany: vi.fn(),
},
};
await expect(
listAssignmentBookings(db as never, {
startDate: new Date("2026-03-01T00:00:00.000Z"),
}),
).rejects.toThrow("startDate and endDate must be provided together");
expect(db.assignment.findMany).not.toHaveBeenCalled();
});
it("supports excluding explicit assignment ids", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
};
await listAssignmentBookings(db as never, {
resourceIds: ["res_1"],
excludeAssignmentIds: ["asn_1"],
});
expect(db.assignment.findMany).toHaveBeenCalledWith({
where: {
status: { not: "CANCELLED" },
resourceId: { in: ["res_1"] },
id: { notIn: ["asn_1"] },
},
select: {
id: true,
projectId: true,
resourceId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
dailyCostCents: true,
status: true,
project: {
select: { id: true, name: true, shortCode: true, status: true, orderType: true },
},
resource: {
select: { id: true, displayName: true, chapter: true },
},
},
});
});
});
@@ -0,0 +1,72 @@
import { AllocationStatus } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { countEstimateHandoffPlanningEntries } from "../index.js";
describe("countEstimateHandoffPlanningEntries", () => {
it("counts demand and assignment handoff entries", async () => {
const result = await countEstimateHandoffPlanningEntries(
{
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
id: "demand_handoff",
projectId: "project_1",
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-18"),
hoursPerDay: 8,
percentage: 100,
role: "Comp",
roleId: "role_comp",
headcount: 1,
status: AllocationStatus.PROPOSED,
metadata: { estimateHandoff: { estimateVersionId: "ver_1" } },
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assignment_handoff",
demandRequirementId: "demand_handoff",
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-18"),
hoursPerDay: 8,
percentage: 100,
role: "Comp",
roleId: "role_comp",
dailyCostCents: 32000,
status: AllocationStatus.PROPOSED,
metadata: { estimateHandoff: { estimateVersionId: "ver_1" } },
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
{
id: "assignment_explicit_only",
demandRequirementId: null,
resourceId: "resource_2",
projectId: "project_1",
startDate: new Date("2026-03-19"),
endDate: new Date("2026-03-20"),
hoursPerDay: 4,
percentage: 50,
role: "Lead",
roleId: "role_lead",
dailyCostCents: 20000,
status: AllocationStatus.PROPOSED,
metadata: { estimateHandoff: { estimateVersionId: "ver_1" } },
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
]),
},
} as never,
{ projectId: "project_1", estimateVersionId: "ver_1" },
);
expect(result).toBe(3);
});
});
@@ -0,0 +1,58 @@
import { AllocationStatus } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { countPlanningEntries } from "../index.js";
describe("countPlanningEntries", () => {
it("counts demand and assignment planning entries", async () => {
const result = await countPlanningEntries(
{
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
id: "demand_1",
projectId: "project_1",
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-18"),
hoursPerDay: 8,
percentage: 100,
role: "FX",
roleId: "role_fx",
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assignment_1",
demandRequirementId: null,
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-03-17"),
endDate: new Date("2026-03-18"),
hoursPerDay: 8,
percentage: 100,
role: "Lead",
roleId: "role_lead",
dailyCostCents: 32000,
status: AllocationStatus.ACTIVE,
metadata: {},
createdAt: new Date("2026-03-13"),
updatedAt: new Date("2026-03-13"),
},
]),
},
} as never,
{ projectIds: ["project_1"] },
);
expect(result.totalCount).toBe(2);
expect(result.countsByProjectId.get("project_1")).toBe(2);
expect(result.countsByRoleId.get("role_fx")).toBe(1);
expect(result.countsByRoleId.get("role_lead")).toBe(1);
});
});
@@ -0,0 +1,707 @@
import { describe, expect, it, vi } from "vitest";
import {
getDashboardDemand,
getDashboardOverview,
getDashboardPeakTimes,
getDashboardTopValueResources,
} from "../index.js";
describe("dashboard use-cases", () => {
it("computes overview budget summary from project budgets and allocation cost", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assignment_1",
projectId: "proj_1",
resourceId: "res_1",
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-03T00:00:00.000Z"),
hoursPerDay: 8,
dailyCostCents: 1_000,
status: "ACTIVE",
project: {
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
status: "ACTIVE",
orderType: "FIXED",
},
resource: {
id: "res_1",
displayName: "Alice",
chapter: "CGI",
},
},
]),
},
resource: {
count: vi
.fn()
.mockResolvedValueOnce(4)
.mockResolvedValueOnce(3),
findMany: vi.fn().mockResolvedValue([
{ chapter: "CGI", chargeabilityTarget: 80 },
{ chapter: "CGI", chargeabilityTarget: 60 },
{ chapter: null, chargeabilityTarget: null },
]),
},
project: {
count: vi.fn().mockResolvedValue(2),
findMany: vi.fn().mockResolvedValue([
{ status: "ACTIVE", budgetCents: 100_000 },
{ status: "DRAFT", budgetCents: 50_000 },
]),
},
demandRequirement: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
findMany: vi.fn().mockResolvedValue([
{
id: "audit_1",
entityType: "Allocation",
action: "CREATE",
createdAt: new Date("2026-03-05T10:00:00.000Z"),
},
]),
},
};
const result = await getDashboardOverview(db as never);
expect(result.budgetSummary).toEqual({
totalBudgetCents: 150_000,
totalCostCents: 3_000,
avgUtilizationPercent: 2,
});
expect(result.projectsByStatus).toEqual([
{ status: "ACTIVE", count: 1 },
{ status: "DRAFT", count: 1 },
]);
expect(result.chapterUtilization).toEqual([
{ chapter: "CGI", resourceCount: 2, avgChargeabilityTarget: 70 },
{ chapter: "Unassigned", resourceCount: 1, avgChargeabilityTarget: 0 },
]);
});
it("avoids double-counting linked legacy allocations in overview budget totals", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assignment_1",
projectId: "proj_1",
resourceId: "res_1",
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-02T00:00:00.000Z"),
hoursPerDay: 8,
dailyCostCents: 2_000,
status: "ACTIVE",
project: {
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
status: "ACTIVE",
orderType: "FIXED",
},
resource: {
id: "res_1",
displayName: "Alice",
chapter: "CGI",
},
},
]),
},
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: 100_000 }]),
},
demandRequirement: {
findMany: vi.fn().mockResolvedValue([]),
},
auditLog: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const result = await getDashboardOverview(db as never);
expect(result.budgetSummary).toEqual({
totalBudgetCents: 100_000,
totalCostCents: 4_000,
avgUtilizationPercent: 4,
});
});
it("counts explicit demand and assignment rows in overview totals even without legacy allocation rows", async () => {
const db = {
assignment: {
findMany: vi
.fn()
.mockResolvedValueOnce([
{
id: "assignment_explicit",
demandRequirementId: "demand_explicit",
projectId: "proj_1",
resourceId: "res_1",
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-02T00:00:00.000Z"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_comp",
dailyCostCents: 2_000,
status: "ACTIVE",
metadata: {},
createdAt: new Date("2026-03-01T00:00:00.000Z"),
updatedAt: new Date("2026-03-01T00:00:00.000Z"),
},
])
.mockResolvedValueOnce([
{
id: "assignment_explicit",
projectId: "proj_1",
resourceId: "res_1",
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-02T00:00:00.000Z"),
hoursPerDay: 8,
dailyCostCents: 2_000,
status: "ACTIVE",
project: {
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
status: "ACTIVE",
orderType: "FIXED",
},
resource: {
id: "res_1",
displayName: "Alice",
chapter: "CGI",
},
},
]),
},
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: 100_000 }]),
},
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
id: "demand_explicit",
projectId: "proj_1",
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-02T00:00:00.000Z"),
hoursPerDay: 8,
percentage: 100,
role: "Compositor",
roleId: "role_comp",
headcount: 1,
status: "ACTIVE",
metadata: {},
createdAt: new Date("2026-03-01T00:00:00.000Z"),
updatedAt: new Date("2026-03-01T00:00:00.000Z"),
},
{
id: "demand_cancelled",
projectId: "proj_2",
startDate: new Date("2026-03-03T00:00:00.000Z"),
endDate: new Date("2026-03-03T00:00:00.000Z"),
hoursPerDay: 4,
percentage: 50,
role: "FX",
roleId: "role_fx",
headcount: 1,
status: "CANCELLED",
metadata: {},
createdAt: new Date("2026-03-03T00:00:00.000Z"),
updatedAt: new Date("2026-03-03T00:00:00.000Z"),
},
]),
},
auditLog: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const result = await getDashboardOverview(db as never);
expect(result.totalAllocations).toBe(3);
expect(result.activeAllocations).toBe(2);
expect(result.budgetSummary).toEqual({
totalBudgetCents: 100_000,
totalCostCents: 4_000,
avgUtilizationPercent: 4,
});
});
it("aggregates peak times into sorted buckets and capacity totals", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assign_1",
projectId: "proj_1",
resourceId: "res_1",
status: "PROPOSED",
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-02T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
project: { id: "proj_1", name: "Alpha", shortCode: "ALPHA", status: "ACTIVE", orderType: "FIXED" },
resource: { id: "res_1", displayName: "Alice", chapter: "CGI" },
},
{
id: "assign_2",
projectId: "proj_2",
resourceId: "res_2",
status: "PROPOSED",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-02T00:00:00.000Z"),
hoursPerDay: 3,
dailyCostCents: 0,
project: { id: "proj_2", name: "Bravo", shortCode: "BRAVO", status: "ACTIVE", orderType: "FIXED" },
resource: { id: "res_2", displayName: "Bob", chapter: "Lighting" },
},
]),
},
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 } },
]),
},
};
const result = await getDashboardPeakTimes(db as never, {
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-03T00:00:00.000Z"),
granularity: "month",
groupBy: "project",
});
expect(result).toEqual([
{
period: "2026-03",
groups: [
{ name: "ALPHA", hours: 8 },
{ name: "BRAVO", hours: 3 },
],
totalHours: 11,
capacityHours: 308,
},
]);
});
it("enforces visible-role filtering for top value resources", async () => {
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({
scoreVisibleRoles: ["ADMIN"],
}),
},
resource: {
findMany: vi.fn(),
},
};
const hidden = await getDashboardTopValueResources(db as never, {
limit: 10,
userRole: "USER",
});
expect(hidden).toEqual([]);
expect(db.resource.findMany).not.toHaveBeenCalled();
db.resource.findMany.mockResolvedValue([{ id: "res_1", valueScore: 99 }]);
const visible = await getDashboardTopValueResources(db as never, {
limit: 1,
userRole: "ADMIN",
});
expect(visible).toEqual([{ id: "res_1", valueScore: 99 }]);
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({ take: 1 }),
);
});
it("returns distinct resource counts for chapter demand grouping", async () => {
const db = {
demandRequirement: {
findMany: vi.fn().mockResolvedValue([]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "alloc_1",
demandRequirementId: null,
projectId: "proj_1",
resourceId: "res_1",
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-02T00:00:00.000Z"),
hoursPerDay: 4,
percentage: 50,
role: null,
roleId: null,
dailyCostCents: 0,
status: "PROPOSED",
metadata: {},
createdAt: new Date("2026-03-01T00:00:00.000Z"),
updatedAt: new Date("2026-03-01T00:00:00.000Z"),
project: {
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
staffingReqs: [],
},
resource: { id: "res_1", displayName: "Alice", chapter: "CGI" },
},
{
id: "alloc_2",
demandRequirementId: null,
projectId: "proj_2",
resourceId: "res_1",
startDate: new Date("2026-03-03T00:00:00.000Z"),
endDate: new Date("2026-03-03T00:00:00.000Z"),
hoursPerDay: 6,
percentage: 75,
role: null,
roleId: null,
dailyCostCents: 0,
status: "PROPOSED",
metadata: {},
createdAt: new Date("2026-03-03T00:00:00.000Z"),
updatedAt: new Date("2026-03-03T00:00:00.000Z"),
project: {
id: "proj_2",
name: "Bravo",
shortCode: "BRAVO",
staffingReqs: [],
},
resource: { id: "res_1", displayName: "Alice", chapter: "CGI" },
},
{
id: "alloc_3",
demandRequirementId: null,
projectId: "proj_2",
resourceId: "res_2",
startDate: new Date("2026-03-03T00:00:00.000Z"),
endDate: new Date("2026-03-03T00:00:00.000Z"),
hoursPerDay: 5,
percentage: 62.5,
role: null,
roleId: null,
dailyCostCents: 0,
status: "PROPOSED",
metadata: {},
createdAt: new Date("2026-03-03T00:00:00.000Z"),
updatedAt: new Date("2026-03-03T00:00:00.000Z"),
project: {
id: "proj_2",
name: "Bravo",
shortCode: "BRAVO",
staffingReqs: [],
},
resource: { id: "res_2", displayName: "Bob", chapter: "CGI" },
},
]),
},
project: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const result = await getDashboardDemand(db as never, {
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
groupBy: "chapter",
});
expect(result).toEqual([
{
id: "CGI",
name: "CGI",
shortCode: "CGI",
allocatedHours: 19,
requiredFTEs: 0,
resourceCount: 2,
},
]);
});
it("prefers demand requirements and assignments for project demand semantics", async () => {
const db = {
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
id: "dem_1",
projectId: "proj_1",
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-01T00:00:00.000Z"),
hoursPerDay: 8,
percentage: 100,
headcount: 2,
status: "PROPOSED",
},
{
id: "dem_2",
projectId: "proj_1",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-02T00:00:00.000Z"),
hoursPerDay: 4,
percentage: 50,
headcount: 1,
status: "COMPLETED",
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "asn_1",
demandRequirementId: "dem_1",
projectId: "proj_1",
resourceId: "res_1",
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-01T00:00:00.000Z"),
hoursPerDay: 8,
percentage: 100,
role: null,
roleId: null,
dailyCostCents: 0,
status: "PROPOSED",
metadata: {},
createdAt: new Date("2026-03-01T00:00:00.000Z"),
updatedAt: new Date("2026-03-01T00:00:00.000Z"),
resource: { id: "res_1", displayName: "Alice", chapter: "CGI" },
project: {
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
staffingReqs: [{ fteCount: 9 }],
},
},
{
id: "asn_2",
demandRequirementId: "dem_2",
projectId: "proj_1",
resourceId: "res_2",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-02T00:00:00.000Z"),
hoursPerDay: 4,
percentage: 50,
role: null,
roleId: null,
dailyCostCents: 0,
status: "PROPOSED",
metadata: {},
createdAt: new Date("2026-03-02T00:00:00.000Z"),
updatedAt: new Date("2026-03-02T00:00:00.000Z"),
resource: { id: "res_2", displayName: "Bob", chapter: "Lighting" },
project: {
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
staffingReqs: [{ fteCount: 9 }],
},
},
{
id: "asn_3",
demandRequirementId: null,
projectId: "proj_1",
resourceId: "res_3",
startDate: new Date("2026-03-03T00:00:00.000Z"),
endDate: new Date("2026-03-03T00:00:00.000Z"),
hoursPerDay: 6,
percentage: 75,
role: null,
roleId: null,
dailyCostCents: 0,
status: "PROPOSED",
metadata: {},
createdAt: new Date("2026-03-03T00:00:00.000Z"),
updatedAt: new Date("2026-03-03T00:00:00.000Z"),
resource: { id: "res_3", displayName: "Cara", chapter: "CGI" },
project: {
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
staffingReqs: [{ fteCount: 9 }],
},
},
]),
},
project: {
findMany: vi.fn().mockResolvedValue([
{
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
staffingReqs: [{ fteCount: 9 }],
},
]),
},
};
const result = await getDashboardDemand(db as never, {
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
groupBy: "project",
});
expect(result).toEqual([
{
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
allocatedHours: 18,
requiredFTEs: 3.5,
resourceCount: 3,
},
]);
});
it("keeps explicit project metadata when demand and assignment rows exist without legacy allocations", async () => {
const db = {
demandRequirement: {
findMany: vi.fn().mockResolvedValue([
{
id: "dem_1",
projectId: "proj_1",
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-01T00:00:00.000Z"),
hoursPerDay: 8,
percentage: 100,
headcount: 1,
status: "PROPOSED",
project: {
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
staffingReqs: [],
},
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "asn_1",
demandRequirementId: "dem_1",
projectId: "proj_1",
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-01T00:00:00.000Z"),
hoursPerDay: 8,
project: {
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
staffingReqs: [],
},
resource: { id: "res_1", displayName: "Alice", chapter: "CGI" },
},
]),
},
project: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const result = await getDashboardDemand(db as never, {
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
groupBy: "project",
});
expect(result).toEqual([
{
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
allocatedHours: 8,
requiredFTEs: 2,
resourceCount: 1,
},
]);
});
it("falls back to staffing requirements when no demand rows exist", async () => {
const db = {
demandRequirement: {
findMany: vi.fn().mockResolvedValue([]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "alloc_1",
demandRequirementId: null,
projectId: "proj_1",
resourceId: "res_1",
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-02T00:00:00.000Z"),
hoursPerDay: 8,
percentage: 100,
role: null,
roleId: null,
dailyCostCents: 0,
status: "PROPOSED",
metadata: {},
createdAt: new Date("2026-03-01T00:00:00.000Z"),
updatedAt: new Date("2026-03-01T00:00:00.000Z"),
project: {
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
staffingReqs: [{ fteCount: 2 }],
},
resource: { id: "res_1", displayName: "Alice", chapter: "CGI" },
},
]),
},
project: {
findMany: vi.fn().mockResolvedValue([
{
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
staffingReqs: [{ fteCount: 2 }],
},
]),
},
};
const result = await getDashboardDemand(db as never, {
startDate: new Date("2026-03-01T00:00:00.000Z"),
endDate: new Date("2026-03-31T00:00:00.000Z"),
groupBy: "project",
});
expect(result).toEqual([
{
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
allocatedHours: 16,
requiredFTEs: 2,
resourceCount: 1,
},
]);
});
});
@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from "vitest";
import { deleteAssignment } from "../index.js";
describe("deleteAssignment", () => {
it("deletes an explicit assignment row", async () => {
const db = {
assignment: {
findUnique: vi.fn().mockResolvedValue({
id: "assignment_1",
projectId: "project_1",
resourceId: "resource_1",
}),
delete: vi.fn().mockResolvedValue({}),
},
};
const result = await deleteAssignment(db as never, "assignment_1");
expect(result).toEqual({
deletedId: "assignment_1",
projectId: "project_1",
resourceId: "resource_1",
});
expect(db.assignment.delete).toHaveBeenCalledWith({
where: { id: "assignment_1" },
});
});
});
@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from "vitest";
import { deleteDemandRequirement } from "../index.js";
describe("deleteDemandRequirement", () => {
it("deletes an explicit demand and unlinks assignments", async () => {
const db = {
demandRequirement: {
findUnique: vi.fn().mockResolvedValue({
id: "demand_1",
projectId: "project_1",
}),
delete: vi.fn().mockResolvedValue({}),
},
assignment: {
updateMany: vi.fn().mockResolvedValue({ count: 2 }),
},
};
const result = await deleteDemandRequirement(db as never, "demand_1");
expect(result).toEqual({
deletedId: "demand_1",
projectId: "project_1",
});
expect(db.assignment.updateMany).toHaveBeenCalledWith({
where: { demandRequirementId: "demand_1" },
data: { demandRequirementId: null },
});
expect(db.demandRequirement.delete).toHaveBeenCalledWith({
where: { id: "demand_1" },
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,759 @@
import { fileURLToPath } from "node:url";
import { describe, expect, it, vi } from "vitest";
import {
assessDispoImportReadiness,
parseDispoChargeabilityWorkbook,
parseDispoPlanningWorkbook,
parseResourceRosterMasterWorkbook,
parseDispoRosterWorkbook,
parseMandatoryDispoReferenceWorkbook,
stageDispoImportBatch,
stageDispoChargeabilityResources,
stageDispoPlanningData,
stageDispoProjects,
stageDispoRosterResources,
stageDispoReferenceData,
} from "../index.js";
const mandatoryWorkbookPath = fileURLToPath(
new URL("../../../../samples/Dispov2/MandatoryDispoCategories_V3.xlsx", import.meta.url),
);
const chargeabilityWorkbookPath = fileURLToPath(
new URL(
"../../../../samples/Dispov2/20260309_Bi-Weekly_Chargeability_Reporting_Content_Production_V0.943_4Hartmut.xlsx",
import.meta.url,
),
);
const planningWorkbookPath = fileURLToPath(
new URL("../../../../samples/Dispov2/DISPO_2026.xlsx", import.meta.url),
);
const rosterWorkbookPath = fileURLToPath(
new URL("../../../../samples/Dispov2/MV_DispoRoster.xlsx", import.meta.url),
);
const costWorkbookPath = fileURLToPath(
new URL(
"../../../../samples/Dispov2/Resource Roster_MASTER_FY26_CJ_20251201.xlsx",
import.meta.url,
),
);
describe("dispo import", () => {
it("parses the mandatory reference workbook into normalized master data", async () => {
const parsed = await parseMandatoryDispoReferenceWorkbook(mandatoryWorkbookPath);
expect(parsed.countries).toEqual(
expect.arrayContaining([
expect.objectContaining({
countryCode: "DE",
name: "Germany",
metroCities: expect.arrayContaining(["Munich", "Stuttgart"]),
}),
expect.objectContaining({
countryCode: "ES",
name: "Spain",
dailyWorkingHours: 8,
}),
]),
);
expect(parsed.orgUnits).toEqual(
expect.arrayContaining([
expect.objectContaining({ level: 5, name: "Content Production", parentName: null }),
expect.objectContaining({
level: 6,
name: "CGI Content",
parentName: "Content Production",
}),
expect.objectContaining({ level: 7, name: "Art Direction", parentName: "CGI Content" }),
]),
);
expect(parsed.managementLevelGroups).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "Consultant",
targetPercentage: 0.808,
levels: expect.arrayContaining(["8-Associate Manager", "9-Team Lead/Consultant"]),
}),
]),
);
expect(parsed.clients).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "BMW", clientCode: "BMW", parentName: null }),
expect.objectContaining({ name: "BMW AG", parentName: "BMW", parentClientCode: "BMW" }),
]),
);
});
it("parses the chargeability workbook into deduplicated staged resources", async () => {
const parsed = await parseDispoChargeabilityWorkbook(chargeabilityWorkbookPath);
expect(parsed.resources.length).toBeGreaterThan(300);
expect(parsed.unresolved).toEqual([]);
expect(parsed.resources).toEqual(
expect.arrayContaining([
expect.objectContaining({
canonicalExternalId: "a.kasperovich",
chapter: "CGI Development",
countryCode: "DE",
chargeabilityTarget: 74.7,
resourceType: "EMPLOYEE",
}),
expect.objectContaining({
canonicalExternalId: "alexander.broeckel",
chapter: "Digital Content Production",
chapterCode: "3D",
roleTokens: ["3D"],
}),
]),
);
});
it("parses the planning workbook into staged assignments, vacations, availability rules, and unresolved project references", async () => {
const parsed = await parseDispoPlanningWorkbook(planningWorkbookPath);
expect(parsed.assignments.length).toBeGreaterThan(1000);
expect(parsed.vacations.length).toBeGreaterThan(1000);
expect(parsed.availabilityRules.length).toBeGreaterThan(0);
expect(parsed.unresolved.length).toBeGreaterThan(0);
expect(parsed.assignments).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceExternalId: "a.d.singh.sandhu",
assignmentDate: new Date("2025-12-22T00:00:00.000Z"),
hoursPerDay: 8,
isInternal: false,
isTbd: true,
projectKey: null,
utilizationCategoryCode: "Chg",
winProbability: 80,
}),
]),
);
expect(parsed.vacations).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceExternalId: "samuel.bubat",
startDate: new Date("2025-12-22T00:00:00.000Z"),
endDate: new Date("2025-12-22T00:00:00.000Z"),
vacationType: "ANNUAL",
isHalfDay: false,
}),
expect.objectContaining({
resourceExternalId: "samuel.bubat",
startDate: new Date("2025-12-24T00:00:00.000Z"),
endDate: new Date("2025-12-24T00:00:00.000Z"),
vacationType: "PUBLIC_HOLIDAY",
isPublicHoliday: true,
}),
]),
);
expect(parsed.availabilityRules).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceExternalId: "marina.hechler",
effectiveStartDate: new Date("2025-12-22T00:00:00.000Z"),
availableHours: 6,
percentage: 75,
ruleType: "PART_TIME",
}),
]),
);
});
it("parses the roster workbook into merged resource master rows", async () => {
const parsed = await parseDispoRosterWorkbook(rosterWorkbookPath, { costWorkbookPath });
expect(parsed.resources.length).toBeGreaterThan(500);
expect(parsed.ignoredPseudoDemandRows).toBeGreaterThan(100);
expect(parsed.excludedCanonicalExternalIds).toEqual(
expect.arrayContaining(["antonia.melzer", "placeholder.hamburg"]),
);
expect(parsed.resources).toEqual(
expect.arrayContaining([
expect.objectContaining({
canonicalExternalId: "a.kasperovich",
displayName: "Alexander Kasperovich",
email: "a.kasperovich@accenture.com",
lcrCents: 10892,
rateResolution: "EXACT",
ucrCents: 7261,
chapter: "CGI-Dev",
clientUnitName: "Cross-Unit",
sourceSheet: "DispoRoster",
}),
expect.objectContaining({
canonicalExternalId: "alexander.broeckel",
displayName: "Alex Bröckel",
email: "alexander.broeckel@accenture.com",
sourceSheet: "SAP_data",
}),
expect.objectContaining({
canonicalExternalId: "a.appelt",
email: "a.appelt@accenture.com",
rateResolution: "LEVEL_AVERAGE",
rateResolutionLevel: "10-Senior Analyst",
resourceType: "FREELANCER",
roleTokens: ["2D"],
}),
]),
);
expect(parsed.resources.find((resource) => resource.canonicalExternalId === "antonia.melzer")).toBeUndefined();
});
it("parses the cost workbook into exact rates and level averages", async () => {
const parsed = await parseResourceRosterMasterWorkbook(costWorkbookPath);
expect(parsed.rates.get("a.kasperovich")).toEqual(
expect.objectContaining({
canonicalExternalId: "a.kasperovich",
lcrCents: 10892,
ucrCents: 7261,
level: "7-Manager",
}),
);
expect(parsed.levelAverages.get("10-Senior Analyst")).toEqual(
expect.objectContaining({
level: "10-Senior Analyst",
lcrCents: expect.any(Number),
ucrCents: expect.any(Number),
sampleCount: expect.any(Number),
}),
);
});
it("assesses import readiness against the merged workbook constraints", async () => {
const report = await assessDispoImportReadiness({
referenceWorkbookPath: mandatoryWorkbookPath,
chargeabilityWorkbookPath,
planningWorkbookPath,
rosterWorkbookPath,
costWorkbookPath,
});
expect(report.resourceCount).toBeGreaterThan(500);
expect(report.canCommitWithStrictSourceData).toBe(true);
expect(report.canCommitWithFallbacks).toBe(true);
expect(report.issues.find((issue) => issue.code === "FALLBACK_EMAIL_REQUIRED")).toBeUndefined();
expect(report.issues.find((issue) => issue.code === "FALLBACK_LCR_REQUIRED")).toBeUndefined();
expect(report.issues.find((issue) => issue.code === "FALLBACK_UCR_REQUIRED")).toBeUndefined();
expect(
report.issues.find((issue) => issue.code === "PLANNING_RESOURCE_MISSING_FROM_ROSTER"),
).toBeUndefined();
expect(report.issues).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "REFERENCE_RESOURCE_MASTER_MISSING",
}),
]),
);
expect(report.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "UNRESOLVED_RECORDS_PRESENT",
severity: "warning",
}),
]),
);
});
it("stages reference workbook clients and upserts master data", async () => {
const db = {
importBatch: {
create: vi.fn().mockResolvedValue({ id: "batch_1", summary: {} }),
findUnique: vi.fn(),
update: vi.fn().mockResolvedValue({ id: "batch_1", summary: {} }),
},
country: {
upsert: vi.fn().mockResolvedValue({ id: "country_1" }),
},
metroCity: {
upsert: vi.fn().mockResolvedValue({ id: "city_1" }),
},
orgUnit: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi
.fn()
.mockResolvedValueOnce({ id: "org_root" })
.mockResolvedValue({ id: "org_child" }),
update: vi.fn(),
upsert: vi.fn().mockResolvedValue({ id: "org_upserted" }),
},
managementLevelGroup: {
upsert: vi.fn().mockResolvedValue({ id: "group_1" }),
},
managementLevel: {
upsert: vi.fn().mockResolvedValue({ id: "level_1" }),
},
client: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({ id: "client_1" }),
update: vi.fn(),
},
stagedClient: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 10 }),
},
stagedResource: {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
stagedUnresolvedRecord: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn(),
},
};
const result = await stageDispoReferenceData(db as never, {
referenceWorkbookPath: mandatoryWorkbookPath,
});
expect(result.batchId).toBe("batch_1");
expect(result.counts.countries).toBeGreaterThan(0);
expect(db.country.upsert).toHaveBeenCalled();
expect(db.stagedClient.createMany).toHaveBeenCalled();
expect(db.importBatch.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "batch_1" },
data: expect.objectContaining({
status: "STAGED",
summary: expect.objectContaining({
reference: expect.objectContaining({
stagedClients: expect.any(Number),
}),
}),
}),
}),
);
});
it("stages chargeability roster resources into staged resource rows", async () => {
const db = {
importBatch: {
create: vi.fn().mockResolvedValue({ id: "batch_2", summary: {} }),
findUnique: vi.fn(),
update: vi.fn().mockResolvedValue({ id: "batch_2", summary: {} }),
},
client: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
country: {
upsert: vi.fn(),
},
metroCity: {
upsert: vi.fn(),
},
orgUnit: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
},
managementLevelGroup: {
upsert: vi.fn(),
},
managementLevel: {
upsert: vi.fn(),
},
stagedClient: {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
stagedResource: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 100 }),
},
stagedUnresolvedRecord: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
};
const result = await stageDispoChargeabilityResources(db as never, {
chargeabilityWorkbookPath,
});
expect(result.batchId).toBe("batch_2");
expect(result.counts.stagedResources).toBeGreaterThan(300);
expect(db.stagedResource.createMany).toHaveBeenCalled();
expect(db.importBatch.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: "STAGED",
summary: expect.objectContaining({
chargeability: expect.objectContaining({
stagedResources: expect.any(Number),
}),
}),
}),
}),
);
});
it("stages roster and SAP workbook rows into staged resource rows", async () => {
const db = {
importBatch: {
create: vi.fn().mockResolvedValue({ id: "batch_roster", summary: {} }),
findUnique: vi.fn(),
update: vi.fn().mockResolvedValue({ id: "batch_roster", summary: {} }),
},
client: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
country: {
upsert: vi.fn(),
},
metroCity: {
upsert: vi.fn(),
},
orgUnit: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
},
managementLevelGroup: {
upsert: vi.fn(),
},
managementLevel: {
upsert: vi.fn(),
},
stagedClient: {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
stagedResource: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 100 }),
},
stagedUnresolvedRecord: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 0 }),
},
};
const result = await stageDispoRosterResources(db as never, {
rosterWorkbookPath,
costWorkbookPath,
});
expect(result.batchId).toBe("batch_roster");
expect(result.counts.stagedResources).toBeGreaterThan(500);
expect(result.counts.ignoredPseudoDemandRows).toBeGreaterThan(100);
expect(result.counts.excludedResources).toBeGreaterThan(0);
expect(db.stagedResource.createMany).toHaveBeenCalled();
expect(db.stagedResource.createMany).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.arrayContaining([
expect.objectContaining({
lcrCents: expect.any(Number),
ucrCents: expect.any(Number),
normalizedData: expect.objectContaining({
rateResolution: expect.any(String),
}),
}),
]),
}),
);
expect(db.importBatch.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: "STAGED",
summary: expect.objectContaining({
roster: expect.objectContaining({
stagedResources: expect.any(Number),
ignoredPseudoDemandRows: expect.any(Number),
}),
}),
}),
}),
);
});
it("stages planning workbook rows into staged planning records", async () => {
const db = {
importBatch: {
create: vi.fn().mockResolvedValue({ id: "batch_3", summary: {} }),
findUnique: vi.fn(),
update: vi.fn().mockResolvedValue({ id: "batch_3", summary: {} }),
},
client: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
country: {
upsert: vi.fn(),
},
metroCity: {
upsert: vi.fn(),
},
orgUnit: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
},
managementLevelGroup: {
upsert: vi.fn(),
},
managementLevel: {
upsert: vi.fn(),
},
stagedClient: {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
stagedResource: {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
stagedAssignment: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 1000 }),
},
stagedVacation: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 1000 }),
},
stagedAvailabilityRule: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 100 }),
},
stagedUnresolvedRecord: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 100 }),
},
};
const result = await stageDispoPlanningData(db as never, {
planningWorkbookPath,
});
expect(result.batchId).toBe("batch_3");
expect(result.counts.stagedAssignments).toBeGreaterThan(1000);
expect(result.counts.stagedVacations).toBeGreaterThan(1000);
expect(result.counts.stagedAvailabilityRules).toBeGreaterThan(0);
expect(result.counts.unresolved).toBeGreaterThan(0);
expect(db.stagedAssignment.createMany).toHaveBeenCalled();
expect(db.stagedVacation.createMany).toHaveBeenCalled();
expect(db.stagedAvailabilityRule.createMany).toHaveBeenCalled();
expect(db.stagedUnresolvedRecord.createMany).toHaveBeenCalled();
expect(db.importBatch.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: "STAGED",
summary: expect.objectContaining({
planning: expect.objectContaining({
stagedAssignments: expect.any(Number),
stagedVacations: expect.any(Number),
}),
}),
}),
}),
);
});
it("resolves staged projects from planning workbook assignments", async () => {
const db = {
importBatch: {
create: vi.fn().mockResolvedValue({ id: "batch_4", summary: {} }),
findUnique: vi.fn(),
update: vi.fn().mockResolvedValue({ id: "batch_4", summary: {} }),
},
client: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
country: {
upsert: vi.fn(),
},
metroCity: {
upsert: vi.fn(),
},
orgUnit: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
},
managementLevelGroup: {
upsert: vi.fn(),
},
managementLevel: {
upsert: vi.fn(),
},
stagedClient: {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
stagedResource: {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
stagedAssignment: {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
stagedVacation: {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
stagedAvailabilityRule: {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
stagedProject: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 100 }),
},
stagedUnresolvedRecord: {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
};
const result = await stageDispoProjects(db as never, {
planningWorkbookPath,
});
expect(result.batchId).toBe("batch_4");
expect(result.counts.stagedProjects).toBeGreaterThan(10);
expect(db.stagedProject.createMany).toHaveBeenCalled();
expect(db.stagedProject.createMany).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.arrayContaining([
expect.objectContaining({
projectKey: "INT-MO",
shortCode: "INT-MO",
name: "Management & Operations",
isInternal: true,
utilizationCategoryCode: "M&O",
}),
expect.objectContaining({
projectKey: "11035763",
shortCode: "11035763",
clientCode: "BMW",
isInternal: false,
utilizationCategoryCode: "Chg",
}),
]),
}),
);
expect(db.importBatch.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: "STAGED",
summary: expect.objectContaining({
projectResolution: expect.objectContaining({
stagedProjects: expect.any(Number),
}),
}),
}),
}),
);
});
it("stages all dispo workbooks into one import batch and persists readiness", async () => {
const db = {
importBatch: {
create: vi.fn().mockResolvedValue({ id: "batch_5", summary: {} }),
findUnique: vi.fn().mockResolvedValue({ id: "batch_5", summary: {} }),
update: vi.fn().mockResolvedValue({ id: "batch_5", summary: {} }),
},
client: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({ id: "client_1" }),
update: vi.fn(),
},
country: {
upsert: vi.fn().mockResolvedValue({ id: "country_1" }),
},
metroCity: {
upsert: vi.fn().mockResolvedValue({ id: "metro_1" }),
},
orgUnit: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi
.fn()
.mockResolvedValueOnce({ id: "org_root" })
.mockResolvedValue({ id: "org_child" }),
update: vi.fn(),
upsert: vi.fn().mockResolvedValue({ id: "org_upserted" }),
},
managementLevelGroup: {
upsert: vi.fn().mockResolvedValue({ id: "group_1" }),
},
managementLevel: {
upsert: vi.fn().mockResolvedValue({ id: "level_1" }),
},
stagedClient: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 10 }),
},
stagedResource: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi
.fn()
.mockResolvedValueOnce({ count: 100 })
.mockResolvedValueOnce({ count: 100 }),
},
stagedProject: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 50 }),
},
stagedAssignment: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 1000 }),
},
stagedVacation: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 1000 }),
},
stagedAvailabilityRule: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 100 }),
},
stagedUnresolvedRecord: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 100 }),
},
};
const result = await stageDispoImportBatch(db as never, {
referenceWorkbookPath: mandatoryWorkbookPath,
chargeabilityWorkbookPath,
planningWorkbookPath,
rosterWorkbookPath,
costWorkbookPath,
});
expect(result.batchId).toBe("batch_5");
expect(result.counts.stagedResources).toBeGreaterThan(800);
expect(result.counts.stagedRosterResources).toBeGreaterThan(500);
expect(result.counts.stagedAssignments).toBeGreaterThan(1000);
expect(result.readiness.canCommitWithStrictSourceData).toBe(true);
expect(result.readiness.issues).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
code: "REFERENCE_RESOURCE_MASTER_MISSING",
}),
]),
);
expect(db.importBatch.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "batch_5" },
data: expect.objectContaining({
summary: expect.objectContaining({
readiness: expect.objectContaining({
canCommitWithStrictSourceData: true,
}),
}),
}),
}),
);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import { AllocationStatus, type AllocationWithDetails } from "@planarchy/shared";
import {
buildSplitAllocationReadModel,
} from "../index.js";
const project = {
id: "project_1",
name: "Project One",
shortCode: "P1",
status: "ACTIVE",
startDate: new Date("2026-01-01"),
endDate: new Date("2026-01-31"),
orderType: "CHARGEABLE",
budgetCents: 100_000,
winProbability: 100,
staffingReqs: [],
responsiblePerson: "Alex",
} satisfies AllocationWithDetails["project"];
const resource = {
id: "resource_1",
displayName: "Ada Lovelace",
eid: "E-1",
lcrCents: 10_000,
chapter: "Engineering",
availability: null,
};
const roleEntity = {
id: "role_1",
name: "Engineer",
color: "#123456",
};
describe("buildSplitAllocationReadModel", () => {
it("builds demand and assignment entries from explicit rows", () => {
const result = buildSplitAllocationReadModel({
demandRequirements: [
{
id: "demand_1",
projectId: project.id,
startDate: new Date("2026-01-06"),
endDate: new Date("2026-01-10"),
hoursPerDay: 8,
percentage: 100,
role: "Engineer",
roleId: roleEntity.id,
headcount: 2,
status: AllocationStatus.PROPOSED,
metadata: { source: "explicit-demand" },
createdAt: new Date("2026-01-01"),
updatedAt: new Date("2026-01-02"),
project,
roleEntity,
},
],
assignments: [
{
id: "assignment_1",
demandRequirementId: "demand_1",
resourceId: resource.id,
projectId: project.id,
startDate: new Date("2026-01-06"),
endDate: new Date("2026-01-10"),
hoursPerDay: 8,
percentage: 100,
role: "Engineer",
roleId: roleEntity.id,
dailyCostCents: 80_000,
status: AllocationStatus.PROPOSED,
metadata: { source: "explicit-assignment" },
createdAt: new Date("2026-01-01"),
updatedAt: new Date("2026-01-02"),
resource,
project,
roleEntity,
},
],
});
expect(result.allocations).toHaveLength(2);
expect(result.demands).toHaveLength(1);
expect(result.assignments).toHaveLength(1);
expect(result.demands[0]).toMatchObject({
id: "demand_1",
entityId: "demand_1",
sourceAllocationId: "demand_1",
requestedHeadcount: 2,
metadata: { source: "explicit-demand" },
});
expect(result.assignments[0]).toMatchObject({
id: "assignment_1",
entityId: "assignment_1",
sourceAllocationId: "assignment_1",
resourceId: resource.id,
metadata: { source: "explicit-assignment" },
});
});
it("returns empty arrays when given no inputs", () => {
const result = buildSplitAllocationReadModel({
demandRequirements: [],
assignments: [],
});
expect(result.allocations).toHaveLength(0);
expect(result.demands).toHaveLength(0);
expect(result.assignments).toHaveLength(0);
});
});
+120
View File
@@ -0,0 +1,120 @@
export {
createDemandRequirement,
type DemandRequirementWithRelations,
} from "./use-cases/allocation/create-demand-requirement.js";
export { updateDemandRequirement } from "./use-cases/allocation/update-demand-requirement.js";
export {
createAssignment,
type AssignmentWithRelations,
} from "./use-cases/allocation/create-assignment.js";
export { updateAssignment } from "./use-cases/allocation/update-assignment.js";
export { buildAllocationReadModel } from "./use-cases/allocation/build-allocation-read-model.js";
export {
buildSplitAllocationReadModel,
type BuildSplitAllocationReadModelInput,
type SplitAssignmentRecord,
type SplitDemandRequirementRecord,
} from "./use-cases/allocation/build-split-allocation-read-model.js";
export {
listAssignmentBookings,
type AssignmentBookingWithFallback,
type ListAssignmentBookingsInput,
} from "./use-cases/allocation/list-assignment-bookings.js";
export {
countPlanningEntries,
type CountPlanningEntriesInput,
type CountPlanningEntriesResult,
} from "./use-cases/allocation/count-planning-entries.js";
export {
countEstimateHandoffPlanningEntries,
type CountEstimateHandoffPlanningEntriesInput,
} from "./use-cases/allocation/count-estimate-handoff-planning-entries.js";
export {
fillDemandRequirement,
type FillDemandRequirementResult,
} from "./use-cases/allocation/fill-demand-requirement.js";
export {
fillOpenDemand,
type FillOpenDemandResult,
} from "./use-cases/allocation/fill-open-demand.js";
export {
findAllocationEntry,
loadAllocationEntry,
type AllocationEntryResolution,
} from "./use-cases/allocation/load-allocation-entry.js";
export {
updateAllocationEntry,
type UpdateAllocationEntryInput,
type UpdateAllocationEntryResult,
} from "./use-cases/allocation/update-allocation-entry.js";
export {
deleteAllocationEntry,
type DeleteAllocationEntryResult,
} from "./use-cases/allocation/delete-allocation-entry.js";
export {
deleteDemandRequirement,
type DeleteDemandRequirementResult,
} from "./use-cases/allocation/delete-demand-requirement.js";
export {
deleteAssignment,
type DeleteAssignmentResult,
} from "./use-cases/allocation/delete-assignment.js";
export {
getDashboardOverview,
getDashboardPeakTimes,
getDashboardTopValueResources,
getDashboardDemand,
getDashboardChargeabilityOverview,
type GetDashboardPeakTimesInput,
type GetDashboardTopValueResourcesInput,
type GetDashboardDemandInput,
type GetDashboardChargeabilityOverviewInput,
} from "./use-cases/dashboard/index.js";
export {
cloneEstimate,
createEstimate,
listEstimates,
getEstimateById,
updateEstimateDraft,
submitEstimateVersion,
approveEstimateVersion,
createEstimateRevision,
createEstimateExport,
createEstimatePlanningHandoff,
type CloneEstimateInput,
type EstimateWithDetails,
type EstimateListItem,
} from "./use-cases/estimate/index.js";
export {
assessDispoImportReadiness,
parseMandatoryDispoReferenceWorkbook,
parseDispoChargeabilityWorkbook,
parseDispoPlanningWorkbook,
parseResourceRosterMasterWorkbook,
parseDispoRosterWorkbook,
persistDispoImportReadiness,
stageDispoReferenceData,
stageDispoChargeabilityResources,
stageDispoRosterResources,
stageDispoPlanningData,
stageDispoProjects,
stageDispoImportBatch,
type AssessDispoImportReadinessInput,
type DispoImportReadinessIssue,
type DispoImportReadinessReport,
type StageDispoReferenceDataResult,
type StageDispoChargeabilityResourcesResult,
type StageDispoRosterResourcesResult,
type StageDispoPlanningResult,
type StageDispoProjectsResult,
type StageDispoImportBatchInput,
type StageDispoImportBatchResult,
} from "./use-cases/dispo-import/index.js";
@@ -0,0 +1,35 @@
import type { Prisma, PrismaClient } from "@planarchy/db";
import { AllocationStatus } from "@planarchy/shared";
type DbClient = PrismaClient | Prisma.TransactionClient;
export interface DemandRequirementFillProgressInput {
demandRequirementId: string;
headcount: number;
}
export const UPDATED_DEMAND_REQUIREMENT_SELECT = {
id: true,
projectId: true,
headcount: true,
status: true,
} as const;
export async function applyDemandRequirementFillProgress(
db: DbClient,
input: DemandRequirementFillProgressInput,
) {
const updatedDemandRequirement = await db.demandRequirement.update({
where: { id: input.demandRequirementId },
data:
input.headcount > 1
? { headcount: input.headcount - 1 }
: { status: AllocationStatus.COMPLETED },
select: UPDATED_DEMAND_REQUIREMENT_SELECT,
});
return {
...updatedDemandRequirement,
status: updatedDemandRequirement.status as AllocationStatus,
};
}
@@ -0,0 +1,54 @@
import type {
AllocationLike,
AllocationReadModel,
Assignment,
DemandRequirement,
} from "@planarchy/shared";
function toDemandRequirement<TAllocation extends AllocationLike>(
allocation: TAllocation,
): DemandRequirement<TAllocation> {
return {
...allocation,
kind: "demand",
sourceAllocationId: allocation.entityId ?? allocation.id,
resourceId: null,
isPlaceholder: true,
requestedHeadcount: allocation.headcount,
unfilledHeadcount: allocation.headcount,
};
}
function toAssignment<TAllocation extends AllocationLike>(
allocation: TAllocation,
): Assignment<TAllocation> {
return {
...allocation,
kind: "assignment",
sourceAllocationId: allocation.entityId ?? allocation.id,
resourceId: allocation.resourceId as string,
isPlaceholder: false,
};
}
export function buildAllocationReadModel<TAllocation extends AllocationLike>(
allocations: TAllocation[],
): AllocationReadModel<TAllocation> {
const demands: DemandRequirement<TAllocation>[] = [];
const assignments: Assignment<TAllocation>[] = [];
for (const allocation of allocations) {
if (allocation.isPlaceholder || allocation.resourceId === null) {
demands.push(toDemandRequirement(allocation));
continue;
}
assignments.push(toAssignment(allocation));
}
return {
allocations,
demands,
assignments,
};
}
@@ -0,0 +1,175 @@
import type {
AllocationLike,
AllocationReadModel,
Assignment,
DemandRequirement,
} from "@planarchy/shared";
type SplitAllocationEntry = AllocationLike;
type SplitProjectSummary = NonNullable<SplitAllocationEntry["project"]>;
type SplitResourceSummary = NonNullable<SplitAllocationEntry["resource"]>;
type SplitRoleSummary = NonNullable<SplitAllocationEntry["roleEntity"]>;
type SplitDemandAllocationEntry = SplitAllocationEntry & {
resourceId: null;
isPlaceholder: true;
};
type SplitAssignmentAllocationEntry = SplitAllocationEntry & {
resourceId: string;
isPlaceholder: false;
};
export interface SplitDemandRequirementRecord {
id: string;
projectId: string;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
percentage: number;
role: string | null;
roleId: string | null;
headcount: number;
status: string;
metadata: unknown;
createdAt: Date | string;
updatedAt: Date | string;
project?: SplitProjectSummary;
roleEntity?: SplitRoleSummary | null;
}
export interface SplitAssignmentRecord {
id: string;
demandRequirementId?: string | null;
resourceId: string;
projectId: string;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
percentage: number;
role: string | null;
roleId: string | null;
dailyCostCents: number;
status: string;
metadata: unknown;
createdAt: Date | string;
updatedAt: Date | string;
resource?: SplitResourceSummary | null;
project?: SplitProjectSummary;
roleEntity?: SplitRoleSummary | null;
}
export interface BuildSplitAllocationReadModelInput {
demandRequirements: SplitDemandRequirementRecord[];
assignments: SplitAssignmentRecord[];
}
function compareEntries(
left: Pick<SplitAllocationEntry, "startDate" | "resourceId" | "id">,
right: Pick<SplitAllocationEntry, "startDate" | "resourceId" | "id">,
): number {
const startDelta =
new Date(left.startDate).getTime() - new Date(right.startDate).getTime();
if (startDelta !== 0) {
return startDelta;
}
const resourceDelta = (left.resourceId ?? "").localeCompare(right.resourceId ?? "");
if (resourceDelta !== 0) {
return resourceDelta;
}
return left.id.localeCompare(right.id);
}
function toDemandAllocationEntry(
demandRequirement: SplitDemandRequirementRecord,
): SplitDemandAllocationEntry {
return {
id: demandRequirement.id,
entityId: demandRequirement.id,
resourceId: null,
projectId: demandRequirement.projectId,
startDate: demandRequirement.startDate,
endDate: demandRequirement.endDate,
hoursPerDay: demandRequirement.hoursPerDay,
percentage: demandRequirement.percentage,
role: demandRequirement.role,
roleId: demandRequirement.roleId,
isPlaceholder: true,
headcount: demandRequirement.headcount,
dailyCostCents: 0,
status: demandRequirement.status,
metadata: demandRequirement.metadata,
createdAt: demandRequirement.createdAt,
updatedAt: demandRequirement.updatedAt,
...(demandRequirement.project ? { project: demandRequirement.project } : {}),
...(demandRequirement.roleEntity !== undefined
? { roleEntity: demandRequirement.roleEntity ?? null }
: {}),
};
}
function toAssignmentAllocationEntry(
assignment: SplitAssignmentRecord,
): SplitAssignmentAllocationEntry {
return {
id: assignment.id,
entityId: assignment.id,
resourceId: assignment.resourceId,
projectId: assignment.projectId,
startDate: assignment.startDate,
endDate: assignment.endDate,
hoursPerDay: assignment.hoursPerDay,
percentage: assignment.percentage,
role: assignment.role,
roleId: assignment.roleId,
isPlaceholder: false,
headcount: 1,
dailyCostCents: assignment.dailyCostCents,
status: assignment.status,
metadata: assignment.metadata,
createdAt: assignment.createdAt,
updatedAt: assignment.updatedAt,
...(assignment.resource !== undefined ? { resource: assignment.resource ?? null } : {}),
...(assignment.project ? { project: assignment.project } : {}),
...(assignment.roleEntity !== undefined ? { roleEntity: assignment.roleEntity ?? null } : {}),
};
}
function toDemandReadModelEntry(
demandRequirement: SplitDemandRequirementRecord,
): DemandRequirement<SplitAllocationEntry> {
const entry = toDemandAllocationEntry(demandRequirement);
return {
...entry,
kind: "demand",
sourceAllocationId: demandRequirement.id,
requestedHeadcount: demandRequirement.headcount,
unfilledHeadcount: demandRequirement.headcount,
};
}
function toAssignmentReadModelEntry(
assignment: SplitAssignmentRecord,
): Assignment<SplitAllocationEntry> {
const entry = toAssignmentAllocationEntry(assignment);
return {
...entry,
kind: "assignment",
sourceAllocationId: assignment.id,
};
}
export function buildSplitAllocationReadModel({
demandRequirements,
assignments,
}: BuildSplitAllocationReadModelInput): AllocationReadModel<SplitAllocationEntry> {
return {
allocations: [
...demandRequirements.map(toDemandAllocationEntry),
...assignments.map(toAssignmentAllocationEntry),
].sort(compareEntries),
demands: demandRequirements.map(toDemandReadModelEntry).sort(compareEntries),
assignments: assignments.map(toAssignmentReadModelEntry).sort(compareEntries),
};
}
@@ -0,0 +1,72 @@
import type { Prisma, PrismaClient } from "@planarchy/db";
import { buildSplitAllocationReadModel } from "./build-split-allocation-read-model.js";
type DbClient =
| Pick<PrismaClient, "demandRequirement" | "assignment">
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
export interface CountEstimateHandoffPlanningEntriesInput {
projectId: string;
estimateVersionId: string;
}
export async function countEstimateHandoffPlanningEntries(
db: DbClient,
input: CountEstimateHandoffPlanningEntriesInput,
): Promise<number> {
const handoffWhere = {
projectId: input.projectId,
metadata: {
path: ["estimateHandoff", "estimateVersionId"],
equals: input.estimateVersionId,
},
} as const;
const [demandRequirements, assignments] = await Promise.all([
db.demandRequirement.findMany({
where: handoffWhere,
select: {
id: true,
projectId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
percentage: true,
role: true,
roleId: true,
headcount: true,
status: true,
metadata: true,
createdAt: true,
updatedAt: true,
},
}),
db.assignment.findMany({
where: handoffWhere,
select: {
id: true,
demandRequirementId: true,
resourceId: true,
projectId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
percentage: true,
role: true,
roleId: true,
dailyCostCents: true,
status: true,
metadata: true,
createdAt: true,
updatedAt: true,
},
}),
]);
return buildSplitAllocationReadModel({
demandRequirements,
assignments,
}).allocations.length;
}
@@ -0,0 +1,129 @@
import type { Prisma, PrismaClient } from "@planarchy/db";
import { buildSplitAllocationReadModel } from "./build-split-allocation-read-model.js";
type DbClient =
| Pick<PrismaClient, "demandRequirement" | "assignment">
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
export interface CountPlanningEntriesInput {
projectIds?: string[];
roleIds?: string[];
}
export interface CountPlanningEntriesResult {
countsByProjectId: Map<string, number>;
countsByRoleId: Map<string, number>;
totalCount: number;
}
function normalizeIds(ids?: string[]): string[] | undefined {
if (!ids) {
return undefined;
}
const normalized = [...new Set(ids.filter(Boolean))];
return normalized.length > 0 ? normalized : undefined;
}
function buildScopedWhere(input: CountPlanningEntriesInput) {
const projectIds = normalizeIds(input.projectIds);
const roleIds = normalizeIds(input.roleIds);
if (input.projectIds && !projectIds) {
return null;
}
if (input.roleIds && !roleIds) {
return null;
}
return {
...(projectIds ? { projectId: { in: projectIds } } : {}),
...(roleIds ? { roleId: { in: roleIds } } : {}),
};
}
export async function countPlanningEntries(
db: DbClient,
input: CountPlanningEntriesInput = {},
): Promise<CountPlanningEntriesResult> {
const scopedWhere = buildScopedWhere(input);
if (scopedWhere === null) {
return {
countsByProjectId: new Map(),
countsByRoleId: new Map(),
totalCount: 0,
};
}
const [demandRequirements, assignments] = await Promise.all([
db.demandRequirement.findMany({
where: scopedWhere,
select: {
id: true,
projectId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
percentage: true,
role: true,
roleId: true,
headcount: true,
status: true,
metadata: true,
createdAt: true,
updatedAt: true,
},
}),
db.assignment.findMany({
where: scopedWhere,
select: {
id: true,
demandRequirementId: true,
resourceId: true,
projectId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
percentage: true,
role: true,
roleId: true,
dailyCostCents: true,
status: true,
metadata: true,
createdAt: true,
updatedAt: true,
},
}),
]);
const readModel = buildSplitAllocationReadModel({
demandRequirements,
assignments,
});
const countsByProjectId = new Map<string, number>();
const countsByRoleId = new Map<string, number>();
for (const allocation of readModel.allocations) {
countsByProjectId.set(
allocation.projectId,
(countsByProjectId.get(allocation.projectId) ?? 0) + 1,
);
if (allocation.roleId) {
countsByRoleId.set(
allocation.roleId,
(countsByRoleId.get(allocation.roleId) ?? 0) + 1,
);
}
}
return {
countsByProjectId,
countsByRoleId,
totalCount: readModel.allocations.length,
};
}
@@ -0,0 +1,174 @@
import { calculateAllocation, validateAvailability } from "@planarchy/engine";
import type { PrismaClient, Prisma } from "@planarchy/db";
import {
type Allocation,
type CreateAssignmentInput,
type WeekdayAvailability,
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { listAssignmentBookings } from "./list-assignment-bookings.js";
type DbClient = PrismaClient | Prisma.TransactionClient;
export const ASSIGNMENT_RELATIONS_INCLUDE = {
resource: { select: { id: true, displayName: true, eid: true, lcrCents: true } },
project: { select: { id: true, name: true, shortCode: true } },
roleEntity: { select: { id: true, name: true, color: true } },
demandRequirement: {
select: {
id: true,
projectId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
percentage: true,
role: true,
roleId: true,
headcount: true,
status: true,
},
},
} as const;
export type AssignmentWithRelations = Prisma.AssignmentGetPayload<{
include: typeof ASSIGNMENT_RELATIONS_INCLUDE;
}>;
async function getVacationDates(db: DbClient, resourceId: string, startDate: Date, endDate: Date) {
const vacationDates: Date[] = [];
try {
const vacations = await db.vacation.findMany({
where: {
resourceId,
status: "APPROVED",
startDate: { lte: endDate },
endDate: { gte: startDate },
},
select: { startDate: true, endDate: true },
});
for (const vacation of vacations) {
const current = new Date(vacation.startDate);
current.setHours(0, 0, 0, 0);
const vacationEnd = new Date(vacation.endDate);
vacationEnd.setHours(0, 0, 0, 0);
while (current <= vacationEnd) {
vacationDates.push(new Date(current));
current.setDate(current.getDate() + 1);
}
}
} catch {
// Vacation persistence may not be available in all environments yet.
}
return vacationDates;
}
export async function createAssignment(
db: DbClient,
input: CreateAssignmentInput,
): Promise<AssignmentWithRelations> {
const project = await db.project.findUnique({ where: { id: input.projectId } });
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const resource = await db.resource.findUnique({ where: { id: input.resourceId } });
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
if (input.demandRequirementId) {
const demandRequirement = await db.demandRequirement.findUnique({
where: { id: input.demandRequirementId },
select: { id: true, projectId: true },
});
if (!demandRequirement) {
throw new TRPCError({ code: "NOT_FOUND", message: "Demand requirement not found" });
}
if (demandRequirement.projectId !== input.projectId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Demand requirement belongs to a different project",
});
}
}
const existingBookings = await listAssignmentBookings(
db as unknown as Parameters<typeof listAssignmentBookings>[0],
{
resourceIds: [input.resourceId],
},
);
const availability = resource.availability as unknown as WeekdayAvailability;
const availabilityWindows = existingBookings.map((booking) => ({
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
})) as Pick<Allocation, "startDate" | "endDate" | "hoursPerDay" | "status">[];
const availabilityResult = validateAvailability(
input.startDate,
input.endDate,
input.hoursPerDay,
availability,
availabilityWindows,
);
if (!availabilityResult.valid && availabilityResult.totalConflictDays > 5) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Resource has availability conflicts on ${availabilityResult.totalConflictDays} days`,
});
}
const vacationDates = await getVacationDates(
db,
input.resourceId,
input.startDate,
input.endDate,
);
const calculation = calculateAllocation({
lcrCents: resource.lcrCents,
hoursPerDay: input.hoursPerDay,
startDate: input.startDate,
endDate: input.endDate,
availability,
vacationDates,
});
const assignment = await db.assignment.create({
data: {
demandRequirementId: input.demandRequirementId ?? null,
resourceId: input.resourceId,
projectId: input.projectId,
startDate: input.startDate,
endDate: input.endDate,
hoursPerDay: input.hoursPerDay,
percentage: input.percentage,
role: input.role ?? null,
roleId: input.roleId ?? null,
dailyCostCents: input.dailyCostCents ?? calculation.dailyCostCents,
status: input.status,
metadata: input.metadata as unknown as Prisma.InputJsonValue,
},
include: ASSIGNMENT_RELATIONS_INCLUDE,
});
await db.auditLog.create({
data: {
entityType: "Assignment",
entityId: assignment.id,
action: "CREATE",
changes: { after: assignment } as unknown as Prisma.InputJsonValue,
},
});
return assignment;
}
@@ -0,0 +1,51 @@
import type { PrismaClient, Prisma } from "@planarchy/db";
import { type CreateDemandRequirementInput } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
type DbClient = PrismaClient | Prisma.TransactionClient;
export const DEMAND_REQUIREMENT_RELATIONS_INCLUDE = {
project: { select: { id: true, name: true, shortCode: true } },
roleEntity: { select: { id: true, name: true, color: true } },
} as const;
export type DemandRequirementWithRelations = Prisma.DemandRequirementGetPayload<{
include: typeof DEMAND_REQUIREMENT_RELATIONS_INCLUDE;
}>;
export async function createDemandRequirement(
db: DbClient,
input: CreateDemandRequirementInput,
): Promise<DemandRequirementWithRelations> {
const project = await db.project.findUnique({ where: { id: input.projectId } });
if (!project) {
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
}
const demandRequirement = await db.demandRequirement.create({
data: {
projectId: input.projectId,
startDate: input.startDate,
endDate: input.endDate,
hoursPerDay: input.hoursPerDay,
percentage: input.percentage,
role: input.role ?? null,
roleId: input.roleId ?? null,
headcount: input.headcount ?? 1,
status: input.status,
metadata: input.metadata as unknown as Prisma.InputJsonValue,
},
include: DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
});
await db.auditLog.create({
data: {
entityType: "DemandRequirement",
entityId: demandRequirement.id,
action: "CREATE",
changes: { after: demandRequirement } as unknown as Prisma.InputJsonValue,
},
});
return demandRequirement;
}
@@ -0,0 +1,51 @@
import type { Prisma, PrismaClient } from "@planarchy/db";
import { TRPCError } from "@trpc/server";
import type { AllocationEntryResolution } from "./load-allocation-entry.js";
import { deleteAssignment } from "./delete-assignment.js";
import { deleteDemandRequirement } from "./delete-demand-requirement.js";
type DbClient =
| Pick<PrismaClient, "demandRequirement" | "assignment">
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
export interface DeleteAllocationEntryResult {
deletedId: string;
projectId: string;
resourceId: string | null;
strategy: "explicit_demand" | "explicit_assignment";
}
export async function deleteAllocationEntry(
db: DbClient,
resolved: AllocationEntryResolution,
): Promise<DeleteAllocationEntryResult> {
if (resolved.kind === "demand") {
const result = await deleteDemandRequirement(
db as Parameters<typeof deleteDemandRequirement>[0],
resolved.demandRequirement.id,
);
return {
deletedId: result.deletedId,
projectId: result.projectId,
resourceId: null,
strategy: "explicit_demand",
};
}
if (resolved.kind === "assignment") {
const result = await deleteAssignment(
db as Parameters<typeof deleteAssignment>[0],
resolved.assignment.id,
);
return {
deletedId: result.deletedId,
projectId: result.projectId,
resourceId: result.resourceId,
strategy: "explicit_assignment",
};
}
throw new TRPCError({ code: "NOT_FOUND", message: "Allocation not found" });
}
@@ -0,0 +1,39 @@
import type { Prisma, PrismaClient } from "@planarchy/db";
type DbClient =
| Pick<PrismaClient, "assignment">
| Pick<Prisma.TransactionClient, "assignment">;
export interface DeleteAssignmentResult {
deletedId: string;
projectId: string;
resourceId: string;
}
export async function deleteAssignment(
db: DbClient,
id: string,
): Promise<DeleteAssignmentResult> {
const assignment = await db.assignment.findUnique({
where: { id },
select: {
id: true,
projectId: true,
resourceId: true,
},
});
if (!assignment) {
throw new Error("Assignment not found");
}
await db.assignment.delete({
where: { id: assignment.id },
});
return {
deletedId: assignment.id,
projectId: assignment.projectId,
resourceId: assignment.resourceId,
};
}
@@ -0,0 +1,40 @@
import type { Prisma, PrismaClient } from "@planarchy/db";
type DbClient =
| Pick<PrismaClient, "demandRequirement" | "assignment">
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
export interface DeleteDemandRequirementResult {
deletedId: string;
projectId: string;
}
export async function deleteDemandRequirement(
db: DbClient,
id: string,
): Promise<DeleteDemandRequirementResult> {
const demandRequirement = await db.demandRequirement.findUnique({
where: { id },
select: {
id: true,
projectId: true,
},
});
if (!demandRequirement) {
throw new Error("Demand requirement not found");
}
await db.assignment.updateMany({
where: { demandRequirementId: demandRequirement.id },
data: { demandRequirementId: null },
});
await db.demandRequirement.delete({
where: { id: demandRequirement.id },
});
return {
deletedId: demandRequirement.id,
projectId: demandRequirement.projectId,
};
}
@@ -0,0 +1,64 @@
import type { PrismaClient } from "@planarchy/db";
import {
AllocationStatus,
type FillDemandRequirementInput,
} from "@planarchy/shared";
import {
createAssignment,
type AssignmentWithRelations,
} from "./create-assignment.js";
import { applyDemandRequirementFillProgress } from "./apply-demand-requirement-fill-progress.js";
export interface DemandRequirementFillTarget {
id: string;
projectId: string;
startDate: Date;
endDate: Date;
hoursPerDay: number;
role: string | null;
roleId: string | null;
headcount: number;
metadata: unknown;
}
export interface FillDemandRequirementWithLegacySyncResult {
assignment: AssignmentWithRelations;
updatedDemandRequirement: Awaited<
ReturnType<typeof applyDemandRequirementFillProgress>
>;
}
export async function fillDemandRequirementWithLegacySync(
db: PrismaClient,
demandRequirement: DemandRequirementFillTarget,
input: FillDemandRequirementInput,
): Promise<FillDemandRequirementWithLegacySyncResult> {
const hoursPerDay = input.hoursPerDay ?? demandRequirement.hoursPerDay;
const percentage = Math.max(1, Math.min(100, Math.round((hoursPerDay / 8) * 100)));
return db.$transaction(async (tx) => {
const createdAssignment = await createAssignment(tx, {
demandRequirementId: demandRequirement.id,
resourceId: input.resourceId,
projectId: demandRequirement.projectId,
startDate: demandRequirement.startDate,
endDate: demandRequirement.endDate,
hoursPerDay,
percentage,
role: demandRequirement.role ?? undefined,
roleId: demandRequirement.roleId ?? undefined,
status: input.status ?? AllocationStatus.PROPOSED,
metadata: (demandRequirement.metadata as Record<string, unknown> | null) ?? {},
});
const updatedDemandRequirement = await applyDemandRequirementFillProgress(tx, {
demandRequirementId: demandRequirement.id,
headcount: demandRequirement.headcount,
});
return {
assignment: createdAssignment,
updatedDemandRequirement,
};
});
}
@@ -0,0 +1,58 @@
import type { PrismaClient } from "@planarchy/db";
import { AllocationStatus, type FillDemandRequirementInput } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { type AssignmentWithRelations } from "./create-assignment.js";
import { fillDemandRequirementWithLegacySync } from "./fill-demand-requirement-with-legacy-sync.js";
export interface FillDemandRequirementResult {
assignment: AssignmentWithRelations;
updatedDemandRequirement: {
id: string;
projectId: string;
headcount: number;
status: AllocationStatus;
};
}
export interface FillDemandRequirementOptions {
}
export async function fillDemandRequirement(
db: PrismaClient,
input: FillDemandRequirementInput,
): Promise<FillDemandRequirementResult> {
const demandRequirement = await db.demandRequirement.findUnique({
where: { id: input.demandRequirementId },
select: {
id: true,
projectId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
role: true,
roleId: true,
headcount: true,
status: true,
metadata: true,
},
});
if (!demandRequirement) {
throw new TRPCError({ code: "NOT_FOUND", message: "Demand requirement not found" });
}
if (demandRequirement.status === AllocationStatus.CANCELLED) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Demand requirement is cancelled",
});
}
if (demandRequirement.status === AllocationStatus.COMPLETED) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Demand requirement is already completed",
});
}
return fillDemandRequirementWithLegacySync(db, demandRequirement, input);
}
@@ -0,0 +1,67 @@
import type { PrismaClient } from "@planarchy/db";
import type { FillOpenDemandByAllocationInput } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { fillDemandRequirement } from "./fill-demand-requirement.js";
import { loadAllocationEntry } from "./load-allocation-entry.js";
export interface FillOpenDemandResult {
strategy: "demand_requirement" | "placeholder";
createdAllocation: {
id: string;
projectId: string;
resourceId: string | null;
};
updatedAllocation: {
id: string;
projectId: string;
resourceId: string | null;
} | null;
}
function toDemandRequirementFillResult(
result: Awaited<ReturnType<typeof fillDemandRequirement>>,
): FillOpenDemandResult {
return {
strategy: "demand_requirement",
createdAllocation: {
id: result.assignment.id,
projectId: result.assignment.projectId,
resourceId: result.assignment.resourceId,
},
updatedAllocation: {
id: result.updatedDemandRequirement.id,
projectId: result.updatedDemandRequirement.projectId,
resourceId: null,
},
};
}
export async function fillOpenDemand(
db: PrismaClient,
input: FillOpenDemandByAllocationInput,
): Promise<FillOpenDemandResult> {
const allocation = await loadAllocationEntry(db, input.allocationId);
if (allocation.kind === "assignment") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Allocation is already filled",
});
}
if (allocation.kind === "demand") {
const result = await fillDemandRequirement(db, {
demandRequirementId: allocation.demandRequirement.id,
resourceId: input.resourceId,
...(input.hoursPerDay !== undefined ? { hoursPerDay: input.hoursPerDay } : {}),
...(input.status !== undefined ? { status: input.status } : {}),
});
return toDemandRequirementFillResult(result);
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Unsupported allocation entry resolution",
});
}
@@ -0,0 +1,94 @@
import type { Prisma, PrismaClient } from "@planarchy/db";
type AssignmentBookingsDbClient = Pick<PrismaClient, "assignment">;
export interface ListAssignmentBookingsInput {
startDate?: Date | undefined;
endDate?: Date | undefined;
resourceIds?: string[] | undefined;
projectIds?: string[] | undefined;
excludeAssignmentIds?: string[] | undefined;
}
export interface AssignmentBookingWithFallback {
id: string;
projectId: string;
resourceId: string | null;
startDate: Date;
endDate: Date;
hoursPerDay: number;
dailyCostCents: number;
status: string;
project: {
id: string;
name: string;
shortCode: string;
status: string;
orderType: string;
};
resource: {
id: string;
displayName: string;
chapter: string | null;
} | null;
}
export async function listAssignmentBookings(
db: AssignmentBookingsDbClient,
input: ListAssignmentBookingsInput,
): Promise<AssignmentBookingWithFallback[]> {
const hasDateBounds = input.startDate !== undefined && input.endDate !== undefined;
if (!hasDateBounds && (input.startDate !== undefined || input.endDate !== undefined)) {
throw new Error("startDate and endDate must be provided together");
}
const excludeAssignmentIds = input.excludeAssignmentIds?.filter(Boolean) ?? [];
const assignmentWhere = {
status: { not: "CANCELLED" as const },
...(hasDateBounds
? {
startDate: { lte: input.endDate! },
endDate: { gte: input.startDate! },
}
: {}),
...(input.resourceIds?.length ? { resourceId: { in: input.resourceIds } } : {}),
...(input.projectIds?.length ? { projectId: { in: input.projectIds } } : {}),
...(excludeAssignmentIds.length ? { id: { notIn: excludeAssignmentIds } } : {}),
} satisfies Prisma.AssignmentWhereInput;
const assignmentSelect = {
id: true,
projectId: true,
resourceId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
dailyCostCents: true,
status: true,
project: {
select: { id: true, name: true, shortCode: true, status: true, orderType: true },
},
resource: {
select: { id: true, displayName: true, chapter: true },
},
} satisfies Prisma.AssignmentSelect;
const assignments = await db.assignment.findMany({
where: assignmentWhere,
select: assignmentSelect,
});
return assignments.map((assignment) => ({
id: assignment.id,
projectId: assignment.projectId,
resourceId: assignment.resourceId,
startDate: assignment.startDate,
endDate: assignment.endDate,
hoursPerDay: assignment.hoursPerDay,
dailyCostCents: assignment.dailyCostCents,
status: assignment.status,
project: assignment.project,
resource: assignment.resource,
}));
}
@@ -0,0 +1,117 @@
import type { Prisma, PrismaClient } from "@planarchy/db";
import type { AllocationWithDetails } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { buildSplitAllocationReadModel } from "./build-split-allocation-read-model.js";
import {
ASSIGNMENT_RELATIONS_INCLUDE,
type AssignmentWithRelations,
} from "./create-assignment.js";
import {
DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
type DemandRequirementWithRelations,
} from "./create-demand-requirement.js";
type DbClient =
| Pick<PrismaClient, "demandRequirement" | "assignment">
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
export type AllocationEntryResolution =
| {
kind: "demand";
entry: AllocationWithDetails;
demandRequirement: DemandRequirementWithRelations;
projectId: string;
resourceId: null;
}
| {
kind: "assignment";
entry: AllocationWithDetails;
assignment: AssignmentWithRelations;
projectId: string;
resourceId: string;
};
function toDemandAllocationEntry(
demandRequirement: DemandRequirementWithRelations,
): AllocationWithDetails {
return buildSplitAllocationReadModel({
demandRequirements: [demandRequirement],
assignments: [],
}).allocations[0] as AllocationWithDetails;
}
function toAssignmentAllocationEntry(
assignment: AssignmentWithRelations,
): AllocationWithDetails {
return buildSplitAllocationReadModel({
demandRequirements: [],
assignments: [assignment],
}).allocations[0] as AllocationWithDetails;
}
async function loadDemandRequirementById(
db: DbClient,
id: string,
): Promise<DemandRequirementWithRelations | null> {
return db.demandRequirement.findUnique({
where: { id },
include: DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
});
}
async function loadAssignmentById(
db: DbClient,
id: string,
): Promise<AssignmentWithRelations | null> {
return db.assignment.findUnique({
where: { id },
include: ASSIGNMENT_RELATIONS_INCLUDE,
});
}
/**
* Resolves an id to a demand requirement or assignment.
*/
export async function findAllocationEntry(
db: DbClient,
id: string,
): Promise<AllocationEntryResolution | null> {
const [demandRequirement, assignment] = await Promise.all([
loadDemandRequirementById(db, id),
loadAssignmentById(db, id),
]);
if (demandRequirement) {
return {
kind: "demand",
entry: toDemandAllocationEntry(demandRequirement),
demandRequirement,
projectId: demandRequirement.projectId,
resourceId: null,
};
}
if (assignment) {
return {
kind: "assignment",
entry: toAssignmentAllocationEntry(assignment),
assignment,
projectId: assignment.projectId,
resourceId: assignment.resourceId,
};
}
return null;
}
export async function loadAllocationEntry(
db: DbClient,
id: string,
): Promise<AllocationEntryResolution> {
const resolved = await findAllocationEntry(db, id);
if (!resolved) {
throw new TRPCError({ code: "NOT_FOUND", message: "Allocation not found" });
}
return resolved;
}
@@ -0,0 +1,82 @@
import type { PrismaClient, Prisma } from "@planarchy/db";
import type {
AllocationWithDetails,
UpdateAssignmentInput,
UpdateDemandRequirementInput,
} from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import { findAllocationEntry } from "./load-allocation-entry.js";
import { updateAssignment } from "./update-assignment.js";
import { updateDemandRequirement } from "./update-demand-requirement.js";
type DbClient =
| Pick<PrismaClient, "demandRequirement" | "assignment">
| Pick<Prisma.TransactionClient, "demandRequirement" | "assignment">;
export interface UpdateAllocationEntryInput {
id: string;
demandRequirementUpdate: UpdateDemandRequirementInput;
assignmentUpdate: UpdateAssignmentInput;
}
export interface UpdateAllocationEntryResult {
allocation: AllocationWithDetails;
strategy: "explicit_demand" | "explicit_assignment";
}
export async function updateAllocationEntry(
db: DbClient,
input: UpdateAllocationEntryInput,
): Promise<UpdateAllocationEntryResult> {
const resolved = await findAllocationEntry(db, input.id);
if (!resolved) {
throw new TRPCError({ code: "NOT_FOUND", message: "Allocation not found" });
}
if (resolved.kind === "demand") {
const updatedDemandRequirement = await updateDemandRequirement(
db as Parameters<typeof updateDemandRequirement>[0],
resolved.demandRequirement.id,
input.demandRequirementUpdate,
);
return {
allocation: {
...resolved.entry,
...updatedDemandRequirement,
id: resolved.entry.id,
resourceId: null,
isPlaceholder: true,
headcount: updatedDemandRequirement.headcount,
dailyCostCents: 0,
project: updatedDemandRequirement.project,
roleEntity: updatedDemandRequirement.roleEntity ?? null,
updatedAt: updatedDemandRequirement.updatedAt,
} as AllocationWithDetails,
strategy: "explicit_demand",
};
}
const updatedAssignment = await updateAssignment(
db as Parameters<typeof updateAssignment>[0],
resolved.assignment.id,
input.assignmentUpdate,
);
return {
allocation: {
...resolved.entry,
...updatedAssignment,
id: resolved.entry.id,
resourceId: updatedAssignment.resourceId,
isPlaceholder: false,
headcount: 1,
project: updatedAssignment.project,
resource: updatedAssignment.resource ?? null,
roleEntity: updatedAssignment.roleEntity ?? null,
updatedAt: updatedAssignment.updatedAt,
} as AllocationWithDetails,
strategy: "explicit_assignment",
};
}
@@ -0,0 +1,85 @@
import type { PrismaClient, Prisma } from "@planarchy/db";
import { type UpdateAssignmentInput } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import {
ASSIGNMENT_RELATIONS_INCLUDE,
type AssignmentWithRelations,
} from "./create-assignment.js";
type DbClient = PrismaClient | Prisma.TransactionClient;
export async function updateAssignment(
db: DbClient,
id: string,
input: UpdateAssignmentInput,
): Promise<AssignmentWithRelations> {
const existing = await db.assignment.findUnique({
where: { id },
include: ASSIGNMENT_RELATIONS_INCLUDE,
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Assignment not found" });
}
const updatedAssignment = await db.assignment.update({
where: { id },
data: {
...(input.resourceId !== undefined ? { resourceId: input.resourceId } : {}),
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
...(input.demandRequirementId !== undefined
? { demandRequirementId: input.demandRequirementId }
: {}),
...(input.startDate !== undefined ? { startDate: input.startDate } : {}),
...(input.endDate !== undefined ? { endDate: input.endDate } : {}),
...(input.hoursPerDay !== undefined ? { hoursPerDay: input.hoursPerDay } : {}),
...(input.percentage !== undefined ? { percentage: input.percentage } : {}),
...(input.role !== undefined ? { role: input.role } : {}),
...(input.roleId !== undefined ? { roleId: input.roleId } : {}),
...(input.dailyCostCents !== undefined ? { dailyCostCents: input.dailyCostCents } : {}),
...(input.status !== undefined ? { status: input.status } : {}),
...(input.metadata !== undefined
? { metadata: input.metadata as unknown as Prisma.InputJsonValue }
: {}),
},
include: ASSIGNMENT_RELATIONS_INCLUDE,
});
await db.auditLog.create({
data: {
entityType: "Assignment",
entityId: id,
action: "UPDATE",
changes: {
before: {
resourceId: existing.resourceId,
projectId: existing.projectId,
demandRequirementId: existing.demandRequirementId,
startDate: existing.startDate,
endDate: existing.endDate,
hoursPerDay: existing.hoursPerDay,
percentage: existing.percentage,
role: existing.role,
roleId: existing.roleId,
dailyCostCents: existing.dailyCostCents,
status: existing.status,
},
after: {
resourceId: updatedAssignment.resourceId,
projectId: updatedAssignment.projectId,
demandRequirementId: updatedAssignment.demandRequirementId,
startDate: updatedAssignment.startDate,
endDate: updatedAssignment.endDate,
hoursPerDay: updatedAssignment.hoursPerDay,
percentage: updatedAssignment.percentage,
role: updatedAssignment.role,
roleId: updatedAssignment.roleId,
dailyCostCents: updatedAssignment.dailyCostCents,
status: updatedAssignment.status,
},
} as unknown as Prisma.InputJsonValue,
},
});
return updatedAssignment;
}
@@ -0,0 +1,77 @@
import type { PrismaClient, Prisma } from "@planarchy/db";
import { type UpdateDemandRequirementInput } from "@planarchy/shared";
import { TRPCError } from "@trpc/server";
import {
DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
type DemandRequirementWithRelations,
} from "./create-demand-requirement.js";
type DbClient = PrismaClient | Prisma.TransactionClient;
export async function updateDemandRequirement(
db: DbClient,
id: string,
input: UpdateDemandRequirementInput,
): Promise<DemandRequirementWithRelations> {
const existing = await db.demandRequirement.findUnique({
where: { id },
include: DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "Demand requirement not found" });
}
const updatedDemandRequirement = await db.demandRequirement.update({
where: { id },
data: {
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
...(input.startDate !== undefined ? { startDate: input.startDate } : {}),
...(input.endDate !== undefined ? { endDate: input.endDate } : {}),
...(input.hoursPerDay !== undefined ? { hoursPerDay: input.hoursPerDay } : {}),
...(input.percentage !== undefined ? { percentage: input.percentage } : {}),
...(input.role !== undefined ? { role: input.role } : {}),
...(input.roleId !== undefined ? { roleId: input.roleId } : {}),
...(input.headcount !== undefined ? { headcount: input.headcount } : {}),
...(input.status !== undefined ? { status: input.status } : {}),
...(input.metadata !== undefined
? { metadata: input.metadata as unknown as Prisma.InputJsonValue }
: {}),
},
include: DEMAND_REQUIREMENT_RELATIONS_INCLUDE,
});
await db.auditLog.create({
data: {
entityType: "DemandRequirement",
entityId: id,
action: "UPDATE",
changes: {
before: {
projectId: existing.projectId,
startDate: existing.startDate,
endDate: existing.endDate,
hoursPerDay: existing.hoursPerDay,
percentage: existing.percentage,
role: existing.role,
roleId: existing.roleId,
headcount: existing.headcount,
status: existing.status,
},
after: {
projectId: updatedDemandRequirement.projectId,
startDate: updatedDemandRequirement.startDate,
endDate: updatedDemandRequirement.endDate,
hoursPerDay: updatedDemandRequirement.hoursPerDay,
percentage: updatedDemandRequirement.percentage,
role: updatedDemandRequirement.role,
roleId: updatedDemandRequirement.roleId,
headcount: updatedDemandRequirement.headcount,
status: updatedDemandRequirement.status,
},
} as unknown as Prisma.InputJsonValue,
},
});
return updatedDemandRequirement;
}
@@ -0,0 +1,84 @@
import type { PrismaClient } from "@planarchy/db";
import { computeChargeability } from "@planarchy/engine";
import type { WeekdayAvailability } from "@planarchy/shared";
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
export interface GetDashboardChargeabilityOverviewInput {
topN: number;
watchlistThreshold: number;
now?: Date;
}
export async function getDashboardChargeabilityOverview(
db: PrismaClient,
input: GetDashboardChargeabilityOverviewInput,
) {
const now = input.now ?? new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const resources = await db.resource.findMany({
where: { isActive: true },
select: {
id: true,
eid: true,
displayName: true,
chapter: true,
chargeabilityTarget: true,
availability: true,
},
});
const bookings = await listAssignmentBookings(db, {
startDate: start,
endDate: end,
resourceIds: resources.map((resource) => resource.id),
});
const stats = resources.map((resource) => {
const availability = resource.availability as unknown as WeekdayAvailability;
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
const actualAllocations = resourceBookings.filter(
(booking) =>
(booking.status === "CONFIRMED" || booking.status === "ACTIVE") &&
booking.project.status !== "DRAFT" &&
booking.project.status !== "CANCELLED",
);
const actual = computeChargeability(
availability,
actualAllocations,
start,
end,
);
const expected = computeChargeability(
availability,
resourceBookings,
start,
end,
);
return {
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
chapter: resource.chapter,
chargeabilityTarget: resource.chargeabilityTarget,
actualChargeability: actual.chargeability,
expectedChargeability: expected.chargeability,
};
});
return {
top: [...stats]
.sort((left, right) => right.actualChargeability - left.actualChargeability)
.slice(0, input.topN),
watchlist: [...stats]
.filter(
(resource) =>
resource.actualChargeability <
resource.chargeabilityTarget - input.watchlistThreshold,
)
.sort((left, right) => left.actualChargeability - right.actualChargeability)
.slice(0, input.topN),
month: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`,
};
}
@@ -0,0 +1,232 @@
import type { PrismaClient } from "@planarchy/db";
import { loadDashboardPlanningReadModel } from "./load-dashboard-planning-read-model.js";
import { calculateAllocationHours } from "./shared.js";
export interface GetDashboardDemandInput {
startDate: Date;
endDate: Date;
groupBy: "project" | "person" | "chapter";
}
interface ProjectSummary {
id: string;
name: string;
shortCode: string;
staffingReqs: unknown;
}
function getDemandFteFactor(hoursPerDay: number, percentage: number): number {
const normalizedPercentage = percentage > 0 ? percentage : (hoursPerDay / 8) * 100;
return normalizedPercentage / 100;
}
function toDate(value: Date | string): Date {
return value instanceof Date ? value : new Date(value);
}
function getProjectRequiredFTEs(staffingReqs: unknown): number {
const requirements = Array.isArray(staffingReqs) ? staffingReqs : [];
return requirements.reduce((sum, requirement) => {
if (
typeof requirement === "object" &&
requirement !== null &&
"fteCount" in requirement &&
typeof requirement.fteCount === "number"
) {
return sum + requirement.fteCount;
}
return sum;
}, 0);
}
export async function getDashboardDemand(
db: PrismaClient,
input: GetDashboardDemandInput,
) {
const { demandRequirements, assignments, projects, readModel } =
await loadDashboardPlanningReadModel(db, {
startDate: input.startDate,
endDate: input.endDate,
});
const demandRequirementById = new Map(
demandRequirements.map((demandRequirement) => [
demandRequirement.id,
demandRequirement,
]),
);
const normalizedAssignments = readModel.assignments;
const normalizedDemands = readModel.demands;
const projectMap = new Map<string, ProjectSummary>(
projects.map((project) => [project.id, project]),
);
for (const allocation of readModel.allocations) {
if (!allocation.project || projectMap.has(allocation.project.id)) {
continue;
}
projectMap.set(allocation.project.id, {
id: allocation.project.id,
name: allocation.project.name,
shortCode: allocation.project.shortCode,
staffingReqs: allocation.project.staffingReqs,
});
}
const assignmentCountByDemandRequirementId = new Map<string, number>();
for (const assignment of assignments) {
if (!assignment.demandRequirementId) {
continue;
}
assignmentCountByDemandRequirementId.set(
assignment.demandRequirementId,
(assignmentCountByDemandRequirementId.get(assignment.demandRequirementId) ?? 0) + 1,
);
}
if (input.groupBy === "project") {
const projectIds = new Set<string>([
...projectMap.keys(),
...normalizedAssignments.map((assignment) => assignment.projectId),
...normalizedDemands.map((demand) => demand.projectId),
]);
return [...projectIds].map((projectId) => {
const project = projectMap.get(projectId) ?? {
id: projectId,
name: projectId,
shortCode: projectId,
staffingReqs: [],
};
const projectAssignments = normalizedAssignments.filter(
(assignment) => assignment.projectId === projectId,
);
const projectDemands = normalizedDemands.filter(
(demand) => demand.projectId === projectId,
);
const allocatedHours = projectAssignments.reduce(
(sum, assignment) =>
sum +
calculateAllocationHours({
startDate: toDate(assignment.startDate),
endDate: toDate(assignment.endDate),
hoursPerDay: assignment.hoursPerDay,
}),
0,
);
const requiredFTEs =
projectDemands.length > 0
? projectDemands.reduce((sum, demand) => {
const demandFteFactor = getDemandFteFactor(
demand.hoursPerDay,
demand.percentage,
);
const explicitDemand = demandRequirementById.get(demand.id);
if (!explicitDemand) {
return sum + demand.requestedHeadcount * demandFteFactor;
}
const linkedAssignmentCount =
assignmentCountByDemandRequirementId.get(explicitDemand.id) ?? 0;
const plannedHeadcount =
linkedAssignmentCount +
(explicitDemand.status === "COMPLETED" ? 0 : explicitDemand.headcount);
return sum + plannedHeadcount * demandFteFactor;
}, 0)
: getProjectRequiredFTEs(project.staffingReqs);
return {
id: project.id,
name: project.name,
shortCode: project.shortCode,
allocatedHours: Math.round(allocatedHours),
requiredFTEs: Math.round(requiredFTEs * 100) / 100,
resourceCount: new Set(
projectAssignments.map((assignment) => assignment.resource?.id).filter(Boolean),
).size,
};
});
}
if (input.groupBy === "chapter") {
const chapterMap = new Map<
string,
{ allocatedHours: number; resourceIds: Set<string> }
>();
for (const assignment of normalizedAssignments) {
const chapter = assignment.resource?.chapter ?? "Unassigned";
const existing = chapterMap.get(chapter) ?? {
allocatedHours: 0,
resourceIds: new Set<string>(),
};
if (assignment.resource?.id) {
existing.resourceIds.add(assignment.resource.id);
}
existing.allocatedHours += 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,
}));
}
const personMap = new Map<
string,
{
name: string;
chapter: string | null;
allocatedHours: number;
projectIds: Set<string>;
}
>();
for (const assignment of normalizedAssignments) {
if (!assignment.resource) {
continue;
}
const existing = personMap.get(assignment.resource.id) ?? {
name: assignment.resource.displayName,
chapter: assignment.resource.chapter ?? null,
allocatedHours: 0,
projectIds: new Set<string>(),
};
existing.allocatedHours += 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,
}));
}
@@ -0,0 +1,157 @@
import type { PrismaClient } from "@planarchy/db";
import { AllocationStatus } from "@planarchy/shared";
import { buildSplitAllocationReadModel } from "../allocation/build-split-allocation-read-model.js";
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
import { calculateInclusiveDays } from "./shared.js";
export async function getDashboardOverview(db: PrismaClient) {
const [
totalResources,
activeResources,
totalProjects,
allProjects,
allDemandRequirements,
allAssignments,
budgetBookings,
recentActivity,
allResources,
] = await Promise.all([
db.resource.count(),
db.resource.count({ where: { isActive: true } }),
db.project.count(),
db.project.findMany({ select: { status: true, budgetCents: true } }),
db.demandRequirement.findMany({
select: {
id: true,
projectId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
percentage: true,
role: true,
roleId: true,
headcount: true,
status: true,
metadata: true,
createdAt: true,
updatedAt: true,
},
}),
db.assignment.findMany({
select: {
id: true,
demandRequirementId: true,
resourceId: true,
projectId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
percentage: true,
role: true,
roleId: true,
dailyCostCents: true,
status: true,
metadata: true,
createdAt: true,
updatedAt: true,
},
}),
listAssignmentBookings(db, {}),
db.auditLog.findMany({
orderBy: { createdAt: "desc" },
take: 10,
select: { id: true, entityType: true, action: true, createdAt: true },
}),
db.resource.findMany({
select: { chapter: true, chargeabilityTarget: true },
}),
]);
const planningReadModel = buildSplitAllocationReadModel({
demandRequirements: allDemandRequirements,
assignments: allAssignments,
});
const totalAllocations = planningReadModel.allocations.length;
const activeAllocations = planningReadModel.allocations.filter(
(allocation) => allocation.status !== AllocationStatus.CANCELLED,
).length;
const totalCostCents = budgetBookings.reduce(
(sum, booking) =>
sum +
(booking.dailyCostCents ?? 0) *
calculateInclusiveDays(booking.startDate, booking.endDate),
0,
);
const totalBudgetCents = allProjects.reduce(
(sum, project) => sum + (project.budgetCents ?? 0),
0,
);
const avgUtilizationPercent =
totalBudgetCents > 0
? Math.round((totalCostCents / totalBudgetCents) * 100)
: 0;
const statusCountMap = new Map<string, number>();
for (const project of allProjects) {
statusCountMap.set(
project.status,
(statusCountMap.get(project.status) ?? 0) + 1,
);
}
const chapterMap = new Map<
string,
{ resourceCount: number; chargeabilitySum: number }
>();
for (const resource of allResources) {
const chapter = resource.chapter ?? "Unassigned";
const existing = chapterMap.get(chapter) ?? {
resourceCount: 0,
chargeabilitySum: 0,
};
chapterMap.set(chapter, {
resourceCount: existing.resourceCount + 1,
chargeabilitySum:
existing.chargeabilitySum + (resource.chargeabilityTarget ?? 0),
});
}
return {
totalResources,
activeResources,
totalProjects,
activeProjects: allProjects.filter((project) => project.status === "ACTIVE")
.length,
totalAllocations,
activeAllocations,
budgetSummary: {
totalBudgetCents,
totalCostCents,
avgUtilizationPercent,
},
recentActivity: recentActivity.map((activity) => ({
id: activity.id,
entityType: activity.entityType,
action: activity.action,
createdAt: activity.createdAt,
})),
projectsByStatus: [...statusCountMap.entries()].map(([status, count]) => ({
status,
count,
})),
chapterUtilization: [...chapterMap.entries()].map(([chapter, data]) => ({
chapter,
resourceCount: data.resourceCount,
avgChargeabilityTarget:
data.resourceCount > 0
? Math.round(data.chargeabilitySum / data.resourceCount)
: 0,
})),
};
}
@@ -0,0 +1,75 @@
import type { PrismaClient } from "@planarchy/db";
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
import { getAverageDailyAvailabilityHours, getMonthBucketKey, getWeekBucketKey } from "./shared.js";
export interface GetDashboardPeakTimesInput {
startDate: Date;
endDate: Date;
granularity: "week" | "month";
groupBy: "project" | "chapter" | "resource";
}
export async function getDashboardPeakTimes(
db: PrismaClient,
input: GetDashboardPeakTimesInput,
) {
const allocations = await listAssignmentBookings(db, {
startDate: input.startDate,
endDate: input.endDate,
});
const buckets = new Map<string, Map<string, number>>();
const getBucketKey = input.granularity === "week" ? getWeekBucketKey : getMonthBucketKey;
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 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());
}
const bucket = buckets.get(bucketKey)!;
bucket.set(group, (bucket.get(group) ?? 0) + allocation.hoursPerDay);
cursor.setDate(cursor.getDate() + 1);
}
}
const resources = await db.resource.findMany({
where: { isActive: true },
select: { availability: true },
});
const dailyCapacityHours = resources.reduce(
(sum, resource) =>
sum +
getAverageDailyAvailabilityHours(
resource.availability as Record<string, number | null | undefined>,
),
0,
);
return [...buckets.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),
}));
}
@@ -0,0 +1,36 @@
import type { PrismaClient } from "@planarchy/db";
export interface GetDashboardTopValueResourcesInput {
limit: number;
userRole: string;
}
export async function getDashboardTopValueResources(
db: PrismaClient,
input: GetDashboardTopValueResourcesInput,
) {
const settings = await db.systemSettings.findUnique({
where: { id: "singleton" },
});
const visibleRoles =
(settings?.scoreVisibleRoles as unknown as string[]) ?? ["ADMIN", "MANAGER"];
if (!visibleRoles.includes(input.userRole)) {
return [];
}
return db.resource.findMany({
where: { isActive: true, valueScore: { not: null } },
select: {
id: true,
eid: true,
displayName: true,
chapter: true,
valueScore: true,
lcrCents: true,
},
orderBy: { valueScore: "desc" },
take: input.limit,
});
}
@@ -0,0 +1,23 @@
export {
getDashboardOverview,
} from "./get-overview.js";
export {
getDashboardPeakTimes,
type GetDashboardPeakTimesInput,
} from "./get-peak-times.js";
export {
getDashboardTopValueResources,
type GetDashboardTopValueResourcesInput,
} from "./get-top-value-resources.js";
export {
getDashboardDemand,
type GetDashboardDemandInput,
} from "./get-demand.js";
export {
getDashboardChargeabilityOverview,
type GetDashboardChargeabilityOverviewInput,
} from "./get-chargeability-overview.js";
@@ -0,0 +1,78 @@
import type { PrismaClient } from "@planarchy/db";
import { AllocationStatus } from "@planarchy/shared";
import { buildSplitAllocationReadModel } from "../allocation/build-split-allocation-read-model.js";
export const DASHBOARD_PLANNING_ALLOCATION_INCLUDE = {
project: {
select: {
id: true,
name: true,
shortCode: true,
staffingReqs: true,
},
},
resource: {
select: {
id: true,
displayName: true,
chapter: true,
eid: true,
lcrCents: true,
},
},
} as const;
export const DASHBOARD_PLANNING_DEMAND_INCLUDE = {
project: DASHBOARD_PLANNING_ALLOCATION_INCLUDE.project,
} as const;
export const DASHBOARD_PLANNING_ASSIGNMENT_INCLUDE = {
project: DASHBOARD_PLANNING_ALLOCATION_INCLUDE.project,
resource: DASHBOARD_PLANNING_ALLOCATION_INCLUDE.resource,
} as const;
type DashboardPlanningReadDbClient = Pick<
PrismaClient,
"demandRequirement" | "assignment" | "project"
>;
export interface LoadDashboardPlanningReadModelInput {
startDate: Date;
endDate: Date;
}
export async function loadDashboardPlanningReadModel(
db: DashboardPlanningReadDbClient,
input: LoadDashboardPlanningReadModelInput,
) {
const activeWindowFilter = {
status: { not: AllocationStatus.CANCELLED },
startDate: { lte: input.endDate },
endDate: { gte: input.startDate },
} as const;
const [demandRequirements, assignments, projects] = await Promise.all([
db.demandRequirement.findMany({
where: activeWindowFilter,
include: DASHBOARD_PLANNING_DEMAND_INCLUDE,
}),
db.assignment.findMany({
where: activeWindowFilter,
include: DASHBOARD_PLANNING_ASSIGNMENT_INCLUDE,
}),
db.project.findMany({
where: activeWindowFilter,
select: { id: true, shortCode: true, name: true, staffingReqs: true },
}),
]);
return {
demandRequirements,
assignments,
projects,
readModel: buildSplitAllocationReadModel({
demandRequirements,
assignments,
}),
};
}
@@ -0,0 +1,40 @@
export const MILLISECONDS_PER_DAY = 86_400_000;
export function calculateInclusiveDays(startDate: Date, endDate: Date): number {
return (endDate.getTime() - startDate.getTime()) / MILLISECONDS_PER_DAY + 1;
}
export function calculateAllocationHours(input: {
startDate: Date;
endDate: Date;
hoursPerDay: number;
}): number {
return input.hoursPerDay * calculateInclusiveDays(input.startDate, input.endDate);
}
export function getMonthBucketKey(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
}
export function getWeekBucketKey(date: Date): string {
const weekStart = new Date(date);
const day = weekStart.getDay();
const diff = weekStart.getDate() - day + (day === 0 ? -6 : 1);
weekStart.setDate(diff);
return weekStart.toISOString().slice(0, 10);
}
export function getAverageDailyAvailabilityHours(
availability: Record<string, number | null | undefined> | null | undefined,
): number {
if (!availability) {
return 0;
}
const totalWeeklyHours = Object.values(availability).reduce(
(sum: number, hours) => sum + (hours ?? 0),
0,
);
return totalWeeklyHours / 5;
}
@@ -0,0 +1,335 @@
import { ImportBatchStatus, type Prisma } from "@planarchy/db";
import { parseDispoChargeabilityWorkbook } from "./parse-chargeability-workbook.js";
import { parseDispoPlanningWorkbook } from "./parse-dispo-matrix.js";
import { parseDispoRosterWorkbook } from "./parse-dispo-roster-workbook.js";
import { parseMandatoryDispoReferenceWorkbook } from "./parse-reference-workbook.js";
import { readWorksheetMatrix } from "./read-workbook.js";
import {
DISPO_REFERENCE_SHEET,
type DispoImportDbClient,
isPseudoDemandResourceIdentity,
normalizeText,
toJsonObject,
} from "./shared.js";
export interface AssessDispoImportReadinessInput {
chargeabilityWorkbookPath: string;
costWorkbookPath?: string;
importBatchId?: string;
notes?: string | null;
planningWorkbookPath: string;
referenceWorkbookPath: string;
rosterWorkbookPath?: string;
}
export interface DispoImportReadinessIssue {
code:
| "FALLBACK_EMAIL_REQUIRED"
| "FALLBACK_LCR_REQUIRED"
| "FALLBACK_UCR_REQUIRED"
| "PLANNING_RESOURCE_MISSING_FROM_ROSTER"
| "REFERENCE_RESOURCE_MASTER_MISSING"
| "UNRESOLVED_RECORDS_PRESENT";
count: number;
message: string;
resolution: string;
severity: "blocker" | "warning";
}
export interface DispoImportReadinessReport {
assignmentCount: number;
availabilityRuleCount: number;
canCommitWithFallbacks: boolean;
canCommitWithStrictSourceData: boolean;
fallbackAssumptions: string[];
issues: DispoImportReadinessIssue[];
projectCount: number;
resourceCount: number;
unresolvedCount: number;
vacationCount: number;
}
interface MergedResourceReadinessRecord {
canonicalExternalId: string;
email: string | null;
lcrCents: number | null;
ucrCents: number | null;
}
function filterUnresolvedCount(
unresolved: ReadonlyArray<{ resourceExternalId?: string | null }>,
excludedIds: ReadonlySet<string>,
) {
return unresolved.filter(
(record) => !record.resourceExternalId || !excludedIds.has(record.resourceExternalId),
).length;
}
function buildReadinessIssue(issue: DispoImportReadinessIssue): DispoImportReadinessIssue {
return issue;
}
function derivePlanningResourceIds(input: {
assignments: Awaited<ReturnType<typeof parseDispoPlanningWorkbook>>["assignments"];
availabilityRules: Awaited<ReturnType<typeof parseDispoPlanningWorkbook>>["availabilityRules"];
vacations: Awaited<ReturnType<typeof parseDispoPlanningWorkbook>>["vacations"];
}) {
const resourceIds = new Set<string>();
for (const assignment of input.assignments) {
if (isPseudoDemandResourceIdentity(assignment.resourceExternalId)) {
continue;
}
resourceIds.add(assignment.resourceExternalId);
}
for (const vacation of input.vacations) {
if (isPseudoDemandResourceIdentity(vacation.resourceExternalId)) {
continue;
}
resourceIds.add(vacation.resourceExternalId);
}
for (const rule of input.availabilityRules) {
if (isPseudoDemandResourceIdentity(rule.resourceExternalId)) {
continue;
}
resourceIds.add(rule.resourceExternalId);
}
return resourceIds;
}
async function hasResourceMasterRows(referenceWorkbookPath: string) {
const rows = await readWorksheetMatrix(referenceWorkbookPath, DISPO_REFERENCE_SHEET);
for (let index = 1; index < rows.length; index += 1) {
const firstCell = normalizeText(rows[index]?.[0]);
if (!firstCell) {
continue;
}
if (/^[a-z0-9]+(?:\.[a-z0-9]+)+$/i.test(firstCell)) {
return true;
}
}
return false;
}
export async function assessDispoImportReadiness(
input: AssessDispoImportReadinessInput,
): Promise<DispoImportReadinessReport> {
const [
referenceWorkbook,
chargeabilityWorkbook,
planningWorkbook,
resourceMasterPresent,
rosterWorkbook,
] =
await Promise.all([
parseMandatoryDispoReferenceWorkbook(input.referenceWorkbookPath),
parseDispoChargeabilityWorkbook(input.chargeabilityWorkbookPath),
parseDispoPlanningWorkbook(input.planningWorkbookPath),
hasResourceMasterRows(input.referenceWorkbookPath),
input.rosterWorkbookPath
? parseDispoRosterWorkbook(input.rosterWorkbookPath, {
...(input.costWorkbookPath ? { costWorkbookPath: input.costWorkbookPath } : {}),
})
: null,
]);
const mergedResources = new Map<string, MergedResourceReadinessRecord>();
const excludedIds = new Set(rosterWorkbook?.excludedCanonicalExternalIds ?? []);
for (const resource of chargeabilityWorkbook.resources) {
if (excludedIds.has(resource.canonicalExternalId)) {
continue;
}
mergedResources.set(resource.canonicalExternalId, {
canonicalExternalId: resource.canonicalExternalId,
email: resource.email,
lcrCents: null,
ucrCents: null,
});
}
for (const resource of rosterWorkbook?.resources ?? []) {
if (excludedIds.has(resource.canonicalExternalId)) {
continue;
}
const existing = mergedResources.get(resource.canonicalExternalId);
mergedResources.set(resource.canonicalExternalId, {
canonicalExternalId: resource.canonicalExternalId,
email: resource.email ?? existing?.email ?? null,
lcrCents: resource.lcrCents ?? existing?.lcrCents ?? null,
ucrCents: resource.ucrCents ?? existing?.ucrCents ?? null,
});
}
const rosterIds = new Set(mergedResources.keys());
const planningResourceIds = derivePlanningResourceIds(planningWorkbook);
const planningResourceMissingFromRoster = Array.from(planningResourceIds).filter(
(resourceId) => !excludedIds.has(resourceId) && !rosterIds.has(resourceId),
);
const unresolvedCount =
filterUnresolvedCount(chargeabilityWorkbook.unresolved, excludedIds) +
filterUnresolvedCount(planningWorkbook.unresolved, excludedIds) +
filterUnresolvedCount(rosterWorkbook?.unresolved ?? [], excludedIds);
const missingEmailCount = Array.from(mergedResources.values()).filter(
(resource) => !resource.email,
).length;
const missingLcrCount = Array.from(mergedResources.values()).filter(
(resource) => resource.lcrCents === null,
).length;
const missingUcrCount = Array.from(mergedResources.values()).filter(
(resource) => resource.ucrCents === null,
).length;
const issues: DispoImportReadinessIssue[] = [];
if (!resourceMasterPresent && !rosterWorkbook) {
issues.push(
buildReadinessIssue({
code: "REFERENCE_RESOURCE_MASTER_MISSING",
count: 1,
message:
"MandatoryDispoCategories_V3.xlsx contains reference/glossary sections only; it does not contain row-based resource master records.",
resolution:
"Provide a real resource master source or explicitly approve generated fallback values for missing required Resource fields.",
severity: "blocker",
}),
);
}
if (missingEmailCount > 0) {
issues.push(
buildReadinessIssue({
code: "FALLBACK_EMAIL_REQUIRED",
count: missingEmailCount,
message:
"Some imported resources still do not have real email addresses after merging the Dispo roster and SAP source data.",
resolution:
"Approve generated placeholder emails for the remaining resources or provide a missing roster/SAP source with email addresses.",
severity: "blocker",
}),
);
}
if (missingLcrCount > 0) {
issues.push(
buildReadinessIssue({
code: "FALLBACK_LCR_REQUIRED",
count: missingLcrCount,
message:
"Some staged resources still do not have resolved LCR values after merging roster data with the cost-rate workbook.",
resolution:
"Provide missing per-resource LCR values or approve placeholders only for the remaining unresolved resources.",
severity: "blocker",
}),
);
}
if (missingUcrCount > 0) {
issues.push(
buildReadinessIssue({
code: "FALLBACK_UCR_REQUIRED",
count: missingUcrCount,
message:
"Some staged resources still do not have resolved UCR values after merging roster data with the cost-rate workbook.",
resolution:
"Provide missing per-resource UCR values or approve placeholders only for the remaining unresolved resources.",
severity: "blocker",
}),
);
}
if (planningResourceMissingFromRoster.length > 0) {
issues.push(
buildReadinessIssue({
code: "PLANNING_RESOURCE_MISSING_FROM_ROSTER",
count: planningResourceMissingFromRoster.length,
message:
"Some resource identities appear in planning data but not in the merged roster/resource-master inputs.",
resolution:
"Add the missing resources to the roster or chargeability inputs, or decide how planning-only identities should be imported.",
severity: "blocker",
}),
);
}
if (unresolvedCount > 0) {
issues.push(
buildReadinessIssue({
code: "UNRESOLVED_RECORDS_PRESENT",
count: unresolvedCount,
message:
"The staging inputs contain unresolved rows, primarily [tbd] project references that must stay out of final project commit.",
resolution:
"Review unresolved rows before final commit or keep automatic commit scoped to resolved rows only.",
severity: "warning",
}),
);
}
const strictBlockers = issues.filter((issue) => issue.severity === "blocker");
const fallbackOnlyBlockers = new Set<DispoImportReadinessIssue["code"]>([
"FALLBACK_EMAIL_REQUIRED",
"FALLBACK_LCR_REQUIRED",
"FALLBACK_UCR_REQUIRED",
"REFERENCE_RESOURCE_MASTER_MISSING",
]);
const canCommitWithStrictSourceData = strictBlockers.length === 0;
const canCommitWithFallbacks = strictBlockers.every((issue) =>
fallbackOnlyBlockers.has(issue.code),
);
return {
resourceCount: mergedResources.size,
projectCount: planningWorkbook.assignments.filter(
(assignment) =>
assignment.projectKey !== null && !assignment.isTbd && !assignment.isUnassigned,
).length,
assignmentCount: planningWorkbook.assignments.length,
vacationCount: planningWorkbook.vacations.length,
availabilityRuleCount: planningWorkbook.availabilityRules.length,
unresolvedCount,
canCommitWithStrictSourceData,
canCommitWithFallbacks,
fallbackAssumptions: canCommitWithFallbacks
? [
"Generate fallback email as <enterpriseId>@accenture.com for imported resources that do not have one in the source files.",
"Commit placeholder LCR/UCR values only for resources still unresolved after roster-to-rate matching and level-average fallback.",
"Keep unresolved [tbd] rows staged and exclude them from final project creation.",
]
: [],
issues,
};
}
export async function persistDispoImportReadiness(
db: DispoImportDbClient,
input: AssessDispoImportReadinessInput & { importBatchId: string },
) {
const report = await assessDispoImportReadiness(input);
const batch = await db.importBatch.findUnique({
where: { id: input.importBatchId },
select: { id: true, summary: true },
});
if (!batch) {
throw new Error(`Import batch "${input.importBatchId}" not found`);
}
const nextSummary = {
...toJsonObject(batch.summary),
readiness: report,
};
await db.importBatch.update({
where: { id: batch.id },
data: {
status: ImportBatchStatus.STAGED,
summary: nextSummary as unknown as Prisma.InputJsonValue,
},
});
return report;
}
@@ -0,0 +1,31 @@
export { parseMandatoryDispoReferenceWorkbook } from "./parse-reference-workbook.js";
export {
assessDispoImportReadiness,
persistDispoImportReadiness,
type AssessDispoImportReadinessInput,
type DispoImportReadinessIssue,
type DispoImportReadinessReport,
} from "./assess-import-readiness.js";
export { parseDispoChargeabilityWorkbook } from "./parse-chargeability-workbook.js";
export { parseDispoPlanningWorkbook } from "./parse-dispo-matrix.js";
export { parseResourceRosterMasterWorkbook } from "./parse-resource-roster-master-workbook.js";
export { parseDispoRosterWorkbook } from "./parse-dispo-roster-workbook.js";
export {
stageDispoReferenceData,
type StageDispoReferenceDataResult,
} from "./stage-reference-data.js";
export {
stageDispoChargeabilityResources,
type StageDispoChargeabilityResourcesResult,
} from "./stage-chargeability-resources.js";
export {
stageDispoRosterResources,
type StageDispoRosterResourcesResult,
} from "./stage-dispo-roster-resources.js";
export { stageDispoPlanningData, type StageDispoPlanningResult } from "./stage-dispo-planning.js";
export { stageDispoProjects, type StageDispoProjectsResult } from "./stage-dispo-projects.js";
export {
stageDispoImportBatch,
type StageDispoImportBatchInput,
type StageDispoImportBatchResult,
} from "./stage-dispo-import-batch.js";
@@ -0,0 +1,206 @@
import { DispoStagedRecordType } from "@planarchy/db";
import { DISPO_CHARGEABILITY_SHEET, type ParsedChargeabilityResource, type ParsedChargeabilityWorkbook, type ParsedUnresolvedRecord, buildFallbackAccentureEmail, createAvailabilityFromFte, deriveCountryCodeFromMetroCity, deriveDisplayNameFromEnterpriseId, deriveNormalizedChapter, deriveRoleTokens, ensurePercentageValue, mapChargeabilityResourceType, normalizeNullableWorkbookValue, normalizeText, resolveCanonicalEnterpriseIdentity } from "./shared.js";
import { readWorksheetMatrix } from "./read-workbook.js";
const CHGFC_HEADERS = {
clientUnit: "MV Client Unit",
enterpriseId: "Enterprise ID",
fte: "FTE",
managementLevelGroup: "Management Level Group",
metroCity: "Metro City",
orgUnitLevel6: "Org Unit Level 6",
rawChapter: "MV Org Unit 1 / Chapter",
rawResourceType: "MV Ressource Type",
target: "Target (per Level)",
} as const;
function buildHeaderMap(headerRow: ReadonlyArray<unknown>): Map<string, number> {
const headerMap = new Map<string, number>();
headerRow.forEach((value, index) => {
const normalized = normalizeText(value);
if (normalized) {
headerMap.set(normalized, index);
}
});
return headerMap;
}
function getCellValue(
row: ReadonlyArray<unknown>,
headerMap: Map<string, number>,
headerName: string,
): unknown {
const index = headerMap.get(headerName);
if (index === undefined) {
return null;
}
return row[index] ?? null;
}
function buildResourceSignature(resource: ParsedChargeabilityResource): string {
return JSON.stringify({
chapter: resource.chapter,
chapterCode: resource.chapterCode,
chargeabilityTarget: resource.chargeabilityTarget,
clientUnitName: resource.clientUnitName,
countryCode: resource.countryCode,
fte: resource.fte,
managementLevelGroupName: resource.managementLevelGroupName,
metroCityName: resource.metroCityName,
resourceType: resource.resourceType,
roleTokens: resource.roleTokens,
});
}
export async function parseDispoChargeabilityWorkbook(
workbookPath: string,
): Promise<ParsedChargeabilityWorkbook> {
const rows = await readWorksheetMatrix(workbookPath, DISPO_CHARGEABILITY_SHEET);
const headerMap = buildHeaderMap(rows[0] ?? []);
const warnings: string[] = [];
const unresolved: ParsedUnresolvedRecord[] = [];
const resourceByCanonicalId = new Map<string, ParsedChargeabilityResource>();
for (let rowNumber = 2; rowNumber <= rows.length; rowNumber += 1) {
const row = rows[rowNumber - 1] ?? [];
const enterpriseIdValue = normalizeNullableWorkbookValue(
getCellValue(row, headerMap, CHGFC_HEADERS.enterpriseId),
);
if (!enterpriseIdValue) {
if (row.some((value) => normalizeText(value) !== null)) {
unresolved.push({
sourceRow: rowNumber,
sourceColumn: "A",
recordType: DispoStagedRecordType.RESOURCE,
resourceExternalId: null,
message: "Missing Enterprise ID in ChgFC row",
resolutionHint: "Populate Enterprise ID before staging resource data",
warnings: [],
normalizedData: {},
});
}
continue;
}
const canonicalExternalId = resolveCanonicalEnterpriseIdentity(enterpriseIdValue);
if (!canonicalExternalId) {
unresolved.push({
sourceRow: rowNumber,
sourceColumn: "A",
recordType: DispoStagedRecordType.RESOURCE,
resourceExternalId: enterpriseIdValue,
message: `Unable to normalize Enterprise ID "${enterpriseIdValue}"`,
resolutionHint: "Validate Enterprise ID formatting in ChgFC",
warnings: [],
normalizedData: {
enterpriseId: enterpriseIdValue,
},
});
continue;
}
const managementLevelGroupName = normalizeNullableWorkbookValue(
getCellValue(row, headerMap, CHGFC_HEADERS.managementLevelGroup),
);
const rawTarget = getCellValue(row, headerMap, CHGFC_HEADERS.target);
const fte = typeof getCellValue(row, headerMap, CHGFC_HEADERS.fte) === "number"
? Number(getCellValue(row, headerMap, CHGFC_HEADERS.fte))
: null;
const metroCityName = normalizeNullableWorkbookValue(
getCellValue(row, headerMap, CHGFC_HEADERS.metroCity),
);
const rawResourceType = normalizeNullableWorkbookValue(
getCellValue(row, headerMap, CHGFC_HEADERS.rawResourceType),
);
const levelSixName = normalizeNullableWorkbookValue(
getCellValue(row, headerMap, CHGFC_HEADERS.orgUnitLevel6),
);
const rawChapter = normalizeNullableWorkbookValue(
getCellValue(row, headerMap, CHGFC_HEADERS.rawChapter),
);
const clientUnitName = normalizeNullableWorkbookValue(
getCellValue(row, headerMap, CHGFC_HEADERS.clientUnit),
);
const roleTokens = deriveRoleTokens(levelSixName, rawChapter);
const normalizedChapter = deriveNormalizedChapter(rawChapter, roleTokens);
const resourceTypeResult = mapChargeabilityResourceType(rawResourceType);
const recordWarnings = resourceTypeResult.warning ? [resourceTypeResult.warning] : [];
const chargeabilityTarget =
typeof rawTarget === "number" ? ensurePercentageValue(rawTarget) : null;
const resource: ParsedChargeabilityResource = {
sourceRow: rowNumber,
canonicalExternalId,
enterpriseId: canonicalExternalId,
eid: canonicalExternalId,
displayName: deriveDisplayNameFromEnterpriseId(canonicalExternalId),
email: buildFallbackAccentureEmail(canonicalExternalId),
chapter: normalizedChapter.chapter,
chapterCode: normalizedChapter.chapterCode,
managementLevelGroupName,
managementLevelName: null,
countryCode: deriveCountryCodeFromMetroCity(metroCityName),
metroCityName,
clientUnitName,
rawResourceType,
resourceType: resourceTypeResult.resourceType,
chargeabilityTarget,
fte,
availability: createAvailabilityFromFte(fte),
roleTokens,
warnings: recordWarnings,
};
const existing = resourceByCanonicalId.get(canonicalExternalId);
if (!existing) {
resourceByCanonicalId.set(canonicalExternalId, resource);
continue;
}
const existingSignature = buildResourceSignature(existing);
const nextSignature = buildResourceSignature(resource);
if (existingSignature === nextSignature) {
existing.warnings.push(`Duplicate ChgFC row ${rowNumber} ignored for ${canonicalExternalId}`);
continue;
}
existing.warnings.push(`Conflicting duplicate ChgFC row ${rowNumber} found for ${canonicalExternalId}`);
unresolved.push({
sourceRow: rowNumber,
sourceColumn: "A",
recordType: DispoStagedRecordType.RESOURCE,
resourceExternalId: canonicalExternalId,
message: `Conflicting resource roster rows found for ${canonicalExternalId}`,
resolutionHint: "Resolve the differing ChgFC roster values before commit",
warnings: [...recordWarnings],
normalizedData: {
existing: {
sourceRow: existing.sourceRow,
chapter: existing.chapter,
clientUnitName: existing.clientUnitName,
fte: existing.fte,
metroCityName: existing.metroCityName,
},
conflicting: {
sourceRow: resource.sourceRow,
chapter: resource.chapter,
clientUnitName: resource.clientUnitName,
fte: resource.fte,
metroCityName: resource.metroCityName,
},
},
});
}
return {
resources: Array.from(resourceByCanonicalId.values()),
unresolved,
warnings,
};
}
@@ -0,0 +1,582 @@
import { DispoStagedRecordType } from "@planarchy/db";
import {
VacationType,
normalizeCanonicalResourceIdentity,
normalizeDispoRoleToken,
normalizeDispoUtilizationToken,
} from "@planarchy/shared";
import { readWorksheetMatrix, toColumnLetter, type WorksheetCellValue } from "./read-workbook.js";
import {
DISPO_PLANNING_SHEET,
type ParsedPlanningAssignment,
type ParsedPlanningAvailabilityRule,
type ParsedPlanningVacation,
type ParsedPlanningWorkbook,
type ParsedUnresolvedRecord,
deriveRoleTokens,
normalizeNullableWorkbookValue,
normalizeText,
} from "./shared.js";
const DISPO_HEADER_ROW = 5;
const DISPO_DATE_ROW = 2;
const DISPO_SLOT_ROW = 3;
const DISPO_DATA_START_ROW = 6;
const DISPO_EID_COLUMN = 3;
const DISPO_CHAPTER_COLUMN = 4;
const DISPO_TYPE_OF_WORK_COLUMN = 5;
const DISPO_UNIT_SPECIFIC_FIELD_COLUMN = 7;
const DISPO_PLANNING_START_COLUMN = 11;
const SLOT_HOURS = 4;
const WEEKDAY_LABELS = new Set(["MO", "DI", "MI", "DO", "FR", "SA", "SO"]);
const BERLIN_DATE_FORMATTER = new Intl.DateTimeFormat("en-CA", {
day: "2-digit",
month: "2-digit",
timeZone: "Europe/Berlin",
year: "numeric",
});
interface PlanningColumn {
assignmentDate: Date;
columnLetter: string;
columnNumber: number;
halfDayPart: "AFTERNOON" | "MORNING" | null;
slotLabel: string;
weekdayLabel: string | null;
}
interface PlanningRowMetadata {
chapter: string | null;
eid: string;
typeOfWork: string | null;
unitSpecificField: string | null;
}
interface AssignmentAccumulator {
assignmentDate: Date;
chapterToken: string | null;
firstColumnNumber: number;
hoursPerDay: number;
isInternal: boolean;
isTbd: boolean;
isUnassigned: boolean;
projectKey: string | null;
rawToken: string;
resourceExternalId: string;
roleName: string | null;
roleToken: string | null;
slotCount: number;
sourceRow: number;
utilizationCategoryCode: string | null;
warnings: Set<string>;
winProbability: number | null;
}
interface VacationAccumulator {
endDate: Date;
firstColumnNumber: number;
halfDayParts: Set<string>;
holidayName: string | null;
isPublicHoliday: boolean;
note: string | null;
rawToken: string;
resourceExternalId: string;
sourceRow: number;
startDate: Date;
vacationType: VacationType;
warnings: Set<string>;
}
interface AvailabilityAccumulator {
availableHours: number | null;
effectiveEndDate: Date;
effectiveStartDate: Date;
firstColumnNumber: number;
isResolved: boolean;
percentage: number | null;
rawToken: string;
resourceExternalId: string;
ruleType: string;
sourceRow: number;
warnings: Set<string>;
}
interface ParsedAssignmentToken {
chapterToken: string | null;
isInternal: boolean;
isTbd: boolean;
isUnassigned: boolean;
projectKey: string | null;
roleName: string | null;
roleToken: string | null;
utilizationCategoryCode: string | null;
winProbability: number | null;
}
function isWeekdayLabel(value: string | null): boolean {
return value !== null && WEEKDAY_LABELS.has(value.toUpperCase());
}
function toDateOnlyInBerlin(value: WorksheetCellValue): Date | null {
if (!(value instanceof Date)) {
return null;
}
const parts = BERLIN_DATE_FORMATTER.formatToParts(value);
const year = Number(parts.find((part) => part.type === "year")?.value);
const month = Number(parts.find((part) => part.type === "month")?.value);
const day = Number(parts.find((part) => part.type === "day")?.value);
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
return null;
}
return new Date(Date.UTC(year, month - 1, day));
}
function getDateKey(value: Date): string {
return value.toISOString().slice(0, 10);
}
function getSlotHalfDayPart(slotLabel: string | null): "AFTERNOON" | "MORNING" | null {
const normalized = normalizeText(slotLabel)?.toLowerCase() ?? null;
if (!normalized) {
return null;
}
if (normalized.includes("9.-13")) {
return "MORNING";
}
if (normalized.includes("14.-18")) {
return "AFTERNOON";
}
return null;
}
function buildPlanningColumns(rows: ReadonlyArray<ReadonlyArray<WorksheetCellValue>>) {
const columns: PlanningColumn[] = [];
const headerWidth = Math.max(rows[DISPO_DATE_ROW - 1]?.length ?? 0, rows[DISPO_SLOT_ROW - 1]?.length ?? 0);
for (let columnNumber = DISPO_PLANNING_START_COLUMN; columnNumber <= headerWidth; columnNumber += 1) {
const slotLabel = normalizeNullableWorkbookValue(rows[DISPO_SLOT_ROW - 1]?.[columnNumber - 1]);
if (!slotLabel) {
continue;
}
const currentHeaderValue = rows[DISPO_DATE_ROW - 1]?.[columnNumber - 1] ?? null;
const previousHeaderLabel = normalizeNullableWorkbookValue(rows[DISPO_DATE_ROW - 1]?.[columnNumber - 2]);
const currentHeaderLabel = normalizeNullableWorkbookValue(currentHeaderValue);
const nextHeaderValue = rows[DISPO_DATE_ROW - 1]?.[columnNumber] ?? null;
const assignmentDate =
toDateOnlyInBerlin(currentHeaderValue) ??
toDateOnlyInBerlin(nextHeaderValue);
if (!assignmentDate) {
continue;
}
const weekdayLabel = isWeekdayLabel(currentHeaderLabel)
? currentHeaderLabel
: isWeekdayLabel(previousHeaderLabel)
? previousHeaderLabel
: null;
columns.push({
assignmentDate,
columnLetter: toColumnLetter(columnNumber),
columnNumber,
halfDayPart: getSlotHalfDayPart(slotLabel),
slotLabel,
weekdayLabel,
});
}
return columns;
}
function normalizePlanningToken(token: string): string {
return token
.trim()
.replace(/\s+/g, " ")
.replace(/\s+(?:HB|SB)_?\s*$/i, "")
.trim();
}
function extractBracketTokens(token: string): string[] {
return Array.from(token.matchAll(/\[([^\]]+)\]/g), (match) => match[1]?.trim() ?? "").filter(Boolean);
}
function extractUtilizationToken(token: string): { utilizationToken: string | null; winProbability: number | null } {
const matches = Array.from(token.matchAll(/\{([A-Z]+)(\d{0,3})\}/gi));
const lastMatch = matches.at(-1);
if (!lastMatch) {
return {
utilizationToken: null,
winProbability: null,
};
}
const utilizationToken = lastMatch[1]?.toUpperCase() ?? null;
const winProbability = lastMatch[2] ? Number(lastMatch[2]) : null;
return {
utilizationToken,
winProbability: Number.isFinite(winProbability) ? winProbability : null,
};
}
function extractRoleToken(token: string, metadata: PlanningRowMetadata): string | null {
const explicitRoleToken = token.match(/^(2D|3D|PM|AD)\b/i)?.[1]?.toUpperCase() ?? null;
if (explicitRoleToken) {
return explicitRoleToken;
}
return deriveRoleTokens(metadata.chapter, metadata.typeOfWork, metadata.unitSpecificField)[0] ?? null;
}
function extractProjectKey(token: string): string | null {
const bracketTokens = extractBracketTokens(token).filter((entry) => !entry.startsWith("_"));
const lastToken = bracketTokens.at(-1) ?? null;
return lastToken && lastToken.toLowerCase() !== "tbd" ? lastToken : null;
}
function extractLabel(token: string): string | null {
const stripped = token
.replace(/^(2D|3D|PM|AD)\s+/i, "")
.replace(/\[[^\]]+\]/g, " ")
.replace(/\{[^}]+\}/g, " ")
.replace(/\s+(?:HB|SB)_?\s*$/i, " ")
.replace(/\s+/g, " ")
.trim();
return stripped.length > 0 ? stripped : null;
}
function parsePercentage(value: string): number | null {
const percentageMatch = value.match(/(\d+(?:[.,]\d+)?)\s*%/);
if (percentageMatch) {
const normalized = Number(percentageMatch[1]?.replace(",", "."));
return Number.isFinite(normalized) ? normalized : null;
}
const fteMatch = value.match(/FTE:\s*(\d+(?:[.,]\d+)?)/i);
if (fteMatch) {
const normalized = Number(fteMatch[1]?.replace(",", "."));
return Number.isFinite(normalized) ? Math.round(normalized * 10000) / 100 : null;
}
return null;
}
function buildAssignmentAccumulator(
column: PlanningColumn,
metadata: PlanningRowMetadata,
rawToken: string,
): AssignmentAccumulator | null {
const roleToken = extractRoleToken(rawToken, metadata);
const roleName = normalizeDispoRoleToken(roleToken);
const { utilizationToken, winProbability } = extractUtilizationToken(rawToken);
const utilizationCategoryCode = normalizeDispoUtilizationToken(utilizationToken);
const projectKey = extractProjectKey(rawToken);
const isTbd = /\[tbd\]/i.test(rawToken);
const isUnassigned = utilizationToken === "UN";
const isInternal = ["MD", "MO", "PD"].includes(utilizationToken ?? "");
if (isUnassigned) {
return null;
}
return {
assignmentDate: column.assignmentDate,
chapterToken: roleToken,
firstColumnNumber: column.columnNumber,
hoursPerDay: SLOT_HOURS,
isInternal,
isTbd,
isUnassigned: false,
projectKey,
rawToken,
resourceExternalId: metadata.eid,
roleName,
roleToken,
slotCount: 1,
sourceRow: 0,
utilizationCategoryCode,
warnings: new Set<string>(),
winProbability,
};
}
function buildVacationAccumulator(
column: PlanningColumn,
metadata: PlanningRowMetadata,
rawToken: string,
vacationType: VacationType,
input: { holidayName?: string | null; isPublicHoliday: boolean; note?: string | null },
): VacationAccumulator {
const halfDayParts = new Set<string>();
if (column.halfDayPart) {
halfDayParts.add(column.halfDayPart);
}
return {
endDate: column.assignmentDate,
firstColumnNumber: column.columnNumber,
halfDayParts,
holidayName: input.holidayName ?? null,
isPublicHoliday: input.isPublicHoliday,
note: input.note ?? null,
rawToken,
resourceExternalId: metadata.eid,
sourceRow: 0,
startDate: column.assignmentDate,
vacationType,
warnings: new Set<string>(),
};
}
function buildAvailabilityAccumulator(
column: PlanningColumn,
metadata: PlanningRowMetadata,
rawToken: string,
): AvailabilityAccumulator {
const percentage = parsePercentage(rawToken);
const availableHours = percentage !== null
? Math.round((percentage / 100) * 8 * 100) / 100
: 8 - SLOT_HOURS;
return {
availableHours,
effectiveEndDate: column.assignmentDate,
effectiveStartDate: column.assignmentDate,
firstColumnNumber: column.columnNumber,
isResolved: false,
percentage,
rawToken,
resourceExternalId: metadata.eid,
ruleType: "PART_TIME",
sourceRow: 0,
warnings: new Set<string>(),
};
}
export async function parseDispoPlanningWorkbook(
workbookPath: string,
): Promise<ParsedPlanningWorkbook> {
const rows = await readWorksheetMatrix(workbookPath, DISPO_PLANNING_SHEET);
const planningColumns = buildPlanningColumns(rows);
const assignments = new Map<string, AssignmentAccumulator>();
const vacations = new Map<string, VacationAccumulator>();
const availabilityRules = new Map<string, AvailabilityAccumulator>();
const unresolved: ParsedUnresolvedRecord[] = [];
const warnings: string[] = [];
for (let rowNumber = DISPO_DATA_START_ROW; rowNumber <= rows.length; rowNumber += 1) {
const row = rows[rowNumber - 1] ?? [];
const eid = normalizeNullableWorkbookValue(row[DISPO_EID_COLUMN - 1]);
if (!eid) {
continue;
}
const metadata: PlanningRowMetadata = {
chapter: normalizeNullableWorkbookValue(row[DISPO_CHAPTER_COLUMN - 1]),
eid: normalizeCanonicalResourceIdentity(eid),
typeOfWork: normalizeNullableWorkbookValue(row[DISPO_TYPE_OF_WORK_COLUMN - 1]),
unitSpecificField: normalizeNullableWorkbookValue(row[DISPO_UNIT_SPECIFIC_FIELD_COLUMN - 1]),
};
for (const column of planningColumns) {
const rawCellValue = normalizeNullableWorkbookValue(row[column.columnNumber - 1]);
if (!rawCellValue) {
continue;
}
const rawToken = normalizePlanningToken(rawCellValue);
const normalizedToken = rawToken.toUpperCase();
if (normalizedToken === "[_NA] WEEKEND {NA}") {
continue;
}
if (normalizedToken.startsWith("[_AB]")) {
const note = extractLabel(rawToken);
const vacationType = note?.toLowerCase().includes("sick") ? VacationType.SICK : VacationType.ANNUAL;
const key = `${metadata.eid}|${getDateKey(column.assignmentDate)}|VAC|${rawToken}`;
const existing = vacations.get(key);
if (existing) {
existing.endDate = column.assignmentDate;
if (column.halfDayPart) {
existing.halfDayParts.add(column.halfDayPart);
}
} else {
const vacation = buildVacationAccumulator(column, metadata, rawToken, vacationType, {
isPublicHoliday: false,
note,
});
vacation.sourceRow = rowNumber;
vacations.set(key, vacation);
}
continue;
}
if (normalizedToken.startsWith("[_NA]") && normalizedToken.includes("PUBLIC HOLIDAY")) {
const holidayName = extractLabel(rawToken);
const key = `${metadata.eid}|${getDateKey(column.assignmentDate)}|PH|${rawToken}`;
const existing = vacations.get(key);
if (existing) {
existing.endDate = column.assignmentDate;
if (column.halfDayPart) {
existing.halfDayParts.add(column.halfDayPart);
}
} else {
const vacation = buildVacationAccumulator(column, metadata, rawToken, VacationType.PUBLIC_HOLIDAY, {
holidayName,
isPublicHoliday: true,
note: holidayName,
});
vacation.sourceRow = rowNumber;
vacations.set(key, vacation);
}
continue;
}
if (normalizedToken.startsWith("[_NA]") && normalizedToken.includes("PART-TIME")) {
const key = `${metadata.eid}|${getDateKey(column.assignmentDate)}|PT|${rawToken}`;
const existing = availabilityRules.get(key);
if (existing) {
existing.availableHours = buildAvailabilityAccumulator(column, metadata, rawToken).availableHours;
existing.percentage = buildAvailabilityAccumulator(column, metadata, rawToken).percentage;
} else {
const availabilityRule = buildAvailabilityAccumulator(column, metadata, rawToken);
availabilityRule.sourceRow = rowNumber;
availabilityRules.set(key, availabilityRule);
}
continue;
}
if (normalizedToken.startsWith("[_UN]")) {
continue;
}
const assignment = buildAssignmentAccumulator(column, metadata, rawToken);
if (!assignment) {
continue;
}
assignment.sourceRow = rowNumber;
if (!assignment.utilizationCategoryCode) {
assignment.warnings.add(`Unable to resolve utilization category from token "${rawToken}"`);
}
if (!assignment.projectKey && !assignment.isInternal && !assignment.isTbd) {
unresolved.push({
sourceRow: rowNumber,
sourceColumn: column.columnLetter,
recordType: DispoStagedRecordType.ASSIGNMENT,
resourceExternalId: metadata.eid,
projectKey: null,
message: `Unable to resolve project key from planning token "${rawToken}"`,
resolutionHint: "Add a WBS token or classify this cell as an internal bucket before commit",
warnings: Array.from(assignment.warnings),
normalizedData: {
assignmentDate: getDateKey(column.assignmentDate),
rawToken,
roleToken: assignment.roleToken,
utilizationCategoryCode: assignment.utilizationCategoryCode,
},
});
continue;
}
if (assignment.isTbd) {
unresolved.push({
sourceRow: rowNumber,
sourceColumn: column.columnLetter,
recordType: DispoStagedRecordType.PROJECT,
resourceExternalId: metadata.eid,
projectKey: null,
message: `Planning token "${rawToken}" references [tbd] and requires project resolution`,
resolutionHint: "Resolve [tbd] rows to a real WBS/project before commit",
warnings: Array.from(assignment.warnings),
normalizedData: {
assignmentDate: getDateKey(column.assignmentDate),
rawToken,
roleToken: assignment.roleToken,
utilizationCategoryCode: assignment.utilizationCategoryCode,
winProbability: assignment.winProbability,
},
});
}
const key = `${metadata.eid}|${getDateKey(column.assignmentDate)}|ASN|${rawToken}`;
const existing = assignments.get(key);
if (existing) {
existing.hoursPerDay += SLOT_HOURS;
existing.slotCount += 1;
} else {
assignments.set(key, assignment);
}
}
}
const parsedAssignments: ParsedPlanningAssignment[] = Array.from(assignments.values()).map((entry) => ({
assignmentDate: entry.assignmentDate,
chapterToken: entry.chapterToken,
hoursPerDay: entry.hoursPerDay,
isInternal: entry.isInternal,
isTbd: entry.isTbd,
isUnassigned: entry.isUnassigned,
percentage: entry.slotCount * 50,
projectKey: entry.projectKey,
rawToken: entry.rawToken,
resourceExternalId: entry.resourceExternalId,
roleName: entry.roleName,
roleToken: entry.roleToken,
slotFraction: entry.slotCount / 2,
sourceColumn: toColumnLetter(entry.firstColumnNumber),
sourceRow: entry.sourceRow,
utilizationCategoryCode: entry.utilizationCategoryCode,
warnings: Array.from(entry.warnings),
winProbability: entry.winProbability,
}));
const parsedVacations: ParsedPlanningVacation[] = Array.from(vacations.values()).map((entry) => ({
endDate: entry.endDate,
halfDayPart: entry.halfDayParts.size === 1 ? Array.from(entry.halfDayParts)[0] ?? null : null,
holidayName: entry.holidayName,
isHalfDay: entry.halfDayParts.size === 1,
isPublicHoliday: entry.isPublicHoliday,
note: entry.note,
rawToken: entry.rawToken,
resourceExternalId: entry.resourceExternalId,
sourceColumn: toColumnLetter(entry.firstColumnNumber),
sourceRow: entry.sourceRow,
startDate: entry.startDate,
vacationType: entry.vacationType,
warnings: Array.from(entry.warnings),
}));
const parsedAvailabilityRules: ParsedPlanningAvailabilityRule[] = Array.from(availabilityRules.values()).map((entry) => ({
availableHours: entry.availableHours,
effectiveEndDate: entry.effectiveEndDate,
effectiveStartDate: entry.effectiveStartDate,
isResolved: entry.isResolved,
percentage: entry.percentage,
rawToken: entry.rawToken,
resourceExternalId: entry.resourceExternalId,
ruleType: entry.ruleType,
sourceColumn: toColumnLetter(entry.firstColumnNumber),
sourceRow: entry.sourceRow,
warnings: Array.from(entry.warnings),
}));
return {
assignments: parsedAssignments,
availabilityRules: parsedAvailabilityRules,
unresolved,
vacations: parsedVacations,
warnings,
};
}
@@ -0,0 +1,570 @@
import { DispoStagedRecordType, ResourceType } from "@planarchy/db";
import { createWeekdayAvailabilityFromFte } from "@planarchy/shared";
import {
parseResourceRosterMasterWorkbook,
type ParsedResourceRosterLevelAverage,
type ParsedResourceRosterMasterWorkbook,
type ParsedResourceRosterRate,
} from "./parse-resource-roster-master-workbook.js";
import {
DISPO_ROSTER_SAP_SHEET,
DISPO_ROSTER_SHEET,
type ParsedRosterResource,
type ParsedRosterWorkbook,
type ParsedUnresolvedRecord,
buildFallbackAccentureEmail,
deriveCountryCodeFromMetroCity,
deriveDisplayNameFromEnterpriseId,
deriveNormalizedChapter,
deriveRoleTokens,
isPseudoDemandResourceIdentity,
mapChargeabilityResourceType,
normalizeNullableWorkbookValue,
normalizeText,
resolveCanonicalEnterpriseIdentity,
} from "./shared.js";
import { readWorksheetMatrix } from "./read-workbook.js";
const ROSTER_HEADERS = {
clientUnit: "MV Client Unit",
dailyWorkingHoursPerFte: "Daily Working Hours/FTE",
department: "MV Org Unit 2 / Department",
eid: "EID",
firstDayInDispo: "First day in dispo",
fte: "FTE",
lastDayInDispo: "Last day in dispo",
mainSkillset: "MV Main Skillset",
managementLevel: "Management Level",
managementLevelGroup: "Management Level Group",
metroCity: "Metro City",
rawChapter: "MV Org Unit 1 / Chapter",
rawResourceType: "MV Ressource Type",
resourceHoursPerWeek: "Resource Hours/Week",
vacationDaysPerYear: "Vacation days / year",
} as const;
const SAP_HEADERS = {
employeeEmail: "Employee Email",
employeeName: "Employee Name",
enterpriseId: "Enterprise ID",
fte: "FTE",
managementLevel: "Management Level",
managementLevelGroup: "Management Level Group",
metroCity: "Metro City",
orgUnitLevel5: "Org Unit Level 5",
orgUnitLevel6: "Org Unit Level 6",
orgUnitLevel7: "Org Unit Level 7",
} as const;
interface RosterSourceRow {
canonicalExternalId: string;
clientUnitName: string | null;
dailyWorkingHoursPerFte: number | null;
department: string | null;
fte: number | null;
mainSkillset: string | null;
managementLevelGroupName: string | null;
managementLevelName: string | null;
metroCityName: string | null;
rawChapter: string | null;
rawResourceType: string | null;
resourceHoursPerWeek: number | null;
rowNumber: number;
vacationDaysPerYear: number | null;
firstDayInDispo: Date | null;
lastDayInDispo: Date | null;
}
interface SapSourceRow {
canonicalExternalId: string;
employeeEmail: string | null;
employeeName: string | null;
fte: number | null;
managementLevelGroupName: string | null;
managementLevelName: string | null;
metroCityName: string | null;
orgUnitLevelFive: string | null;
orgUnitLevelSix: string | null;
orgUnitLevelSeven: string | null;
rowNumber: number;
}
interface ParseDispoRosterWorkbookOptions {
costWorkbookPath?: string;
}
function buildHeaderMap(headerRow: ReadonlyArray<unknown>): Map<string, number> {
const headerMap = new Map<string, number>();
headerRow.forEach((value, index) => {
const normalized = normalizeText(value);
if (normalized) {
headerMap.set(normalized, index);
}
});
return headerMap;
}
function getCellValue(
row: ReadonlyArray<unknown>,
headerMap: Map<string, number>,
headerName: string,
): unknown {
const index = headerMap.get(headerName);
if (index === undefined) {
return null;
}
return row[index] ?? null;
}
function parseOptionalNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
const normalized = normalizeNullableWorkbookValue(value);
if (!normalized) {
return null;
}
const parsed = Number(normalized.replace(",", "."));
return Number.isFinite(parsed) ? parsed : null;
}
function parseOptionalDate(value: unknown): Date | null {
if (value instanceof Date && !Number.isNaN(value.valueOf())) {
return value;
}
const normalized = normalizeNullableWorkbookValue(value);
if (!normalized) {
return null;
}
const parsed = new Date(normalized);
return Number.isNaN(parsed.valueOf()) ? null : parsed;
}
function normalizeSapDisplayName(value: string | null): string | null {
if (!value) {
return null;
}
const normalized = value
.split(",")
.map((part) => normalizeText(part))
.filter((part): part is string => Boolean(part));
if (normalized.length === 2) {
return `${normalized[1]} ${normalized[0]}`;
}
return normalizeText(value);
}
function buildResourceWarnings(
resourceType: ResourceType,
resourceTypeWarning: string | null,
roster: RosterSourceRow | null,
sap: SapSourceRow | null,
): string[] {
const warnings: string[] = [];
if (resourceTypeWarning) {
warnings.push(resourceTypeWarning);
}
if (!roster) {
warnings.push("Missing DispoRoster row; resource imported from SAP_data only");
}
if (!sap) {
warnings.push("Missing SAP_data row; email and display name fall back to derived values");
}
if (resourceType === ResourceType.FREELANCER && !roster?.dailyWorkingHoursPerFte) {
warnings.push("Freelancer row has no daily working hours value; defaulting to 8h/day");
}
return warnings;
}
function shouldExcludeImportedResource(resource: {
canonicalExternalId: string;
sourceEmail: string | null;
managementLevelName: string | null;
}) {
return !resource.sourceEmail && !resource.managementLevelName;
}
function applyRateResolution(input: {
canonicalExternalId: string;
level: string | null;
rateRecord: ParsedResourceRosterRate | null;
levelAverage: ParsedResourceRosterLevelAverage | null;
warnings: string[];
}) {
const { canonicalExternalId, level, rateRecord, levelAverage, warnings } = input;
const exactLcr = rateRecord?.lcrCents ?? null;
const exactUcr = rateRecord?.ucrCents ?? null;
const fallbackLcr = levelAverage?.lcrCents ?? null;
const fallbackUcr = levelAverage?.ucrCents ?? null;
const lcrCents = exactLcr ?? fallbackLcr;
const ucrCents = exactUcr ?? fallbackUcr;
if (!rateRecord) {
if (levelAverage && lcrCents !== null && ucrCents !== null) {
warnings.push(
`Applied level-average rates for ${canonicalExternalId} using management level ${levelAverage.level}`,
);
return {
lcrCents,
ucrCents,
rateResolution: "LEVEL_AVERAGE" as const,
rateResolutionLevel: levelAverage.level,
};
}
warnings.push(
level
? `Missing rate row for ${canonicalExternalId}; no usable level-average rate found for ${level}`
: `Missing rate row for ${canonicalExternalId}; management level unavailable for fallback`,
);
return {
lcrCents,
ucrCents,
rateResolution: "MISSING" as const,
rateResolutionLevel: levelAverage?.level ?? level ?? null,
};
}
if (exactLcr !== null && exactUcr !== null) {
return {
lcrCents,
ucrCents,
rateResolution: "EXACT" as const,
rateResolutionLevel: rateRecord.level ?? null,
};
}
if (levelAverage && lcrCents !== null && ucrCents !== null) {
warnings.push(
`Completed incomplete rate row for ${canonicalExternalId} with level-average rates from ${levelAverage.level}`,
);
return {
lcrCents,
ucrCents,
rateResolution: "LEVEL_AVERAGE" as const,
rateResolutionLevel: levelAverage.level,
};
}
warnings.push(`Incomplete rate row for ${canonicalExternalId} could not be fully resolved`);
return {
lcrCents,
ucrCents,
rateResolution: "MISSING" as const,
rateResolutionLevel: rateRecord.level ?? level ?? null,
};
}
export async function parseDispoRosterWorkbook(
workbookPath: string,
options: ParseDispoRosterWorkbookOptions = {},
): Promise<ParsedRosterWorkbook> {
const [rosterRows, sapRows] = await Promise.all([
readWorksheetMatrix(workbookPath, DISPO_ROSTER_SHEET),
readWorksheetMatrix(workbookPath, DISPO_ROSTER_SAP_SHEET),
]);
const rateWorkbook: ParsedResourceRosterMasterWorkbook | null = options.costWorkbookPath
? await parseResourceRosterMasterWorkbook(options.costWorkbookPath)
: null;
const rosterHeaderMap = buildHeaderMap(rosterRows[0] ?? []);
const sapHeaderMap = buildHeaderMap(sapRows[1] ?? []);
const warnings: string[] = [...(rateWorkbook?.warnings ?? [])];
const unresolved: ParsedUnresolvedRecord[] = [];
const rosterById = new Map<string, RosterSourceRow>();
const sapById = new Map<string, SapSourceRow>();
let ignoredPseudoDemandRows = 0;
for (let rowNumber = 2; rowNumber <= rosterRows.length; rowNumber += 1) {
const row = rosterRows[rowNumber - 1] ?? [];
const eidValue = normalizeNullableWorkbookValue(getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.eid));
if (!eidValue) {
if (row.some((value) => normalizeText(value) !== null)) {
unresolved.push({
sourceRow: rowNumber,
sourceColumn: "A",
recordType: DispoStagedRecordType.RESOURCE,
resourceExternalId: null,
message: "Missing EID in DispoRoster row",
resolutionHint: "Populate EID before staging roster resource data",
warnings: [],
normalizedData: {},
});
}
continue;
}
if (isPseudoDemandResourceIdentity(eidValue)) {
ignoredPseudoDemandRows += 1;
continue;
}
const canonicalExternalId = resolveCanonicalEnterpriseIdentity(eidValue);
if (!canonicalExternalId) {
unresolved.push({
sourceRow: rowNumber,
sourceColumn: "A",
recordType: DispoStagedRecordType.RESOURCE,
resourceExternalId: eidValue,
message: `Unable to normalize EID "${eidValue}"`,
resolutionHint: "Validate EID formatting in DispoRoster",
warnings: [],
normalizedData: { eid: eidValue },
});
continue;
}
if (rosterById.has(canonicalExternalId)) {
unresolved.push({
sourceRow: rowNumber,
sourceColumn: "A",
recordType: DispoStagedRecordType.RESOURCE,
resourceExternalId: canonicalExternalId,
message: `Duplicate DispoRoster row found for ${canonicalExternalId}`,
resolutionHint: "Keep exactly one operational roster row per EID",
warnings: [],
normalizedData: { eid: canonicalExternalId },
});
continue;
}
rosterById.set(canonicalExternalId, {
canonicalExternalId,
rowNumber,
metroCityName: normalizeNullableWorkbookValue(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.metroCity),
),
managementLevelGroupName: normalizeNullableWorkbookValue(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.managementLevelGroup),
),
managementLevelName: normalizeNullableWorkbookValue(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.managementLevel),
),
fte: parseOptionalNumber(getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.fte)),
dailyWorkingHoursPerFte: parseOptionalNumber(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.dailyWorkingHoursPerFte),
),
resourceHoursPerWeek: parseOptionalNumber(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.resourceHoursPerWeek),
),
rawResourceType: normalizeNullableWorkbookValue(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.rawResourceType),
),
clientUnitName: normalizeNullableWorkbookValue(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.clientUnit),
),
rawChapter: normalizeNullableWorkbookValue(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.rawChapter),
),
department: normalizeNullableWorkbookValue(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.department),
),
mainSkillset: normalizeNullableWorkbookValue(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.mainSkillset),
),
firstDayInDispo: parseOptionalDate(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.firstDayInDispo),
),
lastDayInDispo: parseOptionalDate(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.lastDayInDispo),
),
vacationDaysPerYear: parseOptionalNumber(
getCellValue(row, rosterHeaderMap, ROSTER_HEADERS.vacationDaysPerYear),
),
});
}
for (let rowNumber = 3; rowNumber <= sapRows.length; rowNumber += 1) {
const row = sapRows[rowNumber - 1] ?? [];
const enterpriseIdValue = normalizeNullableWorkbookValue(
getCellValue(row, sapHeaderMap, SAP_HEADERS.enterpriseId),
);
if (!enterpriseIdValue) {
continue;
}
const canonicalExternalId = resolveCanonicalEnterpriseIdentity(enterpriseIdValue);
if (!canonicalExternalId) {
unresolved.push({
sourceRow: rowNumber,
sourceColumn: "C",
recordType: DispoStagedRecordType.RESOURCE,
resourceExternalId: enterpriseIdValue,
message: `Unable to normalize Enterprise ID "${enterpriseIdValue}"`,
resolutionHint: "Validate Enterprise ID formatting in SAP_data",
warnings: [],
normalizedData: { enterpriseId: enterpriseIdValue },
});
continue;
}
if (sapById.has(canonicalExternalId)) {
unresolved.push({
sourceRow: rowNumber,
sourceColumn: "C",
recordType: DispoStagedRecordType.RESOURCE,
resourceExternalId: canonicalExternalId,
message: `Duplicate SAP_data row found for ${canonicalExternalId}`,
resolutionHint: "Keep exactly one SAP_data row per Enterprise ID",
warnings: [],
normalizedData: { enterpriseId: canonicalExternalId },
});
continue;
}
sapById.set(canonicalExternalId, {
canonicalExternalId,
rowNumber,
employeeName: normalizeNullableWorkbookValue(
getCellValue(row, sapHeaderMap, SAP_HEADERS.employeeName),
),
employeeEmail: normalizeNullableWorkbookValue(
getCellValue(row, sapHeaderMap, SAP_HEADERS.employeeEmail),
)?.toLowerCase() ?? null,
metroCityName: normalizeNullableWorkbookValue(
getCellValue(row, sapHeaderMap, SAP_HEADERS.metroCity),
),
managementLevelGroupName: normalizeNullableWorkbookValue(
getCellValue(row, sapHeaderMap, SAP_HEADERS.managementLevelGroup),
),
managementLevelName: normalizeNullableWorkbookValue(
getCellValue(row, sapHeaderMap, SAP_HEADERS.managementLevel),
),
orgUnitLevelFive: normalizeNullableWorkbookValue(
getCellValue(row, sapHeaderMap, SAP_HEADERS.orgUnitLevel5),
),
orgUnitLevelSix: normalizeNullableWorkbookValue(
getCellValue(row, sapHeaderMap, SAP_HEADERS.orgUnitLevel6),
),
orgUnitLevelSeven: normalizeNullableWorkbookValue(
getCellValue(row, sapHeaderMap, SAP_HEADERS.orgUnitLevel7),
),
fte: parseOptionalNumber(getCellValue(row, sapHeaderMap, SAP_HEADERS.fte)),
});
}
const resourceIds = new Set<string>([...rosterById.keys(), ...sapById.keys()]);
const resources: ParsedRosterResource[] = [];
const excludedCanonicalExternalIds = new Set<string>();
for (const canonicalExternalId of resourceIds) {
const roster = rosterById.get(canonicalExternalId) ?? null;
const sap = sapById.get(canonicalExternalId) ?? null;
const roleTokens = deriveRoleTokens(
roster?.department,
roster?.rawChapter,
roster?.mainSkillset,
sap?.orgUnitLevelSix,
sap?.orgUnitLevelSeven,
);
const normalizedChapter = deriveNormalizedChapter(roster?.rawChapter ?? null, roleTokens);
const resourceTypeResult = mapChargeabilityResourceType(roster?.rawResourceType ?? null);
const resourceType =
roster?.rawResourceType || sap ? resourceTypeResult.resourceType : ResourceType.EMPLOYEE;
const fte = sap?.fte ?? roster?.fte ?? null;
const dailyWorkingHoursPerFte = roster?.dailyWorkingHoursPerFte ?? null;
const displayName =
normalizeSapDisplayName(sap?.employeeName ?? null) ??
deriveDisplayNameFromEnterpriseId(canonicalExternalId);
const metroCityName = sap?.metroCityName ?? roster?.metroCityName ?? null;
const managementLevelName =
sap?.managementLevelName ?? roster?.managementLevelName ?? null;
const resourceWarnings = buildResourceWarnings(resourceType, resourceTypeResult.warning, roster, sap);
const rateResolution = applyRateResolution({
canonicalExternalId,
level: managementLevelName,
rateRecord: rateWorkbook?.rates.get(canonicalExternalId) ?? null,
levelAverage: managementLevelName
? rateWorkbook?.levelAverages.get(managementLevelName) ?? null
: null,
warnings: resourceWarnings,
});
const resource: ParsedRosterResource = {
sourceRow: roster?.rowNumber ?? sap?.rowNumber ?? 0,
sourceSheet: roster ? DISPO_ROSTER_SHEET : DISPO_ROSTER_SAP_SHEET,
canonicalExternalId,
enterpriseId: canonicalExternalId,
eid: canonicalExternalId,
displayName,
email: sap?.employeeEmail ?? buildFallbackAccentureEmail(canonicalExternalId),
chapter: normalizedChapter.chapter,
chapterCode: normalizedChapter.chapterCode,
managementLevelGroupName: sap?.managementLevelGroupName ?? roster?.managementLevelGroupName ?? null,
managementLevelName,
countryCode: deriveCountryCodeFromMetroCity(metroCityName),
metroCityName,
clientUnitName: roster?.clientUnitName ?? null,
rawResourceType: roster?.rawResourceType ?? null,
resourceType,
fte,
lcrCents: rateResolution.lcrCents,
ucrCents: rateResolution.ucrCents,
rateResolution: rateResolution.rateResolution,
rateResolutionLevel: rateResolution.rateResolutionLevel,
availability: createWeekdayAvailabilityFromFte(
fte ?? 1,
dailyWorkingHoursPerFte ?? 8,
) as unknown as ParsedRosterResource["availability"],
roleTokens,
dailyWorkingHoursPerFte,
department: roster?.department ?? null,
mainSkillset: roster?.mainSkillset ?? null,
resourceHoursPerWeek: roster?.resourceHoursPerWeek ?? null,
firstDayInDispo: roster?.firstDayInDispo ?? null,
lastDayInDispo: roster?.lastDayInDispo ?? null,
vacationDaysPerYear: roster?.vacationDaysPerYear ?? null,
sapEmployeeName: sap?.employeeName ?? null,
sapOrgUnitLevelFive: sap?.orgUnitLevelFive ?? null,
sapOrgUnitLevelSix: sap?.orgUnitLevelSix ?? null,
sapOrgUnitLevelSeven: sap?.orgUnitLevelSeven ?? null,
warnings: resourceWarnings,
};
if (
shouldExcludeImportedResource({
canonicalExternalId,
sourceEmail: sap?.employeeEmail ?? null,
managementLevelName,
})
) {
excludedCanonicalExternalIds.add(canonicalExternalId);
warnings.push(
`Excluded ${canonicalExternalId} from import because neither email nor management level is present in the supplied sources`,
);
continue;
}
resources.push(resource);
}
if (ignoredPseudoDemandRows > 0) {
warnings.push(`Ignored ${ignoredPseudoDemandRows} pseudo-demand rows from DispoRoster`);
}
resources.sort((left, right) => left.canonicalExternalId.localeCompare(right.canonicalExternalId));
return {
excludedCanonicalExternalIds: Array.from(excludedCanonicalExternalIds).sort((left, right) =>
left.localeCompare(right),
),
resources,
unresolved,
warnings,
ignoredPseudoDemandRows,
};
}
@@ -0,0 +1,251 @@
import {
DISPO_PROJECT_REFERENCE_SHEET,
DISPO_REFERENCE_SHEET,
type ParsedClientReference,
type ParsedCountryReference,
type ParsedManagementLevelGroupReference,
type ParsedOrgUnitReference,
type ParsedReferenceWorkbook,
findSectionRow,
getCountryReferenceConfig,
normalizeClientCode,
normalizeNullableWorkbookValue,
normalizeText,
sanitizeClientName,
} from "./shared.js";
import { readWorksheetMatrix, toColumnLetter } from "./read-workbook.js";
function isTerminalSectionName(value: string | null, names: readonly string[]): boolean {
if (!value) {
return false;
}
const normalizedValue = value.toLowerCase();
return names.some((name) => name.toLowerCase() === normalizedValue);
}
function parseCountryReferences(
rows: Awaited<ReturnType<typeof readWorksheetMatrix>>,
): { countries: ParsedCountryReference[]; warnings: string[] } {
const warnings: string[] = [];
const countries: ParsedCountryReference[] = [];
const startRow = findSectionRow(rows, "Country/Territory") + 1;
for (let rowNumber = startRow; rowNumber <= rows.length; rowNumber += 1) {
const row = rows[rowNumber - 1] ?? [];
const firstCell = normalizeText(row[0]);
if (!firstCell) {
continue;
}
if (isTerminalSectionName(firstCell, ["Org Unit Level 5"])) {
break;
}
const config = getCountryReferenceConfig(firstCell);
if (!config) {
warnings.push(`Unsupported country reference "${firstCell}" in EID-Attr row ${rowNumber}`);
continue;
}
const metroCities = row
.slice(1)
.map((value) => normalizeNullableWorkbookValue(value))
.filter((value): value is string => Boolean(value));
countries.push({
sourceRow: rowNumber,
countryCode: config.code,
name: firstCell,
dailyWorkingHours: config.dailyWorkingHours,
metroCities,
...("scheduleRules" in config ? { scheduleRules: config.scheduleRules } : {}),
});
}
return { countries, warnings };
}
function parseOrgUnitReferences(
rows: Awaited<ReturnType<typeof readWorksheetMatrix>>,
): { orgUnits: ParsedOrgUnitReference[]; warnings: string[] } {
const warnings: string[] = [];
const orgUnits: ParsedOrgUnitReference[] = [];
const levelFiveHeaderRow = findSectionRow(rows, "Org Unit Level 5");
const levelSixHeaderRow = findSectionRow(rows, "Org Unit Level 6");
const managementLevelRow = findSectionRow(rows, "Management Level Group");
for (let rowNumber = levelFiveHeaderRow + 1; rowNumber < levelSixHeaderRow; rowNumber += 1) {
const row = rows[rowNumber - 1] ?? [];
const levelFiveName = normalizeNullableWorkbookValue(row[0]);
if (!levelFiveName) {
continue;
}
const secondCell = normalizeText(row[1]);
if (secondCell?.includes("wird nicht mehr benötigt")) {
warnings.push(`Ignored deprecated org unit row "${levelFiveName}" in EID-Attr row ${rowNumber}`);
continue;
}
orgUnits.push({
sourceRow: rowNumber,
level: 5,
name: levelFiveName,
parentName: null,
sortOrder: orgUnits.filter((entry) => entry.level === 5).length + 1,
});
row
.slice(1)
.map((value) => normalizeNullableWorkbookValue(value))
.filter((value): value is string => Boolean(value))
.forEach((levelSixName, index) => {
orgUnits.push({
sourceRow: rowNumber,
level: 6,
name: levelSixName,
parentName: levelFiveName,
sortOrder: index + 1,
});
});
}
for (let rowNumber = levelSixHeaderRow + 1; rowNumber < managementLevelRow; rowNumber += 1) {
const row = rows[rowNumber - 1] ?? [];
const levelSixName = normalizeNullableWorkbookValue(row[0]);
if (!levelSixName) {
continue;
}
row
.slice(1)
.map((value) => normalizeNullableWorkbookValue(value))
.filter((value): value is string => Boolean(value))
.forEach((levelSevenName, index) => {
orgUnits.push({
sourceRow: rowNumber,
level: 7,
name: levelSevenName,
parentName: levelSixName,
sortOrder: index + 1,
});
});
}
return { orgUnits, warnings };
}
function parseManagementLevelReferences(
rows: Awaited<ReturnType<typeof readWorksheetMatrix>>,
): { managementLevelGroups: ParsedManagementLevelGroupReference[]; warnings: string[] } {
const warnings: string[] = [];
const managementLevelGroups: ParsedManagementLevelGroupReference[] = [];
const startRow = findSectionRow(rows, "Management Level Group") + 1;
for (let rowNumber = startRow; rowNumber <= rows.length; rowNumber += 1) {
const row = rows[rowNumber - 1] ?? [];
const groupName = normalizeNullableWorkbookValue(row[0]);
if (!groupName) {
continue;
}
if (isTerminalSectionName(groupName, ["FTE"])) {
break;
}
const targetPercentage = typeof row[1] === "number" ? row[1] : null;
if (targetPercentage === null) {
warnings.push(`Missing target percentage for management level group "${groupName}" in row ${rowNumber}`);
continue;
}
const levels = row
.slice(2)
.map((value) => normalizeNullableWorkbookValue(value))
.filter((value): value is string => Boolean(value));
managementLevelGroups.push({
sourceRow: rowNumber,
name: groupName,
targetPercentage,
sortOrder: managementLevelGroups.length + 1,
levels,
});
}
return { managementLevelGroups, warnings };
}
function parseClientReferences(
rows: Awaited<ReturnType<typeof readWorksheetMatrix>>,
): { clients: ParsedClientReference[]; warnings: string[] } {
const warnings: string[] = [];
const clients: ParsedClientReference[] = [];
const startRow = findSectionRow(rows, "WBS Master Client") + 1;
for (let rowNumber = startRow; rowNumber <= rows.length; rowNumber += 1) {
const row = rows[rowNumber - 1] ?? [];
const masterClientName = normalizeNullableWorkbookValue(row[0]);
if (!masterClientName) {
continue;
}
const normalizedMasterName = sanitizeClientName(masterClientName);
const masterClientCode = normalizeClientCode(normalizedMasterName);
clients.push({
sourceRow: rowNumber,
sourceColumn: "A",
clientCode: masterClientCode,
name: normalizedMasterName,
parentClientCode: null,
parentName: null,
sortOrder: clients.filter((entry) => entry.parentName === null).length + 1,
});
row
.slice(1)
.map((value) => normalizeNullableWorkbookValue(value))
.filter((value): value is string => Boolean(value))
.forEach((childName, index) => {
clients.push({
sourceRow: rowNumber,
sourceColumn: toColumnLetter(index + 2),
clientCode: null,
name: sanitizeClientName(childName),
parentClientCode: masterClientCode,
parentName: normalizedMasterName,
sortOrder: index + 1,
});
});
}
return { clients, warnings };
}
export async function parseMandatoryDispoReferenceWorkbook(
workbookPath: string,
): Promise<ParsedReferenceWorkbook> {
const eidAttrRows = await readWorksheetMatrix(workbookPath, DISPO_REFERENCE_SHEET);
const projectAttrRows = await readWorksheetMatrix(workbookPath, DISPO_PROJECT_REFERENCE_SHEET);
const countryResult = parseCountryReferences(eidAttrRows);
const orgUnitResult = parseOrgUnitReferences(eidAttrRows);
const managementLevelResult = parseManagementLevelReferences(eidAttrRows);
const clientResult = parseClientReferences(projectAttrRows);
return {
countries: countryResult.countries,
orgUnits: orgUnitResult.orgUnits,
managementLevelGroups: managementLevelResult.managementLevelGroups,
clients: clientResult.clients,
warnings: [
...countryResult.warnings,
...orgUnitResult.warnings,
...managementLevelResult.warnings,
...clientResult.warnings,
],
};
}
@@ -0,0 +1,178 @@
import { normalizeCanonicalResourceIdentity } from "@planarchy/shared";
import { readWorksheetMatrix } from "./read-workbook.js";
import { normalizeNullableWorkbookValue, normalizeText } from "./shared.js";
const RESOURCE_ROSTER_MASTER_SHEET = "Dispo Namen";
const HEADERS = {
chapter: "Chapter",
employeeName: "Mitarbeiter (laut Dispo)",
experience: "Experience",
fte: "FTE",
lcr: "LCR (EUR)",
level: "Level",
location: "Location",
status: "Status",
typeOfWork: "Type of work",
ucr: "UCR (EUR)",
} as const;
export interface ParsedResourceRosterRate {
canonicalExternalId: string;
chapter: string | null;
experience: string | null;
fte: number | null;
lcrCents: number | null;
level: string | null;
location: string | null;
sourceRow: number;
status: string | null;
typeOfWork: string | null;
ucrCents: number | null;
warnings: string[];
}
export interface ParsedResourceRosterLevelAverage {
lcrCents: number | null;
level: string;
sampleCount: number;
ucrCents: number | null;
}
export interface ParsedResourceRosterMasterWorkbook {
levelAverages: Map<string, ParsedResourceRosterLevelAverage>;
rates: Map<string, ParsedResourceRosterRate>;
warnings: string[];
}
function buildHeaderMap(headerRow: ReadonlyArray<unknown>): Map<string, number> {
const headerMap = new Map<string, number>();
headerRow.forEach((value, index) => {
const normalized = normalizeText(value);
if (normalized) {
headerMap.set(normalized, index);
}
});
return headerMap;
}
function getCellValue(
row: ReadonlyArray<unknown>,
headerMap: Map<string, number>,
headerName: string,
): unknown {
const index = headerMap.get(headerName);
if (index === undefined) {
return null;
}
return row[index] ?? null;
}
function parseOptionalNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
const normalized = normalizeNullableWorkbookValue(value);
if (!normalized) {
return null;
}
const parsed = Number(normalized.replace(",", "."));
return Number.isFinite(parsed) ? parsed : null;
}
function toCents(value: number | null): number | null {
return value === null ? null : Math.round(value * 100);
}
export async function parseResourceRosterMasterWorkbook(
workbookPath: string,
): Promise<ParsedResourceRosterMasterWorkbook> {
const rows = await readWorksheetMatrix(workbookPath, RESOURCE_ROSTER_MASTER_SHEET);
const headerMap = buildHeaderMap(rows[0] ?? []);
const warnings: string[] = [];
const rates = new Map<string, ParsedResourceRosterRate>();
const levelBuckets = new Map<string, { lcr: number[]; ucr: number[] }>();
for (let rowNumber = 2; rowNumber <= rows.length; rowNumber += 1) {
const row = rows[rowNumber - 1] ?? [];
const employeeName = normalizeNullableWorkbookValue(
getCellValue(row, headerMap, HEADERS.employeeName),
);
if (!employeeName) {
continue;
}
const canonicalExternalId = normalizeCanonicalResourceIdentity(employeeName);
const level = normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.level));
const ucrValue = parseOptionalNumber(getCellValue(row, headerMap, HEADERS.ucr));
const lcrValue = parseOptionalNumber(getCellValue(row, headerMap, HEADERS.lcr));
const recordWarnings: string[] = [];
if (rates.has(canonicalExternalId)) {
recordWarnings.push(`Duplicate rate row ${rowNumber} ignored for ${canonicalExternalId}`);
warnings.push(recordWarnings[0] ?? `Duplicate rate row ${rowNumber} ignored`);
continue;
}
if (ucrValue === null || lcrValue === null) {
recordWarnings.push(`Incomplete rate row for ${canonicalExternalId}`);
}
rates.set(canonicalExternalId, {
canonicalExternalId,
sourceRow: rowNumber,
ucrCents: toCents(ucrValue),
lcrCents: toCents(lcrValue),
fte: parseOptionalNumber(getCellValue(row, headerMap, HEADERS.fte)),
level,
typeOfWork: normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.typeOfWork)),
chapter: normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.chapter)),
location: normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.location)),
status: normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.status)),
experience: normalizeNullableWorkbookValue(getCellValue(row, headerMap, HEADERS.experience)),
warnings: recordWarnings,
});
if (level) {
const bucket = levelBuckets.get(level) ?? { lcr: [], ucr: [] };
if (lcrValue !== null) {
bucket.lcr.push(lcrValue);
}
if (ucrValue !== null) {
bucket.ucr.push(ucrValue);
}
levelBuckets.set(level, bucket);
}
}
const levelAverages = new Map<string, ParsedResourceRosterLevelAverage>();
for (const [level, bucket] of levelBuckets.entries()) {
const lcrAverage =
bucket.lcr.length > 0
? Math.round((bucket.lcr.reduce((sum, value) => sum + value, 0) / bucket.lcr.length) * 100)
: null;
const ucrAverage =
bucket.ucr.length > 0
? Math.round((bucket.ucr.reduce((sum, value) => sum + value, 0) / bucket.ucr.length) * 100)
: null;
levelAverages.set(level, {
level,
sampleCount: Math.max(bucket.lcr.length, bucket.ucr.length),
lcrCents: lcrAverage,
ucrCents: ucrAverage,
});
}
return {
rates,
levelAverages,
warnings,
};
}
@@ -0,0 +1,72 @@
import * as XLSX from "xlsx";
export type WorksheetCellValue = boolean | Date | number | string | null;
export type WorksheetMatrix = WorksheetCellValue[][];
function normalizeWorksheetCellValue(value: unknown): WorksheetCellValue {
if (value === undefined || value === null) {
return null;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return value;
}
if (value instanceof Date) {
return value;
}
return String(value);
}
export async function readWorksheetMatrix(
workbookPath: string,
sheetName: string,
): Promise<WorksheetMatrix> {
const workbook = XLSX.readFile(workbookPath, {
cellDates: true,
dense: true,
});
const worksheet = workbook.Sheets[sheetName];
if (!worksheet) {
throw new Error(`Worksheet "${sheetName}" not found in workbook "${workbookPath}"`);
}
const rows = XLSX.utils.sheet_to_json<(WorksheetCellValue | null)[]>(worksheet, {
header: 1,
raw: true,
defval: null,
});
return rows.map((row) => row.map((value) => normalizeWorksheetCellValue(value)));
}
export function getCellString(
rows: WorksheetMatrix,
rowNumber: number,
columnNumber: number,
): string | null {
const value = rows[rowNumber - 1]?.[columnNumber - 1];
if (value === null || value === undefined) {
return null;
}
if (value instanceof Date) {
return value.toISOString();
}
return String(value);
}
export function toColumnLetter(columnNumber: number): string {
let current = columnNumber;
let result = "";
while (current > 0) {
const remainder = (current - 1) % 26;
result = String.fromCharCode(65 + remainder) + result;
current = Math.floor((current - 1) / 26);
}
return result;
}
@@ -0,0 +1,639 @@
import path from "node:path";
import type { Prisma, PrismaClient } from "@planarchy/db";
import {
DispoImportSourceKind,
DispoStagedRecordType,
ImportBatchStatus,
ResourceType,
StagedRecordStatus,
} from "@planarchy/db";
import {
createWeekdayAvailabilityFromFte,
normalizeCanonicalResourceIdentity,
normalizeDispoChapterToken,
} from "@planarchy/shared";
export type DispoImportDbClient = Pick<
PrismaClient,
| "client"
| "country"
| "importBatch"
| "managementLevel"
| "managementLevelGroup"
| "metroCity"
| "orgUnit"
| "stagedAssignment"
| "stagedAvailabilityRule"
| "stagedClient"
| "stagedProject"
| "stagedResource"
| "stagedVacation"
| "stagedUnresolvedRecord"
>;
export interface DispoReferenceImportInput {
importBatchId?: string;
notes?: string | null;
referenceWorkbookPath: string;
}
export interface DispoChargeabilityImportInput {
chargeabilityWorkbookPath: string;
excludedResourceExternalIds?: string[];
importBatchId?: string;
notes?: string | null;
}
export interface DispoRosterImportInput {
costWorkbookPath?: string;
importBatchId?: string;
notes?: string | null;
rosterWorkbookPath: string;
}
export interface DispoPlanningImportInput {
excludedResourceExternalIds?: string[];
importBatchId?: string;
notes?: string | null;
planningWorkbookPath: string;
}
export interface ParsedCountryReference {
sourceRow: number;
countryCode: string;
name: string;
dailyWorkingHours: number;
metroCities: string[];
scheduleRules?: Prisma.InputJsonValue;
}
export interface ParsedOrgUnitReference {
sourceRow: number;
level: number;
name: string;
parentName: string | null;
sortOrder: number;
}
export interface ParsedManagementLevelGroupReference {
sourceRow: number;
name: string;
targetPercentage: number;
sortOrder: number;
levels: string[];
}
export interface ParsedClientReference {
sourceColumn: string;
sourceRow: number;
clientCode: string | null;
name: string;
parentClientCode: string | null;
parentName: string | null;
sortOrder: number;
}
export interface ParsedReferenceWorkbook {
clients: ParsedClientReference[];
countries: ParsedCountryReference[];
managementLevelGroups: ParsedManagementLevelGroupReference[];
orgUnits: ParsedOrgUnitReference[];
warnings: string[];
}
export interface ParsedChargeabilityResource {
availability: Prisma.InputJsonValue;
canonicalExternalId: string;
chapter: string | null;
chapterCode: string | null;
chargeabilityTarget: number | null;
clientUnitName: string | null;
countryCode: string | null;
displayName: string;
eid: string;
email: string | null;
enterpriseId: string;
fte: number | null;
managementLevelGroupName: string | null;
managementLevelName: string | null;
metroCityName: string | null;
rawResourceType: string | null;
resourceType: ResourceType;
roleTokens: string[];
sourceRow: number;
warnings: string[];
}
export interface ParsedUnresolvedRecord {
message: string;
normalizedData: Record<string, unknown>;
projectKey?: string | null;
recordType: DispoStagedRecordType;
resolutionHint?: string | null;
resourceExternalId?: string | null;
sourceColumn?: string | null;
sourceRow: number;
warnings: string[];
}
export interface ParsedChargeabilityWorkbook {
resources: ParsedChargeabilityResource[];
unresolved: ParsedUnresolvedRecord[];
warnings: string[];
}
export interface ParsedRosterResource {
availability: Prisma.InputJsonValue;
canonicalExternalId: string;
chapter: string | null;
chapterCode: string | null;
clientUnitName: string | null;
countryCode: string | null;
dailyWorkingHoursPerFte: number | null;
department: string | null;
displayName: string;
eid: string;
email: string | null;
enterpriseId: string;
firstDayInDispo: Date | null;
fte: number | null;
lastDayInDispo: Date | null;
lcrCents: number | null;
mainSkillset: string | null;
managementLevelGroupName: string | null;
managementLevelName: string | null;
metroCityName: string | null;
rawResourceType: string | null;
resourceHoursPerWeek: number | null;
resourceType: ResourceType;
rateResolution: "EXACT" | "LEVEL_AVERAGE" | "MISSING";
rateResolutionLevel: string | null;
roleTokens: string[];
sapEmployeeName: string | null;
sapOrgUnitLevelFive: string | null;
sapOrgUnitLevelSix: string | null;
sapOrgUnitLevelSeven: string | null;
sourceRow: number;
sourceSheet: string;
ucrCents: number | null;
vacationDaysPerYear: number | null;
warnings: string[];
}
export interface ParsedRosterWorkbook {
excludedCanonicalExternalIds: string[];
ignoredPseudoDemandRows: number;
resources: ParsedRosterResource[];
unresolved: ParsedUnresolvedRecord[];
warnings: string[];
}
export interface ParsedPlanningAssignment {
assignmentDate: Date;
chapterToken: string | null;
hoursPerDay: number;
isInternal: boolean;
isTbd: boolean;
isUnassigned: boolean;
percentage: number;
projectKey: string | null;
rawToken: string;
resourceExternalId: string;
roleName: string | null;
roleToken: string | null;
slotFraction: number;
sourceColumn: string;
sourceRow: number;
utilizationCategoryCode: string | null;
warnings: string[];
winProbability: number | null;
}
export interface ParsedPlanningVacation {
endDate: Date;
halfDayPart: string | null;
holidayName: string | null;
isHalfDay: boolean;
isPublicHoliday: boolean;
note: string | null;
rawToken: string;
resourceExternalId: string;
sourceColumn: string;
sourceRow: number;
startDate: Date;
vacationType: "ANNUAL" | "OTHER" | "PUBLIC_HOLIDAY" | "SICK";
warnings: string[];
}
export interface ParsedPlanningAvailabilityRule {
availableHours: number | null;
effectiveEndDate: Date | null;
effectiveStartDate: Date | null;
isResolved: boolean;
percentage: number | null;
rawToken: string;
resourceExternalId: string;
ruleType: string;
sourceColumn: string;
sourceRow: number;
warnings: string[];
}
export interface ParsedPlanningWorkbook {
assignments: ParsedPlanningAssignment[];
availabilityRules: ParsedPlanningAvailabilityRule[];
unresolved: ParsedUnresolvedRecord[];
vacations: ParsedPlanningVacation[];
warnings: string[];
}
const COUNTRY_REFERENCE_CONFIG = {
"Costa Rica": {
code: "CR",
dailyWorkingHours: 8,
},
Germany: {
code: "DE",
dailyWorkingHours: 8,
},
Hungary: {
code: "HU",
dailyWorkingHours: 8,
},
India: {
code: "IN",
dailyWorkingHours: 9,
},
Italy: {
code: "IT",
dailyWorkingHours: 8,
},
Portugal: {
code: "PT",
dailyWorkingHours: 8,
},
Spain: {
code: "ES",
dailyWorkingHours: 8,
scheduleRules: {
type: "spain",
fridayHours: 6.5,
summerPeriod: { from: "07-01", to: "09-15" },
summerHours: 6.5,
regularHours: 9,
},
},
"United Kingdom": {
code: "GB",
dailyWorkingHours: 8,
},
} as const satisfies Record<
string,
{
code: string;
dailyWorkingHours: number;
scheduleRules?: Prisma.InputJsonValue;
}
>;
const CLIENT_CODE_OVERRIDES = {
BMW: "BMW",
DAIMLER: "DAIMLER",
"EXOR-STELLANTIS": "STELLANTIS",
VOLKSWAGEN: "VW",
"TATA MOTORS GROUP": "JLR",
} as const satisfies Record<string, string>;
const NULLISH_TOKENS = new Set(["", "-", "0", "(Blank)"]);
function collapseWhitespace(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
export function normalizeText(value: unknown): string | null {
if (value === null || value === undefined) {
return null;
}
const normalized = collapseWhitespace(String(value));
return normalized.length > 0 ? normalized : null;
}
export function normalizeNullableWorkbookValue(value: unknown): string | null {
const normalized = normalizeText(value);
if (!normalized) {
return null;
}
return NULLISH_TOKENS.has(normalized) ? null : normalized;
}
export function buildFallbackAccentureEmail(canonicalExternalId: string): string {
return `${canonicalExternalId}@accenture.com`;
}
export function isPseudoDemandResourceIdentity(value: string | null | undefined): boolean {
return typeof value === "string" && value.toLowerCase().startsWith("demand_");
}
export function sanitizeClientName(value: string): string {
return collapseWhitespace(value.replace(/\s*-\s*$/, ""));
}
export function getWorkbookFileName(workbookPath: string): string {
return path.basename(workbookPath);
}
export function findSectionRow(
rows: ReadonlyArray<ReadonlyArray<unknown>>,
firstCellValue: string,
): number {
const normalizedTarget = firstCellValue.toLowerCase();
for (let index = 0; index < rows.length; index += 1) {
const current = normalizeText(rows[index]?.[0]);
if (current?.toLowerCase() === normalizedTarget) {
return index + 1;
}
}
throw new Error(`Section row "${firstCellValue}" not found`);
}
export function getCountryReferenceConfig(countryName: string) {
return COUNTRY_REFERENCE_CONFIG[countryName as keyof typeof COUNTRY_REFERENCE_CONFIG] ?? null;
}
export function deriveCountryCodeFromMetroCity(
metroCityName: string | null | undefined,
): string | null {
if (!metroCityName) {
return null;
}
for (const [countryName, config] of Object.entries(COUNTRY_REFERENCE_CONFIG)) {
if (metroCityName === countryName) {
return config.code;
}
const isGermanCity =
countryName === "Germany" &&
["Bonn", "Frankfurt", "Hamburg", "Munich", "Stuttgart"].includes(metroCityName);
const isPortugueseCity = countryName === "Portugal" && metroCityName === "Lisbon";
const isUkCity = countryName === "United Kingdom" && metroCityName === "Birmingham";
const isCostaRica = countryName === "Costa Rica" && metroCityName === "Costa Rica";
if (isGermanCity || isPortugueseCity || isUkCity || isCostaRica) {
return config.code;
}
}
return null;
}
export function normalizeClientCode(masterClientName: string): string | null {
return CLIENT_CODE_OVERRIDES[masterClientName as keyof typeof CLIENT_CODE_OVERRIDES] ?? null;
}
export function ensurePercentageValue(value: number | null): number | null {
if (value === null) {
return null;
}
return value <= 1 ? Math.round(value * 10000) / 100 : value;
}
export function deriveDisplayNameFromEnterpriseId(enterpriseId: string): string {
return enterpriseId
.split(".")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export function deriveRoleTokens(...values: Array<string | null | undefined>): string[] {
const tokenSet = new Set<string>();
const combinedValue = values
.map((value) => normalizeText(value))
.filter((value): value is string => Boolean(value))
.join(" ")
.toUpperCase();
if (combinedValue.includes("2D")) {
tokenSet.add("2D");
}
if (combinedValue.includes("3D")) {
tokenSet.add("3D");
}
if (combinedValue.includes("PROGRAM/DELIVERY MGMT") || combinedValue.includes("PROJECT MANAGEMENT")) {
tokenSet.add("PM");
}
if (combinedValue.includes("ART DIRECTION")) {
tokenSet.add("AD");
}
return Array.from(tokenSet);
}
export function deriveNormalizedChapter(
rawChapter: string | null,
roleTokens: string[],
): { chapter: string | null; chapterCode: string | null } {
const firstRoleToken = roleTokens[0] ?? null;
if (firstRoleToken) {
const normalizedChapter = normalizeDispoChapterToken(firstRoleToken);
if (normalizedChapter) {
return {
chapter: normalizedChapter,
chapterCode: firstRoleToken,
};
}
}
return {
chapter: rawChapter,
chapterCode: null,
};
}
export function mapChargeabilityResourceType(rawValue: string | null): {
resourceType: ResourceType;
warning: string | null;
} {
if (!rawValue) {
return {
resourceType: ResourceType.EMPLOYEE,
warning: null,
};
}
const normalizedValue = rawValue.toLowerCase();
if (normalizedValue.includes("freelancer")) {
return { resourceType: ResourceType.FREELANCER, warning: null };
}
if (normalizedValue.includes("intern")) {
return { resourceType: ResourceType.INTERN, warning: null };
}
if (normalizedValue.includes("student")) {
return { resourceType: ResourceType.STUDENT, warning: null };
}
if (normalizedValue.includes("apprentice")) {
return { resourceType: ResourceType.APPRENTICE, warning: null };
}
if (
normalizedValue === "production studios" ||
normalizedValue === "near&offshore" ||
normalizedValue === "accenture" ||
normalizedValue === "long-term absence"
) {
return {
resourceType: ResourceType.EMPLOYEE,
warning: null,
};
}
return {
resourceType: ResourceType.EMPLOYEE,
warning: `Unknown MV Ressource Type "${rawValue}" mapped to EMPLOYEE`,
};
}
export function createAvailabilityFromFte(fte: number | null): Prisma.InputJsonValue {
return createWeekdayAvailabilityFromFte(fte ?? 1) as unknown as Prisma.InputJsonValue;
}
export function buildBatchSummaryEntry(summary: Record<string, unknown>): Prisma.InputJsonValue {
return summary as Prisma.InputJsonValue;
}
export async function ensureImportBatch(
db: Pick<PrismaClient, "importBatch">,
input: {
chargeabilitySourceFile?: string;
importBatchId?: string;
notes?: string | null;
planningSourceFile?: string;
referenceSourceFile?: string;
},
): Promise<{ id: string; summary: Record<string, unknown> }> {
if (input.importBatchId) {
const existing = await db.importBatch.findUnique({
where: { id: input.importBatchId },
select: { id: true, summary: true },
});
if (!existing) {
throw new Error(`Import batch "${input.importBatchId}" not found`);
}
const updated = await db.importBatch.update({
where: { id: input.importBatchId },
data: {
status: ImportBatchStatus.STAGING,
...(input.referenceSourceFile !== undefined
? { referenceSourceFile: input.referenceSourceFile }
: {}),
...(input.chargeabilitySourceFile !== undefined
? { chargeabilitySourceFile: input.chargeabilitySourceFile }
: {}),
...(input.planningSourceFile !== undefined
? { planningSourceFile: input.planningSourceFile }
: {}),
...(input.notes !== undefined ? { notes: input.notes } : {}),
startedAt: new Date(),
},
select: { id: true, summary: true },
});
return {
id: updated.id,
summary: toJsonObject(updated.summary),
};
}
const created = await db.importBatch.create({
data: {
sourceSystem: "DISPO_V2",
status: ImportBatchStatus.STAGING,
...(input.referenceSourceFile !== undefined
? { referenceSourceFile: input.referenceSourceFile }
: {}),
...(input.chargeabilitySourceFile !== undefined
? { chargeabilitySourceFile: input.chargeabilitySourceFile }
: {}),
...(input.planningSourceFile !== undefined
? { planningSourceFile: input.planningSourceFile }
: {}),
...(input.notes !== undefined ? { notes: input.notes } : {}),
startedAt: new Date(),
},
select: { id: true, summary: true },
});
return {
id: created.id,
summary: toJsonObject(created.summary),
};
}
export async function finalizeImportBatchStage(
db: Pick<PrismaClient, "importBatch">,
input: {
batchId: string;
existingSummary: Record<string, unknown>;
key: "chargeability" | "planning" | "projectResolution" | "reference" | "roster";
summary: Record<string, unknown>;
},
) {
const nextSummary = {
...input.existingSummary,
[input.key]: input.summary,
};
await db.importBatch.update({
where: { id: input.batchId },
data: {
status: ImportBatchStatus.STAGED,
stagedAt: new Date(),
summary: buildBatchSummaryEntry(nextSummary),
},
});
}
export function toJsonObject(value: unknown): Record<string, unknown> {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}
export function resolveCanonicalEnterpriseIdentity(value: string | null): string | null {
return value ? normalizeCanonicalResourceIdentity(value) : null;
}
export function createSourceTrace(
sourceKind: DispoImportSourceKind,
sourceWorkbook: string,
sourceSheet: string,
sourceRow: number,
sourceColumn?: string | null,
) {
return {
sourceKind,
sourceWorkbook,
sourceSheet,
sourceRow,
...(sourceColumn !== undefined ? { sourceColumn } : {}),
};
}
export const DISPO_REFERENCE_SHEET = "EID-Attr";
export const DISPO_PROJECT_REFERENCE_SHEET = "Project-Attr";
export const DISPO_CHARGEABILITY_SHEET = "ChgFC";
export const DISPO_PLANNING_SHEET = "Dispo";
export const DISPO_ROSTER_SHEET = "DispoRoster";
export const DISPO_ROSTER_SAP_SHEET = "SAP_data";
export { DispoImportSourceKind, DispoStagedRecordType, StagedRecordStatus };
@@ -0,0 +1,148 @@
import type { Prisma } from "@planarchy/db";
import { DispoImportSourceKind, StagedRecordStatus } from "@planarchy/db";
import { parseDispoChargeabilityWorkbook } from "./parse-chargeability-workbook.js";
import { ensureImportBatch, finalizeImportBatchStage, getWorkbookFileName, type DispoChargeabilityImportInput, type DispoImportDbClient } from "./shared.js";
export interface StageDispoChargeabilityResourcesResult {
batchId: string;
counts: {
stagedResources: number;
unresolved: number;
warnings: number;
};
}
export async function stageDispoChargeabilityResources(
db: DispoImportDbClient,
input: DispoChargeabilityImportInput,
): Promise<StageDispoChargeabilityResourcesResult> {
const batchInput: {
chargeabilitySourceFile: string;
importBatchId?: string;
notes?: string | null;
} = {
chargeabilitySourceFile: getWorkbookFileName(input.chargeabilityWorkbookPath),
};
if (input.importBatchId !== undefined) {
batchInput.importBatchId = input.importBatchId;
}
if (input.notes !== undefined) {
batchInput.notes = input.notes;
}
const batch = await ensureImportBatch(db, batchInput);
const parsed = await parseDispoChargeabilityWorkbook(input.chargeabilityWorkbookPath);
const excludedIds = new Set(input.excludedResourceExternalIds ?? []);
const filteredResources = parsed.resources.filter(
(resource) => !excludedIds.has(resource.canonicalExternalId),
);
const filteredUnresolved = parsed.unresolved.filter(
(record) => !record.resourceExternalId || !excludedIds.has(record.resourceExternalId),
);
const sourceWorkbook = getWorkbookFileName(input.chargeabilityWorkbookPath);
await db.stagedResource.deleteMany({
where: {
importBatchId: batch.id,
sourceKind: DispoImportSourceKind.CHARGEABILITY,
},
});
if (filteredResources.length > 0) {
await db.stagedResource.createMany({
data: filteredResources.map((resource) => ({
importBatchId: batch.id,
status: resource.warnings.length > 0
? StagedRecordStatus.PARSED
: StagedRecordStatus.NORMALIZED,
sourceKind: DispoImportSourceKind.CHARGEABILITY,
sourceWorkbook,
sourceSheet: "ChgFC",
sourceRow: resource.sourceRow,
canonicalExternalId: resource.canonicalExternalId,
enterpriseId: resource.enterpriseId,
eid: resource.eid,
displayName: resource.displayName,
email: resource.email,
chapter: resource.chapter,
chapterCode: resource.chapterCode,
managementLevelGroupName: resource.managementLevelGroupName,
managementLevelName: resource.managementLevelName,
countryCode: resource.countryCode,
metroCityName: resource.metroCityName,
clientUnitName: resource.clientUnitName,
resourceType: resource.resourceType,
chargeabilityTarget: resource.chargeabilityTarget,
fte: resource.fte,
availability: resource.availability,
roleTokens: resource.roleTokens,
warnings: resource.warnings,
rawPayload: {
rawResourceType: resource.rawResourceType,
} as Prisma.InputJsonValue,
normalizedData: {
chapter: resource.chapter,
chapterCode: resource.chapterCode,
chargeabilityTarget: resource.chargeabilityTarget,
clientUnitName: resource.clientUnitName,
countryCode: resource.countryCode,
fte: resource.fte,
managementLevelGroupName: resource.managementLevelGroupName,
metroCityName: resource.metroCityName,
roleTokens: resource.roleTokens,
} as Prisma.InputJsonValue,
})),
});
}
await db.stagedUnresolvedRecord.deleteMany({
where: {
importBatchId: batch.id,
sourceKind: DispoImportSourceKind.CHARGEABILITY,
},
});
if (filteredUnresolved.length > 0) {
await db.stagedUnresolvedRecord.createMany({
data: filteredUnresolved.map((record) => ({
importBatchId: batch.id,
status: StagedRecordStatus.UNRESOLVED,
sourceKind: DispoImportSourceKind.CHARGEABILITY,
sourceWorkbook,
sourceSheet: "ChgFC",
sourceRow: record.sourceRow,
sourceColumn: record.sourceColumn ?? null,
recordType: record.recordType,
resourceExternalId: record.resourceExternalId ?? null,
projectKey: record.projectKey ?? null,
message: record.message,
resolutionHint: record.resolutionHint ?? null,
warnings: record.warnings,
rawPayload: {} as Prisma.InputJsonValue,
normalizedData: record.normalizedData as Prisma.InputJsonValue,
})),
});
}
const warningCount =
parsed.warnings.length +
filteredResources.reduce((count, resource) => count + resource.warnings.length, 0);
const summary = {
stagedResources: filteredResources.length,
unresolved: filteredUnresolved.length,
warnings: warningCount,
};
await finalizeImportBatchStage(db, {
batchId: batch.id,
existingSummary: batch.summary,
key: "chargeability",
summary,
});
return {
batchId: batch.id,
counts: summary,
};
}
@@ -0,0 +1,106 @@
import {
assessDispoImportReadiness,
persistDispoImportReadiness,
type DispoImportReadinessReport,
} from "./assess-import-readiness.js";
import { type DispoImportDbClient } from "./shared.js";
import { stageDispoChargeabilityResources } from "./stage-chargeability-resources.js";
import { stageDispoPlanningData } from "./stage-dispo-planning.js";
import { stageDispoProjects } from "./stage-dispo-projects.js";
import { stageDispoRosterResources } from "./stage-dispo-roster-resources.js";
import { stageDispoReferenceData } from "./stage-reference-data.js";
export interface StageDispoImportBatchInput {
chargeabilityWorkbookPath: string;
costWorkbookPath?: string;
notes?: string | null;
planningWorkbookPath: string;
referenceWorkbookPath: string;
rosterWorkbookPath?: string;
}
export interface StageDispoImportBatchResult {
batchId: string;
counts: {
stagedAssignments: number;
stagedAvailabilityRules: number;
stagedClients: number;
stagedProjects: number;
stagedResources: number;
stagedRosterResources: number;
stagedVacations: number;
unresolved: number;
};
readiness: DispoImportReadinessReport;
}
export async function stageDispoImportBatch(
db: DispoImportDbClient,
input: StageDispoImportBatchInput,
): Promise<StageDispoImportBatchResult> {
const referenceResult = await stageDispoReferenceData(db, {
referenceWorkbookPath: input.referenceWorkbookPath,
...(input.notes !== undefined ? { notes: input.notes } : {}),
});
const batchId = referenceResult.batchId;
const rosterResult = input.rosterWorkbookPath
? await stageDispoRosterResources(db, {
importBatchId: batchId,
rosterWorkbookPath: input.rosterWorkbookPath,
...(input.costWorkbookPath ? { costWorkbookPath: input.costWorkbookPath } : {}),
...(input.notes !== undefined ? { notes: input.notes } : {}),
})
: { counts: { stagedResources: 0, unresolved: 0, warnings: 0, ignoredPseudoDemandRows: 0, excludedResources: 0 }, excludedCanonicalExternalIds: [] };
const chargeabilityResult = await stageDispoChargeabilityResources(db, {
importBatchId: batchId,
chargeabilityWorkbookPath: input.chargeabilityWorkbookPath,
excludedResourceExternalIds: rosterResult.excludedCanonicalExternalIds,
...(input.notes !== undefined ? { notes: input.notes } : {}),
});
const planningResult = await stageDispoPlanningData(db, {
importBatchId: batchId,
planningWorkbookPath: input.planningWorkbookPath,
excludedResourceExternalIds: rosterResult.excludedCanonicalExternalIds,
...(input.notes !== undefined ? { notes: input.notes } : {}),
});
const projectResult = await stageDispoProjects(db, {
importBatchId: batchId,
planningWorkbookPath: input.planningWorkbookPath,
excludedResourceExternalIds: rosterResult.excludedCanonicalExternalIds,
...(input.notes !== undefined ? { notes: input.notes } : {}),
});
const readiness = await persistDispoImportReadiness(db, {
importBatchId: batchId,
referenceWorkbookPath: input.referenceWorkbookPath,
chargeabilityWorkbookPath: input.chargeabilityWorkbookPath,
planningWorkbookPath: input.planningWorkbookPath,
...(input.costWorkbookPath ? { costWorkbookPath: input.costWorkbookPath } : {}),
...(input.rosterWorkbookPath ? { rosterWorkbookPath: input.rosterWorkbookPath } : {}),
...(input.notes !== undefined ? { notes: input.notes } : {}),
});
return {
batchId,
counts: {
stagedClients: referenceResult.counts.stagedClients,
stagedResources:
chargeabilityResult.counts.stagedResources + (rosterResult?.counts.stagedResources ?? 0),
stagedRosterResources: rosterResult?.counts.stagedResources ?? 0,
stagedProjects: projectResult.counts.stagedProjects,
stagedAssignments: planningResult.counts.stagedAssignments,
stagedVacations: planningResult.counts.stagedVacations,
stagedAvailabilityRules: planningResult.counts.stagedAvailabilityRules,
unresolved:
chargeabilityResult.counts.unresolved +
planningResult.counts.unresolved +
(rosterResult?.counts.unresolved ?? 0),
},
readiness,
};
}
@@ -0,0 +1,254 @@
import type { Prisma } from "@planarchy/db";
import { DispoImportSourceKind, StagedRecordStatus } from "@planarchy/db";
import { parseDispoPlanningWorkbook } from "./parse-dispo-matrix.js";
import {
DISPO_PLANNING_SHEET,
ensureImportBatch,
finalizeImportBatchStage,
getWorkbookFileName,
isPseudoDemandResourceIdentity,
type DispoImportDbClient,
type DispoPlanningImportInput,
} from "./shared.js";
export interface StageDispoPlanningResult {
batchId: string;
counts: {
stagedAssignments: number;
stagedAvailabilityRules: number;
stagedVacations: number;
unresolved: number;
warnings: number;
};
}
export async function stageDispoPlanningData(
db: DispoImportDbClient,
input: DispoPlanningImportInput,
): Promise<StageDispoPlanningResult> {
const batchInput: {
importBatchId?: string;
notes?: string | null;
planningSourceFile: string;
} = {
planningSourceFile: getWorkbookFileName(input.planningWorkbookPath),
};
if (input.importBatchId !== undefined) {
batchInput.importBatchId = input.importBatchId;
}
if (input.notes !== undefined) {
batchInput.notes = input.notes;
}
const batch = await ensureImportBatch(db, batchInput);
const parsed = await parseDispoPlanningWorkbook(input.planningWorkbookPath);
const excludedIds = new Set(input.excludedResourceExternalIds ?? []);
const filteredAssignments = parsed.assignments.filter(
(assignment) =>
!excludedIds.has(assignment.resourceExternalId) &&
!isPseudoDemandResourceIdentity(assignment.resourceExternalId),
);
const filteredVacations = parsed.vacations.filter(
(vacation) =>
!excludedIds.has(vacation.resourceExternalId) &&
!isPseudoDemandResourceIdentity(vacation.resourceExternalId),
);
const filteredAvailabilityRules = parsed.availabilityRules.filter(
(rule) =>
!excludedIds.has(rule.resourceExternalId) &&
!isPseudoDemandResourceIdentity(rule.resourceExternalId),
);
const filteredUnresolved = parsed.unresolved.filter(
(record) =>
!record.resourceExternalId ||
(!excludedIds.has(record.resourceExternalId) &&
!isPseudoDemandResourceIdentity(record.resourceExternalId)),
);
const sourceWorkbook = getWorkbookFileName(input.planningWorkbookPath);
await db.stagedAssignment.deleteMany({
where: {
importBatchId: batch.id,
sourceKind: DispoImportSourceKind.PLANNING,
},
});
await db.stagedVacation.deleteMany({
where: {
importBatchId: batch.id,
sourceKind: DispoImportSourceKind.PLANNING,
},
});
await db.stagedAvailabilityRule.deleteMany({
where: {
importBatchId: batch.id,
sourceKind: DispoImportSourceKind.PLANNING,
},
});
await db.stagedUnresolvedRecord.deleteMany({
where: {
importBatchId: batch.id,
sourceKind: DispoImportSourceKind.PLANNING,
},
});
if (filteredAssignments.length > 0) {
await db.stagedAssignment.createMany({
data: filteredAssignments.map((assignment) => ({
importBatchId: batch.id,
status: assignment.warnings.length > 0
? StagedRecordStatus.PARSED
: StagedRecordStatus.NORMALIZED,
sourceKind: DispoImportSourceKind.PLANNING,
sourceWorkbook,
sourceSheet: DISPO_PLANNING_SHEET,
sourceRow: assignment.sourceRow,
sourceColumn: assignment.sourceColumn,
resourceExternalId: assignment.resourceExternalId,
projectKey: assignment.projectKey,
assignmentDate: assignment.assignmentDate,
startDate: assignment.assignmentDate,
endDate: assignment.assignmentDate,
hoursPerDay: assignment.hoursPerDay,
percentage: assignment.percentage,
slotFraction: assignment.slotFraction,
roleToken: assignment.roleToken,
roleName: assignment.roleName,
chapterToken: assignment.chapterToken,
utilizationCategoryCode: assignment.utilizationCategoryCode,
winProbability: assignment.winProbability,
isInternal: assignment.isInternal,
isUnassigned: assignment.isUnassigned,
isTbd: assignment.isTbd,
warnings: assignment.warnings,
rawPayload: {
rawToken: assignment.rawToken,
} as Prisma.InputJsonValue,
normalizedData: {
assignmentDate: assignment.assignmentDate.toISOString().slice(0, 10),
chapterToken: assignment.chapterToken,
hoursPerDay: assignment.hoursPerDay,
percentage: assignment.percentage,
roleToken: assignment.roleToken,
utilizationCategoryCode: assignment.utilizationCategoryCode,
winProbability: assignment.winProbability,
} as Prisma.InputJsonValue,
})),
});
}
if (filteredVacations.length > 0) {
await db.stagedVacation.createMany({
data: filteredVacations.map((vacation) => ({
importBatchId: batch.id,
status: vacation.warnings.length > 0
? StagedRecordStatus.PARSED
: StagedRecordStatus.NORMALIZED,
sourceKind: DispoImportSourceKind.PLANNING,
sourceWorkbook,
sourceSheet: DISPO_PLANNING_SHEET,
sourceRow: vacation.sourceRow,
sourceColumn: vacation.sourceColumn,
resourceExternalId: vacation.resourceExternalId,
vacationType: vacation.vacationType,
startDate: vacation.startDate,
endDate: vacation.endDate,
note: vacation.note,
holidayName: vacation.holidayName,
isHalfDay: vacation.isHalfDay,
halfDayPart: vacation.halfDayPart,
isPublicHoliday: vacation.isPublicHoliday,
warnings: vacation.warnings,
rawPayload: {
rawToken: vacation.rawToken,
} as Prisma.InputJsonValue,
normalizedData: {
holidayName: vacation.holidayName,
isHalfDay: vacation.isHalfDay,
isPublicHoliday: vacation.isPublicHoliday,
note: vacation.note,
} as Prisma.InputJsonValue,
})),
});
}
if (filteredAvailabilityRules.length > 0) {
await db.stagedAvailabilityRule.createMany({
data: filteredAvailabilityRules.map((rule) => ({
importBatchId: batch.id,
status: rule.warnings.length > 0
? StagedRecordStatus.PARSED
: StagedRecordStatus.NORMALIZED,
sourceKind: DispoImportSourceKind.PLANNING,
sourceWorkbook,
sourceSheet: DISPO_PLANNING_SHEET,
sourceRow: rule.sourceRow,
sourceColumn: rule.sourceColumn,
resourceExternalId: rule.resourceExternalId,
ruleType: rule.ruleType,
weekday: null,
effectiveStartDate: rule.effectiveStartDate,
effectiveEndDate: rule.effectiveEndDate,
availableHours: rule.availableHours,
percentage: rule.percentage,
isResolved: rule.isResolved,
warnings: rule.warnings,
rawPayload: {
rawToken: rule.rawToken,
} as Prisma.InputJsonValue,
normalizedData: {
availableHours: rule.availableHours,
percentage: rule.percentage,
ruleType: rule.ruleType,
} as Prisma.InputJsonValue,
})),
});
}
if (filteredUnresolved.length > 0) {
await db.stagedUnresolvedRecord.createMany({
data: filteredUnresolved.map((record) => ({
importBatchId: batch.id,
status: StagedRecordStatus.UNRESOLVED,
sourceKind: DispoImportSourceKind.PLANNING,
sourceWorkbook,
sourceSheet: DISPO_PLANNING_SHEET,
sourceRow: record.sourceRow,
sourceColumn: record.sourceColumn ?? null,
recordType: record.recordType,
resourceExternalId: record.resourceExternalId ?? null,
projectKey: record.projectKey ?? null,
message: record.message,
resolutionHint: record.resolutionHint ?? null,
warnings: record.warnings,
rawPayload: {} as Prisma.InputJsonValue,
normalizedData: record.normalizedData as Prisma.InputJsonValue,
})),
});
}
const warningCount =
parsed.warnings.length +
filteredAssignments.reduce((count, assignment) => count + assignment.warnings.length, 0) +
filteredVacations.reduce((count, vacation) => count + vacation.warnings.length, 0) +
filteredAvailabilityRules.reduce((count, rule) => count + rule.warnings.length, 0);
const summary = {
stagedAssignments: filteredAssignments.length,
stagedAvailabilityRules: filteredAvailabilityRules.length,
stagedVacations: filteredVacations.length,
unresolved: filteredUnresolved.length,
warnings: warningCount,
};
await finalizeImportBatchStage(db, {
batchId: batch.id,
existingSummary: batch.summary,
key: "planning",
summary,
});
return {
batchId: batch.id,
counts: summary,
};
}
@@ -0,0 +1,313 @@
import type { Prisma } from "@planarchy/db";
import { AllocationType, DispoImportSourceKind, OrderType, StagedRecordStatus } from "@planarchy/db";
import { DISPO_INTERNAL_PROJECT_BUCKETS } from "@planarchy/shared";
import { parseDispoPlanningWorkbook } from "./parse-dispo-matrix.js";
import {
DISPO_PLANNING_SHEET,
ensureImportBatch,
finalizeImportBatchStage,
getWorkbookFileName,
isPseudoDemandResourceIdentity,
type DispoImportDbClient,
type DispoPlanningImportInput,
normalizeText,
} from "./shared.js";
interface ResolvedStagedProject {
allocationType: AllocationType;
clientCode: string | null;
isInternal: boolean;
isTbd: boolean;
name: string;
orderType: OrderType;
projectKey: string;
rawTokens: Set<string>;
shortCode: string;
sourceColumn: string;
sourceRow: number;
startDate: Date;
endDate: Date;
utilizationCategoryCode: string | null;
warnings: Set<string>;
winProbability: number | null;
}
function extractBracketTokens(token: string): string[] {
return Array.from(token.matchAll(/\[([^\]]+)\]/g), (match) => match[1]?.trim() ?? "").filter(Boolean);
}
function extractClientCode(token: string): string | null {
const candidates = extractBracketTokens(token).filter(
(entry) =>
entry.length > 0 &&
!entry.startsWith("_") &&
!/^\d+$/.test(entry) &&
entry.toLowerCase() !== "tbd",
);
return candidates[0] ?? null;
}
function deriveProjectName(token: string, fallbackProjectKey: string): string {
const normalized = token
.replace(/^(2D|3D|PM|AD)\s+/i, "")
.replace(/\[[^\]]+\]/g, " ")
.replace(/\{[^}]+\}/g, " ")
.replace(/\s+(?:HB|SB)_?\s*$/i, " ")
.replace(/\s+/g, " ")
.trim();
return normalized.length > 0 ? normalized : `Project ${fallbackProjectKey}`;
}
function updateDateRange(project: ResolvedStagedProject, assignmentDate: Date) {
if (assignmentDate < project.startDate) {
project.startDate = assignmentDate;
}
if (assignmentDate > project.endDate) {
project.endDate = assignmentDate;
}
}
export interface StageDispoProjectsResult {
batchId: string;
counts: {
stagedProjects: number;
warnings: number;
};
}
export async function stageDispoProjects(
db: DispoImportDbClient,
input: DispoPlanningImportInput,
): Promise<StageDispoProjectsResult> {
const batchInput: {
importBatchId?: string;
notes?: string | null;
planningSourceFile: string;
} = {
planningSourceFile: getWorkbookFileName(input.planningWorkbookPath),
};
if (input.importBatchId !== undefined) {
batchInput.importBatchId = input.importBatchId;
}
if (input.notes !== undefined) {
batchInput.notes = input.notes;
}
const batch = await ensureImportBatch(db, batchInput);
const parsed = await parseDispoPlanningWorkbook(input.planningWorkbookPath);
const excludedIds = new Set(input.excludedResourceExternalIds ?? []);
const sourceWorkbook = getWorkbookFileName(input.planningWorkbookPath);
const projects = new Map<string, ResolvedStagedProject>();
for (const bucket of DISPO_INTERNAL_PROJECT_BUCKETS) {
projects.set(bucket.shortCode, {
allocationType: AllocationType.INT,
clientCode: null,
isInternal: true,
isTbd: false,
name: bucket.name,
orderType: OrderType.INTERNAL,
projectKey: bucket.shortCode,
rawTokens: new Set<string>([`{${bucket.sourceToken}}`]),
shortCode: bucket.shortCode,
sourceColumn: "A",
sourceRow: 0,
startDate: new Date("2100-01-01T00:00:00.000Z"),
endDate: new Date("1970-01-01T00:00:00.000Z"),
utilizationCategoryCode: bucket.utilizationCategoryCode,
warnings: new Set<string>(),
winProbability: 100,
});
}
for (const assignment of parsed.assignments) {
if (
excludedIds.has(assignment.resourceExternalId) ||
isPseudoDemandResourceIdentity(assignment.resourceExternalId)
) {
continue;
}
if (assignment.isTbd || assignment.isUnassigned) {
continue;
}
if (assignment.isInternal) {
const internalBucket = DISPO_INTERNAL_PROJECT_BUCKETS.find(
(bucket) => assignment.rawToken.includes(`{${bucket.sourceToken}}`),
);
if (!internalBucket) {
continue;
}
const project = projects.get(internalBucket.shortCode);
if (!project) {
continue;
}
updateDateRange(project, assignment.assignmentDate);
project.rawTokens.add(assignment.rawToken);
continue;
}
if (!assignment.projectKey) {
continue;
}
const derivedClientCode = extractClientCode(assignment.rawToken);
const derivedName = deriveProjectName(assignment.rawToken, assignment.projectKey);
const shortCode = assignment.projectKey;
const existing = projects.get(assignment.projectKey);
if (!existing) {
projects.set(assignment.projectKey, {
allocationType: assignment.utilizationCategoryCode === "Chg"
? AllocationType.EXT
: AllocationType.INT,
clientCode: derivedClientCode,
isInternal: false,
isTbd: false,
name: derivedName,
orderType: assignment.utilizationCategoryCode === "Chg"
? OrderType.CHARGEABLE
: assignment.utilizationCategoryCode === "BD"
? OrderType.BD
: OrderType.INTERNAL,
projectKey: assignment.projectKey,
rawTokens: new Set<string>([assignment.rawToken]),
shortCode,
sourceColumn: assignment.sourceColumn,
sourceRow: assignment.sourceRow,
startDate: assignment.assignmentDate,
endDate: assignment.assignmentDate,
utilizationCategoryCode: assignment.utilizationCategoryCode,
warnings: new Set<string>(assignment.warnings),
winProbability: assignment.winProbability,
});
continue;
}
updateDateRange(existing, assignment.assignmentDate);
existing.rawTokens.add(assignment.rawToken);
if (derivedClientCode && existing.clientCode && existing.clientCode !== derivedClientCode) {
existing.warnings.add(
`Conflicting client codes for project ${assignment.projectKey}: ${existing.clientCode} vs ${derivedClientCode}`,
);
}
if (!existing.clientCode && derivedClientCode) {
existing.clientCode = derivedClientCode;
}
if (normalizeText(existing.name) !== normalizeText(derivedName)) {
existing.warnings.add(
`Multiple project names observed for ${assignment.projectKey}; using "${existing.name}"`,
);
}
if (
existing.utilizationCategoryCode &&
assignment.utilizationCategoryCode &&
existing.utilizationCategoryCode !== assignment.utilizationCategoryCode
) {
existing.warnings.add(
`Conflicting utilization categories for ${assignment.projectKey}: ${existing.utilizationCategoryCode} vs ${assignment.utilizationCategoryCode}`,
);
}
if (!existing.utilizationCategoryCode && assignment.utilizationCategoryCode) {
existing.utilizationCategoryCode = assignment.utilizationCategoryCode;
}
if (
existing.winProbability !== null &&
assignment.winProbability !== null &&
existing.winProbability !== assignment.winProbability
) {
existing.warnings.add(
`Conflicting win probabilities for ${assignment.projectKey}: ${existing.winProbability} vs ${assignment.winProbability}`,
);
}
if (existing.winProbability === null && assignment.winProbability !== null) {
existing.winProbability = assignment.winProbability;
}
}
await db.stagedProject.deleteMany({
where: {
importBatchId: batch.id,
sourceKind: DispoImportSourceKind.PLANNING,
},
});
const stagedProjects = Array.from(projects.values())
.filter((project) => {
if (!project.isInternal) {
return true;
}
return project.startDate <= project.endDate;
})
.map((project) => ({
importBatchId: batch.id,
status: project.warnings.size > 0
? StagedRecordStatus.PARSED
: StagedRecordStatus.NORMALIZED,
sourceKind: DispoImportSourceKind.PLANNING,
sourceWorkbook,
sourceSheet: DISPO_PLANNING_SHEET,
sourceRow: project.sourceRow,
sourceColumn: project.sourceColumn,
projectKey: project.projectKey,
shortCode: project.shortCode,
name: project.name,
clientCode: project.clientCode,
utilizationCategoryCode: project.utilizationCategoryCode,
orderType: project.orderType,
allocationType: project.allocationType,
winProbability: project.winProbability,
isInternal: project.isInternal,
isTbd: project.isTbd,
startDate: project.startDate,
endDate: project.endDate,
warnings: Array.from(project.warnings),
rawPayload: {
rawTokens: Array.from(project.rawTokens),
} as Prisma.InputJsonValue,
normalizedData: {
clientCode: project.clientCode,
name: project.name,
utilizationCategoryCode: project.utilizationCategoryCode,
winProbability: project.winProbability,
} as Prisma.InputJsonValue,
}));
if (stagedProjects.length > 0) {
await db.stagedProject.createMany({
data: stagedProjects,
});
}
const warningCount = stagedProjects.reduce((count, project) => count + project.warnings.length, 0);
const summary = {
stagedProjects: stagedProjects.length,
warnings: warningCount,
};
await finalizeImportBatchStage(db, {
batchId: batch.id,
existingSummary: batch.summary,
key: "projectResolution",
summary,
});
return {
batchId: batch.id,
counts: summary,
};
}

Some files were not shown because too many files have changed in this diff Show More