From 24b8ba6c12df4e1d99b8a586610a1d2e7959c523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 19:59:36 +0200 Subject: [PATCH] refactor(api): extract effort rule procedures --- .../effort-rule-procedure-support.test.ts | 215 +++++++++++++ .../router/effort-rule-procedure-support.ts | 283 ++++++++++++++++++ packages/api/src/router/effort-rule.ts | 272 ++--------------- 3 files changed, 519 insertions(+), 251 deletions(-) create mode 100644 packages/api/src/__tests__/effort-rule-procedure-support.test.ts create mode 100644 packages/api/src/router/effort-rule-procedure-support.ts diff --git a/packages/api/src/__tests__/effort-rule-procedure-support.test.ts b/packages/api/src/__tests__/effort-rule-procedure-support.test.ts new file mode 100644 index 0000000..126c893 --- /dev/null +++ b/packages/api/src/__tests__/effort-rule-procedure-support.test.ts @@ -0,0 +1,215 @@ +import { SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi } from "vitest"; + +const { createAuditEntry } = vi.hoisted(() => ({ + createAuditEntry: vi.fn(), +})); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry, +})); + +vi.mock("@capakraken/engine", () => ({ + expandScopeToEffort: vi.fn().mockReturnValue({ + lines: [ + { + scopeItemName: "Shot_001", + discipline: "Compositing", + chapter: null, + hours: 16, + unitMode: "per_frame", + unitCount: 200, + hoursPerUnit: 0.08, + }, + ], + warnings: [], + unmatchedScopeItems: [], + }), + aggregateByDiscipline: vi.fn().mockReturnValue({ + Compositing: { totalHours: 16, lineCount: 1 }, + }), +})); + +import { + applyEffortRules, + createEffortRuleSet, + previewEffortRules, +} from "../router/effort-rule-procedure-support.js"; + +function createManagerContext(db: Record) { + return { + db: db as never, + dbUser: { + id: "user_mgr", + systemRole: SystemRole.MANAGER, + permissionOverrides: null, + }, + }; +} + +function createControllerContext(db: Record) { + return { + db: db as never, + dbUser: { + id: "user_ctrl", + systemRole: SystemRole.CONTROLLER, + permissionOverrides: null, + }, + }; +} + +function sampleRuleSet(overrides: Record = {}) { + return { + id: "ers_1", + name: "VFX Standard", + description: null, + isDefault: true, + createdAt: new Date(), + updatedAt: new Date(), + rules: [ + { + id: "er_1", + ruleSetId: "ers_1", + scopeType: "shot", + discipline: "Compositing", + chapter: null, + unitMode: "per_frame", + hoursPerUnit: 0.08, + description: null, + sortOrder: 0, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + ...overrides, + }; +} + +describe("effort rule procedure support", () => { + it("creates a rule set and audits it", async () => { + const create = vi.fn().mockResolvedValue(sampleRuleSet()); + const updateMany = vi.fn().mockResolvedValue({ count: 1 }); + + const result = await createEffortRuleSet(createManagerContext({ + effortRuleSet: { + updateMany, + create, + }, + }), { + name: "VFX Standard", + description: "Default", + isDefault: true, + rules: [], + }); + + expect(updateMany).toHaveBeenCalledWith({ + where: { isDefault: true }, + data: { isDefault: false }, + }); + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + name: "VFX Standard", + isDefault: true, + }), + })); + expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({ + entityType: "EffortRuleSet", + action: "CREATE", + entityId: result.id, + })); + }); + + it("previews expanded effort using the latest estimate version", async () => { + const result = await previewEffortRules(createControllerContext({ + estimate: { + findUnique: vi.fn().mockResolvedValue({ + id: "est_1", + versions: [ + { + id: "v_1", + versionNumber: 2, + scopeItems: [ + { + name: "Shot_001", + scopeType: "shot", + frameCount: 200, + itemCount: null, + unitMode: "per_frame", + }, + ], + }, + ], + }), + }, + effortRuleSet: { + findUnique: vi.fn().mockResolvedValue(sampleRuleSet()), + }, + }), { + estimateId: "est_1", + ruleSetId: "ers_1", + }); + + expect(result.scopeItemCount).toBe(1); + expect(result.ruleCount).toBe(1); + expect(result.aggregated).toEqual({ + Compositing: { totalHours: 16, lineCount: 1 }, + }); + }); + + it("replaces demand lines and writes an estimate audit when rules are applied", async () => { + const deleteMany = vi.fn().mockResolvedValue({ count: 1 }); + const createMany = vi.fn().mockResolvedValue({ count: 1 }); + const auditCreate = vi.fn().mockResolvedValue({}); + + const result = await applyEffortRules(createManagerContext({ + estimate: { + findUnique: vi.fn().mockResolvedValue({ + id: "est_1", + baseCurrency: "EUR", + versions: [ + { + id: "v_1", + versionNumber: 2, + status: "WORKING", + scopeItems: [ + { + name: "Shot_001", + scopeType: "shot", + frameCount: 200, + itemCount: null, + unitMode: "per_frame", + }, + ], + demandLines: [{ id: "old_1" }], + }, + ], + }), + }, + effortRuleSet: { + findUnique: vi.fn().mockResolvedValue(sampleRuleSet()), + }, + estimateDemandLine: { + deleteMany, + createMany, + }, + auditLog: { + create: auditCreate, + }, + }), { + estimateId: "est_1", + ruleSetId: "ers_1", + mode: "replace", + }); + + expect(deleteMany).toHaveBeenCalledWith({ + where: { estimateVersionId: "v_1" }, + }); + expect(createMany).toHaveBeenCalledTimes(1); + expect(auditCreate).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + linesGenerated: 1, + warnings: [], + unmatchedScopeItems: [], + }); + }); +}); diff --git a/packages/api/src/router/effort-rule-procedure-support.ts b/packages/api/src/router/effort-rule-procedure-support.ts new file mode 100644 index 0000000..3baebef --- /dev/null +++ b/packages/api/src/router/effort-rule-procedure-support.ts @@ -0,0 +1,283 @@ +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, + }; +} diff --git a/packages/api/src/router/effort-rule.ts b/packages/api/src/router/effort-rule.ts index f0ad061..f5fea7b 100644 --- a/packages/api/src/router/effort-rule.ts +++ b/packages/api/src/router/effort-rule.ts @@ -1,277 +1,47 @@ import { - expandScopeToEffort, - aggregateByDiscipline, -} from "@capakraken/engine"; -import { + ApplyEffortRulesSchema, 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"; + applyEffortRules, + createEffortRuleSet, + deleteEffortRuleSet, + effortRuleIdInputSchema, + effortRulePreviewInputSchema, + getEffortRuleSetById, + listEffortRuleSets, + previewEffortRules, + updateEffortRuleSet, +} from "./effort-rule-procedure-support.js"; export const effortRuleRouter = createTRPCRouter({ - list: controllerProcedure.query(async ({ ctx }) => { - return ctx.db.effortRuleSet.findMany({ - include: effortRuleInclude, - orderBy: [{ isDefault: "desc" }, { name: "asc" }], - }); - }), + list: controllerProcedure.query(({ ctx }) => listEffortRuleSets(ctx)), 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; - }), + .input(effortRuleIdInputSchema) + .query(({ ctx, input }) => getEffortRuleSetById(ctx, input)), 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; - }), + .mutation(({ ctx, input }) => createEffortRuleSet(ctx, input)), 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; - }), + .mutation(({ ctx, input }) => updateEffortRuleSet(ctx, input)), 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 }; - }), + .input(effortRuleIdInputSchema) + .mutation(({ ctx, input }) => deleteEffortRuleSet(ctx, input)), /** 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, - }; - }), + .input(effortRulePreviewInputSchema) + .query(({ ctx, input }) => previewEffortRules(ctx, input)), /** 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, - }; - }), + .mutation(({ ctx, input }) => applyEffortRules(ctx, input)), });