import { expandScopeToEffort, aggregateByDiscipline, } 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"; import { buildEstimateDemandLineRows, buildEffortRuleCreateManyRows, buildEffortRuleSetCreateData, buildEffortRuleSetUpdateData, effortRuleInclude, toEffortRuleEngineInputs, toScopeItemInputs, } from "./effort-rule-support.js"; export const effortRuleRouter = createTRPCRouter({ list: controllerProcedure.query(async ({ ctx }) => { return ctx.db.effortRuleSet.findMany({ include: effortRuleInclude, 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: effortRuleInclude, }), "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: buildEffortRuleSetCreateData(input), include: effortRuleInclude, }); 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: effortRuleInclude }), "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: buildEffortRuleCreateManyRows(input.rules, input.id), }); } const updated = await ctx.db.effortRuleSet.update({ where: { id: input.id }, data: buildEffortRuleSetUpdateData(input), include: effortRuleInclude, }); 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: effortRuleInclude, }), "Effort rule set", ), ]); const version = estimate.versions[0]; if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" }); const scopeItems = toScopeItemInputs(version.scopeItems); const rules = toEffortRuleEngineInputs(ruleSet.rules); 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: effortRuleInclude, }), "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 = toScopeItemInputs(version.scopeItems); const rules = toEffortRuleEngineInputs(ruleSet.rules); 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: buildEstimateDemandLineRows({ estimateVersionId: version.id, currency: estimate.baseCurrency, ruleSet: { id: ruleSet.id, name: ruleSet.name }, lines: result.lines, }), }); } // 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, }; }), });