import { approveEstimateVersion, cloneEstimate, createEstimateExport, createEstimate, createEstimatePlanningHandoff, createEstimateRevision, getEstimateById, listEstimates, submitEstimateVersion, updateEstimateDraft, } from "@capakraken/application"; import type { Prisma } from "@capakraken/db"; import { normalizeEstimateDemandLine, summarizeEstimateDemandLines, generateWeekRange, distributeHoursToWeeks, aggregateWeeklyToMonthly, aggregateWeeklyByChapter, } from "@capakraken/engine"; import { ApproveEstimateVersionSchema, CloneEstimateSchema, CommercialTermsSchema, CreateEstimateExportSchema, CreateEstimatePlanningHandoffSchema, CreateEstimateSchema, CreateEstimateRevisionSchema, EstimateListFiltersSchema, GenerateWeeklyPhasingSchema, PermissionKey, SubmitEstimateVersionSchema, UpdateCommercialTermsSchema, 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, protectedProcedure, requirePermission, } from "../trpc.js"; import { emitAllocationCreated } from "../sse/event-bus.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; } function buildComputedMetrics( demandLines: z.infer["demandLines"], ) { const summary = summarizeEstimateDemandLines(demandLines); return [ { key: "total_hours", label: "Total Hours", metricGroup: "summary", valueDecimal: summary.totalHours, metadata: {}, }, { key: "total_cost", label: "Total Cost", metricGroup: "summary", valueDecimal: summary.totalCostCents / 100, valueCents: summary.totalCostCents, currency: demandLines[0]?.currency ?? "EUR", metadata: {}, }, { key: "total_price", label: "Total Price", metricGroup: "summary", valueDecimal: summary.totalPriceCents / 100, valueCents: summary.totalPriceCents, currency: demandLines[0]?.currency ?? "EUR", metadata: {}, }, { key: "margin", label: "Margin", metricGroup: "summary", valueDecimal: summary.marginCents / 100, valueCents: summary.marginCents, currency: demandLines[0]?.currency ?? "EUR", metadata: {}, }, { key: "margin_percent", label: "Margin %", metricGroup: "summary", valueDecimal: summary.marginPercent, metadata: {}, }, ]; } function normalizeDemandLines< T extends { demandLines: z.infer["demandLines"]; resourceSnapshots: z.infer["resourceSnapshots"]; }, >(input: T, baseCurrency: string) { const snapshotsByResourceId = new Map( input.resourceSnapshots .filter( (snapshot): snapshot is (typeof input.resourceSnapshots)[number] & { resourceId: string; } => typeof snapshot.resourceId === "string" && snapshot.resourceId.length > 0, ) .map((snapshot) => [snapshot.resourceId, snapshot]), ); return input.demandLines.map((line) => normalizeEstimateDemandLine(line, { resourceSnapshot: line.resourceId != null ? snapshotsByResourceId.get(line.resourceId) : null, defaultCurrency: baseCurrency, }), ); } function withComputedMetrics< T extends { demandLines: z.infer["demandLines"]; resourceSnapshots: z.infer["resourceSnapshots"]; metrics: z.infer["metrics"]; }, >(input: T, baseCurrency: string) { const normalizedDemandLines = normalizeDemandLines(input, baseCurrency); const computedMetrics = buildComputedMetrics(normalizedDemandLines); const computedKeys = new Set(computedMetrics.map((metric) => metric.key)); return { ...input, demandLines: normalizedDemandLines, metrics: [ ...input.metrics.filter((metric) => !computedKeys.has(metric.key)), ...computedMetrics, ], }; } /** * Auto-fill rate card rates into demand lines that have default (zero) rates. * A line is eligible for auto-fill when both costRateCents and billRateCents * are 0 (the Zod default) and rateSource is not explicitly set. * * Returns the enriched demand lines and a list of line indices that were auto-filled. */ async function autoFillDemandLineRates( // eslint-disable-next-line @typescript-eslint/no-explicit-any db: any, demandLines: z.infer["demandLines"], projectId?: string | null, ): Promise<{ demandLines: z.infer["demandLines"]; autoFilledIndices: number[]; }> { // Resolve clientId from the linked project let clientId: string | null = null; if (projectId) { const project = await db.project.findUnique({ where: { id: projectId }, select: { clientId: true }, }); clientId = project?.clientId ?? null; } const autoFilledIndices: number[] = []; const enriched = await Promise.all( demandLines.map(async (line, index) => { // Only auto-fill if both rates are at default (0) and no explicit rateSource const isDefaultRate = line.costRateCents === 0 && line.billRateCents === 0; const hasExplicitSource = line.rateSource != null && line.rateSource.length > 0; if (!isDefaultRate || hasExplicitSource) return line; const result = await lookupRate(db, { clientId, chapter: line.chapter ?? null, roleId: line.roleId ?? null, }); if (!result) return line; autoFilledIndices.push(index); const existingMetadata = (line.metadata ?? {}) as Record; return { ...line, costRateCents: result.costRateCents, billRateCents: result.billRateCents, currency: result.currency, rateSource: `rate-card:${result.rateCardId}`, metadata: { ...existingMetadata, autoAppliedRateCard: { rateCardId: result.rateCardId, rateCardLineId: result.rateCardLineId, rateCardName: result.rateCardName, appliedAt: new Date().toISOString(), }, }, }; }), ); return { demandLines: enriched, autoFilledIndices }; } export const estimateRouter = createTRPCRouter({ list: controllerProcedure .input(EstimateListFiltersSchema.default({})) .query(async ({ ctx, input }) => listEstimates( ctx.db as unknown as Parameters[0], input, )), getById: controllerProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const estimate = await findUniqueOrThrow( getEstimateById( ctx.db as unknown as Parameters[0], input.id, ), "Estimate", ); return estimate; }), listVersions: controllerProcedure .input(z.object({ estimateId: z.string() })) .query(async ({ ctx, input }) => { const estimate = await findUniqueOrThrow( ctx.db.estimate.findUnique({ where: { id: input.estimateId }, select: { id: true, name: true, status: true, latestVersionNumber: true, versions: { orderBy: { versionNumber: "desc" }, select: { id: true, versionNumber: true, label: true, status: true, notes: true, lockedAt: true, createdAt: true, updatedAt: true, _count: { select: { assumptions: true, scopeItems: true, demandLines: true, resourceSnapshots: true, exports: true, }, }, }, }, }, }), "Estimate", ); return estimate; }), getVersionSnapshot: controllerProcedure .input(z.object({ estimateId: z.string(), versionId: z.string().optional() })) .query(async ({ ctx, input }) => { const estimate = await ctx.db.estimate.findUnique({ where: { id: input.estimateId }, select: { id: true, name: true, status: true, baseCurrency: true, versions: { ...(input.versionId ? { where: { id: input.versionId } } : { orderBy: { versionNumber: "desc" as const }, take: 1 }), select: { id: true, versionNumber: true, label: true, status: true, notes: true, lockedAt: true, createdAt: true, updatedAt: true, assumptions: { select: { id: true, category: true, key: true, label: true }, }, scopeItems: { select: { id: true, scopeType: true, sequenceNo: true, name: true }, orderBy: [{ sequenceNo: "asc" }, { name: "asc" }], }, demandLines: { select: { id: true, name: true, chapter: true, hours: true, costTotalCents: true, priceTotalCents: true, currency: true, }, }, resourceSnapshots: { select: { id: true, displayName: true, chapter: true, currency: true, lcrCents: true, ucrCents: true, }, }, exports: { select: { id: true, format: true, fileName: true, createdAt: true, }, orderBy: { createdAt: "desc" }, }, }, }, }, }); if (!estimate || estimate.versions.length === 0) { throw new TRPCError({ code: "NOT_FOUND", message: "Estimate version not found" }); } const version = estimate.versions[0]!; const demandSummary = summarizeEstimateDemandLines(version.demandLines); const chapterTotals = version.demandLines.reduce>((acc, line) => { const key = line.chapter ?? "Unassigned"; const current = acc[key] ?? { lineCount: 0, hours: 0, costTotalCents: 0, priceTotalCents: 0, currency: line.currency, }; current.lineCount += 1; current.hours += line.hours; current.costTotalCents += line.costTotalCents; current.priceTotalCents += line.priceTotalCents; acc[key] = current; return acc; }, {}); const scopeTypeTotals = version.scopeItems.reduce>((acc, item) => { acc[item.scopeType] = (acc[item.scopeType] ?? 0) + 1; return acc; }, {}); const assumptionCategoryTotals = version.assumptions.reduce>((acc, assumption) => { acc[assumption.category] = (acc[assumption.category] ?? 0) + 1; return acc; }, {}); return { estimate: { id: estimate.id, name: estimate.name, status: estimate.status, baseCurrency: estimate.baseCurrency, }, version: { id: version.id, versionNumber: version.versionNumber, label: version.label, status: version.status, notes: version.notes, lockedAt: version.lockedAt, createdAt: version.createdAt, updatedAt: version.updatedAt, }, counts: { assumptions: version.assumptions.length, scopeItems: version.scopeItems.length, demandLines: version.demandLines.length, resourceSnapshots: version.resourceSnapshots.length, exports: version.exports.length, }, totals: { hours: demandSummary.totalHours, costTotalCents: demandSummary.totalCostCents, priceTotalCents: demandSummary.totalPriceCents, marginCents: demandSummary.marginCents, marginPercent: demandSummary.marginPercent, }, chapterBreakdown: Object.entries(chapterTotals) .sort((left, right) => right[1].hours - left[1].hours) .map(([chapter, totals]) => ({ chapter, ...totals, })), scopeTypeBreakdown: Object.entries(scopeTypeTotals) .sort((left, right) => right[1] - left[1]) .map(([scopeType, count]) => ({ scopeType, count })), assumptionCategoryBreakdown: Object.entries(assumptionCategoryTotals) .sort((left, right) => right[1] - left[1]) .map(([category, count]) => ({ category, count })), exports: version.exports, }; }), 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; }), submitVersion: managerProcedure .input(SubmitEstimateVersionSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); let estimate; try { estimate = await submitEstimateVersion( ctx.db as unknown as Parameters[0], input, ); } catch (error) { rethrowEstimateRouterError(error, [ { code: "NOT_FOUND", messages: ["Estimate not found", "Estimate version not found"], }, { code: "PRECONDITION_FAILED", messages: [ "Estimate has no working version", "Only working versions can be submitted", ], }, ]); } 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, status: estimate.status, submittedVersionId: estimate.versions.find( (version) => version.status === "SUBMITTED", )?.id, }, } as Prisma.InputJsonValue, }, }); return estimate; }), approveVersion: managerProcedure .input(ApproveEstimateVersionSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); let estimate; try { estimate = await approveEstimateVersion( ctx.db as unknown as Parameters[0], input, ); } catch (error) { rethrowEstimateRouterError(error, [ { code: "NOT_FOUND", messages: ["Estimate not found", "Estimate version not found"], }, { code: "PRECONDITION_FAILED", messages: [ "Estimate has no submitted version", "Only submitted versions can be approved", ], }, ]); } 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, status: estimate.status, approvedVersionId: estimate.versions.find( (version) => version.status === "APPROVED", )?.id, }, } as Prisma.InputJsonValue, }, }); return estimate; }), createRevision: managerProcedure .input(CreateEstimateRevisionSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); let estimate; try { estimate = await createEstimateRevision( ctx.db as unknown as Parameters[0], input, ); } catch (error) { rethrowEstimateRouterError(error, [ { code: "NOT_FOUND", messages: ["Estimate not found", "Estimate version not found"], }, { code: "PRECONDITION_FAILED", messages: [ "Estimate already has a working version", "Estimate has no locked version to revise", "Source version must be locked before creating a revision", ], }, ]); } 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, status: estimate.status, latestVersionNumber: estimate.latestVersionNumber, workingVersionId: estimate.versions.find( (version) => version.status === "WORKING", )?.id, }, } 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; }), 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, }; }), // ─── Commercial Terms ─────────────────────────────────────────────────── getCommercialTerms: controllerProcedure .input(z.object({ estimateId: z.string(), versionId: z.string().optional() })) .query(async ({ ctx, input }) => { const estimate = await ctx.db.estimate.findUnique({ where: { id: input.estimateId }, include: { versions: { ...(input.versionId ? { where: { id: input.versionId } } : { orderBy: { versionNumber: "desc" as const }, take: 1 }), select: { id: true, commercialTerms: true }, }, }, }); if (!estimate || estimate.versions.length === 0) { throw new TRPCError({ code: "NOT_FOUND", message: "Estimate version not found" }); } const version = estimate.versions[0]!; const raw = version.commercialTerms; // Parse stored JSON through Zod for type safety, fall back to defaults const terms = raw ? CommercialTermsSchema.parse(raw) : CommercialTermsSchema.parse({}); return { versionId: version.id, terms }; }), updateCommercialTerms: managerProcedure .input(UpdateCommercialTermsSchema) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); const estimate = await ctx.db.estimate.findUnique({ where: { id: input.estimateId }, include: { versions: { ...(input.versionId ? { where: { id: input.versionId } } : { where: { status: "WORKING" }, take: 1 }), select: { id: true, status: true }, }, }, }); if (!estimate || estimate.versions.length === 0) { throw new TRPCError({ code: "NOT_FOUND", message: "Estimate version not found" }); } const version = estimate.versions[0]!; if (version.status !== "WORKING") { throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Commercial terms can only be edited on working versions", }); } const validated = CommercialTermsSchema.parse(input.terms); await ctx.db.estimateVersion.update({ where: { id: version.id }, data: { commercialTerms: validated as unknown as Prisma.InputJsonValue }, }); await ctx.db.auditLog.create({ data: { entityType: "Estimate", entityId: estimate.id, action: "UPDATE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), changes: { field: "commercialTerms", after: validated, } as Prisma.InputJsonValue, }, }); return { versionId: version.id, terms: validated }; }), // ─── 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, }; }), });