refactor(api): extract timeline shift procedure support

This commit is contained in:
2026-03-31 17:50:36 +02:00
parent 109bf70699
commit eef91a1068
5 changed files with 378 additions and 275 deletions
@@ -0,0 +1,231 @@
import type { ShiftValidationResult } from "@capakraken/shared";
import { AllocationStatus } from "@capakraken/shared";
import { describe, expect, it, vi } from "vitest";
const {
calculateAllocationMock,
updateAssignmentMock,
updateDemandRequirementMock,
validateShiftMock,
} = vi.hoisted(() => ({
validateShiftMock: vi.fn(),
calculateAllocationMock: vi.fn(),
updateAssignmentMock: vi.fn(),
updateDemandRequirementMock: vi.fn(),
}));
vi.mock("@capakraken/engine", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/engine")>();
return {
...actual,
validateShift: validateShiftMock,
calculateAllocation: calculateAllocationMock,
};
});
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
return {
...actual,
updateAssignment: updateAssignmentMock,
updateDemandRequirement: updateDemandRequirementMock,
};
});
import { buildTimelineProjectShiftEventPayload } from "../router/timeline-shift-support.js";
import { applyTimelineProjectShift } from "../router/timeline-shift-procedure-support.js";
function createValidValidationResult(
overrides: Partial<ShiftValidationResult> = {},
): ShiftValidationResult {
return {
valid: true,
errors: [],
warnings: [],
conflictDetails: [],
costImpact: {
currentTotalCents: 100_000,
newTotalCents: 112_000,
deltaCents: 12_000,
budgetCents: 200_000,
budgetUtilizationBefore: 50,
budgetUtilizationAfter: 56,
wouldExceedBudget: false,
},
...overrides,
};
}
describe("timeline shift procedure support", () => {
it("applies project shift updates and recalculates only staffed assignments", async () => {
const validation = createValidValidationResult();
validateShiftMock.mockReturnValue(validation);
calculateAllocationMock.mockReturnValue({ dailyCostCents: 54_321 });
updateDemandRequirementMock.mockResolvedValue({});
updateAssignmentMock.mockResolvedValue({});
const updatedProject = {
id: "project_1",
startDate: new Date("2026-04-03"),
endDate: new Date("2026-04-12"),
};
const db = {
project: {
update: vi.fn().mockResolvedValue(updatedProject),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
calculationRule: {
findMany: vi.fn().mockResolvedValue([{ id: "rule_1", priority: 1 }]),
},
$transaction: vi.fn(async (callback: (tx: typeof db) => unknown) => callback(db)),
};
const context = {
project: {
id: "project_1",
budgetCents: 200_000,
winProbability: 80,
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-10"),
},
demandRequirements: [
{
id: "demand_1",
},
],
assignments: [
{
id: "assignment_staffed",
resourceId: "resource_1",
hoursPerDay: 8,
metadata: { includeSaturday: true },
resource: {
lcrCents: 5_000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
},
},
{
id: "assignment_placeholder",
resourceId: null,
hoursPerDay: 4,
metadata: {},
resource: null,
},
],
shiftPlan: {
validationAllocations: [
{
id: "assignment_1",
resourceId: "resource_1",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-05"),
hoursPerDay: 8,
percentage: 100,
role: "Compositing",
dailyCostCents: 40_000,
status: AllocationStatus.ACTIVE,
resource: {
id: "resource_1",
displayName: "Alice",
lcrCents: 5_000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
},
allAllocationsForResource: [],
},
],
},
};
const result = await applyTimelineProjectShift({
db: db as never,
projectId: "project_1",
newStartDate: new Date("2026-04-03"),
newEndDate: new Date("2026-04-12"),
context: context as never,
});
expect(db.project.update).toHaveBeenCalledWith({
where: { id: "project_1" },
data: {
startDate: new Date("2026-04-03"),
endDate: new Date("2026-04-12"),
},
});
expect(updateDemandRequirementMock).toHaveBeenCalledWith(
db,
"demand_1",
{
startDate: new Date("2026-04-03"),
endDate: new Date("2026-04-12"),
},
);
expect(db.vacation.findMany).toHaveBeenCalledTimes(1);
expect(db.calculationRule.findMany).toHaveBeenCalledTimes(1);
expect(calculateAllocationMock).toHaveBeenCalledTimes(1);
expect(updateAssignmentMock).toHaveBeenNthCalledWith(
1,
db,
"assignment_staffed",
{
startDate: new Date("2026-04-03"),
endDate: new Date("2026-04-12"),
dailyCostCents: 54_321,
},
);
expect(updateAssignmentMock).toHaveBeenNthCalledWith(
2,
db,
"assignment_placeholder",
{
startDate: new Date("2026-04-03"),
endDate: new Date("2026-04-12"),
},
);
expect(db.auditLog.create).toHaveBeenCalledWith({
data: {
entityType: "Project",
entityId: "project_1",
action: "SHIFT",
changes: expect.objectContaining({
before: {
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-10"),
},
after: {
startDate: new Date("2026-04-03"),
endDate: new Date("2026-04-12"),
},
costImpact: validation.costImpact,
}),
},
});
expect(result).toEqual({
project: updatedProject,
validation,
event: buildTimelineProjectShiftEventPayload({
projectId: "project_1",
newStartDate: new Date("2026-04-03"),
newEndDate: new Date("2026-04-12"),
validation,
assignments: context.assignments as never,
}),
});
});
});
@@ -1,18 +1,9 @@
import type { ShiftValidationResult } from "@capakraken/shared";
import { AllocationStatus } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { describe, expect, it, vi } from "vitest";
const {
calculateAllocationMock,
updateAssignmentMock,
updateDemandRequirementMock,
validateShiftMock,
} = vi.hoisted(() => ({
const { validateShiftMock } = vi.hoisted(() => ({
validateShiftMock: vi.fn(),
calculateAllocationMock: vi.fn(),
updateAssignmentMock: vi.fn(),
updateDemandRequirementMock: vi.fn(),
}));
vi.mock("@capakraken/engine", async (importOriginal) => {
@@ -20,21 +11,10 @@ vi.mock("@capakraken/engine", async (importOriginal) => {
return {
...actual,
validateShift: validateShiftMock,
calculateAllocation: calculateAllocationMock,
};
});
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
return {
...actual,
updateAssignment: updateAssignmentMock,
updateDemandRequirement: updateDemandRequirementMock,
};
});
import {
applyTimelineProjectShift,
assertTimelineProjectShiftValid,
buildTimelineProjectShiftEventPayload,
buildTimelineProjectShiftValidation,
@@ -66,27 +46,6 @@ describe("timeline shift support", () => {
const expectedValidation = createValidValidationResult();
validateShiftMock.mockReturnValue(expectedValidation);
const validationAllocations = [
{
id: "assignment_1",
resourceId: "resource_1",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-05"),
hoursPerDay: 8,
percentage: 100,
role: "Compositing",
dailyCostCents: 40_000,
status: AllocationStatus.ACTIVE,
resource: {
id: "resource_1",
displayName: "Alice",
lcrCents: 5_000,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
},
allAllocationsForResource: [],
},
];
const result = buildTimelineProjectShiftValidation({
context: {
project: {
@@ -99,7 +58,31 @@ describe("timeline shift support", () => {
demandRequirements: [],
assignments: [],
shiftPlan: {
validationAllocations,
validationAllocations: [
{
id: "assignment_1",
resourceId: "resource_1",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-05"),
hoursPerDay: 8,
percentage: 100,
role: "Compositing",
dailyCostCents: 40_000,
resource: {
id: "resource_1",
displayName: "Alice",
lcrCents: 5_000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
},
allAllocationsForResource: [],
},
],
},
},
newStartDate: new Date("2026-04-03"),
@@ -117,7 +100,12 @@ describe("timeline shift support", () => {
},
newStartDate: new Date("2026-04-03"),
newEndDate: new Date("2026-04-12"),
allocations: validationAllocations,
allocations: [
expect.objectContaining({
id: "assignment_1",
resourceId: "resource_1",
}),
],
});
});
@@ -138,150 +126,26 @@ describe("timeline shift support", () => {
}));
});
it("applies project shift updates and recalculates only staffed assignments", async () => {
it("builds project shifted event payloads", () => {
const validation = createValidValidationResult();
validateShiftMock.mockReturnValue(validation);
calculateAllocationMock.mockReturnValue({ dailyCostCents: 54_321 });
updateDemandRequirementMock.mockResolvedValue({});
updateAssignmentMock.mockResolvedValue({});
const updatedProject = {
id: "project_1",
startDate: new Date("2026-04-03"),
endDate: new Date("2026-04-12"),
};
const db = {
project: {
update: vi.fn().mockResolvedValue(updatedProject),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
calculationRule: {
findMany: vi.fn().mockResolvedValue([{ id: "rule_1", priority: 1 }]),
},
$transaction: vi.fn(async (callback: (tx: typeof db) => unknown) => callback(db)),
};
const context = {
project: {
id: "project_1",
budgetCents: 200_000,
winProbability: 80,
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-10"),
},
demandRequirements: [
{
id: "demand_1",
},
],
assignments: [
{
id: "assignment_staffed",
resourceId: "resource_1",
hoursPerDay: 8,
metadata: { includeSaturday: true },
resource: {
lcrCents: 5_000,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
},
},
{
id: "assignment_placeholder",
resourceId: null,
hoursPerDay: 4,
metadata: {},
resource: null,
},
],
shiftPlan: {
validationAllocations: [],
},
};
const result = await applyTimelineProjectShift({
db: db as never,
projectId: "project_1",
newStartDate: new Date("2026-04-03"),
newEndDate: new Date("2026-04-12"),
context: context as never,
});
expect(db.project.update).toHaveBeenCalledWith({
where: { id: "project_1" },
data: {
startDate: new Date("2026-04-03"),
endDate: new Date("2026-04-12"),
},
});
expect(updateDemandRequirementMock).toHaveBeenCalledWith(
db,
"demand_1",
{
startDate: new Date("2026-04-03"),
endDate: new Date("2026-04-12"),
},
);
expect(db.vacation.findMany).toHaveBeenCalledTimes(1);
expect(db.calculationRule.findMany).toHaveBeenCalledTimes(1);
expect(calculateAllocationMock).toHaveBeenCalledTimes(1);
expect(updateAssignmentMock).toHaveBeenNthCalledWith(
1,
db,
"assignment_staffed",
{
startDate: new Date("2026-04-03"),
endDate: new Date("2026-04-12"),
dailyCostCents: 54_321,
},
);
expect(updateAssignmentMock).toHaveBeenNthCalledWith(
2,
db,
"assignment_placeholder",
{
startDate: new Date("2026-04-03"),
endDate: new Date("2026-04-12"),
},
);
expect(db.auditLog.create).toHaveBeenCalledWith({
data: {
entityType: "Project",
entityId: "project_1",
action: "SHIFT",
changes: expect.objectContaining({
before: {
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-10"),
},
after: {
startDate: new Date("2026-04-03"),
endDate: new Date("2026-04-12"),
},
costImpact: validation.costImpact,
}),
},
});
expect(result).toEqual({
project: updatedProject,
validation,
event: buildTimelineProjectShiftEventPayload({
expect(
buildTimelineProjectShiftEventPayload({
projectId: "project_1",
newStartDate: new Date("2026-04-03"),
newEndDate: new Date("2026-04-12"),
newStartDate: new Date("2026-04-03T00:00:00.000Z"),
newEndDate: new Date("2026-04-12T00:00:00.000Z"),
validation,
assignments: context.assignments as never,
assignments: [
{ resourceId: "resource_1" },
{ resourceId: null },
] as never,
}),
).toEqual({
projectId: "project_1",
newStartDate: "2026-04-03T00:00:00.000Z",
newEndDate: "2026-04-12T00:00:00.000Z",
costDeltaCents: 12_000,
resourceIds: ["resource_1", null],
});
});
});