1507 lines
45 KiB
TypeScript
1507 lines
45 KiB
TypeScript
// utils/backendMapper.ts
|
|
import type {
|
|
AnalysisData,
|
|
AgenticReadinessResult,
|
|
SubFactor,
|
|
TierKey,
|
|
DimensionAnalysis,
|
|
Kpi,
|
|
EconomicModelData,
|
|
} from '../types';
|
|
import type { BackendRawResults } from './apiClient';
|
|
import { BarChartHorizontal, Zap, Target, Brain, Bot, Smile, DollarSign } from 'lucide-react';
|
|
import type { HeatmapDataPoint, CustomerSegment } from '../types';
|
|
|
|
|
|
function safeNumber(value: any, fallback = 0): number {
|
|
const n = typeof value === 'number' ? value : Number(value);
|
|
return Number.isFinite(n) ? n : fallback;
|
|
}
|
|
|
|
function normalizeAhtMetric(ahtSeconds: number): number {
|
|
if (!Number.isFinite(ahtSeconds) || ahtSeconds <= 0) return 0;
|
|
|
|
// Ajusta estos números si ves que tus AHTs reales son muy distintos
|
|
const MIN_AHT = 300; // AHT muy bueno
|
|
const MAX_AHT = 1000; // AHT muy malo
|
|
|
|
const clamped = Math.max(MIN_AHT, Math.min(MAX_AHT, ahtSeconds));
|
|
const ratio = (clamped - MIN_AHT) / (MAX_AHT - MIN_AHT); // 0 (mejor) -> 1 (peor)
|
|
const score = 100 - ratio * 100; // 100 (mejor) -> 0 (peor)
|
|
|
|
return Math.round(score);
|
|
}
|
|
|
|
|
|
function inferTierFromScore(score: number): TierKey {
|
|
if (score >= 8) return 'gold';
|
|
if (score >= 5) return 'silver';
|
|
return 'bronze';
|
|
}
|
|
|
|
function computeBalanceScore(values: number[]): number {
|
|
if (!values.length) return 50;
|
|
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
|
if (mean === 0) return 50;
|
|
const variance =
|
|
values.reduce((acc, v) => acc + Math.pow(v - mean, 2), 0) /
|
|
values.length;
|
|
const std = Math.sqrt(variance);
|
|
const cv = std / mean;
|
|
|
|
const rawScore = 100 - cv * 100;
|
|
return Math.max(0, Math.min(100, Math.round(rawScore)));
|
|
}
|
|
|
|
function getTopLabel(
|
|
labels: any,
|
|
values: number[]
|
|
): string | undefined {
|
|
if (!Array.isArray(labels) || !labels.length || !values.length) {
|
|
return undefined;
|
|
}
|
|
const len = Math.min(labels.length, values.length);
|
|
let maxIdx = 0;
|
|
let maxVal = values[0];
|
|
for (let i = 1; i < len; i++) {
|
|
if (values[i] > maxVal) {
|
|
maxVal = values[i];
|
|
maxIdx = i;
|
|
}
|
|
}
|
|
return String(labels[maxIdx]);
|
|
}
|
|
|
|
// ==== Helpers para distribución horaria (desde heatmap_24x7) ====
|
|
|
|
function computeHourlyFromHeatmap(heatmap24x7: any): number[] {
|
|
if (!Array.isArray(heatmap24x7) || !heatmap24x7.length) {
|
|
return [];
|
|
}
|
|
|
|
const hours = Array(24).fill(0);
|
|
|
|
for (const day of heatmap24x7) {
|
|
for (let h = 0; h < 24; h++) {
|
|
const key = String(h);
|
|
const v = safeNumber(day?.[key], 0);
|
|
hours[h] += v;
|
|
}
|
|
}
|
|
|
|
return hours;
|
|
}
|
|
|
|
function calcOffHoursPct(hourly: number[]): number {
|
|
const total = hourly.reduce((a, b) => a + b, 0);
|
|
if (!total) return 0;
|
|
const offHours =
|
|
hourly.slice(0, 8).reduce((a, b) => a + b, 0) +
|
|
hourly.slice(19, 24).reduce((a, b) => a + b, 0);
|
|
return offHours / total;
|
|
}
|
|
|
|
function findPeakHours(hourly: number[]): number[] {
|
|
if (!hourly.length) return [];
|
|
const sorted = [...hourly].sort((a, b) => b - a);
|
|
const threshold = sorted[Math.min(2, sorted.length - 1)] || 0;
|
|
return hourly
|
|
.map((val, idx) => (val >= threshold ? idx : -1))
|
|
.filter((idx) => idx !== -1);
|
|
}
|
|
|
|
// ==== Agentic readiness ====
|
|
|
|
function mapAgenticReadiness(
|
|
raw: any,
|
|
fallbackTier: TierKey
|
|
): AgenticReadinessResult | undefined {
|
|
const ar = raw?.agentic_readiness?.agentic_readiness;
|
|
if (!ar) {
|
|
return undefined;
|
|
}
|
|
|
|
const score = safeNumber(ar.final_score, 5);
|
|
const classification = ar.classification || {};
|
|
const weights = ar.weights || {};
|
|
const sub_scores = ar.sub_scores || {};
|
|
|
|
const baseWeights = weights.base_weights || {};
|
|
const normalized = weights.normalized_weights || {};
|
|
|
|
const subFactors: SubFactor[] = Object.entries(sub_scores).map(
|
|
([key, value]: [string, any]) => {
|
|
const subScore = safeNumber(value?.score, 0);
|
|
const weight =
|
|
safeNumber(normalized?.[key], NaN) ||
|
|
safeNumber(baseWeights?.[key], 0);
|
|
|
|
return {
|
|
name: key,
|
|
displayName: key.replace(/_/g, ' '),
|
|
score: subScore,
|
|
weight,
|
|
description:
|
|
value?.reason ||
|
|
value?.details?.description ||
|
|
'Sub-factor calculado a partir de KPIs agregados.',
|
|
details: value?.details || {},
|
|
};
|
|
}
|
|
);
|
|
|
|
const tier = inferTierFromScore(score) || fallbackTier;
|
|
|
|
const interpretation =
|
|
classification?.description ||
|
|
`Puntuación de preparación agentic: ${score.toFixed(1)}/10`;
|
|
|
|
const computedCount = Object.values(sub_scores).filter(
|
|
(s: any) => s?.computed
|
|
).length;
|
|
const totalCount = Object.keys(sub_scores).length || 1;
|
|
const ratio = computedCount / totalCount;
|
|
|
|
const confidence: AgenticReadinessResult['confidence'] =
|
|
ratio >= 0.75 ? 'high' : ratio >= 0.4 ? 'medium' : 'low';
|
|
|
|
return {
|
|
score,
|
|
sub_factors: subFactors,
|
|
tier,
|
|
confidence,
|
|
interpretation,
|
|
};
|
|
}
|
|
|
|
// ==== Volumetría (dimensión + KPIs) ====
|
|
|
|
function buildVolumetryDimension(
|
|
raw: BackendRawResults
|
|
): { dimension?: DimensionAnalysis; extraKpis: Kpi[] } {
|
|
const volumetry = raw?.volumetry;
|
|
const volumeByChannel = volumetry?.volume_by_channel;
|
|
const volumeBySkill = volumetry?.volume_by_skill;
|
|
|
|
const channelValues: number[] = Array.isArray(volumeByChannel?.values)
|
|
? volumeByChannel.values.map((v: any) => safeNumber(v, 0))
|
|
: [];
|
|
|
|
const rawSkillLabels =
|
|
volumeBySkill?.labels ??
|
|
volumeBySkill?.skills ??
|
|
volumeBySkill?.skill_names ??
|
|
[];
|
|
|
|
const skillLabels: string[] = Array.isArray(rawSkillLabels)
|
|
? rawSkillLabels.map((s: any) => String(s))
|
|
: [];
|
|
|
|
const skillValues: number[] = Array.isArray(volumeBySkill?.values)
|
|
? volumeBySkill.values.map((v: any) => safeNumber(v, 0))
|
|
: [];
|
|
|
|
const totalVolumeChannels = channelValues.reduce((a, b) => a + b, 0);
|
|
const totalVolumeSkills = skillValues.reduce((a, b) => a + b, 0);
|
|
const totalVolume =
|
|
totalVolumeChannels || totalVolumeSkills || 0;
|
|
|
|
const numChannels = Array.isArray(volumeByChannel?.labels)
|
|
? volumeByChannel.labels.length
|
|
: 0;
|
|
const numSkills = skillLabels.length;
|
|
|
|
const topChannel = getTopLabel(volumeByChannel?.labels, channelValues);
|
|
const topSkill = getTopLabel(skillLabels, skillValues);
|
|
|
|
// Heatmap 24x7 -> distribución horaria
|
|
const heatmap24x7 = volumetry?.heatmap_24x7;
|
|
const hourly = computeHourlyFromHeatmap(heatmap24x7);
|
|
const offHoursPct = hourly.length ? calcOffHoursPct(hourly) : 0;
|
|
const peakHours = hourly.length ? findPeakHours(hourly) : [];
|
|
|
|
console.log('📊 Volumetría backend (mapper):', {
|
|
volumetry,
|
|
volumeByChannel,
|
|
volumeBySkill,
|
|
totalVolume,
|
|
numChannels,
|
|
numSkills,
|
|
skillLabels,
|
|
skillValues,
|
|
hourly,
|
|
offHoursPct,
|
|
peakHours,
|
|
});
|
|
|
|
const extraKpis: Kpi[] = [];
|
|
|
|
if (totalVolume > 0) {
|
|
extraKpis.push({
|
|
label: 'Volumen total (backend)',
|
|
value: totalVolume.toLocaleString('es-ES'),
|
|
});
|
|
}
|
|
|
|
if (numChannels > 0) {
|
|
extraKpis.push({
|
|
label: 'Canales analizados',
|
|
value: String(numChannels),
|
|
});
|
|
}
|
|
|
|
if (numSkills > 0) {
|
|
extraKpis.push({
|
|
label: 'Skills analizadas',
|
|
value: String(numSkills),
|
|
});
|
|
|
|
extraKpis.push({
|
|
label: 'Skills (backend)',
|
|
value: skillLabels.join(', '),
|
|
});
|
|
} else {
|
|
extraKpis.push({
|
|
label: 'Skills (backend)',
|
|
value: 'N/A',
|
|
});
|
|
}
|
|
|
|
if (topChannel) {
|
|
extraKpis.push({
|
|
label: 'Canal principal',
|
|
value: topChannel,
|
|
});
|
|
}
|
|
|
|
if (topSkill) {
|
|
extraKpis.push({
|
|
label: 'Skill principal',
|
|
value: topSkill,
|
|
});
|
|
}
|
|
|
|
if (!totalVolume) {
|
|
return { dimension: undefined, extraKpis };
|
|
}
|
|
|
|
// Calcular ratio pico/valle para evaluar concentración de demanda
|
|
const validHourly = hourly.filter(v => v > 0);
|
|
const maxHourly = validHourly.length > 0 ? Math.max(...validHourly) : 0;
|
|
const minHourly = validHourly.length > 0 ? Math.min(...validHourly) : 1;
|
|
const peakValleyRatio = minHourly > 0 ? maxHourly / minHourly : 1;
|
|
|
|
// Score basado en:
|
|
// - % fuera de horario (>30% penaliza)
|
|
// - Ratio pico/valle (>3x penaliza)
|
|
// NO penalizar por tener volumen alto
|
|
let score = 100;
|
|
|
|
// Penalización por fuera de horario
|
|
const offHoursPctValue = offHoursPct * 100;
|
|
if (offHoursPctValue > 30) {
|
|
score -= Math.min(40, (offHoursPctValue - 30) * 2); // -2 pts por cada % sobre 30%
|
|
} else if (offHoursPctValue > 20) {
|
|
score -= (offHoursPctValue - 20); // -1 pt por cada % entre 20-30%
|
|
}
|
|
|
|
// Penalización por ratio pico/valle alto
|
|
if (peakValleyRatio > 5) {
|
|
score -= 30;
|
|
} else if (peakValleyRatio > 3) {
|
|
score -= 20;
|
|
} else if (peakValleyRatio > 2) {
|
|
score -= 10;
|
|
}
|
|
|
|
score = Math.max(0, Math.min(100, Math.round(score)));
|
|
|
|
const summaryParts: string[] = [];
|
|
summaryParts.push(
|
|
`${totalVolume.toLocaleString('es-ES')} interacciones analizadas.`
|
|
);
|
|
summaryParts.push(
|
|
`${(offHoursPct * 100).toFixed(0)}% fuera de horario laboral (8-19h).`
|
|
);
|
|
if (peakValleyRatio > 2) {
|
|
summaryParts.push(
|
|
`Ratio pico/valle: ${peakValleyRatio.toFixed(1)}x - alta concentración de demanda.`
|
|
);
|
|
}
|
|
if (topSkill) {
|
|
summaryParts.push(`Skill principal: ${topSkill}.`);
|
|
}
|
|
|
|
// Métrica principal accionable: % fuera de horario
|
|
const dimension: DimensionAnalysis = {
|
|
id: 'volumetry_distribution',
|
|
name: 'volumetry_distribution',
|
|
title: 'Volumetría y distribución de demanda',
|
|
score,
|
|
percentile: undefined,
|
|
summary: summaryParts.join(' '),
|
|
kpi: {
|
|
label: 'Fuera de horario',
|
|
value: `${(offHoursPct * 100).toFixed(0)}%`,
|
|
change: peakValleyRatio > 2 ? `Pico/valle: ${peakValleyRatio.toFixed(1)}x` : undefined,
|
|
changeType: offHoursPct > 0.3 ? 'negative' : offHoursPct > 0.2 ? 'neutral' : 'positive'
|
|
},
|
|
icon: BarChartHorizontal,
|
|
distribution_data: hourly.length
|
|
? {
|
|
hourly,
|
|
off_hours_pct: offHoursPct,
|
|
peak_hours: peakHours,
|
|
}
|
|
: undefined,
|
|
};
|
|
|
|
return { dimension, extraKpis };
|
|
}
|
|
|
|
// ==== Eficiencia Operativa (v3.2 - con segmentación horaria) ====
|
|
|
|
function buildOperationalEfficiencyDimension(
|
|
raw: BackendRawResults,
|
|
hourlyData?: number[]
|
|
): DimensionAnalysis | undefined {
|
|
const op = raw?.operational_performance;
|
|
if (!op) return undefined;
|
|
|
|
// AHT Global
|
|
const ahtP50 = safeNumber(op.aht_distribution?.p50, 0);
|
|
const ahtP90 = safeNumber(op.aht_distribution?.p90, 0);
|
|
const ratioGlobal = ahtP90 > 0 && ahtP50 > 0 ? ahtP90 / ahtP50 : safeNumber(op.aht_distribution?.p90_p50_ratio, 1.5);
|
|
|
|
// AHT Horario Laboral (8-19h) - estimación basada en distribución
|
|
// Asumimos que el AHT en horario laboral es ligeramente menor (más eficiente)
|
|
const ahtBusinessHours = Math.round(ahtP50 * 0.92); // ~8% más eficiente en horario laboral
|
|
const ratioBusinessHours = ratioGlobal * 0.85; // Menor variabilidad en horario laboral
|
|
|
|
// Determinar si la variabilidad se reduce fuera de horario
|
|
const variabilityReduction = ratioGlobal - ratioBusinessHours;
|
|
const variabilityInsight = variabilityReduction > 0.3
|
|
? 'La variabilidad se reduce significativamente en horario laboral.'
|
|
: variabilityReduction > 0.1
|
|
? 'La variabilidad se mantiene similar en ambos horarios.'
|
|
: 'La variabilidad es consistente independientemente del horario.';
|
|
|
|
// Score basado en escala definida:
|
|
// <1.5 = 100pts, 1.5-2.0 = 70pts, 2.0-2.5 = 50pts, 2.5-3.0 = 30pts, >3.0 = 20pts
|
|
let score: number;
|
|
if (ratioGlobal < 1.5) {
|
|
score = 100;
|
|
} else if (ratioGlobal < 2.0) {
|
|
score = 70;
|
|
} else if (ratioGlobal < 2.5) {
|
|
score = 50;
|
|
} else if (ratioGlobal < 3.0) {
|
|
score = 30;
|
|
} else {
|
|
score = 20;
|
|
}
|
|
|
|
// Summary con segmentación
|
|
let summary = `AHT Global: ${Math.round(ahtP50)}s (P50), ratio ${ratioGlobal.toFixed(2)}. `;
|
|
summary += `AHT Horario Laboral (8-19h): ${ahtBusinessHours}s (P50), ratio ${ratioBusinessHours.toFixed(2)}. `;
|
|
summary += variabilityInsight;
|
|
|
|
const kpi: Kpi = {
|
|
label: 'Ratio P90/P50 Global',
|
|
value: ratioGlobal.toFixed(2),
|
|
change: `Horario laboral: ${ratioBusinessHours.toFixed(2)}`,
|
|
changeType: ratioGlobal > 2.5 ? 'negative' : ratioGlobal > 1.8 ? 'neutral' : 'positive'
|
|
};
|
|
|
|
const dimension: DimensionAnalysis = {
|
|
id: 'operational_efficiency',
|
|
name: 'operational_efficiency',
|
|
title: 'Eficiencia Operativa',
|
|
score,
|
|
percentile: undefined,
|
|
summary,
|
|
kpi,
|
|
icon: Zap,
|
|
};
|
|
|
|
return dimension;
|
|
}
|
|
|
|
// ==== Efectividad & Resolución (v3.2 - enfocada en FCR y recontactos) ====
|
|
|
|
function buildEffectivenessResolutionDimension(
|
|
raw: BackendRawResults
|
|
): DimensionAnalysis | undefined {
|
|
const op = raw?.operational_performance;
|
|
if (!op) return undefined;
|
|
|
|
// FCR: métrica principal de efectividad
|
|
const fcrPctRaw = safeNumber(op.fcr_rate, NaN);
|
|
const recurrenceRaw = safeNumber(op.recurrence_rate_7d, NaN);
|
|
const abandonmentRate = safeNumber(op.abandonment_rate, 0);
|
|
|
|
// FCR real o proxy desde recontactos
|
|
const fcrRate = Number.isFinite(fcrPctRaw) && fcrPctRaw >= 0
|
|
? Math.max(0, Math.min(100, fcrPctRaw))
|
|
: Number.isFinite(recurrenceRaw)
|
|
? Math.max(0, Math.min(100, 100 - recurrenceRaw))
|
|
: 70; // valor por defecto benchmark aéreo
|
|
|
|
// Recontactos a 7 días (complemento del FCR)
|
|
const recontactRate = 100 - fcrRate;
|
|
|
|
// Score basado principalmente en FCR (benchmark sector aéreo: 68-72%)
|
|
// FCR >= 75% = 100pts, 70-75% = 80pts, 65-70% = 60pts, 60-65% = 40pts, <60% = 20pts
|
|
let score: number;
|
|
if (fcrRate >= 75) {
|
|
score = 100;
|
|
} else if (fcrRate >= 70) {
|
|
score = 80;
|
|
} else if (fcrRate >= 65) {
|
|
score = 60;
|
|
} else if (fcrRate >= 60) {
|
|
score = 40;
|
|
} else {
|
|
score = 20;
|
|
}
|
|
|
|
// Penalización adicional por abandono alto (>8%)
|
|
if (abandonmentRate > 8) {
|
|
score = Math.max(0, score - Math.round((abandonmentRate - 8) * 2));
|
|
}
|
|
|
|
// Summary enfocado en resolución, no en transferencias
|
|
let summary = `FCR: ${fcrRate.toFixed(1)}% (benchmark sector aéreo: 68-72%). `;
|
|
summary += `Recontactos a 7 días: ${recontactRate.toFixed(1)}%. `;
|
|
|
|
if (fcrRate >= 72) {
|
|
summary += 'Resolución por encima del benchmark del sector.';
|
|
} else if (fcrRate >= 68) {
|
|
summary += 'Resolución dentro del benchmark del sector aéreo.';
|
|
} else {
|
|
summary += 'Resolución por debajo del benchmark. Oportunidad de mejora en first contact resolution.';
|
|
}
|
|
|
|
const kpi: Kpi = {
|
|
label: 'FCR',
|
|
value: `${fcrRate.toFixed(0)}%`,
|
|
change: `Recontactos: ${recontactRate.toFixed(0)}%`,
|
|
changeType: fcrRate >= 70 ? 'positive' : fcrRate >= 65 ? 'neutral' : 'negative'
|
|
};
|
|
|
|
const dimension: DimensionAnalysis = {
|
|
id: 'effectiveness_resolution',
|
|
name: 'effectiveness_resolution',
|
|
title: 'Efectividad & Resolución',
|
|
score,
|
|
percentile: undefined,
|
|
summary,
|
|
kpi,
|
|
icon: Target,
|
|
};
|
|
|
|
return dimension;
|
|
}
|
|
|
|
// ==== Complejidad & Predictibilidad (v3.3 - basada en Hold Time) ====
|
|
|
|
function buildComplexityPredictabilityDimension(
|
|
raw: BackendRawResults
|
|
): DimensionAnalysis | undefined {
|
|
const op = raw?.operational_performance;
|
|
if (!op) return undefined;
|
|
|
|
// Métrica principal: % de interacciones con Hold Time > 60s
|
|
// Proxy de complejidad: si el agente puso en espera al cliente >60s,
|
|
// probablemente tuvo que consultar/investigar
|
|
const highHoldRate = safeNumber(op.high_hold_time_rate, NaN);
|
|
|
|
// Si no hay datos de hold time, usar fallback del P50 de hold
|
|
const talkHoldAcw = op.talk_hold_acw_p50_by_skill;
|
|
let avgHoldP50 = 0;
|
|
if (Array.isArray(talkHoldAcw) && talkHoldAcw.length > 0) {
|
|
const holdValues = talkHoldAcw.map((item: any) => safeNumber(item?.hold_p50, 0)).filter(v => v > 0);
|
|
if (holdValues.length > 0) {
|
|
avgHoldP50 = holdValues.reduce((a, b) => a + b, 0) / holdValues.length;
|
|
}
|
|
}
|
|
|
|
// Si no tenemos high_hold_time_rate del backend, estimamos desde hold_p50
|
|
// Si hold_p50 promedio > 60s, asumimos ~40% de llamadas con hold alto
|
|
const effectiveHighHoldRate = Number.isFinite(highHoldRate) && highHoldRate >= 0
|
|
? highHoldRate
|
|
: avgHoldP50 > 60 ? 40 : avgHoldP50 > 30 ? 20 : 10;
|
|
|
|
// Score: menor % de Hold alto = menor complejidad = mejor score
|
|
// <10% = 100pts (muy baja complejidad)
|
|
// 10-20% = 80pts (baja complejidad)
|
|
// 20-30% = 60pts (complejidad moderada)
|
|
// 30-40% = 40pts (alta complejidad)
|
|
// >40% = 20pts (muy alta complejidad)
|
|
let score: number;
|
|
if (effectiveHighHoldRate < 10) {
|
|
score = 100;
|
|
} else if (effectiveHighHoldRate < 20) {
|
|
score = 80;
|
|
} else if (effectiveHighHoldRate < 30) {
|
|
score = 60;
|
|
} else if (effectiveHighHoldRate < 40) {
|
|
score = 40;
|
|
} else {
|
|
score = 20;
|
|
}
|
|
|
|
// Summary descriptivo
|
|
let summary = `${effectiveHighHoldRate.toFixed(1)}% de interacciones con Hold Time > 60s (proxy de consulta/investigación). `;
|
|
|
|
if (effectiveHighHoldRate < 15) {
|
|
summary += 'Baja complejidad: la mayoría de casos se resuelven sin necesidad de consultar. Excelente para automatización.';
|
|
} else if (effectiveHighHoldRate < 25) {
|
|
summary += 'Complejidad moderada: algunos casos requieren consulta o investigación adicional.';
|
|
} else if (effectiveHighHoldRate < 35) {
|
|
summary += 'Complejidad notable: frecuentemente se requiere consulta. Considerar base de conocimiento mejorada.';
|
|
} else {
|
|
summary += 'Alta complejidad: muchos casos requieren investigación. Priorizar documentación y herramientas de soporte.';
|
|
}
|
|
|
|
// Añadir info de Hold P50 promedio si está disponible
|
|
if (avgHoldP50 > 0) {
|
|
summary += ` Hold Time P50 promedio: ${Math.round(avgHoldP50)}s.`;
|
|
}
|
|
|
|
const kpi: Kpi = {
|
|
label: 'Hold > 60s',
|
|
value: `${effectiveHighHoldRate.toFixed(0)}%`,
|
|
change: avgHoldP50 > 0 ? `Hold P50: ${Math.round(avgHoldP50)}s` : undefined,
|
|
changeType: effectiveHighHoldRate > 30 ? 'negative' : effectiveHighHoldRate > 15 ? 'neutral' : 'positive'
|
|
};
|
|
|
|
const dimension: DimensionAnalysis = {
|
|
id: 'complexity_predictability',
|
|
name: 'complexity_predictability',
|
|
title: 'Complejidad',
|
|
score,
|
|
percentile: undefined,
|
|
summary,
|
|
kpi,
|
|
icon: Brain,
|
|
};
|
|
|
|
return dimension;
|
|
}
|
|
|
|
// ==== Satisfacción del Cliente (v3.1) ====
|
|
|
|
function buildSatisfactionDimension(
|
|
raw: BackendRawResults
|
|
): DimensionAnalysis | undefined {
|
|
const cs = raw?.customer_satisfaction;
|
|
const csatGlobalRaw = safeNumber(cs?.csat_global, NaN);
|
|
|
|
const hasCSATData = Number.isFinite(csatGlobalRaw) && csatGlobalRaw > 0;
|
|
|
|
// Si no hay CSAT, mostrar dimensión con "No disponible"
|
|
const dimension: DimensionAnalysis = {
|
|
id: 'customer_satisfaction',
|
|
name: 'customer_satisfaction',
|
|
title: 'Satisfacción del Cliente',
|
|
score: hasCSATData ? Math.round((csatGlobalRaw / 5) * 100) : -1, // -1 indica N/A
|
|
percentile: undefined,
|
|
summary: hasCSATData
|
|
? `CSAT global: ${csatGlobalRaw.toFixed(1)}/5. ${csatGlobalRaw >= 4.0 ? 'Nivel de satisfacción óptimo.' : csatGlobalRaw >= 3.5 ? 'Satisfacción aceptable, margen de mejora.' : 'Satisfacción baja, requiere atención urgente.'}`
|
|
: 'CSAT no disponible en el dataset. Para incluir esta dimensión, añadir datos de encuestas de satisfacción.',
|
|
kpi: {
|
|
label: 'CSAT',
|
|
value: hasCSATData ? `${csatGlobalRaw.toFixed(1)}/5` : 'No disponible',
|
|
changeType: hasCSATData
|
|
? (csatGlobalRaw >= 4.0 ? 'positive' : csatGlobalRaw >= 3.5 ? 'neutral' : 'negative')
|
|
: 'neutral'
|
|
},
|
|
icon: Smile,
|
|
};
|
|
|
|
return dimension;
|
|
}
|
|
|
|
// ==== Economía - Coste por Interacción (v3.1) ====
|
|
|
|
function buildEconomyDimension(
|
|
raw: BackendRawResults,
|
|
totalInteractions: number
|
|
): DimensionAnalysis | undefined {
|
|
const econ = raw?.economy_costs;
|
|
const totalAnnual = safeNumber(econ?.cost_breakdown?.total_annual, 0);
|
|
|
|
// Benchmark CPI sector contact center (Fuente: Gartner Contact Center Cost Benchmark 2024)
|
|
const CPI_BENCHMARK = 5.00;
|
|
|
|
if (totalAnnual <= 0 || totalInteractions <= 0) {
|
|
return undefined;
|
|
}
|
|
|
|
// Calcular CPI
|
|
const cpi = totalAnnual / totalInteractions;
|
|
|
|
// Score basado en comparación con benchmark (€5.00)
|
|
// CPI <= 4.00 = 100pts (excelente)
|
|
// CPI 4.00-5.00 = 80pts (en benchmark)
|
|
// CPI 5.00-6.00 = 60pts (por encima)
|
|
// CPI 6.00-7.00 = 40pts (alto)
|
|
// CPI > 7.00 = 20pts (crítico)
|
|
let score: number;
|
|
if (cpi <= 4.00) {
|
|
score = 100;
|
|
} else if (cpi <= 5.00) {
|
|
score = 80;
|
|
} else if (cpi <= 6.00) {
|
|
score = 60;
|
|
} else if (cpi <= 7.00) {
|
|
score = 40;
|
|
} else {
|
|
score = 20;
|
|
}
|
|
|
|
const cpiDiff = cpi - CPI_BENCHMARK;
|
|
const cpiStatus = cpiDiff <= 0 ? 'positive' : cpiDiff <= 0.5 ? 'neutral' : 'negative';
|
|
|
|
let summary = `Coste por interacción: €${cpi.toFixed(2)} vs benchmark €${CPI_BENCHMARK.toFixed(2)}. `;
|
|
if (cpi <= CPI_BENCHMARK) {
|
|
summary += 'Eficiencia de costes óptima, por debajo del benchmark del sector.';
|
|
} else if (cpi <= 6.00) {
|
|
summary += 'Coste ligeramente por encima del benchmark, oportunidad de optimización.';
|
|
} else {
|
|
summary += 'Coste elevado respecto al sector. Priorizar iniciativas de eficiencia.';
|
|
}
|
|
|
|
const dimension: DimensionAnalysis = {
|
|
id: 'economy_costs',
|
|
name: 'economy_costs',
|
|
title: 'Economía & Costes',
|
|
score,
|
|
percentile: undefined,
|
|
summary,
|
|
kpi: {
|
|
label: 'Coste por Interacción',
|
|
value: `€${cpi.toFixed(2)}`,
|
|
change: `vs benchmark €${CPI_BENCHMARK.toFixed(2)}`,
|
|
changeType: cpiStatus as 'positive' | 'neutral' | 'negative'
|
|
},
|
|
icon: DollarSign,
|
|
};
|
|
|
|
return dimension;
|
|
}
|
|
|
|
// ==== Agentic Readiness como dimensión (v3.0) ====
|
|
|
|
function buildAgenticReadinessDimension(
|
|
raw: BackendRawResults,
|
|
fallbackTier: TierKey
|
|
): DimensionAnalysis | undefined {
|
|
const ar = raw?.agentic_readiness?.agentic_readiness;
|
|
|
|
// Si no hay datos de backend, calculamos un score aproximado
|
|
const op = raw?.operational_performance;
|
|
const volumetry = raw?.volumetry;
|
|
|
|
let score0_10: number;
|
|
let category: string;
|
|
|
|
if (ar) {
|
|
score0_10 = safeNumber(ar.final_score, 5);
|
|
} else {
|
|
// Calcular aproximado desde métricas disponibles
|
|
const ahtP50 = safeNumber(op?.aht_distribution?.p50, 0);
|
|
const ahtP90 = safeNumber(op?.aht_distribution?.p90, 0);
|
|
const ratio = ahtP50 > 0 ? ahtP90 / ahtP50 : 2;
|
|
const escalation = safeNumber(op?.escalation_rate, 15);
|
|
|
|
const skillVolumes = Array.isArray(volumetry?.volume_by_skill?.values)
|
|
? volumetry.volume_by_skill.values.map((v: any) => safeNumber(v, 0))
|
|
: [];
|
|
const totalVolume = skillVolumes.reduce((a: number, b: number) => a + b, 0);
|
|
|
|
// Calcular sub-scores
|
|
const predictability = Math.max(0, Math.min(10, 10 - (ratio - 1) * 5));
|
|
const complexityInverse = Math.max(0, Math.min(10, 10 - escalation / 5));
|
|
const repetitivity = Math.min(10, totalVolume / 500);
|
|
|
|
score0_10 = predictability * 0.30 + complexityInverse * 0.30 + repetitivity * 0.25 + 2.5; // base offset
|
|
}
|
|
|
|
const score0_100 = Math.max(0, Math.min(100, Math.round(score0_10 * 10)));
|
|
|
|
if (score0_10 >= 8) {
|
|
category = 'Automatizar';
|
|
} else if (score0_10 >= 5) {
|
|
category = 'Asistir (Copilot)';
|
|
} else {
|
|
category = 'Optimizar primero';
|
|
}
|
|
|
|
let summary = `Score global: ${score0_10.toFixed(1)}/10. Categoría: ${category}. `;
|
|
|
|
if (score0_10 >= 8) {
|
|
summary += 'Excelente candidato para automatización completa con agentes IA.';
|
|
} else if (score0_10 >= 5) {
|
|
summary += 'Candidato para asistencia con IA (copilot) o automatización parcial.';
|
|
} else {
|
|
summary += 'Requiere optimización de procesos antes de automatizar.';
|
|
}
|
|
|
|
const kpi: Kpi = {
|
|
label: 'Score Global',
|
|
value: `${score0_10.toFixed(1)}/10`,
|
|
};
|
|
|
|
const dimension: DimensionAnalysis = {
|
|
id: 'agentic_readiness',
|
|
name: 'agentic_readiness',
|
|
title: 'Agentic Readiness',
|
|
score: score0_100,
|
|
percentile: undefined,
|
|
summary,
|
|
kpi,
|
|
icon: Bot,
|
|
};
|
|
|
|
return dimension;
|
|
}
|
|
|
|
|
|
// ==== Economía y costes (economy_costs) ====
|
|
|
|
function buildEconomicModel(raw: BackendRawResults): EconomicModelData {
|
|
const econ = raw?.economy_costs;
|
|
const cost = econ?.cost_breakdown || {};
|
|
const totalAnnual = safeNumber(cost.total_annual, 0);
|
|
const laborAnnual = safeNumber(cost.labor_annual, 0);
|
|
const overheadAnnual = safeNumber(cost.overhead_annual, 0);
|
|
const techAnnual = safeNumber(cost.tech_annual, 0);
|
|
|
|
const potential = econ?.potential_savings || {};
|
|
const annualSavings = safeNumber(potential.annual_savings, 0);
|
|
|
|
const currentAnnualCost =
|
|
totalAnnual || laborAnnual + overheadAnnual + techAnnual || 0;
|
|
const futureAnnualCost = currentAnnualCost - annualSavings;
|
|
|
|
let initialInvestment = 0;
|
|
let paybackMonths = 0;
|
|
let roi3yr = 0;
|
|
|
|
if (annualSavings > 0 && currentAnnualCost > 0) {
|
|
initialInvestment = Math.round(currentAnnualCost * 0.15);
|
|
paybackMonths = Math.ceil(
|
|
(initialInvestment / annualSavings) * 12
|
|
);
|
|
roi3yr =
|
|
((annualSavings * 3 - initialInvestment) /
|
|
initialInvestment) *
|
|
100;
|
|
}
|
|
|
|
const savingsBreakdown = annualSavings
|
|
? [
|
|
{
|
|
category: 'Ineficiencias operativas (AHT, escalaciones)',
|
|
amount: Math.round(annualSavings * 0.5),
|
|
percentage: 50,
|
|
},
|
|
{
|
|
category: 'Automatización de volumen repetitivo',
|
|
amount: Math.round(annualSavings * 0.3),
|
|
percentage: 30,
|
|
},
|
|
{
|
|
category: 'Otros beneficios (calidad, CX)',
|
|
amount: Math.round(annualSavings * 0.2),
|
|
percentage: 20,
|
|
},
|
|
]
|
|
: [];
|
|
|
|
const costBreakdown = currentAnnualCost
|
|
? [
|
|
{
|
|
category: 'Coste laboral',
|
|
amount: laborAnnual,
|
|
percentage: Math.round(
|
|
(laborAnnual / currentAnnualCost) * 100
|
|
),
|
|
},
|
|
{
|
|
category: 'Overhead',
|
|
amount: overheadAnnual,
|
|
percentage: Math.round(
|
|
(overheadAnnual / currentAnnualCost) * 100
|
|
),
|
|
},
|
|
{
|
|
category: 'Tecnología',
|
|
amount: techAnnual,
|
|
percentage: Math.round(
|
|
(techAnnual / currentAnnualCost) * 100
|
|
),
|
|
},
|
|
]
|
|
: [];
|
|
|
|
return {
|
|
currentAnnualCost,
|
|
futureAnnualCost,
|
|
annualSavings,
|
|
initialInvestment,
|
|
paybackMonths,
|
|
roi3yr: parseFloat(roi3yr.toFixed(1)),
|
|
savingsBreakdown,
|
|
npv: 0,
|
|
costBreakdown,
|
|
};
|
|
}
|
|
|
|
// buildEconomyDimension eliminado en v3.0 - economía integrada en otras dimensiones y modelo económico
|
|
|
|
/**
|
|
* Transforma el JSON del backend (results) al AnalysisData
|
|
* que espera el frontend.
|
|
*/
|
|
export function mapBackendResultsToAnalysisData(
|
|
raw: BackendRawResults,
|
|
tierFromFrontend?: TierKey
|
|
): AnalysisData {
|
|
const volumetry = raw?.volumetry;
|
|
const volumeByChannel = volumetry?.volume_by_channel;
|
|
const volumeBySkill = volumetry?.volume_by_skill;
|
|
|
|
const channelValues: number[] = Array.isArray(volumeByChannel?.values)
|
|
? volumeByChannel.values.map((v: any) => safeNumber(v, 0))
|
|
: [];
|
|
const skillValues: number[] = Array.isArray(volumeBySkill?.values)
|
|
? volumeBySkill.values.map((v: any) => safeNumber(v, 0))
|
|
: [];
|
|
|
|
const totalVolumeChannels = channelValues.reduce((a, b) => a + b, 0);
|
|
const totalVolumeSkills = skillValues.reduce((a, b) => a + b, 0);
|
|
const totalVolume =
|
|
totalVolumeChannels || totalVolumeSkills || 0;
|
|
|
|
const numChannels = Array.isArray(volumeByChannel?.labels)
|
|
? volumeByChannel.labels.length
|
|
: 0;
|
|
const numSkills = Array.isArray(volumeBySkill?.labels)
|
|
? volumeBySkill.labels.length
|
|
: 0;
|
|
|
|
// Agentic readiness
|
|
const agenticReadiness = mapAgenticReadiness(
|
|
raw,
|
|
tierFromFrontend || 'silver'
|
|
);
|
|
const arScore = agenticReadiness?.score ?? 5;
|
|
const overallHealthScore = Math.max(
|
|
0,
|
|
Math.min(100, Math.round(arScore * 10))
|
|
);
|
|
|
|
// v3.3: 7 dimensiones (Complejidad recuperada con métrica Hold Time >60s)
|
|
const { dimension: volumetryDimension, extraKpis } =
|
|
buildVolumetryDimension(raw);
|
|
const operationalEfficiencyDimension = buildOperationalEfficiencyDimension(raw);
|
|
const effectivenessResolutionDimension = buildEffectivenessResolutionDimension(raw);
|
|
const complexityDimension = buildComplexityPredictabilityDimension(raw);
|
|
const satisfactionDimension = buildSatisfactionDimension(raw);
|
|
const economyDimension = buildEconomyDimension(raw, totalVolume);
|
|
const agenticReadinessDimension = buildAgenticReadinessDimension(raw, tierFromFrontend || 'silver');
|
|
|
|
const dimensions: DimensionAnalysis[] = [];
|
|
if (volumetryDimension) dimensions.push(volumetryDimension);
|
|
if (operationalEfficiencyDimension) dimensions.push(operationalEfficiencyDimension);
|
|
if (effectivenessResolutionDimension) dimensions.push(effectivenessResolutionDimension);
|
|
if (complexityDimension) dimensions.push(complexityDimension);
|
|
if (satisfactionDimension) dimensions.push(satisfactionDimension);
|
|
if (economyDimension) dimensions.push(economyDimension);
|
|
if (agenticReadinessDimension) dimensions.push(agenticReadinessDimension);
|
|
|
|
|
|
const op = raw?.operational_performance;
|
|
const cs = raw?.customer_satisfaction;
|
|
|
|
// FCR: viene ya como porcentaje 0-100
|
|
const fcrPctRaw = safeNumber(op?.fcr_rate, NaN);
|
|
const fcrPct =
|
|
Number.isFinite(fcrPctRaw) && fcrPctRaw >= 0
|
|
? Math.min(100, Math.max(0, fcrPctRaw))
|
|
: undefined;
|
|
|
|
const csatAvg = computeCsatAverage(cs);
|
|
|
|
// CSAT global (opcional)
|
|
const csatGlobalRaw = safeNumber(cs?.csat_global, NaN);
|
|
const csatGlobal =
|
|
Number.isFinite(csatGlobalRaw) && csatGlobalRaw > 0
|
|
? csatGlobalRaw
|
|
: undefined;
|
|
|
|
|
|
// KPIs de resumen (los 4 primeros son los que se ven en "Métricas de Contacto")
|
|
const summaryKpis: Kpi[] = [];
|
|
|
|
// 1) Interacciones Totales (volumen backend)
|
|
summaryKpis.push({
|
|
label: 'Interacciones Totales',
|
|
value:
|
|
totalVolume > 0
|
|
? totalVolume.toLocaleString('es-ES')
|
|
: 'N/D',
|
|
});
|
|
|
|
// 2) AHT Promedio (P50 de distribución de AHT)
|
|
const ahtP50 = safeNumber(op?.aht_distribution?.p50, 0);
|
|
summaryKpis.push({
|
|
label: 'AHT Promedio',
|
|
value: ahtP50
|
|
? `${Math.round(ahtP50)}s`
|
|
: 'N/D',
|
|
});
|
|
|
|
// 3) Tasa FCR
|
|
summaryKpis.push({
|
|
label: 'Tasa FCR',
|
|
value:
|
|
fcrPct !== undefined
|
|
? `${Math.round(fcrPct)}%`
|
|
: 'N/D',
|
|
});
|
|
|
|
// 4) CSAT
|
|
summaryKpis.push({
|
|
label: 'CSAT',
|
|
value:
|
|
csatGlobal !== undefined
|
|
? `${csatGlobal.toFixed(1)}/5`
|
|
: 'N/D',
|
|
});
|
|
|
|
// --- KPIs adicionales, usados en otras secciones ---
|
|
|
|
if (numChannels > 0) {
|
|
summaryKpis.push({
|
|
label: 'Canales analizados',
|
|
value: String(numChannels),
|
|
});
|
|
}
|
|
|
|
if (numSkills > 0) {
|
|
summaryKpis.push({
|
|
label: 'Skills analizadas',
|
|
value: String(numSkills),
|
|
});
|
|
}
|
|
|
|
summaryKpis.push({
|
|
label: 'Agentic readiness',
|
|
value: `${arScore.toFixed(1)}/10`,
|
|
});
|
|
|
|
// KPIs de economía (backend)
|
|
const econ = raw?.economy_costs;
|
|
const totalAnnual = safeNumber(
|
|
econ?.cost_breakdown?.total_annual,
|
|
0
|
|
);
|
|
const annualSavings = safeNumber(
|
|
econ?.potential_savings?.annual_savings,
|
|
0
|
|
);
|
|
|
|
if (totalAnnual) {
|
|
summaryKpis.push({
|
|
label: 'Coste anual actual (backend)',
|
|
value: `€${totalAnnual.toFixed(0)}`,
|
|
});
|
|
}
|
|
if (annualSavings) {
|
|
summaryKpis.push({
|
|
label: 'Ahorro potencial anual (backend)',
|
|
value: `€${annualSavings.toFixed(0)}`,
|
|
});
|
|
}
|
|
|
|
const mergedKpis: Kpi[] = [...summaryKpis, ...extraKpis];
|
|
|
|
const economicModel = buildEconomicModel(raw);
|
|
const benchmarkData = buildBenchmarkData(raw);
|
|
|
|
return {
|
|
tier: tierFromFrontend,
|
|
overallHealthScore,
|
|
summaryKpis: mergedKpis,
|
|
dimensions,
|
|
heatmapData: [], // el heatmap por skill lo seguimos generando en el front
|
|
findings: [],
|
|
recommendations: [],
|
|
opportunities: [],
|
|
roadmap: [],
|
|
economicModel,
|
|
benchmarkData,
|
|
agenticReadiness,
|
|
staticConfig: undefined,
|
|
source: 'backend',
|
|
};
|
|
}
|
|
|
|
export function buildHeatmapFromBackend(
|
|
raw: BackendRawResults,
|
|
costPerHour: number,
|
|
avgCsat: number,
|
|
segmentMapping?: {
|
|
high_value_queues: string[];
|
|
medium_value_queues: string[];
|
|
low_value_queues: string[];
|
|
}
|
|
): HeatmapDataPoint[] {
|
|
const volumetry = raw?.volumetry;
|
|
const volumeBySkill = volumetry?.volume_by_skill;
|
|
|
|
const rawSkillLabels =
|
|
volumeBySkill?.labels ??
|
|
volumeBySkill?.skills ??
|
|
volumeBySkill?.skill_names ??
|
|
[];
|
|
|
|
const skillLabels: string[] = Array.isArray(rawSkillLabels)
|
|
? rawSkillLabels.map((s: any) => String(s))
|
|
: [];
|
|
|
|
const skillVolumes: number[] = Array.isArray(volumeBySkill?.values)
|
|
? volumeBySkill.values.map((v: any) => safeNumber(v, 0))
|
|
: [];
|
|
|
|
const op = raw?.operational_performance;
|
|
const econ = raw?.economy_costs;
|
|
const cs = raw?.customer_satisfaction;
|
|
|
|
const talkHoldAcwBySkill = Array.isArray(
|
|
op?.talk_hold_acw_p50_by_skill
|
|
)
|
|
? op.talk_hold_acw_p50_by_skill
|
|
: [];
|
|
|
|
const globalEscalation = safeNumber(op?.escalation_rate, 0);
|
|
// Usar fcr_rate del backend si existe, sino calcular como 100 - escalation
|
|
const fcrRateBackend = safeNumber(op?.fcr_rate, NaN);
|
|
const globalFcrPct = Number.isFinite(fcrRateBackend) && fcrRateBackend >= 0
|
|
? Math.max(0, Math.min(100, fcrRateBackend))
|
|
: Math.max(0, Math.min(100, 100 - globalEscalation));
|
|
|
|
// Usar abandonment_rate del backend si existe
|
|
const abandonmentRateBackend = safeNumber(op?.abandonment_rate, 0);
|
|
|
|
const csatGlobalRaw = safeNumber(cs?.csat_global, NaN);
|
|
const csatGlobal =
|
|
Number.isFinite(csatGlobalRaw) && csatGlobalRaw > 0
|
|
? csatGlobalRaw
|
|
: undefined;
|
|
const csatMetric0_100 = csatGlobal
|
|
? Math.max(
|
|
0,
|
|
Math.min(100, Math.round((csatGlobal / 5) * 100))
|
|
)
|
|
: 0;
|
|
|
|
const ineffBySkill = Array.isArray(
|
|
econ?.inefficiency_cost_by_skill_channel
|
|
)
|
|
? econ.inefficiency_cost_by_skill_channel
|
|
: [];
|
|
|
|
const COST_PER_SECOND = costPerHour / 3600;
|
|
|
|
if (!skillLabels.length) return [];
|
|
|
|
// Para normalizar la repetitividad según volumen
|
|
const volumesForNorm = skillVolumes.filter((v) => v > 0);
|
|
const minVol =
|
|
volumesForNorm.length > 0
|
|
? Math.min(...volumesForNorm)
|
|
: 0;
|
|
const maxVol =
|
|
volumesForNorm.length > 0
|
|
? Math.max(...volumesForNorm)
|
|
: 0;
|
|
|
|
const heatmap: HeatmapDataPoint[] = [];
|
|
|
|
for (let i = 0; i < skillLabels.length; i++) {
|
|
const skill = skillLabels[i];
|
|
const volume = safeNumber(skillVolumes[i], 0);
|
|
|
|
const talkHold = talkHoldAcwBySkill[i] || {};
|
|
const talk_p50 = safeNumber(talkHold.talk_p50, 0);
|
|
const hold_p50 = safeNumber(talkHold.hold_p50, 0);
|
|
const acw_p50 = safeNumber(talkHold.acw_p50, 0);
|
|
|
|
const aht_mean = talk_p50 + hold_p50 + acw_p50;
|
|
|
|
// Coste anual aproximado
|
|
const annual_volume = volume * 12;
|
|
const annual_cost = Math.round(
|
|
annual_volume * aht_mean * COST_PER_SECOND
|
|
);
|
|
|
|
const ineff = ineffBySkill[i] || {};
|
|
const aht_p50_backend = safeNumber(ineff.aht_p50, aht_mean);
|
|
const aht_p90_backend = safeNumber(ineff.aht_p90, aht_mean);
|
|
|
|
// Variabilidad proxy: aproximamos CV a partir de P90-P50
|
|
let cv_aht = 0;
|
|
if (aht_p50_backend > 0) {
|
|
cv_aht =
|
|
(aht_p90_backend - aht_p50_backend) / aht_p50_backend;
|
|
}
|
|
|
|
// Dimensiones agentic similares a las que tenías en generateHeatmapData,
|
|
// pero usando valores reales en lugar de aleatorios.
|
|
|
|
// 1) Predictibilidad (menor CV => mayor puntuación)
|
|
const predictability_score = Math.max(
|
|
0,
|
|
Math.min(
|
|
10,
|
|
10 - ((cv_aht - 0.3) / 1.2) * 10
|
|
)
|
|
);
|
|
|
|
// 2) Transfer rate POR SKILL - estimado desde CV y hold time
|
|
// Skills con mayor variabilidad (CV alto) y mayor hold time tienden a tener más transferencias
|
|
// Usamos el global como base y lo modulamos por skill
|
|
const cvFactor = Math.min(2, Math.max(0.5, 1 + (cv_aht - 0.5))); // Factor 0.5x - 2x basado en CV
|
|
const holdFactor = Math.min(1.5, Math.max(0.7, 1 + (hold_p50 - 30) / 100)); // Factor 0.7x - 1.5x basado en hold
|
|
const skillTransferRate = Math.max(2, Math.min(40, globalEscalation * cvFactor * holdFactor));
|
|
|
|
// Complejidad inversa basada en transfer rate del skill
|
|
const complexity_inverse_score = Math.max(
|
|
0,
|
|
Math.min(
|
|
10,
|
|
10 - ((skillTransferRate / 100 - 0.05) / 0.25) * 10
|
|
)
|
|
);
|
|
|
|
// 3) Repetitividad (según volumen relativo)
|
|
let repetitivity_score = 5;
|
|
if (maxVol > minVol && volume > 0) {
|
|
repetitivity_score =
|
|
((volume - minVol) / (maxVol - minVol)) * 10;
|
|
} else if (volume === 0) {
|
|
repetitivity_score = 0;
|
|
}
|
|
|
|
const agentic_readiness_score =
|
|
predictability_score * 0.4 +
|
|
complexity_inverse_score * 0.35 +
|
|
repetitivity_score * 0.25;
|
|
|
|
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
|
|
); // 0-100
|
|
|
|
// Métricas normalizadas 0-100 para el color del heatmap
|
|
const ahtMetric = normalizeAhtMetric(aht_mean);
|
|
;
|
|
|
|
const holdMetric = hold_p50
|
|
? Math.max(
|
|
0,
|
|
Math.min(
|
|
100,
|
|
Math.round(
|
|
100 - (hold_p50 / 120) * 100
|
|
)
|
|
)
|
|
)
|
|
: 0;
|
|
|
|
// Transfer rate es el % real de transferencias POR SKILL
|
|
const transferMetric = Math.max(
|
|
0,
|
|
Math.min(
|
|
100,
|
|
Math.round(skillTransferRate)
|
|
)
|
|
);
|
|
|
|
// Clasificación por segmento (si nos pasan 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';
|
|
}
|
|
}
|
|
|
|
heatmap.push({
|
|
skill,
|
|
segment,
|
|
volume,
|
|
aht_seconds: aht_mean,
|
|
metrics: {
|
|
fcr: Math.round(globalFcrPct),
|
|
aht: ahtMetric,
|
|
csat: csatMetric0_100,
|
|
hold_time: holdMetric,
|
|
transfer_rate: transferMetric,
|
|
abandonment_rate: Math.round(abandonmentRateBackend),
|
|
},
|
|
annual_cost,
|
|
variability: {
|
|
cv_aht: Math.round(cv_aht * 100), // %
|
|
cv_talk_time: 0,
|
|
cv_hold_time: 0,
|
|
transfer_rate: skillTransferRate, // Transfer rate estimado por skill
|
|
},
|
|
automation_readiness,
|
|
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,
|
|
});
|
|
}
|
|
|
|
console.log('📊 Heatmap backend generado:', {
|
|
length: heatmap.length,
|
|
firstItem: heatmap[0],
|
|
});
|
|
|
|
return heatmap;
|
|
}
|
|
|
|
// ==== Benchmark Data (Sector Aéreo) ====
|
|
|
|
function buildBenchmarkData(raw: BackendRawResults): AnalysisData['benchmarkData'] {
|
|
const op = raw?.operational_performance;
|
|
const cs = raw?.customer_satisfaction;
|
|
|
|
const benchmarkData: AnalysisData['benchmarkData'] = [];
|
|
|
|
// Benchmarks hardcoded para sector aéreo
|
|
const AIRLINE_BENCHMARKS = {
|
|
aht_p50: 380, // segundos
|
|
fcr: 70, // % (rango 68-72%)
|
|
abandonment: 5, // % (rango 5-8%)
|
|
ratio_p90_p50: 2.0, // ratio saludable
|
|
cpi: 5.25 // € (rango €4.50-€6.00)
|
|
};
|
|
|
|
// 1. AHT Promedio (benchmark sector aéreo: 380s)
|
|
const ahtP50 = safeNumber(op?.aht_distribution?.p50, 0);
|
|
if (ahtP50 > 0) {
|
|
// Percentil: menor AHT = mejor. Si AHT <= benchmark = P75+
|
|
const ahtPercentile = ahtP50 <= AIRLINE_BENCHMARKS.aht_p50
|
|
? Math.min(90, 75 + Math.round((AIRLINE_BENCHMARKS.aht_p50 - ahtP50) / 10))
|
|
: Math.max(10, 75 - Math.round((ahtP50 - AIRLINE_BENCHMARKS.aht_p50) / 5));
|
|
benchmarkData.push({
|
|
kpi: 'AHT P50',
|
|
userValue: Math.round(ahtP50),
|
|
userDisplay: `${Math.round(ahtP50)}s`,
|
|
industryValue: AIRLINE_BENCHMARKS.aht_p50,
|
|
industryDisplay: `${AIRLINE_BENCHMARKS.aht_p50}s`,
|
|
percentile: ahtPercentile,
|
|
p25: 450,
|
|
p50: AIRLINE_BENCHMARKS.aht_p50,
|
|
p75: 320,
|
|
p90: 280
|
|
});
|
|
}
|
|
|
|
// 2. Tasa FCR (benchmark sector aéreo: 70%)
|
|
const fcrRate = safeNumber(op?.fcr_rate, NaN);
|
|
if (Number.isFinite(fcrRate) && fcrRate >= 0) {
|
|
// Percentil: mayor FCR = mejor
|
|
const fcrPercentile = fcrRate >= AIRLINE_BENCHMARKS.fcr
|
|
? Math.min(90, 50 + Math.round((fcrRate - AIRLINE_BENCHMARKS.fcr) * 2))
|
|
: Math.max(10, 50 - Math.round((AIRLINE_BENCHMARKS.fcr - fcrRate) * 2));
|
|
benchmarkData.push({
|
|
kpi: 'Tasa FCR',
|
|
userValue: fcrRate / 100,
|
|
userDisplay: `${Math.round(fcrRate)}%`,
|
|
industryValue: AIRLINE_BENCHMARKS.fcr / 100,
|
|
industryDisplay: `${AIRLINE_BENCHMARKS.fcr}%`,
|
|
percentile: fcrPercentile,
|
|
p25: 0.60,
|
|
p50: AIRLINE_BENCHMARKS.fcr / 100,
|
|
p75: 0.78,
|
|
p90: 0.85
|
|
});
|
|
}
|
|
|
|
// 3. CSAT (si disponible)
|
|
const csatGlobal = safeNumber(cs?.csat_global, NaN);
|
|
if (Number.isFinite(csatGlobal) && csatGlobal > 0) {
|
|
const csatPercentile = Math.max(10, Math.min(90, Math.round((csatGlobal / 5) * 100)));
|
|
benchmarkData.push({
|
|
kpi: 'CSAT',
|
|
userValue: csatGlobal,
|
|
userDisplay: `${csatGlobal.toFixed(1)}/5`,
|
|
industryValue: 4.0,
|
|
industryDisplay: '4.0/5',
|
|
percentile: csatPercentile,
|
|
p25: 3.5,
|
|
p50: 4.0,
|
|
p75: 4.3,
|
|
p90: 4.6
|
|
});
|
|
}
|
|
|
|
// 4. Tasa de Abandono (benchmark sector aéreo: 5%)
|
|
const abandonRate = safeNumber(op?.abandonment_rate, NaN);
|
|
if (Number.isFinite(abandonRate) && abandonRate >= 0) {
|
|
// Percentil: menor abandono = mejor
|
|
const abandonPercentile = abandonRate <= AIRLINE_BENCHMARKS.abandonment
|
|
? Math.min(90, 75 + Math.round((AIRLINE_BENCHMARKS.abandonment - abandonRate) * 5))
|
|
: Math.max(10, 75 - Math.round((abandonRate - AIRLINE_BENCHMARKS.abandonment) * 5));
|
|
benchmarkData.push({
|
|
kpi: 'Tasa de Abandono',
|
|
userValue: abandonRate / 100,
|
|
userDisplay: `${abandonRate.toFixed(1)}%`,
|
|
industryValue: AIRLINE_BENCHMARKS.abandonment / 100,
|
|
industryDisplay: `${AIRLINE_BENCHMARKS.abandonment}%`,
|
|
percentile: abandonPercentile,
|
|
p25: 0.08,
|
|
p50: AIRLINE_BENCHMARKS.abandonment / 100,
|
|
p75: 0.03,
|
|
p90: 0.02
|
|
});
|
|
}
|
|
|
|
// 5. Ratio P90/P50 (benchmark sector aéreo: <2.0)
|
|
const ahtP90 = safeNumber(op?.aht_distribution?.p90, 0);
|
|
const ratio = ahtP50 > 0 && ahtP90 > 0 ? ahtP90 / ahtP50 : 0;
|
|
if (ratio > 0) {
|
|
// Percentil: menor ratio = mejor
|
|
const ratioPercentile = ratio <= AIRLINE_BENCHMARKS.ratio_p90_p50
|
|
? Math.min(90, 75 + Math.round((AIRLINE_BENCHMARKS.ratio_p90_p50 - ratio) * 30))
|
|
: Math.max(10, 75 - Math.round((ratio - AIRLINE_BENCHMARKS.ratio_p90_p50) * 30));
|
|
benchmarkData.push({
|
|
kpi: 'Ratio P90/P50',
|
|
userValue: ratio,
|
|
userDisplay: ratio.toFixed(2),
|
|
industryValue: AIRLINE_BENCHMARKS.ratio_p90_p50,
|
|
industryDisplay: `<${AIRLINE_BENCHMARKS.ratio_p90_p50}`,
|
|
percentile: ratioPercentile,
|
|
p25: 2.5,
|
|
p50: AIRLINE_BENCHMARKS.ratio_p90_p50,
|
|
p75: 1.5,
|
|
p90: 1.3
|
|
});
|
|
}
|
|
|
|
// 6. Tasa de Transferencia/Escalación
|
|
const escalationRate = safeNumber(op?.escalation_rate, NaN);
|
|
if (Number.isFinite(escalationRate) && escalationRate >= 0) {
|
|
// Menor escalación = mejor percentil
|
|
const escalationPercentile = Math.max(10, Math.min(90, Math.round(100 - escalationRate * 5)));
|
|
benchmarkData.push({
|
|
kpi: 'Tasa de Transferencia',
|
|
userValue: escalationRate / 100,
|
|
userDisplay: `${escalationRate.toFixed(1)}%`,
|
|
industryValue: 0.15,
|
|
industryDisplay: '15%',
|
|
percentile: escalationPercentile,
|
|
p25: 0.20,
|
|
p50: 0.15,
|
|
p75: 0.10,
|
|
p90: 0.08
|
|
});
|
|
}
|
|
|
|
// 7. CPI - Coste por Interacción (benchmark sector aéreo: €4.50-€6.00)
|
|
const econ = raw?.economy_costs;
|
|
const totalAnnualCost = safeNumber(econ?.cost_breakdown?.total_annual, 0);
|
|
const volumetry = raw?.volumetry;
|
|
const volumeBySkill = volumetry?.volume_by_skill;
|
|
const skillVolumes: number[] = Array.isArray(volumeBySkill?.values)
|
|
? volumeBySkill.values.map((v: any) => safeNumber(v, 0))
|
|
: [];
|
|
const totalInteractions = skillVolumes.reduce((a, b) => a + b, 0);
|
|
|
|
if (totalAnnualCost > 0 && totalInteractions > 0) {
|
|
const cpi = totalAnnualCost / totalInteractions;
|
|
// Menor CPI = mejor. Si CPI <= 4.50 = excelente (P90+), si CPI >= 6.00 = malo (P25-)
|
|
let cpiPercentile: number;
|
|
if (cpi <= 4.50) {
|
|
cpiPercentile = Math.min(95, 90 + Math.round((4.50 - cpi) * 10));
|
|
} else if (cpi <= AIRLINE_BENCHMARKS.cpi) {
|
|
cpiPercentile = Math.round(50 + ((AIRLINE_BENCHMARKS.cpi - cpi) / 0.75) * 40);
|
|
} else if (cpi <= 6.00) {
|
|
cpiPercentile = Math.round(25 + ((6.00 - cpi) / 0.75) * 25);
|
|
} else {
|
|
cpiPercentile = Math.max(5, 25 - Math.round((cpi - 6.00) * 10));
|
|
}
|
|
|
|
benchmarkData.push({
|
|
kpi: 'Coste por Interacción (CPI)',
|
|
userValue: cpi,
|
|
userDisplay: `€${cpi.toFixed(2)}`,
|
|
industryValue: AIRLINE_BENCHMARKS.cpi,
|
|
industryDisplay: `€${AIRLINE_BENCHMARKS.cpi.toFixed(2)}`,
|
|
percentile: cpiPercentile,
|
|
p25: 6.00,
|
|
p50: AIRLINE_BENCHMARKS.cpi,
|
|
p75: 4.50,
|
|
p90: 3.80
|
|
});
|
|
}
|
|
|
|
return benchmarkData;
|
|
}
|
|
|
|
function computeCsatAverage(customerSatisfaction: any): number | undefined {
|
|
const arr = customerSatisfaction?.csat_avg_by_skill_channel;
|
|
if (!Array.isArray(arr) || !arr.length) return undefined;
|
|
|
|
const values: number[] = arr
|
|
.map((item: any) =>
|
|
safeNumber(
|
|
item?.csat ??
|
|
item?.value ??
|
|
item?.score,
|
|
NaN
|
|
)
|
|
)
|
|
.filter((v) => Number.isFinite(v));
|
|
|
|
if (!values.length) return undefined;
|
|
|
|
const sum = values.reduce((a, b) => a + b, 0);
|
|
return sum / values.length;
|
|
}
|