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 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,
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user