refactor(api): extract effort rule support

This commit is contained in:
2026-03-31 14:05:20 +02:00
parent c839b18d4e
commit 59c84dfe4f
3 changed files with 398 additions and 108 deletions
@@ -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,
},
},
},
]);
});
});
@@ -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<typeof CreateEffortRuleSetSchema>;
type UpdateEffortRuleSetInput = z.infer<typeof UpdateEffortRuleSetSchema>;
type EffortRuleRowInput =
| CreateEffortRuleSetInput["rules"][number]
| NonNullable<UpdateEffortRuleSetInput["rules"]>[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<UpdateEffortRuleSetInput, "id" | "rules">,
): 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<ScopeItemInput["unitMode"], undefined>,
}) 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,
},
},
}));
}
+27 -98
View File
@@ -1,8 +1,6 @@
import { import {
expandScopeToEffort, expandScopeToEffort,
aggregateByDiscipline, aggregateByDiscipline,
type EffortRuleInput,
type ScopeItemInput,
} from "@capakraken/engine"; } from "@capakraken/engine";
import { import {
CreateEffortRuleSetSchema, CreateEffortRuleSetSchema,
@@ -14,15 +12,20 @@ import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js"; import { findUniqueOrThrow } from "../db/helpers.js";
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js"; import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
import { createAuditEntry } from "../lib/audit.js"; import { createAuditEntry } from "../lib/audit.js";
import {
const ruleInclude = { buildEstimateDemandLineRows,
rules: { orderBy: { sortOrder: "asc" as const } }, buildEffortRuleCreateManyRows,
} as const; buildEffortRuleSetCreateData,
buildEffortRuleSetUpdateData,
effortRuleInclude,
toEffortRuleEngineInputs,
toScopeItemInputs,
} from "./effort-rule-support.js";
export const effortRuleRouter = createTRPCRouter({ export const effortRuleRouter = createTRPCRouter({
list: controllerProcedure.query(async ({ ctx }) => { list: controllerProcedure.query(async ({ ctx }) => {
return ctx.db.effortRuleSet.findMany({ return ctx.db.effortRuleSet.findMany({
include: ruleInclude, include: effortRuleInclude,
orderBy: [{ isDefault: "desc" }, { name: "asc" }], orderBy: [{ isDefault: "desc" }, { name: "asc" }],
}); });
}), }),
@@ -33,7 +36,7 @@ export const effortRuleRouter = createTRPCRouter({
const ruleSet = await findUniqueOrThrow( const ruleSet = await findUniqueOrThrow(
ctx.db.effortRuleSet.findUnique({ ctx.db.effortRuleSet.findUnique({
where: { id: input.id }, where: { id: input.id },
include: ruleInclude, include: effortRuleInclude,
}), }),
"Effort rule set", "Effort rule set",
); );
@@ -52,23 +55,8 @@ export const effortRuleRouter = createTRPCRouter({
} }
const ruleSet = await ctx.db.effortRuleSet.create({ const ruleSet = await ctx.db.effortRuleSet.create({
data: { data: buildEffortRuleSetCreateData(input),
name: input.name, include: effortRuleInclude,
...(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,
}); });
void createAuditEntry({ void createAuditEntry({
@@ -89,7 +77,7 @@ export const effortRuleRouter = createTRPCRouter({
.input(UpdateEffortRuleSetSchema) .input(UpdateEffortRuleSetSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const before = await findUniqueOrThrow( 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", "Effort rule set",
); );
@@ -105,27 +93,14 @@ export const effortRuleRouter = createTRPCRouter({
if (input.rules) { if (input.rules) {
await ctx.db.effortRule.deleteMany({ where: { ruleSetId: input.id } }); await ctx.db.effortRule.deleteMany({ where: { ruleSetId: input.id } });
await ctx.db.effortRule.createMany({ await ctx.db.effortRule.createMany({
data: input.rules.map((r, i) => ({ data: buildEffortRuleCreateManyRows(input.rules, input.id),
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,
})),
}); });
} }
const updated = await ctx.db.effortRuleSet.update({ const updated = await ctx.db.effortRuleSet.update({
where: { id: input.id }, where: { id: input.id },
data: { data: buildEffortRuleSetUpdateData(input),
...(input.name !== undefined ? { name: input.name } : {}), include: effortRuleInclude,
...(input.description !== undefined ? { description: input.description } : {}),
...(input.isDefault !== undefined ? { isDefault: input.isDefault } : {}),
},
include: ruleInclude,
}); });
void createAuditEntry({ void createAuditEntry({
@@ -189,7 +164,7 @@ export const effortRuleRouter = createTRPCRouter({
findUniqueOrThrow( findUniqueOrThrow(
ctx.db.effortRuleSet.findUnique({ ctx.db.effortRuleSet.findUnique({
where: { id: input.ruleSetId }, where: { id: input.ruleSetId },
include: ruleInclude, include: effortRuleInclude,
}), }),
"Effort rule set", "Effort rule set",
), ),
@@ -198,22 +173,8 @@ export const effortRuleRouter = createTRPCRouter({
const version = estimate.versions[0]; const version = estimate.versions[0];
if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" }); if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" });
const scopeItems: ScopeItemInput[] = version.scopeItems.map((s) => ({ const scopeItems = toScopeItemInputs(version.scopeItems);
name: s.name, const rules = toEffortRuleEngineInputs(ruleSet.rules);
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 result = expandScopeToEffort(scopeItems, rules); const result = expandScopeToEffort(scopeItems, rules);
const aggregated = aggregateByDiscipline(result.lines); const aggregated = aggregateByDiscipline(result.lines);
@@ -250,7 +211,7 @@ export const effortRuleRouter = createTRPCRouter({
findUniqueOrThrow( findUniqueOrThrow(
ctx.db.effortRuleSet.findUnique({ ctx.db.effortRuleSet.findUnique({
where: { id: input.ruleSetId }, where: { id: input.ruleSetId },
include: ruleInclude, include: effortRuleInclude,
}), }),
"Effort rule set", "Effort rule set",
), ),
@@ -262,22 +223,8 @@ export const effortRuleRouter = createTRPCRouter({
throw new TRPCError({ code: "BAD_REQUEST", message: "Can only apply rules to a WORKING version" }); throw new TRPCError({ code: "BAD_REQUEST", message: "Can only apply rules to a WORKING version" });
} }
const scopeItems: ScopeItemInput[] = version.scopeItems.map((s) => ({ const scopeItems = toScopeItemInputs(version.scopeItems);
name: s.name, const rules = toEffortRuleEngineInputs(ruleSet.rules);
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 result = expandScopeToEffort(scopeItems, rules); const result = expandScopeToEffort(scopeItems, rules);
@@ -291,30 +238,12 @@ export const effortRuleRouter = createTRPCRouter({
// Create demand lines from expanded results // Create demand lines from expanded results
if (result.lines.length > 0) { if (result.lines.length > 0) {
await ctx.db.estimateDemandLine.createMany({ await ctx.db.estimateDemandLine.createMany({
data: result.lines.map((line) => ({ data: buildEstimateDemandLineRows({
estimateVersionId: version.id, 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, currency: estimate.baseCurrency,
costTotalCents: 0, ruleSet: { id: ruleSet.id, name: ruleSet.name },
priceTotalCents: 0, lines: result.lines,
monthlySpread: {}, }),
staffingAttributes: {},
metadata: {
effortRule: {
ruleSetId: ruleSet.id,
ruleSetName: ruleSet.name,
discipline: line.discipline,
unitMode: line.unitMode,
unitCount: line.unitCount,
hoursPerUnit: line.hoursPerUnit,
},
},
})),
}); });
} }