diff --git a/packages/api/src/__tests__/effort-rule-support.test.ts b/packages/api/src/__tests__/effort-rule-support.test.ts new file mode 100644 index 0000000..9324106 --- /dev/null +++ b/packages/api/src/__tests__/effort-rule-support.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import { + buildEstimateDemandLineRows, + buildEffortRuleCreateManyRows, + buildEffortRuleNestedCreateRows, + buildEffortRuleSetCreateData, + buildEffortRuleSetUpdateData, + effortRuleInclude, + toEffortRuleEngineInputs, + toScopeItemInputs, +} from "../router/effort-rule-support.js"; + +describe("effort rule support", () => { + it("exposes the rule include ordering", () => { + expect(effortRuleInclude).toEqual({ + rules: { orderBy: { sortOrder: "asc" } }, + }); + }); + + it("builds rule rows and preserves fallback sort order", () => { + expect(buildEffortRuleCreateManyRows([ + { + scopeType: "shot", + discipline: "Compositing", + chapter: "VFX", + unitMode: "per_frame", + hoursPerUnit: 0.08, + }, + { + scopeType: "asset", + discipline: "Modeling", + unitMode: "per_item", + hoursPerUnit: 8, + description: "Hero asset", + sortOrder: 5, + }, + ], "ers_1")).toEqual([ + { + ruleSetId: "ers_1", + scopeType: "shot", + discipline: "Compositing", + chapter: "VFX", + unitMode: "per_frame", + hoursPerUnit: 0.08, + sortOrder: 0, + }, + { + ruleSetId: "ers_1", + scopeType: "asset", + discipline: "Modeling", + unitMode: "per_item", + hoursPerUnit: 8, + description: "Hero asset", + sortOrder: 5, + }, + ]); + + expect(buildEffortRuleNestedCreateRows([ + { + scopeType: "shot", + discipline: "Compositing", + unitMode: "per_frame", + hoursPerUnit: 0.08, + }, + ])).toEqual([ + { + scopeType: "shot", + discipline: "Compositing", + unitMode: "per_frame", + hoursPerUnit: 0.08, + sortOrder: 0, + }, + ]); + }); + + it("builds create and sparse update payloads", () => { + expect(buildEffortRuleSetCreateData({ + name: "VFX Standard", + description: "Default effort rules", + isDefault: true, + rules: [ + { + scopeType: "shot", + discipline: "Compositing", + unitMode: "per_frame", + hoursPerUnit: 0.08, + sortOrder: 0, + }, + ], + })).toEqual({ + name: "VFX Standard", + description: "Default effort rules", + isDefault: true, + rules: { + create: [ + { + scopeType: "shot", + discipline: "Compositing", + unitMode: "per_frame", + hoursPerUnit: 0.08, + sortOrder: 0, + }, + ], + }, + }); + + expect(buildEffortRuleSetUpdateData({ + description: null, + isDefault: false, + })).toEqual({ + description: null, + isDefault: false, + }); + }); + + it("maps scope items and rules into engine inputs", () => { + expect(toScopeItemInputs([ + { + name: "Shot_001", + scopeType: "shot", + frameCount: 100, + itemCount: null, + unitMode: "per_frame", + }, + ])).toEqual([ + { + name: "Shot_001", + scopeType: "shot", + frameCount: 100, + itemCount: null, + unitMode: "per_frame", + }, + ]); + + expect(toEffortRuleEngineInputs([ + { + scopeType: "shot", + discipline: "Compositing", + chapter: null, + unitMode: "per_frame", + hoursPerUnit: 0.08, + sortOrder: 0, + }, + ])).toEqual([ + { + scopeType: "shot", + discipline: "Compositing", + chapter: null, + unitMode: "per_frame", + hoursPerUnit: 0.08, + sortOrder: 0, + }, + ]); + }); + + it("builds estimate demand line rows with the current optional chapter semantics", () => { + expect(buildEstimateDemandLineRows({ + estimateVersionId: "ver_1", + currency: "EUR", + ruleSet: { id: "ers_1", name: "VFX Standard" }, + lines: [ + { + scopeItemName: "Shot_001", + scopeType: "shot", + discipline: "Compositing", + chapter: "", + hours: 16, + unitMode: "per_frame", + unitCount: 200, + hoursPerUnit: 0.08, + }, + ], + })).toEqual([ + { + estimateVersionId: "ver_1", + lineType: "LABOR", + name: "Compositing — Shot_001", + hours: 16, + costRateCents: 0, + billRateCents: 0, + currency: "EUR", + costTotalCents: 0, + priceTotalCents: 0, + monthlySpread: {}, + staffingAttributes: {}, + metadata: { + effortRule: { + ruleSetId: "ers_1", + ruleSetName: "VFX Standard", + discipline: "Compositing", + unitMode: "per_frame", + unitCount: 200, + hoursPerUnit: 0.08, + }, + }, + }, + ]); + }); +}); diff --git a/packages/api/src/router/effort-rule-support.ts b/packages/api/src/router/effort-rule-support.ts new file mode 100644 index 0000000..4267cd9 --- /dev/null +++ b/packages/api/src/router/effort-rule-support.ts @@ -0,0 +1,162 @@ +import type { Prisma } from "@capakraken/db"; +import type { + EffortRuleInput, + ScopeItemInput, +} from "@capakraken/engine"; +import { + CreateEffortRuleSetSchema, + UpdateEffortRuleSetSchema, +} from "@capakraken/shared"; +import { z } from "zod"; + +type CreateEffortRuleSetInput = z.infer; +type UpdateEffortRuleSetInput = z.infer; +type EffortRuleRowInput = + | CreateEffortRuleSetInput["rules"][number] + | NonNullable[number]; + +type EffortRuleRecord = { + scopeType: string; + discipline: string; + chapter: string | null; + unitMode: string; + hoursPerUnit: number; + sortOrder: number; +}; + +type ScopeItemRecord = { + name: string; + scopeType: string; + frameCount: number | null; + itemCount: number | null; + unitMode: string | null; +}; + +type DemandLineRuleSetRecord = { + id: string; + name: string; +}; + +type ExpandedEffortLineRecord = { + scopeItemName: string; + discipline: string; + chapter?: string | null; + hours: number; + unitMode: string; + unitCount: number; + hoursPerUnit: number; +}; + +export const effortRuleInclude = { + rules: { orderBy: { sortOrder: "asc" as const } }, +} as const; + +function buildEffortRuleRow(input: EffortRuleRowInput, index: number) { + return { + scopeType: input.scopeType, + discipline: input.discipline, + ...(input.chapter ? { chapter: input.chapter } : {}), + unitMode: input.unitMode, + hoursPerUnit: input.hoursPerUnit, + ...(input.description ? { description: input.description } : {}), + sortOrder: input.sortOrder ?? index, + }; +} + +export function buildEffortRuleNestedCreateRows( + rules: EffortRuleRowInput[], +): Prisma.EffortRuleUncheckedCreateWithoutRuleSetInput[] { + return rules.map((rule, index) => ({ + ...buildEffortRuleRow(rule, index), + })); +} + +export function buildEffortRuleCreateManyRows( + rules: EffortRuleRowInput[], + ruleSetId: string, +): Prisma.EffortRuleCreateManyInput[] { + return rules.map((rule, index) => ({ + ruleSetId, + ...buildEffortRuleRow(rule, index), + })); +} + +export function buildEffortRuleSetCreateData( + input: CreateEffortRuleSetInput, +): Prisma.EffortRuleSetCreateInput { + return { + name: input.name, + ...(input.description ? { description: input.description } : {}), + isDefault: input.isDefault, + rules: { + create: buildEffortRuleNestedCreateRows(input.rules), + }, + }; +} + +export function buildEffortRuleSetUpdateData( + input: Omit, +): Prisma.EffortRuleSetUncheckedUpdateInput { + return { + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.description !== undefined ? { description: input.description } : {}), + ...(input.isDefault !== undefined ? { isDefault: input.isDefault } : {}), + }; +} + +export function toScopeItemInputs( + scopeItems: ScopeItemRecord[], +): ScopeItemInput[] { + return scopeItems.map((scopeItem) => ({ + name: scopeItem.name, + scopeType: scopeItem.scopeType as ScopeItemInput["scopeType"], + frameCount: scopeItem.frameCount, + itemCount: scopeItem.itemCount, + unitMode: (scopeItem.unitMode ?? null) as Exclude, + }) as ScopeItemInput); +} + +export function toEffortRuleEngineInputs( + rules: EffortRuleRecord[], +): EffortRuleInput[] { + return rules.map((rule) => ({ + scopeType: rule.scopeType as EffortRuleInput["scopeType"], + discipline: rule.discipline, + chapter: rule.chapter, + unitMode: rule.unitMode as EffortRuleInput["unitMode"], + hoursPerUnit: rule.hoursPerUnit, + sortOrder: rule.sortOrder, + })); +} + +export function buildEstimateDemandLineRows(input: { + estimateVersionId: string; + currency: string; + ruleSet: DemandLineRuleSetRecord; + lines: ExpandedEffortLineRecord[]; +}): Prisma.EstimateDemandLineUncheckedCreateInput[] { + return input.lines.map((line) => ({ + estimateVersionId: input.estimateVersionId, + lineType: "LABOR", + name: `${line.discipline} — ${line.scopeItemName}`, + ...(line.chapter ? { chapter: line.chapter } : {}), + hours: line.hours, + costRateCents: 0, + billRateCents: 0, + currency: input.currency, + costTotalCents: 0, + priceTotalCents: 0, + monthlySpread: {}, + staffingAttributes: {}, + metadata: { + effortRule: { + ruleSetId: input.ruleSet.id, + ruleSetName: input.ruleSet.name, + discipline: line.discipline, + unitMode: line.unitMode, + unitCount: line.unitCount, + hoursPerUnit: line.hoursPerUnit, + }, + }, + })); +} diff --git a/packages/api/src/router/effort-rule.ts b/packages/api/src/router/effort-rule.ts index 92a5da2..f0ad061 100644 --- a/packages/api/src/router/effort-rule.ts +++ b/packages/api/src/router/effort-rule.ts @@ -1,8 +1,6 @@ import { expandScopeToEffort, aggregateByDiscipline, - type EffortRuleInput, - type ScopeItemInput, } from "@capakraken/engine"; import { CreateEffortRuleSetSchema, @@ -14,15 +12,20 @@ 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; +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: ruleInclude, + include: effortRuleInclude, orderBy: [{ isDefault: "desc" }, { name: "asc" }], }); }), @@ -33,7 +36,7 @@ export const effortRuleRouter = createTRPCRouter({ const ruleSet = await findUniqueOrThrow( ctx.db.effortRuleSet.findUnique({ where: { id: input.id }, - include: ruleInclude, + include: effortRuleInclude, }), "Effort rule set", ); @@ -52,23 +55,8 @@ export const effortRuleRouter = createTRPCRouter({ } const ruleSet = await ctx.db.effortRuleSet.create({ - data: { - name: input.name, - ...(input.description ? { description: input.description } : {}), - isDefault: input.isDefault, - rules: { - create: input.rules.map((r, i) => ({ - scopeType: r.scopeType, - discipline: r.discipline, - ...(r.chapter ? { chapter: r.chapter } : {}), - unitMode: r.unitMode, - hoursPerUnit: r.hoursPerUnit, - ...(r.description ? { description: r.description } : {}), - sortOrder: r.sortOrder ?? i, - })), - }, - }, - include: ruleInclude, + data: buildEffortRuleSetCreateData(input), + include: effortRuleInclude, }); void createAuditEntry({ @@ -89,7 +77,7 @@ export const effortRuleRouter = createTRPCRouter({ .input(UpdateEffortRuleSetSchema) .mutation(async ({ ctx, input }) => { const before = await findUniqueOrThrow( - ctx.db.effortRuleSet.findUnique({ where: { id: input.id }, include: ruleInclude }), + ctx.db.effortRuleSet.findUnique({ where: { id: input.id }, include: effortRuleInclude }), "Effort rule set", ); @@ -105,27 +93,14 @@ export const effortRuleRouter = createTRPCRouter({ if (input.rules) { await ctx.db.effortRule.deleteMany({ where: { ruleSetId: input.id } }); await ctx.db.effortRule.createMany({ - data: input.rules.map((r, i) => ({ - ruleSetId: input.id, - scopeType: r.scopeType, - discipline: r.discipline, - ...(r.chapter ? { chapter: r.chapter } : {}), - unitMode: r.unitMode, - hoursPerUnit: r.hoursPerUnit, - ...(r.description ? { description: r.description } : {}), - sortOrder: r.sortOrder ?? i, - })), + data: buildEffortRuleCreateManyRows(input.rules, input.id), }); } const updated = await ctx.db.effortRuleSet.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: buildEffortRuleSetUpdateData(input), + include: effortRuleInclude, }); void createAuditEntry({ @@ -187,33 +162,19 @@ export const effortRuleRouter = createTRPCRouter({ "Estimate", ), findUniqueOrThrow( - ctx.db.effortRuleSet.findUnique({ - where: { id: input.ruleSetId }, - include: ruleInclude, - }), - "Effort rule set", - ), + 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: ScopeItemInput[] = version.scopeItems.map((s) => ({ - name: s.name, - scopeType: s.scopeType, - frameCount: s.frameCount, - itemCount: s.itemCount, - unitMode: s.unitMode, - })); - - const rules: EffortRuleInput[] = ruleSet.rules.map((r) => ({ - scopeType: r.scopeType, - discipline: r.discipline, - chapter: r.chapter, - unitMode: r.unitMode as "per_frame" | "per_item" | "flat", - hoursPerUnit: r.hoursPerUnit, - sortOrder: r.sortOrder, - })); + const scopeItems = toScopeItemInputs(version.scopeItems); + const rules = toEffortRuleEngineInputs(ruleSet.rules); const result = expandScopeToEffort(scopeItems, rules); const aggregated = aggregateByDiscipline(result.lines); @@ -248,12 +209,12 @@ export const effortRuleRouter = createTRPCRouter({ "Estimate", ), findUniqueOrThrow( - ctx.db.effortRuleSet.findUnique({ - where: { id: input.ruleSetId }, - include: ruleInclude, - }), - "Effort rule set", - ), + ctx.db.effortRuleSet.findUnique({ + where: { id: input.ruleSetId }, + include: effortRuleInclude, + }), + "Effort rule set", + ), ]); const version = estimate.versions[0]; @@ -262,22 +223,8 @@ export const effortRuleRouter = createTRPCRouter({ throw new TRPCError({ code: "BAD_REQUEST", message: "Can only apply rules to a WORKING version" }); } - const scopeItems: ScopeItemInput[] = version.scopeItems.map((s) => ({ - name: s.name, - scopeType: s.scopeType, - frameCount: s.frameCount, - itemCount: s.itemCount, - unitMode: s.unitMode, - })); - - const rules: EffortRuleInput[] = ruleSet.rules.map((r) => ({ - scopeType: r.scopeType, - discipline: r.discipline, - chapter: r.chapter, - unitMode: r.unitMode as "per_frame" | "per_item" | "flat", - hoursPerUnit: r.hoursPerUnit, - sortOrder: r.sortOrder, - })); + const scopeItems = toScopeItemInputs(version.scopeItems); + const rules = toEffortRuleEngineInputs(ruleSet.rules); const result = expandScopeToEffort(scopeItems, rules); @@ -291,30 +238,12 @@ export const effortRuleRouter = createTRPCRouter({ // Create demand lines from expanded results if (result.lines.length > 0) { await ctx.db.estimateDemandLine.createMany({ - data: result.lines.map((line) => ({ + data: buildEstimateDemandLineRows({ estimateVersionId: version.id, - lineType: "LABOR", - name: `${line.discipline} — ${line.scopeItemName}`, - ...(line.chapter ? { chapter: line.chapter } : {}), - hours: line.hours, - costRateCents: 0, - billRateCents: 0, currency: estimate.baseCurrency, - costTotalCents: 0, - priceTotalCents: 0, - monthlySpread: {}, - staffingAttributes: {}, - metadata: { - effortRule: { - ruleSetId: ruleSet.id, - ruleSetName: ruleSet.name, - discipline: line.discipline, - unitMode: line.unitMode, - unitCount: line.unitCount, - hoursPerUnit: line.hoursPerUnit, - }, - }, - })), + ruleSet: { id: ruleSet.id, name: ruleSet.name }, + lines: result.lines, + }), }); }