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