import { expandScopeToEffort, aggregateByDiscipline, type EffortRuleInput, type ScopeItemInput, } from "@capakraken/engine"; import { CreateEffortRuleSetSchema, UpdateEffortRuleSetSchema, ApplyEffortRulesSchema, } 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; export const effortRuleRouter = createTRPCRouter({ list: controllerProcedure.query(async ({ ctx }) => { return ctx.db.effortRuleSet.findMany({ include: ruleInclude, orderBy: [{ isDefault: "desc" }, { name: "asc" }], }); }), getById: controllerProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const ruleSet = await findUniqueOrThrow( ctx.db.effortRuleSet.findUnique({ where: { id: input.id }, include: ruleInclude, }), "Effort rule set", ); return ruleSet; }), create: managerProcedure .input(CreateEffortRuleSetSchema) .mutation(async ({ ctx, input }) => { // If this is set as default, unset other defaults if (input.isDefault) { await ctx.db.effortRuleSet.updateMany({ where: { isDefault: true }, data: { isDefault: false }, }); } const ruleSet = await ctx.db.effortRuleSet.create({ data: { name: input.name, ...(input.description ? { description: input.description } : {}), isDefault: input.isDefault, rules: { create: input.rules.map((r, i) => ({ scopeType: r.scopeType, discipline: r.discipline, ...(r.chapter ? { chapter: r.chapter } : {}), unitMode: r.unitMode, hoursPerUnit: r.hoursPerUnit, ...(r.description ? { description: r.description } : {}), sortOrder: r.sortOrder ?? i, })), }, }, include: ruleInclude, }); void createAuditEntry({ db: ctx.db, entityType: "EffortRuleSet", entityId: ruleSet.id, entityName: ruleSet.name, action: "CREATE", userId: ctx.dbUser?.id, after: { name: input.name, isDefault: input.isDefault, ruleCount: input.rules.length }, source: "ui", }); return ruleSet; }), update: managerProcedure .input(UpdateEffortRuleSetSchema) .mutation(async ({ ctx, input }) => { const before = await findUniqueOrThrow( ctx.db.effortRuleSet.findUnique({ where: { id: input.id }, include: ruleInclude }), "Effort rule set", ); // If setting as default, unset others if (input.isDefault) { await ctx.db.effortRuleSet.updateMany({ where: { isDefault: true, id: { not: input.id } }, data: { isDefault: false }, }); } // If rules are provided, replace all existing rules if (input.rules) { await ctx.db.effortRule.deleteMany({ where: { ruleSetId: input.id } }); await ctx.db.effortRule.createMany({ data: input.rules.map((r, i) => ({ ruleSetId: input.id, scopeType: r.scopeType, discipline: r.discipline, ...(r.chapter ? { chapter: r.chapter } : {}), unitMode: r.unitMode, hoursPerUnit: r.hoursPerUnit, ...(r.description ? { description: r.description } : {}), sortOrder: r.sortOrder ?? i, })), }); } const updated = await ctx.db.effortRuleSet.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: "EffortRuleSet", 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 ruleSet = await findUniqueOrThrow( ctx.db.effortRuleSet.findUnique({ where: { id: input.id } }), "Effort rule set", ); await ctx.db.effortRuleSet.delete({ where: { id: input.id } }); void createAuditEntry({ db: ctx.db, entityType: "EffortRuleSet", entityId: input.id, entityName: ruleSet.name, action: "DELETE", userId: ctx.dbUser?.id, source: "ui", }); return { id: input.id }; }), /** Preview the expansion result without persisting */ preview: controllerProcedure .input(z.object({ estimateId: z.string(), ruleSetId: z.string(), })) .query(async ({ ctx, input }) => { const [estimate, ruleSet] = await Promise.all([ findUniqueOrThrow( ctx.db.estimate.findUnique({ where: { id: input.estimateId }, include: { versions: { orderBy: { versionNumber: "desc" }, take: 1, include: { scopeItems: { orderBy: { sortOrder: "asc" } } }, }, }, }), "Estimate", ), findUniqueOrThrow( ctx.db.effortRuleSet.findUnique({ where: { id: input.ruleSetId }, include: ruleInclude, }), "Effort rule set", ), ]); const version = estimate.versions[0]; if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" }); const scopeItems: ScopeItemInput[] = version.scopeItems.map((s) => ({ name: s.name, scopeType: s.scopeType, frameCount: s.frameCount, itemCount: s.itemCount, unitMode: s.unitMode, })); const rules: EffortRuleInput[] = ruleSet.rules.map((r) => ({ scopeType: r.scopeType, discipline: r.discipline, chapter: r.chapter, unitMode: r.unitMode as "per_frame" | "per_item" | "flat", hoursPerUnit: r.hoursPerUnit, sortOrder: r.sortOrder, })); const result = expandScopeToEffort(scopeItems, rules); const aggregated = aggregateByDiscipline(result.lines); return { ...result, aggregated, scopeItemCount: scopeItems.length, ruleCount: rules.length, }; }), /** Apply effort rules to generate demand lines on the working version */ applyRules: managerProcedure .input(ApplyEffortRulesSchema) .mutation(async ({ ctx, input }) => { const [estimate, ruleSet] = await Promise.all([ findUniqueOrThrow( ctx.db.estimate.findUnique({ where: { id: input.estimateId }, include: { versions: { orderBy: { versionNumber: "desc" }, take: 1, include: { scopeItems: { orderBy: { sortOrder: "asc" } }, demandLines: true, }, }, }, }), "Estimate", ), findUniqueOrThrow( ctx.db.effortRuleSet.findUnique({ where: { id: input.ruleSetId }, include: ruleInclude, }), "Effort rule 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 rules to a WORKING version" }); } const scopeItems: ScopeItemInput[] = version.scopeItems.map((s) => ({ name: s.name, scopeType: s.scopeType, frameCount: s.frameCount, itemCount: s.itemCount, unitMode: s.unitMode, })); const rules: EffortRuleInput[] = ruleSet.rules.map((r) => ({ scopeType: r.scopeType, discipline: r.discipline, chapter: r.chapter, unitMode: r.unitMode as "per_frame" | "per_item" | "flat", hoursPerUnit: r.hoursPerUnit, sortOrder: r.sortOrder, })); const result = expandScopeToEffort(scopeItems, rules); // In replace mode, delete existing demand lines first if (input.mode === "replace") { await ctx.db.estimateDemandLine.deleteMany({ where: { estimateVersionId: version.id }, }); } // Create demand lines from expanded results if (result.lines.length > 0) { await ctx.db.estimateDemandLine.createMany({ data: result.lines.map((line) => ({ estimateVersionId: version.id, lineType: "LABOR", name: `${line.discipline} — ${line.scopeItemName}`, ...(line.chapter ? { chapter: line.chapter } : {}), hours: line.hours, costRateCents: 0, billRateCents: 0, currency: estimate.baseCurrency, costTotalCents: 0, priceTotalCents: 0, monthlySpread: {}, staffingAttributes: {}, metadata: { effortRule: { ruleSetId: ruleSet.id, ruleSetName: ruleSet.name, discipline: line.discipline, unitMode: line.unitMode, unitCount: line.unitCount, hoursPerUnit: line.hoursPerUnit, }, }, })), }); } // Log audit await ctx.db.auditLog.create({ data: { entityType: "Estimate", entityId: estimate.id, action: "UPDATE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), changes: { after: { effortRulesApplied: { ruleSetId: ruleSet.id, ruleSetName: ruleSet.name, mode: input.mode, linesGenerated: result.lines.length, warnings: result.warnings, }, }, }, }, }); return { linesGenerated: result.lines.length, warnings: result.warnings, unmatchedScopeItems: result.unmatchedScopeItems, }; }), });