225 lines
6.4 KiB
TypeScript
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,
|
|
}),
|
|
};
|
|
}
|