import type { PrismaClient } from "@capakraken/db"; 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"; type DbClient = Pick< PrismaClient, "assignment" | "demandRequirement" | "project" | "role" | "resource" | "notification" | "user" >; 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 }, select: { id: true, displayName: true, eid: true, skills: true, lcrCents: true, chargeabilityTarget: true, valueScore: true, availability: true, countryId: true, federalState: true, metroCityId: true, country: { select: { code: true } }, metroCity: { select: { name: true } }, }, take: 5000, }); 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. } }