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,263 @@
|
||||
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 { emitNotificationCreated } from "../sse/event-bus.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<string, unknown>;
|
||||
select: Record<string, unknown>;
|
||||
}) => 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<string, unknown>;
|
||||
select: Record<string, unknown>;
|
||||
}) => Promise<
|
||||
Array<{
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
type: string;
|
||||
isHalfDay: boolean;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
notification: {
|
||||
findFirst: (args: {
|
||||
where: Record<string, unknown>;
|
||||
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<Array<{ id: string }>>;
|
||||
};
|
||||
};
|
||||
|
||||
/** 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<number> {
|
||||
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;
|
||||
|
||||
for (const manager of managers) {
|
||||
const notification = await (db as DbClient).notification.create({
|
||||
data: {
|
||||
userId: manager.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",
|
||||
},
|
||||
});
|
||||
|
||||
emitNotificationCreated(manager.id, notification.id);
|
||||
}
|
||||
|
||||
alertCount++;
|
||||
}
|
||||
|
||||
return alertCount;
|
||||
}
|
||||
Reference in New Issue
Block a user