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:
@@ -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