refactor(api): extract timeline shift support
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user