Files
BeyondCXAnalytics_AE/frontend/utils/realDataAnalysis.ts
sujucu70 820e8b4887 fix: Centralize CPI calculation for fresh data consistency
- Calculate CPI once in main function from heatmapData
- Pass globalCPI to generateDimensionsFromRealData
- This ensures dimension.kpi.value matches ExecutiveSummaryTab's calculation
- Both now use identical formula: weighted avg of (cpi * cost_volume) / total_cost_volume

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:32:40 +01:00

2523 lines
106 KiB
TypeScript
Raw 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.
/**
* Generación de análisis con datos reales (no sintéticos)
*/
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';
/**
* Calcular distribución horaria desde interacciones
* NOTA: Usa interaction_id únicos para consistencia con backend (aggfunc="nunique")
*/
function calculateHourlyDistribution(interactions: RawInteraction[]): { hourly: number[]; off_hours_pct: number; peak_hours: number[] } {
const hourly = new Array(24).fill(0);
// Deduplicar por interaction_id para consistencia con backend (nunique)
const seenIds = new Set<string>();
let duplicateCount = 0;
for (const interaction of interactions) {
// Saltar duplicados de 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 {
// Ignorar fechas inválidas
}
}
if (duplicateCount > 0) {
console.log(`⏰ calculateHourlyDistribution: ${duplicateCount} interaction_ids duplicados ignorados`);
}
const total = hourly.reduce((a, b) => a + b, 0);
// Fuera de horario: 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;
// Encontrar horas pico (top 3 consecutivas)
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 para 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 };
}
/**
* Calcular rango de fechas desde interacciones (optimizado para archivos grandes)
*/
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 análisis 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`);
// PASO 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)}%)`);
// Calcular FCR esperado manualmente
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 (manual): ${expectedFCR.toFixed(1)}% (${fcrRecords.length}/${interactions.length} calls without transfer AND without repeat)`);
// Mostrar sample de datos para 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'})`);
// PASO 0.5: Calcular rango de fechas
const dateRange = calculateDateRange(interactions);
console.log(`📅 Date range: ${dateRange?.min} to ${dateRange?.max}`);
// PASO 1: Analizar record_status (ya no filtramos, el filtrado se hace internamente en calculateSkillMetrics)
// Normalizar a uppercase para comparación case-insensitive
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);
// PASO 1.5: Calcular distribución horaria (sobre TODAS las interacciones para ver patrones completos)
const hourlyDistribution = calculateHourlyDistribution(interactions);
console.log(`⏰ Off-hours: ${hourlyDistribution.off_hours_pct}%, Peak hours: ${hourlyDistribution.peak_hours.join('-')}h`);
// PASO 2: Calcular métricas por skill (pasa TODAS las interacciones, el filtrado se hace internamente)
const skillMetrics = calculateSkillMetrics(interactions, costPerHour);
console.log(`📊 Calculated metrics for ${skillMetrics.length} skills`);
// PASO 3: Generar heatmap data con dimensiones
const heatmapData = generateHeatmapFromMetrics(skillMetrics, avgCsat, segmentMapping);
// PASO 4: Calcular métricas globales
// Volumen total: TODAS las interacciones
const totalInteractions = interactions.length;
// Volumen válido para AHT: suma de volume_valid de cada skill
const totalValidInteractions = skillMetrics.reduce((sum, s) => sum + s.volume_valid, 0);
// AHT promedio: calculado solo sobre interacciones válidas (ponderado por volumen)
const totalWeightedAHT = skillMetrics.reduce((sum, s) => sum + (s.aht_mean * s.volume_valid), 0);
const avgAHT = totalValidInteractions > 0 ? Math.round(totalWeightedAHT / totalValidInteractions) : 0;
// FCR Técnico: 100 - transfer_rate (comparable con benchmarks de industria)
// Ponderado por volumen de cada 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 desde heatmapData ===
// Esta es la ÚNICA fuente de verdad para CPI, igual que 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 principales
const summaryKpis: Kpi[] = [
{ label: "Interacciones Totales", value: totalInteractions.toLocaleString('es-ES') },
{ label: "AHT Promedio", value: `${avgAHT}s` },
{ label: "FCR Técnico", value: `${avgFCR}%` },
{ label: "CSAT", value: `${(avgCsat / 20).toFixed(1)}/5` }
];
// Health Score basado en métricas reales
const overallHealthScore = calculateHealthScore(heatmapData);
// Dimensiones (simplificadas para datos reales) - pasar CPI centralizado
const dimensions: DimensionAnalysis[] = generateDimensionsFromRealData(
interactions,
skillMetrics,
avgCsat,
avgAHT,
hourlyDistribution,
globalCPI // CPI calculado desde heatmapData
);
// Agentic Readiness Score
const agenticReadiness = calculateAgenticReadinessFromRealData(skillMetrics);
// Findings y Recommendations (incluyendo análisis de fuera de horario)
const findings = generateFindingsFromRealData(skillMetrics, interactions, hourlyDistribution);
const recommendations = generateRecommendationsFromRealData(skillMetrics, hourlyDistribution, interactions.length);
// v3.3: Drill-down por Cola + Tipificación - CALCULAR PRIMERO para usar en opportunities y roadmap
const drilldownData = calculateDrilldownMetrics(interactions, costPerHour);
// v3.3: Opportunities y Roadmap basados en drilldownData (colas con CV < 75% = automatizables)
const opportunities = generateOpportunitiesFromDrilldown(drilldownData, costPerHour);
// Roadmap basado en drilldownData
const roadmap = generateRoadmapFromDrilldown(drilldownData, costPerHour);
// Economic Model (v3.10: alineado con TCO del 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
};
}
/**
* PASO 2: Calcular métricas base por skill
*
* LÓGICA DE FILTRADO POR record_status:
* - valid: llamadas normales válidas
* - noise: llamadas < 10 segundos (excluir de AHT, pero suma en volumen/coste)
* - zombie: llamadas > 3 horas (excluir de AHT, pero suma en volumen/coste)
* - abandon: cliente cuelga (excluir de AHT, no suma coste conversación, pero ocupa línea)
*
* Dashboard calidad/eficiencia: filtrar solo valid + abandon para AHT
* Cálculos financieros: usar todo (volume, coste total)
*/
interface SkillMetrics {
skill: string;
volume: number; // Total de interacciones (todas)
volume_valid: number; // Interacciones válidas para AHT (valid + abandon)
aht_mean: number; // AHT "limpio" calculado solo sobre valid (sin noise/zombie/abandon) - para métricas de calidad, CV
aht_total: number; // AHT "total" calculado con TODAS las filas (noise/zombie/abandon incluidas) - solo informativo
aht_benchmark: number; // AHT "tradicional" (incluye noise, excluye zombie/abandon) - para comparación con benchmarks de industria
aht_std: number;
cv_aht: number;
transfer_rate: number; // Calculado sobre valid + abandon
fcr_rate: number; // FCR Real: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE) - sin recontacto 7 días
fcr_tecnico: number; // FCR Técnico: (transfer_flag == FALSE) - solo sin transferencia, comparable con benchmarks de industria
abandonment_rate: number; // % de abandonos sobre total
total_cost: number; // Coste total (todas las interacciones excepto abandon)
cost_volume: number; // Volumen usado para calcular coste (non-abandon)
cpi: number; // Coste por interacción = total_cost / cost_volume
hold_time_mean: number; // Calculado sobre valid
cv_talk_time: number;
// Métricas adicionales para debug
noise_count: number;
zombie_count: number;
abandon_count: number;
}
export function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: number): SkillMetrics[] {
// Agrupar 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);
});
// Calcular métricas para cada skill
const metrics: SkillMetrics[] = [];
skillGroups.forEach((group, skill) => {
const volume = group.length;
if (volume === 0) return;
// === CÁLCULOS SIMPLES Y DIRECTOS DEL 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
// Definición: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE)
// Esta es la métrica MÁS ESTRICTA - sin transferencia Y sin recontacto en 7 días
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;
// FCR Técnico: 100 - transfer_rate
// Definición: (transfer_flag == FALSE) - solo sin transferencia
// Esta métrica es COMPARABLE con benchmarks de industria (COPC, Dimension Data)
// Los benchmarks de industria (~70%) miden FCR sin transferencia, NO sin recontacto
const fcr_tecnico = 100 - transfer_rate;
// Separar por record_status para AHT (normalizar a uppercase para comparación case-insensitive)
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 excepto abandonos)
const nonAbandonRecords = group.filter(i => i.is_abandoned !== true);
const noise_count = noiseRecords.length;
const zombie_count = zombieRecords.length;
// AHT se calcula sobre registros 'valid' (excluye 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 talkTimes = ahtRecords.map(i => i.duration_talk);
const talk_mean = talkTimes.reduce((sum, v) => sum + v, 0) / volume_valid;
const talk_std = Math.sqrt(talkTimes.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 promedio
hold_time_mean = ahtRecords.reduce((sum, i) => sum + i.hold_time, 0) / volume_valid;
}
// === AHT BENCHMARK: para comparación con benchmarks de industria ===
// Incluye NOISE (llamadas cortas son trabajo real), excluye ZOMBIE (errores) y ABANDON (sin handle time)
// Los benchmarks de industria (COPC, Dimension Data) NO filtran llamadas 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 limpio si no hay registros 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 (solo informativo) ===
// Incluye NOISE, ZOMBIE, ABANDON - para comparación con AHT limpio
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;
}
// === CÁLCULOS FINANCIEROS: usar TODAS las interacciones ===
// Coste total con productividad efectiva del 70%
const effectiveProductivity = 0.70;
// Para el coste, usamos todas las interacciones EXCEPTO abandonos (que no generan coste de conversación)
// noise y zombie SÍ generan coste (ocupan agente aunque sea poco/mucho tiempo)
// Usar nonAbandonRecords que ya filtra por is_abandoned y record_status
const costRecords = nonAbandonRecords;
const costVolume = costRecords.length;
// Calcular AHT para coste usando todos los registros que generan coste
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;
}
// Coste Real = (AHT en horas × Coste/hora × Volumen) / Productividad Efectiva
const rawCost = (aht_for_cost / 3600) * costPerHour * costVolume;
const total_cost = rawCost / effectiveProductivity;
// CPI = Coste por interacción (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 (solo informativo)
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: Verificar cálculos ===
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('📊 MÉTRICAS CALCULADAS POR SKILL');
console.log('═══════════════════════════════════════════════════════════════');
console.log(`Total skills: ${metrics.length}`);
console.log(`Total volumen: ${totalVolume}`);
console.log(`Total abandonos (is_abandoned=TRUE): ${totalAbandons}`);
console.log('');
console.log('MÉTRICAS GLOBALES (ponderadas por volumen):');
console.log(` Abandonment Rate: ${globalAbandonRate.toFixed(2)}%`);
console.log(` FCR Real (sin transfer + sin recontacto 7d): ${avgFCRRate.toFixed(2)}%`);
console.log(` FCR Técnico (solo sin transfer, comparable con 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)}%, FCR Técnico=${m.fcr_tecnico.toFixed(1)}%, transfer=${m.transfer_rate.toFixed(1)}%`);
});
console.log('═══════════════════════════════════════════════════════════════');
console.log('');
// Mostrar detalle del primer skill para 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); // Ordenar por volumen descendente
}
/**
* v4.4: Clasificar tier de automatización con datos del heatmap
*
* Esta función replica la lógica de clasificarTier() usando los datos
* disponibles en el heatmap. Acepta parámetros opcionales (fcr, volume)
* para mayor precisión cuando están disponibles.
*
* Se usa en generateDrilldownFromHeatmap() de analysisGenerator.ts para
* asegurar consistencia entre la ruta fresh (datos completos) y la ruta
* cached (datos del heatmap).
*
* @param score - Agentic Readiness Score (0-10)
* @param cv - Coeficiente de Variación del AHT como decimal (0.75 = 75%)
* @param transfer - Tasa de transferencia como decimal (0.20 = 20%)
* @param fcr - FCR rate como decimal (0.80 = 80%), opcional
* @param volume - Volumen mensual de interacciones, opcional
* @returns AgenticTier ('AUTOMATE' | 'ASSIST' | 'AUGMENT' | 'HUMAN-ONLY')
*/
export function clasificarTierSimple(
score: number,
cv: number, // CV como decimal (0.75 = 75%)
transfer: number, // Transfer como decimal (0.20 = 20%)
fcr?: number, // FCR como decimal (0.80 = 80%)
volume?: number // Volumen mensual
): import('../types').AgenticTier {
// RED FLAGS críticos - mismos que clasificarTier() completa
// CV > 120% o Transfer > 50% son red flags absolutos
if (cv > 1.20 || transfer > 0.50) {
return 'HUMAN-ONLY';
}
// Volume < 50/mes es red flag si tenemos el dato
if (volume !== undefined && volume < 50) {
return 'HUMAN-ONLY';
}
// TIER 1: AUTOMATE - requiere métricas óptimas
// Mismo criterio que clasificarTier(): score >= 7.5, cv <= 0.75, transfer <= 0.20, fcr >= 0.50
const fcrOk = fcr === undefined || fcr >= 0.50; // Si no tenemos FCR, asumimos OK
if (score >= 7.5 && cv <= 0.75 && transfer <= 0.20 && fcrOk) {
return 'AUTOMATE';
}
// TIER 2: ASSIST - apto para copilot/asistencia
if (score >= 5.5 && cv <= 0.90 && transfer <= 0.30) {
return 'ASSIST';
}
// TIER 3: AUGMENT - requiere optimización previa
if (score >= 3.5) {
return 'AUGMENT';
}
// TIER 4: HUMAN-ONLY - proceso complejo
return 'HUMAN-ONLY';
}
/**
* v3.4: Calcular métricas drill-down con nueva fórmula de Agentic Readiness Score
*
* SCORE POR COLA (0-10):
* - Factor 1: PREDICTIBILIDAD (30%) - basado en CV AHT
* - Factor 2: RESOLUTIVIDAD (25%) - FCR (60%) + Transfer (40%)
* - Factor 3: VOLUMEN (25%) - basado en volumen mensual
* - Factor 4: CALIDAD DATOS (10%) - % registros válidos
* - Factor 5: SIMPLICIDAD (10%) - basado en AHT
*
* CLASIFICACIÓN EN TIERS:
* - 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 o red flags
*
* RED FLAGS (HUMAN-ONLY automático):
* - CV > 120%
* - Transfer > 50%
* - Vol < 50/mes
* - Valid < 30%
*/
export function calculateDrilldownMetrics(
interactions: RawInteraction[],
costPerHour: number
): DrilldownDataPoint[] {
const effectiveProductivity = 0.70;
// ═══════════════════════════════════════════════════════════════════════════
// FUNCIÓN: Calcular Score por Cola (nueva fórmula v3.4)
// ═══════════════════════════════════════════════════════════════════════════
function calcularScoreCola(
cv: number, // CV AHT (0-2+, donde 1 = 100%)
fcr: number, // FCR rate (0-1)
transfer: number, // Transfer rate (0-1)
vol: number, // Volumen mensual
aht: number, // AHT en segundos
validPct: number // % registros válidos (0-1)
): { score: number; breakdown: import('../types').AgenticScoreBreakdown } {
// FACTOR 1: PREDICTIBILIDAD (30%) - basado en 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: RESOLUTIVIDAD (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: VOLUMEN (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: CALIDAD DATOS (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: SIMPLICIDAD (10%) - basado en 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,
calidadDatos: Math.round(scoreCal * 10) / 10,
simplicidad: Math.round(scoreSimp * 10) / 10
}
};
}
// ═══════════════════════════════════════════════════════════════════════════
// FUNCIÓN: Clasificar Tier del Roadmap
// ═══════════════════════════════════════════════════════════════════════════
function clasificarTier(
score: number,
cv: number, // CV como decimal (0.75 = 75%)
transfer: number, // Transfer como decimal (0.20 = 20%)
fcr: number, // FCR como decimal (0.80 = 80%)
vol: number,
validPct: number
): { tier: import('../types').AgenticTier; motivo: string } {
// RED FLAGS → HUMAN-ONLY automático
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/mes");
if (validPct < 0.30) redFlags.push("Datos < 30% válidos");
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}, métricas óptimas para automatización`
};
}
// TIER 2: ASSIST
if (score >= 5.5 && cv <= 0.90 && transfer <= 0.30) {
return {
tier: 'ASSIST',
motivo: `Score ${score}, apto para copilot/asistencia`
};
}
// TIER 3: AUGMENT
if (score >= 3.5) {
return {
tier: 'AUGMENT',
motivo: `Score ${score}, requiere optimización previa`
};
}
// TIER 4: HUMAN-ONLY
return {
tier: 'HUMAN-ONLY',
motivo: `Score ${score}, proceso complejo para automatización`
};
}
// ═══════════════════════════════════════════════════════════════════════════
// FUNCIÓN: Calcular métricas de un grupo de interacciones
// ═══════════════════════════════════════════════════════════════════════════
function calculateQueueMetrics(group: RawInteraction[]): import('../types').OriginalQueueMetrics | null {
const volume = group.length;
if (volume < 5) return null;
// Filtrar solo VALID para cálculo de CV (normalizar a uppercase para comparación case-insensitive)
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 sobre registros válidos
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 como decimal
const cv_aht_percent = cv_aht_decimal * 100; // CV como %
// Transfer y FCR (como decimales para cálculo, como % para 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 (sin transferencia Y sin recontacto 7d)
const fcrCount = group.filter(i => i.fcr_real_flag === true).length;
const fcr_decimal = fcrCount / volume;
const fcr_percent = fcr_decimal * 100;
// FCR Técnico: 100 - transfer_rate (comparable con benchmarks de industria)
const fcr_tecnico_percent = 100 - transfer_percent;
// Calcular score con nueva fórmula v3.4
const { score, breakdown } = calcularScoreCola(
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: Convertir volumen de 11 meses a anual para el coste
const annualVolume = (volume / 11) * 12; // 11 meses → anual
const annualCost = Math.round((aht_mean / 3600) * costPerHour * annualVolume / effectiveProductivity);
return {
original_queue_id: '', // Se asigna después
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, // FCR Técnico para consistencia con Summary
agenticScore: score,
scoreBreakdown: breakdown,
tier,
tierMotivo: motivo,
isPriorityCandidate: tier === 'AUTOMATE',
annualCost
};
}
// ═══════════════════════════════════════════════════════════════════════════
// PASO 1: Agrupar por queue_skill (nivel estratégico)
// ═══════════════════════════════════════════════════════════════════════════
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 encontrados`);
const drilldownData: DrilldownDataPoint[] = [];
// ═══════════════════════════════════════════════════════════════════════════
// PASO 2: Para cada queue_skill, agrupar por 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 || 'Sin identificar';
if (!queueGroups.has(queueId)) {
queueGroups.set(queueId, []);
}
queueGroups.get(queueId)!.push(interaction);
}
// Calcular métricas para cada 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;
// Ordenar por score descendente, luego por volumen
originalQueues.sort((a, b) => {
if (Math.abs(a.agenticScore - b.agenticScore) > 0.5) {
return b.agenticScore - a.agenticScore;
}
return b.volume - a.volume;
});
// ═══════════════════════════════════════════════════════════════════════
// Calcular métricas agregadas del skill (promedio ponderado por volumen)
// ═══════════════════════════════════════════════════════════════════════
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 ponderado por volumen
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 cola 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, // FCR Técnico para consistencia
agenticScore: Math.round(avgScore * 10) / 10,
isPriorityCandidate: hasAutomateQueue,
annualCost: totalCost
});
});
// ═══════════════════════════════════════════════════════════════════════════
// PASO 3: Ordenar y log resumen
// ═══════════════════════════════════════════════════════════════════════════
drilldownData.sort((a, b) => b.agenticScore - a.agenticScore);
// Contar 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} colas`);
console.log(`🎯 Tiers: AUTOMATE=${tierSummary.AUTOMATE}, ASSIST=${tierSummary.ASSIST}, AUGMENT=${tierSummary.AUGMENT}, HUMAN-ONLY=${tierSummary['HUMAN-ONLY']}`);
return drilldownData;
}
/**
* PASO 3: Transformar métricas a dimensiones (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 => {
// Dimensión 1: Predictibilidad (CV AHT)
const predictability = Math.max(0, Math.min(10, 10 - ((m.cv_aht - 0.3) / 1.2 * 10)));
// Dimensión 2: Complejidad Inversa (Transfer Rate)
const complexity_inverse = Math.max(0, Math.min(10, 10 - ((m.transfer_rate / 100 - 0.05) / 0.25 * 10)));
// Dimensión 3: Repetitividad (Volumen)
let repetitiveness = 0;
if (m.volume >= 5000) {
repetitiveness = 10;
} else if (m.volume <= 100) {
repetitiveness = 0;
} else {
// Interpolación lineal entre 100 y 5000
repetitiveness = ((m.volume - 100) / (5000 - 100)) * 10;
}
// Agentic Readiness Score (promedio ponderado)
const agentic_readiness = (
predictability * 0.40 +
complexity_inverse * 0.35 +
repetitiveness * 0.25
);
// Categoría
let category: 'automate' | 'assist' | 'optimize';
if (agentic_readiness >= 8.0) {
category = 'automate';
} else if (agentic_readiness >= 5.0) {
category = 'assist';
} else {
category = 'optimize';
}
// Segmentación
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)
// Esta es la métrica más estricta - sin transferencia Y sin recontacto en 7 días
const fcr_score = Math.round(m.fcr_rate);
// FCR Técnico: solo sin transferencia (comparable con benchmarks de industria 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 abandonos
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 usado para calcular coste (non-abandon)
aht_seconds: Math.round(m.aht_mean),
aht_total: Math.round(m.aht_total), // AHT con TODAS las filas (solo informativo)
aht_benchmark: Math.round(m.aht_benchmark), // AHT tradicional para comparación con benchmarks de industria
annual_cost: Math.round(m.total_cost), // Coste calculado con TODOS los registros (noise + zombie + valid)
cpi: m.cpi, // Coste por interacción (calculado correctamente)
metrics: {
fcr: fcr_score, // FCR Real (más estricto, con filtro de recontacto 7d)
fcr_tecnico: fcr_tecnico_score, // FCR Técnico (comparable con 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), // Aproximación
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;
}
/**
* Calcular Health Score global - Nueva fórmula basada en benchmarks de industria
*
* PASO 1: Normalización de componentes usando percentiles de industria
* PASO 2: Ponderación (FCR 35%, Abandono 30%, CSAT Proxy 20%, AHT 15%)
* PASO 3: Penalizaciones por umbrales críticos
*
* Benchmarks de industria (Cross-Industry):
* - FCR Técnico: 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;
// ═══════════════════════════════════════════════════════════════
// PASO 0: Extraer métricas ponderadas por volumen
// ═══════════════════════════════════════════════════════════════
// FCR Técnico (%)
const fcrTecnico = heatmapData.reduce((sum, d) =>
sum + (d.metrics?.fcr_tecnico ?? (100 - d.metrics.transfer_rate)) * d.volume, 0) / totalVolume;
// Abandono (%)
const abandono = heatmapData.reduce((sum, d) =>
sum + (d.metrics?.abandonment_rate || 0) * d.volume, 0) / totalVolume;
// AHT (segundos) - usar aht_seconds (AHT limpio sin 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;
// ═══════════════════════════════════════════════════════════════
// PASO 1: Normalización de componentes (0-100 score)
// ═══════════════════════════════════════════════════════════════
// FCR Técnico: P10=85%, P50=68%, P90=50%
// Más alto = mejor
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%
// Más bajo = mejor (invertido)
let abandonoScore: number;
if (abandono <= 3) {
abandonoScore = 95 + 5 * Math.max(0, (3 - abandono) / 3); // 95-100
} else if (abandono <= 5) {
abandonoScore = 50 + 45 * (5 - abandono) / (5 - 3); // 50-95
} else if (abandono <= 10) {
abandonoScore = 20 + 30 * (10 - abandono) / (10 - 5); // 20-50
} else {
// Por encima de P90 (crítico): penalización fuerte
abandonoScore = Math.max(0, 20 - 2 * (abandono - 10)); // 0-20, decrece rápido
}
// AHT: P10=240s, P50=380s, P90=540s
// Más bajo = mejor (invertido)
// PERO: Si FCR es bajo, AHT bajo puede indicar llamadas rushed (mala calidad)
let ahtScore: number;
if (aht <= 240) {
// Por debajo de P10 (excelente eficiencia)
// 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 bajo (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 desde FCR + Abandono
// Sin datos reales de CSAT, usamos proxy
const csatProxy = 0.60 * fcrScore + 0.40 * abandonoScore;
// ═══════════════════════════════════════════════════════════════
// PASO 2: Aplicar pesos
// FCR 35% + Abandono 30% + CSAT Proxy 20% + AHT 15%
// ═══════════════════════════════════════════════════════════════
const subtotal = (
fcrScore * 0.35 +
abandonoScore * 0.30 +
csatProxy * 0.20 +
ahtScore * 0.15
);
// ═══════════════════════════════════════════════════════════════
// PASO 3: Calcular penalizaciones
// ═══════════════════════════════════════════════════════════════
let penalties = 0;
// Penalización por abandono crítico (>10%)
if (abandono > 10) {
penalties += 10;
}
// Penalización por transferencia alta (>20%)
if (transferencia > 20) {
penalties += 5;
}
// Penalización combo: Abandono alto + FCR bajo
// Indica problemas sistémicos de capacidad Y resolución
if (abandono > 8 && fcrTecnico < 78) {
penalties += 5;
}
// ═══════════════════════════════════════════════════════════════
// PASO 4: Score final
// ═══════════════════════════════════════════════════════════════
const finalScore = Math.max(0, Math.min(100, subtotal - penalties));
// Debug logging
console.log('📊 Health Score Calculation:', {
inputs: { fcrTecnico: fcrTecnico.toFixed(1), abandono: abandono.toFixed(1), aht: Math.round(aht), transferencia: transferencia.toFixed(1) },
scores: { fcrScore: fcrScore.toFixed(1), abandonoScore: abandonoScore.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: Generar 7 dimensiones viables desde datos reales
* Benchmarks sector aéreo: AHT P50=380s, FCR=70%, Abandono=5%, Ratio P90/P50 saludable<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 calculado centralmente desde 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);
// FCR Técnico (100 - transfer_rate, ponderado por volumen) - comparable con 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;
// Calcular ratio P90/P50 aproximado desde CV
const avgRatio = 1 + avgCV * 1.5; // Aproximación: ratio ≈ 1 + 1.5*CV
// === SCORE EFICIENCIA: Escala basada en 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;
// === SCORE VOLUMETRÍA: Basado en % fuera horario y ratio pico/valle ===
// % fuera horario >30% penaliza, ratio pico/valle >3x penaliza
const offHoursPct = hourlyDistribution.off_hours_pct;
// Calcular ratio pico/valle (consistente con 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;
// Score volumetría: 100 base, penalizar por fuera de horario y ratio pico/valle
// NOTA: Fórmulas sincronizadas con backendMapper.ts buildVolumetryDimension()
let volumetryScore = 100;
// Penalización por fuera de horario (misma fórmula que backendMapper)
if (offHoursPct > 30) {
volumetryScore -= Math.min(40, (offHoursPct - 30) * 2); // -2 pts por cada % sobre 30%
} else if (offHoursPct > 20) {
volumetryScore -= (offHoursPct - 20); // -1 pt por cada % entre 20-30%
}
// Penalización por ratio pico/valle alto (misma fórmula que 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: Usar el valor centralizado pasado como parámetro ===
// globalCPI ya fue calculado en generateAnalysisFromRealData desde heatmapData
// Esto garantiza consistencia con ExecutiveSummaryTab
const costPerInteraction = globalCPI;
// Calcular 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;
// Determinar percentil de Eficiencia basado en benchmark sector aéreo (ratio <2.0 saludable)
const efficiencyPercentile = avgRatio < 2.0 ? 75 : avgRatio < 2.5 ? 50 : avgRatio < 3.0 ? 35 : 20;
// Determinar percentil de FCR basado en benchmark sector aéreo (70%)
const fcrPercentile = avgFCR >= 70 ? 75 : avgFCR >= 60 ? 50 : avgFCR >= 50 ? 35 : 20;
return [
// 1. VOLUMETRÍA & DISTRIBUCIÓN
{
id: 'volumetry_distribution',
name: 'volumetry_distribution',
title: 'Volumetría & Distribución',
score: volumetryScore,
percentile: offHoursPct <= 20 ? 80 : offHoursPct <= 30 ? 60 : 40,
summary: `${offHoursPct.toFixed(1)}% fuera de horario. Ratio pico/valle: ${peakValleyRatio.toFixed(1)}x. ${totalVolume.toLocaleString('es-ES')} interacciones totales.`,
kpi: { label: 'Fuera de Horario', value: `${offHoursPct.toFixed(0)}%` },
icon: BarChartHorizontal,
distribution_data: {
hourly: hourlyDistribution.hourly,
off_hours_pct: hourlyDistribution.off_hours_pct,
peak_hours: hourlyDistribution.peak_hours
}
},
// 2. EFICIENCIA OPERATIVA - KPI principal: AHT P50 (industry standard)
{
id: 'operational_efficiency',
name: 'operational_efficiency',
title: 'Eficiencia Operativa',
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. EFECTIVIDAD & RESOLUCIÓN (FCR Técnico = 100 - transfer_rate)
{
id: 'effectiveness_resolution',
name: 'effectiveness_resolution',
title: 'Efectividad & Resolución',
score: avgFCR >= 90 ? 100 : avgFCR >= 85 ? 80 : avgFCR >= 80 ? 60 : avgFCR >= 75 ? 40 : 20,
percentile: fcrPercentile,
summary: `FCR Técnico: ${avgFCR.toFixed(1)}% (benchmark: 85-90%). Transfer: ${avgTransferRate.toFixed(1)}%.`,
kpi: { label: 'FCR Técnico', value: `${Math.round(avgFCR)}%` },
icon: Target
},
// 4. COMPLEJIDAD & PREDICTIBILIDAD - KPI principal: CV AHT (industry standard for predictability)
{
id: 'complexity_predictability',
name: 'complexity_predictability',
title: 'Complejidad & Predictibilidad',
score: avgCV <= 0.75 ? 100 : avgCV <= 1.0 ? 80 : avgCV <= 1.25 ? 60 : avgCV <= 1.5 ? 40 : 20, // Basado en 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 ? 'Alta predictibilidad para WFM.' : avgCV <= 1.0 ? 'Predictibilidad aceptable.' : 'Alta variabilidad, dificulta planificación.'}`,
kpi: { label: 'CV AHT', value: `${(avgCV * 100).toFixed(0)}%` },
icon: Brain
},
// 5. SATISFACCIÓN - CSAT
{
id: 'customer_satisfaction',
name: 'customer_satisfaction',
title: 'Satisfacción del Cliente',
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 ? 'Satisfacción alta.' : avgCsat >= 60 ? 'Satisfacción aceptable.' : 'Requiere atención.'}`
: 'CSAT: No disponible en dataset. Considerar implementar encuestas post-llamada.',
kpi: { label: 'CSAT', value: avgCsat > 0 ? `${Math.round(avgCsat)}/100` : 'N/A' },
icon: Smile
},
// 6. ECONOMÍA - CPI
{
id: 'economy_cpi',
name: 'economy_cpi',
title: 'Economía Operacional',
score: costPerInteraction < 4 ? 85 : costPerInteraction < 5 ? 70 : costPerInteraction < 6 ? 55 : 40,
percentile: costPerInteraction < 4.5 ? 70 : costPerInteraction < 5.5 ? 50 : 30,
summary: `CPI: €${costPerInteraction.toFixed(2)} por interacción. Coste anual: €${totalCost.toLocaleString('es-ES')}. Benchmark sector: €5.00 (Fuente: Gartner 2024).`,
kpi: { label: 'Coste/Interacción', 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 ? 'Excelente para automatización.' : agenticScore >= 5 ? 'Candidato para asistencia IA.' : 'Requiere optimización previa.'}`,
kpi: { label: 'Score', value: `${agenticScore.toFixed(1)}/10` },
icon: Bot
}
];
}
/**
* Calcular Agentic Readiness desde datos reales
* Score = Σ(factor_i × peso_i) con 6 factores únicos
*/
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 FACTORES ÚNICOS ===
// 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 interpolación, > 5000 = 10
let repetitiveness = 0;
if (totalVolume >= 5000) repetitiveness = 10;
else if (totalVolume <= 100) repetitiveness = 0;
else repetitiveness = ((totalVolume - 100) / (5000 - 100)) * 10;
// 4. Estructuración (CV Talk Time) - Peso 15%
// Score = 10 - (CV_Talk × 8). Baja variabilidad = alta estructuración
const estructuracion = Math.max(0, Math.min(10, 10 - (avgCVTalk * 8)));
// 5. Estabilidad (ratio pico/valle simplificado) - Peso 10%
// Simplificación: basado en CV general como proxy
const estabilidad = Math.max(0, Math.min(10, 10 - (avgCV * 5)));
// 6. ROI Potencial (basado en 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;
// Score final ponderado: (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 basado en 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'; // Requiere gestión humana
// Sub-factors con descripciones únicas y metodologías específicas
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')} interacciones. Escala lineal 100-5000`
},
{
name: 'estructuracion',
displayName: 'Estructuración',
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: `Basado en variabilidad general. Score = 10 - (CV × 5)`
},
{
name: 'roi_potencial',
displayName: 'ROI Potencial',
score: Math.round(roiPotencial * 10) / 10,
weight: 0.15,
description: `Coste anual: €${totalCost.toLocaleString('es-ES')}. Score logarítmico`
}
];
// Interpretation basada en 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 = 'Requiere optimización. Estandarizar procesos y reducir variabilidad antes de implementar IA.';
} else {
interpretation = 'Gestión humana recomendada. Procesos complejos o variables que requieren intervención humana.';
}
return {
score,
sub_factors,
tier,
confidence: totalVolume > 1000 ? 'high' as const : totalVolume > 500 ? 'medium' as const : 'low' as const,
interpretation
};
}
/**
* Generar findings desde 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;
// Calcular métricas 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;
// Calcular abandono real
const totalAbandoned = metrics.reduce((sum, m) => sum + m.abandon_count, 0);
const abandonRate = totalVolume > 0 ? (totalAbandoned / totalVolume) * 100 : 0;
// Finding 0: Alto volumen fuera de horario - oportunidad para agente 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 Fuera de Horario',
text: `${offHoursPct.toFixed(0)}% de interacciones fuera de horario (8-19h)`,
dimensionId: 'volumetry_distribution',
description: `${offHoursVolume.toLocaleString()} interacciones (${offHoursPct.toFixed(1)}%) ocurren fuera de horario laboral. Oportunidad ideal para implementar agentes virtuales 24/7.`,
impact: offHoursPct > 30 ? 'high' : 'medium'
});
}
// Finding 1: Ratio P90/P50 si está fuera de 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)} supera el benchmark de 2.0. Indica alta dispersión en tiempos de gestión.`
});
}
// 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 muestran CV AHT > 45%, sugiriendo procesos poco estandarizados.`
});
}
// 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 promedio de ${avgTransferRate.toFixed(1)}% indica necesidad de capacitación o 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 abandono de ${abandonRate.toFixed(1)}% supera el benchmark de 5%. Revisar capacidad y tiempos de espera.`
});
}
// Finding 5: Concentración de volumen (solo si hay suficientes skills)
if (metrics.length >= 3) {
const topSkill = metrics[0];
const topSkillPct = (topSkill.volume / totalVolume) * 100;
if (topSkillPct > 30) {
findings.push({
type: 'info',
title: 'Concentración de Volumen',
text: `${topSkill.skill}: ${topSkillPct.toFixed(0)}% del total`,
dimensionId: 'volumetry_distribution',
description: `El skill "${topSkill.skill}" concentra ${topSkillPct.toFixed(1)}% del volumen total (${topSkill.volume.toLocaleString()} interacciones).`
});
}
}
return findings;
}
/**
* Generar recomendaciones desde datos reales
*/
function generateRecommendationsFromRealData(
metrics: SkillMetrics[],
hourlyDistribution?: { hourly: number[]; off_hours_pct: number; peak_hours: number[] },
totalVolume?: number
): Recommendation[] {
const recommendations: Recommendation[] = [];
// Recomendación prioritaria: Agente virtual para fuera de horario
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: 'Implementar Agente Virtual 24/7',
text: `Desplegar agente virtual para atender ${offHoursPct.toFixed(0)}% de interacciones fuera de horario`,
description: `${offHoursVolume.toLocaleString()} interacciones ocurren fuera de horario laboral (19:00-08:00). Un agente virtual puede resolver ~${estimatedContainment}% de estas consultas automáticamente, liberando recursos humanos y mejorando la experiencia del cliente con atención inmediata 24/7.`,
dimensionId: 'volumetry_distribution',
impact: `Potencial de contención: ${estimatedSavings.toLocaleString()} interacciones/período`,
timeline: '1-3 meses'
});
}
const highVariabilitySkills = metrics.filter(m => m.cv_aht > 0.45);
if (highVariabilitySkills.length > 0) {
recommendations.push({
priority: 'high',
title: 'Estandarizar Procesos',
text: `Crear guías y scripts para los ${highVariabilitySkills.length} skills con alta variabilidad`,
description: `Crear guías y scripts para los ${highVariabilitySkills.length} skills con alta variabilidad.`,
impact: 'Reducción del 20-30% en AHT'
});
}
const highVolumeSkills = metrics.filter(m => m.volume > 500);
if (highVolumeSkills.length > 0) {
recommendations.push({
priority: 'high',
title: 'Automatizar Skills de Alto Volumen',
text: `Implementar bots para los ${highVolumeSkills.length} skills con > 500 interacciones`,
description: `Implementar bots para los ${highVolumeSkills.length} skills con > 500 interacciones.`,
impact: 'Ahorro estimado del 40-60%'
});
}
return recommendations;
}
/**
* v3.3: Generar opportunities desde drilldownData (basado en colas con CV < 75%)
* Las oportunidades se clasifican en 3 categorías:
* - Automatizar: Colas con CV < 75% (estables, listas para IA)
* - Asistir: Colas con CV 75-100% (necesitan copilot)
* - Optimizar: Colas con CV > 100% (necesitan estandarización primero)
*/
/**
* v3.5: Calcular ahorro realista usando fórmula TCO por tier
*
* Fórmula TCO por tier:
* - AUTOMATE (Tier 1): 70% containment → ahorro = vol_annual × 0.70 × (CPI_humano - CPI_ia)
* - ASSIST (Tier 2): 30% efficiency → ahorro = vol_annual × 0.30 × (CPI_humano - CPI_copilot)
* - AUGMENT (Tier 3): 15% optimization → ahorro = vol_annual × 0.15 × (CPI_humano - CPI_optimizado)
* - HUMAN-ONLY (Tier 4): 0% → sin ahorro
*
* Costes por interacción (CPI):
* - CPI_humano: Se calcula desde AHT y cost_per_hour (~€4-5/interacción)
* - CPI_ia: €0.15/interacción (chatbot/IVR)
* - CPI_copilot: ~60% del CPI humano (agente asistido)
* - CPI_optimizado: ~85% del CPI humano (mejora marginal)
*/
/**
* v3.6: Constantes CPI para cálculo de ahorro TCO
* Valores alineados con metodología Beyond
*/
const CPI_CONFIG = {
CPI_HUMANO: 2.33, // €/interacción - coste actual agente humano
CPI_BOT: 0.15, // €/interacción - coste bot/automatización
CPI_ASSIST: 1.50, // €/interacción - coste con copilot
CPI_AUGMENT: 2.00, // €/interacción - coste optimizado
// Tasas de éxito/contención por tier
RATE_AUTOMATE: 0.70, // 70% contención en automatización
RATE_ASSIST: 0.30, // 30% eficiencia en asistencia
RATE_AUGMENT: 0.15 // 15% mejora en optimización
};
// Período de datos: el volumen en los datos corresponde a 11 meses, no es mensual
const DATA_PERIOD_MONTHS = 11;
/**
* v4.2: Calcular ahorro TCO realista usando fórmula explícita con CPI fijos
* IMPORTANTE: El volumen de los datos corresponde a 11 meses, por lo que:
* - Primero calculamos volumen mensual: Vol / 11
* - Luego anualizamos: × 12
* Fórmulas:
* - AUTOMATE: (Vol/11) × 12 × 70% × (CPI_humano - CPI_bot)
* - ASSIST: (Vol/11) × 12 × 30% × (CPI_humano - CPI_assist)
* - AUGMENT: (Vol/11) × 12 × 15% × (CPI_humano - CPI_augment)
* - HUMAN-ONLY: 0€
*/
function calculateRealisticSavings(
volume: number,
_annualCost: number, // Mantenido para compatibilidad pero no usado
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;
// Convertir volumen del período (11 meses) a volumen anual
const annualVolume = (volume / DATA_PERIOD_MONTHS) * 12;
switch (tier) {
case 'AUTOMATE':
// Ahorro = VolAnual × 70% × (CPI_humano - CPI_bot)
return Math.round(annualVolume * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT));
case 'ASSIST':
// Ahorro = VolAnual × 30% × (CPI_humano - CPI_assist)
return Math.round(annualVolume * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST));
case 'AUGMENT':
// Ahorro = VolAnual × 15% × (CPI_humano - 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 iniciativas por potencial económico (todos los tiers, no solo AUTOMATE)
// Cada cola = 1 burbuja con su score real y ahorro TCO real según su tier
// Extraer todas las colas con su skill padre (excluir HUMAN-ONLY, no tienen ahorro)
const allQueues = drilldownData.flatMap(skill =>
skill.originalQueues
.filter(q => q.tier !== 'HUMAN-ONLY') // HUMAN-ONLY no genera ahorro
.map(q => ({
...q,
skillName: skill.skill
}))
);
if (allQueues.length === 0) {
console.warn('⚠️ No hay colas con potencial de ahorro para mostrar en Opportunity Matrix');
return [];
}
// Calcular ahorro TCO por cola individual según su tier
const queuesWithSavings = allQueues.map(q => {
const savings = calculateRealisticSavings(q.volume, q.annualCost || 0, q.tier);
return { ...q, savings };
});
// Ordenar por ahorro descendente
queuesWithSavings.sort((a, b) => b.savings - a.savings);
// Calcular max savings para escalar impact a 0-10
const maxSavings = Math.max(...queuesWithSavings.map(q => q.savings), 1);
// Mapeo de tier a dimensionId y 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'
};
// Generar oportunidades individuales (TOP 10 por potencial económico)
const opportunities: Opportunity[] = queuesWithSavings
.slice(0, 10)
.map((q, idx) => {
// Impact: ahorro escalado a 0-10
const impactRaw = (q.savings / maxSavings) * 10;
const impact = Math.max(1, Math.min(10, Math.round(impactRaw * 10) / 10));
// Feasibility: agenticScore directo (ya es 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} iniciativas por potencial económico (de ${allQueues.length} colas con ahorro)`);
return opportunities;
}
/**
* v3.5: Generar roadmap desde drilldownData usando sistema de Tiers
* Iniciativas estructuradas en 3 fases basadas en clasificación Tier:
* - Phase 1 (Automate): Colas tier AUTOMATE - implementación IA directa (70% containment)
* - Phase 2 (Assist): Colas tier ASSIST - copilot y asistencia (30% efficiency)
* - Phase 3 (Augment): Colas tier AUGMENT/HUMAN-ONLY - estandarización primero (15%)
*/
export function generateRoadmapFromDrilldown(drilldownData: DrilldownDataPoint[], costPerHour: number): RoadmapInitiative[] {
const initiatives: RoadmapInitiative[] = [];
let initCounter = 1;
// Extraer y clasificar todas las colas 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');
// Calcular métricas 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 obtener top skills por volumen
const getTopSkillNames = (queues: typeof allQueues, limit: number = 3): string[] => {
const skillVolumes = new Map<string, number>();
queues.forEach(q => {
skillVolumes.set(q.skillName, (skillVolumes.get(q.skillName) || 0) + q.volume);
});
return Array.from(skillVolumes.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 = getTopSkillNames(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 colas con score muy alto (>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} colas score ≥8`,
phase: RoadmapPhase.Automate,
timeline: 'Q1 2026',
investment: Math.round(hsSavings * 0.3), // Inversión = 30% del ahorro
resources: ['1x Bot Developer', 'API Integration', 'QA Team'],
dimensionId: 'agentic_readiness',
risk: 'low',
skillsImpacted: getTopSkillNames(highScoreQueues, 2),
volumeImpacted: hsVolume,
kpiObjective: `Contener 70% del volumen vía chatbot`,
rationale: `${highScoreQueues.length} colas tier AUTOMATE con score promedio ${avgScore.toFixed(1)}/10. Métricas óptimas para automatización completa.`,
savingsDetail: `70% containment × (CPI humano - CPI IA) = ${hsSavings.toLocaleString()}€/año`,
estimatedSavings: hsSavings,
resourceHours: 400
});
}
// IVR para resto de colas 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} colas 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: getTopSkillNames(otherAutomateQueues, 2),
volumeImpacted: oaVolume,
kpiObjective: `Pre-calificar y desviar 70% a self-service`,
rationale: `${otherAutomateQueues.length} colas tier AUTOMATE listas para IVR con NLU.`,
savingsDetail: `70% containment × diferencial CPI = ${oaSavings.toLocaleString()}€/año`,
estimatedSavings: oaSavings,
resourceHours: 320
});
}
}
// ============ PHASE 2: ASSIST (Tier ASSIST - 30% efficiency) ============
if (assistQueues.length > 0) {
const topSkills = getTopSkillNames(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} colas 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: `Reducir AHT 30% con sugerencias IA`,
rationale: `${assistQueues.length} colas tier ASSIST (score ${avgScore.toFixed(1)}/10) se benefician de copilot contextual.`,
savingsDetail: `30% efficiency × diferencial CPI = ${realisticSavings.toLocaleString()}€/año`,
estimatedSavings: realisticSavings,
resourceHours: 360
});
// Copilot para agentes si hay volumen alto
if (assistVolume > 50000) {
const copilotSavings = Math.round(realisticSavings * 0.6);
initiatives.push({
id: `init-${initCounter++}`,
name: `Copilot IA para agentes (${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: `Reducir variabilidad y migrar colas a tier AUTOMATE`,
rationale: `Copilot pre-llena campos, sugiere respuestas y guía al agente para estandarizar.`,
savingsDetail: `Mejora efficiency 30% en ${assistVolume.toLocaleString()} int/mes`,
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 = getTopSkillNames(optimizeQueues);
const avgScore = optimizeQueues.reduce((sum, q) => sum + q.agenticScore, 0) / optimizeQueues.length;
// v3.5: Ahorro REALISTA (muy conservador para AUGMENT)
const realisticSavings = calculateRealisticSavings(optimizeVolume, optimizeCost, 'AUGMENT');
// Estandarización de procesos
initiatives.push({
id: `init-${initCounter++}`,
name: `Estandarización (${optimizeQueues.length} colas variables)`,
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: `Reducir CV para migrar colas a tier ASSIST/AUTOMATE`,
rationale: `${optimizeQueues.length} colas tier AUGMENT/HUMAN (score ${avgScore.toFixed(1)}/10) requieren rediseño de procesos.`,
savingsDetail: `15% optimización = ${realisticSavings.toLocaleString()}€/año (conservador)`,
estimatedSavings: realisticSavings,
resourceHours: 400
});
// Automatización post-estandarización (futuro)
if (optimizeVolume > 30000) {
const futureSavings = calculateRealisticSavings(Math.round(optimizeVolume * 0.4), Math.round(optimizeCost * 0.4), 'ASSIST');
initiatives.push({
id: `init-${initCounter++}`,
name: `Automatización post-estandarización`,
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: `Automatizar 40% del volumen tras estandarización`,
rationale: `Una vez reducido CV, las colas serán aptas para automatización.`,
savingsDetail: `Potencial futuro: ${futureSavings.toLocaleString()}€/año`,
estimatedSavings: futureSavings,
resourceHours: 480
});
}
}
return initiatives;
}
/**
* @deprecated v3.3 - Usar generateOpportunitiesFromDrilldown en su lugar
* Generar opportunities desde datos reales
*/
function generateOpportunitiesFromRealData(metrics: SkillMetrics[], costPerHour: number): Opportunity[] {
// Encontrar el máximo ahorro para calcular impacto relativo
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% de ahorro potencial
// Impacto: relativo al mayor ahorro (escala 1-10)
const impactRaw = (potentialSavings / maxSavings) * 10;
const impact = Math.max(3, Math.min(10, Math.round(impactRaw)));
// Feasibilidad: basada en CV y transfer_rate (baja variabilidad = alta feasibilidad)
const feasibilityRaw = 10 - (m.cv_aht * 5) - (m.transfer_rate / 10);
const feasibility = Math.max(3, Math.min(10, Math.round(feasibilityRaw)));
// Determinar dimensión según características
let dimensionId: string;
if (m.cv_aht < 0.3 && m.transfer_rate < 15) {
dimensionId = 'agentic_readiness'; // Listo para automatizar
} else if (m.cv_aht < 0.5) {
dimensionId = 'effectiveness_resolution'; // Puede mejorar con asistencia
} else {
dimensionId = 'complexity_predictability'; // Necesita optimización
}
// Nombre descriptivo
const prefix = m.cv_aht < 0.3 && m.transfer_rate < 15
? 'Automatizar '
: m.cv_aht < 0.5
? 'Asistir con IA en '
: 'Optimizar procesos en ';
return {
id: `opp-${index + 1}`,
name: `${prefix}${m.skill}`,
impact,
feasibility,
savings: Math.round(potentialSavings),
dimensionId,
customer_segment: 'medium' as CustomerSegment
};
});
}
/**
* Generar roadmap desde opportunities y métricas de skills
* v3.0: Iniciativas conectadas a skills reales con volumeImpacted, kpiObjective, rationale
*/
function generateRoadmapFromRealData(opportunities: Opportunity[], metrics?: SkillMetrics[]): RoadmapInitiative[] {
// Ordenar por savings descendente para priorizar
const sortedOpps = [...opportunities].sort((a, b) => (b.savings || 0) - (a.savings || 0));
// Crear mapa de métricas por skill para lookup rápido
const metricsMap = new Map<string, SkillMetrics>();
if (metrics) {
for (const m of metrics) {
metricsMap.set(m.skill.toLowerCase(), m);
}
}
// Helper para obtener métricas 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 con alto potencial de automatización
const wave1Opps = sortedOpps.slice(0, 2);
for (const opp of wave1Opps) {
const skillName = opp.name?.replace(/^(Automatizar |Asistir con IA en |Optimizar procesos en )/, '') || `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;
// Determinar tipo de iniciativa basado en características del skill
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
? `Automatizar ${Math.round(offHoursPct)}% consultas fuera de horario`
: `Desviar 25% a self-service para gestiones simples`,
rationale: hasOffHoursOpportunity
? `${Math.round(offHoursPct)}% del volumen ocurre fuera de horario. Chatbot puede resolver consultas de estado sin agente.`
: `CV AHT ${Math.round(cvAht)}% indica procesos variables. IVR puede pre-cualificar y resolver casos simples.`,
savingsDetail: `Automatización ${Math.round(offHoursPct)}% volumen fuera horario`,
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(/^(Automatizar |Asistir con IA en |Optimizar procesos en )/, '') || '');
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: 'Knowledge Base dinámica con IA',
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: 'Reducir Hold Time 30% mediante sugerencias en tiempo real',
rationale: 'FCR bajo indica que agentes no encuentran información rápidamente. KB con IA sugiere respuestas contextuales.',
savingsDetail: `Reducción Hold Time 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(/^(Automatizar |Asistir con IA en |Optimizar procesos en )/, '') || '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: `Reducir AHT 15% y CV AHT de ${Math.round(cvAht)}% a <80%`,
rationale: `Skill con alto volumen y variabilidad. Copilot puede pre-llenar formularios, sugerir respuestas y guiar al agente.`,
savingsDetail: `Reducción AHT 15% + mejora FCR 10%`,
estimatedSavings: savings,
resourceHours: 600
});
initCounter++;
}
// WAVE 3: Augment - Estandarización y cobertura extendida
const wave3Opps = sortedOpps.slice(4, 6);
// Iniciativa 1: Estandarización (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(/^(Automatizar |Asistir con IA en |Optimizar procesos en )/, '') || '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: `Estandarización procesos ${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: `Reducir CV AHT de ${Math.round(cvAht)}% a <100%`,
rationale: `CV AHT ${Math.round(cvAht)}% indica procesos no estandarizados. Requiere rediseño y documentación antes de automatizar.`,
savingsDetail: `Estandarización reduce variabilidad y habilita automatización futura`,
estimatedSavings: savings,
resourceHours: 440
});
initCounter++;
}
// Iniciativa 2: Cobertura nocturna (si hay volumen fuera de horario)
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 agentes 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: 'Cobertura 24/7 con 60% resolución automática nocturna',
rationale: `${Math.round(totalOffHoursVolume).toLocaleString()} interacciones fuera de horario. Agente virtual puede resolver consultas y programar callbacks.`,
savingsDetail: `Cobertura 24/7 sin incremento plantilla nocturna`,
estimatedSavings: Math.round(offHoursSavings),
resourceHours: 600
});
}
return initiatives;
}
/**
* v3.10: Generar economic model desde datos reales
* ALINEADO CON ROADMAP: Usa modelo TCO con CPI por tier
* - AUTOMATE: 70% × (€2.33 - €0.15) = €1.526/interacción
* - ASSIST: 30% × (€2.33 - €1.50) = €0.249/interacción
* - AUGMENT: 15% × (€2.33 - €2.00) = €0.050/interacción
*/
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;
// Tasas de contención/deflection por 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, calcular ahorro por tier real
if (drilldownData && drilldownData.length > 0) {
drilldownData.forEach(skill => {
skill.originalQueues.forEach(queue => {
volumeByTier[queue.tier] += queue.volume;
});
});
// Ahorro anual = Volumen × 12 meses × Rate × Diferencial CPI
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 coste total (legacy)
annualSavingsTCO = Math.round(totalCost * 0.35);
}
// Inversión inicial: del Roadmap alineado
// 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 conservador Wave 1-2
initialInvestment = 82000; // €47K + €35K
}
// Costes recurrentes anuales (alineado con Roadmap)
// Wave 2: €40K, Wave 3: €78K, Wave 4: €108K
const recurrentCostAnnual = drilldownData && drilldownData.length > 0
? Math.round(initialInvestment * 0.5) // 50% de inversión como recurrente
: Math.round(initialInvestment * 0.15);
// Margen neto anual (ahorro - recurrente)
const netAnnualSavings = annualSavingsTCO - recurrentCostAnnual;
// Payback: Implementación + Recuperación (alineado con Roadmap v3.9)
const mesesImplementacion = 9; // Wave 1 (6m) + mitad Wave 2 (3m/2)
const margenMensual = netAnnualSavings / 12;
const mesesRecuperacion = margenMensual > 0 ? Math.ceil(initialInvestment / margenMensual) : -1;
const paybackMonths = margenMensual > 0 ? mesesImplementacion + mesesRecuperacion : -1;
// ROI 3 años: ((Ahorro×3) - (Inversión + Recurrente×3)) / (Inversión + Recurrente×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/mes)`,
amount: savingsAUTOMATE,
percentage: Math.round((savingsAUTOMATE / totalSav) * 100)
});
}
if (savingsASSIST > 0) {
savingsBreakdown.push({
category: `ASSIST (${volumeByTier.ASSIST.toLocaleString()} int/mes)`,
amount: savingsASSIST,
percentage: Math.round((savingsASSIST / totalSav) * 100)
});
}
if (savingsAUGMENT > 0) {
savingsBreakdown.push({
category: `AUGMENT (${volumeByTier.AUGMENT.toLocaleString()} int/mes)`,
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: `Reducción AHT 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: 'Desarrollo e implementación', 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 desde datos reales
* BENCHMARKS SECTOR AÉREO: 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
// FCR Técnico: 100 - transfer_rate (ponderado por volumen)
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 interacciones
const totalCost = metrics.reduce((sum, m) => sum + m.total_cost, 0);
const avgCPI = totalInteractions > 0 ? totalCost / totalInteractions : 3.5;
// Calcular percentiles basados en benchmarks sector aéreo
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: 'Coste/Interacción',
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
}
];
}