import { applyExperienceMultipliers, applyExperienceMultipliersBatch, type ExperienceMultiplierRule as EngineRule, } from "@capakraken/engine"; import { CreateExperienceMultiplierSetSchema, UpdateExperienceMultiplierSetSchema, ApplyExperienceMultipliersSchema, } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js"; import { createAuditEntry } from "../lib/audit.js"; const ruleInclude = { rules: { orderBy: { sortOrder: "asc" as const } }, } as const; function toEngineRules( dbRules: Array<{ chapter: string | null; location: string | null; level: string | null; costMultiplier: number; billMultiplier: number; shoringRatio: number | null; additionalEffortRatio: number | null; description: string | null; }>, ): EngineRule[] { return dbRules.map((r) => ({ ...(r.chapter != null ? { chapter: r.chapter } : {}), ...(r.location != null ? { location: r.location } : {}), ...(r.level != null ? { level: r.level } : {}), costMultiplier: r.costMultiplier, billMultiplier: r.billMultiplier, ...(r.shoringRatio != null ? { shoringRatio: r.shoringRatio } : {}), ...(r.additionalEffortRatio != null ? { additionalEffortRatio: r.additionalEffortRatio } : {}), ...(r.description != null ? { description: r.description } : {}), })); } export const experienceMultiplierRouter = createTRPCRouter({ list: controllerProcedure.query(async ({ ctx }) => { return ctx.db.experienceMultiplierSet.findMany({ include: ruleInclude, orderBy: [{ isDefault: "desc" }, { name: "asc" }], }); }), getById: controllerProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const set = await findUniqueOrThrow( ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.id }, include: ruleInclude, }), "Experience multiplier set", ); return set; }), create: managerProcedure .input(CreateExperienceMultiplierSetSchema) .mutation(async ({ ctx, input }) => { if (input.isDefault) { await ctx.db.experienceMultiplierSet.updateMany({ where: { isDefault: true }, data: { isDefault: false }, }); } const set = await ctx.db.experienceMultiplierSet.create({ data: { name: input.name, ...(input.description ? { description: input.description } : {}), isDefault: input.isDefault, rules: { create: input.rules.map((r, i) => ({ ...(r.chapter ? { chapter: r.chapter } : {}), ...(r.location ? { location: r.location } : {}), ...(r.level ? { level: r.level } : {}), costMultiplier: r.costMultiplier, billMultiplier: r.billMultiplier, ...(r.shoringRatio !== undefined ? { shoringRatio: r.shoringRatio } : {}), ...(r.additionalEffortRatio !== undefined ? { additionalEffortRatio: r.additionalEffortRatio } : {}), ...(r.description ? { description: r.description } : {}), sortOrder: r.sortOrder ?? i, })), }, }, include: ruleInclude, }); void createAuditEntry({ db: ctx.db, entityType: "ExperienceMultiplierSet", entityId: set.id, entityName: set.name, action: "CREATE", userId: ctx.dbUser?.id, after: { name: input.name, isDefault: input.isDefault, ruleCount: input.rules.length }, source: "ui", }); return set; }), update: managerProcedure .input(UpdateExperienceMultiplierSetSchema) .mutation(async ({ ctx, input }) => { const before = await findUniqueOrThrow( ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.id }, include: ruleInclude }), "Experience multiplier set", ); if (input.isDefault) { await ctx.db.experienceMultiplierSet.updateMany({ where: { isDefault: true, id: { not: input.id } }, data: { isDefault: false }, }); } if (input.rules) { await ctx.db.experienceMultiplierRule.deleteMany({ where: { multiplierSetId: input.id } }); await ctx.db.experienceMultiplierRule.createMany({ data: input.rules.map((r, i) => ({ multiplierSetId: input.id, ...(r.chapter ? { chapter: r.chapter } : {}), ...(r.location ? { location: r.location } : {}), ...(r.level ? { level: r.level } : {}), costMultiplier: r.costMultiplier, billMultiplier: r.billMultiplier, ...(r.shoringRatio !== undefined ? { shoringRatio: r.shoringRatio } : {}), ...(r.additionalEffortRatio !== undefined ? { additionalEffortRatio: r.additionalEffortRatio } : {}), ...(r.description ? { description: r.description } : {}), sortOrder: r.sortOrder ?? i, })), }); } const updated = await ctx.db.experienceMultiplierSet.update({ where: { id: input.id }, data: { ...(input.name !== undefined ? { name: input.name } : {}), ...(input.description !== undefined ? { description: input.description } : {}), ...(input.isDefault !== undefined ? { isDefault: input.isDefault } : {}), }, include: ruleInclude, }); void createAuditEntry({ db: ctx.db, entityType: "ExperienceMultiplierSet", entityId: input.id, entityName: updated.name, action: "UPDATE", userId: ctx.dbUser?.id, before: { name: before.name, isDefault: before.isDefault, ruleCount: before.rules.length }, after: { name: updated.name, isDefault: updated.isDefault, ruleCount: updated.rules.length }, source: "ui", }); return updated; }), delete: managerProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const set = await findUniqueOrThrow( ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.id } }), "Experience multiplier set", ); await ctx.db.experienceMultiplierSet.delete({ where: { id: input.id } }); void createAuditEntry({ db: ctx.db, entityType: "ExperienceMultiplierSet", entityId: input.id, entityName: set.name, action: "DELETE", userId: ctx.dbUser?.id, source: "ui", }); return { id: input.id }; }), /** Preview the rate adjustment without persisting */ preview: controllerProcedure .input(z.object({ estimateId: z.string(), multiplierSetId: z.string(), })) .query(async ({ ctx, input }) => { const [estimate, multiplierSet] = await Promise.all([ findUniqueOrThrow( ctx.db.estimate.findUnique({ where: { id: input.estimateId }, include: { versions: { orderBy: { versionNumber: "desc" }, take: 1, include: { demandLines: { orderBy: { createdAt: "asc" } } }, }, }, }), "Estimate", ), findUniqueOrThrow( ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.multiplierSetId }, include: ruleInclude, }), "Experience multiplier set", ), ]); const version = estimate.versions[0]; if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" }); const engineRules = toEngineRules(multiplierSet.rules); const demandLines = version.demandLines; const previews = demandLines.map((line) => { const result = applyExperienceMultipliers( { costRateCents: line.costRateCents, billRateCents: line.billRateCents, hours: line.hours, ...(line.chapter != null ? { chapter: line.chapter } : {}), ...(line.metadata != null && typeof line.metadata === "object" && "location" in (line.metadata as Record) ? { location: (line.metadata as Record).location as string } : {}), ...(line.staffingAttributes != null && typeof line.staffingAttributes === "object" && "level" in (line.staffingAttributes as Record) ? { level: (line.staffingAttributes as Record).level as string } : {}), }, engineRules, ); return { demandLineId: line.id, name: line.name, chapter: line.chapter, originalCostRateCents: line.costRateCents, originalBillRateCents: line.billRateCents, originalHours: line.hours, adjustedCostRateCents: result.adjustedCostRateCents, adjustedBillRateCents: result.adjustedBillRateCents, adjustedHours: result.adjustedHours, appliedRules: result.appliedRules, hasChanges: result.adjustedCostRateCents !== line.costRateCents || result.adjustedBillRateCents !== line.billRateCents || result.adjustedHours !== line.hours, }; }); const linesChanged = previews.filter((p) => p.hasChanges).length; const totalOriginalCostCents = demandLines.reduce((s, l) => s + l.costRateCents * l.hours, 0); const totalAdjustedCostCents = previews.reduce((s, p) => s + p.adjustedCostRateCents * p.adjustedHours, 0); return { previews, demandLineCount: demandLines.length, linesChanged, totalOriginalCostCents: Math.round(totalOriginalCostCents), totalAdjustedCostCents: Math.round(totalAdjustedCostCents), multiplierSetName: multiplierSet.name, ruleCount: multiplierSet.rules.length, }; }), /** Apply multipliers to demand lines on the working version */ applyRules: managerProcedure .input(ApplyExperienceMultipliersSchema) .mutation(async ({ ctx, input }) => { const [estimate, multiplierSet] = await Promise.all([ findUniqueOrThrow( ctx.db.estimate.findUnique({ where: { id: input.estimateId }, include: { versions: { orderBy: { versionNumber: "desc" }, take: 1, include: { demandLines: true }, }, }, }), "Estimate", ), findUniqueOrThrow( ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.multiplierSetId }, include: ruleInclude, }), "Experience multiplier set", ), ]); const version = estimate.versions[0]; if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" }); if (version.status !== "WORKING") { throw new TRPCError({ code: "BAD_REQUEST", message: "Can only apply multipliers to a WORKING version" }); } const engineRules = toEngineRules(multiplierSet.rules); const demandLines = version.demandLines; const inputs = demandLines.map((line) => ({ costRateCents: line.costRateCents, billRateCents: line.billRateCents, hours: line.hours, ...(line.chapter != null ? { chapter: line.chapter } : {}), ...(line.metadata != null && typeof line.metadata === "object" && "location" in (line.metadata as Record) ? { location: (line.metadata as Record).location as string } : {}), ...(line.staffingAttributes != null && typeof line.staffingAttributes === "object" && "level" in (line.staffingAttributes as Record) ? { level: (line.staffingAttributes as Record).level as string } : {}), })); const batch = applyExperienceMultipliersBatch(inputs, engineRules); // Update each demand line that changed let updatedCount = 0; for (let i = 0; i < demandLines.length; i++) { const line = demandLines[i]!; const result = batch.results[i]!; if ( result.adjustedCostRateCents !== line.costRateCents || result.adjustedBillRateCents !== line.billRateCents || result.adjustedHours !== line.hours ) { const newCostTotal = Math.round(result.adjustedCostRateCents * result.adjustedHours); const newPriceTotal = Math.round(result.adjustedBillRateCents * result.adjustedHours); await ctx.db.estimateDemandLine.update({ where: { id: line.id }, data: { costRateCents: result.adjustedCostRateCents, billRateCents: result.adjustedBillRateCents, hours: result.adjustedHours, costTotalCents: newCostTotal, priceTotalCents: newPriceTotal, metadata: { ...(typeof line.metadata === "object" && line.metadata !== null ? line.metadata as Record : {}), experienceMultiplier: { setId: multiplierSet.id, setName: multiplierSet.name, appliedRules: result.appliedRules, originalCostRateCents: line.costRateCents, originalBillRateCents: line.billRateCents, originalHours: line.hours, }, }, }, }); updatedCount++; } } // Audit log await ctx.db.auditLog.create({ data: { entityType: "Estimate", entityId: estimate.id, action: "UPDATE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), changes: { after: { experienceMultipliersApplied: { setId: multiplierSet.id, setName: multiplierSet.name, linesUpdated: updatedCount, totalOriginalHours: batch.totalOriginalHours, totalAdjustedHours: batch.totalAdjustedHours, }, }, }, }, }); return { linesUpdated: updatedCount, totalOriginalHours: batch.totalOriginalHours, totalAdjustedHours: batch.totalAdjustedHours, }; }), });