feat: Sprint 3 — automation, intelligence, skill marketplace
Auto-Staffing Suggestions (A6): - generateAutoSuggestions() ranks top-3 resources on demand creation - Uses existing staffing engine (skill 40%, availability 30%, cost 20%, util 10%) - Creates in-app notification with match scores for managers - Triggered after createDemandRequirement and partial fillDemandRequirement Vacation Conflict Detection (A7): - checkVacationConflicts() warns when >50% chapter absent on same days - Returns warnings array in approve/batchApprove responses (advisory, non-blocking) - Creates VACATION_CONFLICT_WARNING notification for approver Weekly Chargeability Alerts (A10): - checkChargeabilityAlerts() finds resources >15pp below target - Cron endpoint: GET /api/cron/chargeability-alerts - Duplicate-safe by resourceId + month composite key Rate Card Auto-Apply (A11): - lookupRate() finds best matching rate card line (weighted scoring) - Auto-fills demand line rates in estimate create/updateDraft when rates are 0 - Marks auto-filled lines with metadata.autoAppliedRateCard - New lookupDemandLineRate query for on-demand UI lookups Public Holiday Auto-Import (A12): - autoImportPublicHolidays() generates holidays by resource federal state - Cron endpoint: GET /api/cron/public-holidays?year=2027 - Duplicate-safe, uses existing getPublicHolidays() from shared Skill Marketplace MVP (G6): - New page: /analytics/skill-marketplace with 3 sections - Skill Search: filter by name, proficiency, availability, sortable results - Skill Gap Heat Map: supply vs demand per skill, shortage/surplus indicators - Skill Distribution: top-20 horizontal bar chart (reuses SkillDistributionChart) - New getSkillMarketplace query in resource router - Sidebar nav link under Analytics for ADMIN/MANAGER/CONTROLLER Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
import { listAssignmentBookings } from "@planarchy/application";
|
||||
import { rankResources } from "@planarchy/staffing";
|
||||
import type { SkillEntry } from "@planarchy/shared";
|
||||
import { emitNotificationCreated } from "../sse/event-bus.js";
|
||||
|
||||
/**
|
||||
* Minimal DB interface for auto-staffing — avoids importing the full PrismaClient.
|
||||
* Follows the same pattern as budget-alerts.ts.
|
||||
*/
|
||||
type DbClient = Parameters<typeof listAssignmentBookings>[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<Array<{
|
||||
id: string;
|
||||
displayName: string;
|
||||
eid: string | null;
|
||||
skills: unknown;
|
||||
lcrCents: number;
|
||||
chargeabilityTarget: number;
|
||||
availability: unknown;
|
||||
valueScore: number | null;
|
||||
}>>;
|
||||
};
|
||||
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<Array<{ id: string }>>;
|
||||
};
|
||||
};
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
if (resources.length === 0) return;
|
||||
|
||||
const bookings = await listAssignmentBookings(db, {
|
||||
startDate: demand.startDate,
|
||||
endDate: demand.endDate,
|
||||
resourceIds: resources.map((r) => r.id),
|
||||
});
|
||||
|
||||
// 5. Enrich resources with utilization data for the demand's date range
|
||||
const enrichedResources = resources.map((resource) => {
|
||||
const avail = resource.availability as
|
||||
| { monday?: number; tuesday?: number; wednesday?: number; thursday?: number; friday?: number }
|
||||
| null;
|
||||
const totalAvailableHours = avail?.monday ?? 8;
|
||||
const resourceBookings = bookings.filter((b) => b.resourceId === resource.id);
|
||||
|
||||
const allocatedHoursPerDay = resourceBookings.reduce(
|
||||
(sum, b) => sum + b.hoursPerDay,
|
||||
0,
|
||||
);
|
||||
|
||||
const utilizationPercent =
|
||||
totalAvailableHours > 0
|
||||
? Math.min(100, (allocatedHoursPerDay / totalAvailableHours) * 100)
|
||||
: 0;
|
||||
|
||||
const wouldExceedCapacity =
|
||||
allocatedHoursPerDay + demand.hoursPerDay > totalAvailableHours;
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
for (const manager of managers) {
|
||||
const notification = await db.notification.create({
|
||||
data: {
|
||||
userId: manager.id,
|
||||
type: "AUTO_STAFFING_SUGGESTION",
|
||||
category: "NOTIFICATION",
|
||||
priority: "NORMAL",
|
||||
title,
|
||||
body,
|
||||
entityId: demandRequirementId,
|
||||
entityType: "demand",
|
||||
link: `/staffing?demandId=${demandRequirementId}`,
|
||||
channel: "in_app",
|
||||
},
|
||||
});
|
||||
|
||||
emitNotificationCreated(manager.id, notification.id);
|
||||
}
|
||||
} catch {
|
||||
// Fire-and-forget: swallow all errors to avoid disrupting the caller.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user