feat: dashboard overhaul, chargeability reports, dispo import enhancements, UI polish

Dashboard: expanded chargeability widget, resource/project table widgets
with sorting and filters, stat cards with formatMoney integration.

Chargeability: new report client with filtering, chargeability-bookings
use case, updated dashboard overview logic.

Dispo import: TBD project handling, parse-dispo-matrix improvements,
stage-dispo-projects resource value scores, new tests.

Estimates: CommercialTermsEditor component, commercial-terms engine
module, expanded estimate schemas and types.

UI: AppShell navigation updates, timeline filter/toolbar enhancements,
role management improvements, signin page redesign, Tailwind/globals
polish, SystemSettings SMTP section, anonymization support.

Tests: new router tests (anonymization, chargeability, effort-rule,
entitlement, estimate, experience-multiplier, notification, resource,
staffing, vacation).

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-14 23:29:07 +01:00
parent ad0855902b
commit 625a842d89
74 changed files with 11680 additions and 1583 deletions
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from "vitest";
import { getAnonymizationDirectory } from "../lib/anonymization.js";
describe("anonymization directory", () => {
it("persists aliases so existing resources keep the same identity when new resources appear", async () => {
let storedAliases: Record<string, { displayName: string; eid: string }> = {
resource_a: {
displayName: "Iron Man",
eid: "iron.man",
},
};
const resourcesRoundOne = [
{
id: "resource_a",
eid: "alice",
displayName: "Alice",
email: "alice@example.com",
lcrCents: 14000,
},
{
id: "resource_b",
eid: "bob",
displayName: "Bob",
email: "bob@example.com",
lcrCents: 11000,
},
];
const resourcesRoundTwo = [
...resourcesRoundOne,
{
id: "resource_c",
eid: "carol",
displayName: "Carol",
email: "carol@example.com",
lcrCents: 12500,
},
];
const db = {
systemSettings: {
findUnique: vi.fn(async () => ({
anonymizationEnabled: true,
anonymizationDomain: "superhartmut.de",
anonymizationSeed: "stable-seed",
anonymizationMode: "global",
anonymizationAliases: storedAliases,
})),
update: vi.fn(async ({ data }: { data: { anonymizationAliases: typeof storedAliases } }) => {
storedAliases = data.anonymizationAliases;
return {};
}),
},
resource: {
findMany: vi
.fn()
.mockResolvedValueOnce(resourcesRoundOne)
.mockResolvedValueOnce(resourcesRoundTwo),
},
};
const firstDirectory = await getAnonymizationDirectory(db as never);
const secondDirectory = await getAnonymizationDirectory(db as never);
expect(firstDirectory?.byResourceId.get("resource_a")).toMatchObject({
displayName: "Iron Man",
eid: "iron.man",
email: "iron.man@superhartmut.de",
});
expect(firstDirectory?.byResourceId.get("resource_b")).toBeDefined();
expect(secondDirectory?.byResourceId.get("resource_a")).toMatchObject({
displayName: "Iron Man",
eid: "iron.man",
email: "iron.man@superhartmut.de",
});
expect(secondDirectory?.byResourceId.get("resource_b")).toEqual(
firstDirectory?.byResourceId.get("resource_b"),
);
expect(secondDirectory?.byResourceId.get("resource_c")).toBeDefined();
expect(db.systemSettings.update).toHaveBeenCalledTimes(2);
});
it("regenerates legacy aliases with digits into stable aliases without numeric characters", async () => {
let storedAliases: Record<string, { displayName: string; eid: string }> = {
resource_a: {
displayName: "Baloo 16",
eid: "baloo.16",
},
};
const db = {
systemSettings: {
findUnique: vi.fn(async () => ({
anonymizationEnabled: true,
anonymizationDomain: "superhartmut.de",
anonymizationSeed: "stable-seed",
anonymizationMode: "global",
anonymizationAliases: storedAliases,
})),
update: vi.fn(async ({ data }: { data: { anonymizationAliases: typeof storedAliases } }) => {
storedAliases = data.anonymizationAliases;
return {};
}),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_a",
eid: "alice",
displayName: "Alice",
email: "alice@example.com",
lcrCents: 6000,
},
]),
},
};
const directory = await getAnonymizationDirectory(db as never);
const alias = directory?.byResourceId.get("resource_a");
expect(alias).toBeDefined();
expect(alias?.displayName).not.toContain("16");
expect(alias?.eid).not.toContain("16");
expect(alias?.displayName).toMatch(/^[A-Za-z]+(?: [A-Za-z]+)*$/);
expect(alias?.eid).toMatch(/^[a-z]+(?:\.[a-z]+)*$/);
expect(db.systemSettings.update).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,207 @@
import { SystemRole } from "@planarchy/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@planarchy/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@planarchy/application")>();
return {
...actual,
isChargeabilityActualBooking: actual.isChargeabilityActualBooking,
listAssignmentBookings: vi.fn(),
};
});
import { listAssignmentBookings } from "@planarchy/application";
import { chargeabilityReportRouter } from "../router/chargeability-report.js";
import { createCallerFactory } from "../trpc.js";
const createCaller = createCallerFactory(chargeabilityReportRouter);
function createControllerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "controller@example.com", name: "Controller", image: null },
expires: "2026-03-14T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_controller",
systemRole: SystemRole.CONTROLLER,
permissionOverrides: null,
},
});
}
describe("chargeability report router", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("excludes proposed bookings by default but includes them when requested", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_1",
eid: "E-001",
displayName: "Alice",
fte: 1,
chargeabilityTarget: 80,
country: {
id: "country_es",
code: "ES",
dailyWorkingHours: 8,
scheduleRules: null,
},
orgUnit: { id: "org_1", name: "CGI" },
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
managementLevel: { id: "level_1", name: "L7" },
metroCity: { id: "city_1", name: "Barcelona" },
},
]),
},
project: {
findMany: vi.fn().mockResolvedValue([
{ id: "project_confirmed", utilizationCategory: { code: "Chg" } },
{ id: "project_proposed", utilizationCategory: { code: "Chg" } },
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "assignment_confirmed",
projectId: "project_confirmed",
resourceId: "resource_1",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-06T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "CONFIRMED",
project: {
id: "project_confirmed",
name: "Confirmed Project",
shortCode: "CP",
status: "ACTIVE",
orderType: "CLIENT",
dynamicFields: null,
},
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
},
{
id: "assignment_proposed",
projectId: "project_proposed",
resourceId: "resource_1",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-06T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "PROPOSED",
project: {
id: "project_proposed",
name: "Proposed Project",
shortCode: "PP",
status: "ACTIVE",
orderType: "CLIENT",
dynamicFields: null,
},
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
},
]);
const caller = createControllerCaller(db);
const strict = await caller.getReport({
startMonth: "2026-03",
endMonth: "2026-03",
});
const withProposed = await caller.getReport({
startMonth: "2026-03",
endMonth: "2026-03",
includeProposed: true,
});
const strictMonth = strict.resources[0]?.months[0];
const proposedMonth = withProposed.resources[0]?.months[0];
expect(strictMonth).toBeDefined();
expect(proposedMonth).toBeDefined();
expect(strictMonth?.chg).toBeGreaterThan(0);
expect(proposedMonth?.chg).toBeGreaterThan(strictMonth?.chg ?? 0);
expect(proposedMonth?.chg).toBeCloseTo((strictMonth?.chg ?? 0) * 2, 5);
expect(withProposed.groupTotals[0]?.chg).toBeGreaterThan(strict.groupTotals[0]?.chg ?? 0);
});
it("includes imported TBD draft work only when proposed bookings are enabled", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_1",
eid: "E-001",
displayName: "Alice",
fte: 1,
chargeabilityTarget: 80,
country: {
id: "country_es",
code: "ES",
dailyWorkingHours: 8,
scheduleRules: null,
},
orgUnit: { id: "org_1", name: "CGI" },
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
managementLevel: { id: "level_1", name: "L7" },
metroCity: { id: "city_1", name: "Barcelona" },
},
]),
},
project: {
findMany: vi.fn().mockResolvedValue([
{ id: "project_tbd", utilizationCategory: { code: "Chg" } },
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "assignment_tbd",
projectId: "project_tbd",
resourceId: "resource_1",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-06T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "PROPOSED",
project: {
id: "project_tbd",
name: "TBD Project",
shortCode: "TBD-P1",
status: "DRAFT",
orderType: "CLIENT",
dynamicFields: { dispoImport: { isTbd: true } },
},
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
},
]);
const caller = createControllerCaller(db);
const strict = await caller.getReport({
startMonth: "2026-03",
endMonth: "2026-03",
});
const withProposed = await caller.getReport({
startMonth: "2026-03",
endMonth: "2026-03",
includeProposed: true,
});
expect(strict.resources[0]?.months[0]?.chg).toBe(0);
expect(withProposed.resources[0]?.months[0]?.chg).toBeGreaterThan(0);
expect(withProposed.groupTotals[0]?.chg).toBeGreaterThan(strict.groupTotals[0]?.chg ?? 0);
});
});
@@ -0,0 +1,540 @@
import { SystemRole } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { effortRuleRouter } from "../router/effort-rule.js";
import { createCallerFactory } from "../trpc.js";
// Mock the engine — we focus on the router/DB layer, not the pure engine logic
vi.mock("@planarchy/engine", () => ({
expandScopeToEffort: vi.fn().mockReturnValue({
lines: [
{
scopeItemName: "Shot_001",
scopeType: "shot",
discipline: "Compositing",
chapter: null,
hours: 16,
unitMode: "per_frame" as const,
unitCount: 200,
hoursPerUnit: 0.08,
},
],
warnings: [],
unmatchedScopeItems: [],
}),
aggregateByDiscipline: vi.fn().mockReturnValue({
Compositing: { totalHours: 16, lineCount: 1 },
}),
}));
const createCaller = createCallerFactory(effortRuleRouter);
function createControllerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "ctrl@example.com", name: "Controller", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_ctrl",
systemRole: SystemRole.CONTROLLER,
permissionOverrides: null,
},
});
}
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "mgr@example.com", name: "Manager", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_mgr",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
// ── Sample data factories ────────────────────────────────────────────────────
function sampleRuleSet(overrides: Record<string, unknown> = {}) {
return {
id: "ers_1",
name: "VFX Standard",
description: null,
isDefault: true,
createdAt: new Date(),
updatedAt: new Date(),
rules: [
{
id: "er_1",
ruleSetId: "ers_1",
scopeType: "shot",
discipline: "Compositing",
chapter: null,
unitMode: "per_frame",
hoursPerUnit: 0.08,
description: null,
sortOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
},
],
...overrides,
};
}
// ─── list ────────────────────────────────────────────────────────────────────
describe("effortRule.list", () => {
it("returns rule sets ordered by isDefault desc, name asc", async () => {
const sets = [sampleRuleSet(), sampleRuleSet({ id: "ers_2", name: "Animation", isDefault: false })];
const db = {
effortRuleSet: {
findMany: vi.fn().mockResolvedValue(sets),
},
};
const caller = createControllerCaller(db);
const result = await caller.list();
expect(result).toHaveLength(2);
expect(db.effortRuleSet.findMany).toHaveBeenCalledWith(
expect.objectContaining({
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
}),
);
});
});
// ─── getById ─────────────────────────────────────────────────────────────────
describe("effortRule.getById", () => {
it("returns the rule set when found", async () => {
const set = sampleRuleSet();
const db = {
effortRuleSet: {
findUnique: vi.fn().mockResolvedValue(set),
},
};
const caller = createControllerCaller(db);
const result = await caller.getById({ id: "ers_1" });
expect(result.id).toBe("ers_1");
expect(result.rules).toHaveLength(1);
});
it("throws NOT_FOUND when rule set does not exist", async () => {
const db = {
effortRuleSet: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createControllerCaller(db);
await expect(caller.getById({ id: "nonexistent" })).rejects.toThrow("Effort rule set not found");
});
});
// ─── create ──────────────────────────────────────────────────────────────────
describe("effortRule.create", () => {
it("creates a rule set with rules", async () => {
const created = sampleRuleSet();
const db = {
effortRuleSet: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue(created),
},
};
const caller = createManagerCaller(db);
const result = await caller.create({
name: "VFX Standard",
isDefault: false,
rules: [
{
scopeType: "shot",
discipline: "Compositing",
unitMode: "per_frame",
hoursPerUnit: 0.08,
sortOrder: 0,
},
],
});
expect(result.id).toBe("ers_1");
expect(db.effortRuleSet.create).toHaveBeenCalledTimes(1);
// isDefault was false, so updateMany should NOT have been called
expect(db.effortRuleSet.updateMany).not.toHaveBeenCalled();
});
it("unsets other defaults when creating a new default rule set", async () => {
const created = sampleRuleSet({ isDefault: true });
const db = {
effortRuleSet: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
create: vi.fn().mockResolvedValue(created),
},
};
const caller = createManagerCaller(db);
await caller.create({
name: "VFX Standard",
isDefault: true,
rules: [],
});
expect(db.effortRuleSet.updateMany).toHaveBeenCalledWith({
where: { isDefault: true },
data: { isDefault: false },
});
});
});
// ─── update ──────────────────────────────────────────────────────────────────
describe("effortRule.update", () => {
it("updates name and description without touching rules", async () => {
const existing = sampleRuleSet();
const updated = { ...existing, name: "VFX Updated" };
const db = {
effortRuleSet: {
findUnique: vi.fn().mockResolvedValue(existing),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
update: vi.fn().mockResolvedValue(updated),
},
effortRule: {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
};
const caller = createManagerCaller(db);
const result = await caller.update({ id: "ers_1", name: "VFX Updated" });
expect(result.name).toBe("VFX Updated");
// No rules provided, so rule replacement should not happen
expect(db.effortRule.deleteMany).not.toHaveBeenCalled();
expect(db.effortRule.createMany).not.toHaveBeenCalled();
});
it("replaces rules when rules array is provided", async () => {
const existing = sampleRuleSet();
const updated = sampleRuleSet({ name: "Updated" });
const db = {
effortRuleSet: {
findUnique: vi.fn().mockResolvedValue(existing),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
update: vi.fn().mockResolvedValue(updated),
},
effortRule: {
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
createMany: vi.fn().mockResolvedValue({ count: 2 }),
},
};
const caller = createManagerCaller(db);
await caller.update({
id: "ers_1",
rules: [
{ scopeType: "shot", discipline: "Lighting", unitMode: "per_frame", hoursPerUnit: 0.1, sortOrder: 0 },
{ scopeType: "asset", discipline: "Modeling", unitMode: "per_item", hoursPerUnit: 8, sortOrder: 1 },
],
});
expect(db.effortRule.deleteMany).toHaveBeenCalledWith({ where: { ruleSetId: "ers_1" } });
expect(db.effortRule.createMany).toHaveBeenCalledWith({
data: expect.arrayContaining([
expect.objectContaining({ ruleSetId: "ers_1", discipline: "Lighting" }),
expect.objectContaining({ ruleSetId: "ers_1", discipline: "Modeling" }),
]),
});
});
it("throws NOT_FOUND when rule set does not exist", async () => {
const db = {
effortRuleSet: {
findUnique: vi.fn().mockResolvedValue(null),
updateMany: vi.fn(),
update: vi.fn(),
},
effortRule: { deleteMany: vi.fn(), createMany: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(caller.update({ id: "nonexistent", name: "X" })).rejects.toThrow("Effort rule set not found");
});
});
// ─── delete ──────────────────────────────────────────────────────────────────
describe("effortRule.delete", () => {
it("deletes the rule set and returns its id", async () => {
const existing = sampleRuleSet();
const db = {
effortRuleSet: {
findUnique: vi.fn().mockResolvedValue(existing),
delete: vi.fn().mockResolvedValue(existing),
},
};
const caller = createManagerCaller(db);
const result = await caller.delete({ id: "ers_1" });
expect(result).toEqual({ id: "ers_1" });
expect(db.effortRuleSet.delete).toHaveBeenCalledWith({ where: { id: "ers_1" } });
});
it("throws NOT_FOUND when rule set does not exist", async () => {
const db = {
effortRuleSet: {
findUnique: vi.fn().mockResolvedValue(null),
delete: vi.fn(),
},
};
const caller = createManagerCaller(db);
await expect(caller.delete({ id: "nonexistent" })).rejects.toThrow("Effort rule set not found");
});
});
// ─── preview ─────────────────────────────────────────────────────────────────
describe("effortRule.preview", () => {
it("returns expansion result with aggregation", async () => {
const estimate = {
id: "est_1",
baseCurrency: "EUR",
versions: [
{
id: "v_1",
versionNumber: 1,
status: "WORKING",
scopeItems: [
{
id: "si_1",
name: "Shot_001",
scopeType: "shot",
frameCount: 200,
itemCount: null,
unitMode: "per_frame",
sortOrder: 0,
},
],
},
],
};
const ruleSet = sampleRuleSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) },
};
const caller = createControllerCaller(db);
const result = await caller.preview({ estimateId: "est_1", ruleSetId: "ers_1" });
expect(result.lines).toHaveLength(1);
expect(result.scopeItemCount).toBe(1);
expect(result.ruleCount).toBe(1);
expect(result.aggregated).toBeDefined();
});
it("throws NOT_FOUND when estimate does not exist", async () => {
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(null) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(sampleRuleSet()) },
};
const caller = createControllerCaller(db);
await expect(caller.preview({ estimateId: "nope", ruleSetId: "ers_1" })).rejects.toThrow("Estimate not found");
});
it("throws NOT_FOUND when rule set does not exist", async () => {
const estimate = { id: "est_1", versions: [{ id: "v_1", scopeItems: [] }] };
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(null) },
};
const caller = createControllerCaller(db);
await expect(caller.preview({ estimateId: "est_1", ruleSetId: "nope" })).rejects.toThrow("Effort rule set not found");
});
it("throws NOT_FOUND when estimate has no versions", async () => {
const estimate = { id: "est_1", versions: [] };
const ruleSet = sampleRuleSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) },
};
const caller = createControllerCaller(db);
await expect(caller.preview({ estimateId: "est_1", ruleSetId: "ers_1" })).rejects.toThrow("Estimate has no versions");
});
});
// ─── applyRules ──────────────────────────────────────────────────────────────
describe("effortRule.applyRules", () => {
function makeEstimate(versionStatus: string, demandLines: unknown[] = []) {
return {
id: "est_1",
baseCurrency: "EUR",
versions: [
{
id: "v_1",
versionNumber: 1,
status: versionStatus,
scopeItems: [
{
id: "si_1",
name: "Shot_001",
scopeType: "shot",
frameCount: 200,
itemCount: null,
unitMode: "per_frame",
sortOrder: 0,
},
],
demandLines,
},
],
};
}
it("replaces existing demand lines in replace mode", async () => {
const estimate = makeEstimate("WORKING", [{ id: "dl_old" }]);
const ruleSet = sampleRuleSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) },
estimateDemandLine: {
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
createMany: vi.fn().mockResolvedValue({ count: 1 }),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
const result = await caller.applyRules({
estimateId: "est_1",
ruleSetId: "ers_1",
mode: "replace",
});
expect(result.linesGenerated).toBe(1);
expect(db.estimateDemandLine.deleteMany).toHaveBeenCalledWith({
where: { estimateVersionId: "v_1" },
});
expect(db.estimateDemandLine.createMany).toHaveBeenCalledTimes(1);
expect(db.auditLog.create).toHaveBeenCalledTimes(1);
});
it("does not delete existing lines in append mode", async () => {
const estimate = makeEstimate("WORKING", [{ id: "dl_old" }]);
const ruleSet = sampleRuleSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) },
estimateDemandLine: {
deleteMany: vi.fn(),
createMany: vi.fn().mockResolvedValue({ count: 1 }),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
const result = await caller.applyRules({
estimateId: "est_1",
ruleSetId: "ers_1",
mode: "append",
});
expect(result.linesGenerated).toBe(1);
expect(db.estimateDemandLine.deleteMany).not.toHaveBeenCalled();
});
it("rejects applying to a non-WORKING version", async () => {
const estimate = makeEstimate("SUBMITTED");
const ruleSet = sampleRuleSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) },
estimateDemandLine: { deleteMany: vi.fn(), createMany: vi.fn() },
auditLog: { create: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(
caller.applyRules({ estimateId: "est_1", ruleSetId: "ers_1", mode: "replace" }),
).rejects.toThrow("Can only apply rules to a WORKING version");
});
it("throws NOT_FOUND when estimate does not exist", async () => {
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(null) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(sampleRuleSet()) },
estimateDemandLine: { deleteMany: vi.fn(), createMany: vi.fn() },
auditLog: { create: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(
caller.applyRules({ estimateId: "nope", ruleSetId: "ers_1", mode: "replace" }),
).rejects.toThrow("Estimate not found");
});
it("throws NOT_FOUND when estimate has no versions", async () => {
const estimate = { id: "est_1", baseCurrency: "EUR", versions: [] };
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(sampleRuleSet()) },
estimateDemandLine: { deleteMany: vi.fn(), createMany: vi.fn() },
auditLog: { create: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(
caller.applyRules({ estimateId: "est_1", ruleSetId: "ers_1", mode: "replace" }),
).rejects.toThrow("Estimate has no versions");
});
it("creates demand lines with correct metadata shape", async () => {
const estimate = makeEstimate("WORKING");
const ruleSet = sampleRuleSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
effortRuleSet: { findUnique: vi.fn().mockResolvedValue(ruleSet) },
estimateDemandLine: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
createMany: vi.fn().mockResolvedValue({ count: 1 }),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
await caller.applyRules({ estimateId: "est_1", ruleSetId: "ers_1", mode: "replace" });
const createManyArg = db.estimateDemandLine.createMany.mock.calls[0][0];
const firstLine = createManyArg.data[0];
expect(firstLine.estimateVersionId).toBe("v_1");
expect(firstLine.lineType).toBe("LABOR");
expect(firstLine.currency).toBe("EUR");
expect(firstLine.costRateCents).toBe(0);
expect(firstLine.billRateCents).toBe(0);
expect(firstLine.metadata).toEqual(
expect.objectContaining({
effortRule: expect.objectContaining({
ruleSetId: "ers_1",
ruleSetName: "VFX Standard",
}),
}),
);
});
});
@@ -0,0 +1,443 @@
import { SystemRole } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { entitlementRouter } from "../router/entitlement.js";
import { createCallerFactory } from "../trpc.js";
// Mock @planarchy/db to provide the enums used in the router
vi.mock("@planarchy/db", () => ({
VacationType: { ANNUAL: "ANNUAL", SICK: "SICK", OTHER: "OTHER", PUBLIC_HOLIDAY: "PUBLIC_HOLIDAY" },
VacationStatus: { APPROVED: "APPROVED", PENDING: "PENDING", REJECTED: "REJECTED" },
}));
const createCaller = createCallerFactory(entitlementRouter);
// ── Caller factories ─────────────────────────────────────────────────────────
function createProtectedCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
});
}
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "mgr@example.com", name: "Manager", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_mgr",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
function createAdminCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "admin@example.com", name: "Admin", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_admin",
systemRole: SystemRole.ADMIN,
permissionOverrides: null,
},
});
}
// ── Sample data ──────────────────────────────────────────────────────────────
function sampleEntitlement(overrides: Record<string, unknown> = {}) {
return {
id: "ent_1",
resourceId: "res_1",
year: 2026,
entitledDays: 30,
carryoverDays: 2,
usedDays: 5,
pendingDays: 3,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
// ─── getBalance ──────────────────────────────────────────────────────────────
describe("entitlement.getBalance", () => {
it("returns vacation balance for a resource and year", async () => {
const entitlement = sampleEntitlement();
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
expect(result.year).toBe(2026);
expect(result.resourceId).toBe("res_1");
expect(result.entitledDays).toBe(30);
expect(result.remainingDays).toBe(22); // 30 - 5 - 3
expect(result).toHaveProperty("sickDays");
});
it("creates entitlement with carryover when none exists", async () => {
const prevEntitlement = sampleEntitlement({
id: "ent_prev",
year: 2025,
entitledDays: 28,
usedDays: 20,
pendingDays: 0,
});
const createdEntitlement = sampleEntitlement({
year: 2026,
entitledDays: 36, // 28 default + 8 carryover
carryoverDays: 8,
usedDays: 0,
pendingDays: 0,
});
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
vacationEntitlement: {
findUnique: vi
.fn()
.mockResolvedValueOnce(null) // current year not found
.mockResolvedValueOnce(prevEntitlement), // previous year found
create: vi.fn().mockResolvedValue(createdEntitlement),
update: vi.fn().mockResolvedValue(createdEntitlement),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
expect(result.entitledDays).toBe(36);
expect(result.carryoverDays).toBe(8);
expect(db.vacationEntitlement.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
resourceId: "res_1",
year: 2026,
carryoverDays: 8,
}),
}),
);
});
it("uses default of 28 days when no system settings exist", async () => {
const entitlement = sampleEntitlement({ entitledDays: 28, carryoverDays: 0 });
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
expect(result.entitledDays).toBe(28);
});
it("counts sick days separately", async () => {
const entitlement = sampleEntitlement({ usedDays: 0, pendingDays: 0 });
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi
.fn()
// First call: balance-type vacations (for syncEntitlement)
.mockResolvedValueOnce([])
// Second call: sick days
.mockResolvedValueOnce([
{
startDate: new Date("2026-03-10"),
endDate: new Date("2026-03-12"),
isHalfDay: false,
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
expect(result.sickDays).toBe(3);
});
});
// ─── get ─────────────────────────────────────────────────────────────────────
describe("entitlement.get", () => {
it("returns existing entitlement (manager role)", async () => {
const entitlement = sampleEntitlement();
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement),
},
};
const caller = createManagerCaller(db);
const result = await caller.get({ resourceId: "res_1", year: 2026 });
expect(result.id).toBe("ent_1");
expect(result.entitledDays).toBe(30);
});
it("rejects access by a regular user (FORBIDDEN)", async () => {
const db = {
systemSettings: {
findUnique: vi.fn(),
},
vacationEntitlement: {
findUnique: vi.fn(),
},
};
const caller = createProtectedCaller(db);
await expect(caller.get({ resourceId: "res_1", year: 2026 })).rejects.toThrow();
});
});
// ─── set ─────────────────────────────────────────────────────────────────────
describe("entitlement.set", () => {
it("updates existing entitlement", async () => {
const existing = sampleEntitlement();
const updated = { ...existing, entitledDays: 35 };
const db = {
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(existing),
update: vi.fn().mockResolvedValue(updated),
create: vi.fn(),
},
};
const caller = createManagerCaller(db);
const result = await caller.set({
resourceId: "res_1",
year: 2026,
entitledDays: 35,
});
expect(result.entitledDays).toBe(35);
expect(db.vacationEntitlement.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "ent_1" },
data: { entitledDays: 35 },
}),
);
expect(db.vacationEntitlement.create).not.toHaveBeenCalled();
});
it("creates new entitlement when none exists", async () => {
const created = sampleEntitlement({ entitledDays: 30, carryoverDays: 0 });
const db = {
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(null),
update: vi.fn(),
create: vi.fn().mockResolvedValue(created),
},
};
const caller = createManagerCaller(db);
const result = await caller.set({
resourceId: "res_1",
year: 2026,
entitledDays: 30,
});
expect(result.entitledDays).toBe(30);
expect(db.vacationEntitlement.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
resourceId: "res_1",
year: 2026,
entitledDays: 30,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
}),
}),
);
expect(db.vacationEntitlement.update).not.toHaveBeenCalled();
});
});
// ─── bulkSet ─────────────────────────────────────────────────────────────────
describe("entitlement.bulkSet", () => {
it("upserts entitlements for all active resources (admin role)", async () => {
const resources = [{ id: "res_1" }, { id: "res_2" }, { id: "res_3" }];
const db = {
resource: {
findMany: vi.fn().mockResolvedValue(resources),
},
vacationEntitlement: {
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
},
};
const caller = createAdminCaller(db);
const result = await caller.bulkSet({
year: 2026,
entitledDays: 30,
});
expect(result.updated).toBe(3);
expect(db.vacationEntitlement.upsert).toHaveBeenCalledTimes(3);
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ isActive: true }),
}),
);
});
it("filters by resourceIds when provided", async () => {
const resources = [{ id: "res_1" }];
const db = {
resource: {
findMany: vi.fn().mockResolvedValue(resources),
},
vacationEntitlement: {
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
},
};
const caller = createAdminCaller(db);
await caller.bulkSet({
year: 2026,
entitledDays: 30,
resourceIds: ["res_1"],
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
isActive: true,
id: { in: ["res_1"] },
}),
}),
);
});
it("rejects bulk set by a manager (admin only)", async () => {
const db = {
resource: { findMany: vi.fn() },
vacationEntitlement: { upsert: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(
caller.bulkSet({ year: 2026, entitledDays: 30 }),
).rejects.toThrow();
});
});
// ─── getYearSummary ──────────────────────────────────────────────────────────
describe("entitlement.getYearSummary", () => {
it("returns summary for all active resources (manager role)", async () => {
const resources = [
{ id: "res_1", displayName: "Alice", eid: "alice", chapter: "VFX" },
{ id: "res_2", displayName: "Bob", eid: "bob", chapter: "Animation" },
];
const entitlement = sampleEntitlement({ usedDays: 5, pendingDays: 2 });
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
resource: {
findMany: vi.fn().mockResolvedValue(resources),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createManagerCaller(db);
const result = await caller.getYearSummary({ year: 2026 });
expect(result).toHaveLength(2);
expect(result[0]).toHaveProperty("resourceId");
expect(result[0]).toHaveProperty("displayName");
expect(result[0]).toHaveProperty("remainingDays");
});
it("filters by chapter when provided", async () => {
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
resource: {
findMany: vi.fn().mockResolvedValue([]),
},
vacationEntitlement: {
findUnique: vi.fn(),
update: vi.fn(),
},
vacation: {
findMany: vi.fn(),
},
};
const caller = createManagerCaller(db);
await caller.getYearSummary({ year: 2026, chapter: "VFX" });
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
isActive: true,
chapter: "VFX",
}),
}),
);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,629 @@
import { SystemRole } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { experienceMultiplierRouter } from "../router/experience-multiplier.js";
import { createCallerFactory } from "../trpc.js";
// Mock the engine — we focus on the router/DB layer, not the pure engine logic
vi.mock("@planarchy/engine", () => ({
applyExperienceMultipliers: vi.fn().mockReturnValue({
adjustedCostRateCents: 12000,
adjustedBillRateCents: 18000,
adjustedHours: 110,
appliedRules: ["Rate multiplied (chapter=VFX): cost x1.2, bill x1.2"],
}),
applyExperienceMultipliersBatch: vi.fn().mockReturnValue({
results: [
{
adjustedCostRateCents: 12000,
adjustedBillRateCents: 18000,
adjustedHours: 110,
appliedRules: ["Rate multiplied (chapter=VFX): cost x1.2, bill x1.2"],
},
],
totalOriginalHours: 100,
totalAdjustedHours: 110,
linesAdjusted: 1,
}),
}));
const createCaller = createCallerFactory(experienceMultiplierRouter);
function createControllerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "ctrl@example.com", name: "Controller", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_ctrl",
systemRole: SystemRole.CONTROLLER,
permissionOverrides: null,
},
});
}
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "mgr@example.com", name: "Manager", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_mgr",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
// ── Sample data factories ────────────────────────────────────────────────────
function sampleMultiplierSet(overrides: Record<string, unknown> = {}) {
return {
id: "ems_1",
name: "Standard Multipliers",
description: null,
isDefault: true,
createdAt: new Date(),
updatedAt: new Date(),
rules: [
{
id: "emr_1",
multiplierSetId: "ems_1",
chapter: "VFX",
location: null,
level: null,
costMultiplier: 1.2,
billMultiplier: 1.2,
shoringRatio: null,
additionalEffortRatio: null,
description: null,
sortOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
},
],
...overrides,
};
}
function sampleDemandLine(overrides: Record<string, unknown> = {}) {
return {
id: "dl_1",
name: "Compositing Senior",
chapter: "VFX",
costRateCents: 10000,
billRateCents: 15000,
hours: 100,
costTotalCents: 1000000,
priceTotalCents: 1500000,
metadata: null,
staffingAttributes: null,
createdAt: new Date(),
...overrides,
};
}
// ─── list ────────────────────────────────────────────────────────────────────
describe("experienceMultiplier.list", () => {
it("returns sets ordered by isDefault desc, name asc", async () => {
const sets = [
sampleMultiplierSet(),
sampleMultiplierSet({ id: "ems_2", name: "Custom", isDefault: false }),
];
const db = {
experienceMultiplierSet: {
findMany: vi.fn().mockResolvedValue(sets),
},
};
const caller = createControllerCaller(db);
const result = await caller.list();
expect(result).toHaveLength(2);
expect(db.experienceMultiplierSet.findMany).toHaveBeenCalledWith(
expect.objectContaining({
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
}),
);
});
});
// ─── getById ─────────────────────────────────────────────────────────────────
describe("experienceMultiplier.getById", () => {
it("returns the multiplier set when found", async () => {
const set = sampleMultiplierSet();
const db = {
experienceMultiplierSet: {
findUnique: vi.fn().mockResolvedValue(set),
},
};
const caller = createControllerCaller(db);
const result = await caller.getById({ id: "ems_1" });
expect(result.id).toBe("ems_1");
expect(result.rules).toHaveLength(1);
});
it("throws NOT_FOUND when set does not exist", async () => {
const db = {
experienceMultiplierSet: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createControllerCaller(db);
await expect(caller.getById({ id: "nonexistent" })).rejects.toThrow(
"Experience multiplier set not found",
);
});
});
// ─── create ──────────────────────────────────────────────────────────────────
describe("experienceMultiplier.create", () => {
it("creates a set with rules", async () => {
const created = sampleMultiplierSet();
const db = {
experienceMultiplierSet: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue(created),
},
};
const caller = createManagerCaller(db);
const result = await caller.create({
name: "Standard Multipliers",
isDefault: false,
rules: [
{
chapter: "VFX",
costMultiplier: 1.2,
billMultiplier: 1.2,
sortOrder: 0,
},
],
});
expect(result.id).toBe("ems_1");
expect(db.experienceMultiplierSet.create).toHaveBeenCalledTimes(1);
// isDefault was false, so updateMany should NOT have been called
expect(db.experienceMultiplierSet.updateMany).not.toHaveBeenCalled();
});
it("unsets other defaults when creating a new default set", async () => {
const created = sampleMultiplierSet({ isDefault: true });
const db = {
experienceMultiplierSet: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
create: vi.fn().mockResolvedValue(created),
},
};
const caller = createManagerCaller(db);
await caller.create({
name: "Standard Multipliers",
isDefault: true,
rules: [],
});
expect(db.experienceMultiplierSet.updateMany).toHaveBeenCalledWith({
where: { isDefault: true },
data: { isDefault: false },
});
});
});
// ─── update ──────────────────────────────────────────────────────────────────
describe("experienceMultiplier.update", () => {
it("updates name and description without touching rules", async () => {
const existing = sampleMultiplierSet();
const updated = { ...existing, name: "Updated Name" };
const db = {
experienceMultiplierSet: {
findUnique: vi.fn().mockResolvedValue(existing),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
update: vi.fn().mockResolvedValue(updated),
},
experienceMultiplierRule: {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
};
const caller = createManagerCaller(db);
const result = await caller.update({ id: "ems_1", name: "Updated Name" });
expect(result.name).toBe("Updated Name");
// No rules provided, so rule replacement should not happen
expect(db.experienceMultiplierRule.deleteMany).not.toHaveBeenCalled();
expect(db.experienceMultiplierRule.createMany).not.toHaveBeenCalled();
});
it("replaces rules when rules array is provided", async () => {
const existing = sampleMultiplierSet();
const updated = sampleMultiplierSet({ name: "Updated" });
const db = {
experienceMultiplierSet: {
findUnique: vi.fn().mockResolvedValue(existing),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
update: vi.fn().mockResolvedValue(updated),
},
experienceMultiplierRule: {
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
createMany: vi.fn().mockResolvedValue({ count: 2 }),
},
};
const caller = createManagerCaller(db);
await caller.update({
id: "ems_1",
rules: [
{ chapter: "VFX", costMultiplier: 1.3, billMultiplier: 1.3, sortOrder: 0 },
{ location: "India", costMultiplier: 0.7, billMultiplier: 0.9, shoringRatio: 0.5, sortOrder: 1 },
],
});
expect(db.experienceMultiplierRule.deleteMany).toHaveBeenCalledWith({
where: { multiplierSetId: "ems_1" },
});
expect(db.experienceMultiplierRule.createMany).toHaveBeenCalledWith({
data: expect.arrayContaining([
expect.objectContaining({ multiplierSetId: "ems_1", chapter: "VFX" }),
expect.objectContaining({ multiplierSetId: "ems_1", location: "India" }),
]),
});
});
it("throws NOT_FOUND when set does not exist", async () => {
const db = {
experienceMultiplierSet: {
findUnique: vi.fn().mockResolvedValue(null),
updateMany: vi.fn(),
update: vi.fn(),
},
experienceMultiplierRule: { deleteMany: vi.fn(), createMany: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(caller.update({ id: "nonexistent", name: "X" })).rejects.toThrow(
"Experience multiplier set not found",
);
});
});
// ─── delete ──────────────────────────────────────────────────────────────────
describe("experienceMultiplier.delete", () => {
it("deletes the set and returns its id", async () => {
const existing = sampleMultiplierSet();
const db = {
experienceMultiplierSet: {
findUnique: vi.fn().mockResolvedValue(existing),
delete: vi.fn().mockResolvedValue(existing),
},
};
const caller = createManagerCaller(db);
const result = await caller.delete({ id: "ems_1" });
expect(result).toEqual({ id: "ems_1" });
expect(db.experienceMultiplierSet.delete).toHaveBeenCalledWith({ where: { id: "ems_1" } });
});
it("throws NOT_FOUND when set does not exist", async () => {
const db = {
experienceMultiplierSet: {
findUnique: vi.fn().mockResolvedValue(null),
delete: vi.fn(),
},
};
const caller = createManagerCaller(db);
await expect(caller.delete({ id: "nonexistent" })).rejects.toThrow(
"Experience multiplier set not found",
);
});
});
// ─── preview ─────────────────────────────────────────────────────────────────
describe("experienceMultiplier.preview", () => {
function makeEstimate(demandLines: unknown[] = [sampleDemandLine()]) {
return {
id: "est_1",
versions: [
{
id: "v_1",
versionNumber: 1,
status: "WORKING",
demandLines,
},
],
};
}
it("returns preview results with summary stats", async () => {
const estimate = makeEstimate();
const multiplierSet = sampleMultiplierSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) },
};
const caller = createControllerCaller(db);
const result = await caller.preview({ estimateId: "est_1", multiplierSetId: "ems_1" });
expect(result.previews).toHaveLength(1);
expect(result.demandLineCount).toBe(1);
expect(result.multiplierSetName).toBe("Standard Multipliers");
expect(result.ruleCount).toBe(1);
// The mock returns adjusted values different from original, so hasChanges = true
expect(result.previews[0].hasChanges).toBe(true);
expect(result.previews[0].originalCostRateCents).toBe(10000);
expect(result.previews[0].adjustedCostRateCents).toBe(12000);
expect(result.linesChanged).toBe(1);
});
it("throws NOT_FOUND when estimate does not exist", async () => {
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(null) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(sampleMultiplierSet()) },
};
const caller = createControllerCaller(db);
await expect(caller.preview({ estimateId: "nope", multiplierSetId: "ems_1" })).rejects.toThrow(
"Estimate not found",
);
});
it("throws NOT_FOUND when multiplier set does not exist", async () => {
const estimate = makeEstimate();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(null) },
};
const caller = createControllerCaller(db);
await expect(caller.preview({ estimateId: "est_1", multiplierSetId: "nope" })).rejects.toThrow(
"Experience multiplier set not found",
);
});
it("throws NOT_FOUND when estimate has no versions", async () => {
const estimate = { id: "est_1", versions: [] };
const multiplierSet = sampleMultiplierSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) },
};
const caller = createControllerCaller(db);
await expect(caller.preview({ estimateId: "est_1", multiplierSetId: "ems_1" })).rejects.toThrow(
"Estimate has no versions",
);
});
it("reports no changes when rates are unchanged", async () => {
// Import the mock to override for this test
const { applyExperienceMultipliers } = await import("@planarchy/engine");
const mockFn = applyExperienceMultipliers as ReturnType<typeof vi.fn>;
mockFn.mockReturnValueOnce({
adjustedCostRateCents: 10000,
adjustedBillRateCents: 15000,
adjustedHours: 100,
appliedRules: ["No matching rule found -- values unchanged."],
});
const estimate = makeEstimate();
const multiplierSet = sampleMultiplierSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) },
};
const caller = createControllerCaller(db);
const result = await caller.preview({ estimateId: "est_1", multiplierSetId: "ems_1" });
expect(result.linesChanged).toBe(0);
expect(result.previews[0].hasChanges).toBe(false);
});
});
// ─── applyRules ──────────────────────────────────────────────────────────────
describe("experienceMultiplier.applyRules", () => {
function makeEstimate(versionStatus: string, demandLines: unknown[] = [sampleDemandLine()]) {
return {
id: "est_1",
versions: [
{
id: "v_1",
versionNumber: 1,
status: versionStatus,
demandLines,
},
],
};
}
it("updates demand lines with adjusted rates and creates audit log", async () => {
const estimate = makeEstimate("WORKING");
const multiplierSet = sampleMultiplierSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) },
estimateDemandLine: {
update: vi.fn().mockResolvedValue({}),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
const result = await caller.applyRules({
estimateId: "est_1",
multiplierSetId: "ems_1",
});
expect(result.linesUpdated).toBe(1);
expect(result.totalOriginalHours).toBe(100);
expect(result.totalAdjustedHours).toBe(110);
expect(db.estimateDemandLine.update).toHaveBeenCalledTimes(1);
// Verify the update call contains the adjusted rates and metadata
const updateCall = db.estimateDemandLine.update.mock.calls[0][0];
expect(updateCall.where).toEqual({ id: "dl_1" });
expect(updateCall.data.costRateCents).toBe(12000);
expect(updateCall.data.billRateCents).toBe(18000);
expect(updateCall.data.hours).toBe(110);
expect(updateCall.data.costTotalCents).toBe(Math.round(12000 * 110));
expect(updateCall.data.priceTotalCents).toBe(Math.round(18000 * 110));
expect(updateCall.data.metadata.experienceMultiplier).toEqual(
expect.objectContaining({
setId: "ems_1",
setName: "Standard Multipliers",
originalCostRateCents: 10000,
originalBillRateCents: 15000,
originalHours: 100,
}),
);
// Audit log should be created
expect(db.auditLog.create).toHaveBeenCalledTimes(1);
const auditCall = db.auditLog.create.mock.calls[0][0];
expect(auditCall.data.entityType).toBe("Estimate");
expect(auditCall.data.entityId).toBe("est_1");
expect(auditCall.data.action).toBe("UPDATE");
expect(auditCall.data.userId).toBe("user_mgr");
});
it("skips unchanged lines (no update call)", async () => {
const { applyExperienceMultipliersBatch } = await import("@planarchy/engine");
const mockFn = applyExperienceMultipliersBatch as ReturnType<typeof vi.fn>;
mockFn.mockReturnValueOnce({
results: [
{
adjustedCostRateCents: 10000,
adjustedBillRateCents: 15000,
adjustedHours: 100,
appliedRules: ["No matching rule found."],
},
],
totalOriginalHours: 100,
totalAdjustedHours: 100,
linesAdjusted: 0,
});
const estimate = makeEstimate("WORKING");
const multiplierSet = sampleMultiplierSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) },
estimateDemandLine: {
update: vi.fn(),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
const result = await caller.applyRules({
estimateId: "est_1",
multiplierSetId: "ems_1",
});
expect(result.linesUpdated).toBe(0);
expect(db.estimateDemandLine.update).not.toHaveBeenCalled();
});
it("rejects applying to a non-WORKING version", async () => {
const estimate = makeEstimate("SUBMITTED");
const multiplierSet = sampleMultiplierSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) },
estimateDemandLine: { update: vi.fn() },
auditLog: { create: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(
caller.applyRules({ estimateId: "est_1", multiplierSetId: "ems_1" }),
).rejects.toThrow("Can only apply multipliers to a WORKING version");
});
it("throws NOT_FOUND when estimate does not exist", async () => {
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(null) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(sampleMultiplierSet()) },
estimateDemandLine: { update: vi.fn() },
auditLog: { create: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(
caller.applyRules({ estimateId: "nope", multiplierSetId: "ems_1" }),
).rejects.toThrow("Estimate not found");
});
it("throws NOT_FOUND when multiplier set does not exist", async () => {
const estimate = makeEstimate("WORKING");
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(null) },
estimateDemandLine: { update: vi.fn() },
auditLog: { create: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(
caller.applyRules({ estimateId: "est_1", multiplierSetId: "nope" }),
).rejects.toThrow("Experience multiplier set not found");
});
it("throws NOT_FOUND when estimate has no versions", async () => {
const estimate = { id: "est_1", versions: [] };
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(sampleMultiplierSet()) },
estimateDemandLine: { update: vi.fn() },
auditLog: { create: vi.fn() },
};
const caller = createManagerCaller(db);
await expect(
caller.applyRules({ estimateId: "est_1", multiplierSetId: "ems_1" }),
).rejects.toThrow("Estimate has no versions");
});
it("preserves existing metadata when updating demand lines", async () => {
const lineWithMetadata = sampleDemandLine({
metadata: { someField: "existing-value", anotherField: 42 },
});
const estimate = makeEstimate("WORKING", [lineWithMetadata]);
const multiplierSet = sampleMultiplierSet();
const db = {
estimate: { findUnique: vi.fn().mockResolvedValue(estimate) },
experienceMultiplierSet: { findUnique: vi.fn().mockResolvedValue(multiplierSet) },
estimateDemandLine: {
update: vi.fn().mockResolvedValue({}),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
await caller.applyRules({ estimateId: "est_1", multiplierSetId: "ems_1" });
const updateCall = db.estimateDemandLine.update.mock.calls[0][0];
// Existing metadata fields should be preserved alongside experienceMultiplier
expect(updateCall.data.metadata.someField).toBe("existing-value");
expect(updateCall.data.metadata.anotherField).toBe(42);
expect(updateCall.data.metadata.experienceMultiplier).toBeDefined();
});
});
@@ -0,0 +1,263 @@
import { SystemRole } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { notificationRouter } from "../router/notification.js";
import { createCallerFactory } from "../trpc.js";
// Mock the SSE event bus — we don't test real event emission here
vi.mock("../sse/event-bus.js", () => ({
emitNotificationCreated: vi.fn(),
}));
const createCaller = createCallerFactory(notificationRouter);
// ── Caller factories ─────────────────────────────────────────────────────────
function createProtectedCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
});
}
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "mgr@example.com", name: "Manager", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_mgr",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
// ── Sample data ──────────────────────────────────────────────────────────────
function sampleNotification(overrides: Record<string, unknown> = {}) {
return {
id: "notif_1",
userId: "user_1",
type: "VACATION_APPROVED",
title: "Vacation approved",
body: null,
entityId: null,
entityType: null,
readAt: null,
createdAt: new Date("2026-01-15T10:00:00Z"),
...overrides,
};
}
/** DB mock that resolves the session user for resolveUserId */
function withUserLookup(db: Record<string, unknown>, userId = "user_1") {
return {
user: {
findUnique: vi.fn().mockResolvedValue({ id: userId }),
},
...db,
};
}
// ─── list ────────────────────────────────────────────────────────────────────
describe("notification.list", () => {
it("returns notifications for the current user", async () => {
const notifications = [sampleNotification(), sampleNotification({ id: "notif_2" })];
const db = withUserLookup({
notification: {
findMany: vi.fn().mockResolvedValue(notifications),
},
});
const caller = createProtectedCaller(db);
const result = await caller.list({ limit: 50 });
expect(result).toHaveLength(2);
expect(db.notification.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ userId: "user_1" }),
orderBy: { createdAt: "desc" },
take: 50,
}),
);
});
it("filters to unread only when unreadOnly is true", async () => {
const db = withUserLookup({
notification: {
findMany: vi.fn().mockResolvedValue([]),
},
});
const caller = createProtectedCaller(db);
await caller.list({ unreadOnly: true, limit: 10 });
expect(db.notification.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ userId: "user_1", readAt: null }),
take: 10,
}),
);
});
});
// ─── unreadCount ─────────────────────────────────────────────────────────────
describe("notification.unreadCount", () => {
it("returns count of unread notifications", async () => {
const db = withUserLookup({
notification: {
count: vi.fn().mockResolvedValue(5),
},
});
const caller = createProtectedCaller(db);
const result = await caller.unreadCount();
expect(result).toBe(5);
expect(db.notification.count).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user_1", readAt: null },
}),
);
});
});
// ─── markRead ────────────────────────────────────────────────────────────────
describe("notification.markRead", () => {
it("marks a single notification as read when id is provided", async () => {
const db = withUserLookup({
notification: {
update: vi.fn().mockResolvedValue(sampleNotification({ readAt: new Date() })),
updateMany: vi.fn(),
},
});
const caller = createProtectedCaller(db);
await caller.markRead({ id: "notif_1" });
expect(db.notification.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "notif_1", userId: "user_1" },
data: expect.objectContaining({ readAt: expect.any(Date) }),
}),
);
expect(db.notification.updateMany).not.toHaveBeenCalled();
});
it("marks all unread notifications as read when no id is provided", async () => {
const db = withUserLookup({
notification: {
update: vi.fn(),
updateMany: vi.fn().mockResolvedValue({ count: 3 }),
},
});
const caller = createProtectedCaller(db);
await caller.markRead({});
expect(db.notification.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user_1", readAt: null },
data: expect.objectContaining({ readAt: expect.any(Date) }),
}),
);
expect(db.notification.update).not.toHaveBeenCalled();
});
});
// ─── create ──────────────────────────────────────────────────────────────────
describe("notification.create", () => {
it("creates a notification (manager role)", async () => {
const created = sampleNotification({ userId: "target_user" });
const db = withUserLookup(
{
notification: {
create: vi.fn().mockResolvedValue(created),
},
},
"user_mgr",
);
const caller = createManagerCaller(db);
const result = await caller.create({
userId: "target_user",
type: "INFO",
title: "Test notification",
});
expect(result.id).toBe("notif_1");
expect(db.notification.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
userId: "target_user",
type: "INFO",
title: "Test notification",
}),
}),
);
});
it("creates a notification with optional fields", async () => {
const created = sampleNotification({
userId: "target_user",
body: "Details here",
entityId: "proj_1",
entityType: "PROJECT",
});
const db = withUserLookup(
{
notification: {
create: vi.fn().mockResolvedValue(created),
},
},
"user_mgr",
);
const caller = createManagerCaller(db);
await caller.create({
userId: "target_user",
type: "INFO",
title: "Test",
body: "Details here",
entityId: "proj_1",
entityType: "PROJECT",
});
expect(db.notification.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
body: "Details here",
entityId: "proj_1",
entityType: "PROJECT",
}),
}),
);
});
it("rejects creation by a regular user (FORBIDDEN)", async () => {
const db = withUserLookup({
notification: {
create: vi.fn(),
},
});
const caller = createProtectedCaller(db);
await expect(
caller.create({ userId: "target", type: "INFO", title: "Nope" }),
).rejects.toThrow();
});
});
@@ -0,0 +1,523 @@
import { SystemRole } from "@planarchy/shared";
import { ResourceType } from "@planarchy/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@planarchy/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@planarchy/application")>();
return {
...actual,
isChargeabilityActualBooking: actual.isChargeabilityActualBooking,
isChargeabilityRelevantProject: actual.isChargeabilityRelevantProject,
listAssignmentBookings: vi.fn(),
recomputeResourceValueScores: vi.fn(),
};
});
import { listAssignmentBookings } from "@planarchy/application";
import { resourceRouter } from "../router/resource.js";
import { createCallerFactory } from "../trpc.js";
const createCaller = createCallerFactory(resourceRouter);
function createControllerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "controller@example.com", name: "Controller", image: null, role: "CONTROLLER" },
expires: "2026-03-14T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_controller",
systemRole: SystemRole.CONTROLLER,
permissionOverrides: null,
},
});
}
function createProtectedCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null, role: "USER" },
expires: "2026-03-14T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
});
}
describe("resource router", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("filters proposed utilization rows unless explicitly requested", async () => {
const resource = {
id: "resource_1",
eid: "E-001",
displayName: "Alice",
email: "alice@example.com",
chapter: "CGI",
lcrCents: 5000,
ucrCents: 9000,
currency: "EUR",
chargeabilityTarget: 80,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
skills: [],
dynamicFields: {},
blueprintId: null,
isActive: true,
createdAt: new Date("2026-03-01"),
updatedAt: new Date("2026-03-01"),
roleId: null,
portfolioUrl: null,
postalCode: null,
federalState: null,
valueScore: null,
valueScoreBreakdown: null,
valueScoreUpdatedAt: null,
userId: null,
};
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([resource]),
},
};
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "assignment_confirmed",
projectId: "project_1",
resourceId: "resource_1",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-06T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "CONFIRMED",
project: {
id: "project_1",
name: "Project 1",
shortCode: "P1",
status: "ACTIVE",
orderType: "CLIENT",
dynamicFields: null,
},
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
},
{
id: "assignment_proposed",
projectId: "project_2",
resourceId: "resource_1",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-06T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "PROPOSED",
project: {
id: "project_2",
name: "Project 2",
shortCode: "P2",
status: "ACTIVE",
orderType: "CLIENT",
dynamicFields: null,
},
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
},
]);
const caller = createControllerCaller(db);
const strict = await caller.listWithUtilization({
startDate: "2026-03-02T00:00:00.000Z",
endDate: "2026-03-08T00:00:00.000Z",
});
const withProposed = await caller.listWithUtilization({
startDate: "2026-03-02T00:00:00.000Z",
endDate: "2026-03-08T00:00:00.000Z",
includeProposed: true,
});
expect(strict[0]).toMatchObject({
bookingCount: 1,
bookedHours: 20,
utilizationPercent: 50,
});
expect(withProposed[0]).toMatchObject({
bookingCount: 2,
bookedHours: 40,
utilizationPercent: 100,
});
});
it("uses a composite displayName/id cursor for stable pagination", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{ id: "2", displayName: "Alex", eid: "E-002", email: "alex2@example.com" },
{ id: "3", displayName: "Bea", eid: "E-003", email: "bea@example.com" },
]),
count: vi.fn().mockResolvedValue(3),
},
};
const caller = createProtectedCaller(db);
const result = await caller.list({
limit: 1,
cursor: JSON.stringify({ displayName: "Alex", id: "1" }),
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
AND: expect.arrayContaining([
{
OR: [
{ displayName: { gt: "Alex" } },
{ displayName: "Alex", id: { gt: "1" } },
],
},
]),
}),
orderBy: [{ displayName: "asc" }, { id: "asc" }],
take: 2,
}),
);
expect(result.nextCursor).toBe(JSON.stringify({ displayName: "Alex", id: "2" }));
});
it("resolves resource ownership server-side without exposing linked user email", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
eid: "E-001",
displayName: "Alice",
email: "alice@example.com",
chapter: "CGI",
lcrCents: 5000,
ucrCents: 9000,
currency: "EUR",
chargeabilityTarget: 80,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
skills: [],
dynamicFields: {},
blueprint: null,
blueprintId: null,
isActive: true,
createdAt: new Date("2026-03-01"),
updatedAt: new Date("2026-03-01"),
resourceRoles: [],
areaRole: null,
portfolioUrl: null,
roleId: null,
aiSummary: null,
aiSummaryUpdatedAt: null,
skillMatrixUpdatedAt: null,
valueScore: null,
valueScoreBreakdown: null,
valueScoreUpdatedAt: null,
userId: "user_1",
}),
findMany: vi.fn().mockResolvedValue([]),
},
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getById({ id: "resource_1" });
expect(result).toMatchObject({
id: "resource_1",
displayName: "Alice",
eid: "E-001",
email: "alice@example.com",
isOwnedByCurrentUser: true,
});
expect(result).not.toHaveProperty("user");
expect(db.resource.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
include: expect.not.objectContaining({
user: expect.anything(),
}),
}),
);
});
it("counts imported TBD draft projects in chargeability stats only when proposed work is enabled", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_1",
eid: "E-001",
displayName: "Alice",
chapter: "CGI",
chargeabilityTarget: 80,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
},
]),
},
};
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "assignment_tbd",
projectId: "project_tbd",
resourceId: "resource_1",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-06T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "PROPOSED",
project: {
id: "project_tbd",
name: "TBD Project",
shortCode: "TBD-P1",
status: "DRAFT",
orderType: "CLIENT",
dynamicFields: { dispoImport: { isTbd: true } },
},
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
},
]);
const caller = createControllerCaller(db);
const strict = await caller.getChargeabilityStats({});
const withProposed = await caller.getChargeabilityStats({ includeProposed: true });
expect(strict[0]?.actualChargeability).toBe(0);
expect(strict[0]?.expectedChargeability).toBeGreaterThan(0);
expect(withProposed[0]?.actualChargeability).toBeGreaterThan(strict[0]?.actualChargeability ?? 0);
expect(withProposed[0]?.expectedChargeability).toBe(strict[0]?.expectedChargeability);
});
it("applies country filters including explicit no-country toggle", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
};
const caller = createProtectedCaller(db);
await caller.list({
countryIds: ["country_de", "country_us"],
includeWithoutCountry: false,
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
AND: expect.arrayContaining([
{ isActive: true },
{ countryId: { in: ["country_de", "country_us"] } },
]),
},
}),
);
});
it("excludes disabled countries while leaving all others visible", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
};
const caller = createProtectedCaller(db);
await caller.list({
excludedCountryIds: ["country_fr"],
includeWithoutCountry: true,
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
AND: expect.arrayContaining([
{ isActive: true },
{ NOT: { countryId: { in: ["country_fr"] } } },
]),
},
}),
);
});
it("applies resource type filters while keeping unspecified rows when requested", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
};
const caller = createProtectedCaller(db);
await caller.list({
resourceTypes: [ResourceType.EMPLOYEE, ResourceType.INTERN],
includeWithoutResourceType: true,
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
AND: expect.arrayContaining([
{ isActive: true },
{
OR: [
{ resourceType: { in: [ResourceType.EMPLOYEE, ResourceType.INTERN] } },
{ resourceType: null },
],
},
]),
},
}),
);
});
it("excludes disabled resource types while leaving all others visible", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
};
const caller = createProtectedCaller(db);
await caller.list({
excludedResourceTypes: [ResourceType.FREELANCER],
includeWithoutResourceType: true,
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
AND: expect.arrayContaining([
{ isActive: true },
{ NOT: { resourceType: { in: [ResourceType.FREELANCER] } } },
]),
},
}),
);
});
it("applies rolled-off and departed filters", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
};
const caller = createProtectedCaller(db);
await caller.list({
rolledOff: true,
departed: false,
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
AND: expect.arrayContaining([
{ isActive: true },
{ rolledOff: true },
{ departed: false },
]),
},
}),
);
});
it("applies multi-select chapter filters", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
};
const caller = createProtectedCaller(db);
await caller.list({
chapters: ["Art Direction", "Project Management"],
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
AND: expect.arrayContaining([
{ isActive: true },
{ chapter: { in: ["Art Direction", "Project Management"] } },
]),
},
}),
);
});
it("supports stable anonymized identities and alias-based filtering", async () => {
const resource = {
id: "resource_anon_1",
eid: "h.noerenberg",
displayName: "Hartmut Noerenberg",
email: "h.noerenberg@accenture.com",
chapter: "Art Direction",
lcrCents: 15000,
isActive: true,
createdAt: new Date("2026-03-01"),
updatedAt: new Date("2026-03-01"),
};
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({
anonymizationEnabled: true,
anonymizationDomain: "superhartmut.de",
anonymizationSeed: null,
anonymizationMode: "global",
}),
},
resource: {
findMany: vi.fn().mockResolvedValue([resource]),
count: vi.fn(),
},
};
const caller = createProtectedCaller(db);
const first = await caller.list({ limit: 10 });
const alias = first.resources[0];
expect(alias).toBeDefined();
expect(alias?.displayName).not.toBe(resource.displayName);
expect(alias?.eid).not.toBe(resource.eid);
expect(alias?.email).toBe(`${alias?.eid}@superhartmut.de`);
const byAlias = await caller.list({ eids: [alias!.eid], limit: 10 });
const byAliasSearch = await caller.list({ search: alias!.displayName.slice(0, 4), limit: 10 });
expect(byAlias.resources).toHaveLength(1);
expect(byAlias.resources[0]?.id).toBe(resource.id);
expect(byAliasSearch.resources).toHaveLength(1);
expect(byAliasSearch.resources[0]?.id).toBe(resource.id);
});
});
@@ -0,0 +1,297 @@
import { SystemRole } from "@planarchy/shared";
import { describe, expect, it, vi } from "vitest";
import { staffingRouter } from "../router/staffing.js";
import { createCallerFactory } from "../trpc.js";
// Mock the pure-logic packages — we focus on the router/DB layer
vi.mock("@planarchy/staffing", () => ({
rankResources: vi.fn().mockImplementation((input: { resources: { id: string }[] }) =>
input.resources.map((r: { id: string }, i: number) => ({
resourceId: r.id,
score: 80 - i * 10,
breakdown: {
skillScore: 70,
availabilityScore: 90,
costScore: 80,
utilizationScore: 75,
},
})),
),
analyzeUtilization: vi.fn().mockReturnValue({
resourceId: "res_1",
displayName: "Alice",
totalDays: 20,
allocatedDays: 15,
utilizationPercent: 75,
chargeablePercent: 60,
overallocatedDays: 0,
dailyBreakdown: [],
}),
findCapacityWindows: vi.fn().mockReturnValue([
{
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-10"),
availableHoursPerDay: 6,
},
]),
}));
vi.mock("@planarchy/application", () => ({
listAssignmentBookings: vi.fn().mockResolvedValue([]),
}));
const createCaller = createCallerFactory(staffingRouter);
// ── Caller factories ─────────────────────────────────────────────────────────
function createProtectedCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
});
}
// ── Sample data ──────────────────────────────────────────────────────────────
function sampleResource(overrides: Record<string, unknown> = {}) {
return {
id: "res_1",
displayName: "Alice",
eid: "alice",
lcrCents: 7500,
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
skills: [
{ skill: "Compositing", proficiency: 4, isMainSkill: true },
{ skill: "Nuke", proficiency: 3, isMainSkill: false, category: "Software" },
],
isActive: true,
valueScore: 85,
chapter: "VFX",
...overrides,
};
}
// ─── getSuggestions ──────────────────────────────────────────────────────────
describe("staffing.getSuggestions", () => {
it("returns ranked suggestions for a staffing demand", async () => {
const resources = [
sampleResource(),
sampleResource({ id: "res_2", displayName: "Bob", eid: "bob", valueScore: 70 }),
];
const db = {
resource: {
findMany: vi.fn().mockResolvedValue(resources),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getSuggestions({
requiredSkills: ["Compositing"],
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
hoursPerDay: 8,
});
expect(result).toHaveLength(2);
expect(result[0]).toHaveProperty("resourceId");
expect(result[0]).toHaveProperty("score");
});
it("filters resources by chapter when provided", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
await caller.getSuggestions({
requiredSkills: ["Compositing"],
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
hoursPerDay: 8,
chapter: "VFX",
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
isActive: true,
chapter: "VFX",
}),
}),
);
});
it("returns empty array when no resources match", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getSuggestions({
requiredSkills: ["Compositing"],
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
hoursPerDay: 8,
});
expect(result).toHaveLength(0);
});
it("passes budget constraint to ranking", async () => {
const resources = [sampleResource()];
const db = {
resource: {
findMany: vi.fn().mockResolvedValue(resources),
},
};
const { rankResources } = await import("@planarchy/staffing");
const caller = createProtectedCaller(db);
await caller.getSuggestions({
requiredSkills: ["Compositing"],
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
hoursPerDay: 8,
budgetLcrCentsPerHour: 10000,
});
expect(rankResources).toHaveBeenCalledWith(
expect.objectContaining({
budgetLcrCentsPerHour: 10000,
}),
);
});
});
// ─── analyzeUtilization ──────────────────────────────────────────────────────
describe("staffing.analyzeUtilization", () => {
it("returns utilization analysis for an existing resource", async () => {
const resource = {
id: "res_1",
displayName: "Alice",
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
};
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue(resource),
},
};
const caller = createProtectedCaller(db);
const result = await caller.analyzeUtilization({
resourceId: "res_1",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
});
expect(result).toHaveProperty("utilizationPercent");
expect(result.resourceId).toBe("res_1");
});
it("throws NOT_FOUND when resource does not exist", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.analyzeUtilization({
resourceId: "nonexistent",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
}),
).rejects.toThrow("Resource not found");
});
});
// ─── findCapacity ────────────────────────────────────────────────────────────
describe("staffing.findCapacity", () => {
it("returns capacity windows for an existing resource", async () => {
const resource = {
id: "res_1",
displayName: "Alice",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
};
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue(resource),
},
};
const caller = createProtectedCaller(db);
const result = await caller.findCapacity({
resourceId: "res_1",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
});
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty("availableHoursPerDay");
});
it("throws NOT_FOUND when resource does not exist", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.findCapacity({
resourceId: "nonexistent",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
}),
).rejects.toThrow("Resource not found");
});
it("passes minAvailableHoursPerDay to engine", async () => {
const resource = {
id: "res_1",
displayName: "Alice",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
};
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue(resource),
},
};
const { findCapacityWindows } = await import("@planarchy/staffing");
const caller = createProtectedCaller(db);
await caller.findCapacity({
resourceId: "res_1",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
minAvailableHoursPerDay: 6,
});
expect(findCapacityWindows).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.any(Date),
expect.any(Date),
6,
);
});
});
@@ -0,0 +1,854 @@
import { SystemRole } from "@planarchy/shared";
import { VacationStatus, VacationType } from "@planarchy/db";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { vacationRouter } from "../router/vacation.js";
import { createCallerFactory } from "../trpc.js";
vi.mock("../sse/event-bus.js", () => ({
emitVacationCreated: vi.fn(),
emitVacationUpdated: vi.fn(),
emitVacationDeleted: vi.fn(),
emitNotificationCreated: vi.fn(),
}));
vi.mock("../lib/email.js", () => ({
sendEmail: vi.fn(),
}));
const createCaller = createCallerFactory(vacationRouter);
function createProtectedCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2026-12-31T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
});
}
function createManagerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "manager@example.com", name: "Manager", image: null },
expires: "2026-12-31T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "mgr_1",
systemRole: SystemRole.MANAGER,
permissionOverrides: null,
},
});
}
function createAdminCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "admin@example.com", name: "Admin", image: null },
expires: "2026-12-31T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "admin_1",
systemRole: SystemRole.ADMIN,
permissionOverrides: null,
},
});
}
function createUnauthenticatedCaller(db: Record<string, unknown>) {
return createCaller({
session: null,
db: db as never,
dbUser: null,
});
}
const sampleVacation = {
id: "vac_1",
resourceId: "res_1",
type: VacationType.ANNUAL,
status: VacationStatus.PENDING,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
note: "Summer vacation",
isHalfDay: false,
halfDayPart: null,
requestedById: "user_1",
approvedById: null,
approvedAt: null,
rejectionReason: null,
createdAt: new Date("2026-03-01"),
updatedAt: new Date("2026-03-01"),
resource: { id: "res_1", displayName: "Alice", eid: "E-001" },
requestedBy: { id: "user_1", name: "User", email: "user@example.com" },
approvedBy: null,
};
describe("vacation router", () => {
describe("list", () => {
it("returns vacations with default filters", async () => {
const db = {
vacation: {
findMany: vi.fn().mockResolvedValue([sampleVacation]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.list({});
expect(result).toHaveLength(1);
expect(result[0].id).toBe("vac_1");
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
orderBy: { startDate: "asc" },
take: 100,
}),
);
});
it("applies resourceId filter", async () => {
const db = {
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
await caller.list({ resourceId: "res_1" });
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ resourceId: "res_1" }),
}),
);
});
it("applies status and type filters", async () => {
const db = {
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createProtectedCaller(db);
await caller.list({
status: VacationStatus.APPROVED,
type: VacationType.SICK,
});
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: VacationStatus.APPROVED,
type: VacationType.SICK,
}),
}),
);
});
it("rejects unauthenticated users", async () => {
const db = { vacation: { findMany: vi.fn() } };
const caller = createUnauthenticatedCaller(db);
await expect(caller.list({})).rejects.toThrow("Authentication required");
});
});
describe("getById", () => {
it("returns vacation by id", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getById({ id: "vac_1" });
expect(result.id).toBe("vac_1");
expect(db.vacation.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "vac_1" },
}),
);
});
it("throws NOT_FOUND for missing vacation", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createProtectedCaller(db);
await expect(caller.getById({ id: "missing" })).rejects.toThrow("Vacation not found");
});
});
describe("create", () => {
it("creates vacation as PENDING for regular user", async () => {
const createdVacation = {
...sampleVacation,
status: VacationStatus.PENDING,
};
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(createdVacation),
},
};
const caller = createProtectedCaller(db);
const result = await caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
});
expect(result.status).toBe(VacationStatus.PENDING);
expect(db.vacation.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: VacationStatus.PENDING,
resourceId: "res_1",
type: VacationType.ANNUAL,
}),
}),
);
});
it("creates vacation as APPROVED for manager", async () => {
const createdVacation = {
...sampleVacation,
status: VacationStatus.APPROVED,
approvedById: "mgr_1",
};
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
},
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(createdVacation),
},
};
const caller = createManagerCaller(db);
const result = await caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
});
expect(result.status).toBe(VacationStatus.APPROVED);
expect(db.vacation.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: VacationStatus.APPROVED,
approvedById: "mgr_1",
}),
}),
);
});
it("rejects overlapping vacation", async () => {
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
vacation: {
findFirst: vi.fn().mockResolvedValue({ id: "existing_vac" }),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
}),
).rejects.toThrow("Overlapping vacation already exists");
});
it("rejects when end date is before start date", async () => {
const db = {
user: { findUnique: vi.fn() },
vacation: { findFirst: vi.fn() },
};
const caller = createProtectedCaller(db);
await expect(
caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-05"),
endDate: new Date("2026-06-01"),
}),
).rejects.toThrow();
});
it("supports half-day vacations", async () => {
const createdVacation = {
...sampleVacation,
isHalfDay: true,
halfDayPart: "MORNING",
};
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(createdVacation),
},
};
const caller = createProtectedCaller(db);
const result = await caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-01"),
isHalfDay: true,
halfDayPart: "MORNING",
});
expect(result.isHalfDay).toBe(true);
expect(db.vacation.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
isHalfDay: true,
halfDayPart: "MORNING",
}),
}),
);
});
});
describe("approve", () => {
it("approves a PENDING vacation", async () => {
const updatedVacation = {
...sampleVacation,
status: VacationStatus.APPROVED,
approvedById: "mgr_1",
};
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createManagerCaller(db);
const result = await caller.approve({ id: "vac_1" });
expect(result.status).toBe(VacationStatus.APPROVED);
expect(db.vacation.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "vac_1" },
data: expect.objectContaining({
status: VacationStatus.APPROVED,
rejectionReason: null,
}),
}),
);
});
it("throws NOT_FOUND for missing vacation", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createManagerCaller(db);
await expect(caller.approve({ id: "missing" })).rejects.toThrow("Vacation not found");
});
it("rejects approving an already APPROVED vacation", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.APPROVED,
}),
},
};
const caller = createManagerCaller(db);
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow(
"Only PENDING, CANCELLED, or REJECTED vacations can be approved",
);
});
it("forbids regular users from approving", async () => {
const db = {};
const caller = createProtectedCaller(db);
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow();
});
});
describe("reject", () => {
it("rejects a PENDING vacation", async () => {
const updatedVacation = {
...sampleVacation,
status: VacationStatus.REJECTED,
rejectionReason: "Team conflict",
};
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createManagerCaller(db);
const result = await caller.reject({ id: "vac_1", rejectionReason: "Team conflict" });
expect(result.status).toBe(VacationStatus.REJECTED);
expect(db.vacation.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: VacationStatus.REJECTED,
rejectionReason: "Team conflict",
}),
}),
);
});
it("throws when rejecting non-PENDING vacation", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.APPROVED,
}),
},
};
const caller = createManagerCaller(db);
await expect(caller.reject({ id: "vac_1" })).rejects.toThrow(
"Only PENDING vacations can be rejected",
);
});
});
describe("cancel", () => {
it("cancels an existing vacation", async () => {
const updatedVacation = {
...sampleVacation,
status: VacationStatus.CANCELLED,
};
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
};
const caller = createProtectedCaller(db);
const result = await caller.cancel({ id: "vac_1" });
expect(result.status).toBe(VacationStatus.CANCELLED);
});
it("throws NOT_FOUND for missing vacation", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createProtectedCaller(db);
await expect(caller.cancel({ id: "missing" })).rejects.toThrow("Vacation not found");
});
it("throws when already cancelled", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.CANCELLED,
}),
},
};
const caller = createProtectedCaller(db);
await expect(caller.cancel({ id: "vac_1" })).rejects.toThrow("Already cancelled");
});
});
describe("batchApprove", () => {
it("approves multiple pending vacations", async () => {
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{ id: "vac_1", resourceId: "res_1" },
{ id: "vac_2", resourceId: "res_2" },
]),
updateMany: vi.fn().mockResolvedValue({ count: 2 }),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createManagerCaller(db);
const result = await caller.batchApprove({ ids: ["vac_1", "vac_2"] });
expect(result.approved).toBe(2);
expect(db.vacation.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: { in: ["vac_1", "vac_2"] } },
data: expect.objectContaining({
status: VacationStatus.APPROVED,
}),
}),
);
});
it("only approves PENDING vacations from the requested set", async () => {
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{ id: "vac_1", resourceId: "res_1" },
]),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createManagerCaller(db);
const result = await caller.batchApprove({ ids: ["vac_1", "vac_already_approved"] });
expect(result.approved).toBe(1);
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: { in: ["vac_1", "vac_already_approved"] }, status: VacationStatus.PENDING },
}),
);
});
});
describe("batchReject", () => {
it("rejects multiple pending vacations with optional reason", async () => {
const db = {
vacation: {
findMany: vi.fn().mockResolvedValue([
{ id: "vac_1", resourceId: "res_1" },
]),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createManagerCaller(db);
const result = await caller.batchReject({
ids: ["vac_1"],
rejectionReason: "Budget freeze",
});
expect(result.rejected).toBe(1);
expect(db.vacation.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: VacationStatus.REJECTED,
rejectionReason: "Budget freeze",
}),
}),
);
});
});
describe("getForResource", () => {
it("returns approved vacations in date range", async () => {
const db = {
vacation: {
findMany: vi.fn().mockResolvedValue([
{
id: "vac_1",
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
type: VacationType.ANNUAL,
status: VacationStatus.APPROVED,
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getForResource({
resourceId: "res_1",
startDate: new Date("2026-01-01"),
endDate: new Date("2026-12-31"),
});
expect(result).toHaveLength(1);
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
resourceId: "res_1",
status: VacationStatus.APPROVED,
}),
}),
);
});
});
describe("getPendingApprovals", () => {
it("returns all pending vacations for managers", async () => {
const db = {
vacation: {
findMany: vi.fn().mockResolvedValue([sampleVacation]),
},
};
const caller = createManagerCaller(db);
const result = await caller.getPendingApprovals();
expect(result).toHaveLength(1);
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: VacationStatus.PENDING },
}),
);
});
it("forbids regular users from viewing pending approvals", async () => {
const db = {};
const caller = createProtectedCaller(db);
await expect(caller.getPendingApprovals()).rejects.toThrow();
});
});
describe("getTeamOverlap", () => {
it("returns overlapping vacations for the same chapter", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue({ chapter: "Animation" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{
...sampleVacation,
id: "vac_other",
resourceId: "res_2",
resource: { id: "res_2", displayName: "Bob", eid: "E-002" },
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getTeamOverlap({
resourceId: "res_1",
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
});
expect(result).toHaveLength(1);
expect(db.vacation.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
resource: { chapter: "Animation" },
resourceId: { not: "res_1" },
}),
}),
);
});
it("returns empty array when resource has no chapter", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue({ chapter: null }),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getTeamOverlap({
resourceId: "res_1",
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-05"),
});
expect(result).toEqual([]);
});
});
describe("batchCreatePublicHolidays", () => {
it("creates public holidays for all active resources (admin only)", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{ id: "res_1" },
{ id: "res_2" },
]),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "admin_1" }),
},
vacation: {
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({}),
},
};
const caller = createAdminCaller(db);
const result = await caller.batchCreatePublicHolidays({
year: 2026,
federalState: "BY",
});
expect(result.created).toBeGreaterThan(0);
expect(result.resources).toBe(2);
expect(db.vacation.create).toHaveBeenCalled();
});
it("skips already existing holidays", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([{ id: "res_1" }]),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "admin_1" }),
},
vacation: {
findFirst: vi.fn().mockResolvedValue({ id: "existing" }),
create: vi.fn(),
},
};
const caller = createAdminCaller(db);
const result = await caller.batchCreatePublicHolidays({
year: 2026,
federalState: "BY",
});
expect(result.created).toBe(0);
expect(db.vacation.create).not.toHaveBeenCalled();
});
it("forbids non-admin users", async () => {
const db = {};
const caller = createManagerCaller(db);
await expect(
caller.batchCreatePublicHolidays({ year: 2026 }),
).rejects.toThrow("Admin role required");
});
});
describe("updateStatus", () => {
it("allows manager to approve via updateStatus", async () => {
const updatedVacation = {
...sampleVacation,
status: VacationStatus.APPROVED,
};
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
},
};
const caller = createManagerCaller(db);
const result = await caller.updateStatus({ id: "vac_1", status: "APPROVED" });
expect(result.status).toBe(VacationStatus.APPROVED);
});
it("allows any user to cancel via updateStatus", async () => {
const updatedVacation = {
...sampleVacation,
status: VacationStatus.CANCELLED,
};
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
};
const caller = createProtectedCaller(db);
const result = await caller.updateStatus({ id: "vac_1", status: "CANCELLED" });
expect(result.status).toBe(VacationStatus.CANCELLED);
});
it("forbids non-manager from approving via updateStatus", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.updateStatus({ id: "vac_1", status: "APPROVED" }),
).rejects.toThrow("Manager role required to approve/reject");
});
it("throws NOT_FOUND when vacation does not exist", async () => {
const db = {
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
const caller = createProtectedCaller(db);
await expect(
caller.updateStatus({ id: "missing", status: "CANCELLED" }),
).rejects.toThrow("Vacation not found");
});
});
});
+841
View File
@@ -0,0 +1,841 @@
import { createHash } from "node:crypto";
import type { PrismaClient } from "@planarchy/db";
const DEFAULT_ANONYMIZATION_DOMAIN = "superhartmut.de";
const DEFAULT_ANONYMIZATION_SEED = "planarchy-superhartmut-global";
type AliasEntry = {
name: string;
slug: string;
};
type ResourceIdentity = {
id: string;
eid?: string | null;
displayName?: string | null;
email?: string | null;
};
type UserIdentity = {
id?: string | null;
name?: string | null;
email?: string | null;
};
type DirectoryResource = {
id: string;
eid: string;
displayName: string;
email: string;
lcrCents: number;
};
type DirectoryIdentity = Pick<DirectoryResource, "id" | "eid" | "displayName" | "email">;
export type AnonymizationConfig = {
enabled: boolean;
domain: string;
seed: string;
mode: "global";
};
export type ResourceAlias = {
displayName: string;
eid: string;
email: string;
};
type AnonymizationDirectory = {
config: AnonymizationConfig;
byResourceId: Map<string, ResourceAlias>;
byAliasEid: Map<string, string>;
};
type StoredAliasEntry = {
displayName: string;
eid: string;
};
type StoredAliasMap = Record<string, StoredAliasEntry>;
const ALIAS_NAME_RE = /^[A-Za-z]+(?: [A-Za-z]+)*$/;
const ALIAS_SLUG_RE = /^[a-z]+(?:\.[a-z]+)*$/;
const ICONIC_ALIAS_NAMES = [
"Iron Man",
"Spider Man",
"Captain America",
"Thor",
"Hulk",
"Black Widow",
"Black Panther",
"Doctor Strange",
"Scarlet Witch",
"Captain Marvel",
"Wolverine",
"Batman",
"Superman",
"Wonder Woman",
"Flash",
"Aquaman",
"Deadpool",
"Loki",
"Thanos",
"Joker",
"Darth Vader",
"Harley Quinn",
"Doctor Doom",
"Magneto",
"Venom",
"Elsa",
"Mickey Mouse",
"Moana",
"Mulan",
"Simba",
"Stitch",
"Jack Sparrow",
"Maleficent",
"Cruella",
"Hades",
"Ursula",
"Luke Skywalker",
"Leia Organa",
"Han Solo",
"Obi Wan Kenobi",
"Yoda",
"Darth Maul",
"Buzz Lightyear",
"Woody",
"Belle",
"Green Goblin",
"Mystique",
"Poison Ivy",
"Ultron",
"Robert Downey",
"Scarlett Johansson",
"Chris Hemsworth",
"Ryan Reynolds",
"Hugh Jackman",
"Harrison Ford",
];
const LEGENDARY_ALIAS_NAMES = [
"Hawkeye",
"Falcon",
"Winter Soldier",
"Vision",
"Ant Man",
"Wasp",
"War Machine",
"Shuri",
"Shang Chi",
"Gamora",
"Star Lord",
"Rocket",
"Groot",
"Nebula",
"Daredevil",
"Punisher",
"Moon Knight",
"She Hulk",
"Ms Marvel",
"Kate Bishop",
"Yelena Belova",
"Pepper Potts",
"Nick Fury",
"Phil Coulson",
"Korg",
"Mantis",
"Drax",
"Sif",
"Professor X",
"Jean Grey",
"Cyclops",
"Rogue",
"Gambit",
"Silver Surfer",
"Ghost Rider",
"Blade",
"Killmonger",
"Taskmaster",
"Red Skull",
"Kingpin",
"Carnage",
"Sandman",
"Mysterio",
"Sabretooth",
"Abomination",
"Ronan",
"Scar",
"Jafar",
"Ursula",
"Gaston",
"Captain Hook",
"Cruella",
"Tiana",
"Rapunzel",
"Merida",
"Aladdin",
"Jasmine",
"Nala",
"Olaf",
"Kristoff",
"Maui",
"Baymax",
"Hercules",
"Megara",
"Tinker Bell",
"Baloo",
"Timon",
"Pumbaa",
"Ariel",
"Aurora",
"Pocahontas",
"Rey Skywalker",
"Kylo Ren",
"Ahsoka Tano",
"Chewbacca",
"Lando Calrissian",
"Mace Windu",
"Anakin Skywalker",
"Padme Amidala",
"Chris Evans",
"Tom Holland",
"Zendaya",
"Benedict Cumberbatch",
"Tom Hiddleston",
"Emma Stone",
"Margot Robbie",
"Christian Bale",
"Gal Gadot",
"Jason Momoa",
"Pedro Pascal",
"Keanu Reeves",
"Ryan Reynolds",
"Samuel Jackson",
"Brie Larson",
"Paul Rudd",
];
const EXTENDED_ALIAS_NAMES = [
"Okoye",
"Mbaku",
"Wong",
"Monica Rambeau",
"America Chavez",
"Kate Pryde",
"Nightcrawler",
"Beast",
"Elektra",
"Jessica Jones",
"Jessica Drew",
"Colossus",
"Domino",
"Cable",
"Nova",
"Adam Warlock",
"Clea",
"Ancient One",
"General Zod",
"Lex Luthor",
"Darkseid",
"Bane",
"Riddler",
"Two Face",
"Penguin",
"Catwoman",
"Mister Freeze",
"Red Hood",
"Nightwing",
"Robin",
"Supergirl",
"Batgirl",
"Green Lantern",
"Black Adam",
"Shazam",
"Raven",
"Starfire",
"Beast Boy",
"Doctor Octopus",
"Sandman",
"Mysterio",
"Sabretooth",
"Abomination",
"Ronan",
"Doctor Facilier",
"Mother Gothel",
"Prince Eric",
"Snow White",
"Cinderella",
"Pinocchio",
"Peter Pan",
"Winnie Pooh",
"Minnie Mouse",
"Donald Duck",
"Daisy Duck",
"Goofy",
"Pluto",
"Milo Thatch",
"Kida Nedakh",
"Li Shang",
"Genie",
"Flynn Rider",
"Prince Naveen",
"Qui Gon",
"Finn",
"Poe Dameron",
"Elizabeth Olsen",
"Mark Ruffalo",
"Natalie Portman",
"Emma Watson",
"Robert Pattinson",
"Angelina Jolie",
"Johnny Depp",
"Jeremy Renner",
"Tom Cruise",
"Will Smith",
"Dwayne Johnson",
"Jennifer Lawrence",
"Anne Hathaway",
"Meryl Streep",
"Charlize Theron",
"Brad Pitt",
];
const COMPOSITE_GIVEN_NAMES = [
"Tony",
"Peter",
"Steve",
"Natasha",
"Carol",
"Wanda",
"Logan",
"Bruce",
"Diana",
"Clark",
"Barry",
"Arthur",
"Selina",
"Harley",
"Loki",
"Thor",
"Shuri",
"Stephen",
"Leia",
"Luke",
"Anakin",
"Padme",
"Elsa",
"Moana",
"Mulan",
"Belle",
"Ariel",
"Simba",
"Tiana",
"Rapunzel",
"Mickey",
"Wade",
"Victor",
"Rey",
"Robert",
"Scarlett",
"Chris",
"Tom",
"Zendaya",
"Ryan",
"Keanu",
"Hugh",
"Emma",
"Pedro",
"Angelina",
"Brie",
"Paul",
"Jeremy",
"Samuel",
"Benedict",
"Gal",
"Jason",
"Margot",
"Christian",
"Harrison",
"Natalie",
"Mark",
"Elizabeth",
"Anne",
"Jennifer",
"Charlize",
];
const COMPOSITE_SURNAMES = [
"Stark",
"Parker",
"Rogers",
"Romanoff",
"Danvers",
"Maximoff",
"Howlett",
"Banner",
"Wayne",
"Kent",
"Allen",
"Curry",
"Prince",
"Quinn",
"Wilson",
"Odinson",
"Strange",
"Mouse",
"Lightyear",
"Skywalker",
"Solo",
"Kenobi",
"Vader",
"Ren",
"Palmer",
"Rambeau",
"Dameron",
"Tano",
"Downey",
"Evans",
"Johansson",
"Hemsworth",
"Holland",
"Reynolds",
"Jackman",
"Reeves",
"Stone",
"Watson",
"Pascal",
"Jolie",
"Larson",
"Rudd",
"Renner",
"Jackson",
"Cumberbatch",
"Hiddleston",
"Gadot",
"Momoa",
"Robbie",
"Bale",
"Ford",
"Portman",
"Ruffalo",
"Olsen",
"Lawrence",
"Hathaway",
"Theron",
"Pitt",
];
function normalizeAliasName(name: string): string {
return name.replace(/[^A-Za-z\s]+/g, " ").replace(/\s+/g, " ").trim();
}
function slugifyAliasName(name: string): string {
return normalizeAliasName(name).toLowerCase().split(" ").filter(Boolean).join(".");
}
function buildAliasEntries(names: string[]): AliasEntry[] {
const seen = new Set<string>();
const entries: AliasEntry[] = [];
for (const rawName of names) {
const name = normalizeAliasName(rawName);
const slug = slugifyAliasName(name);
if (!name || !slug || seen.has(slug)) {
continue;
}
if (!ALIAS_NAME_RE.test(name) || !ALIAS_SLUG_RE.test(slug)) {
continue;
}
seen.add(slug);
entries.push({ name, slug });
}
return entries;
}
function buildCompositeAliasEntries(givenNames: string[], surnames: string[]): AliasEntry[] {
const names: string[] = [];
for (const givenName of givenNames) {
for (const surname of surnames) {
if (givenName === surname) {
continue;
}
names.push(`${givenName} ${surname}`);
}
}
return buildAliasEntries(names);
}
const ICONIC_CHARACTERS = buildAliasEntries(ICONIC_ALIAS_NAMES);
const WELL_KNOWN_CHARACTERS = buildAliasEntries(LEGENDARY_ALIAS_NAMES);
const COMPOSITE_CHARACTERS = buildCompositeAliasEntries(COMPOSITE_GIVEN_NAMES, COMPOSITE_SURNAMES);
const SUPPORTING_CHARACTERS = buildAliasEntries([
...EXTENDED_ALIAS_NAMES,
...COMPOSITE_CHARACTERS.map((entry) => entry.name),
]);
const USER_CHARACTER_POOL = buildAliasEntries([
...ICONIC_ALIAS_NAMES,
...LEGENDARY_ALIAS_NAMES,
...EXTENDED_ALIAS_NAMES,
]);
function hashInt(input: string): number {
const hex = createHash("sha256").update(input).digest("hex").slice(0, 8);
return Number.parseInt(hex, 16);
}
function normalize(value: string | null | undefined): string {
return (value ?? "").trim().toLowerCase();
}
function isStoredAliasEntryValid(alias: StoredAliasEntry): boolean {
const displayName = normalizeAliasName(alias.displayName);
const eid = slugifyAliasName(alias.displayName);
return (
alias.displayName === displayName &&
alias.eid === eid &&
ALIAS_NAME_RE.test(displayName) &&
ALIAS_SLUG_RE.test(alias.eid) &&
!/\d/.test(alias.displayName) &&
!/\d/.test(alias.eid)
);
}
function parseStoredAliases(value: unknown): StoredAliasMap {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
const entries = Object.entries(value);
const parsed: StoredAliasMap = {};
for (const [resourceId, alias] of entries) {
if (!alias || typeof alias !== "object" || Array.isArray(alias)) {
continue;
}
const displayName =
typeof (alias as { displayName?: unknown }).displayName === "string"
? (alias as { displayName: string }).displayName.trim()
: "";
const eid =
typeof (alias as { eid?: unknown }).eid === "string"
? (alias as { eid: string }).eid.trim()
: "";
if (!displayName || !eid) {
continue;
}
parsed[resourceId] = { displayName, eid };
}
return parsed;
}
function toAlias(entry: StoredAliasEntry, domain: string): ResourceAlias {
return {
displayName: entry.displayName,
eid: entry.eid,
email: `${entry.eid}@${domain}`,
};
}
function getCharacterPool(lcrCents: number): AliasEntry[] {
if (lcrCents >= 12000) return ICONIC_CHARACTERS;
if (lcrCents >= 9000) return WELL_KNOWN_CHARACTERS;
return SUPPORTING_CHARACTERS;
}
function pickUniqueAlias(
resource: DirectoryResource,
config: AnonymizationConfig,
usedSlugs: Set<string>,
): AliasEntry {
const primaryPool = getCharacterPool(resource.lcrCents);
const orderedPools = [primaryPool, ICONIC_CHARACTERS, WELL_KNOWN_CHARACTERS, SUPPORTING_CHARACTERS];
const offset = hashInt(`${config.seed}:${resource.id}`);
for (const pool of orderedPools) {
for (let i = 0; i < pool.length; i += 1) {
const candidate = pool[(offset + i) % pool.length]!;
if (!usedSlugs.has(candidate.slug)) {
return candidate;
}
}
}
for (const candidate of COMPOSITE_CHARACTERS) {
if (!usedSlugs.has(candidate.slug)) {
return candidate;
}
}
const fallbackWords = [...COMPOSITE_GIVEN_NAMES, ...COMPOSITE_SURNAMES];
const firstOffset = hashInt(`${config.seed}:${resource.id}:fallback:first`);
const secondOffset = hashInt(`${config.seed}:${resource.id}:fallback:second`);
for (let i = 0; i < fallbackWords.length; i += 1) {
for (let j = 0; j < fallbackWords.length; j += 1) {
const first = fallbackWords[(firstOffset + i) % fallbackWords.length]!;
const second = fallbackWords[(secondOffset + j) % fallbackWords.length]!;
if (first === second) {
continue;
}
const candidate = buildAliasEntries([`${first} ${second}`])[0];
if (candidate && !usedSlugs.has(candidate.slug)) {
return candidate;
}
}
}
throw new Error("Unable to generate a unique anonymization alias without digits");
}
async function loadDirectoryResources(
db: Pick<PrismaClient, "resource">,
): Promise<DirectoryResource[]> {
if (!("resource" in db) || typeof db.resource?.findMany !== "function") {
return [];
}
const resources = await db.resource.findMany({
select: {
id: true,
eid: true,
displayName: true,
email: true,
lcrCents: true,
},
orderBy: { id: "asc" },
});
return resources.map((resource) => ({
...resource,
lcrCents: resource.lcrCents ?? 0,
}));
}
export async function getAnonymizationConfig(
db: Pick<PrismaClient, "systemSettings">,
): Promise<AnonymizationConfig> {
if (!("systemSettings" in db) || typeof db.systemSettings?.findUnique !== "function") {
return {
enabled: false,
domain: DEFAULT_ANONYMIZATION_DOMAIN,
seed: DEFAULT_ANONYMIZATION_SEED,
mode: "global",
};
}
const settings = await db.systemSettings.findUnique({
where: { id: "singleton" },
select: {
anonymizationEnabled: true,
anonymizationDomain: true,
anonymizationSeed: true,
anonymizationMode: true,
},
});
return {
enabled: settings?.anonymizationEnabled ?? false,
domain: settings?.anonymizationDomain?.trim() || DEFAULT_ANONYMIZATION_DOMAIN,
seed: settings?.anonymizationSeed?.trim() || DEFAULT_ANONYMIZATION_SEED,
mode: "global",
};
}
export async function getAnonymizationDirectory(
db: Pick<PrismaClient, "systemSettings" | "resource">,
): Promise<AnonymizationDirectory | null> {
if (!("systemSettings" in db) || typeof db.systemSettings?.findUnique !== "function") {
return null;
}
const settings = await db.systemSettings.findUnique({
where: { id: "singleton" },
select: {
anonymizationEnabled: true,
anonymizationDomain: true,
anonymizationSeed: true,
anonymizationMode: true,
anonymizationAliases: true,
},
});
const config: AnonymizationConfig = {
enabled: settings?.anonymizationEnabled ?? false,
domain: settings?.anonymizationDomain?.trim() || DEFAULT_ANONYMIZATION_DOMAIN,
seed: settings?.anonymizationSeed?.trim() || DEFAULT_ANONYMIZATION_SEED,
mode: "global",
};
if (!config.enabled) {
return null;
}
const resources = await loadDirectoryResources(db);
const usedSlugs = new Set<string>();
const byResourceId = new Map<string, ResourceAlias>();
const byAliasEid = new Map<string, string>();
const storedAliases = parseStoredAliases(settings?.anonymizationAliases);
let aliasesChanged = false;
for (const [resourceId, storedAlias] of Object.entries(storedAliases)) {
const normalizedEid = normalize(storedAlias.eid);
if (!normalizedEid || usedSlugs.has(normalizedEid) || !isStoredAliasEntryValid(storedAlias)) {
delete storedAliases[resourceId];
aliasesChanged = true;
continue;
}
usedSlugs.add(normalizedEid);
byResourceId.set(resourceId, toAlias(storedAlias, config.domain));
byAliasEid.set(normalizedEid, resourceId);
}
const rankedResources = [...resources].sort((left, right) => {
if (right.lcrCents !== left.lcrCents) {
return right.lcrCents - left.lcrCents;
}
return left.id.localeCompare(right.id);
});
for (const resource of rankedResources) {
const existing = byResourceId.get(resource.id);
if (existing) {
continue;
}
const alias = pickUniqueAlias(resource, config, usedSlugs);
const entry = {
displayName: alias.name,
eid: alias.slug,
email: `${alias.slug}@${config.domain}`,
};
usedSlugs.add(normalize(alias.slug));
byResourceId.set(resource.id, entry);
byAliasEid.set(normalize(alias.slug), resource.id);
storedAliases[resource.id] = {
displayName: alias.name,
eid: alias.slug,
};
aliasesChanged = true;
}
if (aliasesChanged && typeof db.systemSettings.update === "function") {
await db.systemSettings.update({
where: { id: "singleton" },
data: { anonymizationAliases: storedAliases },
});
}
return {
config,
byResourceId,
byAliasEid,
};
}
export function anonymizeResource<T extends ResourceIdentity>(
resource: T,
directory: AnonymizationDirectory | null,
): T {
if (!directory) {
return resource;
}
const alias = directory.byResourceId.get(resource.id);
if (!alias) {
return resource;
}
return {
...resource,
displayName: alias.displayName,
eid: alias.eid,
...(Object.prototype.hasOwnProperty.call(resource, "email") ? { email: alias.email } : {}),
};
}
export function anonymizeResources<T extends ResourceIdentity>(
resources: T[],
directory: AnonymizationDirectory | null,
): T[] {
if (!directory) {
return resources;
}
return resources.map((resource) => anonymizeResource(resource, directory));
}
export function anonymizeUser<T extends UserIdentity>(
user: T,
directory: AnonymizationDirectory | null,
): T {
if (!directory) {
return user;
}
const stableKey = normalize(user.id) || normalize(user.email) || normalize(user.name);
if (!stableKey) {
return user;
}
const index = hashInt(`${directory.config.seed}:user:${stableKey}`) % USER_CHARACTER_POOL.length;
const alias = USER_CHARACTER_POOL[index]!;
return {
...user,
name: alias.name,
...(Object.prototype.hasOwnProperty.call(user, "email")
? { email: `${alias.slug}@${directory.config.domain}` }
: {}),
};
}
export function anonymizeSearchMatches(
resource: DirectoryIdentity,
alias: ResourceAlias | undefined,
search: string,
): boolean {
const query = normalize(search);
if (!query) {
return true;
}
const haystack = [
resource.displayName,
resource.eid,
resource.email,
alias?.displayName,
alias?.eid,
alias?.email,
]
.filter(Boolean)
.map((value) => normalize(value));
return haystack.some((value) => value.includes(query));
}
export function resolveResourceIdsByDisplayedEids(
resources: DirectoryIdentity[],
directory: AnonymizationDirectory | null,
requestedEids: string[],
): string[] {
if (requestedEids.length === 0) {
return [];
}
const realByEid = new Map(resources.map((resource) => [normalize(resource.eid), resource.id]));
const ids = requestedEids
.map((value) => {
const normalized = normalize(value);
return directory?.byAliasEid.get(normalized) ?? realByEid.get(normalized) ?? null;
})
.filter((value): value is string => value !== null);
return [...new Set(ids)];
}
@@ -10,10 +10,11 @@ import {
type AssignmentSlice,
} from "@planarchy/engine";
import type { SpainScheduleRule } from "@planarchy/shared";
import { listAssignmentBookings } from "@planarchy/application";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@planarchy/application";
import { VacationStatus } from "@planarchy/db";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
export const chargeabilityReportRouter = createTRPCRouter({
getReport: controllerProcedure
@@ -24,10 +25,11 @@ export const chargeabilityReportRouter = createTRPCRouter({
orgUnitId: z.string().optional(),
managementLevelGroupId: z.string().optional(),
countryId: z.string().optional(),
includeProposed: z.boolean().default(false),
}),
)
.query(async ({ ctx, input }) => {
const { startMonth, endMonth } = input;
const { startMonth, endMonth, includeProposed } = input;
// Parse month range
const [startYear, startMo] = startMonth.split("-").map(Number) as [number, number];
@@ -100,7 +102,8 @@ export const chargeabilityReportRouter = createTRPCRouter({
// Normalize bookings to a common shape
const assignments = allBookings
.filter((b) => b.resourceId !== null)
.filter((booking) => booking.resourceId !== null)
.filter((booking) => isChargeabilityActualBooking(booking, includeProposed))
.map((b) => ({
resourceId: b.resourceId!,
startDate: b.startDate,
@@ -171,8 +174,6 @@ export const chargeabilityReportRouter = createTRPCRouter({
// 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;
@@ -236,9 +237,11 @@ export const chargeabilityReportRouter = createTRPCRouter({
};
});
const directory = await getAnonymizationDirectory(ctx.db);
return {
monthKeys,
resources: resourceRows,
resources: anonymizeResources(resourceRows, directory),
groupTotals,
};
}),
+33 -13
View File
@@ -7,6 +7,7 @@ import {
getDashboardPeakTimes,
getDashboardTopValueResources,
} from "@planarchy/application";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
export const dashboardRouter = createTRPCRouter({
getOverview: protectedProcedure.query(({ ctx }) => getDashboardOverview(ctx.db)),
@@ -31,13 +32,17 @@ export const dashboardRouter = createTRPCRouter({
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",
}),
),
.query(async ({ ctx, input }) => {
const [resources, directory] = await Promise.all([
getDashboardTopValueResources(ctx.db, {
limit: input.limit,
userRole:
(ctx.session.user as { role?: string } | undefined)?.role ?? "USER",
}),
getAnonymizationDirectory(ctx.db),
]);
return anonymizeResources(resources, directory);
}),
getDemand: protectedProcedure
.input(
@@ -58,14 +63,29 @@ export const dashboardRouter = createTRPCRouter({
getChargeabilityOverview: controllerProcedure
.input(
z.object({
includeProposed: z.boolean().default(false),
topN: z.number().int().min(1).max(50).default(10),
watchlistThreshold: z.number().default(15),
countryIds: z.array(z.string()).optional(),
departed: z.boolean().optional(),
}),
)
.query(({ ctx, input }) =>
getDashboardChargeabilityOverview(ctx.db, {
topN: input.topN,
watchlistThreshold: input.watchlistThreshold,
}),
),
.query(async ({ ctx, input }) => {
const [overview, directory] = await Promise.all([
getDashboardChargeabilityOverview(ctx.db, {
includeProposed: input.includeProposed,
topN: input.topN,
watchlistThreshold: input.watchlistThreshold,
...(input.countryIds !== undefined ? { countryIds: input.countryIds } : {}),
...(input.departed !== undefined ? { departed: input.departed } : {}),
}),
getAnonymizationDirectory(ctx.db),
]);
return {
...overview,
top: anonymizeResources(overview.top, directory),
watchlist: anonymizeResources(overview.watchlist, directory),
};
}),
});
+20
View File
@@ -38,6 +38,10 @@ export const settingsRouter = createTRPCRouter({
smtpFrom: settings?.smtpFrom ?? null,
smtpTls: settings?.smtpTls ?? true,
hasSmtpPassword: !!settings?.smtpPassword,
// Global anonymization
anonymizationEnabled: settings?.anonymizationEnabled ?? false,
anonymizationDomain: settings?.anonymizationDomain ?? "superhartmut.de",
anonymizationMode: settings?.anonymizationMode ?? "global",
// Vacation defaults
vacationDefaultDays: settings?.vacationDefaultDays ?? 28,
};
@@ -75,6 +79,11 @@ export const settingsRouter = createTRPCRouter({
smtpPassword: z.string().optional(),
smtpFrom: z.string().email().optional().or(z.literal("")),
smtpTls: z.boolean().optional(),
// Global anonymization
anonymizationEnabled: z.boolean().optional(),
anonymizationDomain: z.string().trim().min(1).optional(),
anonymizationSeed: z.string().trim().min(1).optional().or(z.literal("")),
anonymizationMode: z.enum(["global"]).optional(),
// Vacation
vacationDefaultDays: z.number().int().min(0).max(365).optional(),
}),
@@ -107,6 +116,17 @@ export const settingsRouter = createTRPCRouter({
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;
// Global anonymization
if (input.anonymizationEnabled !== undefined) data.anonymizationEnabled = input.anonymizationEnabled;
if (input.anonymizationDomain !== undefined) data.anonymizationDomain = input.anonymizationDomain || "superhartmut.de";
if (input.anonymizationSeed !== undefined) {
data.anonymizationSeed = input.anonymizationSeed || null;
data.anonymizationAliases = null;
}
if (input.anonymizationMode !== undefined) {
data.anonymizationMode = input.anonymizationMode;
data.anonymizationAliases = null;
}
// Vacation
if (input.vacationDefaultDays !== undefined) data.vacationDefaultDays = input.vacationDefaultDays;
+1
View File
@@ -14,6 +14,7 @@
"@planarchy/db": "workspace:*",
"@planarchy/engine": "workspace:*",
"@planarchy/shared": "workspace:*",
"@planarchy/staffing": "workspace:*",
"@trpc/server": "^11.0.0",
"xlsx": "^0.18.5"
},
@@ -21,6 +21,7 @@ describe("listAssignmentBookings", () => {
shortCode: "ALPHA",
status: "ACTIVE",
orderType: "CHARGEABLE",
dynamicFields: null,
},
resource: {
id: "res_1",
@@ -43,6 +44,7 @@ describe("listAssignmentBookings", () => {
shortCode: "BRAVO",
status: "DRAFT",
orderType: "INTERNAL",
dynamicFields: null,
},
resource: {
id: "res_2",
@@ -75,6 +77,7 @@ describe("listAssignmentBookings", () => {
shortCode: "ALPHA",
status: "ACTIVE",
orderType: "CHARGEABLE",
dynamicFields: null,
},
resource: {
id: "res_1",
@@ -97,6 +100,7 @@ describe("listAssignmentBookings", () => {
shortCode: "BRAVO",
status: "DRAFT",
orderType: "INTERNAL",
dynamicFields: null,
},
resource: {
id: "res_2",
@@ -133,7 +137,14 @@ describe("listAssignmentBookings", () => {
dailyCostCents: true,
status: true,
project: {
select: { id: true, name: true, shortCode: true, status: true, orderType: true },
select: {
id: true,
name: true,
shortCode: true,
status: true,
orderType: true,
dynamicFields: true,
},
},
resource: {
select: { id: true, displayName: true, chapter: true },
@@ -186,7 +197,14 @@ describe("listAssignmentBookings", () => {
dailyCostCents: true,
status: true,
project: {
select: { id: true, name: true, shortCode: true, status: true, orderType: true },
select: {
id: true,
name: true,
shortCode: true,
status: true,
orderType: true,
dynamicFields: true,
},
},
resource: {
select: { id: true, displayName: true, chapter: true },
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { commitDispoImportBatch } from "../index.js";
import { deriveTbdDispoProjectIdentity } from "../use-cases/dispo-import/tbd-projects.js";
function createCommitDb(overrides: Record<string, unknown> = {}) {
const tx = {
@@ -331,6 +332,162 @@ describe("commitDispoImportBatch", () => {
);
});
it("commits [tbd] rows as provisional projects when explicitly enabled", async () => {
const { db, tx } = createCommitDb();
const rawToken = "[DAI] 590 C AMG GT Stills [tbd]{CH} HB_";
const tbdProject = deriveTbdDispoProjectIdentity(rawToken, "Chg");
db.stagedUnresolvedRecord.findMany.mockResolvedValue([
{
id: "unresolved_1",
recordType: "PROJECT",
message: `Planning token "${rawToken}" references [tbd] and requires project resolution`,
resolutionHint: "Resolve [tbd] rows to a real WBS/project before commit",
normalizedData: { rawToken },
status: "UNRESOLVED",
},
]);
tx.stagedResource.findMany.mockResolvedValue([
{
id: "sr_roster",
sourceKind: "ROSTER",
canonicalExternalId: "h.noerenberg",
displayName: "Hartmut Norenberg",
email: "h.noerenberg@accenture.com",
chapter: "Art Direction",
chargeabilityTarget: null,
clientUnitName: null,
countryCode: "DE",
fte: 1,
lcrCents: 1000,
managementLevelGroupName: null,
managementLevelName: null,
metroCityName: null,
resourceType: "EMPLOYEE",
roleTokens: ["AD"],
ucrCents: 1500,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
rawPayload: {},
warnings: [],
},
]);
tx.stagedProject.findMany.mockResolvedValue([
{
id: "sp_tbd_1",
shortCode: tbdProject.shortCode,
projectKey: tbdProject.projectKey,
name: tbdProject.name,
clientCode: "DAI",
utilizationCategoryCode: "Chg",
allocationType: "EXT",
orderType: "CHARGEABLE",
winProbability: 100,
isInternal: false,
isTbd: true,
startDate: new Date("2026-02-03T00:00:00.000Z"),
endDate: new Date("2026-02-04T00:00:00.000Z"),
warnings: [],
rawPayload: {
rawTokens: [rawToken],
},
},
]);
tx.stagedAssignment.findMany.mockResolvedValue([
{
id: "sa_tbd_1",
resourceExternalId: "h.noerenberg",
projectKey: null,
assignmentDate: new Date("2026-02-03T00:00:00.000Z"),
startDate: new Date("2026-02-03T00:00:00.000Z"),
endDate: new Date("2026-02-03T00:00:00.000Z"),
hoursPerDay: 4,
percentage: 50,
roleToken: "AD",
roleName: "Art Director",
utilizationCategoryCode: "Chg",
isInternal: false,
isUnassigned: false,
isTbd: true,
rawPayload: {
rawToken,
},
},
{
id: "sa_tbd_2",
resourceExternalId: "h.noerenberg",
projectKey: null,
assignmentDate: new Date("2026-02-04T00:00:00.000Z"),
startDate: new Date("2026-02-04T00:00:00.000Z"),
endDate: new Date("2026-02-04T00:00:00.000Z"),
hoursPerDay: 4,
percentage: 50,
roleToken: "AD",
roleName: "Art Director",
utilizationCategoryCode: "Chg",
isInternal: false,
isUnassigned: false,
isTbd: true,
rawPayload: {
rawToken,
},
},
]);
const result = await commitDispoImportBatch(db as never, {
importBatchId: "batch_1",
importTbdProjects: true,
});
expect(result).toEqual({
batchId: "batch_1",
counts: {
committedAssignments: 1,
committedProjects: 1,
committedResources: 1,
committedVacations: 0,
updatedEntitlements: 0,
updatedResourceAvailabilities: 0,
upsertedResourceRoles: 2,
},
unresolved: {
blocked: 0,
skippedTbd: 1,
},
});
expect(tx.project.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { shortCode: tbdProject.shortCode },
create: expect.objectContaining({
name: tbdProject.name,
shortCode: tbdProject.shortCode,
status: "DRAFT",
}),
}),
);
expect(tx.assignment.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({
endDate: new Date("2026-02-04T00:00:00.000Z"),
startDate: new Date("2026-02-03T00:00:00.000Z"),
}),
}),
);
expect(tx.stagedProject.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { importBatchId: "batch_1" },
}),
);
});
it("blocks commit when non-[tbd] unresolved rows remain", async () => {
const { db } = createCommitDb();
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import {
getDashboardChargeabilityOverview,
getDashboardDemand,
getDashboardOverview,
getDashboardPeakTimes,
@@ -26,6 +27,7 @@ describe("dashboard use-cases", () => {
shortCode: "ALPHA",
status: "ACTIVE",
orderType: "FIXED",
dynamicFields: null,
},
resource: {
id: "res_1",
@@ -342,6 +344,175 @@ describe("dashboard use-cases", () => {
);
});
it("keeps proposed allocations out of actual chargeability by default but can include them", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assign_1",
projectId: "proj_1",
resourceId: "res_1",
status: "PROPOSED",
startDate: new Date("2026-03-03T00:00:00.000Z"),
endDate: new Date("2026-03-03T00:00:00.000Z"),
hoursPerDay: 8,
dailyCostCents: 0,
project: {
id: "proj_1",
name: "Alpha",
shortCode: "ALPHA",
status: "ACTIVE",
orderType: "FIXED",
},
resource: {
id: "res_1",
displayName: "Alice",
chapter: "CGI",
},
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
eid: "alice",
displayName: "Alice",
chapter: "CGI",
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
},
]),
},
};
const strict = await getDashboardChargeabilityOverview(db as never, {
now: new Date("2026-03-15T00:00:00.000Z"),
topN: 10,
watchlistThreshold: 15,
});
const withProposed = await getDashboardChargeabilityOverview(db as never, {
includeProposed: true,
now: new Date("2026-03-15T00:00:00.000Z"),
topN: 10,
watchlistThreshold: 15,
});
expect(strict.top[0]?.actualChargeability).toBe(0);
expect(strict.top[0]?.expectedChargeability).toBe(5);
expect(withProposed.top[0]?.actualChargeability).toBe(5);
expect(withProposed.top[0]?.expectedChargeability).toBe(5);
});
it("filters chargeability overview by departed state and country", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
eid: "alice",
displayName: "Alice",
chapter: "CGI",
countryId: "country_de",
departed: false,
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
},
]),
},
};
const result = await getDashboardChargeabilityOverview(db as never, {
now: new Date("2026-03-15T00:00:00.000Z"),
topN: 10,
watchlistThreshold: 15,
countryIds: ["country_de"],
departed: false,
});
expect(db.resource.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
isActive: true,
countryId: { in: ["country_de"] },
departed: false,
}),
}),
);
expect(result.top).toHaveLength(1);
expect(result.top[0]).toEqual(
expect.objectContaining({
id: "res_1",
countryId: "country_de",
departed: false,
}),
);
});
it("includes imported TBD draft projects in actual chargeability only when proposed work is enabled", async () => {
const db = {
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assign_tbd",
projectId: "proj_tbd",
resourceId: "res_1",
status: "PROPOSED",
startDate: new Date("2026-03-03T00:00:00.000Z"),
endDate: new Date("2026-03-03T00:00:00.000Z"),
hoursPerDay: 8,
dailyCostCents: 0,
project: {
id: "proj_tbd",
name: "TBD: AMG",
shortCode: "TBD-AMG",
status: "DRAFT",
orderType: "CLIENT",
dynamicFields: { dispoImport: { isTbd: true } },
},
resource: {
id: "res_1",
displayName: "Alice",
chapter: "CGI",
},
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
eid: "alice",
displayName: "Alice",
chapter: "CGI",
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
},
]),
},
};
const strict = await getDashboardChargeabilityOverview(db as never, {
now: new Date("2026-03-15T00:00:00.000Z"),
topN: 10,
watchlistThreshold: 15,
});
const withProposed = await getDashboardChargeabilityOverview(db as never, {
includeProposed: true,
now: new Date("2026-03-15T00:00:00.000Z"),
topN: 10,
watchlistThreshold: 15,
});
expect(strict.top[0]?.actualChargeability).toBe(0);
expect(strict.top[0]?.expectedChargeability).toBe(5);
expect(withProposed.top[0]?.actualChargeability).toBe(5);
expect(withProposed.top[0]?.expectedChargeability).toBe(5);
});
it("returns distinct resource counts for chapter demand grouping", async () => {
const db = {
demandRequirement: {
@@ -640,6 +640,11 @@ describe("dispo import", () => {
isInternal: false,
utilizationCategoryCode: "Chg",
}),
expect.objectContaining({
isTbd: true,
isInternal: false,
shortCode: expect.stringMatching(/^TBD-/),
}),
]),
}),
);
+10
View File
@@ -22,6 +22,11 @@ export {
type AssignmentBookingWithFallback,
type ListAssignmentBookingsInput,
} from "./use-cases/allocation/list-assignment-bookings.js";
export {
isChargeabilityActualBooking,
isChargeabilityRelevantProject,
isImportedTbdDraftProject,
} from "./use-cases/allocation/chargeability-bookings.js";
export {
countPlanningEntries,
type CountPlanningEntriesInput,
@@ -93,6 +98,11 @@ export {
type EstimateListItem,
} from "./use-cases/estimate/index.js";
export {
recomputeResourceValueScores,
type RecomputeResourceValueScoresInput,
} from "./use-cases/resource/index.js";
export {
assessDispoImportReadiness,
parseMandatoryDispoReferenceWorkbook,
@@ -0,0 +1,53 @@
import type { Prisma } from "@planarchy/db";
import type { AssignmentBookingWithFallback } from "./list-assignment-bookings.js";
type ChargeabilityProjectLike = AssignmentBookingWithFallback["project"];
type ChargeabilityBookingLike = Pick<AssignmentBookingWithFallback, "status" | "project">;
function asObject(value: Prisma.JsonValue | null | undefined): Record<string, unknown> | null {
if (value === null || value === undefined || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
export function isImportedTbdDraftProject(project: ChargeabilityProjectLike): boolean {
if (project.status !== "DRAFT") {
return false;
}
const dynamicFields = asObject(project.dynamicFields);
const dispoImport = asObject(dynamicFields?.["dispoImport"] as Prisma.JsonValue | undefined);
return dispoImport?.["isTbd"] === true;
}
export function isChargeabilityRelevantProject(
project: ChargeabilityProjectLike,
includeProposed: boolean,
): boolean {
if (project.status === "ACTIVE") {
return true;
}
if (project.status === "CANCELLED") {
return false;
}
return includeProposed && isImportedTbdDraftProject(project);
}
export function isChargeabilityActualBooking(
booking: ChargeabilityBookingLike,
includeProposed: boolean,
): boolean {
if (!isChargeabilityRelevantProject(booking.project, includeProposed)) {
return false;
}
return (
booking.status === "CONFIRMED" ||
booking.status === "ACTIVE" ||
(includeProposed && booking.status === "PROPOSED")
);
}
@@ -25,6 +25,7 @@ export interface AssignmentBookingWithFallback {
shortCode: string;
status: string;
orderType: string;
dynamicFields: Prisma.JsonValue | null;
};
resource: {
id: string;
@@ -67,7 +68,14 @@ export async function listAssignmentBookings(
dailyCostCents: true,
status: true,
project: {
select: { id: true, name: true, shortCode: true, status: true, orderType: true },
select: {
id: true,
name: true,
shortCode: true,
status: true,
orderType: true,
dynamicFields: true,
},
},
resource: {
select: { id: true, displayName: true, chapter: true },
@@ -1,11 +1,18 @@
import type { PrismaClient } from "@planarchy/db";
import { computeChargeability } from "@planarchy/engine";
import type { WeekdayAvailability } from "@planarchy/shared";
import {
isChargeabilityActualBooking,
isChargeabilityRelevantProject,
} from "../allocation/chargeability-bookings.js";
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
export interface GetDashboardChargeabilityOverviewInput {
includeProposed?: boolean;
topN: number;
watchlistThreshold: number;
countryIds?: string[];
departed?: boolean;
now?: Date;
}
@@ -18,12 +25,20 @@ export async function getDashboardChargeabilityOverview(
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const resources = await db.resource.findMany({
where: { isActive: true },
where: {
isActive: true,
...(input.countryIds && input.countryIds.length > 0
? { countryId: { in: input.countryIds } }
: {}),
...(input.departed !== undefined ? { departed: input.departed } : {}),
},
select: {
id: true,
eid: true,
displayName: true,
chapter: true,
countryId: true,
departed: true,
chargeabilityTarget: true,
availability: true,
},
@@ -37,11 +52,11 @@ export async function getDashboardChargeabilityOverview(
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 actualAllocations = resourceBookings.filter((booking) =>
isChargeabilityActualBooking(booking, input.includeProposed === true),
);
const expectedAllocations = resourceBookings.filter(
(booking) => isChargeabilityRelevantProject(booking.project, true),
);
const actual = computeChargeability(
availability,
@@ -51,7 +66,7 @@ export async function getDashboardChargeabilityOverview(
);
const expected = computeChargeability(
availability,
resourceBookings,
expectedAllocations,
start,
end,
);
@@ -61,6 +76,8 @@ export async function getDashboardChargeabilityOverview(
eid: resource.eid,
displayName: resource.displayName,
chapter: resource.chapter,
countryId: resource.countryId,
departed: resource.departed,
chargeabilityTarget: resource.chargeabilityTarget,
actualChargeability: actual.chargeability,
expectedChargeability: expected.chargeability,
@@ -69,16 +86,14 @@ export async function getDashboardChargeabilityOverview(
return {
top: [...stats]
.sort((left, right) => right.actualChargeability - left.actualChargeability)
.slice(0, input.topN),
.sort((left, right) => right.actualChargeability - left.actualChargeability),
watchlist: [...stats]
.filter(
(resource) =>
resource.actualChargeability <
resource.chargeabilityTarget - input.watchlistThreshold,
)
.sort((left, right) => left.actualChargeability - right.actualChargeability)
.slice(0, input.topN),
.sort((left, right) => left.actualChargeability - right.actualChargeability),
month: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`,
};
}
@@ -573,10 +573,10 @@ export async function parseDispoPlanningWorkbook(
const existing = availabilityRules.get(key);
if (existing) {
const nextAvailability = buildAvailabilityAccumulator(column, metadata, rawToken, {
availableHours: naHandling.availableHours,
percentage: naHandling.percentage,
ruleType: naHandling.ruleType,
warning: naHandling.warning,
...(naHandling.availableHours !== undefined ? { availableHours: naHandling.availableHours } : {}),
...(naHandling.percentage !== undefined ? { percentage: naHandling.percentage } : {}),
...(naHandling.ruleType !== undefined ? { ruleType: naHandling.ruleType } : {}),
...(naHandling.warning !== undefined ? { warning: naHandling.warning } : {}),
});
existing.availableHours = nextAvailability.availableHours;
existing.percentage = nextAvailability.percentage;
@@ -585,10 +585,10 @@ export async function parseDispoPlanningWorkbook(
}
} else {
const availabilityRule = buildAvailabilityAccumulator(column, metadata, rawToken, {
availableHours: naHandling.availableHours,
percentage: naHandling.percentage,
ruleType: naHandling.ruleType,
warning: naHandling.warning,
...(naHandling.availableHours !== undefined ? { availableHours: naHandling.availableHours } : {}),
...(naHandling.percentage !== undefined ? { percentage: naHandling.percentage } : {}),
...(naHandling.ruleType !== undefined ? { ruleType: naHandling.ruleType } : {}),
...(naHandling.warning !== undefined ? { warning: naHandling.warning } : {}),
});
availabilityRule.sourceRow = rowNumber;
availabilityRules.set(key, availabilityRule);
@@ -1,11 +1,4 @@
import XLSXModule from "xlsx";
const XLSX =
(
XLSXModule as typeof import("xlsx") & {
default?: typeof import("xlsx");
}
).default ?? (XLSXModule as typeof import("xlsx"));
import XLSX from "xlsx";
export type WorksheetCellValue = boolean | Date | number | string | null;
export type WorksheetMatrix = WorksheetCellValue[][];
@@ -2,6 +2,11 @@ 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 {
classifyDispoProject,
deriveResolvedDispoProjectIdentity,
deriveTbdDispoProjectIdentity,
} from "./tbd-projects.js";
import {
DISPO_PLANNING_SHEET,
ensureImportBatch,
@@ -32,34 +37,6 @@ interface ResolvedStagedProject {
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;
@@ -131,7 +108,7 @@ export async function stageDispoProjects(
continue;
}
if (assignment.isTbd || assignment.isUnassigned) {
if (assignment.isUnassigned) {
continue;
}
@@ -153,32 +130,27 @@ export async function stageDispoProjects(
continue;
}
if (!assignment.projectKey) {
if (!assignment.projectKey && !assignment.isTbd) {
continue;
}
const derivedClientCode = extractClientCode(assignment.rawToken);
const derivedName = deriveProjectName(assignment.rawToken, assignment.projectKey);
const shortCode = assignment.projectKey;
const existing = projects.get(assignment.projectKey);
const identity = assignment.isTbd
? deriveTbdDispoProjectIdentity(assignment.rawToken, assignment.utilizationCategoryCode)
: deriveResolvedDispoProjectIdentity(assignment.rawToken, assignment.projectKey!);
const classification = classifyDispoProject(assignment.utilizationCategoryCode);
const existing = projects.get(identity.projectKey);
if (!existing) {
projects.set(assignment.projectKey, {
allocationType: assignment.utilizationCategoryCode === "Chg"
? AllocationType.EXT
: AllocationType.INT,
clientCode: derivedClientCode,
projects.set(identity.projectKey, {
allocationType: classification.allocationType,
clientCode: identity.clientCode,
isInternal: false,
isTbd: false,
name: derivedName,
orderType: assignment.utilizationCategoryCode === "Chg"
? OrderType.CHARGEABLE
: assignment.utilizationCategoryCode === "BD"
? OrderType.BD
: OrderType.INTERNAL,
projectKey: assignment.projectKey,
isTbd: assignment.isTbd,
name: identity.name,
orderType: classification.orderType,
projectKey: identity.projectKey,
rawTokens: new Set<string>([assignment.rawToken]),
shortCode,
shortCode: identity.shortCode,
sourceColumn: assignment.sourceColumn,
sourceRow: assignment.sourceRow,
startDate: assignment.assignmentDate,
@@ -193,19 +165,19 @@ export async function stageDispoProjects(
updateDateRange(existing, assignment.assignmentDate);
existing.rawTokens.add(assignment.rawToken);
if (derivedClientCode && existing.clientCode && existing.clientCode !== derivedClientCode) {
if (identity.clientCode && existing.clientCode && existing.clientCode !== identity.clientCode) {
existing.warnings.add(
`Conflicting client codes for project ${assignment.projectKey}: ${existing.clientCode} vs ${derivedClientCode}`,
`Conflicting client codes for project ${identity.projectKey}: ${existing.clientCode} vs ${identity.clientCode}`,
);
}
if (!existing.clientCode && derivedClientCode) {
existing.clientCode = derivedClientCode;
if (!existing.clientCode && identity.clientCode) {
existing.clientCode = identity.clientCode;
}
if (normalizeText(existing.name) !== normalizeText(derivedName)) {
if (normalizeText(existing.name) !== normalizeText(identity.name)) {
existing.warnings.add(
`Multiple project names observed for ${assignment.projectKey}; using "${existing.name}"`,
`Multiple project names observed for ${identity.projectKey}; using "${existing.name}"`,
);
}
@@ -215,7 +187,7 @@ export async function stageDispoProjects(
existing.utilizationCategoryCode !== assignment.utilizationCategoryCode
) {
existing.warnings.add(
`Conflicting utilization categories for ${assignment.projectKey}: ${existing.utilizationCategoryCode} vs ${assignment.utilizationCategoryCode}`,
`Conflicting utilization categories for ${identity.projectKey}: ${existing.utilizationCategoryCode} vs ${assignment.utilizationCategoryCode}`,
);
}
@@ -229,7 +201,7 @@ export async function stageDispoProjects(
existing.winProbability !== assignment.winProbability
) {
existing.warnings.add(
`Conflicting win probabilities for ${assignment.projectKey}: ${existing.winProbability} vs ${assignment.winProbability}`,
`Conflicting win probabilities for ${identity.projectKey}: ${existing.winProbability} vs ${assignment.winProbability}`,
);
}
@@ -0,0 +1,116 @@
import { createHash } from "node:crypto";
import { AllocationType, OrderType } from "@planarchy/db";
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 extractProjectLabel(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 slugifyFragment(value: string): string {
return value
.toUpperCase()
.replace(/[^A-Z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function shortSegment(value: string | null | undefined, fallback: string, maxLength: number): string {
const normalized = slugifyFragment(value ?? "");
return (normalized.length > 0 ? normalized : fallback).slice(0, maxLength);
}
export interface DerivedDispoProjectIdentity {
clientCode: string | null;
name: string;
projectKey: string;
shortCode: string;
}
export interface DerivedDispoProjectClassification {
allocationType: AllocationType;
orderType: OrderType;
}
export function classifyDispoProject(utilizationCategoryCode: string | null): DerivedDispoProjectClassification {
if (utilizationCategoryCode === "Chg") {
return {
allocationType: AllocationType.EXT,
orderType: OrderType.CHARGEABLE,
};
}
if (utilizationCategoryCode === "BD") {
return {
allocationType: AllocationType.INT,
orderType: OrderType.BD,
};
}
return {
allocationType: AllocationType.INT,
orderType: OrderType.INTERNAL,
};
}
export function deriveResolvedDispoProjectIdentity(
rawToken: string,
fallbackProjectKey: string,
): DerivedDispoProjectIdentity {
const name = extractProjectLabel(rawToken) ?? `Project ${fallbackProjectKey}`;
return {
clientCode: extractClientCode(rawToken),
name,
projectKey: fallbackProjectKey,
shortCode: fallbackProjectKey,
};
}
export function deriveTbdDispoProjectIdentity(
rawToken: string,
utilizationCategoryCode: string | null,
): DerivedDispoProjectIdentity {
const clientCode = extractClientCode(rawToken);
const name = extractProjectLabel(rawToken) ?? "Unresolved Dispo Work";
const clientSegment = shortSegment(clientCode, "GEN", 10);
const utilizationSegment = shortSegment(utilizationCategoryCode, "UNK", 6);
const labelSegment = shortSegment(name, "UNRESOLVED-WORK", 24);
const hash = createHash("sha1")
.update(`${rawToken.trim().toLowerCase()}|${utilizationCategoryCode ?? ""}`)
.digest("hex")
.slice(0, 8)
.toUpperCase();
const shortCode = `TBD-${clientSegment}-${utilizationSegment}-${labelSegment}-${hash}`;
return {
clientCode,
name: `TBD: ${name}`,
projectKey: shortCode,
shortCode,
};
}
@@ -0,0 +1,4 @@
export {
recomputeResourceValueScores,
type RecomputeResourceValueScoresInput,
} from "./recompute-resource-value-scores.js";
@@ -0,0 +1,111 @@
import type { PrismaClient } from "@planarchy/db";
import { computeValueScore } from "@planarchy/staffing";
import { VALUE_SCORE_WEIGHTS } from "@planarchy/shared";
import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js";
type ResourceValueScoreDbClient = Partial<Pick<PrismaClient, "systemSettings">> &
Pick<PrismaClient, "assignment" | "resource" | "$transaction">;
export interface RecomputeResourceValueScoresInput {
daysBack?: number;
}
export async function recomputeResourceValueScores(
db: ResourceValueScoreDbClient,
input: RecomputeResourceValueScoresInput = {},
) {
if (
typeof db.resource?.findMany !== "function" ||
typeof db.resource?.update !== "function"
) {
return { updated: 0 };
}
const daysBack = input.daysBack ?? 90;
const [resources, settings] = await Promise.all([
db.resource.findMany({
where: { isActive: true },
select: {
id: true,
skills: true,
lcrCents: true,
chargeabilityTarget: true,
},
}),
db.systemSettings?.findUnique?.({ where: { id: "singleton" } }) ?? Promise.resolve(null),
]);
if (resources.length === 0) {
return { updated: 0 };
}
const bookings = await listAssignmentBookings(db, {
startDate: new Date(Date.now() - daysBack * 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 | null) ?? defaultWeights;
const maxLcrCents = resources.reduce((max, resource) => Math.max(max, resource.lcrCents), 0);
const now = new Date();
type SkillRow = {
category?: string;
isMainSkill?: boolean;
proficiency: number;
skill: string;
yearsExperience?: number;
};
const totalWorkDays = daysBack * (5 / 7);
const availableHours = totalWorkDays * 8;
const updates = resources.map((resource) => {
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
const bookedHours = resourceBookings.reduce((sum, booking) => {
const days = Math.max(
0,
(new Date(booking.endDate).getTime() - new Date(booking.startDate).getTime()) /
(1000 * 60 * 60 * 24) +
1,
);
return sum + booking.hoursPerDay * days;
}, 0);
const 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 db.resource.update({
where: { id: resource.id },
data: {
valueScore: breakdown.total,
valueScoreBreakdown:
breakdown as unknown as import("@planarchy/db").Prisma.InputJsonValue,
valueScoreUpdatedAt: now,
},
});
});
await db.$transaction(updates);
return { updated: updates.length };
}
@@ -6,6 +6,7 @@ test("parseImportDispoBatchArgs uses the agreed workbook defaults", () => {
const options = parseImportDispoBatchArgs([]);
assert.equal(options.allowTbdUnresolved, true);
assert.equal(options.importTbdProjects, false);
assert.equal(options.skipCommit, false);
assert.equal(options.strictSourceData, false);
assert.equal(options.previewUnresolvedLimit, 10);
@@ -33,6 +34,7 @@ test("parseImportDispoBatchArgs applies operator overrides", () => {
"--skip-commit",
"--strict-source-data",
"--disallow-tbd",
"--import-tbd-projects",
"--no-roster",
"--no-cost",
]);
@@ -43,6 +45,7 @@ test("parseImportDispoBatchArgs applies operator overrides", () => {
assert.equal(options.skipCommit, true);
assert.equal(options.strictSourceData, true);
assert.equal(options.allowTbdUnresolved, false);
assert.equal(options.importTbdProjects, true);
assert.equal(options.rosterWorkbookPath, undefined);
assert.equal(options.costWorkbookPath, undefined);
});
+10
View File
@@ -37,6 +37,7 @@ export interface ImportDispoBatchOptions {
allowTbdUnresolved: boolean;
chargeabilityWorkbookPath: string;
costWorkbookPath: string | undefined;
importTbdProjects: boolean;
notes: string | undefined;
planningWorkbookPath: string;
previewUnresolvedLimit: number;
@@ -93,6 +94,7 @@ interface StageDispoImportBatchResult {
interface CommitDispoImportBatchInput {
allowTbdUnresolved?: boolean;
importTbdProjects?: boolean;
importBatchId: string;
}
@@ -149,6 +151,7 @@ export function parseImportDispoBatchArgs(argv: string[]): ImportDispoBatchOptio
allowTbdUnresolved: true,
chargeabilityWorkbookPath: DEFAULT_CHARGEABILITY_WORKBOOK,
costWorkbookPath: DEFAULT_COST_WORKBOOK,
importTbdProjects: false,
notes: undefined,
planningWorkbookPath: DEFAULT_PLANNING_WORKBOOK,
previewUnresolvedLimit: 10,
@@ -222,6 +225,11 @@ export function parseImportDispoBatchArgs(argv: string[]): ImportDispoBatchOptio
continue;
}
if (argument === "--import-tbd-projects") {
options.importTbdProjects = true;
continue;
}
if (argument === "--no-roster") {
options.rosterWorkbookPath = undefined;
continue;
@@ -259,6 +267,7 @@ function buildHelpText() {
" --skip-commit Stage and assess readiness only",
" --strict-source-data Require readiness without fallback assumptions",
" --disallow-tbd Fail commit if [tbd] unresolved rows remain",
" --import-tbd-projects Commit [tbd] rows as provisional DRAFT projects",
].join("\n");
}
@@ -415,6 +424,7 @@ export async function runImportDispoBatch(options: ImportDispoBatchOptions) {
const commitResult = await dispoImport.commitDispoImportBatch(prisma, {
allowTbdUnresolved: options.allowTbdUnresolved,
importTbdProjects: options.importTbdProjects,
importBatchId: stageResult.batchId,
});
@@ -0,0 +1,226 @@
import { describe, expect, it } from "vitest";
import {
computeCommercialTermsSummary,
computeMilestoneAmounts,
defaultCommercialTerms,
validatePaymentMilestones,
} from "../estimate/commercial-terms.js";
import type { CommercialTerms, PaymentMilestone } from "@planarchy/shared";
const BASE_TERMS: CommercialTerms = {
pricingModel: "fixed_price",
contingencyPercent: 0,
discountPercent: 0,
paymentTermDays: 30,
paymentMilestones: [],
warrantyMonths: 0,
};
describe("computeCommercialTermsSummary", () => {
it("returns unchanged totals when contingency and discount are 0", () => {
const result = computeCommercialTermsSummary({
baseCostCents: 100_000_00,
basePriceCents: 150_000_00,
terms: BASE_TERMS,
});
expect(result.adjustedCostCents).toBe(100_000_00);
expect(result.adjustedPriceCents).toBe(150_000_00);
expect(result.contingencyCents).toBe(0);
expect(result.discountCents).toBe(0);
expect(result.adjustedMarginCents).toBe(50_000_00);
});
it("adds contingency to cost", () => {
const result = computeCommercialTermsSummary({
baseCostCents: 100_000_00,
basePriceCents: 150_000_00,
terms: { ...BASE_TERMS, contingencyPercent: 10 },
});
expect(result.contingencyCents).toBe(10_000_00);
expect(result.adjustedCostCents).toBe(110_000_00);
expect(result.adjustedPriceCents).toBe(150_000_00);
expect(result.adjustedMarginCents).toBe(40_000_00);
});
it("subtracts discount from price", () => {
const result = computeCommercialTermsSummary({
baseCostCents: 100_000_00,
basePriceCents: 200_000_00,
terms: { ...BASE_TERMS, discountPercent: 5 },
});
expect(result.discountCents).toBe(10_000_00);
expect(result.adjustedPriceCents).toBe(190_000_00);
expect(result.adjustedCostCents).toBe(100_000_00);
expect(result.adjustedMarginCents).toBe(90_000_00);
});
it("applies both contingency and discount together", () => {
const result = computeCommercialTermsSummary({
baseCostCents: 100_000_00,
basePriceCents: 200_000_00,
terms: { ...BASE_TERMS, contingencyPercent: 15, discountPercent: 10 },
});
// cost: 100k + 15% = 115k
expect(result.adjustedCostCents).toBe(115_000_00);
// price: 200k - 10% = 180k
expect(result.adjustedPriceCents).toBe(180_000_00);
// margin: 180k - 115k = 65k
expect(result.adjustedMarginCents).toBe(65_000_00);
});
it("computes margin percent correctly", () => {
const result = computeCommercialTermsSummary({
baseCostCents: 70_000_00,
basePriceCents: 100_000_00,
terms: BASE_TERMS,
});
expect(result.adjustedMarginPercent).toBeCloseTo(30, 1);
});
it("handles zero price gracefully (margin% = 0)", () => {
const result = computeCommercialTermsSummary({
baseCostCents: 50_000_00,
basePriceCents: 0,
terms: BASE_TERMS,
});
expect(result.adjustedMarginPercent).toBe(0);
expect(result.adjustedMarginCents).toBe(-50_000_00);
});
it("handles negative margin when discount exceeds buffer", () => {
const result = computeCommercialTermsSummary({
baseCostCents: 100_000_00,
basePriceCents: 110_000_00,
terms: { ...BASE_TERMS, contingencyPercent: 20, discountPercent: 10 },
});
// cost: 100k + 20% = 120k, price: 110k - 10% = 99k → margin = -21k
expect(result.adjustedMarginCents).toBe(-21_000_00);
expect(result.adjustedMarginPercent).toBeLessThan(0);
});
it("rounds contingency and discount to integer cents", () => {
const result = computeCommercialTermsSummary({
baseCostCents: 33_333,
basePriceCents: 66_667,
terms: { ...BASE_TERMS, contingencyPercent: 7.5, discountPercent: 3.3 },
});
expect(Number.isInteger(result.contingencyCents)).toBe(true);
expect(Number.isInteger(result.discountCents)).toBe(true);
expect(Number.isInteger(result.adjustedCostCents)).toBe(true);
expect(Number.isInteger(result.adjustedPriceCents)).toBe(true);
});
});
describe("validatePaymentMilestones", () => {
it("returns no warnings for empty milestones", () => {
expect(validatePaymentMilestones([])).toEqual([]);
});
it("returns no warnings when milestones sum to 100%", () => {
const milestones: PaymentMilestone[] = [
{ label: "Kickoff", percent: 30 },
{ label: "Midpoint", percent: 40 },
{ label: "Delivery", percent: 30 },
];
expect(validatePaymentMilestones(milestones)).toEqual([]);
});
it("warns when milestones do not sum to 100%", () => {
const milestones: PaymentMilestone[] = [
{ label: "Kickoff", percent: 30 },
{ label: "Delivery", percent: 50 },
];
const warnings = validatePaymentMilestones(milestones);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("80.0%");
});
it("warns about zero-percent milestones", () => {
const milestones: PaymentMilestone[] = [
{ label: "Kickoff", percent: 0 },
{ label: "Delivery", percent: 100 },
];
const warnings = validatePaymentMilestones(milestones);
expect(warnings.some((w) => w.includes("0%"))).toBe(true);
});
it("warns about empty labels", () => {
const milestones: PaymentMilestone[] = [
{ label: " ", percent: 50 },
{ label: "End", percent: 50 },
];
const warnings = validatePaymentMilestones(milestones);
expect(warnings.some((w) => w.includes("empty label"))).toBe(true);
});
it("warns about non-chronological dates", () => {
const milestones: PaymentMilestone[] = [
{ label: "Late", percent: 50, dueDate: "2026-06-01" },
{ label: "Early", percent: 50, dueDate: "2026-03-01" },
];
const warnings = validatePaymentMilestones(milestones);
expect(warnings.some((w) => w.includes("earlier date"))).toBe(true);
});
it("ignores date order for milestones without dates", () => {
const milestones: PaymentMilestone[] = [
{ label: "A", percent: 50 },
{ label: "B", percent: 50, dueDate: "2026-01-01" },
];
expect(validatePaymentMilestones(milestones)).toEqual([]);
});
});
describe("computeMilestoneAmounts", () => {
it("computes amounts from adjusted price", () => {
const milestones: PaymentMilestone[] = [
{ label: "Kickoff", percent: 30 },
{ label: "Final", percent: 70 },
];
const amounts = computeMilestoneAmounts(100_000_00, milestones);
expect(amounts).toHaveLength(2);
expect(amounts[0]!.amountCents).toBe(30_000_00);
expect(amounts[1]!.amountCents).toBe(70_000_00);
});
it("rounds amounts to integer cents", () => {
const milestones: PaymentMilestone[] = [
{ label: "A", percent: 33.33 },
{ label: "B", percent: 33.33 },
{ label: "C", percent: 33.34 },
];
const amounts = computeMilestoneAmounts(99_999, milestones);
for (const a of amounts) {
expect(Number.isInteger(a.amountCents)).toBe(true);
}
});
it("preserves due dates", () => {
const milestones: PaymentMilestone[] = [
{ label: "M1", percent: 100, dueDate: "2026-06-15" },
];
const amounts = computeMilestoneAmounts(50_000_00, milestones);
expect(amounts[0]!.dueDate).toBe("2026-06-15");
});
});
describe("defaultCommercialTerms", () => {
it("returns valid defaults", () => {
const terms = defaultCommercialTerms();
expect(terms.pricingModel).toBe("fixed_price");
expect(terms.contingencyPercent).toBe(0);
expect(terms.discountPercent).toBe(0);
expect(terms.paymentTermDays).toBe(30);
expect(terms.paymentMilestones).toEqual([]);
expect(terms.warrantyMonths).toBe(0);
});
});
@@ -0,0 +1,129 @@
/**
* Pure commercial-terms calculation engine.
*
* Applies contingency, discount, and payment milestone validation
* to base cost/price totals from demand lines.
*/
import type { CommercialTerms, CommercialTermsSummary, PaymentMilestone } from "@planarchy/shared";
export interface CommercialTermsInput {
baseCostCents: number;
basePriceCents: number;
terms: CommercialTerms;
}
/**
* Compute adjusted totals after applying contingency and discount.
*
* Contingency is added to cost (risk buffer on cost side).
* Discount is subtracted from price (reduction on sell side).
*
* adjustedCost = baseCost * (1 + contingency%)
* adjustedPrice = basePrice * (1 - discount%)
* margin = adjustedPrice - adjustedCost
*/
export function computeCommercialTermsSummary(
input: CommercialTermsInput,
): CommercialTermsSummary {
const { baseCostCents, basePriceCents, terms } = input;
const contingencyFactor = terms.contingencyPercent / 100;
const discountFactor = terms.discountPercent / 100;
const contingencyCents = Math.round(baseCostCents * contingencyFactor);
const discountCents = Math.round(basePriceCents * discountFactor);
const adjustedCostCents = baseCostCents + contingencyCents;
const adjustedPriceCents = basePriceCents - discountCents;
const adjustedMarginCents = adjustedPriceCents - adjustedCostCents;
const adjustedMarginPercent =
adjustedPriceCents > 0
? (adjustedMarginCents / adjustedPriceCents) * 100
: 0;
return {
baseCostCents,
basePriceCents,
contingencyCents,
discountCents,
adjustedCostCents,
adjustedPriceCents,
adjustedMarginCents,
adjustedMarginPercent,
};
}
/**
* Validate that payment milestones sum to 100%.
* Returns list of validation warnings (empty = valid).
*/
export function validatePaymentMilestones(
milestones: PaymentMilestone[],
): string[] {
const warnings: string[] = [];
if (milestones.length === 0) return warnings;
const totalPercent = milestones.reduce((sum, m) => sum + m.percent, 0);
if (Math.abs(totalPercent - 100) > 0.01) {
warnings.push(
`Payment milestones sum to ${totalPercent.toFixed(1)}%, expected 100%`,
);
}
for (let i = 0; i < milestones.length; i++) {
const m = milestones[i]!;
if (m.percent <= 0) {
warnings.push(`Milestone "${m.label}" has 0% or negative allocation`);
}
if (!m.label.trim()) {
warnings.push(`Milestone at position ${i + 1} has empty label`);
}
}
// Check chronological order when dates are provided
const datedMilestones = milestones.filter(
(m): m is PaymentMilestone & { dueDate: string } =>
m.dueDate != null && m.dueDate !== "",
);
for (let i = 1; i < datedMilestones.length; i++) {
if (datedMilestones[i]!.dueDate < datedMilestones[i - 1]!.dueDate) {
warnings.push(
`Milestone "${datedMilestones[i]!.label}" has an earlier date than "${datedMilestones[i - 1]!.label}"`,
);
}
}
return warnings;
}
/**
* Compute per-milestone payment amounts from adjusted price.
*/
export function computeMilestoneAmounts(
adjustedPriceCents: number,
milestones: PaymentMilestone[],
): Array<{ label: string; percent: number; amountCents: number; dueDate?: string | null }> {
return milestones.map((m) => ({
label: m.label,
percent: m.percent,
amountCents: Math.round(adjustedPriceCents * (m.percent / 100)),
...(m.dueDate !== undefined ? { dueDate: m.dueDate } : {}),
}));
}
/**
* Default commercial terms for a new estimate.
*/
export function defaultCommercialTerms(): CommercialTerms {
return {
pricingModel: "fixed_price",
contingencyPercent: 0,
discountPercent: 0,
paymentTermDays: 30,
paymentMilestones: [],
warrantyMonths: 0,
};
}
+1
View File
@@ -1,3 +1,4 @@
export * from "./commercial-terms.js";
export * from "./effort-rules.js";
export * from "./experience-multiplier.js";
export * from "./export-serializer.js";
+2
View File
@@ -8,6 +8,8 @@ export const RESOURCE_COLUMNS: ColumnDef[] = [
{ key: "chargeability", label: "Chargeability", defaultVisible: true, hideable: true, sortable: true },
{ key: "lcr", label: "LCR", defaultVisible: false, hideable: true },
{ key: "valueScore", label: "Score", defaultVisible: false, hideable: true },
{ key: "rolledOff", label: "Rolled Off", defaultVisible: false, hideable: true },
{ key: "departed", label: "Departed", defaultVisible: false, hideable: true },
{ key: "isActive", label: "Status", defaultVisible: true, hideable: true },
];
@@ -334,6 +334,36 @@ export const UpdateWeeklyPhasingSchema = z.object({
export type GenerateWeeklyPhasingInput = z.infer<typeof GenerateWeeklyPhasingSchema>;
export type UpdateWeeklyPhasingInput = z.infer<typeof UpdateWeeklyPhasingSchema>;
// ─── Commercial Terms ───────────────────────────────────────────────────────
export const PricingModelSchema = z.enum(["fixed_price", "time_and_materials", "hybrid"]);
export const PaymentMilestoneSchema = z.object({
label: z.string().min(1).max(200),
percent: z.number().min(0).max(100),
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
description: z.string().max(1000).nullable().optional(),
});
export const CommercialTermsSchema = z.object({
pricingModel: PricingModelSchema.default("fixed_price"),
contingencyPercent: z.number().min(0).max(100).default(0),
discountPercent: z.number().min(0).max(100).default(0),
paymentTermDays: z.number().int().min(0).max(365).default(30),
paymentMilestones: z.array(PaymentMilestoneSchema).default([]),
warrantyMonths: z.number().int().min(0).max(60).default(0),
notes: z.string().max(5_000).nullable().optional(),
});
export const UpdateCommercialTermsSchema = z.object({
estimateId: z.string(),
versionId: z.string().optional(),
terms: CommercialTermsSchema,
});
export type CommercialTermsInput = z.infer<typeof CommercialTermsSchema>;
export type UpdateCommercialTermsInput = z.infer<typeof UpdateCommercialTermsSchema>;
export type CreateEstimateInput = z.infer<typeof CreateEstimateSchema>;
export type UpdateEstimateInput = z.infer<typeof UpdateEstimateSchema>;
export type UpdateEstimateDraftInput = z.infer<typeof UpdateEstimateDraftSchema>;
+32
View File
@@ -326,6 +326,38 @@ export interface WeeklyPhasingConfig {
pattern: PhasingPattern;
}
// --- Commercial Terms ---
export type PricingModel = "fixed_price" | "time_and_materials" | "hybrid";
export interface PaymentMilestone {
label: string;
percent: number;
dueDate?: string | null;
description?: string | null;
}
export interface CommercialTerms {
pricingModel: PricingModel;
contingencyPercent: number;
discountPercent: number;
paymentTermDays: number;
paymentMilestones: PaymentMilestone[];
warrantyMonths: number;
notes?: string | null;
}
export interface CommercialTermsSummary {
baseCostCents: number;
basePriceCents: number;
contingencyCents: number;
discountCents: number;
adjustedCostCents: number;
adjustedPriceCents: number;
adjustedMarginCents: number;
adjustedMarginPercent: number;
}
export interface EstimatePlanningHandoffResult {
estimateId: string;
estimateVersionId: string;
+1
View File
@@ -52,4 +52,5 @@ export interface Resource {
valueScore?: number | null;
valueScoreBreakdown?: ValueScoreBreakdown | null;
valueScoreUpdatedAt?: Date | null;
isOwnedByCurrentUser?: boolean;
}