refactor(api): extract insights anomaly snapshot
This commit is contained in:
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 (Mon–Fri).
|
* Count business days between two dates (Mon–Fri).
|
||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user