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:
2026-03-19 21:39:05 +01:00
parent 6e5b9ec85b
commit 6f34659587
16 changed files with 1906 additions and 4 deletions
@@ -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;
}