import { applyExperienceMultipliers, applyExperienceMultipliersBatch, } from "@capakraken/engine"; import { ApplyExperienceMultipliersSchema, CreateExperienceMultiplierSetSchema, UpdateExperienceMultiplierSetSchema, } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { createAuditEntry } from "../lib/audit.js"; import type { TRPCContext } from "../trpc.js"; import { buildExperienceMultiplierCreateManyRows, buildExperienceMultiplierDemandLineUpdateData, buildExperienceMultiplierInput, buildExperienceMultiplierSetCreateData, buildExperienceMultiplierSetUpdateData, experienceMultiplierRuleInclude, hasExperienceMultiplierChanges, toExperienceMultiplierEngineRules, } from "./experience-multiplier-support.js"; type ExperienceMultiplierProcedureContext = Pick; function withAuditUser(userId: string | undefined) { return userId ? { userId } : {}; } export const experienceMultiplierIdInputSchema = z.object({ id: z.string() }); export const experienceMultiplierPreviewInputSchema = z.object({ estimateId: z.string(), multiplierSetId: z.string(), }); type ExperienceMultiplierIdInput = z.infer; type ExperienceMultiplierCreateInput = z.infer; type ExperienceMultiplierUpdateInput = z.infer; type ExperienceMultiplierPreviewInput = z.infer; type ExperienceMultiplierApplyInput = z.infer; async function getExperienceMultiplierSetOrThrow( ctx: ExperienceMultiplierProcedureContext, id: string, ) { return findUniqueOrThrow( ctx.db.experienceMultiplierSet.findUnique({ where: { id }, include: experienceMultiplierRuleInclude, }), "Experience multiplier set", ); } async function getEstimateWithLatestVersionOrThrow( ctx: ExperienceMultiplierProcedureContext, estimateId: string, orderedDemandLines: boolean, ) { return findUniqueOrThrow( ctx.db.estimate.findUnique({ where: { id: estimateId }, include: { versions: { orderBy: { versionNumber: "desc" }, take: 1, include: { demandLines: orderedDemandLines ? { orderBy: { createdAt: "asc" } } : true, }, }, }, }), "Estimate", ); } export async function listExperienceMultiplierSets( ctx: ExperienceMultiplierProcedureContext, ) { return ctx.db.experienceMultiplierSet.findMany({ include: experienceMultiplierRuleInclude, orderBy: [{ isDefault: "desc" }, { name: "asc" }], }); } export async function getExperienceMultiplierSetById( ctx: ExperienceMultiplierProcedureContext, input: ExperienceMultiplierIdInput, ) { return getExperienceMultiplierSetOrThrow(ctx, input.id); } export async function createExperienceMultiplierSet( ctx: ExperienceMultiplierProcedureContext, input: ExperienceMultiplierCreateInput, ) { if (input.isDefault) { await ctx.db.experienceMultiplierSet.updateMany({ where: { isDefault: true }, data: { isDefault: false }, }); } const set = await ctx.db.experienceMultiplierSet.create({ data: buildExperienceMultiplierSetCreateData(input), include: experienceMultiplierRuleInclude, }); void createAuditEntry({ db: ctx.db, entityType: "ExperienceMultiplierSet", entityId: set.id, entityName: set.name, action: "CREATE", ...withAuditUser(ctx.dbUser?.id), after: { name: input.name, isDefault: input.isDefault, ruleCount: input.rules.length }, source: "ui", }); return set; } export async function updateExperienceMultiplierSet( ctx: ExperienceMultiplierProcedureContext, input: ExperienceMultiplierUpdateInput, ) { const before = await getExperienceMultiplierSetOrThrow(ctx, input.id); 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: buildExperienceMultiplierCreateManyRows(input.rules, input.id), }); } const updated = await ctx.db.experienceMultiplierSet.update({ where: { id: input.id }, data: buildExperienceMultiplierSetUpdateData(input), include: experienceMultiplierRuleInclude, }); void createAuditEntry({ db: ctx.db, entityType: "ExperienceMultiplierSet", entityId: input.id, entityName: updated.name, action: "UPDATE", ...withAuditUser(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; } export async function deleteExperienceMultiplierSet( ctx: ExperienceMultiplierProcedureContext, input: ExperienceMultiplierIdInput, ) { 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", ...withAuditUser(ctx.dbUser?.id), source: "ui", }); return { id: input.id }; } export async function previewExperienceMultipliers( ctx: ExperienceMultiplierProcedureContext, input: ExperienceMultiplierPreviewInput, ) { const [estimate, multiplierSet] = await Promise.all([ getEstimateWithLatestVersionOrThrow(ctx, input.estimateId, true), getExperienceMultiplierSetOrThrow(ctx, input.multiplierSetId), ]); const version = estimate.versions[0]; if (!version) { throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" }); } const engineRules = toExperienceMultiplierEngineRules(multiplierSet.rules); const demandLines = version.demandLines; const previews = demandLines.map((line) => { const result = applyExperienceMultipliers( buildExperienceMultiplierInput(line), 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: hasExperienceMultiplierChanges(line, result), }; }); const linesChanged = previews.filter((preview) => preview.hasChanges).length; const totalOriginalCostCents = demandLines.reduce( (sum, line) => sum + line.costRateCents * line.hours, 0, ); const totalAdjustedCostCents = previews.reduce( (sum, preview) => sum + preview.adjustedCostRateCents * preview.adjustedHours, 0, ); return { previews, demandLineCount: demandLines.length, linesChanged, totalOriginalCostCents: Math.round(totalOriginalCostCents), totalAdjustedCostCents: Math.round(totalAdjustedCostCents), multiplierSetName: multiplierSet.name, ruleCount: multiplierSet.rules.length, }; } export async function applyExperienceMultiplierRules( ctx: ExperienceMultiplierProcedureContext, input: ExperienceMultiplierApplyInput, ) { const [estimate, multiplierSet] = await Promise.all([ getEstimateWithLatestVersionOrThrow(ctx, input.estimateId, false), getExperienceMultiplierSetOrThrow(ctx, input.multiplierSetId), ]); 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 = toExperienceMultiplierEngineRules(multiplierSet.rules); const demandLines = version.demandLines; const inputs = demandLines.map((line) => buildExperienceMultiplierInput(line)); const batch = applyExperienceMultipliersBatch(inputs, engineRules); let updatedCount = 0; for (let i = 0; i < demandLines.length; i++) { const line = demandLines[i]!; const result = batch.results[i]!; if (hasExperienceMultiplierChanges(line, result)) { await ctx.db.estimateDemandLine.update({ where: { id: line.id }, data: buildExperienceMultiplierDemandLineUpdateData({ line, result, multiplierSet, }), }); updatedCount++; } } 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, }; }