6f34659587
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>
179 lines
4.6 KiB
TypeScript
179 lines
4.6 KiB
TypeScript
/**
|
|
* Rate card lookup logic for auto-filling demand line rates.
|
|
*
|
|
* Match priority (highest specificity wins):
|
|
* 1. Exact client + chapter + role
|
|
* 2. Client + chapter (any role)
|
|
* 3. Client + role (any chapter)
|
|
* 4. Client only (fallback)
|
|
* 5. Default rate card (no client) + best match
|
|
*
|
|
* Within each priority tier, additional criteria (seniority, location,
|
|
* workType) increase the score.
|
|
*/
|
|
|
|
export interface RateCardLookupParams {
|
|
clientId?: string | null;
|
|
chapter?: string | null;
|
|
roleId?: string | null;
|
|
seniority?: string | null;
|
|
location?: string | null;
|
|
workType?: string | null;
|
|
effectiveDate?: Date | null;
|
|
}
|
|
|
|
export interface RateCardLookupResult {
|
|
costRateCents: number;
|
|
billRateCents: number;
|
|
currency: string;
|
|
rateCardId: string;
|
|
rateCardLineId: string;
|
|
rateCardName: string;
|
|
}
|
|
|
|
interface RateCardLineRow {
|
|
id: string;
|
|
rateCardId: string;
|
|
roleId: string | null;
|
|
chapter: string | null;
|
|
location: string | null;
|
|
seniority: string | null;
|
|
workType: string | null;
|
|
costRateCents: number;
|
|
billRateCents: number | null;
|
|
rateCard: {
|
|
id: string;
|
|
name: string;
|
|
currency: string;
|
|
clientId: string | null;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Look up the best-matching rate card line for a given set of criteria.
|
|
* Returns null when no active rate card line matches.
|
|
*/
|
|
export async function lookupRate(
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
db: any,
|
|
params: RateCardLookupParams,
|
|
): Promise<RateCardLookupResult | null> {
|
|
const effectiveDate = params.effectiveDate ?? new Date();
|
|
|
|
// Build rate card filter: active cards, within effective date range
|
|
const rateCardWhere: Record<string, unknown> = {
|
|
isActive: true,
|
|
OR: [
|
|
{ effectiveFrom: null },
|
|
{ effectiveFrom: { lte: effectiveDate } },
|
|
],
|
|
AND: [
|
|
{
|
|
OR: [
|
|
{ effectiveTo: null },
|
|
{ effectiveTo: { gte: effectiveDate } },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
// If we have a clientId, look for both client-specific and default (null client) cards
|
|
if (params.clientId) {
|
|
rateCardWhere.clientId = { in: [params.clientId, null] };
|
|
}
|
|
// If no clientId, only look at default (null client) cards
|
|
// (don't pass clientId filter at all to keep the OR above valid)
|
|
|
|
const lines = (await db.rateCardLine.findMany({
|
|
where: {
|
|
rateCard: rateCardWhere,
|
|
},
|
|
select: {
|
|
id: true,
|
|
rateCardId: true,
|
|
roleId: true,
|
|
chapter: true,
|
|
location: true,
|
|
seniority: true,
|
|
workType: true,
|
|
costRateCents: true,
|
|
billRateCents: true,
|
|
rateCard: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
currency: true,
|
|
clientId: true,
|
|
},
|
|
},
|
|
},
|
|
})) as RateCardLineRow[];
|
|
|
|
if (lines.length === 0) return null;
|
|
|
|
// Score each line. Higher = better match.
|
|
type ScoredLine = { line: RateCardLineRow; score: number; mismatch: boolean };
|
|
const scored: ScoredLine[] = lines.map((line) => {
|
|
let score = 0;
|
|
let mismatch = false;
|
|
|
|
// Client specificity: client-specific cards get a large bonus
|
|
if (params.clientId && line.rateCard.clientId === params.clientId) {
|
|
score += 100;
|
|
} else if (params.clientId && line.rateCard.clientId != null) {
|
|
// Different client entirely => disqualify
|
|
mismatch = true;
|
|
}
|
|
// Default card (null client) gets no bonus but is a valid fallback
|
|
|
|
// Role match
|
|
if (params.roleId && line.roleId) {
|
|
if (line.roleId === params.roleId) score += 16;
|
|
else mismatch = true;
|
|
}
|
|
|
|
// Chapter match
|
|
if (params.chapter && line.chapter) {
|
|
if (line.chapter === params.chapter) score += 8;
|
|
else mismatch = true;
|
|
}
|
|
|
|
// Seniority match
|
|
if (params.seniority && line.seniority) {
|
|
if (line.seniority === params.seniority) score += 4;
|
|
else mismatch = true;
|
|
}
|
|
|
|
// Location match
|
|
if (params.location && line.location) {
|
|
if (line.location === params.location) score += 2;
|
|
else mismatch = true;
|
|
}
|
|
|
|
// Work type match
|
|
if (params.workType && line.workType) {
|
|
if (line.workType === params.workType) score += 1;
|
|
else mismatch = true;
|
|
}
|
|
|
|
return { line, score, mismatch };
|
|
});
|
|
|
|
// Filter out mismatched lines and sort by score descending
|
|
const candidates = scored
|
|
.filter((s) => !s.mismatch)
|
|
.sort((a, b) => b.score - a.score);
|
|
|
|
const best = candidates[0];
|
|
if (!best) return null;
|
|
|
|
return {
|
|
costRateCents: best.line.costRateCents,
|
|
billRateCents: best.line.billRateCents ?? 0,
|
|
currency: best.line.rateCard.currency,
|
|
rateCardId: best.line.rateCard.id,
|
|
rateCardLineId: best.line.id,
|
|
rateCardName: best.line.rateCard.name,
|
|
};
|
|
}
|