feat: calculation rules engine for decoupled cost attribution and chargeability
Introduces an admin-configurable rules engine that determines per-day cost attribution (CHARGE/ZERO/REDUCE) and chargeability reporting (COUNT/SKIP) for absence types (sick, vacation, public holiday). Includes shared types, Zod schemas, Prisma model, rule matching with specificity scoring, default rules, calculator integration, CRUD API router, seed data, chargeability report integration, and admin UI. 283/283 engine tests, 209/209 API tests, 0 TS errors. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
CreateCalculationRuleSchema,
|
||||
UpdateCalculationRuleSchema,
|
||||
} from "@planarchy/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
|
||||
|
||||
export const calculationRuleRouter = createTRPCRouter({
|
||||
list: controllerProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db.calculationRule.findMany({
|
||||
orderBy: [{ priority: "desc" }, { name: "asc" }],
|
||||
include: { project: { select: { id: true, name: true, shortCode: true } } },
|
||||
});
|
||||
}),
|
||||
|
||||
getById: controllerProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const rule = await ctx.db.calculationRule.findUnique({
|
||||
where: { id: input.id },
|
||||
include: { project: { select: { id: true, name: true, shortCode: true } } },
|
||||
});
|
||||
if (!rule) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Calculation rule not found" });
|
||||
}
|
||||
return rule;
|
||||
}),
|
||||
|
||||
/** Get all active rules (optimized for engine use — no project include) */
|
||||
getActive: controllerProcedure.query(async ({ ctx }) => {
|
||||
return ctx.db.calculationRule.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: [{ priority: "desc" }],
|
||||
});
|
||||
}),
|
||||
|
||||
create: managerProcedure
|
||||
.input(CreateCalculationRuleSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.db.calculationRule.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
triggerType: input.triggerType,
|
||||
costEffect: input.costEffect,
|
||||
chargeabilityEffect: input.chargeabilityEffect,
|
||||
...(input.description !== undefined ? { description: input.description } : {}),
|
||||
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
|
||||
...(input.orderType !== undefined ? { orderType: input.orderType as never } : {}),
|
||||
...(input.costReductionPercent !== undefined ? { costReductionPercent: input.costReductionPercent } : {}),
|
||||
priority: input.priority,
|
||||
isActive: input.isActive,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
update: managerProcedure
|
||||
.input(UpdateCalculationRuleSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input;
|
||||
const existing = await ctx.db.calculationRule.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Calculation rule not found" });
|
||||
}
|
||||
|
||||
// Build update data using exactOptionalPropertyTypes pattern
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.triggerType !== undefined) updateData.triggerType = data.triggerType;
|
||||
if (data.projectId !== undefined) updateData.projectId = data.projectId;
|
||||
if (data.orderType !== undefined) updateData.orderType = data.orderType;
|
||||
if (data.costEffect !== undefined) updateData.costEffect = data.costEffect;
|
||||
if (data.costReductionPercent !== undefined) updateData.costReductionPercent = data.costReductionPercent;
|
||||
if (data.chargeabilityEffect !== undefined) updateData.chargeabilityEffect = data.chargeabilityEffect;
|
||||
if (data.priority !== undefined) updateData.priority = data.priority;
|
||||
if (data.isActive !== undefined) updateData.isActive = data.isActive;
|
||||
|
||||
return ctx.db.calculationRule.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
}),
|
||||
|
||||
delete: managerProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.calculationRule.findUnique({ where: { id: input.id } });
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Calculation rule not found" });
|
||||
}
|
||||
await ctx.db.calculationRule.delete({ where: { id: input.id } });
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user