diff --git a/packages/api/src/__tests__/experience-multiplier-procedure-support.test.ts b/packages/api/src/__tests__/experience-multiplier-procedure-support.test.ts new file mode 100644 index 0000000..5c2e781 --- /dev/null +++ b/packages/api/src/__tests__/experience-multiplier-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", () => ({ + applyExperienceMultipliers: vi.fn().mockReturnValue({ + adjustedCostRateCents: 12_000, + adjustedBillRateCents: 18_000, + adjustedHours: 110, + appliedRules: ["Rate multiplied (chapter=VFX): cost x1.2, bill x1.2"], + }), + applyExperienceMultipliersBatch: vi.fn().mockReturnValue({ + results: [ + { + adjustedCostRateCents: 12_000, + adjustedBillRateCents: 18_000, + adjustedHours: 110, + appliedRules: ["Rate multiplied (chapter=VFX): cost x1.2, bill x1.2"], + }, + ], + totalOriginalHours: 100, + totalAdjustedHours: 110, + linesAdjusted: 1, + }), +})); + +import { + applyExperienceMultiplierRules, + createExperienceMultiplierSet, + previewExperienceMultipliers, +} from "../router/experience-multiplier-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 sampleMultiplierSet(overrides: Record = {}) { + return { + id: "ems_1", + name: "Standard Multipliers", + description: null, + isDefault: true, + createdAt: new Date(), + updatedAt: new Date(), + rules: [ + { + id: "emr_1", + multiplierSetId: "ems_1", + chapter: "VFX", + location: null, + level: null, + costMultiplier: 1.2, + billMultiplier: 1.2, + shoringRatio: null, + additionalEffortRatio: null, + description: null, + sortOrder: 0, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + ...overrides, + }; +} + +describe("experience multiplier procedure support", () => { + it("creates a multiplier set and audits it", async () => { + const create = vi.fn().mockResolvedValue(sampleMultiplierSet()); + const updateMany = vi.fn().mockResolvedValue({ count: 1 }); + + const result = await createExperienceMultiplierSet(createManagerContext({ + experienceMultiplierSet: { + updateMany, + create, + }, + }), { + name: "Standard Multipliers", + description: "Default", + isDefault: true, + rules: [], + }); + + expect(updateMany).toHaveBeenCalledWith({ + where: { isDefault: true }, + data: { isDefault: false }, + }); + expect(create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + name: "Standard Multipliers", + isDefault: true, + }), + })); + expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({ + entityType: "ExperienceMultiplierSet", + action: "CREATE", + entityId: result.id, + })); + }); + + it("previews adjusted demand lines for the latest estimate version", async () => { + const result = await previewExperienceMultipliers(createControllerContext({ + estimate: { + findUnique: vi.fn().mockResolvedValue({ + id: "est_1", + versions: [ + { + id: "v_1", + versionNumber: 2, + demandLines: [ + { + id: "dl_1", + name: "Compositing Senior", + chapter: "VFX", + costRateCents: 10_000, + billRateCents: 15_000, + hours: 100, + metadata: null, + staffingAttributes: null, + }, + ], + }, + ], + }), + }, + experienceMultiplierSet: { + findUnique: vi.fn().mockResolvedValue(sampleMultiplierSet()), + }, + }), { + estimateId: "est_1", + multiplierSetId: "ems_1", + }); + + expect(result.demandLineCount).toBe(1); + expect(result.linesChanged).toBe(1); + expect(result.totalOriginalCostCents).toBe(1_000_000); + expect(result.totalAdjustedCostCents).toBe(1_320_000); + }); + + it("updates changed demand lines and audits estimate updates", async () => { + const update = vi.fn().mockResolvedValue({}); + const auditCreate = vi.fn().mockResolvedValue({}); + + const result = await applyExperienceMultiplierRules(createManagerContext({ + estimate: { + findUnique: vi.fn().mockResolvedValue({ + id: "est_1", + versions: [ + { + id: "v_1", + versionNumber: 2, + status: "WORKING", + demandLines: [ + { + id: "dl_1", + name: "Compositing Senior", + chapter: "VFX", + costRateCents: 10_000, + billRateCents: 15_000, + hours: 100, + metadata: null, + staffingAttributes: null, + }, + ], + }, + ], + }), + }, + experienceMultiplierSet: { + findUnique: vi.fn().mockResolvedValue(sampleMultiplierSet()), + }, + estimateDemandLine: { + update, + }, + auditLog: { + create: auditCreate, + }, + }), { + estimateId: "est_1", + multiplierSetId: "ems_1", + }); + + expect(update).toHaveBeenCalledTimes(1); + expect(auditCreate).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + linesUpdated: 1, + totalOriginalHours: 100, + totalAdjustedHours: 110, + }); + }); +}); diff --git a/packages/api/src/router/experience-multiplier-procedure-support.ts b/packages/api/src/router/experience-multiplier-procedure-support.ts new file mode 100644 index 0000000..6f36ebc --- /dev/null +++ b/packages/api/src/router/experience-multiplier-procedure-support.ts @@ -0,0 +1,321 @@ +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, + }; +} diff --git a/packages/api/src/router/experience-multiplier.ts b/packages/api/src/router/experience-multiplier.ts index 9bf678a..8f250e2 100644 --- a/packages/api/src/router/experience-multiplier.ts +++ b/packages/api/src/router/experience-multiplier.ts @@ -1,299 +1,47 @@ import { - applyExperienceMultipliers, - applyExperienceMultipliersBatch, -} from "@capakraken/engine"; -import { + ApplyExperienceMultipliersSchema, CreateExperienceMultiplierSetSchema, UpdateExperienceMultiplierSetSchema, - ApplyExperienceMultipliersSchema, } 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 { - buildExperienceMultiplierCreateManyRows, - buildExperienceMultiplierDemandLineUpdateData, - buildExperienceMultiplierInput, - buildExperienceMultiplierSetCreateData, - buildExperienceMultiplierSetUpdateData, - experienceMultiplierRuleInclude, - hasExperienceMultiplierChanges, - toExperienceMultiplierEngineRules, -} from "./experience-multiplier-support.js"; + applyExperienceMultiplierRules, + createExperienceMultiplierSet, + deleteExperienceMultiplierSet, + experienceMultiplierIdInputSchema, + experienceMultiplierPreviewInputSchema, + getExperienceMultiplierSetById, + listExperienceMultiplierSets, + previewExperienceMultipliers, + updateExperienceMultiplierSet, +} from "./experience-multiplier-procedure-support.js"; export const experienceMultiplierRouter = createTRPCRouter({ - list: controllerProcedure.query(async ({ ctx }) => { - return ctx.db.experienceMultiplierSet.findMany({ - include: experienceMultiplierRuleInclude, - orderBy: [{ isDefault: "desc" }, { name: "asc" }], - }); - }), + list: controllerProcedure.query(({ ctx }) => listExperienceMultiplierSets(ctx)), getById: controllerProcedure - .input(z.object({ id: z.string() })) - .query(async ({ ctx, input }) => { - const set = await findUniqueOrThrow( - ctx.db.experienceMultiplierSet.findUnique({ - where: { id: input.id }, - include: experienceMultiplierRuleInclude, - }), - "Experience multiplier set", - ); - return set; - }), + .input(experienceMultiplierIdInputSchema) + .query(({ ctx, input }) => getExperienceMultiplierSetById(ctx, input)), create: managerProcedure .input(CreateExperienceMultiplierSetSchema) - .mutation(async ({ ctx, input }) => { - 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", - userId: ctx.dbUser?.id, - after: { name: input.name, isDefault: input.isDefault, ruleCount: input.rules.length }, - source: "ui", - }); - - return set; - }), + .mutation(({ ctx, input }) => createExperienceMultiplierSet(ctx, input)), update: managerProcedure .input(UpdateExperienceMultiplierSetSchema) - .mutation(async ({ ctx, input }) => { - const before = await findUniqueOrThrow( - ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.id }, include: experienceMultiplierRuleInclude }), - "Experience multiplier set", - ); - - 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", - 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 }) => updateExperienceMultiplierSet(ctx, input)), delete: managerProcedure - .input(z.object({ id: z.string() })) - .mutation(async ({ ctx, input }) => { - 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", - userId: ctx.dbUser?.id, - source: "ui", - }); - - return { id: input.id }; - }), + .input(experienceMultiplierIdInputSchema) + .mutation(({ ctx, input }) => deleteExperienceMultiplierSet(ctx, input)), /** Preview the rate adjustment without persisting */ preview: controllerProcedure - .input(z.object({ - estimateId: z.string(), - multiplierSetId: z.string(), - })) - .query(async ({ ctx, input }) => { - const [estimate, multiplierSet] = await Promise.all([ - findUniqueOrThrow( - ctx.db.estimate.findUnique({ - where: { id: input.estimateId }, - include: { - versions: { - orderBy: { versionNumber: "desc" }, - take: 1, - include: { demandLines: { orderBy: { createdAt: "asc" } } }, - }, - }, - }), - "Estimate", - ), - findUniqueOrThrow( - ctx.db.experienceMultiplierSet.findUnique({ - where: { id: input.multiplierSetId }, - include: experienceMultiplierRuleInclude, - }), - "Experience multiplier set", - ), - ]); - - 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((p) => p.hasChanges).length; - const totalOriginalCostCents = demandLines.reduce((s, l) => s + l.costRateCents * l.hours, 0); - const totalAdjustedCostCents = previews.reduce((s, p) => s + p.adjustedCostRateCents * p.adjustedHours, 0); - - return { - previews, - demandLineCount: demandLines.length, - linesChanged, - totalOriginalCostCents: Math.round(totalOriginalCostCents), - totalAdjustedCostCents: Math.round(totalAdjustedCostCents), - multiplierSetName: multiplierSet.name, - ruleCount: multiplierSet.rules.length, - }; - }), + .input(experienceMultiplierPreviewInputSchema) + .query(({ ctx, input }) => previewExperienceMultipliers(ctx, input)), /** Apply multipliers to demand lines on the working version */ applyRules: managerProcedure .input(ApplyExperienceMultipliersSchema) - .mutation(async ({ ctx, input }) => { - const [estimate, multiplierSet] = await Promise.all([ - findUniqueOrThrow( - ctx.db.estimate.findUnique({ - where: { id: input.estimateId }, - include: { - versions: { - orderBy: { versionNumber: "desc" }, - take: 1, - include: { demandLines: true }, - }, - }, - }), - "Estimate", - ), - findUniqueOrThrow( - ctx.db.experienceMultiplierSet.findUnique({ - where: { id: input.multiplierSetId }, - include: experienceMultiplierRuleInclude, - }), - "Experience multiplier 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 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); - - // Update each demand line that changed - 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++; - } - } - - // Audit log - 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, - }; - }), + .mutation(({ ctx, input }) => applyExperienceMultiplierRules(ctx, input)), });