refactor(api): extract estimate phasing procedures

This commit is contained in:
2026-03-31 11:30:38 +02:00
parent 7dde6a7461
commit 79a396d788
2 changed files with 181 additions and 171 deletions
+179
View File
@@ -0,0 +1,179 @@
import type { Prisma } from "@capakraken/db";
import {
aggregateWeeklyByChapter,
aggregateWeeklyToMonthly,
distributeHoursToWeeks,
generateWeekRange,
} from "@capakraken/engine";
import { PermissionKey, GenerateWeeklyPhasingSchema } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import {
controllerProcedure,
managerProcedure,
requirePermission,
} from "../trpc.js";
import { getEstimateById } from "@capakraken/application";
type WeeklyPhasingMeta = {
startDate: string;
endDate: string;
pattern: string;
weeklyHours: Record<string, number>;
generatedAt: string;
};
export const estimatePhasingProcedures = {
generateWeeklyPhasing: managerProcedure
.input(GenerateWeeklyPhasingSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const estimate = await findUniqueOrThrow(
getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.estimateId,
),
"Estimate",
);
const workingVersion = estimate.versions.find(
(version) => version.status === "WORKING",
);
if (!workingVersion) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Estimate has no working version",
});
}
const pattern = input.pattern ?? "even";
const updates: Array<{
id: string;
monthlySpread: Record<string, number>;
metadata: Record<string, unknown>;
}> = [];
for (const line of workingVersion.demandLines) {
const result = distributeHoursToWeeks({
totalHours: line.hours,
startDate: input.startDate,
endDate: input.endDate,
pattern,
});
const monthlySpread = aggregateWeeklyToMonthly(result.weeklyHours);
const existingMetadata = (line.metadata ?? {}) as Record<string, unknown>;
const metadata = {
...existingMetadata,
weeklyPhasing: {
startDate: input.startDate,
endDate: input.endDate,
pattern,
weeklyHours: result.weeklyHours,
generatedAt: new Date().toISOString(),
},
};
updates.push({ id: line.id, monthlySpread, metadata });
}
await Promise.all(
updates.map((update) =>
ctx.db.estimateDemandLine.update({
where: { id: update.id },
data: {
monthlySpread: update.monthlySpread as Prisma.InputJsonValue,
metadata: update.metadata as Prisma.InputJsonValue,
},
}),
),
);
return {
estimateId: input.estimateId,
versionId: workingVersion.id,
linesUpdated: updates.length,
startDate: input.startDate,
endDate: input.endDate,
pattern,
};
}),
getWeeklyPhasing: controllerProcedure
.input(z.object({ estimateId: z.string() }))
.query(async ({ ctx, input }) => {
const estimate = await findUniqueOrThrow(
getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.estimateId,
),
"Estimate",
);
const version = estimate.versions[0];
if (!version) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Estimate has no versions",
});
}
const linesWithPhasing: Array<{
id: string;
name: string;
chapter: string | null;
hours: number;
weeklyHours: Record<string, number>;
}> = [];
let phasingConfig: { startDate: string; endDate: string; pattern: string } | null = null;
for (const line of version.demandLines) {
const meta = (line.metadata ?? {}) as Record<string, unknown>;
const phasing = meta["weeklyPhasing"] as WeeklyPhasingMeta | undefined;
if (!phasing) {
continue;
}
if (!phasingConfig) {
phasingConfig = {
startDate: phasing.startDate,
endDate: phasing.endDate,
pattern: phasing.pattern,
};
}
linesWithPhasing.push({
id: line.id,
name: line.name,
chapter: line.chapter ?? null,
hours: line.hours,
weeklyHours: phasing.weeklyHours,
});
}
if (!phasingConfig || linesWithPhasing.length === 0) {
return {
estimateId: input.estimateId,
versionId: version.id,
hasPhasing: false as const,
config: null,
weeks: [],
lines: [],
chapterAggregation: {},
};
}
return {
estimateId: input.estimateId,
versionId: version.id,
hasPhasing: true as const,
config: phasingConfig,
weeks: generateWeekRange(phasingConfig.startDate, phasingConfig.endDate),
lines: linesWithPhasing,
chapterAggregation: aggregateWeeklyByChapter(linesWithPhasing),
};
}),
};
+2 -171
View File
@@ -10,17 +10,12 @@ import type { Prisma } from "@capakraken/db";
import {
normalizeEstimateDemandLine,
summarizeEstimateDemandLines,
generateWeekRange,
distributeHoursToWeeks,
aggregateWeeklyToMonthly,
aggregateWeeklyByChapter,
} from "@capakraken/engine";
import {
CloneEstimateSchema,
CreateEstimateExportSchema,
CreateEstimatePlanningHandoffSchema,
CreateEstimateSchema,
GenerateWeeklyPhasingSchema,
PermissionKey,
UpdateEstimateDraftSchema,
} from "@capakraken/shared";
@@ -36,6 +31,7 @@ import {
} from "../trpc.js";
import { emitAllocationCreated } from "../sse/event-bus.js";
import { estimateCommercialProcedures } from "./estimate-commercial.js";
import { estimatePhasingProcedures } from "./estimate-phasing.js";
import { estimateReadProcedures } from "./estimate-read.js";
import { estimateVersionWorkflowProcedures } from "./estimate-version-workflow.js";
@@ -239,6 +235,7 @@ async function autoFillDemandLineRates(
export const estimateRouter = createTRPCRouter({
...estimateReadProcedures,
...estimateCommercialProcedures,
...estimatePhasingProcedures,
...estimateVersionWorkflowProcedures,
create: managerProcedure
@@ -518,172 +515,6 @@ export const estimateRouter = createTRPCRouter({
return result;
}),
generateWeeklyPhasing: managerProcedure
.input(GenerateWeeklyPhasingSchema)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
const estimate = await findUniqueOrThrow(
getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.estimateId,
),
"Estimate",
);
const workingVersion = estimate.versions.find(
(v) => v.status === "WORKING",
);
if (!workingVersion) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Estimate has no working version",
});
}
const pattern = input.pattern ?? "even";
// Distribute hours for each demand line and update DB
const updates: Array<{ id: string; monthlySpread: Record<string, number>; metadata: Record<string, unknown> }> = [];
for (const line of workingVersion.demandLines) {
const result = distributeHoursToWeeks({
totalHours: line.hours,
startDate: input.startDate,
endDate: input.endDate,
pattern,
});
const monthlySpread = aggregateWeeklyToMonthly(result.weeklyHours);
const existingMetadata = (line.metadata ?? {}) as Record<string, unknown>;
const metadata = {
...existingMetadata,
weeklyPhasing: {
startDate: input.startDate,
endDate: input.endDate,
pattern,
weeklyHours: result.weeklyHours,
generatedAt: new Date().toISOString(),
},
};
updates.push({ id: line.id, monthlySpread, metadata });
}
// Batch update all demand lines
await Promise.all(
updates.map((update) =>
ctx.db.estimateDemandLine.update({
where: { id: update.id },
data: {
monthlySpread: update.monthlySpread as Prisma.InputJsonValue,
metadata: update.metadata as Prisma.InputJsonValue,
},
}),
),
);
return {
estimateId: input.estimateId,
versionId: workingVersion.id,
linesUpdated: updates.length,
startDate: input.startDate,
endDate: input.endDate,
pattern,
};
}),
getWeeklyPhasing: controllerProcedure
.input(z.object({ estimateId: z.string() }))
.query(async ({ ctx, input }) => {
const estimate = await findUniqueOrThrow(
getEstimateById(
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
input.estimateId,
),
"Estimate",
);
// Get the latest version (first in the sorted array)
const version = estimate.versions[0];
if (!version) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Estimate has no versions",
});
}
// Extract weekly phasing from each demand line's metadata
type WeeklyPhasingMeta = {
startDate: string;
endDate: string;
pattern: string;
weeklyHours: Record<string, number>;
generatedAt: string;
};
const linesWithPhasing: Array<{
id: string;
name: string;
chapter: string | null;
hours: number;
weeklyHours: Record<string, number>;
}> = [];
let phasingConfig: { startDate: string; endDate: string; pattern: string } | null = null;
for (const line of version.demandLines) {
const meta = (line.metadata ?? {}) as Record<string, unknown>;
const phasing = meta["weeklyPhasing"] as WeeklyPhasingMeta | undefined;
if (phasing) {
if (!phasingConfig) {
phasingConfig = {
startDate: phasing.startDate,
endDate: phasing.endDate,
pattern: phasing.pattern,
};
}
linesWithPhasing.push({
id: line.id,
name: line.name,
chapter: line.chapter ?? null,
hours: line.hours,
weeklyHours: phasing.weeklyHours,
});
}
}
if (!phasingConfig || linesWithPhasing.length === 0) {
return {
estimateId: input.estimateId,
versionId: version.id,
hasPhasing: false as const,
config: null,
weeks: [],
lines: [],
chapterAggregation: {},
};
}
const weeks = generateWeekRange(phasingConfig.startDate, phasingConfig.endDate);
const chapterAggregation = aggregateWeeklyByChapter(linesWithPhasing);
return {
estimateId: input.estimateId,
versionId: version.id,
hasPhasing: true as const,
config: phasingConfig,
weeks,
lines: linesWithPhasing,
chapterAggregation,
};
}),
// ─── Rate Card Lookup for Demand Lines ──────────────────────────────────
lookupDemandLineRate: controllerProcedure