Files
BeyondCXAnalytics_AE/frontend/utils/analysisGenerator.ts
Susana 8082a14e1b fix: Corregir cálculo de transfer_rate en métricas
- transfer_rate ahora muestra el % real de transferencias
- FCR = 100 - transfer_rate (resolución en primer contacto)
- Antes ambos mostraban el mismo valor (FCR)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:48:43 +00:00

1119 lines
43 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.
// analysisGenerator.ts - v2.0 con 6 dimensiones
import type { AnalysisData, Kpi, DimensionAnalysis, HeatmapDataPoint, Opportunity, RoadmapInitiative, EconomicModelData, BenchmarkDataPoint, Finding, Recommendation, TierKey, CustomerSegment } from '../types';
import { generateAnalysisFromRealData } from './realDataAnalysis';
import { RoadmapPhase } from '../types';
import { BarChartHorizontal, Zap, Target, Brain, Bot } from 'lucide-react';
import { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2';
import { callAnalysisApiRaw } from './apiClient';
import {
mapBackendResultsToAnalysisData,
buildHeatmapFromBackend,
} from './backendMapper';
const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min;
const randomFloat = (min: number, max: number, decimals: number) => parseFloat((Math.random() * (max - min) + min).toFixed(decimals));
const randomFromList = <T,>(arr: T[]): T => arr[Math.floor(Math.random() * arr.length)];
// Distribución normal (Box-Muller transform)
const normalRandom = (mean: number, std: number): number => {
const u1 = Math.random();
const u2 = Math.random();
const z0 = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
return mean + std * z0;
};
const getScoreColor = (score: number): 'green' | 'yellow' | 'red' => {
if (score >= 80) return 'green';
if (score >= 60) return 'yellow';
return 'red';
};
// v3.0: 5 DIMENSIONES VIABLES
const DIMENSIONS_CONTENT = {
volumetry_distribution: {
icon: BarChartHorizontal,
titles: ["Volumetría & Distribución", "Análisis de la Demanda"],
summaries: {
good: ["El volumen de interacciones se alinea con las previsiones, permitiendo una planificación de personal precisa.", "La distribución horaria es uniforme con picos predecibles. Concentración Pareto equilibrada."],
medium: ["Existen picos de demanda imprevistos que generan caídas en el nivel de servicio.", "Alta concentración en pocas colas (>80% en 20% de colas), riesgo de cuellos de botella."],
bad: ["Desajuste crónico entre el forecast y el volumen real, resultando en sobrecostes o mal servicio.", "Distribución horaria muy irregular con múltiples picos impredecibles."]
},
kpis: [
{ label: "Volumen Mensual", value: `${randomInt(5000, 25000).toLocaleString('es-ES')}` },
{ label: "% Fuera de Horario", value: `${randomInt(15, 45)}%` },
],
},
operational_efficiency: {
icon: Zap,
titles: ["Eficiencia Operativa", "Optimización de Tiempos"],
summaries: {
good: ["El ratio P90/P50 es bajo (<1.5), indicando tiempos consistentes y procesos estandarizados.", "Tiempos de espera, hold y ACW bien controlados, maximizando la productividad."],
medium: ["El ratio P90/P50 es moderado (1.5-2.0), existen casos outliers que afectan la eficiencia.", "El tiempo de hold es ligeramente elevado, sugiriendo mejoras en acceso a información."],
bad: ["Alto ratio P90/P50 (>2.0), indicando alta variabilidad en tiempos de gestión.", "Tiempos de ACW y hold prolongados indican procesos manuales ineficientes."]
},
kpis: [
{ label: "AHT P50", value: `${randomInt(280, 450)}s` },
{ label: "Ratio P90/P50", value: `${randomFloat(1.2, 2.5, 2)}` },
],
},
effectiveness_resolution: {
icon: Target,
titles: ["Efectividad & Resolución", "Calidad del Servicio"],
summaries: {
good: ["FCR proxy >85%, mínima repetición de contactos a 7 días.", "Baja tasa de transferencias (<10%) y llamadas problemáticas (<5%)."],
medium: ["FCR proxy 70-85%, hay oportunidad de reducir recontactos.", "Tasa de transferencias moderada (10-20%), concentradas en ciertas colas."],
bad: ["FCR proxy <70%, alto volumen de recontactos a 7 días.", "Alta tasa de llamadas problemáticas (>15%) y transferencias excesivas."]
},
kpis: [
{ label: "FCR Proxy 7d", value: `${randomInt(65, 92)}%` },
{ label: "Tasa Transfer", value: `${randomInt(5, 25)}%` },
],
},
complexity_predictability: {
icon: Brain,
titles: ["Complejidad & Predictibilidad", "Análisis de Variabilidad"],
summaries: {
good: ["Baja variabilidad AHT (ratio P90/P50 <1.5), proceso altamente predecible.", "Diversidad de tipificaciones controlada, bajo % de llamadas con múltiples holds."],
medium: ["Variabilidad AHT moderada, algunos casos outliers afectan la predictibilidad.", "% llamadas con múltiples holds elevado (15-30%), indicando complejidad."],
bad: ["Alta variabilidad AHT (ratio >2.0), proceso impredecible y difícil de automatizar.", "Alta diversidad de tipificaciones y % transferencias, indicando alta complejidad."]
},
kpis: [
{ label: "Ratio P90/P50", value: `${randomFloat(1.2, 2.5, 2)}` },
{ label: "% Transferencias", value: `${randomInt(5, 30)}%` },
],
},
agentic_readiness: {
icon: Bot,
titles: ["Agentic Readiness", "Potencial de Automatización"],
summaries: {
good: ["Score 8-10: Excelente candidato para automatización completa con agentes IA.", "Alto volumen, baja variabilidad, pocas transferencias. Proceso repetitivo y predecible."],
medium: ["Score 5-7: Candidato para asistencia con IA (copilot) o automatización parcial.", "Volumen moderado con algunas complejidades que requieren supervisión humana."],
bad: ["Score 0-4: Requiere optimización previa antes de automatizar.", "Alta complejidad, baja repetitividad o variabilidad excesiva."]
},
kpis: [
{ label: "Score Global", value: `${randomFloat(3.0, 9.5, 1)}/10` },
{ label: "Categoría", value: randomFromList(['Automatizar', 'Asistir', 'Optimizar']) },
],
},
};
const KEY_FINDINGS: Finding[] = [
{
text: "El ratio P90/P50 de AHT es alto (>2.0) en varias colas, indicando alta variabilidad.",
dimensionId: 'operational_efficiency',
type: 'warning',
title: 'Alta Variabilidad en Tiempos',
description: 'Procesos poco estandarizados generan tiempos impredecibles y afectan la planificación.',
impact: 'high'
},
{
text: "Un 22% de las transferencias desde 'Soporte Técnico N1' hacia otras colas son incorrectas.",
dimensionId: 'effectiveness_resolution',
type: 'warning',
title: 'Enrutamiento Incorrecto',
description: 'Existe un problema de routing que genera ineficiencias y experiencia pobre del cliente.',
impact: 'high'
},
{
text: "El pico de demanda de los lunes por la mañana provoca una caída del Nivel de Servicio al 65%.",
dimensionId: 'volumetry_distribution',
type: 'critical',
title: 'Crisis de Capacidad (Lunes por la mañana)',
description: 'Los lunes 8-11h generan picos impredecibles que agotan la capacidad disponible.',
impact: 'high'
},
{
text: "El 28% de las interacciones ocurren fuera del horario laboral estándar (8-18h).",
dimensionId: 'volumetry_distribution',
type: 'info',
title: 'Demanda Fuera de Horario',
description: 'Casi 1 de 3 interacciones se produce fuera del horario laboral, requiriendo cobertura extendida.',
impact: 'medium'
},
{
text: "Las consultas sobre 'estado del pedido' representan el 30% de las interacciones y tienen alta repetitividad.",
dimensionId: 'agentic_readiness',
type: 'info',
title: 'Oportunidad de Automatización: Estado de Pedido',
description: 'Volumen significativo en consultas altamente repetitivas y automatizables (Score Agentic >8).',
impact: 'high'
},
{
text: "FCR proxy <75% en colas de facturación, alto recontacto a 7 días.",
dimensionId: 'effectiveness_resolution',
type: 'warning',
title: 'Baja Resolución en Facturación',
description: 'El equipo de facturación tiene alto % de recontactos, indicando problemas de resolución.',
impact: 'high'
},
{
text: "Alta diversidad de tipificaciones y >20% llamadas con múltiples holds en colas complejas.",
dimensionId: 'complexity_predictability',
type: 'warning',
title: 'Alta Complejidad en Ciertas Colas',
description: 'Colas con alta complejidad requieren optimización antes de considerar automatización.',
impact: 'medium'
},
];
const RECOMMENDATIONS: Recommendation[] = [
{
text: "Estandarizar procesos en colas con alto ratio P90/P50 para reducir variabilidad.",
dimensionId: 'operational_efficiency',
priority: 'high',
title: 'Estandarización de Procesos',
description: 'Implementar scripts y guías paso a paso para reducir la variabilidad en tiempos de gestión.',
impact: 'Reducción ratio P90/P50: 20-30%, Mejora predictibilidad',
timeline: '3-4 semanas'
},
{
text: "Desarrollar un bot de estado de pedido para WhatsApp para desviar el 30% de las consultas.",
dimensionId: 'agentic_readiness',
priority: 'high',
title: 'Bot Automatizado de Seguimiento de Pedidos',
description: 'Implementar ChatBot en WhatsApp para consultas con alto Agentic Score (>8).',
impact: 'Reducción de volumen: 20-30%, Ahorro anual: €40-60K',
timeline: '1-2 meses'
},
{
text: "Revisar la planificación de personal (WFM) para los lunes, añadiendo recursos flexibles.",
dimensionId: 'volumetry_distribution',
priority: 'high',
title: 'Ajuste de Plantilla (WFM)',
description: 'Reposicionar agentes y añadir recursos part-time para los lunes 8-11h.',
impact: 'Mejora del NSL: +15-20%, Coste adicional: €5-8K/mes',
timeline: '1 mes'
},
{
text: "Crear una Knowledge Base más robusta para reducir hold time y mejorar FCR.",
dimensionId: 'effectiveness_resolution',
priority: 'high',
title: 'Mejora de Acceso a Información',
description: 'Desarrollar una KB centralizada para reducir búsquedas y mejorar resolución en primer contacto.',
impact: 'Reducción hold time: 15-25%, Mejora FCR: 5-10%',
timeline: '6-8 semanas'
},
{
text: "Implementar cobertura 24/7 con agentes virtuales para el 28% de interacciones fuera de horario.",
dimensionId: 'volumetry_distribution',
priority: 'medium',
title: 'Cobertura 24/7 con IA',
description: 'Desplegar agentes virtuales para gestionar interacciones nocturnas y fines de semana.',
impact: 'Captura de demanda: 20-25%, Coste incremental: €15-20K/mes',
timeline: '2-3 meses'
},
{
text: "Simplificar tipificaciones y reducir complejidad en colas problemáticas.",
dimensionId: 'complexity_predictability',
priority: 'medium',
title: 'Reducción de Complejidad',
description: 'Consolidar tipificaciones y simplificar flujos para mejorar predictibilidad.',
impact: 'Reducción de complejidad: 20-30%, Mejora Agentic Score',
timeline: '4-6 semanas'
},
];
// === RECOMENDACIONES BASADAS EN DATOS REALES ===
const MAX_RECOMMENDATIONS = 4;
const generateRecommendationsFromData = (
analysis: AnalysisData
): Recommendation[] => {
const dimensions = analysis.dimensions || [];
const dimScoreMap = new Map<string, number>();
dimensions.forEach((d) => {
if (d.id && typeof d.score === 'number') {
dimScoreMap.set(d.id, d.score);
}
});
const overallScore =
typeof analysis.overallHealthScore === 'number'
? analysis.overallHealthScore
: 70;
const econ = analysis.economicModel;
const annualSavings = econ?.annualSavings ?? 0;
const currentCost = econ?.currentAnnualCost ?? 0;
// Relevancia por recomendación
const scoredTemplates = RECOMMENDATIONS.map((tpl, index) => {
const dimId = tpl.dimensionId || 'overall';
const dimScore = dimScoreMap.get(dimId) ?? overallScore;
let relevance = 0;
// 1) Dimensiones débiles => más relevancia
if (dimScore < 60) relevance += 3;
else if (dimScore < 75) relevance += 2;
else if (dimScore < 85) relevance += 1;
// 2) Prioridad declarada en la plantilla
if (tpl.priority === 'high') relevance += 2;
else if (tpl.priority === 'medium') relevance += 1;
// 3) Refuerzo en función del potencial económico
if (
annualSavings > 0 &&
currentCost > 0 &&
annualSavings / currentCost > 0.15 &&
dimId === 'economy'
) {
relevance += 2;
}
// 4) Ligera penalización si la dimensión ya está muy bien (>85)
if (dimScore > 85) relevance -= 1;
return {
tpl,
relevance,
index, // por si queremos desempatar
};
});
// Filtramos las que no aportan nada (relevance <= 0)
let filtered = scoredTemplates.filter((s) => s.relevance > 0);
// Si ninguna pasa el filtro (por ejemplo, todo muy bien),
// nos quedamos al menos con 23 de las de mayor prioridad
if (filtered.length === 0) {
filtered = scoredTemplates
.slice()
.sort((a, b) => {
const prioWeight = (p?: 'high' | 'medium' | 'low') => {
if (p === 'high') return 3;
if (p === 'medium') return 2;
return 1;
};
return (
prioWeight(b.tpl.priority) - prioWeight(a.tpl.priority)
);
})
.slice(0, MAX_RECOMMENDATIONS);
} else {
// Ordenamos por relevancia (desc), y en empate, por orden original
filtered.sort((a, b) => {
if (b.relevance !== a.relevance) {
return b.relevance - a.relevance;
}
return a.index - b.index;
});
}
const selected = filtered.slice(0, MAX_RECOMMENDATIONS).map((s) => s.tpl);
// Mapear a tipo Recommendation completo
return selected.map((rec, i): Recommendation => ({
priority:
rec.priority || (i === 0 ? ('high' as const) : ('medium' as const)),
title: rec.title || 'Recomendación',
description: rec.description || rec.text,
impact:
rec.impact ||
'Mejora estimada del 10-20% en los KPIs clave.',
timeline: rec.timeline || '4-8 semanas',
// campos obligatorios:
text:
rec.text ||
rec.description ||
'Recomendación prioritaria basada en el análisis de datos.',
dimensionId: rec.dimensionId || 'overall',
}));
};
// === FINDINGS BASADOS EN DATOS REALES ===
const MAX_FINDINGS = 5;
const generateFindingsFromData = (
analysis: AnalysisData
): Finding[] => {
const dimensions = analysis.dimensions || [];
const dimScoreMap = new Map<string, number>();
dimensions.forEach((d) => {
if (d.id && typeof d.score === 'number') {
dimScoreMap.set(d.id, d.score);
}
});
const overallScore =
typeof analysis.overallHealthScore === 'number'
? analysis.overallHealthScore
: 70;
// Miramos volumetría para reforzar algunos findings
const volumetryDim = dimensions.find(
(d) => d.id === 'volumetry_distribution'
);
const offHoursPct =
volumetryDim?.distribution_data?.off_hours_pct ?? 0;
// Relevancia por finding
const scoredTemplates = KEY_FINDINGS.map((tpl, index) => {
const dimId = tpl.dimensionId || 'overall';
const dimScore = dimScoreMap.get(dimId) ?? overallScore;
let relevance = 0;
// 1) Dimensiones débiles => más relevancia
if (dimScore < 60) relevance += 3;
else if (dimScore < 75) relevance += 2;
else if (dimScore < 85) relevance += 1;
// 2) Tipo de finding (critical > warning > info)
if (tpl.type === 'critical') relevance += 3;
else if (tpl.type === 'warning') relevance += 2;
else relevance += 1;
// 3) Impacto (high > medium > low)
if (tpl.impact === 'high') relevance += 2;
else if (tpl.impact === 'medium') relevance += 1;
// 4) Refuerzo en volumetría si hay mucha demanda fuera de horario
if (
offHoursPct > 0.25 &&
tpl.dimensionId === 'volumetry_distribution'
) {
relevance += 2;
if (
tpl.title?.toLowerCase().includes('fuera de horario') ||
tpl.text
?.toLowerCase()
.includes('fuera del horario laboral')
) {
relevance += 1;
}
}
return {
tpl,
relevance,
index,
};
});
// Filtramos los que no aportan nada (relevance <= 0)
let filtered = scoredTemplates.filter((s) => s.relevance > 0);
// Si nada pasa el filtro, cogemos al menos algunos por prioridad/tipo
if (filtered.length === 0) {
filtered = scoredTemplates
.slice()
.sort((a, b) => {
const typeWeight = (t?: Finding['type']) => {
if (t === 'critical') return 3;
if (t === 'warning') return 2;
return 1;
};
const impactWeight = (imp?: string) => {
if (imp === 'high') return 3;
if (imp === 'medium') return 2;
return 1;
};
const scoreA =
typeWeight(a.tpl.type) + impactWeight(a.tpl.impact);
const scoreB =
typeWeight(b.tpl.type) + impactWeight(b.tpl.impact);
return scoreB - scoreA;
})
.slice(0, MAX_FINDINGS);
} else {
// Ordenamos por relevancia (desc), y en empate, por orden original
filtered.sort((a, b) => {
if (b.relevance !== a.relevance) {
return b.relevance - a.relevance;
}
return a.index - b.index;
});
}
const selected = filtered.slice(0, MAX_FINDINGS).map((s) => s.tpl);
// Mapear a tipo Finding completo
return selected.map((finding, i): Finding => ({
type:
finding.type ||
(i === 0
? ('warning' as const)
: ('info' as const)),
title: finding.title || 'Hallazgo',
description: finding.description || finding.text,
// campos obligatorios:
text:
finding.text ||
finding.description ||
'Hallazgo relevante basado en datos.',
dimensionId: finding.dimensionId || 'overall',
impact: finding.impact,
}));
};
const generateFindingsFromTemplates = (): Finding[] => {
return [
...new Set(
Array.from({ length: 3 }, () => randomFromList(KEY_FINDINGS))
),
].map((finding, i): Finding => ({
type: finding.type || (i === 0 ? 'warning' : 'info'),
title: finding.title || 'Hallazgo',
description: finding.description || finding.text,
// campos obligatorios:
text: finding.text || finding.description || 'Hallazgo relevante',
dimensionId: finding.dimensionId || 'overall',
impact: finding.impact,
}));
};
const generateRecommendationsFromTemplates = (): Recommendation[] => {
return [
...new Set(
Array.from({ length: 3 }, () => randomFromList(RECOMMENDATIONS))
),
].map((rec, i): Recommendation => ({
priority: rec.priority || (i === 0 ? 'high' : 'medium'),
title: rec.title || 'Recomendación',
description: rec.description || rec.text,
impact: rec.impact || 'Mejora estimada del 20-30%',
timeline: rec.timeline || '1-2 semanas',
// campos obligatorios:
text: rec.text || rec.description || 'Recomendación prioritaria',
dimensionId: rec.dimensionId || 'overall',
}));
};
// v2.0: Generar distribución horaria realista
const generateHourlyDistribution = (): number[] => {
// Distribución con picos en 9-11h y 14-17h
const distribution = Array(24).fill(0).map((_, hour) => {
if (hour >= 9 && hour <= 11) return randomInt(800, 1200); // Pico mañana
if (hour >= 14 && hour <= 17) return randomInt(700, 1000); // Pico tarde
if (hour >= 8 && hour <= 18) return randomInt(300, 600); // Horario laboral
return randomInt(50, 200); // Fuera de horario
});
return distribution;
};
// v2.0: Calcular % fuera de horario
const calculateOffHoursPct = (hourly_distribution: number[]): number => {
const total = hourly_distribution.reduce((a, b) => a + b, 0);
if (total === 0) return 0; // Evitar división por cero
const off_hours = hourly_distribution.slice(0, 8).reduce((a, b) => a + b, 0) +
hourly_distribution.slice(19, 24).reduce((a, b) => a + b, 0);
return off_hours / total;
};
// v2.0: Identificar horas pico
const identifyPeakHours = (hourly_distribution: number[]): number[] => {
if (!hourly_distribution || hourly_distribution.length === 0) return [];
const sorted = [...hourly_distribution].sort((a, b) => b - a);
const threshold = sorted[Math.min(2, sorted.length - 1)] || 0; // Top 3 o máximo disponible
return hourly_distribution
.map((val, idx) => val >= threshold ? idx : -1)
.filter(idx => idx !== -1);
};
// v2.1: Generar heatmap con nueva lógica de transformación (3 dimensiones)
const generateHeatmapData = (
costPerHour: number = 20,
avgCsat: number = 85,
segmentMapping?: { high_value_queues: string[]; medium_value_queues: string[]; low_value_queues: string[] }
): HeatmapDataPoint[] => {
const skills = ['Ventas Inbound', 'Soporte Técnico N1', 'Facturación', 'Retención', 'VIP Support', 'Trial Support'];
const COST_PER_SECOND = costPerHour / 3600;
return skills.map(skill => {
const volume = randomInt(800, 5500); // Volumen mensual (ampliado para cubrir rango de repetitividad)
// Simular raw data: duration_talk, hold_time, wrap_up_time
const avg_talk_time = randomInt(240, 450); // segundos
const avg_hold_time = randomInt(15, 80); // segundos
const avg_wrap_up = randomInt(10, 50); // segundos
const aht_mean = avg_talk_time + avg_hold_time + avg_wrap_up; // AHT promedio
// Simular desviación estándar del AHT (para CV)
const aht_std = randomInt(Math.round(aht_mean * 0.15), Math.round(aht_mean * 0.60)); // 15-60% del AHT
const cv_aht = aht_std / aht_mean; // Coeficiente de Variación
// Transfer rate (para complejidad inversa)
const transfer_rate = randomInt(5, 35); // %
const fcr_approx = 100 - transfer_rate; // FCR aproximado
// Coste anual
const annual_volume = volume * 12;
const annual_cost = Math.round(annual_volume * aht_mean * COST_PER_SECOND);
// === NUEVA LÓGICA: 3 DIMENSIONES ===
// Dimensión 1: Predictibilidad (Proxy: CV del AHT)
// Fórmula: MAX(0, MIN(10, 10 - ((CV - 0.3) / 1.2 * 10)))
const predictability_score = Math.max(0, Math.min(10,
10 - ((cv_aht - 0.3) / 1.2 * 10)
));
// Dimensión 2: Complejidad Inversa (Proxy: Tasa de Transferencia)
// Fórmula: MAX(0, MIN(10, 10 - ((T - 0.05) / 0.25 * 10)))
const complexity_inverse_score = Math.max(0, Math.min(10,
10 - ((transfer_rate / 100 - 0.05) / 0.25 * 10)
));
// Dimensión 3: Repetitividad/Impacto (Proxy: Volumen)
// > 5,000 = 10, < 100 = 0, interpolación lineal entre 100-5000
let repetitivity_score: number;
if (volume >= 5000) {
repetitivity_score = 10;
} else if (volume <= 100) {
repetitivity_score = 0;
} else {
repetitivity_score = ((volume - 100) / (5000 - 100)) * 10;
}
// Agentic Readiness Score (Promedio ponderado)
// Pesos: Predictibilidad 40%, Complejidad 35%, Repetitividad 25%
const agentic_readiness_score =
predictability_score * 0.40 +
complexity_inverse_score * 0.35 +
repetitivity_score * 0.25;
// Categoría de readiness
let readiness_category: 'automate_now' | 'assist_copilot' | 'optimize_first';
if (agentic_readiness_score >= 8.0) {
readiness_category = 'automate_now';
} else if (agentic_readiness_score >= 5.0) {
readiness_category = 'assist_copilot';
} else {
readiness_category = 'optimize_first';
}
const automation_readiness = Math.round(agentic_readiness_score * 10); // Escala 0-100 para compatibilidad
// Clasificar segmento si hay mapeo
let segment: CustomerSegment | undefined;
if (segmentMapping) {
const normalizedSkill = skill.toLowerCase();
if (segmentMapping.high_value_queues.some(q => normalizedSkill.includes(q.toLowerCase()))) {
segment = 'high';
} else if (segmentMapping.low_value_queues.some(q => normalizedSkill.includes(q.toLowerCase()))) {
segment = 'low';
} else {
segment = 'medium';
}
}
return {
skill,
segment,
volume,
aht_seconds: aht_mean, // Renombrado para compatibilidad
metrics: {
fcr: isNaN(fcr_approx) ? 0 : Math.max(0, Math.min(100, Math.round(fcr_approx))),
aht: isNaN(aht_mean) ? 0 : Math.max(0, Math.min(100, Math.round(100 - ((aht_mean - 240) / 310) * 100))),
csat: isNaN(avgCsat) ? 0 : Math.max(0, Math.min(100, Math.round(avgCsat))),
hold_time: isNaN(avg_hold_time) ? 0 : Math.max(0, Math.min(100, Math.round(100 - (avg_hold_time / 120) * 100))),
transfer_rate: isNaN(transfer_rate) ? 0 : Math.max(0, Math.min(100, Math.round(transfer_rate * 100)))
},
annual_cost,
variability: {
cv_aht: Math.round(cv_aht * 100), // Convertir a porcentaje
cv_talk_time: 0, // Deprecado en v2.1
cv_hold_time: 0, // Deprecado en v2.1
transfer_rate
},
automation_readiness,
// Nuevas dimensiones (v2.1)
dimensions: {
predictability: Math.round(predictability_score * 10) / 10,
complexity_inverse: Math.round(complexity_inverse_score * 10) / 10,
repetitivity: Math.round(repetitivity_score * 10) / 10
},
readiness_category
};
});
};
// v3.0: Oportunidades con nuevas dimensiones
const generateOpportunityMatrixData = (): Opportunity[] => {
const opportunities = [
{ id: 'opp1', name: 'Automatizar consulta de pedidos', savings: 85000, dimensionId: 'agentic_readiness', customer_segment: 'medium' as CustomerSegment },
{ id: 'opp2', name: 'Implementar Knowledge Base dinámica', savings: 45000, dimensionId: 'operational_efficiency', customer_segment: 'high' as CustomerSegment },
{ id: 'opp3', name: 'Chatbot de triaje inicial', savings: 120000, dimensionId: 'effectiveness_resolution', customer_segment: 'medium' as CustomerSegment },
{ id: 'opp4', name: 'Reducir complejidad en colas críticas', savings: 30000, dimensionId: 'complexity_predictability', customer_segment: 'high' as CustomerSegment },
{ id: 'opp5', name: 'Cobertura 24/7 con agentes virtuales', savings: 65000, dimensionId: 'volumetry_distribution', customer_segment: 'low' as CustomerSegment },
];
return opportunities.map(opp => ({ ...opp, impact: randomInt(3, 10), feasibility: randomInt(2, 9) }));
};
// v3.0: Roadmap con nuevas dimensiones
const generateRoadmapData = (): RoadmapInitiative[] => {
return [
{ id: 'r1', name: 'Chatbot de estado de pedido', phase: RoadmapPhase.Automate, timeline: 'Q1 2025', investment: 25000, resources: ['1x Bot Developer', 'API Access'], dimensionId: 'agentic_readiness', risk: 'low' },
{ id: 'r2', name: 'Implementar Knowledge Base dinámica', phase: RoadmapPhase.Assist, timeline: 'Q1 2025', investment: 15000, resources: ['1x PM', 'Content Team'], dimensionId: 'operational_efficiency', risk: 'low' },
{ id: 'r3', name: 'Agent Assist para sugerencias en real-time', phase: RoadmapPhase.Assist, timeline: 'Q2 2025', investment: 45000, resources: ['2x AI Devs', 'QA Team'], dimensionId: 'effectiveness_resolution', risk: 'medium' },
{ id: 'r4', name: 'Estandarización de procesos complejos', phase: RoadmapPhase.Augment, timeline: 'Q3 2025', investment: 30000, resources: ['Process Analyst', 'Training Team'], dimensionId: 'complexity_predictability', risk: 'medium' },
{ id: 'r5', name: 'Cobertura 24/7 con agentes virtuales', phase: RoadmapPhase.Augment, timeline: 'Q4 2025', investment: 75000, resources: ['Lead AI Engineer', 'Data Scientist'], dimensionId: 'volumetry_distribution', risk: 'high' },
];
};
// v2.0: Añadir NPV y costBreakdown
const generateEconomicModelData = (): EconomicModelData => {
const currentAnnualCost = randomInt(800000, 2500000);
const annualSavings = randomInt(150000, 500000);
const futureAnnualCost = currentAnnualCost - annualSavings;
const initialInvestment = randomInt(40000, 150000);
const paybackMonths = Math.ceil((initialInvestment / annualSavings) * 12);
const roi3yr = (((annualSavings * 3) - initialInvestment) / initialInvestment) * 100;
// NPV con tasa de descuento 10%
const discountRate = 0.10;
const npv = -initialInvestment +
(annualSavings / (1 + discountRate)) +
(annualSavings / Math.pow(1 + discountRate, 2)) +
(annualSavings / Math.pow(1 + discountRate, 3));
const savingsBreakdown = [
{ category: 'Automatización de tareas', amount: annualSavings * 0.45, percentage: 45 },
{ category: 'Eficiencia operativa', amount: annualSavings * 0.30, percentage: 30 },
{ category: 'Mejora FCR', amount: annualSavings * 0.15, percentage: 15 },
{ category: 'Reducción attrition', amount: annualSavings * 0.075, percentage: 7.5 },
{ category: 'Otros', amount: annualSavings * 0.025, percentage: 2.5 },
];
const costBreakdown = [
{ category: 'Software y licencias', amount: initialInvestment * 0.43, percentage: 43 },
{ category: 'Implementación', amount: initialInvestment * 0.29, percentage: 29 },
{ category: 'Training y change mgmt', amount: initialInvestment * 0.18, percentage: 18 },
{ category: 'Contingencia', amount: initialInvestment * 0.10, percentage: 10 },
];
return {
currentAnnualCost,
futureAnnualCost,
annualSavings,
initialInvestment,
paybackMonths,
roi3yr: parseFloat(roi3yr.toFixed(1)),
npv: Math.round(npv),
savingsBreakdown,
costBreakdown
};
};
// v2.x: Generar Opportunity Matrix a partir de datos REALES (heatmap + modelo económico)
const generateOpportunitiesFromHeatmap = (
heatmapData: HeatmapDataPoint[],
economicModel?: EconomicModelData
): Opportunity[] => {
if (!heatmapData || heatmapData.length === 0) return [];
// Ahorro anual total calculado por el backend (si existe)
const globalSavings = economicModel?.annualSavings ?? 0;
// 1) Calculamos un "peso" por skill en función de:
// - coste anual
// - ineficiencia (FCR bajo)
// - readiness (facilidad para automatizar)
const scored = heatmapData.map((h) => {
const annualCost = h.annual_cost ?? 0;
const readiness = h.automation_readiness ?? 0;
const fcrScore = h.metrics?.fcr ?? 0;
// FCR bajo => más ineficiencia
const ineffPenalty = Math.max(0, 100 - fcrScore); // 0100
// Peso base: coste alto + ineficiencia alta + readiness alto
const baseWeight =
annualCost *
(1 + ineffPenalty / 100) *
(0.3 + 0.7 * (readiness / 100));
const weight = !Number.isFinite(baseWeight) || baseWeight < 0 ? 0 : baseWeight;
return { heat: h, weight };
});
const totalWeight =
scored.reduce((sum, s) => sum + s.weight, 0) || 1;
// 2) Asignamos "savings" (ahorro potencial) por skill
const opportunitiesWithSavings = scored.map((s) => {
const { heat } = s;
const annualCost = heat.annual_cost ?? 0;
// Si el backend nos da un ahorro anual total, lo distribuimos proporcionalmente
const savings =
globalSavings > 0 && totalWeight > 0
? (globalSavings * s.weight) / totalWeight
: // Si no hay dato de ahorro global, suponemos un 20% del coste anual
annualCost * 0.2;
return {
heat,
savings: Math.max(0, savings),
};
});
const maxSavings =
opportunitiesWithSavings.reduce(
(max, s) => (s.savings > max ? s.savings : max),
0
) || 1;
// 3) Construimos cada oportunidad
return opportunitiesWithSavings.map((item, index) => {
const { heat, savings } = item;
const skillName = heat.skill || `Skill ${index + 1}`;
// Impacto: relativo al mayor ahorro
const impactRaw = (savings / maxSavings) * 10;
const impact = Math.max(
3,
Math.min(10, Math.round(impactRaw))
);
// Factibilidad base: a partir del automation_readiness (0100)
const readiness = heat.automation_readiness ?? 0;
const feasibilityRaw = (readiness / 100) * 7 + 3; // 310
const feasibility = Math.max(
3,
Math.min(10, Math.round(feasibilityRaw))
);
// Dimensión a la que lo vinculamos
const dimensionId =
readiness >= 70
? 'agentic_readiness'
: readiness >= 40
? 'effectiveness_resolution'
: 'complexity_predictability';
// Segmento de cliente (high/medium/low) si lo tenemos
const customer_segment = heat.segment;
// Nombre legible que incluye el skill -> esto ayuda a
// OpportunityMatrixPro a encontrar el skill en el heatmap
const namePrefix =
readiness >= 70
? 'Automatizar '
: readiness >= 40
? 'Augmentar con IA en '
: 'Optimizar proceso en ';
const idSlug = skillName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
return {
id: `opp_${index + 1}_${idSlug}`,
name: `${namePrefix}${skillName}`,
impact,
feasibility,
savings: Math.round(savings),
dimensionId,
customer_segment,
};
});
};
// v2.0: Añadir percentiles múltiples
const generateBenchmarkData = (): BenchmarkDataPoint[] => {
const userAHT = randomInt(380, 450);
const industryAHT = 420;
const userFCR = randomFloat(0.65, 0.78, 2);
const industryFCR = 0.72;
const userCSAT = randomFloat(4.1, 4.6, 1);
const industryCSAT = 4.3;
const userCPI = randomFloat(2.8, 4.5, 2);
const industryCPI = 3.5;
return [
{
kpi: 'AHT Promedio',
userValue: userAHT,
userDisplay: `${userAHT}s`,
industryValue: industryAHT,
industryDisplay: `${industryAHT}s`,
percentile: randomInt(40, 75),
p25: 380,
p50: 420,
p75: 460,
p90: 510
},
{
kpi: 'Tasa FCR',
userValue: userFCR,
userDisplay: `${(userFCR * 100).toFixed(0)}%`,
industryValue: industryFCR,
industryDisplay: `${(industryFCR * 100).toFixed(0)}%`,
percentile: randomInt(30, 65),
p25: 0.65,
p50: 0.72,
p75: 0.82,
p90: 0.88
},
{
kpi: 'CSAT',
userValue: userCSAT,
userDisplay: `${userCSAT}/5`,
industryValue: industryCSAT,
industryDisplay: `${industryCSAT}/5`,
percentile: randomInt(45, 80),
p25: 4.0,
p50: 4.3,
p75: 4.6,
p90: 4.8
},
{
kpi: 'Coste por Interacción (Voz)',
userValue: userCPI,
userDisplay: `${userCPI.toFixed(2)}`,
industryValue: industryCPI,
industryDisplay: `${industryCPI.toFixed(2)}`,
percentile: randomInt(50, 85),
p25: 2.8,
p50: 3.5,
p75: 4.2,
p90: 5.0
},
];
};
export const generateAnalysis = async (
tier: TierKey,
costPerHour: number = 20,
avgCsat: number = 85,
segmentMapping?: { high_value_queues: string[]; medium_value_queues: string[]; low_value_queues: string[] },
file?: File,
sheetUrl?: string,
useSynthetic?: boolean,
authHeaderOverride?: string
): Promise<AnalysisData> => {
// Si hay archivo, procesarlo
// Si hay archivo, primero intentamos usar el backend
if (file && !useSynthetic) {
console.log('📡 Processing file (API first):', file.name);
// 1) Intentar backend + mapeo
try {
const raw = await callAnalysisApiRaw({
tier,
costPerHour,
avgCsat,
segmentMapping,
file,
authHeaderOverride,
});
const mapped = mapBackendResultsToAnalysisData(raw, tier);
// Heatmap: primero lo construimos a partir de datos reales del backend
mapped.heatmapData = buildHeatmapFromBackend(
raw,
costPerHour,
avgCsat,
segmentMapping
);
// Oportunidades: AHORA basadas en heatmap real + modelo económico del backend
mapped.opportunities = generateOpportunitiesFromHeatmap(
mapped.heatmapData,
mapped.economicModel
);
// 👉 El resto sigue siendo "frontend-driven" de momento
mapped.findings = generateFindingsFromData(mapped);
mapped.recommendations = generateRecommendationsFromData(mapped);
mapped.roadmap = generateRoadmapData();
// Benchmark: de momento no tenemos datos reales -> no lo generamos en modo backend
mapped.benchmarkData = [];
console.log(
'✅ Usando resultados del backend mapeados (heatmap + opportunities reales)'
);
return mapped;
} catch (apiError: any) {
const status = apiError?.status;
const msg = (apiError as Error).message || '';
// 🔐 Si es un error de autenticación (401), NO hacemos fallback
if (status === 401 || msg.includes('401')) {
console.error(
'❌ Error de autenticación en backend, abortando análisis (sin fallback).'
);
throw apiError;
}
console.error(
'❌ Backend /analysis no disponible o mapeo incompleto, fallback a lógica local:',
apiError
);
}
// 2) Fallback completo: lógica antigua del frontend
try {
const { parseFile, validateInteractions } = await import('./fileParser');
const interactions = await parseFile(file);
const validation = validateInteractions(interactions);
if (!validation.valid) {
console.error('❌ Validation errors:', validation.errors);
throw new Error(
`Validación fallida: ${validation.errors.join(', ')}`
);
}
if (validation.warnings.length > 0) {
console.warn('⚠️ Warnings:', validation.warnings);
}
return generateAnalysisFromRealData(
tier,
interactions,
costPerHour,
avgCsat,
segmentMapping
);
} catch (error) {
console.error('❌ Error processing file:', error);
throw new Error(
`Error procesando archivo: ${(error as Error).message}`
);
}
}
// Si hay URL de Google Sheets, procesarla (TODO: implementar)
if (sheetUrl && !useSynthetic) {
console.warn('🔗 Google Sheets URL processing not implemented yet, using synthetic data');
}
// Generar datos sintéticos (fallback)
console.log('✨ Generating synthetic data');
return generateSyntheticAnalysis(tier, costPerHour, avgCsat, segmentMapping);
};
// Función auxiliar para generar análisis con datos sintéticos
const generateSyntheticAnalysis = (
tier: TierKey,
costPerHour: number = 20,
avgCsat: number = 85,
segmentMapping?: { high_value_queues: string[]; medium_value_queues: string[]; low_value_queues: string[] }
): AnalysisData => {
const overallHealthScore = randomInt(55, 95);
const summaryKpis: Kpi[] = [
{ label: "Interacciones Totales", value: randomInt(15000, 50000).toLocaleString('es-ES') },
{ label: "AHT Promedio", value: `${randomInt(300, 480)}s`, change: `-${randomInt(5, 20)}s`, changeType: 'positive' },
{ label: "Tasa FCR", value: `${randomInt(70, 88)}%`, change: `+${randomFloat(0.5, 2, 1)}%`, changeType: 'positive' },
{ label: "CSAT", value: `${randomFloat(4.1, 4.8, 1)}/5`, change: `-${randomFloat(0.1, 0.3, 1)}`, changeType: 'negative' },
];
// v3.0: 5 dimensiones viables
const dimensionKeys = ['volumetry_distribution', 'operational_efficiency', 'effectiveness_resolution', 'complexity_predictability', 'agentic_readiness'];
const dimensions: DimensionAnalysis[] = dimensionKeys.map(key => {
const content = DIMENSIONS_CONTENT[key as keyof typeof DIMENSIONS_CONTENT];
const score = randomInt(50, 98);
const status = getScoreColor(score);
const dimension: DimensionAnalysis = {
id: key,
name: key as any,
title: randomFromList(content.titles),
score,
percentile: randomInt(30, 85),
summary: randomFromList(content.summaries[status === 'green' ? 'good' : status === 'yellow' ? 'medium' : 'bad']),
kpi: randomFromList(content.kpis),
icon: content.icon,
};
// Añadir distribution_data para volumetry_distribution
if (key === 'volumetry_distribution') {
const hourly = generateHourlyDistribution();
dimension.distribution_data = {
hourly,
off_hours_pct: calculateOffHoursPct(hourly),
peak_hours: identifyPeakHours(hourly)
};
}
return dimension;
});
// v2.0: Calcular Agentic Readiness Score
let agenticReadiness = undefined;
if (tier === 'gold' || tier === 'silver') {
// Generar datos sintéticos para el algoritmo
const volumen_mes = randomInt(5000, 25000);
const aht_values = Array.from({ length: 100 }, () =>
Math.max(180, normalRandom(420, 120)) // Media 420s, std 120s
);
const escalation_rate = randomFloat(0.05, 0.25, 2);
const cpi_humano = randomFloat(2.5, 5.0, 2);
const volumen_anual = volumen_mes * 12;
const agenticInput: AgenticReadinessInput = {
volumen_mes,
aht_values,
escalation_rate,
cpi_humano,
volumen_anual,
tier
};
// Datos adicionales para GOLD
if (tier === 'gold') {
const hourly_distribution = dimensions.find(d => d.name === 'volumetry_distribution')?.distribution_data?.hourly;
const off_hours_pct = dimensions.find(d => d.name === 'volumetry_distribution')?.distribution_data?.off_hours_pct;
agenticInput.structured_fields_pct = randomFloat(0.4, 0.9, 2);
agenticInput.exception_rate = randomFloat(0.05, 0.25, 2);
agenticInput.hourly_distribution = hourly_distribution;
agenticInput.off_hours_pct = off_hours_pct;
agenticInput.csat_values = Array.from({ length: 100 }, () =>
Math.max(1, Math.min(5, normalRandom(4.3, 0.8)))
);
}
agenticReadiness = calculateAgenticReadinessScore(agenticInput);
}
const heatmapData = generateHeatmapData(costPerHour, avgCsat, segmentMapping);
console.log('📊 Heatmap data generated:', {
length: heatmapData.length,
firstItem: heatmapData[0],
metricsKeys: heatmapData[0] ? Object.keys(heatmapData[0].metrics) : [],
metricsValues: heatmapData[0] ? heatmapData[0].metrics : {},
hasNaN: heatmapData.some(item =>
Object.values(item.metrics).some(v => isNaN(v))
)
});
return {
tier,
overallHealthScore,
summaryKpis,
dimensions,
heatmapData,
agenticReadiness,
findings: generateFindingsFromTemplates(),
recommendations: generateRecommendationsFromTemplates(),
opportunities: generateOpportunityMatrixData(),
economicModel: generateEconomicModelData(),
roadmap: generateRoadmapData(),
benchmarkData: generateBenchmarkData(),
source: 'synthetic',
};
};