diff --git a/packages/api/src/__tests__/timeline-shift-procedure-support.test.ts b/packages/api/src/__tests__/timeline-shift-procedure-support.test.ts new file mode 100644 index 0000000..e40ca73 --- /dev/null +++ b/packages/api/src/__tests__/timeline-shift-procedure-support.test.ts @@ -0,0 +1,231 @@ +import type { ShiftValidationResult } from "@capakraken/shared"; +import { AllocationStatus } from "@capakraken/shared"; +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(); + return { + ...actual, + validateShift: validateShiftMock, + calculateAllocation: calculateAllocationMock, + }; +}); + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + updateAssignment: updateAssignmentMock, + updateDemandRequirement: updateDemandRequirementMock, + }; +}); + +import { buildTimelineProjectShiftEventPayload } from "../router/timeline-shift-support.js"; +import { applyTimelineProjectShift } from "../router/timeline-shift-procedure-support.js"; + +function createValidValidationResult( + overrides: Partial = {}, +): 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 procedure support", () => { + 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: [ + { + 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 = 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, + }), + }); + }); +}); diff --git a/packages/api/src/__tests__/timeline-shift-support.test.ts b/packages/api/src/__tests__/timeline-shift-support.test.ts index 012e6b7..a68813c 100644 --- a/packages/api/src/__tests__/timeline-shift-support.test.ts +++ b/packages/api/src/__tests__/timeline-shift-support.test.ts @@ -1,18 +1,9 @@ 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(() => ({ +const { validateShiftMock } = vi.hoisted(() => ({ validateShiftMock: vi.fn(), - calculateAllocationMock: vi.fn(), - updateAssignmentMock: vi.fn(), - updateDemandRequirementMock: vi.fn(), })); vi.mock("@capakraken/engine", async (importOriginal) => { @@ -20,21 +11,10 @@ vi.mock("@capakraken/engine", async (importOriginal) => { return { ...actual, validateShift: validateShiftMock, - calculateAllocation: calculateAllocationMock, - }; -}); - -vi.mock("@capakraken/application", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - updateAssignment: updateAssignmentMock, - updateDemandRequirement: updateDemandRequirementMock, }; }); import { - applyTimelineProjectShift, assertTimelineProjectShiftValid, buildTimelineProjectShiftEventPayload, buildTimelineProjectShiftValidation, @@ -66,27 +46,6 @@ describe("timeline shift support", () => { 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: { @@ -99,7 +58,31 @@ describe("timeline shift support", () => { demandRequirements: [], assignments: [], shiftPlan: { - validationAllocations, + 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, + resource: { + id: "resource_1", + displayName: "Alice", + lcrCents: 5_000, + availability: { + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + }, + }, + allAllocationsForResource: [], + }, + ], }, }, newStartDate: new Date("2026-04-03"), @@ -117,7 +100,12 @@ describe("timeline shift support", () => { }, newStartDate: new Date("2026-04-03"), newEndDate: new Date("2026-04-12"), - allocations: validationAllocations, + allocations: [ + expect.objectContaining({ + id: "assignment_1", + resourceId: "resource_1", + }), + ], }); }); @@ -138,150 +126,26 @@ describe("timeline shift support", () => { })); }); - it("applies project shift updates and recalculates only staffed assignments", async () => { + it("builds project shifted event payloads", () => { 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({ + expect( + buildTimelineProjectShiftEventPayload({ projectId: "project_1", - newStartDate: new Date("2026-04-03"), - newEndDate: new Date("2026-04-12"), + newStartDate: new Date("2026-04-03T00:00:00.000Z"), + newEndDate: new Date("2026-04-12T00:00:00.000Z"), validation, - assignments: context.assignments as never, + assignments: [ + { resourceId: "resource_1" }, + { resourceId: null }, + ] as never, }), + ).toEqual({ + projectId: "project_1", + newStartDate: "2026-04-03T00:00:00.000Z", + newEndDate: "2026-04-12T00:00:00.000Z", + costDeltaCents: 12_000, + resourceIds: ["resource_1", null], }); }); }); diff --git a/packages/api/src/router/timeline-shift-procedure-support.ts b/packages/api/src/router/timeline-shift-procedure-support.ts new file mode 100644 index 0000000..d171719 --- /dev/null +++ b/packages/api/src/router/timeline-shift-procedure-support.ts @@ -0,0 +1,96 @@ +import { + updateAssignment, + updateDemandRequirement, +} from "@capakraken/application"; +import type { PrismaClient } from "@capakraken/db"; +import { + assertTimelineProjectShiftValid, + buildTimelineProjectShiftEventPayload, + buildTimelineProjectShiftValidation, + type LoadedTimelineShiftContext, +} from "./timeline-shift-support.js"; +import { + buildTimelineProjectDateRangeUpdate, + buildTimelineProjectShiftAuditChanges, + buildTimelineShiftedAssignmentUpdate, + recalculateShiftedAssignmentDailyCost, +} from "./timeline-shift-mutation-support.js"; + +export interface ApplyTimelineProjectShiftInput { + db: PrismaClient; + projectId: string; + newStartDate: Date; + newEndDate: Date; + context: LoadedTimelineShiftContext; +} + +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: buildTimelineProjectDateRangeUpdate(input), + }); + + for (const demandRequirement of input.context.demandRequirements) { + await updateDemandRequirement( + tx as unknown as Parameters[0], + demandRequirement.id, + buildTimelineProjectDateRangeUpdate(input), + ); + } + + 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[0], + assignment.id, + buildTimelineShiftedAssignmentUpdate({ + newStartDate: input.newStartDate, + newEndDate: input.newEndDate, + 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, + }), + }; +} diff --git a/packages/api/src/router/timeline-shift-support.ts b/packages/api/src/router/timeline-shift-support.ts index 2e98542..f240ef6 100644 --- a/packages/api/src/router/timeline-shift-support.ts +++ b/packages/api/src/router/timeline-shift-support.ts @@ -1,20 +1,11 @@ -import { - updateAssignment, - updateDemandRequirement, - type SplitAssignmentRecord, - type SplitDemandRequirementRecord, +import type { + SplitAssignmentRecord, + SplitDemandRequirementRecord, } from "@capakraken/application"; -import type { PrismaClient } from "@capakraken/db"; import { validateShift } from "@capakraken/engine"; import type { ShiftValidationResult } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import type { TimelineShiftPlan } from "./timeline-shift-planning.js"; -import { - buildTimelineProjectDateRangeUpdate, - buildTimelineProjectShiftAuditChanges, - buildTimelineShiftedAssignmentUpdate, - recalculateShiftedAssignmentDailyCost, -} from "./timeline-shift-mutation-support.js"; export interface TimelineShiftProjectRecord { id: string; @@ -31,14 +22,6 @@ export interface LoadedTimelineShiftContext { shiftPlan: TimelineShiftPlan; } -export interface ApplyTimelineProjectShiftInput { - db: PrismaClient; - projectId: string; - newStartDate: Date; - newEndDate: Date; - context: LoadedTimelineShiftContext; -} - export interface TimelineProjectShiftEventPayload extends Record { projectId: string; newStartDate: string; @@ -96,74 +79,3 @@ export function buildTimelineProjectShiftEventPayload(input: { resourceIds: input.assignments.map((assignment) => assignment.resourceId), }; } - -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: buildTimelineProjectDateRangeUpdate(input), - }); - - for (const demandRequirement of input.context.demandRequirements) { - await updateDemandRequirement( - tx as unknown as Parameters[0], - demandRequirement.id, - buildTimelineProjectDateRangeUpdate(input), - ); - } - - 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[0], - assignment.id, - buildTimelineShiftedAssignmentUpdate({ - newStartDate: input.newStartDate, - newEndDate: input.newEndDate, - 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, - }), - }; -} diff --git a/packages/api/src/router/timeline.ts b/packages/api/src/router/timeline.ts index 1dcb437..e70cb6a 100644 --- a/packages/api/src/router/timeline.ts +++ b/packages/api/src/router/timeline.ts @@ -4,7 +4,7 @@ import { createTRPCRouter, managerProcedure, requirePermission } from "../trpc.j import { timelineAllocationMutationProcedures } from "./timeline-allocation-mutations.js"; import { timelineReadProcedures } from "./timeline-read.js"; import { loadProjectShiftContext } from "./timeline-project-load-support.js"; -import { applyTimelineProjectShift } from "./timeline-shift-support.js"; +import { applyTimelineProjectShift } from "./timeline-shift-procedure-support.js"; export const timelineRouter = createTRPCRouter({ ...timelineReadProcedures,