import { listAssignmentBookings } from "@capakraken/application"; import { rankResources } from "@capakraken/staffing"; import type { SkillEntry, WeekdayAvailability } from "@capakraken/shared"; import { calculateEffectiveAvailableHours, calculateEffectiveBookedHours, loadResourceDailyAvailabilityContexts, } from "./resource-capacity.js"; import { createNotificationsForUsers } from "./create-notification.js"; /** * Minimal DB interface for auto-staffing — avoids importing the full PrismaClient. * Follows the same pattern as budget-alerts.ts. */ type DbClient = Parameters[0] & { demandRequirement: { findUnique: (args: { where: { id: string }; select: { id: true; projectId: true; startDate: true; endDate: true; hoursPerDay: true; role: true; roleId: true; headcount: true; budgetCents: true; }; }) => Promise<{ id: string; projectId: string; startDate: Date; endDate: Date; hoursPerDay: number; role: string | null; roleId: string | null; headcount: number; budgetCents: number; } | null>; }; project: { findUnique: (args: { where: { id: string }; select: { id: true; name: true }; }) => Promise<{ id: string; name: string } | null>; }; role: { findUnique: (args: { where: { id: string }; select: { id: true; name: true }; }) => Promise<{ id: string; name: string } | null>; }; resource: { findMany: (args: { where: { isActive: true }; }) => Promise>; }; notification: { 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>; }; }; const TOP_N = 3; /** * Generate automatic staffing suggestions for a demand requirement. * * Fetches the demand's role/dates/hours, runs the staffing ranking algorithm * for the top 3 matches, and creates a notification for project managers * with a summary of the suggestions. * * This function is designed to be called fire-and-forget (non-blocking). * It swallows all errors to avoid disrupting the caller. */ export async function generateAutoSuggestions( db: DbClient, demandRequirementId: string, ): Promise { try { // 1. Load the demand requirement const demand = await db.demandRequirement.findUnique({ where: { id: demandRequirementId }, select: { id: true, projectId: true, startDate: true, endDate: true, hoursPerDay: true, role: true, roleId: true, headcount: true, budgetCents: true, }, }); if (!demand) return; // 2. Resolve project and role names const [project, roleEntity] = await Promise.all([ db.project.findUnique({ where: { id: demand.projectId }, select: { id: true, name: true }, }), demand.roleId ? db.role.findUnique({ where: { id: demand.roleId }, select: { id: true, name: true }, }) : Promise.resolve(null), ]); if (!project) return; const roleName = roleEntity?.name ?? demand.role ?? "Unspecified role"; // 3. Derive required skills from role name // The role name itself is treated as the primary required skill. // Resources with matching skill names in their skill matrix will rank highest. const requiredSkills = [roleName]; // 4. Fetch all active resources and their current bookings const resources = await db.resource.findMany({ where: { isActive: true }, }); if (resources.length === 0) return; const bookings = await listAssignmentBookings(db, { startDate: demand.startDate, endDate: demand.endDate, resourceIds: resources.map((r) => r.id), }); const contexts = await loadResourceDailyAvailabilityContexts( db as Parameters[0], resources.map((resource) => ({ id: resource.id, availability: resource.availability as unknown as WeekdayAvailability, countryId: resource.countryId, countryCode: resource.country?.code, federalState: resource.federalState, metroCityId: resource.metroCityId, metroCityName: resource.metroCity?.name, })), demand.startDate, demand.endDate, ); // 5. Enrich resources with utilization data for the demand's date range const enrichedResources = resources.map((resource) => { const availability = resource.availability as unknown as WeekdayAvailability; const context = contexts.get(resource.id); const resourceBookings = bookings.filter((b) => b.resourceId === resource.id); const totalAvailableHours = calculateEffectiveAvailableHours({ availability, periodStart: demand.startDate, periodEnd: demand.endDate, context, }); const allocatedHours = resourceBookings.reduce( (sum, booking) => sum + calculateEffectiveBookedHours({ availability, startDate: booking.startDate, endDate: booking.endDate, hoursPerDay: booking.hoursPerDay, periodStart: demand.startDate, periodEnd: demand.endDate, context, }), 0, ); const utilizationPercent = totalAvailableHours > 0 ? Math.min(100, (allocatedHours / totalAvailableHours) * 100) : 0; const wouldExceedCapacity = totalAvailableHours > 0 ? allocatedHours + demand.hoursPerDay > totalAvailableHours : demand.hoursPerDay > 0; return { id: resource.id, displayName: resource.displayName, eid: resource.eid, skills: resource.skills as unknown as SkillEntry[], lcrCents: resource.lcrCents, chargeabilityTarget: resource.chargeabilityTarget, currentUtilizationPercent: utilizationPercent, hasAvailabilityConflicts: wouldExceedCapacity, conflictDays: wouldExceedCapacity ? ["(multiple days)"] : [], valueScore: resource.valueScore ?? 0, }; }); // 6. Rank resources using the staffing algorithm const budgetLcrCentsPerHour = demand.budgetCents > 0 ? demand.budgetCents : undefined; const ranked = rankResources({ requiredSkills, resources: enrichedResources, budgetLcrCentsPerHour, } as unknown as Parameters[0]); // Value-score tiebreaker (same logic as staffing router) ranked.sort((a, b) => { if (Math.abs(a.score - b.score) <= 2) { const aVal = enrichedResources.find((r) => r.id === a.resourceId)?.valueScore ?? 0; const bVal = enrichedResources.find((r) => r.id === b.resourceId)?.valueScore ?? 0; return bVal - aVal; } return 0; }); const topSuggestions = ranked.slice(0, TOP_N); if (topSuggestions.length === 0) return; // 7. Build notification message const suggestionSummary = topSuggestions .map((s) => `${s.resourceName} (${s.score}%)`) .join(", "); const title = `Staffing suggestions for ${roleName} on ${project.name}`; const body = `${topSuggestions.length} matching resources found for ${roleName} on ${project.name}: ${suggestionSummary}`; // 8. Notify all managers and admins const managers = await db.user.findMany({ where: { systemRole: { in: ["ADMIN", "MANAGER"] } }, select: { id: true }, }); await createNotificationsForUsers({ db, userIds: managers.map((m) => m.id), type: "AUTO_STAFFING_SUGGESTION", category: "NOTIFICATION", priority: "NORMAL", title, body, entityId: demandRequirementId, entityType: "demand", link: `/staffing?demandId=${demandRequirementId}`, channel: "in_app", }); } catch { // Fire-and-forget: swallow all errors to avoid disrupting the caller. } }