From 02275bac07b00d82f1d77c87de783a14273344dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 14:07:21 +0200 Subject: [PATCH] refactor(api): extract experience multiplier support --- .../experience-multiplier-support.test.ts | 195 ++++++++++++++++++ .../router/experience-multiplier-support.ts | 182 ++++++++++++++++ .../api/src/router/experience-multiplier.ts | 158 +++----------- 3 files changed, 408 insertions(+), 127 deletions(-) create mode 100644 packages/api/src/__tests__/experience-multiplier-support.test.ts create mode 100644 packages/api/src/router/experience-multiplier-support.ts diff --git a/packages/api/src/__tests__/experience-multiplier-support.test.ts b/packages/api/src/__tests__/experience-multiplier-support.test.ts new file mode 100644 index 0000000..b97f884 --- /dev/null +++ b/packages/api/src/__tests__/experience-multiplier-support.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "vitest"; +import { + buildExperienceMultiplierCreateManyRows, + buildExperienceMultiplierDemandLineUpdateData, + buildExperienceMultiplierInput, + buildExperienceMultiplierNestedCreateRows, + buildExperienceMultiplierSetCreateData, + buildExperienceMultiplierSetUpdateData, + experienceMultiplierRuleInclude, + hasExperienceMultiplierChanges, + toExperienceMultiplierEngineRules, +} from "../router/experience-multiplier-support.js"; + +describe("experience multiplier support", () => { + it("exposes the rule include ordering", () => { + expect(experienceMultiplierRuleInclude).toEqual({ + rules: { orderBy: { sortOrder: "asc" } }, + }); + }); + + it("builds create-many and nested-create rule rows", () => { + expect(buildExperienceMultiplierCreateManyRows([ + { + chapter: "VFX", + costMultiplier: 1.2, + billMultiplier: 1.3, + }, + { + location: "India", + level: "Senior", + costMultiplier: 0.8, + billMultiplier: 0.9, + shoringRatio: 0.5, + additionalEffortRatio: 0.1, + description: "Offshore senior", + sortOrder: 5, + }, + ], "ems_1")).toEqual([ + { + multiplierSetId: "ems_1", + chapter: "VFX", + costMultiplier: 1.2, + billMultiplier: 1.3, + sortOrder: 0, + }, + { + multiplierSetId: "ems_1", + location: "India", + level: "Senior", + costMultiplier: 0.8, + billMultiplier: 0.9, + shoringRatio: 0.5, + additionalEffortRatio: 0.1, + description: "Offshore senior", + sortOrder: 5, + }, + ]); + + expect(buildExperienceMultiplierNestedCreateRows([ + { + chapter: "Animation", + costMultiplier: 1.1, + billMultiplier: 1.2, + }, + ])).toEqual([ + { + chapter: "Animation", + costMultiplier: 1.1, + billMultiplier: 1.2, + sortOrder: 0, + }, + ]); + }); + + it("builds create and sparse update payloads", () => { + expect(buildExperienceMultiplierSetCreateData({ + name: "Standard Multipliers", + description: "Default adjustments", + isDefault: true, + rules: [ + { + chapter: "VFX", + costMultiplier: 1.2, + billMultiplier: 1.2, + sortOrder: 0, + }, + ], + })).toEqual({ + name: "Standard Multipliers", + description: "Default adjustments", + isDefault: true, + rules: { + create: [ + { + chapter: "VFX", + costMultiplier: 1.2, + billMultiplier: 1.2, + sortOrder: 0, + }, + ], + }, + }); + + expect(buildExperienceMultiplierSetUpdateData({ + description: null, + isDefault: false, + })).toEqual({ + description: null, + isDefault: false, + }); + }); + + it("maps db rules and demand lines into engine-facing inputs", () => { + expect(toExperienceMultiplierEngineRules([ + { + chapter: "VFX", + location: null, + level: null, + costMultiplier: 1.2, + billMultiplier: 1.1, + shoringRatio: null, + additionalEffortRatio: null, + description: null, + }, + ])).toEqual([ + { + chapter: "VFX", + costMultiplier: 1.2, + billMultiplier: 1.1, + }, + ]); + + expect(buildExperienceMultiplierInput({ + id: "dl_1", + name: "Comp Senior", + chapter: "VFX", + costRateCents: 10000, + billRateCents: 15000, + hours: 100, + metadata: { location: "India" }, + staffingAttributes: { level: "Senior" }, + })).toEqual({ + chapter: "VFX", + costRateCents: 10000, + billRateCents: 15000, + hours: 100, + location: "India", + level: "Senior", + }); + }); + + it("detects changes and builds demand line update data", () => { + const line = { + id: "dl_1", + name: "Comp Senior", + chapter: "VFX", + costRateCents: 10000, + billRateCents: 15000, + hours: 100, + metadata: { location: "India", existing: true }, + staffingAttributes: { level: "Senior" }, + }; + const result = { + adjustedCostRateCents: 12000, + adjustedBillRateCents: 18000, + adjustedHours: 110, + appliedRules: ["VFX uplift"], + }; + + expect(hasExperienceMultiplierChanges(line, result)).toBe(true); + expect(buildExperienceMultiplierDemandLineUpdateData({ + line, + result, + multiplierSet: { id: "ems_1", name: "Standard" }, + })).toEqual({ + costRateCents: 12000, + billRateCents: 18000, + hours: 110, + costTotalCents: Math.round(12000 * 110), + priceTotalCents: Math.round(18000 * 110), + metadata: { + location: "India", + existing: true, + experienceMultiplier: { + setId: "ems_1", + setName: "Standard", + appliedRules: ["VFX uplift"], + originalCostRateCents: 10000, + originalBillRateCents: 15000, + originalHours: 100, + }, + }, + }); + }); +}); diff --git a/packages/api/src/router/experience-multiplier-support.ts b/packages/api/src/router/experience-multiplier-support.ts new file mode 100644 index 0000000..6d985b4 --- /dev/null +++ b/packages/api/src/router/experience-multiplier-support.ts @@ -0,0 +1,182 @@ +import type { Prisma } from "@capakraken/db"; +import type { ExperienceMultiplierRule as EngineRule } from "@capakraken/engine"; +import { + CreateExperienceMultiplierSetSchema, + UpdateExperienceMultiplierSetSchema, +} from "@capakraken/shared"; +import { z } from "zod"; + +type CreateExperienceMultiplierSetInput = z.infer; +type UpdateExperienceMultiplierSetInput = z.infer; +type ExperienceMultiplierRuleRowInput = + | CreateExperienceMultiplierSetInput["rules"][number] + | NonNullable[number]; + +type ExperienceMultiplierRuleRecord = { + chapter: string | null; + location: string | null; + level: string | null; + costMultiplier: number; + billMultiplier: number; + shoringRatio: number | null; + additionalEffortRatio: number | null; + description: string | null; +}; + +type DemandLineRecord = { + id: string; + name: string; + chapter: string | null; + costRateCents: number; + billRateCents: number; + hours: number; + metadata: unknown; + staffingAttributes: unknown; +}; + +type MultiplierSetRecord = { + id: string; + name: string; +}; + +type ExperienceMultiplierResultRecord = { + adjustedCostRateCents: number; + adjustedBillRateCents: number; + adjustedHours: number; + appliedRules: string[]; +}; + +export const experienceMultiplierRuleInclude = { + rules: { orderBy: { sortOrder: "asc" as const } }, +} as const; + +function buildExperienceMultiplierRuleRow( + input: ExperienceMultiplierRuleRowInput, + index: number, +) { + return { + ...(input.chapter ? { chapter: input.chapter } : {}), + ...(input.location ? { location: input.location } : {}), + ...(input.level ? { level: input.level } : {}), + costMultiplier: input.costMultiplier, + billMultiplier: input.billMultiplier, + ...(input.shoringRatio !== undefined ? { shoringRatio: input.shoringRatio } : {}), + ...(input.additionalEffortRatio !== undefined ? { additionalEffortRatio: input.additionalEffortRatio } : {}), + ...(input.description ? { description: input.description } : {}), + sortOrder: input.sortOrder ?? index, + }; +} + +export function buildExperienceMultiplierNestedCreateRows( + rules: ExperienceMultiplierRuleRowInput[], +): Prisma.ExperienceMultiplierRuleUncheckedCreateWithoutMultiplierSetInput[] { + return rules.map((rule, index) => ({ + ...buildExperienceMultiplierRuleRow(rule, index), + })); +} + +export function buildExperienceMultiplierCreateManyRows( + rules: ExperienceMultiplierRuleRowInput[], + multiplierSetId: string, +): Prisma.ExperienceMultiplierRuleCreateManyInput[] { + return rules.map((rule, index) => ({ + multiplierSetId, + ...buildExperienceMultiplierRuleRow(rule, index), + })); +} + +export function buildExperienceMultiplierSetCreateData( + input: CreateExperienceMultiplierSetInput, +): Prisma.ExperienceMultiplierSetCreateInput { + return { + name: input.name, + ...(input.description ? { description: input.description } : {}), + isDefault: input.isDefault, + rules: { + create: buildExperienceMultiplierNestedCreateRows(input.rules), + }, + }; +} + +export function buildExperienceMultiplierSetUpdateData( + input: Omit, +): Prisma.ExperienceMultiplierSetUncheckedUpdateInput { + return { + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.description !== undefined ? { description: input.description } : {}), + ...(input.isDefault !== undefined ? { isDefault: input.isDefault } : {}), + }; +} + +export function toExperienceMultiplierEngineRules( + dbRules: ExperienceMultiplierRuleRecord[], +): EngineRule[] { + return dbRules.map((rule) => ({ + ...(rule.chapter != null ? { chapter: rule.chapter } : {}), + ...(rule.location != null ? { location: rule.location } : {}), + ...(rule.level != null ? { level: rule.level } : {}), + costMultiplier: rule.costMultiplier, + billMultiplier: rule.billMultiplier, + ...(rule.shoringRatio != null ? { shoringRatio: rule.shoringRatio } : {}), + ...(rule.additionalEffortRatio != null ? { additionalEffortRatio: rule.additionalEffortRatio } : {}), + ...(rule.description != null ? { description: rule.description } : {}), + })); +} + +export function buildExperienceMultiplierInput( + line: DemandLineRecord, +) { + return { + costRateCents: line.costRateCents, + billRateCents: line.billRateCents, + hours: line.hours, + ...(line.chapter != null ? { chapter: line.chapter } : {}), + ...(line.metadata != null && typeof line.metadata === "object" && "location" in (line.metadata as Record) + ? { location: (line.metadata as Record).location as string } + : {}), + ...(line.staffingAttributes != null && typeof line.staffingAttributes === "object" && "level" in (line.staffingAttributes as Record) + ? { level: (line.staffingAttributes as Record).level as string } + : {}), + }; +} + +export function hasExperienceMultiplierChanges( + line: Pick, + result: ExperienceMultiplierResultRecord, +): boolean { + return ( + result.adjustedCostRateCents !== line.costRateCents || + result.adjustedBillRateCents !== line.billRateCents || + result.adjustedHours !== line.hours + ); +} + +export function buildExperienceMultiplierDemandLineUpdateData(input: { + line: DemandLineRecord; + result: ExperienceMultiplierResultRecord; + multiplierSet: MultiplierSetRecord; +}): Prisma.EstimateDemandLineUncheckedUpdateInput { + const newCostTotal = Math.round(input.result.adjustedCostRateCents * input.result.adjustedHours); + const newPriceTotal = Math.round(input.result.adjustedBillRateCents * input.result.adjustedHours); + + return { + costRateCents: input.result.adjustedCostRateCents, + billRateCents: input.result.adjustedBillRateCents, + hours: input.result.adjustedHours, + costTotalCents: newCostTotal, + priceTotalCents: newPriceTotal, + metadata: { + ...(typeof input.line.metadata === "object" && input.line.metadata !== null + ? input.line.metadata as Record + : {}), + experienceMultiplier: { + setId: input.multiplierSet.id, + setName: input.multiplierSet.name, + appliedRules: input.result.appliedRules, + originalCostRateCents: input.line.costRateCents, + originalBillRateCents: input.line.billRateCents, + originalHours: input.line.hours, + }, + }, + }; +} diff --git a/packages/api/src/router/experience-multiplier.ts b/packages/api/src/router/experience-multiplier.ts index e9499d2..9bf678a 100644 --- a/packages/api/src/router/experience-multiplier.ts +++ b/packages/api/src/router/experience-multiplier.ts @@ -1,7 +1,6 @@ import { applyExperienceMultipliers, applyExperienceMultipliersBatch, - type ExperienceMultiplierRule as EngineRule, } from "@capakraken/engine"; import { CreateExperienceMultiplierSetSchema, @@ -13,39 +12,21 @@ 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; - -function toEngineRules( - dbRules: Array<{ - chapter: string | null; - location: string | null; - level: string | null; - costMultiplier: number; - billMultiplier: number; - shoringRatio: number | null; - additionalEffortRatio: number | null; - description: string | null; - }>, -): EngineRule[] { - return dbRules.map((r) => ({ - ...(r.chapter != null ? { chapter: r.chapter } : {}), - ...(r.location != null ? { location: r.location } : {}), - ...(r.level != null ? { level: r.level } : {}), - costMultiplier: r.costMultiplier, - billMultiplier: r.billMultiplier, - ...(r.shoringRatio != null ? { shoringRatio: r.shoringRatio } : {}), - ...(r.additionalEffortRatio != null ? { additionalEffortRatio: r.additionalEffortRatio } : {}), - ...(r.description != null ? { description: r.description } : {}), - })); -} +import { + buildExperienceMultiplierCreateManyRows, + buildExperienceMultiplierDemandLineUpdateData, + buildExperienceMultiplierInput, + buildExperienceMultiplierSetCreateData, + buildExperienceMultiplierSetUpdateData, + experienceMultiplierRuleInclude, + hasExperienceMultiplierChanges, + toExperienceMultiplierEngineRules, +} from "./experience-multiplier-support.js"; export const experienceMultiplierRouter = createTRPCRouter({ list: controllerProcedure.query(async ({ ctx }) => { return ctx.db.experienceMultiplierSet.findMany({ - include: ruleInclude, + include: experienceMultiplierRuleInclude, orderBy: [{ isDefault: "desc" }, { name: "asc" }], }); }), @@ -56,7 +37,7 @@ export const experienceMultiplierRouter = createTRPCRouter({ const set = await findUniqueOrThrow( ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.id }, - include: ruleInclude, + include: experienceMultiplierRuleInclude, }), "Experience multiplier set", ); @@ -74,25 +55,8 @@ export const experienceMultiplierRouter = createTRPCRouter({ } const set = await ctx.db.experienceMultiplierSet.create({ - data: { - name: input.name, - ...(input.description ? { description: input.description } : {}), - isDefault: input.isDefault, - rules: { - create: input.rules.map((r, i) => ({ - ...(r.chapter ? { chapter: r.chapter } : {}), - ...(r.location ? { location: r.location } : {}), - ...(r.level ? { level: r.level } : {}), - costMultiplier: r.costMultiplier, - billMultiplier: r.billMultiplier, - ...(r.shoringRatio !== undefined ? { shoringRatio: r.shoringRatio } : {}), - ...(r.additionalEffortRatio !== undefined ? { additionalEffortRatio: r.additionalEffortRatio } : {}), - ...(r.description ? { description: r.description } : {}), - sortOrder: r.sortOrder ?? i, - })), - }, - }, - include: ruleInclude, + data: buildExperienceMultiplierSetCreateData(input), + include: experienceMultiplierRuleInclude, }); void createAuditEntry({ @@ -113,7 +77,7 @@ export const experienceMultiplierRouter = createTRPCRouter({ .input(UpdateExperienceMultiplierSetSchema) .mutation(async ({ ctx, input }) => { const before = await findUniqueOrThrow( - ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.id }, include: ruleInclude }), + ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.id }, include: experienceMultiplierRuleInclude }), "Experience multiplier set", ); @@ -127,29 +91,14 @@ export const experienceMultiplierRouter = createTRPCRouter({ if (input.rules) { await ctx.db.experienceMultiplierRule.deleteMany({ where: { multiplierSetId: input.id } }); await ctx.db.experienceMultiplierRule.createMany({ - data: input.rules.map((r, i) => ({ - multiplierSetId: input.id, - ...(r.chapter ? { chapter: r.chapter } : {}), - ...(r.location ? { location: r.location } : {}), - ...(r.level ? { level: r.level } : {}), - costMultiplier: r.costMultiplier, - billMultiplier: r.billMultiplier, - ...(r.shoringRatio !== undefined ? { shoringRatio: r.shoringRatio } : {}), - ...(r.additionalEffortRatio !== undefined ? { additionalEffortRatio: r.additionalEffortRatio } : {}), - ...(r.description ? { description: r.description } : {}), - sortOrder: r.sortOrder ?? i, - })), + data: buildExperienceMultiplierCreateManyRows(input.rules, input.id), }); } const updated = await ctx.db.experienceMultiplierSet.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, + data: buildExperienceMultiplierSetUpdateData(input), + include: experienceMultiplierRuleInclude, }); void createAuditEntry({ @@ -213,7 +162,7 @@ export const experienceMultiplierRouter = createTRPCRouter({ findUniqueOrThrow( ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.multiplierSetId }, - include: ruleInclude, + include: experienceMultiplierRuleInclude, }), "Experience multiplier set", ), @@ -222,23 +171,12 @@ export const experienceMultiplierRouter = createTRPCRouter({ const version = estimate.versions[0]; if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" }); - const engineRules = toEngineRules(multiplierSet.rules); + const engineRules = toExperienceMultiplierEngineRules(multiplierSet.rules); const demandLines = version.demandLines; const previews = demandLines.map((line) => { const result = applyExperienceMultipliers( - { - costRateCents: line.costRateCents, - billRateCents: line.billRateCents, - hours: line.hours, - ...(line.chapter != null ? { chapter: line.chapter } : {}), - ...(line.metadata != null && typeof line.metadata === "object" && "location" in (line.metadata as Record) - ? { location: (line.metadata as Record).location as string } - : {}), - ...(line.staffingAttributes != null && typeof line.staffingAttributes === "object" && "level" in (line.staffingAttributes as Record) - ? { level: (line.staffingAttributes as Record).level as string } - : {}), - }, + buildExperienceMultiplierInput(line), engineRules, ); @@ -253,10 +191,7 @@ export const experienceMultiplierRouter = createTRPCRouter({ adjustedBillRateCents: result.adjustedBillRateCents, adjustedHours: result.adjustedHours, appliedRules: result.appliedRules, - hasChanges: - result.adjustedCostRateCents !== line.costRateCents || - result.adjustedBillRateCents !== line.billRateCents || - result.adjustedHours !== line.hours, + hasChanges: hasExperienceMultiplierChanges(line, result), }; }); @@ -296,7 +231,7 @@ export const experienceMultiplierRouter = createTRPCRouter({ findUniqueOrThrow( ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.multiplierSetId }, - include: ruleInclude, + include: experienceMultiplierRuleInclude, }), "Experience multiplier set", ), @@ -308,21 +243,10 @@ export const experienceMultiplierRouter = createTRPCRouter({ throw new TRPCError({ code: "BAD_REQUEST", message: "Can only apply multipliers to a WORKING version" }); } - const engineRules = toEngineRules(multiplierSet.rules); + const engineRules = toExperienceMultiplierEngineRules(multiplierSet.rules); const demandLines = version.demandLines; - const inputs = demandLines.map((line) => ({ - costRateCents: line.costRateCents, - billRateCents: line.billRateCents, - hours: line.hours, - ...(line.chapter != null ? { chapter: line.chapter } : {}), - ...(line.metadata != null && typeof line.metadata === "object" && "location" in (line.metadata as Record) - ? { location: (line.metadata as Record).location as string } - : {}), - ...(line.staffingAttributes != null && typeof line.staffingAttributes === "object" && "level" in (line.staffingAttributes as Record) - ? { level: (line.staffingAttributes as Record).level as string } - : {}), - })); + const inputs = demandLines.map((line) => buildExperienceMultiplierInput(line)); const batch = applyExperienceMultipliersBatch(inputs, engineRules); @@ -332,34 +256,14 @@ export const experienceMultiplierRouter = createTRPCRouter({ const line = demandLines[i]!; const result = batch.results[i]!; - if ( - result.adjustedCostRateCents !== line.costRateCents || - result.adjustedBillRateCents !== line.billRateCents || - result.adjustedHours !== line.hours - ) { - const newCostTotal = Math.round(result.adjustedCostRateCents * result.adjustedHours); - const newPriceTotal = Math.round(result.adjustedBillRateCents * result.adjustedHours); - + if (hasExperienceMultiplierChanges(line, result)) { await ctx.db.estimateDemandLine.update({ where: { id: line.id }, - data: { - costRateCents: result.adjustedCostRateCents, - billRateCents: result.adjustedBillRateCents, - hours: result.adjustedHours, - costTotalCents: newCostTotal, - priceTotalCents: newPriceTotal, - metadata: { - ...(typeof line.metadata === "object" && line.metadata !== null ? line.metadata as Record : {}), - experienceMultiplier: { - setId: multiplierSet.id, - setName: multiplierSet.name, - appliedRules: result.appliedRules, - originalCostRateCents: line.costRateCents, - originalBillRateCents: line.billRateCents, - originalHours: line.hours, - }, - }, - }, + data: buildExperienceMultiplierDemandLineUpdateData({ + line, + result, + multiplierSet, + }), }); updatedCount++; }