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 { projectId: string; newStartDate: string; newEndDate: string; costDeltaCents: number; resourceIds: Array; } 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 { 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; 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[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[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, }), }; }