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), }; }), };