From 79a396d78826152a3d93e5e722b17b9bd2e5f0a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 11:30:38 +0200 Subject: [PATCH] refactor(api): extract estimate phasing procedures --- packages/api/src/router/estimate-phasing.ts | 179 ++++++++++++++++++++ packages/api/src/router/estimate.ts | 173 +------------------ 2 files changed, 181 insertions(+), 171 deletions(-) create mode 100644 packages/api/src/router/estimate-phasing.ts diff --git a/packages/api/src/router/estimate-phasing.ts b/packages/api/src/router/estimate-phasing.ts new file mode 100644 index 0000000..3b96980 --- /dev/null +++ b/packages/api/src/router/estimate-phasing.ts @@ -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; + 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[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; + metadata: Record; + }> = []; + + 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; + 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[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; + }> = []; + let phasingConfig: { startDate: string; endDate: string; pattern: string } | null = null; + + for (const line of version.demandLines) { + const meta = (line.metadata ?? {}) as Record; + 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), + }; + }), +}; diff --git a/packages/api/src/router/estimate.ts b/packages/api/src/router/estimate.ts index 7bc8483..2cf93eb 100644 --- a/packages/api/src/router/estimate.ts +++ b/packages/api/src/router/estimate.ts @@ -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[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; metadata: Record }> = []; - - 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; - 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[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; - generatedAt: string; - }; - - const linesWithPhasing: Array<{ - id: string; - name: string; - chapter: string | null; - hours: number; - weeklyHours: Record; - }> = []; - - let phasingConfig: { startDate: string; endDate: string; pattern: string } | null = null; - - for (const line of version.demandLines) { - const meta = (line.metadata ?? {}) as Record; - 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