Files
BeyondCXAnalytics-Demo/frontend/utils/realDataAnalysis.ts
Claude 94178eaaae Translate Phase 1 high-priority frontend utils (backendMapper, analysisGenerator, realDataAnalysis)
Phase 1 of Spanish-to-English translation for critical path files:
- backendMapper.ts: Translated ~50 occurrences (comments, labels, dimension titles)
- analysisGenerator.ts: Translated ~49 occurrences (findings, recommendations, dimension content)
- realDataAnalysis.ts: Translated ~92 occurrences (clasificarTier functions, inline comments)

All function names and API variable names preserved for compatibility.
Frontend compilation tested and verified successful.

Related to TRANSLATION_STATUS.md Phase 1 objectives.

https://claude.ai/code/session_01GNbnkFoESkRcnPr3bLCYDg
2026-02-07 10:35:40 +00:00

2524 lines
105 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Generation of analysis with real data (not synthetic)
*/
import type { AnalysisData, Kpi, DimensionAnalysis, HeatmapDataPoint, Opportunity, RoadmapInitiative, EconomicModelData, BenchmarkDataPoint, Finding, Recommendation, TierKey, CustomerSegment, RawInteraction, AgenticReadinessResult, SubFactor, SkillMetrics, DrilldownDataPoint } from '../types';
import { RoadmapPhase } from '../types';
import { BarChartHorizontal, Zap, Target, Brain, Bot, DollarSign, Smile } from 'lucide-react';
import { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2';
import { classifyQueue } from './segmentClassifier';
/**
* Calculate hourly distribution from interactions
* NOTE: Uses unique interaction_id for consistency with backend (aggfunc="nunique")
*/
function calculateHourlyDistribution(interactions: RawInteraction[]): { hourly: number[]; off_hours_pct: number; peak_hours: number[] } {
const hourly = new Array(24).fill(0);
// Deduplicate by interaction_id for consistency with backend (nunique)
const seenIds = new Set<string>();
let duplicateCount = 0;
for (const interaction of interactions) {
// Skip duplicate interaction_id
const id = interaction.interaction_id;
if (id && seenIds.has(id)) {
duplicateCount++;
continue;
}
if (id) seenIds.add(id);
try {
const date = new Date(interaction.datetime_start);
if (!isNaN(date.getTime())) {
const hour = date.getHours();
hourly[hour]++;
}
} catch {
// Ignore invalid dates
}
}
if (duplicateCount > 0) {
console.log(`⏰ calculateHourlyDistribution: ${duplicateCount} duplicate interaction_ids ignored`);
}
const total = hourly.reduce((a, b) => a + b, 0);
// Off hours: 19:00-08:00
const offHoursVolume = hourly.slice(0, 8).reduce((a, b) => a + b, 0) +
hourly.slice(19).reduce((a, b) => a + b, 0);
const off_hours_pct = total > 0 ? Math.round((offHoursVolume / total) * 100) : 0;
// Find peak hours (top 3 consecutive)
let maxSum = 0;
let peakStart = 0;
for (let i = 0; i < 22; i++) {
const sum = hourly[i] + hourly[i + 1] + hourly[i + 2];
if (sum > maxSum) {
maxSum = sum;
peakStart = i;
}
}
const peak_hours = [peakStart, peakStart + 1, peakStart + 2];
// Log for debugging
const hourlyNonZero = hourly.filter(v => v > 0);
const peakVolume = Math.max(...hourlyNonZero, 1);
const valleyVolume = Math.min(...hourlyNonZero.filter(v => v > 0), 1);
console.log(`⏰ Hourly distribution: total=${total}, peak=${peakVolume}, valley=${valleyVolume}, ratio=${(peakVolume/valleyVolume).toFixed(2)}`);
return { hourly, off_hours_pct, peak_hours };
}
/**
* Calculate date range from interactions (optimized for large files)
*/
function calculateDateRange(interactions: RawInteraction[]): { min: string; max: string } | undefined {
let minTime = Infinity;
let maxTime = -Infinity;
let validCount = 0;
for (const interaction of interactions) {
const date = new Date(interaction.datetime_start);
const time = date.getTime();
if (!isNaN(time)) {
validCount++;
if (time < minTime) minTime = time;
if (time > maxTime) maxTime = time;
}
}
if (validCount === 0) return undefined;
return {
min: new Date(minTime).toISOString().split('T')[0],
max: new Date(maxTime).toISOString().split('T')[0]
};
}
/**
* Generar analysis completo con datos reales
*/
export function generateAnalysisFromRealData(
tier: TierKey,
interactions: RawInteraction[],
costPerHour: number,
avgCsat: number,
segmentMapping?: { high_value_queues: string[]; medium_value_queues: string[]; low_value_queues: string[] }
): AnalysisData {
console.log(`🔄 Generating analysis from ${interactions.length} real interactions`);
// STEP 0: Detectar si tenemos datos de repeat_call_7d
const repeatCallTrueCount = interactions.filter(i => i.repeat_call_7d === true).length;
const repeatCallFalseCount = interactions.filter(i => i.repeat_call_7d === false).length;
const repeatCallUndefinedCount = interactions.filter(i => i.repeat_call_7d === undefined).length;
const transferTrueCount = interactions.filter(i => i.transfer_flag === true).length;
const transferFalseCount = interactions.filter(i => i.transfer_flag === false).length;
const hasRepeatCallData = repeatCallTrueCount > 0;
console.log('📞 DETAILED DATA CHECK:');
console.log(` - repeat_call_7d TRUE: ${repeatCallTrueCount} (${((repeatCallTrueCount/interactions.length)*100).toFixed(1)}%)`);
console.log(` - repeat_call_7d FALSE: ${repeatCallFalseCount} (${((repeatCallFalseCount/interactions.length)*100).toFixed(1)}%)`);
console.log(` - repeat_call_7d UNDEFINED: ${repeatCallUndefinedCount}`);
console.log(` - transfer_flag TRUE: ${transferTrueCount} (${((transferTrueCount/interactions.length)*100).toFixed(1)}%)`);
console.log(` - transfer_flag FALSE: ${transferFalseCount} (${((transferFalseCount/interactions.length)*100).toFixed(1)}%)`);
// Calculate FCR esperado mannualmente
const fcrRecords = interactions.filter(i => i.transfer_flag !== true && i.repeat_call_7d !== true);
const expectedFCR = (fcrRecords.length / interactions.length) * 100;
console.log(`📊 EXPECTED FCR (mannual): ${expectedFCR.toFixed(1)}% (${fcrRecords.length}/${interactions.length} calls without transfer AND without repeat)`);
// Mostrar sample de datos for debugging
if (interactions.length > 0) {
console.log('📋 SAMPLE DATA (first 5 rows):', interactions.slice(0, 5).map(i => ({
id: i.interaction_id?.substring(0, 8),
transfer_flag: i.transfer_flag,
repeat_call_7d: i.repeat_call_7d,
is_abandoned: i.is_abandoned
})));
}
console.log(`📞 Repeat call data: ${repeatCallTrueCount} calls marked as repeat (${hasRepeatCallData ? 'USING repeat_call_7d' : 'NO repeat_call_7d data - FCR = 100% - transfer_rate'})`);
// STEP 0.5: Calculate date range
const dateRange = calculateDateRange(interactions);
console.log(`📅 Date range: ${dateRange?.min} to ${dateRange?.max}`);
// STEP 1: Analizar record_status (ya no filtramos, filtering is done internally en calculateSkillMetrics)
// Normalize to uppercase for case-insensitive comparison
const getStatus = (i: RawInteraction) => (i.record_status || '').toString().toUpperCase().trim();
const statusCounts = {
valid: interactions.filter(i => !i.record_status || getStatus(i) === 'VALID').length,
noise: interactions.filter(i => getStatus(i) === 'NOISE').length,
zombie: interactions.filter(i => getStatus(i) === 'ZOMBIE').length,
abandon: interactions.filter(i => getStatus(i) === 'ABANDON').length
};
console.log(`📊 Record status breakdown:`, statusCounts);
// STEP 1.5: Calculate hourly distribution (on ALL interactions to see complete patterns)
const hourlyDistribution = calculateHourlyDistribution(interactions);
console.log(`⏰ Off-hours: ${hourlyDistribution.off_hours_pct}%, Peak hours: ${hourlyDistribution.peak_hours.join('-')}h`);
// STEP 2: Calcular metrics por skill (passes ALL interactions, filtering is done internally)
const skillMetrics = calculateSkillMetrics(interactions, costPerHour);
console.log(`📊 Calculated metrics for ${skillMetrics.length} skills`);
// STEP 3: Generar heatmap data con dimensions
const heatmapData = generateHeatmapFromMetrics(skillMetrics, avgCsat, segmentMapping);
// STEP 4: Calcular metrics globales
// Volumen total: ALL interactions
const totalInteractions = interactions.length;
// Valid volume for AHT: sum of volume_valid from each skill
const totalValidInteractions = skillMetrics.reduce((sum, s) => sum + s.volume_valid, 0);
// AHT average: calculated only on valid interactions (weighted by volume)
const totalWeightedAHT = skillMetrics.reduce((sum, s) => sum + (s.aht_mean * s.volume_valid), 0);
const avgAHT = totalValidInteractions > 0 ? Math.round(totalWeightedAHT / totalValidInteractions) : 0;
// Technical FCR: 100 - transfer_rate (comparable with industry benchmarks)
// Weighted by volume of each skill
const totalVolumeForFCR = skillMetrics.reduce((sum, s) => sum + s.volume_valid, 0);
const avgFCR = totalVolumeForFCR > 0
? Math.round(skillMetrics.reduce((sum, s) => sum + (s.fcr_tecnico * s.volume_valid), 0) / totalVolumeForFCR)
: 0;
// Coste total
const totalCost = Math.round(skillMetrics.reduce((sum, s) => sum + s.total_cost, 0));
// === CPI CENTRALIZADO: Calcular UNA sola vez from heatmapData ===
// This is the ONLY source of truth for CPI, same as ExecutiveSummaryTab
const totalCostVolume = heatmapData.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0);
const totalAnnualCost = heatmapData.reduce((sum, h) => sum + (h.annual_cost || 0), 0);
const hasCpiField = heatmapData.some(h => h.cpi !== undefined && h.cpi > 0);
const globalCPI = hasCpiField
? (totalCostVolume > 0
? heatmapData.reduce((sum, h) => sum + (h.cpi || 0) * (h.cost_volume || h.volume), 0) / totalCostVolume
: 0)
: (totalCostVolume > 0 ? totalAnnualCost / totalCostVolume : 0);
// KPIs main
const summaryKpis: Kpi[] = [
{ label: "Total Interactions", value: totalInteractions.toLocaleString('es-ES') },
{ label: "Average AHT", value: `${avgAHT}s` },
{ label: "Technical FCR", value: `${avgFCR}%` },
{ label: "CSAT", value: `${(avgCsat / 20).toFixed(1)}/5` }
];
// Health Score based on real metrics
const overallHealthScore = calculateHealthScore(heatmapData);
// Dimensiones (simplified for real data) - pass centralized CPI
const dimensions: DimensionAnalysis[] = generateDimensionsFromRealData(
interactions,
skillMetrics,
avgCsat,
avgAHT,
hourlyDistribution,
globalCPI // CPI calculated from heatmapData
);
// Agentic Readiness Score
const agenticReadiness = calculateAgenticReadinessFromRealData(skillMetrics);
// Findings y Recommendations (including analysis of off hours)
const findings = generateFindingsFromRealData(skillMetrics, interactions, hourlyDistribution);
const recommendations = generateRecommendationsFromRealData(skillMetrics, hourlyDistribution, interactions.length);
// v3.3: Drill-down by Queue + Typification - CALCULATE FIRST to use in opportunities y roadmap
const drilldownData = calculateDrilldownMetrics(interactions, costPerHour);
// v3.3: Opportunities y Roadmap based on drilldownData (queues with CV < 75% = automatable)
const opportunities = generateOpportunitiesFromDrilldown(drilldownData, costPerHour);
// Roadmap based on drilldownData
const roadmap = generateRoadmapFromDrilldown(drilldownData, costPerHour);
// Economic Model (v3.10: aligned with TCO of Roadmap)
const economicModel = generateEconomicModelFromRealData(skillMetrics, costPerHour, roadmap, drilldownData);
// Benchmark
const benchmarkData = generateBenchmarkFromRealData(skillMetrics);
return {
tier,
overallHealthScore,
summaryKpis,
dimensions,
heatmapData,
agenticReadiness,
findings,
recommendations,
opportunities,
roadmap,
economicModel,
benchmarkData,
dateRange,
drilldownData
};
}
/**
* STEP 2: Calcular metrics base por skill
*
* FILTERING LOGIC BY record_status:
* - valid: normal valid calls
* - noise: calls < 10 segundos (exclude from AHT, but sum in volumen/coste)
* - zombie: calls > 3 hours (exclude from AHT, but sum in volumen/coste)
* - abandon: customer hangs up (exclude from AHT, no sum conversation cost, but occupies line)
*
* Dashboard quality/efficiency: filter only valid + abandon para AHT
* Financial calculations: use all (volume, total cost)
*/
interface SkillMetrics {
skill: string;
volume: number; // Total of interactions (all)
volume_valid: number; // Valid interactions for AHT (valid + abandon)
aht_mean: number; // AHT "clean" calculado only above valid (without noise/zombie/abandon) - for metrics of quality, CV
aht_total: number; // AHT "total" calculado con TODAS las filas (noise/zombie/abandon included) - only informative
aht_benchmark: number; // AHT "traditional" (includes noise, excludes zombie/abandon) - for comparison with industry benchmarks
aht_std: number;
cv_aht: number;
transfer_rate: number; // Calculado above valid + abandon
fcr_rate: number; // FCR Real: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE) - without recontact 7 days
fcr_tecnico: number; // Technical FCR: (transfer_flag == FALSE) - only without transferencia, comparable with industry benchmarks
abandonment_rate: number; // % de abandonments of total
total_cost: number; // Coste total (all las interactions except abandon)
cost_volume: number; // Volumen used to calculate coste (non-abandon)
cpi: number; // Coste per interaction = total_cost / cost_volume
hold_time_mean: number; // Calculado above valid
cv_talk_time: number;
// Additional metrics for debug
noise_count: number;
zombie_count: number;
abandon_count: number;
}
export function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: number): SkillMetrics[] {
// Group por skill
const skillGroups = new Map<string, RawInteraction[]>();
interactions.forEach(i => {
if (!skillGroups.has(i.queue_skill)) {
skillGroups.set(i.queue_skill, []);
}
skillGroups.get(i.queue_skill)!.push(i);
});
// Calculate metrics for each skill
const metrics: SkillMetrics[] = [];
skillGroups.forEach((group, skill) => {
const volume = group.length;
if (volume === 0) return;
// === SIMPLE AND DIRECT CALCULATIONS FROM CSV ===
// Abandonment: DIRECTO del campo is_abandoned del CSV
const abandon_count = group.filter(i => i.is_abandoned === true).length;
const abandonment_rate = (abandon_count / volume) * 100;
// FCR Real: DIRECTO del campo fcr_real_flag del CSV
// Definition: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE)
// This is the STRICTEST metric - without transfer AND without recontact in 7 days
const fcrTrueCount = group.filter(i => i.fcr_real_flag === true).length;
const fcr_rate = (fcrTrueCount / volume) * 100;
// Transfer rate: DIRECTO del campo transfer_flag del CSV
const transfers = group.filter(i => i.transfer_flag === true).length;
const transfer_rate = (transfers / volume) * 100;
// Technical FCR: 100 - transfer_rate
// Definition: (transfer_flag == FALSE) - only without transferencia
// This metric is COMPARABLE with industry benchmarks (COPC, Dimension Data)
// Industry benchmarks (~70%) measure FCR without transfer, NOT without recontact
const fcr_tecnico = 100 - transfer_rate;
// Separate by record_status for AHT (normalize to uppercase for case-insensitive comparison)
const getStatus = (i: RawInteraction) => (i.record_status || '').toString().toUpperCase().trim();
const noiseRecords = group.filter(i => getStatus(i) === 'NOISE');
const zombieRecords = group.filter(i => getStatus(i) === 'ZOMBIE');
const validRecords = group.filter(i => !i.record_status || getStatus(i) === 'VALID');
// Registros que generan coste (todo except abandonments)
const nonAbandonRecords = group.filter(i => i.is_abandoned !== true);
const noise_count = noiseRecords.length;
const zombie_count = zombieRecords.length;
// AHT se calcula above records 'valid' (excludes noise, zombie)
const ahtRecords = validRecords;
const volume_valid = ahtRecords.length;
let aht_mean = 0;
let aht_std = 0;
let cv_aht = 0;
let hold_time_mean = 0;
let cv_talk_time = 0;
if (volume_valid > 0) {
// AHT = duration_talk + hold_time + wrap_up_time
const ahts = ahtRecords.map(i => i.duration_talk + i.hold_time + i.wrap_up_time);
aht_mean = ahts.reduce((sum, v) => sum + v, 0) / volume_valid;
const aht_variance = ahts.reduce((sum, v) => sum + Math.pow(v - aht_mean, 2), 0) / volume_valid;
aht_std = Math.sqrt(aht_variance);
cv_aht = aht_mean > 0 ? aht_std / aht_mean : 0;
// Talk time CV
const talkTimonth = ahtRecords.map(i => i.duration_talk);
const talk_mean = talkTimonth.reduce((sum, v) => sum + v, 0) / volume_valid;
const talk_std = Math.sqrt(talkTimonth.reduce((sum, v) => sum + Math.pow(v - talk_mean, 2), 0) / volume_valid);
cv_talk_time = talk_mean > 0 ? talk_std / talk_mean : 0;
// Hold time average
hold_time_mean = ahtRecords.reduce((sum, i) => sum + i.hold_time, 0) / volume_valid;
}
// === AHT BENCHMARK: for comparison with industry benchmarks ===
// Incluye NOISE (calls cortas son traunder real), excludes ZOMBIE (errores) y ABANDON (without handle time)
// Industry benchmarks (COPC, Dimension Data) NO filtran calls cortas
const benchmarkRecords = group.filter(i =>
getStatus(i) !== 'ZOMBIE' &&
getStatus(i) !== 'ABANDON' &&
i.is_abandoned !== true
);
const volume_benchmark = benchmarkRecords.length;
let aht_benchmark = aht_mean; // Fallback al AHT clean si no hay records benchmark
if (volume_benchmark > 0) {
const benchmarkAhts = benchmarkRecords.map(i => i.duration_talk + i.hold_time + i.wrap_up_time);
aht_benchmark = benchmarkAhts.reduce((sum, v) => sum + v, 0) / volume_benchmark;
}
// === AHT TOTAL: calculado con TODAS las filas (only informative) ===
// Incluye NOISE, ZOMBIE, ABANDON - for comparison with AHT clean
let aht_total = 0;
if (volume > 0) {
const allAhts = group.map(i => i.duration_talk + i.hold_time + i.wrap_up_time);
aht_total = allAhts.reduce((sum, v) => sum + v, 0) / volume;
}
// === FINANCIAL CALCULATIONS: use ALL interactions ===
// Total cost with effective productivity of 70%
const effectiveProductivity = 0.70;
// For cost, we use all interactions EXCEPT abandonments (which do not generate conversation cost)
// noise y zombie DO generate cost (occupy agent even if little/much time)
// Usar nonAbandonRecords que ya filtra por is_abandoned y record_status
const costRecords = nonAbandonRecords;
const costVolume = costRecords.length;
// Calculate AHT for cost using all records that generate cost
let aht_for_cost = 0;
if (costVolume > 0) {
const costAhts = costRecords.map(i => i.duration_talk + i.hold_time + i.wrap_up_time);
aht_for_cost = costAhts.reduce((sum, v) => sum + v, 0) / costVolume;
}
// Real Cost = (AHT in hours × Cost/hour × Volume) / Effective Productivity
const rawCost = (aht_for_cost / 3600) * costPerHour * costVolume;
const total_cost = rawCost / effectiveProductivity;
// CPI = Coste per interaction (usando el volumen correcto)
const cpi = costVolume > 0 ? total_cost / costVolume : 0;
metrics.push({
skill,
volume,
volume_valid,
aht_mean,
aht_total, // AHT con TODAS las filas (only informative)
aht_benchmark,
aht_std,
cv_aht,
transfer_rate,
fcr_rate,
fcr_tecnico,
abandonment_rate,
total_cost,
cost_volume: costVolume,
cpi,
hold_time_mean,
cv_talk_time,
noise_count,
zombie_count,
abandon_count
});
});
// === DEBUG: Verify calculations ===
const totalVolume = metrics.reduce((sum, m) => sum + m.volume, 0);
const totalValidVolume = metrics.reduce((sum, m) => sum + m.volume_valid, 0);
const totalAbandons = metrics.reduce((sum, m) => sum + m.abandon_count, 0);
const globalAbandonRate = totalVolume > 0 ? (totalAbandons / totalVolume) * 100 : 0;
// FCR y Transfer rate globales (ponderados por volumen)
const avgFCRRate = totalVolume > 0
? metrics.reduce((sum, m) => sum + m.fcr_rate * m.volume, 0) / totalVolume
: 0;
const avgFCRTecnicoRate = totalVolume > 0
? metrics.reduce((sum, m) => sum + m.fcr_tecnico * m.volume, 0) / totalVolume
: 0;
const avgTransferRate = totalVolume > 0
? metrics.reduce((sum, m) => sum + m.transfer_rate * m.volume, 0) / totalVolume
: 0;
console.log('');
console.log('═══════════════════════════════════════════════════════════════');
console.log('📊 METRICS CALCULATED BY SKILL');
console.log('═══════════════════════════════════════════════════════════════');
console.log(`Total skills: ${metrics.length}`);
console.log(`Total volumen: ${totalVolume}`);
console.log(`Total abandonments (is_abandoned=TRUE): ${totalAbandons}`);
console.log('');
console.log('GLOBAL METRICS (weighted by volume):');
console.log(` Abandonment Rate: ${globalAbandonRate.toFixed(2)}%`);
console.log(` FCR Real (without transfer + without recontact 7d): ${avgFCRRate.toFixed(2)}%`);
console.log(` Technical FCR (only without transfer, comparable with benchmarks): ${avgFCRTecnicoRate.toFixed(2)}%`);
console.log(` Transfer Rate: ${avgTransferRate.toFixed(2)}%`);
console.log('');
console.log('Detalle por skill (top 5):');
metrics.slice(0, 5).forEach(m => {
console.log(` ${m.skill}: vol=${m.volume}, abandon=${m.abandon_count} (${m.abandonment_rate.toFixed(1)}%), FCR Real=${m.fcr_rate.toFixed(1)}%, Technical FCR=${m.fcr_tecnico.toFixed(1)}%, transfer=${m.transfer_rate.toFixed(1)}%`);
});
console.log('═══════════════════════════════════════════════════════════════');
console.log('');
// Mostrar detalle del primer skill for debug
if (metrics[0]) {
console.log('📋 Sample skill detail:', {
skill: metrics[0].skill,
volume: metrics[0].volume,
volume_valid: metrics[0].volume_valid,
transfer_rate: `${metrics[0].transfer_rate.toFixed(2)}%`,
fcr_rate: `${metrics[0].fcr_rate.toFixed(2)}%`,
abandon_count: metrics[0].abandon_count,
abandonment_rate: `${metrics[0].abandonment_rate.toFixed(2)}%`
});
}
return metrics.sort((a, b) => b.volume - a.volume); // Sort by descending volume
}
/**
* v4.4: Classify automation tier with heatmap data
*
* This function replicates the logic of clasificarTier() usando los datos
* disponibles en el heatmap. Accepts optional parameters (fcr, volume)
* for greater precision when available.
*
* Used in generateDrilldownFromHeatmap() de analysisGenerator.ts para
* asegurar consistencia between the fresh path (complete data) y la ruta
* cached (heatmap data).
*
* @param score - Agentic Readiness Score (0-10)
* @param cv - Coefficient of Variation of AHT as decimal (0.75 = 75%)
* @param transfer - Transfer rate as decimal (0.20 = 20%)
* @param fcr - FCR rate as decimal (0.80 = 80%), optional
* @param volume - Monthly volume of interactions, optional
* @returns AgenticTier ('AUTOMATE' | 'ASSIST' | 'AUGMENT' | 'HUMAN-ONLY')
*/
export function clasificarTierSimple(
score: number,
cv: number, // CV as decimal (0.75 = 75%)
transfer: number, // Transfer as decimal (0.20 = 20%)
fcr?: number, // FCR as decimal (0.80 = 80%)
volume?: number // Monthly volume
): import('../types').AgenticTier {
// critical RED FLAGS - same as clasificarTier() complete
// CV > 120% o Transfer > 50% are absolute red flags
if (cv > 1.20 || transfer > 0.50) {
return 'HUMAN-ONLY';
}
// Volume < 50/month is a red flag if we have the data
if (volume !== undefined && volume < 50) {
return 'HUMAN-ONLY';
}
// TIER 1: AUTOMATE - requires optimal metrics
// Same criterion as clasificarTier(): score >= 7.5, cv <= 0.75, transfer <= 0.20, fcr >= 0.50
const fcrOk = fcr === undefined || fcr >= 0.50; // If we dont have FCR, we assume OK
if (score >= 7.5 && cv <= 0.75 && transfer <= 0.20 && fcrOk) {
return 'AUTOMATE';
}
// TIER 2: ASSIST - suitable for copilot/assistance
if (score >= 5.5 && cv <= 0.90 && transfer <= 0.30) {
return 'ASSIST';
}
// TIER 3: AUGMENT - requires prior optimization
if (score >= 3.5) {
return 'AUGMENT';
}
// TIER 4: HUMAN-ONLY - complex process
return 'HUMAN-ONLY';
}
/**
* v3.4: Calculate drill-down metrics with new formula for Agentic Readiness Score
*
* SCORE BY QUEUE (0-10):
* - Factor 1: PREDICTABILITY (30%) - based on CV AHT
* - Factor 2: RESOLUTION (25%) - FCR (60%) + Transfer (40%)
* - Factor 3: VOLUME (25%) - based on volumen monthly
* - Factor 4: DATA QUALITY (10%) - % valid records
* - Factor 5: SIMPLICITY (10%) - based on AHT
*
* TIER CLASSIFICATION:
* - AUTOMATE: score >= 7.5, CV <= 75%, transfer <= 20%, FCR >= 50%
* - ASSIST: score >= 5.5, CV <= 90%, transfer <= 30%
* - AUGMENT: score >= 3.5
* - HUMAN-ONLY: score < 3.5 or red flags
*
* RED FLAGS (automatic HUMAN-ONLY):
* - CV > 120%
* - Transfer > 50%
* - Vol < 50/month
* - Valid < 30%
*/
export function calculateDrilldownMetrics(
interactions: RawInteraction[],
costPerHour: number
): DrilldownDataPoint[] {
const effectiveProductivity = 0.70;
// ═══════════════════════════════════════════════════════════════════════════
// FUNCTION: Calculate Score by Queue (new formula v3.4)
// ═══════════════════════════════════════════════════════════════════════════
function calculateScoreCola(
cv: number, // CV AHT (0-2+, where 1 = 100%)
fcr: number, // FCR rate (0-1)
transfer: number, // Transfer rate (0-1)
vol: number, // Monthly volume
aht: number, // AHT in seconds
validPct: number // % valid records (0-1)
): { score: number; breakdown: import('../types').AgenticScoreBreakdown } {
// FACTOR 1: PREDICTABILITY (30%) - based on CV AHT
let scorePred: number;
if (cv <= 0.50) {
scorePred = 10;
} else if (cv <= 0.65) {
scorePred = 8 + (0.65 - cv) / 0.15 * 2;
} else if (cv <= 0.75) {
scorePred = 6 + (0.75 - cv) / 0.10 * 2;
} else if (cv <= 0.90) {
scorePred = 3 + (0.90 - cv) / 0.15 * 3;
} else if (cv <= 1.10) {
scorePred = 1 + (1.10 - cv) / 0.20 * 2;
} else {
scorePred = Math.max(0, 1 - (cv - 1.10) / 0.50);
}
// FACTOR 2: RESOLUTION (25%) = FCR (60%) + Transfer (40%)
let scoreFcr: number;
if (fcr >= 0.80) {
scoreFcr = 10;
} else if (fcr >= 0.70) {
scoreFcr = 7 + (fcr - 0.70) / 0.10 * 3;
} else if (fcr >= 0.50) {
scoreFcr = 4 + (fcr - 0.50) / 0.20 * 3;
} else if (fcr >= 0.30) {
scoreFcr = 2 + (fcr - 0.30) / 0.20 * 2;
} else {
scoreFcr = fcr / 0.30 * 2;
}
let scoreTrans: number;
if (transfer <= 0.05) {
scoreTrans = 10;
} else if (transfer <= 0.15) {
scoreTrans = 7 + (0.15 - transfer) / 0.10 * 3;
} else if (transfer <= 0.25) {
scoreTrans = 4 + (0.25 - transfer) / 0.10 * 3;
} else if (transfer <= 0.40) {
scoreTrans = 1 + (0.40 - transfer) / 0.15 * 3;
} else {
scoreTrans = Math.max(0, 1 - (transfer - 0.40) / 0.30);
}
const scoreResol = scoreFcr * 0.6 + scoreTrans * 0.4;
// FACTOR 3: VOLUME (25%)
let scoreVol: number;
if (vol >= 10000) {
scoreVol = 10;
} else if (vol >= 5000) {
scoreVol = 8 + (vol - 5000) / 5000 * 2;
} else if (vol >= 1000) {
scoreVol = 5 + (vol - 1000) / 4000 * 3;
} else if (vol >= 500) {
scoreVol = 3 + (vol - 500) / 500 * 2;
} else if (vol >= 100) {
scoreVol = 1 + (vol - 100) / 400 * 2;
} else {
scoreVol = vol / 100;
}
// FACTOR 4: DATA QUALITY (10%)
let scoreCal: number;
if (validPct >= 0.90) {
scoreCal = 10;
} else if (validPct >= 0.75) {
scoreCal = 7 + (validPct - 0.75) / 0.15 * 3;
} else if (validPct >= 0.50) {
scoreCal = 4 + (validPct - 0.50) / 0.25 * 3;
} else {
scoreCal = validPct / 0.50 * 4;
}
// FACTOR 5: SIMPLICITY (10%) - based on AHT
let scoreSimp: number;
if (aht <= 180) {
scoreSimp = 10;
} else if (aht <= 300) {
scoreSimp = 8 + (300 - aht) / 120 * 2;
} else if (aht <= 480) {
scoreSimp = 5 + (480 - aht) / 180 * 3;
} else if (aht <= 720) {
scoreSimp = 2 + (720 - aht) / 240 * 3;
} else {
scoreSimp = Math.max(0, 2 - (aht - 720) / 600 * 2);
}
// SCORE TOTAL PONDERADO
const scoreTotal = (
scorePred * 0.30 +
scoreResol * 0.25 +
scoreVol * 0.25 +
scoreCal * 0.10 +
scoreSimp * 0.10
);
return {
score: Math.round(scoreTotal * 10) / 10,
breakdown: {
predictibilidad: Math.round(scorePred * 10) / 10,
resolutividad: Math.round(scoreResol * 10) / 10,
volumen: Math.round(scoreVol * 10) / 10,
qualityDatos: Math.round(scoreCal * 10) / 10,
simplicidad: Math.round(scoreSimp * 10) / 10
}
};
}
// ═══════════════════════════════════════════════════════════════════════════
// FUNCTION: Clasificar Tier del Roadmap
// ═══════════════════════════════════════════════════════════════════════════
function clasificarTier(
score: number,
cv: number, // CV as decimal (0.75 = 75%)
transfer: number, // Transfer as decimal (0.20 = 20%)
fcr: number, // FCR as decimal (0.80 = 80%)
vol: number,
validPct: number
): { tier: import('../types').AgenticTier; motivo: string } {
// RED FLAGS → HUMAN-ONLY automatic
const redFlags: string[] = [];
if (cv > 1.20) redFlags.push("CV > 120%");
if (transfer > 0.50) redFlags.push("Transfer > 50%");
if (vol < 50) redFlags.push("Vol < 50/month");
if (validPct < 0.30) redFlags.push("Data < 30% valid");
if (redFlags.length > 0) {
return {
tier: 'HUMAN-ONLY',
motivo: `Red flags: ${redFlags.join(', ')}`
};
}
// TIER 1: AUTOMATE
if (score >= 7.5 && cv <= 0.75 && transfer <= 0.20 && fcr >= 0.50) {
return {
tier: 'AUTOMATE',
motivo: `Score ${score}, optimal metrics for automation`
};
}
// TIER 2: ASSIST
if (score >= 5.5 && cv <= 0.90 && transfer <= 0.30) {
return {
tier: 'ASSIST',
motivo: `Score ${score}, suitable for copilot/assistance`
};
}
// TIER 3: AUGMENT
if (score >= 3.5) {
return {
tier: 'AUGMENT',
motivo: `Score ${score}, requires prior optimization`
};
}
// TIER 4: HUMAN-ONLY
return {
tier: 'HUMAN-ONLY',
motivo: `Score ${score}, complex process for automation`
};
}
// ═══════════════════════════════════════════════════════════════════════════
// FUNCTION: Calcular metrics de un group of interactions
// ═══════════════════════════════════════════════════════════════════════════
function calculateQueueMetrics(group: RawInteraction[]): import('../types').OriginalQueueMetrics | null {
const volume = group.length;
if (volume < 5) return null;
// Filter only VALID for CV calculation (normalize to uppercase for case-insensitive comparison)
const getStatus = (i: RawInteraction) => (i.record_status || '').toString().toUpperCase().trim();
const validRecords = group.filter(i => !i.record_status || getStatus(i) === 'VALID');
const volumeValid = validRecords.length;
if (volumeValid < 3) return null;
const validPct = volumeValid / volume;
// AHT y CV on valid records
const ahts = validRecords.map(i => i.duration_talk + i.hold_time + i.wrap_up_time);
const aht_mean = ahts.reduce((sum, v) => sum + v, 0) / volumeValid;
const aht_variance = ahts.reduce((sum, v) => sum + Math.pow(v - aht_mean, 2), 0) / volumeValid;
const aht_std = Math.sqrt(aht_variance);
const cv_aht_decimal = aht_mean > 0 ? aht_std / aht_mean : 1.5; // CV as decimal
const cv_aht_percent = cv_aht_decimal * 100; // CV como %
// Transfer y FCR (as decimals for calculation, as % for display)
const transfers = group.filter(i => i.transfer_flag === true).length;
const transfer_decimal = transfers / volume;
const transfer_percent = transfer_decimal * 100;
// FCR Real: usa fcr_real_flag del CSV (without transferencia Y without recontact 7d)
const fcrCount = group.filter(i => i.fcr_real_flag === true).length;
const fcr_decimal = fcrCount / volume;
const fcr_percent = fcr_decimal * 100;
// Technical FCR: 100 - transfer_rate (comparable with industry benchmarks)
const fcr_tecnico_percent = 100 - transfer_percent;
// Calculate score con new formula v3.4
const { score, breakdown } = calculateScoreCola(
cv_aht_decimal,
fcr_decimal,
transfer_decimal,
volume,
aht_mean,
validPct
);
// Clasificar tier
const { tier, motivo } = clasificarTier(
score,
cv_aht_decimal,
transfer_decimal,
fcr_decimal,
volume,
validPct
);
// v4.2: Convert volume from 11 months a annual para el coste
const annualVolume = (volume / 11) * 12; // 11 months → annual
const annualCost = Math.round((aht_mean / 3600) * costPerHour * annualVolume / effectiveProductivity);
return {
original_queue_id: '', // Assigned later
volume,
volumeValid,
aht_mean: Math.round(aht_mean),
cv_aht: Math.round(cv_aht_percent * 10) / 10,
transfer_rate: Math.round(transfer_percent * 10) / 10,
fcr_rate: Math.round(fcr_percent * 10) / 10,
fcr_tecnico: Math.round(fcr_tecnico_percent * 10) / 10, // Technical FCR for consistency with Summary
agenticScore: score,
scoreBreakdown: breakdown,
tier,
tierMotivo: motivo,
isPriorityCandidate: tier === 'AUTOMATE',
annualCost
};
}
// ═══════════════════════════════════════════════════════════════════════════
// STEP 1: Group by queue_skill (strategic level)
// ═══════════════════════════════════════════════════════════════════════════
const skillGroups = new Map<string, RawInteraction[]>();
for (const interaction of interactions) {
const skill = interaction.queue_skill;
if (!skill) continue;
if (!skillGroups.has(skill)) {
skillGroups.set(skill, []);
}
skillGroups.get(skill)!.push(interaction);
}
console.log(`📊 Drill-down v3.4: ${skillGroups.size} queue_skills found`);
const drilldownData: DrilldownDataPoint[] = [];
// ═══════════════════════════════════════════════════════════════════════════
// STEP 2: For each queue_skill, group by original_queue_id
// ═══════════════════════════════════════════════════════════════════════════
skillGroups.forEach((skillGroup, skill) => {
if (skillGroup.length < 10) return;
const queueGroups = new Map<string, RawInteraction[]>();
for (const interaction of skillGroup) {
const queueId = interaction.original_queue_id || 'Without identification';
if (!queueGroups.has(queueId)) {
queueGroups.set(queueId, []);
}
queueGroups.get(queueId)!.push(interaction);
}
// Calculate metrics for each original_queue_id
const originalQueues: import('../types').OriginalQueueMetrics[] = [];
queueGroups.forEach((queueGroup, queueId) => {
const metrics = calculateQueueMetrics(queueGroup);
if (metrics) {
metrics.original_queue_id = queueId;
originalQueues.push(metrics);
}
});
if (originalQueues.length === 0) return;
// Sort by descending score, then by volume
originalQueues.sort((a, b) => {
if (Math.abs(a.agenticScore - b.agenticScore) > 0.5) {
return b.agenticScore - a.agenticScore;
}
return b.volume - a.volume;
});
// ═══════════════════════════════════════════════════════════════════════
// Calculate aggregated metrics of the skill (volume-weighted average)
// ═══════════════════════════════════════════════════════════════════════
const totalVolume = originalQueues.reduce((sum, q) => sum + q.volume, 0);
const totalVolumeValid = originalQueues.reduce((sum, q) => sum + q.volumeValid, 0);
const totalCost = originalQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0);
const avgAht = originalQueues.reduce((sum, q) => sum + q.aht_mean * q.volume, 0) / totalVolume;
const avgCv = originalQueues.reduce((sum, q) => sum + q.cv_aht * q.volume, 0) / totalVolume;
const avgTransfer = originalQueues.reduce((sum, q) => sum + q.transfer_rate * q.volume, 0) / totalVolume;
const avgFcr = originalQueues.reduce((sum, q) => sum + q.fcr_rate * q.volume, 0) / totalVolume;
const avgFcrTecnico = originalQueues.reduce((sum, q) => sum + q.fcr_tecnico * q.volume, 0) / totalVolume;
// Score global weighted by volume
const avgScore = originalQueues.reduce((sum, q) => sum + q.agenticScore * q.volume, 0) / totalVolume;
// Tier predominante (el de mayor volumen)
const tierCounts = { 'AUTOMATE': 0, 'ASSIST': 0, 'AUGMENT': 0, 'HUMAN-ONLY': 0 };
originalQueues.forEach(q => {
tierCounts[q.tier] += q.volume;
});
// isPriorityCandidate si hay al menos una queue AUTOMATE
const hasAutomateQueue = originalQueues.some(q => q.tier === 'AUTOMATE');
drilldownData.push({
skill,
originalQueues,
volume: totalVolume,
volumeValid: totalVolumeValid,
aht_mean: Math.round(avgAht),
cv_aht: Math.round(avgCv * 10) / 10,
transfer_rate: Math.round(avgTransfer * 10) / 10,
fcr_rate: Math.round(avgFcr * 10) / 10,
fcr_tecnico: Math.round(avgFcrTecnico * 10) / 10, // Technical FCR para consistencia
agenticScore: Math.round(avgScore * 10) / 10,
isPriorityCandidate: hasAutomateQueue,
annualCost: totalCost
});
});
// ═══════════════════════════════════════════════════════════════════════════
// STEP 3: Ordenar y log resumen
// ═══════════════════════════════════════════════════════════════════════════
drilldownData.sort((a, b) => b.agenticScore - a.agenticScore);
// Count tiers
const allQueues = drilldownData.flatMap(s => s.originalQueues);
const tierSummary = {
AUTOMATE: allQueues.filter(q => q.tier === 'AUTOMATE').length,
ASSIST: allQueues.filter(q => q.tier === 'ASSIST').length,
AUGMENT: allQueues.filter(q => q.tier === 'AUGMENT').length,
'HUMAN-ONLY': allQueues.filter(q => q.tier === 'HUMAN-ONLY').length
};
console.log(`📊 Drill-down v3.4: ${drilldownData.length} skills, ${allQueues.length} queues`);
console.log(`🎯 Tiers: AUTOMATE=${tierSummary.AUTOMATE}, ASSIST=${tierSummary.ASSIST}, AUGMENT=${tierSummary.AUGMENT}, HUMAN-ONLY=${tierSummary['HUMAN-ONLY']}`);
return drilldownData;
}
/**
* STEP 3: Transformar metrics a dimensions (0-10)
*/
export function generateHeatmapFromMetrics(
metrics: SkillMetrics[],
avgCsat: number,
segmentMapping?: { high_value_queues: string[]; medium_value_queues: string[]; low_value_queues: string[] }
): HeatmapDataPoint[] {
console.log('🔍 generateHeatmapFromMetrics called with:', {
metricsLength: metrics.length,
firstMetric: metrics[0],
avgCsat,
hasSegmentMapping: !!segmentMapping
});
const result = metrics.map(m => {
// Dimension 1: Predictability (CV AHT)
const predictability = Math.max(0, Math.min(10, 10 - ((m.cv_aht - 0.3) / 1.2 * 10)));
// Dimension 2: Inverse Complexity (Transfer Rate)
const complexity_inverse = Math.max(0, Math.min(10, 10 - ((m.transfer_rate / 100 - 0.05) / 0.25 * 10)));
// Dimension 3: Repeatability (Volumen)
let repetitiveness = 0;
if (m.volume >= 5000) {
repetitiveness = 10;
} else if (m.volume <= 100) {
repetitiveness = 0;
} else {
// Linear interpolation between 100 y 5000
repetitiveness = ((m.volume - 100) / (5000 - 100)) * 10;
}
// Agentic Readiness Score (average ponderado)
const agentic_readiness = (
predictability * 0.40 +
complexity_inverse * 0.35 +
repetitiveness * 0.25
);
// Category
let category: 'automate' | 'assist' | 'optimize';
if (agentic_readiness >= 8.0) {
category = 'automate';
} else if (agentic_readiness >= 5.0) {
category = 'assist';
} else {
category = 'optimize';
}
// Segmentation
const segment = segmentMapping
? classifyQueue(m.skill, segmentMapping.high_value_queues, segmentMapping.medium_value_queues, segmentMapping.low_value_queues)
: 'medium' as CustomerSegment;
// Scores de performance (normalizados 0-100)
// FCR Real: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE)
// This is the strictest metric - without transfer AND without recontact in 7 days
const fcr_score = Math.round(m.fcr_rate);
// Technical FCR: only without transferencia (comparable with industry benchmarks COPC, Dimension Data)
const fcr_tecnico_score = Math.round(m.fcr_tecnico);
const aht_score = Math.round(Math.max(0, Math.min(100, 100 - ((m.aht_mean - 240) / 310) * 100)));
const csat_score = avgCsat;
const hold_time_score = Math.round(Math.max(0, Math.min(100, 100 - (m.hold_time_mean / 60) * 10)));
// Transfer rate es el % real de transferencias (NO el complemento)
const actual_transfer_rate = Math.round(m.transfer_rate);
// Abandonment rate es el % real de abandonments
const actual_abandonment_rate = Math.round(m.abandonment_rate * 10) / 10; // 1 decimal
return {
skill: m.skill,
volume: m.volume,
cost_volume: m.cost_volume, // Volumen used to calculate coste (non-abandon)
aht_seconds: Math.round(m.aht_mean),
aht_total: Math.round(m.aht_total), // AHT con TODAS las filas (only informative)
aht_benchmark: Math.round(m.aht_benchmark), // AHT traditional for comparison with industry benchmarks
annual_cost: Math.round(m.total_cost), // Coste calculado con TODOS los records (noise + zombie + valid)
cpi: m.cpi, // Coste per interaction (calculado correctamente)
metrics: {
fcr: fcr_score, // FCR Real (stricter, with recontact filter 7d)
fcr_tecnico: fcr_tecnico_score, // Technical FCR (comparable with benchmarks industria)
aht: aht_score,
csat: csat_score,
hold_time: hold_time_score,
transfer_rate: actual_transfer_rate,
abandonment_rate: actual_abandonment_rate
},
automation_readiness: Math.round(agentic_readiness * 10),
variability: {
cv_aht: Math.round(m.cv_aht * 100),
cv_talk_time: Math.round(m.cv_talk_time * 100),
cv_hold_time: Math.round(m.cv_talk_time * 80), // Approximation
transfer_rate: Math.round(m.transfer_rate)
},
dimensions: {
predictability: Math.round(predictability * 10) / 10,
complexity_inverse: Math.round(complexity_inverse * 10) / 10,
repetitiveness: Math.round(repetitiveness * 10) / 10
},
agentic_readiness: Math.round(agentic_readiness * 10) / 10,
category,
segment
};
});
console.log('📊 Heatmap data generated from real data:', {
length: result.length,
firstItem: result[0],
objectKeys: result[0] ? Object.keys(result[0]) : [],
hasMetricsObject: result[0] && typeof result[0].metrics !== 'undefined',
metricsKeys: result[0] && result[0].metrics ? Object.keys(result[0].metrics) : [],
firstMetrics: result[0] && result[0].metrics ? result[0].metrics : null,
automation_readiness: result[0] ? result[0].automation_readiness : null
});
return result;
}
/**
* Calculate global Health Score - New formula based on industry benchmarks
*
* STEP 1: Normalization of components using industry percentiles
* STEP 2: Weighting (FCR 35%, Abandono 30%, CSAT Proxy 20%, AHT 15%)
* STEP 3: Penalizaciones por umbrales criticals
*
* Benchmarks de industria (Cross-Industry):
* - Technical FCR: P10=85%, P50=68%, P90=50%
* - Abandono: P10=3%, P50=5%, P90=10%
* - AHT: P10=240s, P50=380s, P90=540s
*/
function calculateHealthScore(heatmapData: HeatmapDataPoint[]): number {
if (heatmapData.length === 0) return 50;
const totalVolume = heatmapData.reduce((sum, d) => sum + d.volume, 0);
if (totalVolume === 0) return 50;
// ═══════════════════════════════════════════════════════════════
// STEP 0: Extraer metrics ponderadas por volumen
// ═══════════════════════════════════════════════════════════════
// Technical FCR (%)
const fcrTecnico = heatmapData.reduce((sum, d) =>
sum + (d.metrics?.fcr_tecnico ?? (100 - d.metrics.transfer_rate)) * d.volume, 0) / totalVolume;
// Abandono (%)
const abandonment = heatmapData.reduce((sum, d) =>
sum + (d.metrics?.abandonment_rate || 0) * d.volume, 0) / totalVolume;
// AHT (segundos) - usar aht_seconds (AHT clean without noise/zombies)
const aht = heatmapData.reduce((sum, d) =>
sum + d.aht_seconds * d.volume, 0) / totalVolume;
// Transferencia (%)
const transferencia = heatmapData.reduce((sum, d) =>
sum + (d.metrics?.transfer_rate || 0) * d.volume, 0) / totalVolume;
// ═══════════════════════════════════════════════════════════════
// STEP 1: Component normalization (0-100 score)
// ═══════════════════════════════════════════════════════════════
// Technical FCR: P10=85%, P50=68%, P90=50%
// Higher = better
let fcrScore: number;
if (fcrTecnico >= 85) {
fcrScore = 95 + 5 * Math.min(1, (fcrTecnico - 85) / 15); // 95-100
} else if (fcrTecnico >= 68) {
fcrScore = 50 + 50 * (fcrTecnico - 68) / (85 - 68); // 50-100
} else if (fcrTecnico >= 50) {
fcrScore = 20 + 30 * (fcrTecnico - 50) / (68 - 50); // 20-50
} else {
fcrScore = Math.max(0, 20 * fcrTecnico / 50); // 0-20
}
// Abandono: P10=3%, P50=5%, P90=10%
// Lower = better (inverted)
let abandonmentScore: number;
if (abandonment <= 3) {
abandonmentScore = 95 + 5 * Math.max(0, (3 - abandonment) / 3); // 95-100
} else if (abandonment <= 5) {
abandonmentScore = 50 + 45 * (5 - abandonment) / (5 - 3); // 50-95
} else if (abandonment <= 10) {
abandonmentScore = 20 + 30 * (10 - abandonment) / (10 - 5); // 20-50
} else {
// Above P90 (critical): strong penalty
abandonmentScore = Math.max(0, 20 - 2 * (abandonment - 10)); // 0-20, decreases rapidly
}
// AHT: P10=240s, P50=380s, P90=540s
// Lower = better (inverted)
// PERO: Si FCR es under, AHT under puede indicar calls rushed (mala quality)
let ahtScore: number;
if (aht <= 240) {
// Por deunder de P10 (excellent efficiency)
// Si FCR > 65%, es genuinamente eficiente; si no, puede ser rushed
if (fcrTecnico > 65) {
ahtScore = 95 + 5 * Math.max(0, (240 - aht) / 60); // 95-100
} else {
ahtScore = 70; // Cap score si FCR es under (posible rushed calls)
}
} else if (aht <= 380) {
ahtScore = 50 + 45 * (380 - aht) / (380 - 240); // 50-95
} else if (aht <= 540) {
ahtScore = 20 + 30 * (540 - aht) / (540 - 380); // 20-50
} else {
ahtScore = Math.max(0, 20 * (600 - aht) / 60); // 0-20
}
// CSAT Proxy: Calculado from FCR + Abandono
// Sin datos reales de CSAT, usamos proxy
const csatProxy = 0.60 * fcrScore + 0.40 * abandonmentScore;
// ═══════════════════════════════════════════════════════════════
// STEP 2: Aplicar pesos
// FCR 35% + Abandono 30% + CSAT Proxy 20% + AHT 15%
// ═══════════════════════════════════════════════════════════════
const subtotal = (
fcrScore * 0.35 +
abandonmentScore * 0.30 +
csatProxy * 0.20 +
ahtScore * 0.15
);
// ═══════════════════════════════════════════════════════════════
// STEP 3: Calcular penalizesciones
// ═══════════════════════════════════════════════════════════════
let penalties = 0;
// Penalty for abandonment critical (>10%)
if (abandonment > 10) {
penalties += 10;
}
// Penalty for transferencia alta (>20%)
if (transferencia > 20) {
penalties += 5;
}
// Combo penalty: Abandono high + FCR under
// Indicates systemic problems of capacity AND resolution
if (abandonment > 8 && fcrTecnico < 78) {
penalties += 5;
}
// ═══════════════════════════════════════════════════════════════
// STEP 4: Final Score
// ═══════════════════════════════════════════════════════════════
const finalScore = Math.max(0, Math.min(100, subtotal - penalties));
// Debug logging
console.log('📊 Health Score Calculation:', {
inputs: { fcrTecnico: fcrTecnico.toFixed(1), abandonment: abandonment.toFixed(1), aht: Math.round(aht), transferencia: transferencia.toFixed(1) },
scores: { fcrScore: fcrScore.toFixed(1), abandonmentScore: abandonmentScore.toFixed(1), ahtScore: ahtScore.toFixed(1), csatProxy: csatProxy.toFixed(1) },
weighted: { subtotal: subtotal.toFixed(1), penalties, final: Math.round(finalScore) }
});
return Math.round(finalScore);
}
/**
* v4.0: Generate 7 viable dimensions from real data
* Airline sector benchmarks: AHT P50=380s, FCR=70%, Abandono=5%, Ratio P90/P50 healthy<2.0
*/
function generateDimensionsFromRealData(
interactions: RawInteraction[],
metrics: SkillMetrics[],
avgCsat: number,
avgAHT: number,
hourlyDistribution: { hourly: number[]; off_hours_pct: number; peak_hours: number[] },
globalCPI: number // CPI calculated centrally from heatmapData
): DimensionAnalysis[] {
const totalVolume = interactions.length;
const avgCV = metrics.reduce((sum, m) => sum + m.cv_aht, 0) / metrics.length;
const avgTransferRate = metrics.reduce((sum, m) => sum + m.transfer_rate, 0) / metrics.length;
const avgHoldTime = metrics.reduce((sum, m) => sum + m.hold_time_mean, 0) / metrics.length;
const totalCost = metrics.reduce((sum, m) => sum + m.total_cost, 0);
// Technical FCR (100 - transfer_rate, weighted by volume) - comparable with benchmarks
const totalVolumeForFCR = metrics.reduce((sum, m) => sum + m.volume_valid, 0);
const avgFCR = totalVolumeForFCR > 0
? metrics.reduce((sum, m) => sum + (m.fcr_tecnico * m.volume_valid), 0) / totalVolumeForFCR
: 0;
// Calculate ratio P90/P50 approximated from CV
const avgRatio = 1 + avgCV * 1.5; // Approximation: ratio ≈ 1 + 1.5*CV
// === SCORE EFICIENCIA: Scale based on ratio P90/P50 ===
// <1.5 = 100pts, 1.5-2.0 = 70pts, 2.0-2.5 = 50pts, 2.5-3.0 = 30pts, >3.0 = 20pts
let efficiencyScore: number;
if (avgRatio < 1.5) efficiencyScore = 100;
else if (avgRatio < 2.0) efficiencyScore = 70 + (2.0 - avgRatio) * 60; // 70-100
else if (avgRatio < 2.5) efficiencyScore = 50 + (2.5 - avgRatio) * 40; // 50-70
else if (avgRatio < 3.0) efficiencyScore = 30 + (3.0 - avgRatio) * 40; // 30-50
else efficiencyScore = 20;
// === VOLUMETRY SCORE: Based on % off hours and peak/valley ratio ===
// % off hours >30% penalizes, peak/valley ratio >3x penalizes
const offHoursPct = hourlyDistribution.off_hours_pct;
// Calculate peak/valley ratio (consistent with backendMapper.ts)
const hourlyValues = hourlyDistribution.hourly.filter(v => v > 0);
const peakVolume = hourlyValues.length > 0 ? Math.max(...hourlyValues) : 0;
const valleyVolume = hourlyValues.length > 0 ? Math.min(...hourlyValues) : 1;
const peakValleyRatio = valleyVolume > 0 ? peakVolume / valleyVolume : 1;
// Volumetry score: 100 base, penalize by off hours y peak/valley ratio
// NOTA: Formulas synchronized with backendMapper.ts buildVolumetryDimension()
let volumetryScore = 100;
// Penalty for off hours (same formula as backendMapper)
if (offHoursPct > 30) {
volumetryScore -= Math.min(40, (offHoursPct - 30) * 2); // -2 pts per each % above 30%
} else if (offHoursPct > 20) {
volumetryScore -= (offHoursPct - 20); // -1 pt per each % between 20-30%
}
// Penalty for peak/valley ratio high (same formula as backendMapper)
if (peakValleyRatio > 5) {
volumetryScore -= 30;
} else if (peakValleyRatio > 3) {
volumetryScore -= 20;
} else if (peakValleyRatio > 2) {
volumetryScore -= 10;
}
volumetryScore = Math.max(0, Math.min(100, Math.round(volumetryScore)));
// === CPI: Use the centralized value passed as parameter ===
// globalCPI was already calculated in generateAnalysisFromRealData from heatmapData
// This ensures consistency with ExecutiveSummaryTab
const costPerInteraction = globalCPI;
// Calculate Agentic Score
const predictability = Math.max(0, Math.min(10, 10 - ((avgCV - 0.3) / 1.2 * 10)));
const complexityInverse = Math.max(0, Math.min(10, 10 - (avgTransferRate / 10)));
const repetitivity = Math.min(10, totalVolume / 500);
const agenticScore = predictability * 0.30 + complexityInverse * 0.30 + repetitivity * 0.25 + 2.5;
// Determine percentile of Eficiencia based on benchmark airline sector (ratio <2.0 healthy)
const efficiencyPercentile = avgRatio < 2.0 ? 75 : avgRatio < 2.5 ? 50 : avgRatio < 3.0 ? 35 : 20;
// Determine percentile of FCR based on benchmark airline sector (70%)
const fcrPercentile = avgFCR >= 70 ? 75 : avgFCR >= 60 ? 50 : avgFCR >= 50 ? 35 : 20;
return [
// 1. VOLUMETRY & DISTRIBUTION
{
id: 'volumetry_distribution',
name: 'volumetry_distribution',
title: 'Volumetry & Distribution',
score: volumetryScore,
percentile: offHoursPct <= 20 ? 80 : offHoursPct <= 30 ? 60 : 40,
summary: `${offHoursPct.toFixed(1)}% off hours. Ratio pico/valle: ${peakValleyRatio.toFixed(1)}x. ${totalVolume.toLocaleString('es-ES')} total interactions.`,
kpi: { label: 'Off Hours', value: `${offHoursPct.toFixed(0)}%` },
icon: BarChartHorizontal,
distribution_data: {
hourly: hourlyDistribution.hourly,
off_hours_pct: hourlyDistribution.off_hours_pct,
peak_hours: hourlyDistribution.peak_hours
}
},
// 2. OPERATIONAL EFFICIENCY - KPI principal: AHT P50 (industry standard)
{
id: 'operational_efficiency',
name: 'operational_efficiency',
title: 'Operational Efficiency',
score: Math.round(efficiencyScore),
percentile: efficiencyPercentile,
summary: `AHT P50: ${avgAHT}s (benchmark: 300s). Ratio P90/P50: ${avgRatio.toFixed(2)} (benchmark: <2.0). Hold time: ${Math.round(avgHoldTime)}s.`,
kpi: { label: 'AHT P50', value: `${avgAHT}s` },
icon: Zap
},
// 3. EFFECTIVENESS & RESOLUTION (Technical FCR = 100 - transfer_rate)
{
id: 'effectiveness_resolution',
name: 'effectiveness_resolution',
title: 'Effectiveness & Resolution',
score: avgFCR >= 90 ? 100 : avgFCR >= 85 ? 80 : avgFCR >= 80 ? 60 : avgFCR >= 75 ? 40 : 20,
percentile: fcrPercentile,
summary: `Technical FCR: ${avgFCR.toFixed(1)}% (benchmark: 85-90%). Transfer: ${avgTransferRate.toFixed(1)}%.`,
kpi: { label: 'Technical FCR', value: `${Math.round(avgFCR)}%` },
icon: Target
},
// 4. COMPLEXITY COMPLEJIDAD & PREDICTABILITY PREDICTABILITY - KPI principal: CV AHT (industry standard for predictability)
{
id: 'complexity_predictability',
name: 'complexity_predictability',
title: 'Complexity Complejidad & Predictibilidad Predictability',
score: avgCV <= 0.75 ? 100 : avgCV <= 1.0 ? 80 : avgCV <= 1.25 ? 60 : avgCV <= 1.5 ? 40 : 20, // Based on CV AHT
percentile: avgCV <= 0.75 ? 75 : avgCV <= 1.0 ? 55 : avgCV <= 1.25 ? 40 : 25,
summary: `CV AHT: ${(avgCV * 100).toFixed(0)}% (benchmark: <75%). Hold time: ${Math.round(avgHoldTime)}s. ${avgCV <= 0.75 ? 'High predictability for WFM.' : avgCV <= 1.0 ? 'Acceptable predictability.' : 'High variability, complicates planning.'}`,
kpi: { label: 'CV AHT', value: `${(avgCV * 100).toFixed(0)}%` },
icon: Brain
},
// 5. SATISFACTION - CSAT
{
id: 'customer_satisfaction',
name: 'customer_satisfaction',
title: 'Customer Satisfaction',
score: avgCsat > 0 ? Math.round(avgCsat) : 0,
percentile: avgCsat > 0 ? (avgCsat >= 80 ? 70 : avgCsat >= 60 ? 50 : 30) : 0,
summary: avgCsat > 0
? `CSAT: ${avgCsat.toFixed(1)}/100. ${avgCsat >= 80 ? 'High satisfaction.' : avgCsat >= 60 ? 'Acceptable satisfaction.' : 'Requires attention.'}`
: 'CSAT: Not available in dataset. Consider implementing post-call surveys.',
kpi: { label: 'CSAT', value: avgCsat > 0 ? `${Math.round(avgCsat)}/100` : 'N/A' },
icon: Smile
},
// 6. ECONOMY - CPI (airline benchmark: p25=2.20, p50=3.50, p75=4.50, p90=5.50)
{
id: 'economy_cpi',
name: 'economy_cpi',
title: 'Operational Economy',
// Score based on percentiles airlines (CPI inverted: lower = better)
score: costPerInteraction <= 2.20 ? 100 : costPerInteraction <= 3.50 ? 80 : costPerInteraction <= 4.50 ? 60 : costPerInteraction <= 5.50 ? 40 : 20,
percentile: costPerInteraction <= 2.20 ? 90 : costPerInteraction <= 3.50 ? 70 : costPerInteraction <= 4.50 ? 50 : costPerInteraction <= 5.50 ? 25 : 10,
summary: `CPI: €${costPerInteraction.toFixed(2)} per interaction. Annual cost: €${totalCost.toLocaleString('es-ES')}. Airline sector benchmark: €3.50.`,
kpi: { label: 'Cost/Interaction', value: `${costPerInteraction.toFixed(2)}` },
icon: DollarSign
},
// 7. AGENTIC READINESS
{
id: 'agentic_readiness',
name: 'agentic_readiness',
title: 'Agentic Readiness',
score: Math.round(agenticScore * 10),
percentile: agenticScore >= 7 ? 75 : agenticScore >= 5 ? 55 : 35,
summary: `Score: ${agenticScore.toFixed(1)}/10. ${agenticScore >= 8 ? 'Excellent for automation.' : agenticScore >= 5 ? 'Candidate for AI assistance.' : 'Requires prior optimization.'}`,
kpi: { label: 'Score', value: `${agenticScore.toFixed(1)}/10` },
icon: Bot
}
];
}
/**
* Calcular Agentic Readiness from datos reales
* Score = Σ(factor_i × weight_i) with 6 unique factors
*/
function calculateAgenticReadinessFromRealData(metrics: SkillMetrics[]): AgenticReadinessResult {
const totalVolume = metrics.reduce((sum, m) => sum + m.volume, 0);
const avgCV = metrics.reduce((sum, m) => sum + m.cv_aht, 0) / metrics.length;
const avgCVTalk = metrics.reduce((sum, m) => sum + m.cv_talk_time, 0) / metrics.length;
const avgTransferRate = metrics.reduce((sum, m) => sum + m.transfer_rate, 0) / metrics.length;
const totalCost = metrics.reduce((sum, m) => sum + m.total_cost, 0);
// === 6 UNIQUE FACTORS ===
// 1. Predictibilidad (CV AHT) - Peso 25%
// Score = 10 - (CV_AHT × 10). CV < 30% = Score > 7
const predictability = Math.max(0, Math.min(10, 10 - (avgCV * 10)));
// 2. Simplicidad Operativa (Transfer Rate) - Peso 20%
// Score = 10 - (Transfer / 5). Transfer < 10% = Score > 8
const complexity_inverse = Math.max(0, Math.min(10, 10 - (avgTransferRate / 5)));
// 3. Volumen e Impacto - Peso 15%
// Score lineal: < 100 = 0, 100-5000 interpolation, > 5000 = 10
let repetitiveness = 0;
if (totalVolume >= 5000) repetitiveness = 10;
else if (totalVolume <= 100) repetitiveness = 0;
else repetitiveness = ((totalVolume - 100) / (5000 - 100)) * 10;
// 4. Structuring (CV Talk Time) - Peso 15%
// Score = 10 - (CV_Talk × 8). Low variability = high structuring
const estructuracion = Math.max(0, Math.min(10, 10 - (avgCVTalk * 8)));
// 5. Estabilidad (peak/valley ratio simplificado) - Peso 10%
// Simplification: based on CV general as proxy
const estabilidad = Math.max(0, Math.min(10, 10 - (avgCV * 5)));
// 6. ROI Potencial (based on coste y volumen) - Peso 15%
// Score = min(10, log10(Coste) - 2) para costes > €100
const roiPotencial = totalCost > 100
? Math.max(0, Math.min(10, (Math.log10(totalCost) - 2) * 2.5))
: 0;
// Final weighted Score: (10×0.25)+(5×0.20)+(10×0.15)+(0×0.15)+(10×0.10)+(10×0.15)
const score = Math.round((
predictability * 0.25 +
complexity_inverse * 0.20 +
repetitiveness * 0.15 +
estructuracion * 0.15 +
estabilidad * 0.10 +
roiPotencial * 0.15
) * 10) / 10;
// Tier based on score (umbrales actualizados)
let tier: TierKey;
if (score >= 6) tier = 'gold'; // Listo para Copilot
else if (score >= 4) tier = 'silver'; // Optimizar primero
else tier = 'bronze'; // Requires human management
// Sub-factors with unique descriptions and specific methodologies
const sub_factors: SubFactor[] = [
{
name: 'predictibilidad',
displayName: 'Predictibilidad',
score: Math.round(predictability * 10) / 10,
weight: 0.25,
description: `CV AHT: ${Math.round(avgCV * 100)}%. Score = 10 - (CV × 10)`
},
{
name: 'complejidad_inversa',
displayName: 'Simplicidad Operativa',
score: Math.round(complexity_inverse * 10) / 10,
weight: 0.20,
description: `Transfer rate: ${Math.round(avgTransferRate)}%. Score = 10 - (Transfer / 5)`
},
{
name: 'repetitividad',
displayName: 'Volumen e Impacto',
score: Math.round(repetitiveness * 10) / 10,
weight: 0.15,
description: `${totalVolume.toLocaleString('es-ES')} interactions. Scale lineal 100-5000`
},
{
name: 'estructuracion',
displayName: 'Structuring',
score: Math.round(estructuracion * 10) / 10,
weight: 0.15,
description: `CV Talk: ${Math.round(avgCVTalk * 100)}%. Score = 10 - (CV_Talk × 8)`
},
{
name: 'estabilidad',
displayName: 'Estabilidad Temporal',
score: Math.round(estabilidad * 10) / 10,
weight: 0.10,
description: `Based on general variability. Score = 10 - (CV × 5)`
},
{
name: 'roi_potencial',
displayName: 'ROI Potencial',
score: Math.round(roiPotencial * 10) / 10,
weight: 0.15,
description: `Annual cost: €${totalCost.toLocaleString('es-ES')}. Logarithmic score`
}
];
// Interpretation based on umbrales actualizados
let interpretation: string;
if (score >= 6) {
interpretation = 'Listo para Copilot. Procesos con predictibilidad y simplicidad suficientes para asistencia IA.';
} else if (score >= 4) {
interpretation = 'Requires optimization. Standardize processes and reduce variability before implementing AI.';
} else {
interpretation = 'Human management recommended. Complex or variable processes that require human intervention.';
}
return {
score,
sub_factors,
tier,
confidence: totalVolume > 1000 ? 'high' as const : totalVolume > 500 ? 'medium' as const : 'low' as const,
interpretation
};
}
/**
* Generar findings from datos reales - SOLO datos calculados del dataset
*/
function generateFindingsFromRealData(
metrics: SkillMetrics[],
interactions: RawInteraction[],
hourlyDistribution?: { hourly: number[]; off_hours_pct: number; peak_hours: number[] }
): Finding[] {
const findings: Finding[] = [];
const totalVolume = interactions.length;
// Calculate metrics globales
const avgCV = metrics.reduce((sum, m) => sum + m.cv_aht, 0) / metrics.length;
const avgTransferRate = metrics.reduce((sum, m) => sum + m.transfer_rate, 0) / metrics.length;
const avgRatio = 1 + avgCV * 1.5;
// Calculate abandonment real
const totalAbandoned = metrics.reduce((sum, m) => sum + m.abandon_count, 0);
const abandonRate = totalVolume > 0 ? (totalAbandoned / totalVolume) * 100 : 0;
// Finding 0: Alto off hours volume - oportunidad para agent virtual
const offHoursPct = hourlyDistribution?.off_hours_pct ?? 0;
if (offHoursPct > 20) {
const offHoursVolume = Math.round(totalVolume * offHoursPct / 100);
findings.push({
type: offHoursPct > 30 ? 'critical' : 'warning',
title: 'Alto Volumen Off Hours',
text: `${offHoursPct.toFixed(0)}% of interactions off hours (8-19h)`,
dimensionId: 'volumetry_distribution',
description: `${offHoursVolume.toLocaleString()} interactions (${offHoursPct.toFixed(1)}%) occur outside business hours. Ideal opportunity to implement virtual agents 24/7.`,
impact: offHoursPct > 30 ? 'high' : 'medium'
});
}
// Finding 1: Ratio P90/P50 if outside benchmark
if (avgRatio > 2.0) {
findings.push({
type: avgRatio > 3.0 ? 'critical' : 'warning',
title: 'Ratio P90/P50 elevado',
text: `Ratio P90/P50: ${avgRatio.toFixed(2)}`,
dimensionId: 'operational_efficiency',
description: `Ratio P90/P50 de ${avgRatio.toFixed(2)} exceeds the benchmark of 2.0. Indicates high dispersion in handling times.`
});
}
// Finding 2: Variabilidad alta (CV AHT)
const highVariabilitySkills = metrics.filter(m => m.cv_aht > 0.45);
if (highVariabilitySkills.length > 0) {
findings.push({
type: 'warning',
title: 'Alta Variabilidad AHT',
text: `${highVariabilitySkills.length} skills con CV > 45%`,
dimensionId: 'complexity_predictability',
description: `${highVariabilitySkills.length} de ${metrics.length} skills show CV AHT > 45%, suggesting poorly standardized processes.`
});
}
// Finding 3: Transferencias altas
if (avgTransferRate > 15) {
findings.push({
type: avgTransferRate > 25 ? 'critical' : 'warning',
title: 'Tasa de Transferencia',
text: `Transfer rate: ${avgTransferRate.toFixed(1)}%`,
dimensionId: 'complexity_predictability',
description: `Tasa de transferencia average de ${avgTransferRate.toFixed(1)}% indicates need for training or routing.`
});
}
// Finding 4: Abandono si supera benchmark
if (abandonRate > 5) {
findings.push({
type: abandonRate > 10 ? 'critical' : 'warning',
title: 'Tasa de Abandono',
text: `Abandono: ${abandonRate.toFixed(1)}%`,
dimensionId: 'effectiveness_resolution',
description: `Tasa de abandonment de ${abandonRate.toFixed(1)}% exceeds the benchmark of 5%. Review capacity and wait times.`
});
}
// Finding 5: Volume concentration (only if there are enough skills)
if (metrics.length >= 3) {
const topSkill = metrics[0];
const topSkillPct = (topSkill.volume / totalVolume) * 100;
if (topSkillPct > 30) {
findings.push({
type: 'info',
title: 'Volume Concentration',
text: `${topSkill.skill}: ${topSkillPct.toFixed(0)}% del total`,
dimensionId: 'volumetry_distribution',
description: `El skill "${topSkill.skill}" concentrates ${topSkillPct.toFixed(1)}% of total volume (${topSkill.volume.toLocaleString()} interactions).`
});
}
}
return findings;
}
/**
* Generar recomendaciones from datos reales
*/
function generateRecommendationsFromRealData(
metrics: SkillMetrics[],
hourlyDistribution?: { hourly: number[]; off_hours_pct: number; peak_hours: number[] },
totalVolume?: number
): Recommendation[] {
const recommendations: Recommendation[] = [];
// Priority recommendation: Virtual agent for off hours
const offHoursPct = hourlyDistribution?.off_hours_pct ?? 0;
const volume = totalVolume ?? metrics.reduce((sum, m) => sum + m.volume, 0);
if (offHoursPct > 20) {
const offHoursVolume = Math.round(volume * offHoursPct / 100);
const estimatedContainment = offHoursPct > 30 ? 60 : 45; // % que puede resolver el bot
const estimatedSavings = Math.round(offHoursVolume * estimatedContainment / 100);
recommendations.push({
priority: 'high',
title: 'Implement Virtual Agent 24/7',
text: `Desplegar agent virtual para atender ${offHoursPct.toFixed(0)}% of interactions off hours`,
description: `${offHoursVolume.toLocaleString()} interactions occur outside business hours (19:00-08:00). A virtual agent can resolve ~${estimatedContainment}% of these queries automatically, freeing human resources and improving customer experience with immediate attention 24/7.`,
dimensionId: 'volumetry_distribution',
impact: `Containment potential: ${estimatedSavings.toLocaleString()} interactions/period`,
timeline: '1-3 months'
});
}
const highVariabilitySkills = metrics.filter(m => m.cv_aht > 0.45);
if (highVariabilitySkills.length > 0) {
recommendations.push({
priority: 'high',
title: 'Estandarizar Procesos',
text: `Create guides and scripts for the ${highVariabilitySkills.length} skills with high variability`,
description: `Create guides and scripts for the ${highVariabilitySkills.length} skills with high variability.`,
impact: 'Reduction of 20-30% en AHT'
});
}
const highVolumeSkills = metrics.filter(m => m.volume > 500);
if (highVolumeSkills.length > 0) {
recommendations.push({
priority: 'high',
title: 'Automate Skills de Alto Volumen',
text: `Implement bots for the ${highVolumeSkills.length} skills con > 500 interactions`,
description: `Implement bots for the ${highVolumeSkills.length} skills con > 500 interactions.`,
impact: 'Ahorro estimado del 40-60%'
});
}
return recommendations;
}
/**
* v3.3: Generar opportunities from drilldownData (based on queues with CV < 75%)
* Las oportunidades se clasifican en 3 categorys:
* - Automatizar: Colas con CV < 75% (estables, listas para IA)
* - Asistir: Colas con CV 75-100% (necesitan copilot)
* - Optimize: Queues with CV > 100% (need standardization first)
*/
/**
* v3.5: Calculate realistic savings using TCO formula by tier
*
* TCO formula by tier:
* - AUTOMATE (Tier 1): 70% containment → savings = vol_annual × 0.70 × (CPI_human - CPI_ai)
* - ASSIST (Tier 2): 30% efficiency → savings = vol_annual × 0.30 × (CPI_human - CPI_copilot)
* - AUGMENT (Tier 3): 15% optimization → savings = vol_annual × 0.15 × (CPI_human - CPI_optimized)
* - HUMAN-ONLY (Tier 4): 0% → without ahorro
*
* Costes per interaction (CPI):
* - CPI_human: Se calcula from AHT y cost_per_hour (~€4-5/interaction)
* - CPI_ai: €0.15/interaction (chatbot/IVR)
* - CPI_copilot: ~60% del CPI human (agent asistido)
* - CPI_optimized: ~85% del CPI human (mejora marginal)
*/
/**
* v3.6: CPI constants for TCO savings calculation
* Values aligned with Beyond methodology
*/
const CPI_CONFIG = {
CPI_HUMANO: 2.33, // €/interaction - coste actual agent human
CPI_BOT: 0.15, // €/interaction - cost bot/automation
CPI_ASSIST: 1.50, // €/interaction - coste con copilot
CPI_AUGMENT: 2.00, // €/interaction - coste optimizado
// Success/containment rates by tier
RATE_AUTOMATE: 0.70, // 70% containment in automation
RATE_ASSIST: 0.30, // 30% efficiency en asistencia
RATE_AUGMENT: 0.15 // 15% improvement in optimization
};
// Data period: the volume in the data corresponds to 11 months, is not monthly
const DATA_PERIOD_MONTHS = 11;
/**
* v4.2: Calculate realistic TCO savings using explicit formula with fixed CPI
* IMPORTANT: The volume in the data corresponds to 11 months, therefore:
* - First we calculate monthly volume: Vol / 11
* - Then we annualize: × 12
* Formulas:
* - AUTOMATE: (Vol/11) × 12 × 70% × (CPI_human - CPI_bot)
* - ASSIST: (Vol/11) × 12 × 30% × (CPI_human - CPI_assist)
* - AUGMENT: (Vol/11) × 12 × 15% × (CPI_human - CPI_augment)
* - HUMAN-ONLY: 0€
*/
function calculateRealisticSavings(
volume: number,
_annualCost: number, // Kept for compatibility but not used
tier: 'AUTOMATE' | 'ASSIST' | 'AUGMENT' | 'HUMAN-ONLY'
): number {
if (volume === 0) return 0;
const { CPI_HUMANO, CPI_BOT, CPI_ASSIST, CPI_AUGMENT, RATE_AUTOMATE, RATE_ASSIST, RATE_AUGMENT } = CPI_CONFIG;
// Convert volume from period (11 months) to annual volume
const annualVolume = (volume / DATA_PERIOD_MONTHS) * 12;
switch (tier) {
case 'AUTOMATE':
// Savings = VolAnual × 70% × (CPI_human - CPI_bot)
return Math.round(annualVolume * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT));
case 'ASSIST':
// Savings = VolAnual × 30% × (CPI_human - CPI_assist)
return Math.round(annualVolume * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST));
case 'AUGMENT':
// Savings = VolAnual × 15% × (CPI_human - CPI_augment)
return Math.round(annualVolume * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT));
case 'HUMAN-ONLY':
default:
return 0;
}
}
export function generateOpportunitiesFromDrilldown(drilldownData: DrilldownDataPoint[], costPerHour: number): Opportunity[] {
// v4.3: Top 10 initiatives by economic potential (all tiers, not only AUTOMATE)
// Each queue = 1 bubble with its real score and real TCO savings according to its tier
// Extract all queues with their parent skill (exclude HUMAN-ONLY, have no savings)
const allQueues = drilldownData.flatMap(skill =>
skill.originalQueues
.filter(q => q.tier !== 'HUMAN-ONLY') // HUMAN-ONLY generates no savings
.map(q => ({
...q,
skillName: skill.skill
}))
);
if (allQueues.length === 0) {
console.warn('⚠️ No queues with savings potential to show in Opportunity Matrix');
return [];
}
// Calculate TCO savings per individual queue according to its tier
const queuesWithSavings = allQueues.map(q => {
const savings = calculateRealisticSavings(q.volume, q.annualCost || 0, q.tier);
return { ...q, savings };
});
// Sort by descending savings
queuesWithSavings.sort((a, b) => b.savings - a.savings);
// Calculate max savings to scale impact to 0-10
const maxSavings = Math.max(...queuesWithSavings.map(q => q.savings), 1);
// Mapping of tier to dimensionId and customer_segment
const tierToDimension: Record<string, string> = {
'AUTOMATE': 'agentic_readiness',
'ASSIST': 'effectiveness_resolution',
'AUGMENT': 'complexity_predictability'
};
const tierToSegment: Record<string, CustomerSegment> = {
'AUTOMATE': 'high',
'ASSIST': 'medium',
'AUGMENT': 'low'
};
// Generate individual opportunities (TOP 10 by economic potential)
const opportunities: Opportunity[] = queuesWithSavings
.slice(0, 10)
.map((q, idx) => {
// Impact: savings scaled to 0-10
const impactRaw = (q.savings / maxSavings) * 10;
const impact = Math.max(1, Math.min(10, Math.round(impactRaw * 10) / 10));
// Feasibility: direct agenticScore (already is 0-10)
const feasibility = Math.round(q.agenticScore * 10) / 10;
// Nombre con prefijo de tier para claridad
const tierPrefix = q.tier === 'AUTOMATE' ? '🤖' : q.tier === 'ASSIST' ? '🤝' : '📚';
const shortName = q.original_queue_id.length > 22
? `${tierPrefix} ${q.original_queue_id.substring(0, 19)}...`
: `${tierPrefix} ${q.original_queue_id}`;
return {
id: `opp-${q.tier.toLowerCase()}-${idx + 1}`,
name: shortName,
impact,
feasibility,
savings: q.savings,
dimensionId: tierToDimension[q.tier] || 'agentic_readiness',
customer_segment: tierToSegment[q.tier] || 'medium'
};
});
console.log(`📊 Opportunity Matrix: Top ${opportunities.length} initiatives by economic potential (de ${allQueues.length} queues with ahorro)`);
return opportunities;
}
/**
* v3.5: Generar roadmap from drilldownData usando sistema de Tiers
* Initiatives structured in 3 phases based on Tier classification:
* - Phase 1 (Automate): AUTOMATE tier Queues - direct AI implementation (70% containment)
* - Phase 2 (Assist): Colas tier ASSIST - copilot y asistencia (30% efficiency)
* - Phase 3 (Augment): AUGMENT/HUMAN-ONLY tier Queues - standardization first (15%)
*/
export function generateRoadmapFromDrilldown(drilldownData: DrilldownDataPoint[], costPerHour: number): RoadmapInitiative[] {
const initiatives: RoadmapInitiative[] = [];
let initCounter = 1;
// Extract y clasificar all las queues por TIER
const allQueues = drilldownData.flatMap(skill =>
skill.originalQueues.map(q => ({
...q,
skillName: skill.skill
}))
);
// v3.5: Clasificar por TIER
const automateQueues = allQueues.filter(q => q.tier === 'AUTOMATE');
const assistQueues = allQueues.filter(q => q.tier === 'ASSIST');
const augmentQueues = allQueues.filter(q => q.tier === 'AUGMENT');
const humanQueues = allQueues.filter(q => q.tier === 'HUMAN-ONLY');
// Calculate metrics por tier
const automateVolume = automateQueues.reduce((sum, q) => sum + q.volume, 0);
const automateCost = automateQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0);
const assistVolume = assistQueues.reduce((sum, q) => sum + q.volume, 0);
const assistCost = assistQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0);
const augmentVolume = augmentQueues.reduce((sum, q) => sum + q.volume, 0);
const augmentCost = augmentQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0);
// Helper para obtain top skills por volumen
const getTopSkillNamonth = (queues: typeof allQueues, limit: number = 3): string[] => {
const skillVolumonth = new Map<string, number>();
queues.forEach(q => {
skillVolumonth.set(q.skillName, (skillVolumonth.get(q.skillName) || 0) + q.volume);
});
return Array.from(skillVolumonth.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([name]) => name);
};
// ============ PHASE 1: AUTOMATE (Tier AUTOMATE - 70% containment) ============
if (automateQueues.length > 0) {
const topSkills = getTopSkillNamonth(automateQueues);
const avgScore = automateQueues.reduce((sum, q) => sum + q.agenticScore, 0) / automateQueues.length;
const avgCv = automateQueues.reduce((sum, q) => sum + q.cv_aht, 0) / automateQueues.length;
// v3.5: Ahorro REALISTA con TCO
const realisticSavings = calculateRealisticSavings(automateVolume, automateCost, 'AUTOMATE');
// Chatbot para queues with score muy high (>8)
const highScoreQueues = automateQueues.filter(q => q.agenticScore >= 8);
if (highScoreQueues.length > 0) {
const hsVolume = highScoreQueues.reduce((sum, q) => sum + q.volume, 0);
const hsCost = highScoreQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0);
const hsSavings = calculateRealisticSavings(hsVolume, hsCost, 'AUTOMATE');
initiatives.push({
id: `init-${initCounter++}`,
name: `Chatbot IA para ${highScoreQueues.length} queues score ≥8`,
phase: RoadmapPhase.Automate,
timeline: 'Q1 2026',
investment: Math.round(hsSavings * 0.3), // Investment = 30% of savings
resources: ['1x Bot Developer', 'API Integration', 'QA Team'],
dimensionId: 'agentic_readiness',
risk: 'low',
skillsImpacted: getTopSkillNamonth(highScoreQueues, 2),
volumeImpacted: hsVolume,
kpiObjective: `Contain 70% of volume via chatbot`,
rationale: `${highScoreQueues.length} queues tier AUTOMATE con score average ${avgScore.toFixed(1)}/10. Optimal metrics for complete automation.`,
savingsDetail: `70% containment × (CPI human - CPI AI) = ${hsSavings.toLocaleString()}€/year`,
estimatedSavings: hsSavings,
resourceHours: 400
});
}
// IVR para resto de queues AUTOMATE
const otherAutomateQueues = automateQueues.filter(q => q.agenticScore < 8);
if (otherAutomateQueues.length > 0) {
const oaVolume = otherAutomateQueues.reduce((sum, q) => sum + q.volume, 0);
const oaCost = otherAutomateQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0);
const oaSavings = calculateRealisticSavings(oaVolume, oaCost, 'AUTOMATE');
initiatives.push({
id: `init-${initCounter++}`,
name: `IVR inteligente para ${otherAutomateQueues.length} queues AUTOMATE`,
phase: RoadmapPhase.Automate,
timeline: 'Q2 2026',
investment: Math.round(oaSavings * 0.25),
resources: ['1x Voice UX Designer', 'Integration Team', 'QA'],
dimensionId: 'agentic_readiness',
risk: 'low',
skillsImpacted: getTopSkillNamonth(otherAutomateQueues, 2),
volumeImpacted: oaVolume,
kpiObjective: `Pre-calificar y desviar 70% a self-service`,
rationale: `${otherAutomateQueues.length} queues tier AUTOMATE listas para IVR con NLU.`,
savingsDetail: `70% containment × CPI differential = ${oaSavings.toLocaleString()}€/year`,
estimatedSavings: oaSavings,
resourceHours: 320
});
}
}
// ============ PHASE 2: ASSIST (Tier ASSIST - 30% efficiency) ============
if (assistQueues.length > 0) {
const topSkills = getTopSkillNamonth(assistQueues);
const avgScore = assistQueues.reduce((sum, q) => sum + q.agenticScore, 0) / assistQueues.length;
// v3.5: Ahorro REALISTA
const realisticSavings = calculateRealisticSavings(assistVolume, assistCost, 'ASSIST');
// Knowledge Base con IA
initiatives.push({
id: `init-${initCounter++}`,
name: `Knowledge Base IA para ${assistQueues.length} queues ASSIST`,
phase: RoadmapPhase.Assist,
timeline: 'Q2 2026',
investment: Math.round(realisticSavings * 0.4),
resources: ['1x PM', 'Content Team', 'AI Developer'],
dimensionId: 'effectiveness_resolution',
risk: 'low',
skillsImpacted: topSkills,
volumeImpacted: assistVolume,
kpiObjective: `Reduce AHT 30% with AI suggestions`,
rationale: `${assistQueues.length} queues tier ASSIST (score ${avgScore.toFixed(1)}/10) se benefician de copilot contextual.`,
savingsDetail: `30% efficiency × CPI differential = ${realisticSavings.toLocaleString()}€/year`,
estimatedSavings: realisticSavings,
resourceHours: 360
});
// Copilot para agents si hay volumen high
if (assistVolume > 50000) {
const copilotSavings = Math.round(realisticSavings * 0.6);
initiatives.push({
id: `init-${initCounter++}`,
name: `Copilot IA para agents (${topSkills.slice(0, 2).join(', ')})`,
phase: RoadmapPhase.Assist,
timeline: 'Q3 2026',
investment: Math.round(copilotSavings * 0.5),
resources: ['2x AI Developers', 'QA Team', 'Training'],
dimensionId: 'effectiveness_resolution',
risk: 'medium',
skillsImpacted: topSkills.slice(0, 3),
volumeImpacted: assistVolume,
kpiObjective: `Reduce variability and migrate queues to tier AUTOMATE`,
rationale: `Copilot pre-fills fields, suggests answers and guides agent to standardize.`,
savingsDetail: `Mejora efficiency 30% en ${assistVolume.toLocaleString()} int/month`,
estimatedSavings: copilotSavings,
resourceHours: 520
});
}
}
// ============ PHASE 3: AUGMENT (Tier AUGMENT + HUMAN-ONLY - 15%) ============
const optimizeQueues = [...augmentQueues, ...humanQueues];
const optimizeVolume = optimizeQueues.reduce((sum, q) => sum + q.volume, 0);
const optimizeCost = optimizeQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0);
if (optimizeQueues.length > 0) {
const topSkills = getTopSkillNamonth(optimizeQueues);
const avgScore = optimizeQueues.reduce((sum, q) => sum + q.agenticScore, 0) / optimizeQueues.length;
// v3.5: Ahorro REALISTA (muy conservative para AUGMENT)
const realisticSavings = calculateRealisticSavings(optimizeVolume, optimizeCost, 'AUGMENT');
// Process Standardization
initiatives.push({
id: `init-${initCounter++}`,
name: `Standardization (${optimizeQueues.length} variable queues)`,
phase: RoadmapPhase.Augment,
timeline: 'Q3 2026',
investment: Math.round(realisticSavings * 0.8),
resources: ['Process Analyst', 'Training Team', 'QA'],
dimensionId: 'complexity_predictability',
risk: 'medium',
skillsImpacted: topSkills,
volumeImpacted: optimizeVolume,
kpiObjective: `Reduce CV to migrate queues to tier ASSIST/AUTOMATE`,
rationale: `${optimizeQueues.length} queues tier AUGMENT/HUMAN (score ${avgScore.toFixed(1)}/10) require process redesign.`,
savingsDetail: `15% optimization = ${realisticSavings.toLocaleString()}€/year (conservative)`,
estimatedSavings: realisticSavings,
resourceHours: 400
});
// Post-standardization Automation (futuro)
if (optimizeVolume > 30000) {
const futureSavings = calculateRealisticSavings(Math.round(optimizeVolume * 0.4), Math.round(optimizeCost * 0.4), 'ASSIST');
initiatives.push({
id: `init-${initCounter++}`,
name: `Post-standardization Automation`,
phase: RoadmapPhase.Augment,
timeline: 'Q1 2027',
investment: Math.round(futureSavings * 0.5),
resources: ['Lead AI Engineer', 'Process Team', 'QA'],
dimensionId: 'agentic_readiness',
risk: 'medium',
skillsImpacted: topSkills.slice(0, 2),
volumeImpacted: Math.round(optimizeVolume * 0.4),
kpiObjective: `Automate 40% of volume after standardization`,
rationale: `Once CV is reduced, queues will be suitable for automation.`,
savingsDetail: `Future potential: ${futureSavings.toLocaleString()}€/year`,
estimatedSavings: futureSavings,
resourceHours: 480
});
}
}
return initiatives;
}
/**
* Use generateOpportunitiesFromDrilldown instead
* Generate opportunities from real data
*/
function generateOpportunitiesFromRealData(metrics: SkillMetrics[], costPerHour: number): Opportunity[] {
// Find the maximum savings to calculate relative impact
const maxSavings = Math.max(...metrics.map(m => m.total_cost * 0.4), 1);
return metrics.slice(0, 10).map((m, index) => {
const potentialSavings = m.total_cost * 0.4; // 40% of potential savings
// Impact: relative to maximum savings (scale 1-10)
const impactRaw = (potentialSavings / maxSavings) * 10;
const impact = Math.max(3, Math.min(10, Math.round(impactRaw)));
// Feasibility: based on CV and transfer_rate (low variability = high feasibility)
const feasibilityRaw = 10 - (m.cv_aht * 5) - (m.transfer_rate / 10);
const feasibility = Math.max(3, Math.min(10, Math.round(feasibilityRaw)));
// Determine dimension according to characteristics
let dimensionId: string;
if (m.cv_aht < 0.3 && m.transfer_rate < 15) {
dimensionId = 'agentic_readiness'; // Ready to automate
} else if (m.cv_aht < 0.5) {
dimensionId = 'effectiveness_resolution'; // Can improve with assistance
} else {
dimensionId = 'complexity_predictability'; // Needs optimization
}
// Descriptive name
const prefix = m.cv_aht < 0.3 && m.transfer_rate < 15
? 'Automate '
: m.cv_aht < 0.5
? 'Assist with AI in '
: 'Optimize process in ';
return {
id: `opp-${index + 1}`,
name: `${prefix}${m.skill}`,
impact,
feasibility,
savings: Math.round(potentialSavings),
dimensionId,
customer_segment: 'medium' as CustomerSegment
};
});
}
/**
* Generate roadmap from opportunities and skill metrics
* v3.0: Initiatives connected to real skills with volumeImpacted, kpiObjective, rationale
*/
function generateRoadmapFromRealData(opportunities: Opportunity[], metrics?: SkillMetrics[]): RoadmapInitiative[] {
// Sort by descending savings to prioritize
const sortedOpps = [...opportunities].sort((a, b) => (b.savings || 0) - (a.savings || 0));
// Create map of metrics per skill for quick lookup
const metricsMap = new Map<string, SkillMetrics>();
if (metrics) {
for (const m of metrics) {
metricsMap.set(m.skill.toLowerCase(), m);
}
}
// Helper para obtain metrics de un skill
const getSkillMetrics = (skillName: string): SkillMetrics | undefined => {
return metricsMap.get(skillName.toLowerCase()) ||
Array.from(metricsMap.values()).find(m =>
m.skill.toLowerCase().includes(skillName.toLowerCase()) ||
skillName.toLowerCase().includes(m.skill.toLowerCase())
);
};
const initiatives: RoadmapInitiative[] = [];
let initCounter = 1;
// WAVE 1: Automate - Skills with high automation potential
const wave1Opps = sortedOpps.slice(0, 2);
for (const opp of wave1Opps) {
const skillName = opp.name?.replace(/^(Automate |Assist with AI in |Optimize process in )/, '') || `Skill ${initCounter}`;
const savings = opp.savings || 0;
const skillMetrics = getSkillMetrics(skillName);
const volume = skillMetrics?.volume || Math.round(savings / 5);
const cvAht = skillMetrics?.cv_aht || 50;
const offHoursPct = skillMetrics?.off_hours_pct || 28;
// Determine initiative type based on skill characteristics
const isHighVolume = volume > 100000;
const hasOffHoursOpportunity = offHoursPct > 25;
initiatives.push({
id: `init-${initCounter}`,
name: hasOffHoursOpportunity
? `Chatbot consultas ${skillName} (24/7)`
: `IVR inteligente ${skillName}`,
phase: RoadmapPhase.Automate,
timeline: 'Q1 2026',
investment: Math.round(savings * 0.3),
resources: hasOffHoursOpportunity
? ['1x Bot Developer', 'API Integration', 'QA Team']
: ['1x Voice UX Designer', 'Integration Team'],
dimensionId: 'agentic_readiness',
risk: 'low',
skillsImpacted: [skillName],
volumeImpacted: volume,
kpiObjective: hasOffHoursOpportunity
? `Automate ${Math.round(offHoursPct)}% consultas off hours`
: `Desviar 25% a self-service para gestiones simples`,
rationale: hasOffHoursOpportunity
? `${Math.round(offHoursPct)}% del volumen ocurre off hours. Chatbot puede resolver consultas de status without agent.`
: `CV AHT ${Math.round(cvAht)}% indica process variables. IVR puede pre-cualificar y resolver casos simples.`,
savingsDetail: `Automation ${Math.round(offHoursPct)}% off hours volume`,
estimatedSavings: savings,
resourceHours: 440
});
initCounter++;
}
// WAVE 2: Assist - Knowledge Base + Copilot
const wave2Opps = sortedOpps.slice(2, 4);
// Iniciativa 1: Knowledge Base (agrupa varios skills)
if (wave2Opps.length > 0) {
const kbSkills = wave2Opps.map(o => o.name?.replace(/^(Automate |Assist with AI in |Optimize process in )/, '') || '');
const kbSavings = wave2Opps.reduce((sum, o) => sum + (o.savings || 0), 0) * 0.4;
const kbVolume = wave2Opps.reduce((sum, o) => {
const m = getSkillMetrics(o.name || '');
return sum + (m?.volume || 10000);
}, 0);
initiatives.push({
id: `init-${initCounter}`,
name: 'Dynamic Knowledge Base with AI',
phase: RoadmapPhase.Assist,
timeline: 'Q2 2026',
investment: Math.round(kbSavings * 0.25),
resources: ['1x PM', 'Content Team', 'AI Developer'],
dimensionId: 'effectiveness_resolution',
risk: 'low',
skillsImpacted: kbSkills.filter(s => s),
volumeImpacted: kbVolume,
kpiObjective: 'Reduce Hold Time 30% through real-time suggestions',
rationale: 'FCR low indicates that agents do not find information quickly. KB with AI suggests contextual responses.',
savingsDetail: `Hold Time Reduction 30% en ${kbSkills.length} skills`,
estimatedSavings: Math.round(kbSavings),
resourceHours: 400
});
initCounter++;
}
// Iniciativa 2: Copilot para skill principal
if (wave2Opps.length > 0) {
const mainOpp = wave2Opps[0];
const skillName = mainOpp.name?.replace(/^(Automate |Assist with AI in |Optimize process in )/, '') || 'Principal';
const savings = mainOpp.savings || 0;
const skillMetrics = getSkillMetrics(skillName);
const volume = skillMetrics?.volume || Math.round(savings / 5);
const cvAht = skillMetrics?.cv_aht || 100;
initiatives.push({
id: `init-${initCounter}`,
name: `Copilot para ${skillName}`,
phase: RoadmapPhase.Assist,
timeline: 'Q3 2026',
investment: Math.round(savings * 0.35),
resources: ['2x AI Developers', 'QA Team', 'Training'],
dimensionId: 'effectiveness_resolution',
risk: 'medium',
skillsImpacted: [skillName],
volumeImpacted: volume,
kpiObjective: `Reduce AHT 15% and CV AHT from ${Math.round(cvAht)}% a <80%`,
rationale: `Skill con high volumen y variabilidad. Copilot puede pre-llenar formularios, sugerir respuestas y guiar al agent.`,
savingsDetail: `AHT Reduction 15% + FCR improvement 10%`,
estimatedSavings: savings,
resourceHours: 600
});
initCounter++;
}
// WAVE 3: Augment - Standardization and extended coverage
const wave3Opps = sortedOpps.slice(4, 6);
// Iniciativa 1: Standardization (skill con mayor CV)
if (wave3Opps.length > 0) {
const highCvOpp = wave3Opps.reduce((max, o) => {
const m = getSkillMetrics(o.name || '');
const maxM = getSkillMetrics(max.name || '');
return (m?.cv_aht || 0) > (maxM?.cv_aht || 0) ? o : max;
}, wave3Opps[0]);
const skillName = highCvOpp.name?.replace(/^(Automate |Assist with AI in |Optimize process in )/, '') || 'Variable';
const savings = highCvOpp.savings || 0;
const skillMetrics = getSkillMetrics(skillName);
const volume = skillMetrics?.volume || Math.round(savings / 5);
const cvAht = skillMetrics?.cv_aht || 150;
initiatives.push({
id: `init-${initCounter}`,
name: `Process Standardization ${skillName}`,
phase: RoadmapPhase.Augment,
timeline: 'Q4 2026',
investment: Math.round(savings * 0.4),
resources: ['Process Analyst', 'Training Team', 'QA'],
dimensionId: 'complexity_predictability',
risk: 'medium',
skillsImpacted: [skillName],
volumeImpacted: volume,
kpiObjective: `Reduce CV AHT from ${Math.round(cvAht)}% a <100%`,
rationale: `CV AHT ${Math.round(cvAht)}% indicates non-standardized processes. Requires redesign and documentation before automation.`,
savingsDetail: `Standardization reduces variability and enables future automation`,
estimatedSavings: savings,
resourceHours: 440
});
initCounter++;
}
// Iniciativa 2: Cobertura nocturna (si hay off hours volume)
const totalOffHoursVolume = metrics?.reduce((sum, m) => sum + (m.volume * (m.off_hours_pct || 0) / 100), 0) || 0;
if (totalOffHoursVolume > 10000 && wave3Opps.length > 1) {
const offHoursSkills = metrics?.filter(m => (m.off_hours_pct || 0) > 20).map(m => m.skill).slice(0, 3) || [];
const offHoursSavings = totalOffHoursVolume * 5 * 0.6; // CPI €5, 60% automatizable
initiatives.push({
id: `init-${initCounter}`,
name: 'Cobertura nocturna con agents virtuales',
phase: RoadmapPhase.Augment,
timeline: 'Q1 2027',
investment: Math.round(offHoursSavings * 0.5),
resources: ['Lead AI Engineer', 'Data Scientist', 'QA Team'],
dimensionId: 'agentic_readiness',
risk: 'high',
skillsImpacted: offHoursSkills.length > 0 ? offHoursSkills : ['Customer Service', 'Support'],
volumeImpacted: Math.round(totalOffHoursVolume),
kpiObjective: '24/7 Coverage with 60% automatic overnight resolution',
rationale: `${Math.round(totalOffHoursVolume).toLocaleString()} interactions off hours. Agente virtual puede resolver consultas y programar callbacks.`,
savingsDetail: `Cobertura 24/7 without incremento plantilla nocturna`,
estimatedSavings: Math.round(offHoursSavings),
resourceHours: 600
});
}
return initiatives;
}
/**
* v3.10: Generar economic model from datos reales
* ALINEADO CON ROADMAP: Usa modelo TCO con CPI por tier
* - AUTOMATE: 70% × (€2.33 - €0.15) = €1.526/interaction
* - ASSIST: 30% × (€2.33 - €1.50) = €0.249/interaction
* - AUGMENT: 15% × (€2.33 - €2.00) = €0.050/interaction
*/
function generateEconomicModelFromRealData(
metrics: SkillMetrics[],
costPerHour: number,
roadmap?: RoadmapInitiative[],
drilldownData?: DrilldownDataPoint[]
): EconomicModelData {
const totalCost = metrics.reduce((sum, m) => sum + m.total_cost, 0);
// v3.10: Calcular ahorro usando modelo TCO alineado con Roadmap
const CPI_HUMANO = 2.33;
const CPI_BOT = 0.15;
const CPI_ASSIST = 1.50;
const CPI_AUGMENT = 2.00;
// Containment/deflection rates by tier
const RATE_AUTOMATE = 0.70;
const RATE_ASSIST = 0.30;
const RATE_AUGMENT = 0.15;
let annualSavingsTCO = 0;
let volumeByTier = { AUTOMATE: 0, ASSIST: 0, AUGMENT: 0, 'HUMAN-ONLY': 0 };
// Si tenemos drilldownData, calculate ahorro por tier real
if (drilldownData && drilldownData.length > 0) {
drilldownData.forEach(skill => {
skill.originalQueues.forEach(queue => {
volumeByTier[queue.tier] += queue.volume;
});
});
// Annual savings = Volume × 12 months × Rate × CPI Differential
const savingsAUTOMATE = volumeByTier.AUTOMATE * 12 * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT);
const savingsASSIST = volumeByTier.ASSIST * 12 * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST);
const savingsAUGMENT = volumeByTier.AUGMENT * 12 * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT);
annualSavingsTCO = Math.round(savingsAUTOMATE + savingsASSIST + savingsAUGMENT);
} else {
// Fallback: estimar 35% del total cost (legacy)
annualSavingsTCO = Math.round(totalCost * 0.35);
}
// Initial investment: from aligned Roadmap
// Wave 1: €47K, Wave 2: €35K, Wave 3: €70K, Wave 4: €85K = €237K total
let initialInvestment: number;
if (roadmap && roadmap.length > 0) {
initialInvestment = roadmap.reduce((sum, init) => sum + (init.investment || 0), 0);
} else {
// Default: Escenario conservative Wave 1-2
initialInvestment = 82000; // €47K + €35K
}
// Costes recurrentes annuales (alineado con Roadmap)
// Wave 2: €40K, Wave 3: €78K, Wave 4: €108K
const recurrentCostAnnual = drilldownData && drilldownData.length > 0
? Math.round(initialInvestment * 0.5) // 50% of investment as recurring
: Math.round(initialInvestment * 0.15);
// Margen neto annual (ahorro - recurrente)
const netAnnualSavings = annualSavingsTCO - recurrentCostAnnual;
// Payback: Implementation + Recovery (aligned with Roadmap v3.9)
const monthsImplementacion = 9; // Wave 1 (6m) + mitad Wave 2 (3m/2)
const margenMensual = netAnnualSavings / 12;
const monthsRecuperacion = margenMensual > 0 ? Math.ceil(initialInvestment / margenMensual) : -1;
const paybackMonths = margenMensual > 0 ? monthsImplementacion + monthsRecuperacion : -1;
// ROI 3 years: ((Savings×3) - (Investment + Recurring×3)) / (Investment + Recurring×3) × 100
const costeTotalTresAnos = initialInvestment + (recurrentCostAnnual * 3);
const ahorroTotalTresAnos = annualSavingsTCO * 3;
const roi3yr = costeTotalTresAnos > 0
? ((ahorroTotalTresAnos - costeTotalTresAnos) / costeTotalTresAnos) * 100
: 0;
// NPV con tasa de descuento 10%
const discountRate = 0.10;
const npv = -initialInvestment +
(netAnnualSavings / (1 + discountRate)) +
(netAnnualSavings / Math.pow(1 + discountRate, 2)) +
(netAnnualSavings / Math.pow(1 + discountRate, 3));
// Desglose de ahorro por tier (alineado con TCO)
const savingsBreakdown: { category: string; amount: number; percentage: number }[] = [];
if (drilldownData && drilldownData.length > 0) {
const savingsAUTOMATE = Math.round(volumeByTier.AUTOMATE * 12 * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT));
const savingsASSIST = Math.round(volumeByTier.ASSIST * 12 * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST));
const savingsAUGMENT = Math.round(volumeByTier.AUGMENT * 12 * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT));
const totalSav = savingsAUTOMATE + savingsASSIST + savingsAUGMENT || 1;
if (savingsAUTOMATE > 0) {
savingsBreakdown.push({
category: `AUTOMATE (${volumeByTier.AUTOMATE.toLocaleString()} int/month)`,
amount: savingsAUTOMATE,
percentage: Math.round((savingsAUTOMATE / totalSav) * 100)
});
}
if (savingsASSIST > 0) {
savingsBreakdown.push({
category: `ASSIST (${volumeByTier.ASSIST.toLocaleString()} int/month)`,
amount: savingsASSIST,
percentage: Math.round((savingsASSIST / totalSav) * 100)
});
}
if (savingsAUGMENT > 0) {
savingsBreakdown.push({
category: `AUGMENT (${volumeByTier.AUGMENT.toLocaleString()} int/month)`,
amount: savingsAUGMENT,
percentage: Math.round((savingsAUGMENT / totalSav) * 100)
});
}
} else {
// Fallback legacy
const topSkills = metrics.slice(0, 4);
topSkills.forEach((skill, idx) => {
const skillSavings = Math.round(skill.total_cost * 0.4);
savingsBreakdown.push({
category: `AHT Reduction 15% ${skill.skill}`,
amount: skillSavings,
percentage: Math.round((skillSavings / (annualSavingsTCO || 1)) * 100)
});
});
}
const costBreakdown = [
{ category: 'Software y licencias', amount: Math.round(initialInvestment * 0.40), percentage: 40 },
{ category: 'Development and implementation', amount: Math.round(initialInvestment * 0.30), percentage: 30 },
{ category: 'Training y change mgmt', amount: Math.round(initialInvestment * 0.20), percentage: 20 },
{ category: 'Contingencia', amount: Math.round(initialInvestment * 0.10), percentage: 10 },
];
return {
currentAnnualCost: Math.round(totalCost),
futureAnnualCost: Math.round(totalCost - netAnnualSavings),
annualSavings: annualSavingsTCO, // Ahorro bruto TCO (para comparar con Roadmap)
initialInvestment,
paybackMonths: paybackMonths > 0 ? paybackMonths : 0,
roi3yr: parseFloat(roi3yr.toFixed(1)),
npv: Math.round(npv),
savingsBreakdown,
costBreakdown
};
}
/**
* Generar benchmark from datos reales
* AIRLINE SECTOR BENCHMARKS: AHT P50=380s, FCR=70%, Abandono=5%, Ratio P90/P50<2.0
*/
function generateBenchmarkFromRealData(metrics: SkillMetrics[]): BenchmarkDataPoint[] {
const avgAHT = metrics.reduce((sum, m) => sum + m.aht_mean, 0) / (metrics.length || 1);
const avgCV = metrics.reduce((sum, m) => sum + m.cv_aht, 0) / (metrics.length || 1);
const avgRatio = 1 + avgCV * 1.5; // Ratio P90/P50 aproximado
// Technical FCR: 100 - transfer_rate (weighted by volume)
const totalVolume = metrics.reduce((sum, m) => sum + m.volume_valid, 0);
const avgFCR = totalVolume > 0
? metrics.reduce((sum, m) => sum + (m.fcr_tecnico * m.volume_valid), 0) / totalVolume
: 0;
// Abandono real
const totalInteractions = metrics.reduce((sum, m) => sum + m.volume, 0);
const totalAbandoned = metrics.reduce((sum, m) => sum + m.abandon_count, 0);
const abandonRate = totalInteractions > 0 ? (totalAbandoned / totalInteractions) * 100 : 0;
// CPI: Coste total / Total interactions
const totalCost = metrics.reduce((sum, m) => sum + m.total_cost, 0);
const avgCPI = totalInteractions > 0 ? totalCost / totalInteractions : 3.5;
// Calculate percentiles based on benchmarks airline sector
const ahtPercentile = avgAHT <= 380 ? 75 : avgAHT <= 420 ? 60 : avgAHT <= 480 ? 40 : 25;
const fcrPercentile = avgFCR >= 70 ? 70 : avgFCR >= 60 ? 50 : avgFCR >= 50 ? 35 : 20;
const abandonPercentile = abandonRate <= 5 ? 75 : abandonRate <= 8 ? 55 : abandonRate <= 12 ? 35 : 20;
const ratioPercentile = avgRatio <= 2.0 ? 75 : avgRatio <= 2.5 ? 50 : avgRatio <= 3.0 ? 30 : 15;
return [
{
kpi: 'AHT P50',
userValue: Math.round(avgAHT),
userDisplay: `${Math.round(avgAHT)}s`,
industryValue: 380,
industryDisplay: '380s',
percentile: ahtPercentile,
p25: 320,
p50: 380,
p75: 450,
p90: 520
},
{
kpi: 'FCR',
userValue: avgFCR,
userDisplay: `${Math.round(avgFCR)}%`,
industryValue: 70,
industryDisplay: '70%',
percentile: fcrPercentile,
p25: 55,
p50: 70,
p75: 80,
p90: 88
},
{
kpi: 'Abandono',
userValue: abandonRate,
userDisplay: `${abandonRate.toFixed(1)}%`,
industryValue: 5,
industryDisplay: '5%',
percentile: abandonPercentile,
p25: 8,
p50: 5,
p75: 3,
p90: 2
},
{
kpi: 'Ratio P90/P50',
userValue: avgRatio,
userDisplay: avgRatio.toFixed(2),
industryValue: 2.0,
industryDisplay: '<2.0',
percentile: ratioPercentile,
p25: 2.5,
p50: 2.0,
p75: 1.7,
p90: 1.4
},
{
kpi: 'Cost/Interaction',
userValue: avgCPI,
userDisplay: `${avgCPI.toFixed(2)}`,
industryValue: 3.5,
industryDisplay: '€3.50',
percentile: avgCPI <= 3.5 ? 65 : avgCPI <= 4.5 ? 45 : 25,
p25: 4.5,
p50: 3.5,
p75: 2.8,
p90: 2.2
}
];
}