refactor(api): extract timeline allocation batch shift support
This commit is contained in:
@@ -0,0 +1,146 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const {
|
||||||
|
findAllocationEntryMock,
|
||||||
|
updateAllocationEntryMock,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
findAllocationEntryMock: vi.fn(),
|
||||||
|
updateAllocationEntryMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
findAllocationEntry: findAllocationEntryMock,
|
||||||
|
updateAllocationEntry: updateAllocationEntryMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { applyTimelineBatchAllocationShift } from "../router/timeline-allocation-shift-support.js";
|
||||||
|
|
||||||
|
describe("timeline allocation shift support", () => {
|
||||||
|
it("returns an empty result when the shift delta is zero", async () => {
|
||||||
|
const db = {
|
||||||
|
$transaction: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(applyTimelineBatchAllocationShift({
|
||||||
|
db: db as never,
|
||||||
|
allocationIds: ["allocation_1"],
|
||||||
|
daysDelta: 0,
|
||||||
|
mode: "move",
|
||||||
|
})).resolves.toEqual([]);
|
||||||
|
|
||||||
|
expect(findAllocationEntryMock).not.toHaveBeenCalled();
|
||||||
|
expect(db.$transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when no allocations can be resolved", async () => {
|
||||||
|
findAllocationEntryMock.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(applyTimelineBatchAllocationShift({
|
||||||
|
db: {
|
||||||
|
$transaction: vi.fn(),
|
||||||
|
} as never,
|
||||||
|
allocationIds: ["missing_1", "missing_2"],
|
||||||
|
daysDelta: 2,
|
||||||
|
mode: "move",
|
||||||
|
})).rejects.toThrowError(new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "No allocations found",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates resolved allocations and writes a batch audit record", async () => {
|
||||||
|
findAllocationEntryMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
entry: {
|
||||||
|
id: "allocation_1",
|
||||||
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-05T00:00:00.000Z"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
entry: {
|
||||||
|
id: "allocation_3",
|
||||||
|
startDate: new Date("2026-04-10T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-12T00:00:00.000Z"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
updateAllocationEntryMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
allocation: { id: "allocation_1", projectId: "project_1", resourceId: "resource_1" },
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
allocation: { id: "allocation_3", projectId: "project_2", resourceId: "resource_2" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const tx = {
|
||||||
|
auditLog: {
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const db = {
|
||||||
|
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await applyTimelineBatchAllocationShift({
|
||||||
|
db: db as never,
|
||||||
|
allocationIds: ["allocation_1", "missing_2", "allocation_3"],
|
||||||
|
daysDelta: 2,
|
||||||
|
mode: "move",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ id: "allocation_1", projectId: "project_1", resourceId: "resource_1" },
|
||||||
|
{ id: "allocation_3", projectId: "project_2", resourceId: "resource_2" },
|
||||||
|
]);
|
||||||
|
expect(updateAllocationEntryMock).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
tx,
|
||||||
|
{
|
||||||
|
id: "allocation_1",
|
||||||
|
demandRequirementUpdate: {
|
||||||
|
startDate: new Date("2026-04-03T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-07T00:00:00.000Z"),
|
||||||
|
},
|
||||||
|
assignmentUpdate: {
|
||||||
|
startDate: new Date("2026-04-03T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-07T00:00:00.000Z"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(updateAllocationEntryMock).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
tx,
|
||||||
|
{
|
||||||
|
id: "allocation_3",
|
||||||
|
demandRequirementUpdate: {
|
||||||
|
startDate: new Date("2026-04-12T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-14T00:00:00.000Z"),
|
||||||
|
},
|
||||||
|
assignmentUpdate: {
|
||||||
|
startDate: new Date("2026-04-12T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-14T00:00:00.000Z"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(tx.auditLog.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
entityType: "Allocation",
|
||||||
|
entityId: "allocation_1,missing_2,allocation_3",
|
||||||
|
action: "UPDATE",
|
||||||
|
changes: {
|
||||||
|
operation: "batchShift",
|
||||||
|
mode: "move",
|
||||||
|
daysDelta: 2,
|
||||||
|
count: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
buildSplitAllocationReadModel,
|
buildSplitAllocationReadModel,
|
||||||
createAssignment,
|
createAssignment,
|
||||||
findAllocationEntry,
|
|
||||||
loadAllocationEntry,
|
loadAllocationEntry,
|
||||||
updateAllocationEntry,
|
updateAllocationEntry,
|
||||||
} from "@capakraken/application";
|
} from "@capakraken/application";
|
||||||
@@ -25,11 +24,10 @@ import {
|
|||||||
buildTimelineAllocationEntryUpdate,
|
buildTimelineAllocationEntryUpdate,
|
||||||
buildTimelineAllocationMetadata,
|
buildTimelineAllocationMetadata,
|
||||||
buildTimelineAllocationUpdateAuditChanges,
|
buildTimelineAllocationUpdateAuditChanges,
|
||||||
buildTimelineBatchShiftAuditChanges,
|
|
||||||
buildTimelineQuickAssignAssignmentInput,
|
buildTimelineQuickAssignAssignmentInput,
|
||||||
shiftTimelineAllocationWindow,
|
|
||||||
validateTimelineAllocationDateRanges,
|
validateTimelineAllocationDateRanges,
|
||||||
} from "./timeline-allocation-mutation-support.js";
|
} from "./timeline-allocation-mutation-support.js";
|
||||||
|
import { applyTimelineBatchAllocationShift } from "./timeline-allocation-shift-support.js";
|
||||||
import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js";
|
import { calculateTimelineAllocationDailyCost } from "./timeline-cost-support.js";
|
||||||
|
|
||||||
export const timelineAllocationMutationProcedures = {
|
export const timelineAllocationMutationProcedures = {
|
||||||
@@ -227,65 +225,13 @@ export const timelineAllocationMutationProcedures = {
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||||
|
|
||||||
if (input.daysDelta === 0) {
|
const results = await applyTimelineBatchAllocationShift({
|
||||||
return { count: 0 };
|
db: ctx.db as PrismaClient,
|
||||||
}
|
allocationIds: input.allocationIds,
|
||||||
|
|
||||||
const entries = await Promise.all(
|
|
||||||
input.allocationIds.map((allocationId) => findAllocationEntry(ctx.db, allocationId)),
|
|
||||||
);
|
|
||||||
const resolved = entries.filter(
|
|
||||||
(entry): entry is NonNullable<typeof entry> => entry !== null,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (resolved.length === 0) {
|
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "No allocations found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await ctx.db.$transaction(async (tx) => {
|
|
||||||
const updated = [];
|
|
||||||
for (const entry of resolved) {
|
|
||||||
const existing = entry.entry;
|
|
||||||
const shiftedWindow = shiftTimelineAllocationWindow({
|
|
||||||
startDate: existing.startDate,
|
|
||||||
endDate: existing.endDate,
|
|
||||||
daysDelta: input.daysDelta,
|
daysDelta: input.daysDelta,
|
||||||
mode: input.mode,
|
mode: input.mode,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await updateAllocationEntry(
|
|
||||||
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
|
|
||||||
{
|
|
||||||
id: existing.id,
|
|
||||||
demandRequirementUpdate: {
|
|
||||||
startDate: shiftedWindow.startDate,
|
|
||||||
endDate: shiftedWindow.endDate,
|
|
||||||
},
|
|
||||||
assignmentUpdate: {
|
|
||||||
startDate: shiftedWindow.startDate,
|
|
||||||
endDate: shiftedWindow.endDate,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
updated.push(result.allocation);
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.auditLog.create({
|
|
||||||
data: {
|
|
||||||
entityType: "Allocation",
|
|
||||||
entityId: input.allocationIds.join(","),
|
|
||||||
action: "UPDATE",
|
|
||||||
changes: buildTimelineBatchShiftAuditChanges({
|
|
||||||
mode: input.mode,
|
|
||||||
daysDelta: input.daysDelta,
|
|
||||||
count: resolved.length,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const allocation of results) {
|
for (const allocation of results) {
|
||||||
emitAllocationUpdated({
|
emitAllocationUpdated({
|
||||||
id: allocation.id,
|
id: allocation.id,
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { findAllocationEntry, updateAllocationEntry } from "@capakraken/application";
|
||||||
|
import type { PrismaClient } from "@capakraken/db";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import {
|
||||||
|
buildTimelineBatchShiftAuditChanges,
|
||||||
|
shiftTimelineAllocationWindow,
|
||||||
|
type TimelineBatchShiftMode,
|
||||||
|
} from "./timeline-allocation-mutation-support.js";
|
||||||
|
|
||||||
|
export async function applyTimelineBatchAllocationShift(input: {
|
||||||
|
db: PrismaClient;
|
||||||
|
allocationIds: string[];
|
||||||
|
daysDelta: number;
|
||||||
|
mode: TimelineBatchShiftMode;
|
||||||
|
}) {
|
||||||
|
if (input.daysDelta === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await Promise.all(
|
||||||
|
input.allocationIds.map((allocationId) => findAllocationEntry(input.db, allocationId)),
|
||||||
|
);
|
||||||
|
const resolved = entries.filter(
|
||||||
|
(entry): entry is NonNullable<typeof entry> => entry !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resolved.length === 0) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "No allocations found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return input.db.$transaction(async (tx) => {
|
||||||
|
const updated = [];
|
||||||
|
for (const entry of resolved) {
|
||||||
|
const existing = entry.entry;
|
||||||
|
const shiftedWindow = shiftTimelineAllocationWindow({
|
||||||
|
startDate: existing.startDate,
|
||||||
|
endDate: existing.endDate,
|
||||||
|
daysDelta: input.daysDelta,
|
||||||
|
mode: input.mode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await updateAllocationEntry(
|
||||||
|
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
|
||||||
|
{
|
||||||
|
id: existing.id,
|
||||||
|
demandRequirementUpdate: {
|
||||||
|
startDate: shiftedWindow.startDate,
|
||||||
|
endDate: shiftedWindow.endDate,
|
||||||
|
},
|
||||||
|
assignmentUpdate: {
|
||||||
|
startDate: shiftedWindow.startDate,
|
||||||
|
endDate: shiftedWindow.endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
updated.push(result.allocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: {
|
||||||
|
entityType: "Allocation",
|
||||||
|
entityId: input.allocationIds.join(","),
|
||||||
|
action: "UPDATE",
|
||||||
|
changes: buildTimelineBatchShiftAuditChanges({
|
||||||
|
mode: input.mode,
|
||||||
|
daysDelta: input.daysDelta,
|
||||||
|
count: resolved.length,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user