refactor(api): extract timeline shift mutation helpers
This commit is contained in:
@@ -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 SplitDemandRequirementRecord,
|
||||
} from "@capakraken/application";
|
||||
import { Prisma, type PrismaClient } from "@capakraken/db";
|
||||
import type { PrismaClient } from "@capakraken/db";
|
||||
import { validateShift } from "@capakraken/engine";
|
||||
import type { ShiftValidationResult } from "@capakraken/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js";
|
||||
import type { TimelineShiftPlan } from "./timeline-shift-planning.js";
|
||||
import {
|
||||
buildTimelineProjectDateRangeUpdate,
|
||||
buildTimelineProjectShiftAuditChanges,
|
||||
buildTimelineShiftedAssignmentUpdate,
|
||||
recalculateShiftedAssignmentDailyCost,
|
||||
} from "./timeline-shift-mutation-support.js";
|
||||
|
||||
export interface TimelineShiftProjectRecord {
|
||||
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) {
|
||||
const validation = buildTimelineProjectShiftValidation({
|
||||
context: input.context,
|
||||
@@ -146,20 +108,14 @@ export async function applyTimelineProjectShift(input: ApplyTimelineProjectShift
|
||||
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,
|
||||
},
|
||||
data: buildTimelineProjectDateRangeUpdate(input),
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
buildTimelineProjectDateRangeUpdate(input),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -174,11 +130,11 @@ export async function applyTimelineProjectShift(input: ApplyTimelineProjectShift
|
||||
await updateAssignment(
|
||||
tx as unknown as Parameters<typeof updateAssignment>[0],
|
||||
assignment.id,
|
||||
{
|
||||
startDate: input.newStartDate,
|
||||
endDate: input.newEndDate,
|
||||
...(dailyCostCents !== undefined ? { dailyCostCents } : {}),
|
||||
},
|
||||
buildTimelineShiftedAssignmentUpdate({
|
||||
newStartDate: input.newStartDate,
|
||||
newEndDate: input.newEndDate,
|
||||
dailyCostCents,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user