import { cloneEstimate, createEstimate, createEstimateExport, createEstimatePlanningHandoff, updateEstimateDraft, } from "@capakraken/application"; import type { Prisma } from "@capakraken/db"; import { CloneEstimateSchema, CreateEstimateExportSchema, CreateEstimatePlanningHandoffSchema, CreateEstimateSchema, 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 { emitAllocationCreated } from "../sse/event-bus.js"; import type { TRPCContext } from "../trpc.js"; import { autoFillDemandLineRates, withComputedMetrics, } from "./estimate-demand-lines.js"; type EstimateProcedureContext = Pick; type EstimateRouterErrorCode = "NOT_FOUND" | "PRECONDITION_FAILED"; type EstimateRouterErrorRule = { code: EstimateRouterErrorCode; messages?: readonly string[]; predicates?: readonly ((message: string) => boolean)[]; }; function withAuditUser(userId: string | undefined) { return userId ? { userId } : {}; } export 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 lookupDemandLineRateInputSchema = 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(), }); type CreateEstimateInput = z.infer; type CloneEstimateInput = z.infer; type UpdateEstimateDraftInput = z.infer; type CreateEstimateExportInput = z.infer; type CreateEstimatePlanningHandoffInput = z.infer< typeof CreateEstimatePlanningHandoffSchema >; type LookupDemandLineRateInput = z.infer; export async function createEstimateRecord( ctx: EstimateProcedureContext, input: CreateEstimateInput, ) { if (input.projectId) { await findUniqueOrThrow( ctx.db.project.findUnique({ where: { id: input.projectId }, select: { id: true }, }), "Project", ); } const { demandLines: enrichedLines, autoFilledIndices } = await autoFillDemandLineRates(ctx.db, input.demandLines, input.projectId); const enrichedInput = { ...input, demandLines: enrichedLines }; const estimate = await ctx.db.$transaction(async (tx) => { const created = await createEstimate( tx as unknown as Parameters[0], withComputedMetrics(enrichedInput, input.baseCurrency), ); await tx.auditLog.create({ data: { entityType: "Estimate", entityId: created.id, action: "CREATE", ...withAuditUser(ctx.dbUser?.id), changes: { after: { id: created.id, name: created.name, status: created.status, projectId: created.projectId, latestVersionNumber: created.latestVersionNumber, autoFilledRateCardLines: autoFilledIndices.length, }, } as Prisma.InputJsonValue, }, }); return created; }); return estimate; } export async function cloneEstimateRecord( ctx: EstimateProcedureContext, input: CloneEstimateInput, ) { let estimate; try { estimate = await ctx.db.$transaction(async (tx) => { const cloned = await cloneEstimate( tx as unknown as Parameters[0], input, ); await tx.auditLog.create({ data: { entityType: "Estimate", entityId: cloned.id, action: "CREATE", ...withAuditUser(ctx.dbUser?.id), changes: { after: { id: cloned.id, name: cloned.name, clonedFrom: input.sourceEstimateId, }, } as Prisma.InputJsonValue, }, }); return cloned; }); } catch (error) { rethrowEstimateRouterError(error, [ { code: "NOT_FOUND", messages: ["Source estimate not found", "Source estimate has no versions"], }, ]); } return estimate; } export async function updateEstimateDraftRecord( ctx: EstimateProcedureContext, input: UpdateEstimateDraftInput, ) { if (input.projectId) { await findUniqueOrThrow( ctx.db.project.findUnique({ where: { id: input.projectId }, select: { id: true }, }), "Project", ); } 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 ctx.db.$transaction(async (tx) => { const updated = await updateEstimateDraft( tx as unknown as Parameters[0], withComputedMetrics(enrichedInput, input.baseCurrency ?? "EUR"), ); await tx.auditLog.create({ data: { entityType: "Estimate", entityId: updated.id, action: "UPDATE", ...withAuditUser(ctx.dbUser?.id), changes: { after: { id: updated.id, name: updated.name, status: updated.status, latestVersionNumber: updated.latestVersionNumber, workingVersionId: updated.versions.find( (version) => version.status === "WORKING", )?.id, autoFilledRateCardLines: autoFilledIndices.length, }, } as Prisma.InputJsonValue, }, }); return updated; }); } catch (error) { rethrowEstimateRouterError(error, [ { code: "NOT_FOUND", messages: ["Estimate not found"], }, { code: "PRECONDITION_FAILED", messages: ["Estimate has no working version"], }, ]); } return estimate; } export async function createEstimateExportRecord( ctx: EstimateProcedureContext, input: CreateEstimateExportInput, ) { let estimate; try { estimate = await ctx.db.$transaction(async (tx) => { const exported = await createEstimateExport( tx as unknown as Parameters[0], input, ); const exportedVersion = input.versionId ? exported.versions.find((version) => version.id === input.versionId) : exported.versions[0]; await tx.auditLog.create({ data: { entityType: "Estimate", entityId: exported.id, action: "UPDATE", ...withAuditUser(ctx.dbUser?.id), changes: { after: { id: exported.id, exportFormat: input.format, exportCount: exportedVersion?.exports.length ?? null, versionId: exportedVersion?.id ?? null, }, } as Prisma.InputJsonValue, }, }); return exported; }); } catch (error) { rethrowEstimateRouterError(error, [ { code: "NOT_FOUND", messages: [ "Estimate not found", "Estimate version not found", "Estimate has no version to export", ], }, ]); } return estimate; } export async function createEstimatePlanningHandoffRecord( ctx: EstimateProcedureContext, input: CreateEstimatePlanningHandoffInput, ) { let result; try { result = await ctx.db.$transaction(async (tx) => { const handoff = await createEstimatePlanningHandoff( tx as unknown as Parameters[0], input, ); await tx.auditLog.create({ data: { entityType: "Estimate", entityId: handoff.estimateId, action: "UPDATE", ...withAuditUser(ctx.dbUser?.id), changes: { after: { planningHandoff: { versionId: handoff.estimateVersionId, versionNumber: handoff.estimateVersionNumber, projectId: handoff.projectId, createdCount: handoff.createdCount, assignedCount: handoff.assignedCount, placeholderCount: handoff.placeholderCount, fallbackPlaceholderCount: handoff.fallbackPlaceholderCount, }, }, } as Prisma.InputJsonValue, }, }); return handoff; }); } 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"), ], }, ]); } for (const allocation of result.allocations) { emitAllocationCreated({ id: allocation.id, projectId: allocation.projectId, resourceId: allocation.resourceId ?? null, }); } return result; } export async function lookupDemandLineRateForEstimate( ctx: EstimateProcedureContext, input: LookupDemandLineRateInput, ) { 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, }; }