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
+37 -108
View File
@@ -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,
}),
});
}