9051ff73d0
Replace ~440 lines of hand-written structural DB client types across 7 lib files with `Pick<PrismaClient, ...>` from @capakraken/db. This eliminates all `as any` casts at Prisma boundaries (cron routes, allocation effects, vacation procedures) and surfaces two pre-existing bugs: - weekly-digest.ts: `db.allocation.count()` called non-existent model (fixed → demandRequirement) - estimate-reminders.ts: `submittedAt` field doesn't exist on EstimateVersion (fixed → updatedAt) Also adds root eslint.config.mjs so lint-staged can lint package files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
219 lines
7.1 KiB
TypeScript
219 lines
7.1 KiB
TypeScript
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<void> {
|
|
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<typeof loadResourceDailyAvailabilityContexts>[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<typeof rankResources>[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.
|
|
}
|
|
}
|