import { cloneEstimate, createEstimateExport, createEstimate, createEstimatePlanningHandoff, getEstimateById, updateEstimateDraft, } from "@capakraken/application"; import type { Prisma } from "@capakraken/db"; import { CloneEstimateSchema, CreateEstimateExportSchema, CreateEstimatePlanningHandoffSchema, CreateEstimateSchema, PermissionKey, UpdateEstimateDraftSchema, } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { lookupRate } from "../lib/rate-card-lookup.js"; import { controllerProcedure, createTRPCRouter, managerProcedure, requirePermission, } from "../trpc.js"; import { emitAllocationCreated } from "../sse/event-bus.js"; import { estimateCommercialProcedures } from "./estimate-commercial.js"; import { autoFillDemandLineRates, withComputedMetrics, } from "./estimate-demand-lines.js"; import { estimatePhasingProcedures } from "./estimate-phasing.js"; import { estimateReadProcedures } from "./estimate-read.js"; import { estimateVersionWorkflowProcedures } from "./estimate-version-workflow.js"; type EstimateRouterErrorCode = "NOT_FOUND" | "PRECONDITION_FAILED"; type EstimateRouterErrorRule = { code: EstimateRouterErrorCode; messages?: readonly string[]; predicates?: readonly ((message: string) => boolean)[]; }; function rethrowEstimateRouterError( error: unknown, rules: readonly EstimateRouterErrorRule[], ): never { if (!(error instanceof Error)) { throw error; } const matchingRule = rules.find( (rule) => rule.messages?.includes(error.message) === true || rule.predicates?.some((predicate) => predicate(error.message)) === true, ); if (matchingRule) { throw new TRPCError({ code: matchingRule.code, message: error.message, }); } throw error; } export const estimateRouter = createTRPCRouter({ ...estimateReadProcedures, ...estimateCommercialProcedures, ...estimatePhasingProcedures, ...estimateVersionWorkflowProcedures, create: managerProcedure .input(CreateEstimateSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); if (input.projectId) { await findUniqueOrThrow( ctx.db.project.findUnique({ where: { id: input.projectId }, select: { id: true }, }), "Project", ); } // Auto-fill rates from rate cards for demand lines with default (zero) rates const { demandLines: enrichedLines, autoFilledIndices } = await autoFillDemandLineRates(ctx.db, input.demandLines, input.projectId); const enrichedInput = { ...input, demandLines: enrichedLines }; const estimate = await createEstimate( ctx.db as unknown as Parameters[0], withComputedMetrics(enrichedInput, input.baseCurrency), ); await ctx.db.auditLog.create({ data: { entityType: "Estimate", entityId: estimate.id, action: "CREATE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), changes: { after: { id: estimate.id, name: estimate.name, status: estimate.status, projectId: estimate.projectId, latestVersionNumber: estimate.latestVersionNumber, autoFilledRateCardLines: autoFilledIndices.length, }, } as Prisma.InputJsonValue, }, }); return estimate; }), clone: managerProcedure .input(CloneEstimateSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); let estimate; try { estimate = await cloneEstimate( ctx.db as unknown as Parameters[0], input, ); } catch (error) { rethrowEstimateRouterError(error, [ { code: "NOT_FOUND", messages: ["Source estimate not found", "Source estimate has no versions"], }, ]); } await ctx.db.auditLog.create({ data: { entityType: "Estimate", entityId: estimate.id, action: "CREATE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), changes: { after: { id: estimate.id, name: estimate.name, clonedFrom: input.sourceEstimateId, }, } as Prisma.InputJsonValue, }, }); return estimate; }), updateDraft: managerProcedure .input(UpdateEstimateDraftSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); if (input.projectId) { await findUniqueOrThrow( ctx.db.project.findUnique({ where: { id: input.projectId }, select: { id: true }, }), "Project", ); } // Auto-fill rates from rate cards for demand lines with default (zero) rates // Resolve projectId: explicit input or existing estimate's projectId let effectiveProjectId = input.projectId; if (!effectiveProjectId) { const existing = await ctx.db.estimate.findUnique({ where: { id: input.id }, select: { projectId: true }, }); effectiveProjectId = existing?.projectId ?? undefined; } const { demandLines: enrichedLines, autoFilledIndices } = await autoFillDemandLineRates(ctx.db, input.demandLines, effectiveProjectId); const enrichedInput = { ...input, demandLines: enrichedLines }; let estimate; try { estimate = await updateEstimateDraft( ctx.db as unknown as Parameters[0], withComputedMetrics(enrichedInput, input.baseCurrency ?? "EUR"), ); } catch (error) { rethrowEstimateRouterError(error, [ { code: "NOT_FOUND", messages: ["Estimate not found"], }, { code: "PRECONDITION_FAILED", messages: ["Estimate has no working version"], }, ]); } await ctx.db.auditLog.create({ data: { entityType: "Estimate", entityId: estimate.id, action: "UPDATE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), changes: { after: { id: estimate.id, name: estimate.name, status: estimate.status, latestVersionNumber: estimate.latestVersionNumber, workingVersionId: estimate.versions.find( (version) => version.status === "WORKING", )?.id, autoFilledRateCardLines: autoFilledIndices.length, }, } as Prisma.InputJsonValue, }, }); return estimate; }), createExport: managerProcedure .input(CreateEstimateExportSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); let estimate; try { estimate = await createEstimateExport( ctx.db as unknown as Parameters[0], input, ); } catch (error) { rethrowEstimateRouterError(error, [ { code: "NOT_FOUND", messages: [ "Estimate not found", "Estimate version not found", "Estimate has no version to export", ], }, ]); } const exportedVersion = input.versionId ? estimate.versions.find((version) => version.id === input.versionId) : estimate.versions[0]; await ctx.db.auditLog.create({ data: { entityType: "Estimate", entityId: estimate.id, action: "UPDATE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), changes: { after: { id: estimate.id, exportFormat: input.format, exportCount: exportedVersion?.exports.length ?? null, versionId: exportedVersion?.id ?? null, }, } as Prisma.InputJsonValue, }, }); return estimate; }), createPlanningHandoff: managerProcedure .input(CreateEstimatePlanningHandoffSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS); let result; try { result = await createEstimatePlanningHandoff( ctx.db as unknown as Parameters[0], input, ); } catch (error) { rethrowEstimateRouterError(error, [ { code: "NOT_FOUND", messages: [ "Estimate not found", "Estimate version not found", "Linked project not found", ], }, { code: "PRECONDITION_FAILED", messages: [ "Estimate has no approved version", "Only approved versions can be handed off to planning", "Estimate must be linked to a project before planning handoff", "Planning handoff already exists for this approved version", "Linked project has an invalid date range", ], predicates: [ (message) => message.startsWith("Project window has no working days for demand line"), ], }, ]); } await ctx.db.auditLog.create({ data: { entityType: "Estimate", entityId: result.estimateId, action: "UPDATE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), changes: { after: { planningHandoff: { versionId: result.estimateVersionId, versionNumber: result.estimateVersionNumber, projectId: result.projectId, createdCount: result.createdCount, assignedCount: result.assignedCount, placeholderCount: result.placeholderCount, fallbackPlaceholderCount: result.fallbackPlaceholderCount, }, }, } as Prisma.InputJsonValue, }, }); for (const allocation of result.allocations) { emitAllocationCreated({ id: allocation.id, projectId: allocation.projectId, resourceId: allocation.resourceId ?? null, }); } return result; }), // ─── Rate Card Lookup for Demand Lines ────────────────────────────────── lookupDemandLineRate: controllerProcedure .input(z.object({ projectId: z.string().optional(), clientId: z.string().optional(), roleId: z.string().optional(), chapter: z.string().optional(), seniority: z.string().optional(), location: z.string().optional(), workType: z.string().optional(), effectiveDate: z.coerce.date().optional(), })) .query(async ({ ctx, input }) => { // Resolve clientId from project if not provided directly let clientId = input.clientId ?? null; if (!clientId && input.projectId) { const project = await ctx.db.project.findUnique({ where: { id: input.projectId }, select: { clientId: true }, }); clientId = project?.clientId ?? null; } const result = await lookupRate(ctx.db, { clientId, chapter: input.chapter ?? null, roleId: input.roleId ?? null, seniority: input.seniority ?? null, location: input.location ?? null, workType: input.workType ?? null, effectiveDate: input.effectiveDate ?? null, }); if (!result) return { found: false as const }; return { found: true as const, costRateCents: result.costRateCents, billRateCents: result.billRateCents, currency: result.currency, rateCardId: result.rateCardId, rateCardLineId: result.rateCardLineId, rateCardName: result.rateCardName, }; }), });