Files
CapaKraken/packages/api/src/router/timeline-shift-support.ts
T

225 lines
6.4 KiB
TypeScript

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,
}),
};
}