refactor(api): extract timeline shift support

This commit is contained in:
2026-03-31 14:52:34 +02:00
parent b093a47c1b
commit b669de54e1
3 changed files with 522 additions and 103 deletions
@@ -0,0 +1,287 @@
import type { ShiftValidationResult } from "@capakraken/shared";
import { AllocationStatus } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { describe, expect, it, vi } from "vitest";
const {
calculateAllocationMock,
updateAssignmentMock,
updateDemandRequirementMock,
validateShiftMock,
} = vi.hoisted(() => ({
validateShiftMock: vi.fn(),
calculateAllocationMock: vi.fn(),
updateAssignmentMock: vi.fn(),
updateDemandRequirementMock: vi.fn(),
}));
vi.mock("@capakraken/engine", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/engine")>();
return {
...actual,
validateShift: validateShiftMock,
calculateAllocation: calculateAllocationMock,
};
});
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
return {
...actual,
updateAssignment: updateAssignmentMock,
updateDemandRequirement: updateDemandRequirementMock,
};
});
import {
applyTimelineProjectShift,
assertTimelineProjectShiftValid,
buildTimelineProjectShiftEventPayload,
buildTimelineProjectShiftValidation,
} from "../router/timeline-shift-support.js";
function createValidValidationResult(
overrides: Partial<ShiftValidationResult> = {},
): ShiftValidationResult {
return {
valid: true,
errors: [],
warnings: [],
conflictDetails: [],
costImpact: {
currentTotalCents: 100_000,
newTotalCents: 112_000,
deltaCents: 12_000,
budgetCents: 200_000,
budgetUtilizationBefore: 50,
budgetUtilizationAfter: 56,
wouldExceedBudget: false,
},
...overrides,
};
}
describe("timeline shift support", () => {
it("builds validation requests from loaded shift context", () => {
const expectedValidation = createValidValidationResult();
validateShiftMock.mockReturnValue(expectedValidation);
const validationAllocations = [
{
id: "assignment_1",
resourceId: "resource_1",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-05"),
hoursPerDay: 8,
percentage: 100,
role: "Compositing",
dailyCostCents: 40_000,
status: AllocationStatus.ACTIVE,
resource: {
id: "resource_1",
displayName: "Alice",
lcrCents: 5_000,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
},
allAllocationsForResource: [],
},
];
const result = buildTimelineProjectShiftValidation({
context: {
project: {
id: "project_1",
budgetCents: 200_000,
winProbability: 80,
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-10"),
},
demandRequirements: [],
assignments: [],
shiftPlan: {
validationAllocations,
},
},
newStartDate: new Date("2026-04-03"),
newEndDate: new Date("2026-04-12"),
});
expect(result).toBe(expectedValidation);
expect(validateShiftMock).toHaveBeenCalledWith({
project: {
id: "project_1",
budgetCents: 200_000,
winProbability: 80,
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-10"),
},
newStartDate: new Date("2026-04-03"),
newEndDate: new Date("2026-04-12"),
allocations: validationAllocations,
});
});
it("throws a BAD_REQUEST error for invalid shift validations", () => {
expect(() =>
assertTimelineProjectShiftValid(createValidValidationResult({
valid: false,
errors: [
{
code: "AVAILABILITY_CONFLICT",
message: "Alice exceeds capacity",
},
],
})),
).toThrowError(new TRPCError({
code: "BAD_REQUEST",
message: "Shift validation failed: Alice exceeds capacity",
}));
});
it("applies project shift updates and recalculates only staffed assignments", async () => {
const validation = createValidValidationResult();
validateShiftMock.mockReturnValue(validation);
calculateAllocationMock.mockReturnValue({ dailyCostCents: 54_321 });
updateDemandRequirementMock.mockResolvedValue({});
updateAssignmentMock.mockResolvedValue({});
const updatedProject = {
id: "project_1",
startDate: new Date("2026-04-03"),
endDate: new Date("2026-04-12"),
};
const db = {
project: {
update: vi.fn().mockResolvedValue(updatedProject),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
calculationRule: {
findMany: vi.fn().mockResolvedValue([{ id: "rule_1", priority: 1 }]),
},
$transaction: vi.fn(async (callback: (tx: typeof db) => unknown) => callback(db)),
};
const context = {
project: {
id: "project_1",
budgetCents: 200_000,
winProbability: 80,
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-10"),
},
demandRequirements: [
{
id: "demand_1",
},
],
assignments: [
{
id: "assignment_staffed",
resourceId: "resource_1",
hoursPerDay: 8,
metadata: { includeSaturday: true },
resource: {
lcrCents: 5_000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
},
},
{
id: "assignment_placeholder",
resourceId: null,
hoursPerDay: 4,
metadata: {},
resource: null,
},
],
shiftPlan: {
validationAllocations: [],
},
};
const result = await applyTimelineProjectShift({
db: db as never,
projectId: "project_1",
newStartDate: new Date("2026-04-03"),
newEndDate: new Date("2026-04-12"),
context: context as never,
});
expect(db.project.update).toHaveBeenCalledWith({
where: { id: "project_1" },
data: {
startDate: new Date("2026-04-03"),
endDate: new Date("2026-04-12"),
},
});
expect(updateDemandRequirementMock).toHaveBeenCalledWith(
db,
"demand_1",
{
startDate: new Date("2026-04-03"),
endDate: new Date("2026-04-12"),
},
);
expect(db.vacation.findMany).toHaveBeenCalledTimes(1);
expect(db.calculationRule.findMany).toHaveBeenCalledTimes(1);
expect(calculateAllocationMock).toHaveBeenCalledTimes(1);
expect(updateAssignmentMock).toHaveBeenNthCalledWith(
1,
db,
"assignment_staffed",
{
startDate: new Date("2026-04-03"),
endDate: new Date("2026-04-12"),
dailyCostCents: 54_321,
},
);
expect(updateAssignmentMock).toHaveBeenNthCalledWith(
2,
db,
"assignment_placeholder",
{
startDate: new Date("2026-04-03"),
endDate: new Date("2026-04-12"),
},
);
expect(db.auditLog.create).toHaveBeenCalledWith({
data: {
entityType: "Project",
entityId: "project_1",
action: "SHIFT",
changes: expect.objectContaining({
before: {
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-10"),
},
after: {
startDate: new Date("2026-04-03"),
endDate: new Date("2026-04-12"),
},
costImpact: validation.costImpact,
}),
},
});
expect(result).toEqual({
project: updatedProject,
validation,
event: buildTimelineProjectShiftEventPayload({
projectId: "project_1",
newStartDate: new Date("2026-04-03"),
newEndDate: new Date("2026-04-12"),
validation,
assignments: context.assignments as never,
}),
});
});
});
@@ -0,0 +1,224 @@
import {
updateAssignment,
updateDemandRequirement,
type SplitAssignmentRecord,
type SplitDemandRequirementRecord,
} from "@capakraken/application";
import { Prisma, type PrismaClient } from "@capakraken/db";
import { calculateAllocation, validateShift } from "@capakraken/engine";
import type { ShiftValidationResult, WeekdayAvailability } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { buildAbsenceDays, loadCalculationRules } from "./timeline-allocation-mutations.js";
import type { TimelineShiftPlan } from "./timeline-shift-planning.js";
export interface TimelineShiftProjectRecord {
id: string;
budgetCents: number;
winProbability: number;
startDate: Date;
endDate: Date;
}
export interface LoadedTimelineShiftContext {
project: TimelineShiftProjectRecord;
demandRequirements: SplitDemandRequirementRecord[];
assignments: SplitAssignmentRecord[];
shiftPlan: TimelineShiftPlan;
}
export interface ApplyTimelineProjectShiftInput {
db: PrismaClient;
projectId: string;
newStartDate: Date;
newEndDate: Date;
context: LoadedTimelineShiftContext;
}
export interface TimelineProjectShiftEventPayload extends Record<string, unknown> {
projectId: string;
newStartDate: string;
newEndDate: string;
costDeltaCents: number;
resourceIds: Array<string | null>;
}
export function buildTimelineProjectShiftValidation(input: {
context: LoadedTimelineShiftContext;
newStartDate: Date;
newEndDate: Date;
}) {
const { context, newStartDate, newEndDate } = input;
return validateShift({
project: {
id: context.project.id,
budgetCents: context.project.budgetCents,
winProbability: context.project.winProbability,
startDate: context.project.startDate,
endDate: context.project.endDate,
},
newStartDate,
newEndDate,
allocations: context.shiftPlan.validationAllocations,
});
}
export function assertTimelineProjectShiftValid(
validation: ShiftValidationResult,
): void {
if (validation.valid) {
return;
}
throw new TRPCError({
code: "BAD_REQUEST",
message: `Shift validation failed: ${validation.errors.map((error) => error.message).join(", ")}`,
});
}
export function buildTimelineProjectShiftEventPayload(input: {
projectId: string;
newStartDate: Date;
newEndDate: Date;
validation: ShiftValidationResult;
assignments: SplitAssignmentRecord[];
}): TimelineProjectShiftEventPayload {
return {
projectId: input.projectId,
newStartDate: input.newStartDate.toISOString(),
newEndDate: input.newEndDate.toISOString(),
costDeltaCents: input.validation.costImpact.deltaCents,
resourceIds: input.assignments.map((assignment) => assignment.resourceId),
};
}
function buildTimelineProjectShiftAuditChanges(input: {
project: TimelineShiftProjectRecord;
newStartDate: Date;
newEndDate: Date;
validation: ShiftValidationResult;
}): Prisma.InputJsonValue {
return {
before: {
startDate: input.project.startDate,
endDate: input.project.endDate,
},
after: {
startDate: input.newStartDate,
endDate: input.newEndDate,
},
costImpact: input.validation.costImpact,
} as unknown as Prisma.InputJsonValue;
}
async function recalculateShiftedAssignmentDailyCost(input: {
db: PrismaClient;
assignment: SplitAssignmentRecord;
newStartDate: Date;
newEndDate: Date;
}): Promise<number | undefined> {
if (!input.assignment.resourceId || !input.assignment.resource) {
return undefined;
}
const metadata = (input.assignment.metadata as Record<string, unknown> | null | undefined) ?? {};
const includeSaturday = (metadata.includeSaturday as boolean | undefined) ?? false;
const [shiftAbsenceData, shiftRules] = await Promise.all([
buildAbsenceDays(
input.db,
input.assignment.resourceId,
input.newStartDate,
input.newEndDate,
),
loadCalculationRules(input.db),
]);
return calculateAllocation({
lcrCents: input.assignment.resource.lcrCents,
hoursPerDay: input.assignment.hoursPerDay,
startDate: input.newStartDate,
endDate: input.newEndDate,
availability: input.assignment.resource.availability as WeekdayAvailability,
includeSaturday,
vacationDates: shiftAbsenceData.legacyVacationDates,
absenceDays: shiftAbsenceData.absenceDays,
calculationRules: shiftRules,
}).dailyCostCents;
}
export async function applyTimelineProjectShift(input: ApplyTimelineProjectShiftInput) {
const validation = buildTimelineProjectShiftValidation({
context: input.context,
newStartDate: input.newStartDate,
newEndDate: input.newEndDate,
});
assertTimelineProjectShiftValid(validation);
const updatedProject = await input.db.$transaction(async (tx) => {
const projectRecord = await tx.project.update({
where: { id: input.projectId },
data: {
startDate: input.newStartDate,
endDate: input.newEndDate,
},
});
for (const demandRequirement of input.context.demandRequirements) {
await updateDemandRequirement(
tx as unknown as Parameters<typeof updateDemandRequirement>[0],
demandRequirement.id,
{
startDate: input.newStartDate,
endDate: input.newEndDate,
},
);
}
for (const assignment of input.context.assignments) {
const dailyCostCents = await recalculateShiftedAssignmentDailyCost({
db: input.db,
assignment,
newStartDate: input.newStartDate,
newEndDate: input.newEndDate,
});
await updateAssignment(
tx as unknown as Parameters<typeof updateAssignment>[0],
assignment.id,
{
startDate: input.newStartDate,
endDate: input.newEndDate,
...(dailyCostCents !== undefined ? { dailyCostCents } : {}),
},
);
}
await tx.auditLog.create({
data: {
entityType: "Project",
entityId: input.projectId,
action: "SHIFT",
changes: buildTimelineProjectShiftAuditChanges({
project: input.context.project,
newStartDate: input.newStartDate,
newEndDate: input.newEndDate,
validation,
}),
},
});
return projectRecord;
});
return {
project: updatedProject,
validation,
event: buildTimelineProjectShiftEventPayload({
projectId: input.projectId,
newStartDate: input.newStartDate,
newEndDate: input.newEndDate,
validation,
assignments: input.context.assignments,
}),
};
}
+11 -103
View File
@@ -1,13 +1,10 @@
import { updateAssignment, updateDemandRequirement } from "@capakraken/application";
import type { PrismaClient } from "@capakraken/db";
import { calculateAllocation, validateShift } from "@capakraken/engine";
import { PermissionKey, ShiftProjectSchema } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { emitProjectShifted } from "../sse/event-bus.js";
import { createTRPCRouter, managerProcedure, requirePermission } from "../trpc.js";
import { buildAbsenceDays, loadCalculationRules, timelineAllocationMutationProcedures } from "./timeline-allocation-mutations.js";
import { timelineAllocationMutationProcedures } from "./timeline-allocation-mutations.js";
import { timelineReadProcedures } from "./timeline-read.js";
import { loadProjectShiftContext } from "./timeline-project-read.js";
import { applyTimelineProjectShift } from "./timeline-shift-support.js";
export const timelineRouter = createTRPCRouter({
...timelineReadProcedures,
@@ -22,109 +19,20 @@ export const timelineRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
const { projectId, newStartDate, newEndDate } = input;
const { project, demandRequirements, assignments, shiftPlan } = await loadProjectShiftContext(
ctx.db,
const context = await loadProjectShiftContext(ctx.db, projectId);
const result = await applyTimelineProjectShift({
db: ctx.db,
projectId,
);
const validation = validateShift({
project: {
id: project.id,
budgetCents: project.budgetCents,
winProbability: project.winProbability,
startDate: project.startDate,
endDate: project.endDate,
},
newStartDate,
newEndDate,
allocations: shiftPlan.validationAllocations,
context,
});
if (!validation.valid) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Shift validation failed: ${validation.errors.map((error) => error.message).join(", ")}`,
});
}
emitProjectShifted(result.event);
const shiftRules = await loadCalculationRules(ctx.db as PrismaClient);
const updatedProject = await ctx.db.$transaction(async (tx) => {
const projectRecord = await tx.project.update({
where: { id: projectId },
data: { startDate: newStartDate, endDate: newEndDate },
});
for (const demandRequirement of demandRequirements) {
await updateDemandRequirement(
tx as unknown as Parameters<typeof updateDemandRequirement>[0],
demandRequirement.id,
{
startDate: newStartDate,
endDate: newEndDate,
},
);
}
for (const assignment of assignments) {
const metadata = (assignment.metadata as Record<string, unknown> | null | undefined) ?? {};
const includeSaturday = (metadata.includeSaturday as boolean | undefined) ?? false;
const shiftAbsenceData = await buildAbsenceDays(
ctx.db as PrismaClient,
assignment.resourceId!,
newStartDate,
newEndDate,
);
const newDailyCost = calculateAllocation({
lcrCents: assignment.resource!.lcrCents,
hoursPerDay: assignment.hoursPerDay,
startDate: newStartDate,
endDate: newEndDate,
availability:
assignment.resource!.availability as unknown as import("@capakraken/shared").WeekdayAvailability,
includeSaturday,
vacationDates: shiftAbsenceData.legacyVacationDates,
absenceDays: shiftAbsenceData.absenceDays,
calculationRules: shiftRules,
}).dailyCostCents;
await updateAssignment(
tx as unknown as Parameters<typeof updateAssignment>[0],
assignment.id,
{
startDate: newStartDate,
endDate: newEndDate,
dailyCostCents: newDailyCost,
},
);
}
await tx.auditLog.create({
data: {
entityType: "Project",
entityId: projectId,
action: "SHIFT",
changes: {
before: { startDate: project.startDate, endDate: project.endDate },
after: { startDate: newStartDate, endDate: newEndDate },
costImpact: validation.costImpact,
} as unknown as import("@capakraken/db").Prisma.InputJsonValue,
},
});
return projectRecord;
});
emitProjectShifted({
projectId,
newStartDate: newStartDate.toISOString(),
newEndDate: newEndDate.toISOString(),
costDeltaCents: validation.costImpact.deltaCents,
resourceIds: assignments.map((assignment) => assignment.resourceId),
});
return { project: updatedProject, validation };
return {
project: result.project,
validation: result.validation,
};
}),
});