refactor(api): extract insights anomaly snapshot

This commit is contained in:
2026-03-31 11:33:36 +02:00
parent c79ae70177
commit d4682ff0ac
2 changed files with 283 additions and 274 deletions
@@ -0,0 +1,282 @@
export interface Anomaly {
type: "budget" | "staffing" | "utilization" | "timeline";
severity: "warning" | "critical";
entityId: string;
entityName: string;
message: string;
}
interface InsightDemandRecord {
headcount: number;
startDate: Date;
endDate: Date;
_count: {
assignments: number;
};
}
interface InsightProjectAssignmentRecord {
resourceId: string;
startDate: Date;
endDate: Date;
hoursPerDay: number;
dailyCostCents: number;
status: string;
}
interface InsightProjectRecord {
id: string;
name: string;
budgetCents: number;
startDate: Date;
endDate: Date;
demandRequirements: InsightDemandRecord[];
assignments: InsightProjectAssignmentRecord[];
}
interface InsightResourceRecord {
id: string;
displayName: string;
availability: unknown;
}
interface InsightAssignmentLoadRecord {
resourceId: string;
hoursPerDay: number;
}
export interface InsightSnapshot {
anomalies: Anomaly[];
summary: {
total: number;
criticalCount: number;
budget: number;
staffing: number;
timeline: number;
utilization: number;
};
}
export interface InsightsDbAccess {
project: {
findMany(args: Record<string, unknown>): Promise<InsightProjectRecord[]>;
};
resource: {
findMany(args: Record<string, unknown>): Promise<InsightResourceRecord[]>;
};
assignment: {
findMany(args: Record<string, unknown>): Promise<InsightAssignmentLoadRecord[]>;
};
}
function countBusinessDays(start: Date, end: Date): number {
let count = 0;
const date = new Date(start);
while (date <= end) {
const dayOfWeek = date.getDay();
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
count++;
}
date.setDate(date.getDate() + 1);
}
return count;
}
async function loadInsightProjects(db: InsightsDbAccess["project"]) {
return db.findMany({
where: { status: { in: ["ACTIVE", "DRAFT"] } },
include: {
demandRequirements: {
select: {
headcount: true,
startDate: true,
endDate: true,
_count: { select: { assignments: true } },
},
},
assignments: {
select: {
resourceId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
dailyCostCents: true,
status: true,
},
},
},
}) as Promise<InsightProjectRecord[]>;
}
async function loadInsightResources(db: InsightsDbAccess["resource"]) {
return db.findMany({
where: { isActive: true },
select: {
id: true,
displayName: true,
availability: true,
},
}) as Promise<InsightResourceRecord[]>;
}
async function loadInsightAssignmentLoads(db: InsightsDbAccess["assignment"], now: Date) {
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return db.findMany({
where: {
status: { in: ["ACTIVE", "CONFIRMED"] },
startDate: { lte: periodEnd },
endDate: { gte: periodStart },
},
select: {
resourceId: true,
hoursPerDay: true,
},
}) as Promise<InsightAssignmentLoadRecord[]>;
}
function summarizeAnomalies(anomalies: Anomaly[]): InsightSnapshot["summary"] {
return anomalies.reduce<InsightSnapshot["summary"]>((summary, anomaly) => {
summary.total += 1;
summary[anomaly.type] += 1;
if (anomaly.severity === "critical") {
summary.criticalCount += 1;
}
return summary;
}, {
total: 0,
criticalCount: 0,
budget: 0,
staffing: 0,
timeline: 0,
utilization: 0,
});
}
export async function buildInsightSnapshot(
db: InsightsDbAccess,
now = new Date(),
): Promise<InsightSnapshot> {
const [projects, resources, activeAssignments] = await Promise.all([
loadInsightProjects(db.project),
loadInsightResources(db.resource),
loadInsightAssignmentLoads(db.assignment, now),
]);
const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
const anomalies: Anomaly[] = [];
for (const project of projects) {
if (project.budgetCents > 0) {
const totalDays = countBusinessDays(project.startDate, project.endDate);
const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate);
if (totalDays > 0 && elapsedDays > 0) {
const expectedBurnRate = elapsedDays / totalDays;
const totalCostCents = project.assignments.reduce((sum, assignment) => {
const assignmentStart = assignment.startDate < project.startDate
? project.startDate
: assignment.startDate;
const assignmentEnd = assignment.endDate > now ? now : assignment.endDate;
if (assignmentEnd < assignmentStart) {
return sum;
}
return sum + assignment.dailyCostCents * countBusinessDays(assignmentStart, assignmentEnd);
}, 0);
const actualBurnRate = totalCostCents / project.budgetCents;
if (actualBurnRate > expectedBurnRate * 1.2) {
const overSpendPercent = Math.round(((actualBurnRate - expectedBurnRate) / expectedBurnRate) * 100);
anomalies.push({
type: "budget",
severity: actualBurnRate > expectedBurnRate * 1.5 ? "critical" : "warning",
entityId: project.id,
entityName: project.name,
message: `Burning budget ${overSpendPercent}% faster than expected. ${Math.round(actualBurnRate * 100)}% spent at ${Math.round(expectedBurnRate * 100)}% timeline.`,
});
}
}
}
const upcomingDemands = project.demandRequirements.filter(
(demand) => demand.startDate <= twoWeeksFromNow && demand.endDate >= now,
);
for (const demand of upcomingDemands) {
const unfilledCount = demand.headcount - demand._count.assignments;
const unfillPct = demand.headcount > 0 ? unfilledCount / demand.headcount : 0;
if (unfillPct > 0.3) {
anomalies.push({
type: "staffing",
severity: unfillPct > 0.6 ? "critical" : "warning",
entityId: project.id,
entityName: project.name,
message: `${unfilledCount} of ${demand.headcount} positions unfilled, starting ${demand.startDate.toISOString().slice(0, 10)}.`,
});
}
}
const overrunAssignments = project.assignments.filter(
(assignment) => assignment.endDate > project.endDate
&& (assignment.status === "ACTIVE" || assignment.status === "CONFIRMED"),
);
if (overrunAssignments.length > 0) {
anomalies.push({
type: "timeline",
severity: "warning",
entityId: project.id,
entityName: project.name,
message: `${overrunAssignments.length} assignment(s) extend beyond the project end date (${project.endDate.toISOString().slice(0, 10)}).`,
});
}
}
const resourceHoursMap = new Map<string, number>();
for (const assignment of activeAssignments) {
const currentHours = resourceHoursMap.get(assignment.resourceId) ?? 0;
resourceHoursMap.set(assignment.resourceId, currentHours + assignment.hoursPerDay);
}
for (const resource of resources) {
const availability = resource.availability as Record<string, number> | null;
if (!availability) {
continue;
}
const dailyAvailableHours = Object.values(availability).reduce((sum, hours) => sum + (hours ?? 0), 0) / 5;
if (dailyAvailableHours <= 0) {
continue;
}
const bookedHours = resourceHoursMap.get(resource.id) ?? 0;
const utilizationPercent = Math.round((bookedHours / dailyAvailableHours) * 100);
if (utilizationPercent > 110) {
anomalies.push({
type: "utilization",
severity: utilizationPercent > 130 ? "critical" : "warning",
entityId: resource.id,
entityName: resource.displayName,
message: `Resource at ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailableHours.toFixed(1)}h per day).`,
});
} else if (utilizationPercent < 40 && bookedHours > 0) {
anomalies.push({
type: "utilization",
severity: "warning",
entityId: resource.id,
entityName: resource.displayName,
message: `Resource at only ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailableHours.toFixed(1)}h per day).`,
});
}
}
anomalies.sort((left, right) => {
if (left.severity !== right.severity) {
return left.severity === "critical" ? -1 : 1;
}
return left.type.localeCompare(right.type);
});
return {
anomalies,
summary: summarizeAnomalies(anomalies),
};
}
+1 -274
View File
@@ -2,81 +2,7 @@ import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../a
import { controllerProcedure, createTRPCRouter } from "../trpc.js"; import { controllerProcedure, createTRPCRouter } from "../trpc.js";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { buildInsightSnapshot, type InsightsDbAccess } from "./insights-anomalies.js";
// ─── Types ───────────────────────────────────────────────────────────────────
export interface Anomaly {
type: "budget" | "staffing" | "utilization" | "timeline";
severity: "warning" | "critical";
entityId: string;
entityName: string;
message: string;
}
interface InsightDemandRecord {
headcount: number;
startDate: Date;
endDate: Date;
_count: {
assignments: number;
};
}
interface InsightProjectAssignmentRecord {
resourceId: string;
startDate: Date;
endDate: Date;
hoursPerDay: number;
dailyCostCents: number;
status: string;
}
interface InsightProjectRecord {
id: string;
name: string;
budgetCents: number;
startDate: Date;
endDate: Date;
demandRequirements: InsightDemandRecord[];
assignments: InsightProjectAssignmentRecord[];
}
interface InsightResourceRecord {
id: string;
displayName: string;
availability: unknown;
}
interface InsightAssignmentLoadRecord {
resourceId: string;
hoursPerDay: number;
}
interface InsightSnapshot {
anomalies: Anomaly[];
summary: {
total: number;
criticalCount: number;
budget: number;
staffing: number;
timeline: number;
utilization: number;
};
}
interface InsightsDbAccess {
project: {
findMany(args: Record<string, unknown>): Promise<InsightProjectRecord[]>;
};
resource: {
findMany(args: Record<string, unknown>): Promise<InsightResourceRecord[]>;
};
assignment: {
findMany(args: Record<string, unknown>): Promise<InsightAssignmentLoadRecord[]>;
};
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** /**
* Count business days between two dates (MonFri). * Count business days between two dates (MonFri).
@@ -92,205 +18,6 @@ function countBusinessDays(start: Date, end: Date): number {
return count; return count;
} }
async function loadInsightProjects(db: InsightsDbAccess["project"]) {
return db.findMany({
where: { status: { in: ["ACTIVE", "DRAFT"] } },
include: {
demandRequirements: {
select: {
headcount: true,
startDate: true,
endDate: true,
_count: { select: { assignments: true } },
},
},
assignments: {
select: {
resourceId: true,
startDate: true,
endDate: true,
hoursPerDay: true,
dailyCostCents: true,
status: true,
},
},
},
}) as Promise<InsightProjectRecord[]>;
}
async function loadInsightResources(db: InsightsDbAccess["resource"]) {
return db.findMany({
where: { isActive: true },
select: {
id: true,
displayName: true,
availability: true,
},
}) as Promise<InsightResourceRecord[]>;
}
async function loadInsightAssignmentLoads(db: InsightsDbAccess["assignment"], now: Date) {
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return db.findMany({
where: {
status: { in: ["ACTIVE", "CONFIRMED"] },
startDate: { lte: periodEnd },
endDate: { gte: periodStart },
},
select: {
resourceId: true,
hoursPerDay: true,
},
}) as Promise<InsightAssignmentLoadRecord[]>;
}
function summarizeAnomalies(anomalies: Anomaly[]): InsightSnapshot["summary"] {
return anomalies.reduce<InsightSnapshot["summary"]>((summary, anomaly) => {
summary.total += 1;
summary[anomaly.type] += 1;
if (anomaly.severity === "critical") {
summary.criticalCount += 1;
}
return summary;
}, {
total: 0,
criticalCount: 0,
budget: 0,
staffing: 0,
timeline: 0,
utilization: 0,
});
}
async function buildInsightSnapshot(db: InsightsDbAccess, now = new Date()): Promise<InsightSnapshot> {
const [projects, resources, activeAssignments] = await Promise.all([
loadInsightProjects(db.project),
loadInsightResources(db.resource),
loadInsightAssignmentLoads(db.assignment, now),
]);
const twoWeeksFromNow = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
const anomalies: Anomaly[] = [];
for (const project of projects) {
if (project.budgetCents > 0) {
const totalDays = countBusinessDays(project.startDate, project.endDate);
const elapsedDays = countBusinessDays(project.startDate, now < project.endDate ? now : project.endDate);
if (totalDays > 0 && elapsedDays > 0) {
const expectedBurnRate = elapsedDays / totalDays;
const totalCostCents = project.assignments.reduce((sum, assignment) => {
const assignmentStart = assignment.startDate < project.startDate
? project.startDate
: assignment.startDate;
const assignmentEnd = assignment.endDate > now ? now : assignment.endDate;
if (assignmentEnd < assignmentStart) {
return sum;
}
return sum + assignment.dailyCostCents * countBusinessDays(assignmentStart, assignmentEnd);
}, 0);
const actualBurnRate = totalCostCents / project.budgetCents;
if (actualBurnRate > expectedBurnRate * 1.2) {
const overSpendPercent = Math.round(((actualBurnRate - expectedBurnRate) / expectedBurnRate) * 100);
anomalies.push({
type: "budget",
severity: actualBurnRate > expectedBurnRate * 1.5 ? "critical" : "warning",
entityId: project.id,
entityName: project.name,
message: `Burning budget ${overSpendPercent}% faster than expected. ${Math.round(actualBurnRate * 100)}% spent at ${Math.round(expectedBurnRate * 100)}% timeline.`,
});
}
}
}
const upcomingDemands = project.demandRequirements.filter(
(demand) => demand.startDate <= twoWeeksFromNow && demand.endDate >= now,
);
for (const demand of upcomingDemands) {
const unfilledCount = demand.headcount - demand._count.assignments;
const unfillPct = demand.headcount > 0 ? unfilledCount / demand.headcount : 0;
if (unfillPct > 0.3) {
anomalies.push({
type: "staffing",
severity: unfillPct > 0.6 ? "critical" : "warning",
entityId: project.id,
entityName: project.name,
message: `${unfilledCount} of ${demand.headcount} positions unfilled, starting ${demand.startDate.toISOString().slice(0, 10)}.`,
});
}
}
const overrunAssignments = project.assignments.filter(
(assignment) => assignment.endDate > project.endDate
&& (assignment.status === "ACTIVE" || assignment.status === "CONFIRMED"),
);
if (overrunAssignments.length > 0) {
anomalies.push({
type: "timeline",
severity: "warning",
entityId: project.id,
entityName: project.name,
message: `${overrunAssignments.length} assignment(s) extend beyond the project end date (${project.endDate.toISOString().slice(0, 10)}).`,
});
}
}
const resourceHoursMap = new Map<string, number>();
for (const assignment of activeAssignments) {
const currentHours = resourceHoursMap.get(assignment.resourceId) ?? 0;
resourceHoursMap.set(assignment.resourceId, currentHours + assignment.hoursPerDay);
}
for (const resource of resources) {
const availability = resource.availability as Record<string, number> | null;
if (!availability) {
continue;
}
const dailyAvailableHours = Object.values(availability).reduce((sum, hours) => sum + (hours ?? 0), 0) / 5;
if (dailyAvailableHours <= 0) {
continue;
}
const bookedHours = resourceHoursMap.get(resource.id) ?? 0;
const utilizationPercent = Math.round((bookedHours / dailyAvailableHours) * 100);
if (utilizationPercent > 110) {
anomalies.push({
type: "utilization",
severity: utilizationPercent > 130 ? "critical" : "warning",
entityId: resource.id,
entityName: resource.displayName,
message: `Resource at ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailableHours.toFixed(1)}h per day).`,
});
} else if (utilizationPercent < 40 && bookedHours > 0) {
anomalies.push({
type: "utilization",
severity: "warning",
entityId: resource.id,
entityName: resource.displayName,
message: `Resource at only ${utilizationPercent}% utilization (${bookedHours.toFixed(1)}h/${dailyAvailableHours.toFixed(1)}h per day).`,
});
}
}
anomalies.sort((left, right) => {
if (left.severity !== right.severity) {
return left.severity === "critical" ? -1 : 1;
}
return left.type.localeCompare(right.type);
});
return {
anomalies,
summary: summarizeAnomalies(anomalies),
};
}
// ─── Router ────────────────────────────────────────────────────────────────── // ─── Router ──────────────────────────────────────────────────────────────────
export const insightsRouter = createTRPCRouter({ export const insightsRouter = createTRPCRouter({