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 };
|
||||
}),
|
||||
});
|
||||
@@ -7,8 +7,11 @@ import {
|
||||
getMonthKeys,
|
||||
countWorkingDaysInOverlap,
|
||||
calculateSAH,
|
||||
calculateAllocation,
|
||||
DEFAULT_CALCULATION_RULES,
|
||||
type AssignmentSlice,
|
||||
} from "@planarchy/engine";
|
||||
import type { CalculationRule, AbsenceDay } from "@planarchy/shared";
|
||||
import type { SpainScheduleRule } from "@planarchy/shared";
|
||||
import { isChargeabilityActualBooking, listAssignmentBookings } from "@planarchy/application";
|
||||
import { VacationStatus } from "@planarchy/db";
|
||||
@@ -115,7 +118,7 @@ export const chargeabilityReportRouter = createTRPCRouter({
|
||||
},
|
||||
}));
|
||||
|
||||
// Fetch vacations/absences in the range
|
||||
// Fetch vacations/absences in the range (including type for rules engine)
|
||||
const vacations = await ctx.db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: { in: resourceIds },
|
||||
@@ -127,9 +130,25 @@ export const chargeabilityReportRouter = createTRPCRouter({
|
||||
resourceId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
type: true,
|
||||
isHalfDay: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Load calculation rules for chargeability adjustments
|
||||
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
|
||||
try {
|
||||
const dbRules = await ctx.db.calculationRule.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: [{ priority: "desc" }],
|
||||
});
|
||||
if (dbRules.length > 0) {
|
||||
calcRules = dbRules as unknown as CalculationRule[];
|
||||
}
|
||||
} catch {
|
||||
// table may not exist yet
|
||||
}
|
||||
|
||||
// Build per-resource, per-month forecasts
|
||||
const resourceRows = resources.map((resource) => {
|
||||
const resourceAssignments = assignments.filter((a) => a.resourceId === resource.id);
|
||||
@@ -171,18 +190,65 @@ export const chargeabilityReportRouter = createTRPCRouter({
|
||||
absenceDays: absenceDates,
|
||||
});
|
||||
|
||||
// Build assignment slices for this month
|
||||
// Build typed absence days for this resource in this month
|
||||
const monthAbsenceDays: AbsenceDay[] = [];
|
||||
for (const v of resourceVacations) {
|
||||
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
|
||||
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
|
||||
if (vStart > vEnd) continue;
|
||||
const absCursor = new Date(vStart);
|
||||
absCursor.setUTCHours(0, 0, 0, 0);
|
||||
const absEndNorm = new Date(vEnd);
|
||||
absEndNorm.setUTCHours(0, 0, 0, 0);
|
||||
const triggerType = v.type === "SICK" ? "SICK" as const
|
||||
: v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
|
||||
: "VACATION" as const;
|
||||
while (absCursor <= absEndNorm) {
|
||||
monthAbsenceDays.push({
|
||||
date: new Date(absCursor),
|
||||
type: triggerType,
|
||||
...(v.isHalfDay ? { isHalfDay: true } : {}),
|
||||
});
|
||||
absCursor.setUTCDate(absCursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Build assignment slices for this month, using rules to compute chargeable hours
|
||||
const slices: AssignmentSlice[] = [];
|
||||
for (const a of resourceAssignments) {
|
||||
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
|
||||
if (workingDays <= 0) continue;
|
||||
|
||||
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
|
||||
slices.push({
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
workingDays,
|
||||
categoryCode,
|
||||
});
|
||||
|
||||
// If there are absences and rules, compute rules-adjusted chargeable hours
|
||||
if (monthAbsenceDays.length > 0) {
|
||||
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
|
||||
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
|
||||
|
||||
const calcResult = calculateAllocation({
|
||||
lcrCents: 0, // we only need hours, not costs
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
startDate: overlapStart,
|
||||
endDate: overlapEnd,
|
||||
availability: { monday: dailyHours, tuesday: dailyHours, wednesday: dailyHours, thursday: dailyHours, friday: dailyHours, saturday: 0, sunday: 0 },
|
||||
absenceDays: monthAbsenceDays,
|
||||
calculationRules: calcRules,
|
||||
});
|
||||
|
||||
slices.push({
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
workingDays,
|
||||
categoryCode,
|
||||
...(calcResult.totalChargeableHours !== undefined ? { totalChargeableHours: calcResult.totalChargeableHours } : {}),
|
||||
});
|
||||
} else {
|
||||
slices.push({
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
workingDays,
|
||||
categoryCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const forecast = deriveResourceForecast({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createTRPCRouter } from "../trpc.js";
|
||||
import { allocationRouter } from "./allocation.js";
|
||||
import { calculationRuleRouter } from "./calculation-rules.js";
|
||||
import { blueprintRouter } from "./blueprint.js";
|
||||
import { chargeabilityReportRouter } from "./chargeability-report.js";
|
||||
import { clientRouter } from "./client.js";
|
||||
@@ -49,6 +50,7 @@ export const appRouter = createTRPCRouter({
|
||||
managementLevel: managementLevelRouter,
|
||||
rateCard: rateCardRouter,
|
||||
chargeabilityReport: chargeabilityReportRouter,
|
||||
calculationRule: calculationRuleRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
updateAllocationEntry,
|
||||
} from "@planarchy/application";
|
||||
import type { PrismaClient } from "@planarchy/db";
|
||||
import { calculateAllocation, computeBudgetStatus, validateShift } from "@planarchy/engine";
|
||||
import { calculateAllocation, computeBudgetStatus, validateShift, DEFAULT_CALCULATION_RULES } from "@planarchy/engine";
|
||||
import type { CalculationRule, AbsenceDay } from "@planarchy/shared";
|
||||
import { VacationType } from "@planarchy/db";
|
||||
import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@planarchy/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
@@ -35,7 +37,7 @@ type ShiftDbClient = Pick<
|
||||
|
||||
type TimelineEntriesDbClient = Pick<
|
||||
PrismaClient,
|
||||
"demandRequirement" | "assignment" | "resource"
|
||||
"demandRequirement" | "assignment" | "resource" | "project"
|
||||
>;
|
||||
|
||||
type TimelineEntriesFilters = {
|
||||
@@ -43,6 +45,7 @@ type TimelineEntriesFilters = {
|
||||
endDate: Date;
|
||||
resourceIds?: string[] | undefined;
|
||||
projectIds?: string[] | undefined;
|
||||
clientIds?: string[] | undefined;
|
||||
chapters?: string[] | undefined;
|
||||
eids?: string[] | undefined;
|
||||
};
|
||||
@@ -63,7 +66,7 @@ async function loadTimelineEntriesReadModel(
|
||||
db: TimelineEntriesDbClient,
|
||||
input: TimelineEntriesFilters,
|
||||
) {
|
||||
const { startDate, endDate, resourceIds, projectIds, chapters, eids } = input;
|
||||
const { startDate, endDate, resourceIds, projectIds, clientIds, chapters, eids } = input;
|
||||
|
||||
// When resource-level filters are active (resourceIds, chapters, or eids),
|
||||
// resolve matching resource IDs so we can push the filter to the DB query.
|
||||
@@ -85,6 +88,23 @@ async function loadTimelineEntriesReadModel(
|
||||
return matching.map((r) => r.id);
|
||||
})();
|
||||
|
||||
const effectiveProjectIds = await (async () => {
|
||||
if (!clientIds || clientIds.length === 0) return projectIds;
|
||||
|
||||
const matchingProjects = await db.project.findMany({
|
||||
where: { clientId: { in: clientIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const clientProjectIds = matchingProjects.map((project) => project.id);
|
||||
|
||||
if (!projectIds || projectIds.length === 0) {
|
||||
return clientProjectIds;
|
||||
}
|
||||
|
||||
const allowedIds = new Set(clientProjectIds);
|
||||
return projectIds.filter((projectId) => allowedIds.has(projectId));
|
||||
})();
|
||||
|
||||
// When filtering by resource (either explicit resourceIds or derived from chapters),
|
||||
// demands without a resource are excluded.
|
||||
const excludeDemands = effectiveResourceIds !== undefined;
|
||||
@@ -97,7 +117,7 @@ async function loadTimelineEntriesReadModel(
|
||||
status: { not: "CANCELLED" },
|
||||
startDate: { lte: endDate },
|
||||
endDate: { gte: startDate },
|
||||
...(projectIds ? { projectId: { in: projectIds } } : {}),
|
||||
...(effectiveProjectIds ? { projectId: { in: effectiveProjectIds } } : {}),
|
||||
},
|
||||
include: PROJECT_PLANNING_DEMAND_INCLUDE,
|
||||
orderBy: [{ startDate: "asc" }, { projectId: "asc" }],
|
||||
@@ -108,7 +128,7 @@ async function loadTimelineEntriesReadModel(
|
||||
startDate: { lte: endDate },
|
||||
endDate: { gte: startDate },
|
||||
...(effectiveResourceIds ? { resourceId: { in: effectiveResourceIds } } : {}),
|
||||
...(projectIds ? { projectId: { in: projectIds } } : {}),
|
||||
...(effectiveProjectIds ? { projectId: { in: effectiveProjectIds } } : {}),
|
||||
},
|
||||
include: TIMELINE_ASSIGNMENT_INCLUDE,
|
||||
orderBy: [{ startDate: "asc" }, { resourceId: "asc" }],
|
||||
@@ -185,6 +205,74 @@ function anonymizeResourceOnEntry<T extends { resource?: { id: string } | null }
|
||||
};
|
||||
}
|
||||
|
||||
/** Load active calculation rules from DB, falling back to defaults if none configured. */
|
||||
async function loadCalculationRules(db: PrismaClient): Promise<CalculationRule[]> {
|
||||
try {
|
||||
const rules = await db.calculationRule.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: [{ priority: "desc" }],
|
||||
});
|
||||
if (rules.length > 0) {
|
||||
return rules as unknown as CalculationRule[];
|
||||
}
|
||||
} catch {
|
||||
// table may not exist yet
|
||||
}
|
||||
return DEFAULT_CALCULATION_RULES;
|
||||
}
|
||||
|
||||
/** Build typed absence days from vacations for a resource in a date range. */
|
||||
async function buildAbsenceDays(
|
||||
db: PrismaClient,
|
||||
resourceId: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
): Promise<{ absenceDays: AbsenceDay[]; legacyVacationDates: Date[] }> {
|
||||
const absenceDays: AbsenceDay[] = [];
|
||||
const legacyVacationDates: Date[] = [];
|
||||
|
||||
try {
|
||||
const vacations = await db.vacation.findMany({
|
||||
where: {
|
||||
resourceId,
|
||||
status: "APPROVED",
|
||||
startDate: { lte: endDate },
|
||||
endDate: { gte: startDate },
|
||||
},
|
||||
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
|
||||
});
|
||||
|
||||
for (const v of vacations) {
|
||||
const cur = new Date(v.startDate);
|
||||
cur.setHours(0, 0, 0, 0);
|
||||
const vEnd = new Date(v.endDate);
|
||||
vEnd.setHours(0, 0, 0, 0);
|
||||
|
||||
// Map Prisma VacationType to AbsenceTrigger
|
||||
const triggerType = v.type === VacationType.SICK ? "SICK" as const
|
||||
: v.type === VacationType.PUBLIC_HOLIDAY ? "PUBLIC_HOLIDAY" as const
|
||||
: "VACATION" as const;
|
||||
|
||||
while (cur <= vEnd) {
|
||||
absenceDays.push({
|
||||
date: new Date(cur),
|
||||
type: triggerType,
|
||||
...(v.isHalfDay ? { isHalfDay: true } : {}),
|
||||
});
|
||||
// Also populate legacy vacation dates for backward compat
|
||||
if (triggerType === "VACATION") {
|
||||
legacyVacationDates.push(new Date(cur));
|
||||
}
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// vacation table may not exist yet
|
||||
}
|
||||
|
||||
return { absenceDays, legacyVacationDates };
|
||||
}
|
||||
|
||||
export const timelineRouter = createTRPCRouter({
|
||||
/**
|
||||
* Get all timeline entries (projects + allocations) for a date range.
|
||||
@@ -197,6 +285,7 @@ export const timelineRouter = createTRPCRouter({
|
||||
endDate: z.coerce.date(),
|
||||
resourceIds: z.array(z.string()).optional(),
|
||||
projectIds: z.array(z.string()).optional(),
|
||||
clientIds: z.array(z.string()).optional(),
|
||||
chapters: z.array(z.string()).optional(),
|
||||
eids: z.array(z.string()).optional(),
|
||||
}),
|
||||
@@ -214,6 +303,7 @@ export const timelineRouter = createTRPCRouter({
|
||||
endDate: z.coerce.date(),
|
||||
resourceIds: z.array(z.string()).optional(),
|
||||
projectIds: z.array(z.string()).optional(),
|
||||
clientIds: z.array(z.string()).optional(),
|
||||
chapters: z.array(z.string()).optional(),
|
||||
eids: z.array(z.string()).optional(),
|
||||
}),
|
||||
@@ -345,31 +435,11 @@ export const timelineRouter = createTRPCRouter({
|
||||
// Load recurrence from merged metadata
|
||||
const recurrence = (newMeta.recurrence as import("@planarchy/shared").RecurrencePattern | undefined);
|
||||
|
||||
// Load approved vacations for recalculation (graceful fallback if table not yet migrated)
|
||||
const vacationDates: Date[] = [];
|
||||
try {
|
||||
const vacations = await ctx.db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: resolved.resourceId,
|
||||
status: "APPROVED",
|
||||
startDate: { lte: newEndDate },
|
||||
endDate: { gte: newStartDate },
|
||||
},
|
||||
select: { startDate: true, endDate: true },
|
||||
});
|
||||
for (const v of vacations) {
|
||||
const cur = new Date(v.startDate);
|
||||
cur.setHours(0, 0, 0, 0);
|
||||
const vEnd = new Date(v.endDate);
|
||||
vEnd.setHours(0, 0, 0, 0);
|
||||
while (cur <= vEnd) {
|
||||
vacationDates.push(new Date(cur));
|
||||
cur.setDate(cur.getDate() + 1);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// vacation table may not exist yet — proceed without vacation adjustment
|
||||
}
|
||||
// Load typed absences + calculation rules for rules-aware cost computation
|
||||
const [absenceData, calculationRules] = await Promise.all([
|
||||
buildAbsenceDays(ctx.db as PrismaClient, resolved.resourceId, newStartDate, newEndDate),
|
||||
loadCalculationRules(ctx.db as PrismaClient),
|
||||
]);
|
||||
|
||||
newDailyCostCents = calculateAllocation({
|
||||
lcrCents: existingResource.lcrCents,
|
||||
@@ -379,7 +449,9 @@ export const timelineRouter = createTRPCRouter({
|
||||
availability,
|
||||
includeSaturday,
|
||||
...(recurrence ? { recurrence } : {}),
|
||||
vacationDates,
|
||||
vacationDates: absenceData.legacyVacationDates,
|
||||
absenceDays: absenceData.absenceDays,
|
||||
calculationRules,
|
||||
}).dailyCostCents;
|
||||
}
|
||||
|
||||
@@ -500,6 +572,9 @@ export const timelineRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Pre-load calculation rules for cost recalculation
|
||||
const shiftRules = await loadCalculationRules(ctx.db as PrismaClient);
|
||||
|
||||
// Apply shift in a transaction
|
||||
const updatedProject = await ctx.db.$transaction(async (tx) => {
|
||||
// Update project dates
|
||||
@@ -523,6 +598,13 @@ export const timelineRouter = createTRPCRouter({
|
||||
const metadata = (assignment.metadata as Record<string, unknown> | null | undefined) ?? {};
|
||||
const includeSaturday = (metadata.includeSaturday as boolean | undefined) ?? false;
|
||||
|
||||
const shiftAbsenceData = await buildAbsenceDays(
|
||||
ctx.db as PrismaClient,
|
||||
assignment.resourceId!,
|
||||
newStartDate,
|
||||
newEndDate,
|
||||
);
|
||||
|
||||
const newDailyCost = calculateAllocation({
|
||||
lcrCents: assignment.resource!.lcrCents,
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
@@ -531,6 +613,9 @@ export const timelineRouter = createTRPCRouter({
|
||||
availability:
|
||||
assignment.resource!.availability as unknown as import("@planarchy/shared").WeekdayAvailability,
|
||||
includeSaturday,
|
||||
vacationDates: shiftAbsenceData.legacyVacationDates,
|
||||
absenceDays: shiftAbsenceData.absenceDays,
|
||||
calculationRules: shiftRules,
|
||||
}).dailyCostCents;
|
||||
|
||||
await updateAssignment(
|
||||
|
||||
Reference in New Issue
Block a user