refactor(api): extract timeline shift mutation helpers

This commit is contained in:
2026-03-31 15:50:17 +02:00
parent 345e9dd623
commit ef3db11c35
3 changed files with 234 additions and 58 deletions
@@ -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<typeof import("@capakraken/engine")>();
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();
});
});
@@ -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<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;
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,
});
}
@@ -4,12 +4,17 @@ import {
type SplitAssignmentRecord, type SplitAssignmentRecord,
type SplitDemandRequirementRecord, type SplitDemandRequirementRecord,
} from "@capakraken/application"; } from "@capakraken/application";
import { Prisma, type PrismaClient } from "@capakraken/db"; import type { PrismaClient } from "@capakraken/db";
import { validateShift } from "@capakraken/engine"; import { validateShift } from "@capakraken/engine";
import type { ShiftValidationResult } from "@capakraken/shared"; import type { ShiftValidationResult } from "@capakraken/shared";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js";
import type { TimelineShiftPlan } from "./timeline-shift-planning.js"; import type { TimelineShiftPlan } from "./timeline-shift-planning.js";
import {
buildTimelineProjectDateRangeUpdate,
buildTimelineProjectShiftAuditChanges,
buildTimelineShiftedAssignmentUpdate,
recalculateShiftedAssignmentDailyCost,
} from "./timeline-shift-mutation-support.js";
export interface TimelineShiftProjectRecord { export interface TimelineShiftProjectRecord {
id: string; 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<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;
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) { export async function applyTimelineProjectShift(input: ApplyTimelineProjectShiftInput) {
const validation = buildTimelineProjectShiftValidation({ const validation = buildTimelineProjectShiftValidation({
context: input.context, context: input.context,
@@ -146,20 +108,14 @@ export async function applyTimelineProjectShift(input: ApplyTimelineProjectShift
const updatedProject = await input.db.$transaction(async (tx) => { const updatedProject = await input.db.$transaction(async (tx) => {
const projectRecord = await tx.project.update({ const projectRecord = await tx.project.update({
where: { id: input.projectId }, where: { id: input.projectId },
data: { data: buildTimelineProjectDateRangeUpdate(input),
startDate: input.newStartDate,
endDate: input.newEndDate,
},
}); });
for (const demandRequirement of input.context.demandRequirements) { for (const demandRequirement of input.context.demandRequirements) {
await updateDemandRequirement( await updateDemandRequirement(
tx as unknown as Parameters<typeof updateDemandRequirement>[0], tx as unknown as Parameters<typeof updateDemandRequirement>[0],
demandRequirement.id, demandRequirement.id,
{ buildTimelineProjectDateRangeUpdate(input),
startDate: input.newStartDate,
endDate: input.newEndDate,
},
); );
} }
@@ -174,11 +130,11 @@ export async function applyTimelineProjectShift(input: ApplyTimelineProjectShift
await updateAssignment( await updateAssignment(
tx as unknown as Parameters<typeof updateAssignment>[0], tx as unknown as Parameters<typeof updateAssignment>[0],
assignment.id, assignment.id,
{ buildTimelineShiftedAssignmentUpdate({
startDate: input.newStartDate, newStartDate: input.newStartDate,
endDate: input.newEndDate, newEndDate: input.newEndDate,
...(dailyCostCents !== undefined ? { dailyCostCents } : {}), dailyCostCents,
}, }),
); );
} }