chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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("; "),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
@@ -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 2–3 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 };
|
||||
}),
|
||||
});
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
@@ -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) };
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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}` });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@planarchy/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/__tests__"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
+35
@@ -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),
|
||||
};
|
||||
}
|
||||
+72
@@ -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,
|
||||
};
|
||||
}
|
||||
+64
@@ -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,
|
||||
],
|
||||
};
|
||||
}
|
||||
+178
@@ -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
Reference in New Issue
Block a user