refactor(api): extract timeline project budget mapping

This commit is contained in:
2026-03-31 15:18:43 +02:00
parent a018d04251
commit ea10851fe4
3 changed files with 166 additions and 34 deletions
@@ -1,10 +1,13 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
buildTimelineBudgetStatusAllocations,
buildTimelineBudgetStatusResponse,
buildTimelineProjectAssignmentConflicts, buildTimelineProjectAssignmentConflicts,
buildTimelineProjectContextDetailResponse, buildTimelineProjectContextDetailResponse,
buildTimelineProjectContextResponse, buildTimelineProjectContextResponse,
buildTimelineProjectContextSummary, buildTimelineProjectContextSummary,
buildTimelineShiftValidationBookings,
buildTimelineShiftPreviewDetailResponse, buildTimelineShiftPreviewDetailResponse,
resolveTimelineProjectContextPeriod, resolveTimelineProjectContextPeriod,
} from "../router/timeline-project-context-support.js"; } from "../router/timeline-project-context-support.js";
@@ -371,4 +374,81 @@ describe("timeline project context support", () => {
preview: { valid: true }, preview: { valid: true },
}); });
}); });
it("maps shift validation bookings and budget status payloads", () => {
expect(
buildTimelineShiftValidationBookings([
{
id: "booking_1",
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-05T00:00:00.000Z"),
hoursPerDay: 8,
status: "CONFIRMED",
},
{
id: "booking_2",
resourceId: null,
projectId: "project_1",
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-05T00:00:00.000Z"),
hoursPerDay: 4,
status: "PROPOSED",
},
]),
).toEqual([
{
id: "booking_1",
resourceId: "resource_1",
projectId: "project_1",
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-05T00:00:00.000Z"),
hoursPerDay: 8,
status: "CONFIRMED",
},
]);
expect(
buildTimelineBudgetStatusAllocations([
{
status: "CONFIRMED",
dailyCostCents: 40000,
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-05T00:00:00.000Z"),
hoursPerDay: 8,
},
]),
).toEqual([
{
status: "CONFIRMED",
dailyCostCents: 40000,
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-05T00:00:00.000Z"),
hoursPerDay: 8,
},
]);
expect(
buildTimelineBudgetStatusResponse({
project: {
name: "Project One",
shortCode: "PRJ",
budgetCents: 120000,
},
budgetStatus: {
withinBudget: true,
varianceCents: 2000,
},
totalAllocations: 3,
}),
).toEqual({
withinBudget: true,
varianceCents: 2000,
projectName: "Project One",
projectCode: "PRJ",
totalAllocations: 3,
budgetCents: 120000,
});
});
}); });
@@ -1,4 +1,5 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import type { Allocation } from "@capakraken/shared";
import { summarizeHolidayOverlays, type formatHolidayOverlays } from "./timeline-holiday-read.js"; import { summarizeHolidayOverlays, type formatHolidayOverlays } from "./timeline-holiday-read.js";
import { import {
anonymizeResourceOnEntry, anonymizeResourceOnEntry,
@@ -162,6 +163,79 @@ export function buildTimelineProjectContextSummary(input: {
}; };
} }
export function buildTimelineShiftValidationBookings(
bookings: Array<{
id: string;
resourceId: string | null;
projectId: string | null;
startDate: Date;
endDate: Date;
hoursPerDay: number;
status: string;
}>,
) {
return bookings
.filter(
(
booking,
): booking is typeof booking & { resourceId: string; projectId: string } =>
booking.resourceId !== null && booking.projectId !== null,
)
.map((booking) => ({
id: booking.id,
resourceId: booking.resourceId,
projectId: booking.projectId,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
}));
}
export function buildTimelineBudgetStatusAllocations(
bookings: Array<{
status: string;
dailyCostCents: number;
startDate: Date;
endDate: Date;
hoursPerDay: number;
}>,
): Pick<
Allocation,
"status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay"
>[] {
return bookings.map((booking) => ({
status: booking.status as Allocation["status"],
dailyCostCents: booking.dailyCostCents,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
}));
}
export function buildTimelineBudgetStatusResponse<TBudgetStatus extends object>(input: {
project: {
name: string;
shortCode: string | null;
budgetCents: number;
};
budgetStatus: TBudgetStatus;
totalAllocations: number;
}): TBudgetStatus & {
projectName: string;
projectCode: string;
totalAllocations: number;
budgetCents: number;
} {
return {
...input.budgetStatus,
projectName: input.project.name,
projectCode: input.project.shortCode ?? "",
totalAllocations: input.totalAllocations,
budgetCents: input.project.budgetCents,
};
}
export function buildTimelineProjectContextResponse< export function buildTimelineProjectContextResponse<
TProject, TProject,
TAllocation extends { resource?: { id: string } | null }, TAllocation extends { resource?: { id: string } | null },
@@ -7,21 +7,18 @@ import { getAnonymizationDirectory } from "../lib/anonymization.js";
import { controllerProcedure } from "../trpc.js"; import { controllerProcedure } from "../trpc.js";
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js"; import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
import { import {
buildTimelineBudgetStatusAllocations,
buildTimelineBudgetStatusResponse,
buildTimelineProjectContextDetailResponse, buildTimelineProjectContextDetailResponse,
buildTimelineProjectContextResponse, buildTimelineProjectContextResponse,
buildTimelineProjectAssignmentConflicts, buildTimelineProjectAssignmentConflicts,
buildTimelineProjectContextSummary, buildTimelineShiftValidationBookings,
buildTimelineShiftPreviewDetailResponse, buildTimelineShiftPreviewDetailResponse,
resolveTimelineProjectContextPeriod, resolveTimelineProjectContextPeriod,
} from "./timeline-project-context-support.js"; } from "./timeline-project-context-support.js";
import { buildTimelineShiftPlan } from "./timeline-shift-planning.js"; import { buildTimelineShiftPlan } from "./timeline-shift-planning.js";
import { loadTimelineHolidayOverlays, formatHolidayOverlays } from "./timeline-holiday-read.js"; import { loadTimelineHolidayOverlays, formatHolidayOverlays } from "./timeline-holiday-read.js";
import { import { getAssignmentResourceIds, ShiftDbClient } from "./timeline-read-shared.js";
anonymizeResourceOnEntry,
fmtDate,
getAssignmentResourceIds,
ShiftDbClient,
} from "./timeline-read-shared.js";
import { buildTimelineProjectShiftValidation } from "./timeline-shift-support.js"; import { buildTimelineProjectShiftValidation } from "./timeline-shift-support.js";
export async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) { export async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) {
@@ -48,19 +45,11 @@ export async function loadProjectShiftContext(db: ShiftDbClient, projectId: stri
const allAssignmentWindows = const allAssignmentWindows =
resourceIds.length === 0 resourceIds.length === 0
? [] ? []
: ( : buildTimelineShiftValidationBookings(
await listAssignmentBookings(db, { await listAssignmentBookings(db, {
resourceIds, resourceIds,
}) }),
).map((booking) => ({ );
id: booking.id,
resourceId: booking.resourceId!,
projectId: booking.projectId,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
status: booking.status,
}));
const shiftPlan = buildTimelineShiftPlan({ const shiftPlan = buildTimelineShiftPlan({
demandRequirements, demandRequirements,
@@ -275,26 +264,15 @@ export const timelineProjectReadProcedures = {
const budgetStatus = computeBudgetStatus( const budgetStatus = computeBudgetStatus(
project.budgetCents, project.budgetCents,
project.winProbability, project.winProbability,
bookings.map((booking) => ({ buildTimelineBudgetStatusAllocations(bookings),
status: booking.status,
dailyCostCents: booking.dailyCostCents,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
})) as unknown as Pick<
import("@capakraken/shared").Allocation,
"status" | "dailyCostCents" | "startDate" | "endDate" | "hoursPerDay"
>[],
project.startDate, project.startDate,
project.endDate, project.endDate,
); );
return { return buildTimelineBudgetStatusResponse({
...budgetStatus, project,
projectName: project.name, budgetStatus,
projectCode: project.shortCode,
totalAllocations: bookings.length, totalAllocations: bookings.length,
budgetCents: project.budgetCents, });
};
}), }),
}; };