import { deriveResourceForecast, getMonthRange, countWorkingDaysInOverlap, calculateSAH, type AssignmentSlice, } from "@planarchy/engine"; import type { SpainScheduleRule } from "@planarchy/shared"; import { isChargeabilityActualBooking, listAssignmentBookings } from "@planarchy/application"; import { VacationStatus } from "@planarchy/db"; import { createNotificationsForUsers } from "./create-notification.js"; /** * Minimal DB client type for chargeability alerts. * Uses structural typing so we can pass in `prisma as any` from the cron route. */ type DbClient = { resource: { findMany: (args: { where: Record; select: Record; }) => Promise< Array<{ id: string; displayName: string; fte: number; chargeabilityTarget: number; country: { dailyWorkingHours: number | null; scheduleRules: unknown } | null; managementLevelGroup: { targetPercentage: number | null } | null; }> >; }; vacation: { findMany: (args: { where: Record; select: Record; }) => Promise< Array<{ resourceId: string; startDate: Date; endDate: Date; type: string; isHalfDay: boolean; }> >; }; notification: { findFirst: (args: { where: Record; select: { id: true }; }) => Promise<{ id: string } | null>; create: (args: { data: { userId: string; type: string; category: string; priority: string; title: string; body: string; entityId: string; entityType: string; link: string; channel: string; }; }) => Promise<{ id: string; userId: string }>; }; user: { findMany: (args: { where: { systemRole: { in: string[] } }; select: { id: true }; }) => Promise>; }; }; /** Alert when chargeability is more than 15pp below target */ const GAP_THRESHOLD_PP = 15; /** * Find resources whose current-month chargeability is >15 percentage points * below their target, and create a notification for all managers. * * Duplicate-safe: skips resources that already have an alert this month. * * Returns the number of new alerts created. */ export async function checkChargeabilityAlerts( // eslint-disable-next-line @typescript-eslint/no-explicit-any db: any, ): Promise { const now = new Date(); const year = now.getUTCFullYear(); const month = now.getUTCMonth() + 1; const { start: monthStart, end: monthEnd } = getMonthRange(year, month); const monthKey = `${year}-${String(month).padStart(2, "0")}`; // Fetch active, chg-responsible resources const resources = await (db as DbClient).resource.findMany({ where: { isActive: true, chgResponsibility: true, departed: false, rolledOff: false, }, select: { id: true, displayName: true, fte: true, chargeabilityTarget: true, country: { select: { dailyWorkingHours: true, scheduleRules: true } }, managementLevelGroup: { select: { targetPercentage: true } }, }, }); if (resources.length === 0) return 0; const resourceIds = resources.map((r) => r.id); // Fetch bookings for the current month const allBookings = await listAssignmentBookings(db, { startDate: monthStart, endDate: monthEnd, resourceIds, }); // Fetch vacations for the current month const vacations = await (db as DbClient).vacation.findMany({ where: { resourceId: { in: resourceIds }, status: VacationStatus.APPROVED, startDate: { lte: monthEnd }, endDate: { gte: monthStart }, }, select: { resourceId: true, startDate: true, endDate: true, type: true, isHalfDay: true, }, }); // Compute chargeability per resource const underperformers: Array<{ resource: typeof resources[0]; chg: number; target: number; gap: number }> = []; for (const resource of resources) { const dailyHours = resource.country?.dailyWorkingHours ?? 8; // Compute absence dates for SAH const resourceVacations = vacations.filter((v) => v.resourceId === resource.id); const absenceDates: string[] = []; 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 cursor = new Date(vStart); cursor.setUTCHours(0, 0, 0, 0); const endNorm = new Date(vEnd); endNorm.setUTCHours(0, 0, 0, 0); while (cursor <= endNorm) { absenceDates.push(cursor.toISOString().slice(0, 10)); cursor.setUTCDate(cursor.getUTCDate() + 1); } } const scheduleRules = (resource.country?.scheduleRules ?? null) as SpainScheduleRule | null; const sahResult = calculateSAH({ dailyWorkingHours: dailyHours, scheduleRules, fte: resource.fte, periodStart: monthStart, periodEnd: monthEnd, publicHolidays: [], absenceDays: absenceDates, }); // Build assignment slices const resourceBookings = allBookings.filter( (b) => b.resourceId === resource.id && isChargeabilityActualBooking(b, false), ); const slices: AssignmentSlice[] = resourceBookings.map((b) => { const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, b.startDate, b.endDate); return { hoursPerDay: b.hoursPerDay, workingDays, categoryCode: "Chg", // simplified — treat all actual bookings as chargeable }; }); const targetPct = resource.managementLevelGroup?.targetPercentage ?? (resource.chargeabilityTarget / 100); const forecast = deriveResourceForecast({ fte: resource.fte, targetPercentage: targetPct, assignments: slices, sah: sahResult.standardAvailableHours, }); const chgPct = forecast.chg * 100; const targetPctVal = targetPct * 100; const gap = targetPctVal - chgPct; if (gap > GAP_THRESHOLD_PP) { underperformers.push({ resource, chg: Math.round(chgPct), target: Math.round(targetPctVal), gap: Math.round(gap), }); } } if (underperformers.length === 0) return 0; // Fetch managers to notify const managers = await (db as DbClient).user.findMany({ where: { systemRole: { in: ["ADMIN", "MANAGER"] } }, select: { id: true }, }); if (managers.length === 0) return 0; let alertCount = 0; for (const { resource, chg, target, gap } of underperformers) { // Duplicate check: one alert per resource per month const entityId = `chg-alert-${resource.id}-${monthKey}`; const existing = await (db as DbClient).notification.findFirst({ where: { entityId, entityType: "chargeability_alert", type: "CHARGEABILITY_ALERT", }, select: { id: true }, }); if (existing) continue; await createNotificationsForUsers({ db: db as DbClient, userIds: managers.map((m) => m.id), type: "CHARGEABILITY_ALERT", category: "NOTIFICATION", priority: "HIGH", title: `Low chargeability: ${resource.displayName}`, body: `${resource.displayName} is at ${chg}% chargeability this month (target: ${target}%, gap: ${gap}pp).`, entityId, entityType: "chargeability_alert", link: "/chargeability", channel: "in_app", }); alertCount++; } return alertCount; }