import { aggregateByDiscipline, expandScopeToEffort, } from "@capakraken/engine"; import { ApplyEffortRulesSchema, CreateEffortRuleSetSchema, UpdateEffortRuleSetSchema, } 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 { buildEstimateDemandLineRows, buildEffortRuleCreateManyRows, buildEffortRuleSetCreateData, buildEffortRuleSetUpdateData, effortRuleInclude, toEffortRuleEngineInputs, toScopeItemInputs, } from "./effort-rule-support.js"; type EffortRuleProcedureContext = Pick; function withAuditUser(userId: string | undefined) { return userId ? { userId } : {}; } export const effortRuleIdInputSchema = z.object({ id: z.string() }); export const effortRulePreviewInputSchema = z.object({ estimateId: z.string(), ruleSetId: z.string(), }); type EffortRuleIdInput = z.infer; type EffortRuleCreateInput = z.infer; type EffortRuleUpdateInput = z.infer; type EffortRulePreviewInput = z.infer; type EffortRuleApplyInput = z.infer; async function getEffortRuleSetOrThrow( ctx: EffortRuleProcedureContext, id: string, ) { return findUniqueOrThrow( ctx.db.effortRuleSet.findUnique({ where: { id }, include: effortRuleInclude, }), "Effort rule set", ); } async function getEstimateWithLatestVersionOrThrow( ctx: EffortRuleProcedureContext, estimateId: string, includeDemandLines: boolean, ) { return findUniqueOrThrow( ctx.db.estimate.findUnique({ where: { id: estimateId }, include: { versions: { orderBy: { versionNumber: "desc" }, take: 1, include: { scopeItems: { orderBy: { sortOrder: "asc" } }, ...(includeDemandLines ? { demandLines: true } : {}), }, }, }, }), "Estimate", ); } export async function listEffortRuleSets( ctx: EffortRuleProcedureContext, ) { return ctx.db.effortRuleSet.findMany({ include: effortRuleInclude, orderBy: [{ isDefault: "desc" }, { name: "asc" }], }); } export async function getEffortRuleSetById( ctx: EffortRuleProcedureContext, input: EffortRuleIdInput, ) { return getEffortRuleSetOrThrow(ctx, input.id); } export async function createEffortRuleSet( ctx: EffortRuleProcedureContext, input: EffortRuleCreateInput, ) { 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", ...withAuditUser(ctx.dbUser?.id), after: { name: input.name, isDefault: input.isDefault, ruleCount: input.rules.length }, source: "ui", }); return ruleSet; } export async function updateEffortRuleSet( ctx: EffortRuleProcedureContext, input: EffortRuleUpdateInput, ) { const before = await getEffortRuleSetOrThrow(ctx, input.id); if (input.isDefault) { await ctx.db.effortRuleSet.updateMany({ where: { isDefault: true, id: { not: input.id } }, data: { isDefault: false }, }); } 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", ...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 deleteEffortRuleSet( ctx: EffortRuleProcedureContext, input: EffortRuleIdInput, ) { 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", ...withAuditUser(ctx.dbUser?.id), source: "ui", }); return { id: input.id }; } export async function previewEffortRules( ctx: EffortRuleProcedureContext, input: EffortRulePreviewInput, ) { const [estimate, ruleSet] = await Promise.all([ getEstimateWithLatestVersionOrThrow(ctx, input.estimateId, false), getEffortRuleSetOrThrow(ctx, input.ruleSetId), ]); 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, }; } export async function applyEffortRules( ctx: EffortRuleProcedureContext, input: EffortRuleApplyInput, ) { const [estimate, ruleSet] = await Promise.all([ getEstimateWithLatestVersionOrThrow(ctx, input.estimateId, true), getEffortRuleSetOrThrow(ctx, input.ruleSetId), ]); 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); if (input.mode === "replace") { await ctx.db.estimateDemandLine.deleteMany({ where: { estimateVersionId: version.id }, }); } 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, }), }); } 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, }; }