diff --git a/packages/api/src/__tests__/timeline-shift-mutation-support.test.ts b/packages/api/src/__tests__/timeline-shift-mutation-support.test.ts new file mode 100644 index 0000000..c0f21a3 --- /dev/null +++ b/packages/api/src/__tests__/timeline-shift-mutation-support.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from "vitest"; + +const { calculateAllocationMock } = vi.hoisted(() => ({ + calculateAllocationMock: vi.fn(), +})); + +vi.mock("@capakraken/engine", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + calculateAllocation: calculateAllocationMock, + }; +}); + +import { + buildTimelineProjectDateRangeUpdate, + buildTimelineProjectShiftAuditChanges, + buildTimelineShiftedAssignmentUpdate, + recalculateShiftedAssignmentDailyCost, +} from "../router/timeline-shift-mutation-support.js"; + +describe("timeline shift mutation support", () => { + it("builds shared date range updates for project and demand records", () => { + expect( + buildTimelineProjectDateRangeUpdate({ + newStartDate: new Date("2026-04-03T00:00:00.000Z"), + newEndDate: new Date("2026-04-12T00:00:00.000Z"), + }), + ).toEqual({ + startDate: new Date("2026-04-03T00:00:00.000Z"), + endDate: new Date("2026-04-12T00:00:00.000Z"), + }); + }); + + it("builds assignment updates with optional recalculated daily cost", () => { + expect( + buildTimelineShiftedAssignmentUpdate({ + newStartDate: new Date("2026-04-03T00:00:00.000Z"), + newEndDate: new Date("2026-04-12T00:00:00.000Z"), + dailyCostCents: 54321, + }), + ).toEqual({ + startDate: new Date("2026-04-03T00:00:00.000Z"), + endDate: new Date("2026-04-12T00:00:00.000Z"), + dailyCostCents: 54321, + }); + + expect( + buildTimelineShiftedAssignmentUpdate({ + newStartDate: new Date("2026-04-03T00:00:00.000Z"), + newEndDate: new Date("2026-04-12T00:00:00.000Z"), + dailyCostCents: undefined, + }), + ).toEqual({ + startDate: new Date("2026-04-03T00:00:00.000Z"), + endDate: new Date("2026-04-12T00:00:00.000Z"), + }); + }); + + it("builds project shift audit payloads", () => { + expect( + buildTimelineProjectShiftAuditChanges({ + project: { + id: "project_1", + budgetCents: 200_000, + winProbability: 80, + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + }, + newStartDate: new Date("2026-04-03T00:00:00.000Z"), + newEndDate: new Date("2026-04-12T00:00:00.000Z"), + validation: { + valid: true, + errors: [], + warnings: [], + conflictDetails: [], + costImpact: { + currentTotalCents: 100_000, + newTotalCents: 112_000, + deltaCents: 12_000, + budgetCents: 200_000, + budgetUtilizationBefore: 50, + budgetUtilizationAfter: 56, + wouldExceedBudget: false, + }, + }, + }), + ).toEqual({ + before: { + startDate: new Date("2026-04-01T00:00:00.000Z"), + endDate: new Date("2026-04-10T00:00:00.000Z"), + }, + after: { + startDate: new Date("2026-04-03T00:00:00.000Z"), + endDate: new Date("2026-04-12T00:00:00.000Z"), + }, + costImpact: { + currentTotalCents: 100_000, + newTotalCents: 112_000, + deltaCents: 12_000, + budgetCents: 200_000, + budgetUtilizationBefore: 50, + budgetUtilizationAfter: 56, + wouldExceedBudget: false, + }, + }); + }); + + it("recalculates daily cost only for staffed assignments", async () => { + calculateAllocationMock.mockReturnValueOnce({ dailyCostCents: 54321 }); + + await expect( + recalculateShiftedAssignmentDailyCost({ + db: { + vacation: { findMany: vi.fn().mockResolvedValue([]) }, + calculationRule: { findMany: vi.fn().mockResolvedValue([{ id: "rule_1" }]) }, + } as never, + assignment: { + resourceId: "resource_1", + hoursPerDay: 8, + metadata: { includeSaturday: true }, + resource: { + lcrCents: 5_000, + availability: { + monday: 8, + tuesday: 8, + wednesday: 8, + thursday: 8, + friday: 8, + }, + }, + } as never, + newStartDate: new Date("2026-04-03T00:00:00.000Z"), + newEndDate: new Date("2026-04-12T00:00:00.000Z"), + }), + ).resolves.toBe(54321); + + await expect( + recalculateShiftedAssignmentDailyCost({ + db: {} as never, + assignment: { + resourceId: null, + resource: null, + } as never, + newStartDate: new Date("2026-04-03T00:00:00.000Z"), + newEndDate: new Date("2026-04-12T00:00:00.000Z"), + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/api/src/router/timeline-shift-mutation-support.ts b/packages/api/src/router/timeline-shift-mutation-support.ts new file mode 100644 index 0000000..ae62436 --- /dev/null +++ b/packages/api/src/router/timeline-shift-mutation-support.ts @@ -0,0 +1,70 @@ +import type { SplitAssignmentRecord } from "@capakraken/application"; +import { Prisma, type PrismaClient } from "@capakraken/db"; +import type { ShiftValidationResult, WeekdayAvailability } from "@capakraken/shared"; +import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js"; +import type { TimelineShiftProjectRecord } from "./timeline-shift-support.js"; + +export function buildTimelineProjectDateRangeUpdate(input: { + newStartDate: Date; + newEndDate: Date; +}) { + return { + startDate: input.newStartDate, + endDate: input.newEndDate, + }; +} + +export function buildTimelineShiftedAssignmentUpdate(input: { + newStartDate: Date; + newEndDate: Date; + dailyCostCents: number | undefined; +}) { + return { + ...buildTimelineProjectDateRangeUpdate(input), + ...(input.dailyCostCents !== undefined ? { dailyCostCents: input.dailyCostCents } : {}), + }; +} + +export 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; +} + +export async function recalculateShiftedAssignmentDailyCost(input: { + db: PrismaClient; + assignment: SplitAssignmentRecord; + newStartDate: Date; + newEndDate: Date; +}): Promise { + if (!input.assignment.resourceId || !input.assignment.resource) { + return undefined; + } + + const metadata = (input.assignment.metadata as Record | null | undefined) ?? {}; + const includeSaturday = (metadata.includeSaturday as boolean | undefined) ?? false; + + return calculateTimelineAllocationDailyCost({ + db: input.db, + resourceId: input.assignment.resourceId, + lcrCents: input.assignment.resource.lcrCents, + hoursPerDay: input.assignment.hoursPerDay, + startDate: input.newStartDate, + endDate: input.newEndDate, + availability: input.assignment.resource.availability as WeekdayAvailability, + includeSaturday, + }); +} diff --git a/packages/api/src/router/timeline-shift-support.ts b/packages/api/src/router/timeline-shift-support.ts index 0c5d28b..2e98542 100644 --- a/packages/api/src/router/timeline-shift-support.ts +++ b/packages/api/src/router/timeline-shift-support.ts @@ -4,12 +4,17 @@ import { type SplitAssignmentRecord, type SplitDemandRequirementRecord, } from "@capakraken/application"; -import { Prisma, type PrismaClient } from "@capakraken/db"; +import type { PrismaClient } from "@capakraken/db"; import { validateShift } from "@capakraken/engine"; import type { ShiftValidationResult } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; -import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js"; import type { TimelineShiftPlan } from "./timeline-shift-planning.js"; +import { + buildTimelineProjectDateRangeUpdate, + buildTimelineProjectShiftAuditChanges, + buildTimelineShiftedAssignmentUpdate, + recalculateShiftedAssignmentDailyCost, +} from "./timeline-shift-mutation-support.js"; export interface TimelineShiftProjectRecord { id: string; @@ -92,49 +97,6 @@ export function buildTimelineProjectShiftEventPayload(input: { }; } -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 { - if (!input.assignment.resourceId || !input.assignment.resource) { - return undefined; - } - - const metadata = (input.assignment.metadata as Record | null | undefined) ?? {}; - const includeSaturday = (metadata.includeSaturday as boolean | undefined) ?? false; - return calculateTimelineAllocationDailyCost({ - db: input.db, - resourceId: input.assignment.resourceId, - lcrCents: input.assignment.resource.lcrCents, - hoursPerDay: input.assignment.hoursPerDay, - startDate: input.newStartDate, - endDate: input.newEndDate, - availability: input.assignment.resource.availability as import("@capakraken/shared").WeekdayAvailability, - includeSaturday, - }); -} - export async function applyTimelineProjectShift(input: ApplyTimelineProjectShiftInput) { const validation = buildTimelineProjectShiftValidation({ context: input.context, @@ -146,20 +108,14 @@ export async function applyTimelineProjectShift(input: ApplyTimelineProjectShift 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, - }, + data: buildTimelineProjectDateRangeUpdate(input), }); for (const demandRequirement of input.context.demandRequirements) { await updateDemandRequirement( tx as unknown as Parameters[0], demandRequirement.id, - { - startDate: input.newStartDate, - endDate: input.newEndDate, - }, + buildTimelineProjectDateRangeUpdate(input), ); } @@ -174,11 +130,11 @@ export async function applyTimelineProjectShift(input: ApplyTimelineProjectShift await updateAssignment( tx as unknown as Parameters[0], assignment.id, - { - startDate: input.newStartDate, - endDate: input.newEndDate, - ...(dailyCostCents !== undefined ? { dailyCostCents } : {}), - }, + buildTimelineShiftedAssignmentUpdate({ + newStartDate: input.newStartDate, + newEndDate: input.newEndDate, + dailyCostCents, + }), ); }