1128 lines
30 KiB
TypeScript
1128 lines
30 KiB
TypeScript
// utils/backendMapper.ts
|
||
import type {
|
||
AnalysisData,
|
||
AgenticReadinessResult,
|
||
SubFactor,
|
||
TierKey,
|
||
DimensionAnalysis,
|
||
Kpi,
|
||
EconomicModelData,
|
||
} from '../types';
|
||
import type { BackendRawResults } from './apiClient';
|
||
import { BarChartHorizontal, Zap, DollarSign, Smile, Target } 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 };
|
||
}
|
||
|
||
const summaryParts: string[] = [];
|
||
summaryParts.push(
|
||
`Se han analizado aproximadamente ${totalVolume.toLocaleString(
|
||
'es-ES'
|
||
)} interacciones mensuales.`
|
||
);
|
||
if (numChannels > 0) {
|
||
summaryParts.push(
|
||
`El tráfico se reparte en ${numChannels} canales${
|
||
topChannel ? `, destacando ${topChannel} como el canal con mayor volumen` : ''
|
||
}.`
|
||
);
|
||
}
|
||
if (numSkills > 0) {
|
||
const skillsList =
|
||
skillLabels.length > 0 ? skillLabels.join(', ') : undefined;
|
||
summaryParts.push(
|
||
`Se han identificado ${numSkills} skills${
|
||
skillsList ? ` (${skillsList})` : ''
|
||
}${
|
||
topSkill ? `, siendo ${topSkill} la de mayor carga` : ''
|
||
}.`
|
||
);
|
||
}
|
||
|
||
const dimension: DimensionAnalysis = {
|
||
id: 'volumetry_distribution',
|
||
name: 'volumetry_distribution',
|
||
title: 'Volumetría y distribución de demanda',
|
||
score: computeBalanceScore(
|
||
skillValues.length ? skillValues : channelValues
|
||
),
|
||
percentile: undefined,
|
||
summary: summaryParts.join(' '),
|
||
kpi: {
|
||
label: 'Interacciones mensuales (backend)',
|
||
value: totalVolume.toLocaleString('es-ES'),
|
||
},
|
||
icon: BarChartHorizontal,
|
||
distribution_data: hourly.length
|
||
? {
|
||
hourly,
|
||
off_hours_pct: offHoursPct,
|
||
peak_hours: peakHours,
|
||
}
|
||
: undefined,
|
||
};
|
||
|
||
return { dimension, extraKpis };
|
||
}
|
||
|
||
// ==== Performance (operational_performance) ====
|
||
|
||
function buildPerformanceDimension(
|
||
raw: BackendRawResults
|
||
): DimensionAnalysis | undefined {
|
||
const op = raw?.operational_performance;
|
||
if (!op) return undefined;
|
||
|
||
const perfScore0_10 = safeNumber(op.performance_score?.score, NaN);
|
||
if (!Number.isFinite(perfScore0_10)) return undefined;
|
||
|
||
const score = Math.max(
|
||
0,
|
||
Math.min(100, Math.round(perfScore0_10 * 10))
|
||
);
|
||
|
||
const ahtP50 = safeNumber(op.aht_distribution?.p50, 0);
|
||
const ahtP90 = safeNumber(op.aht_distribution?.p90, 0);
|
||
const ratio = safeNumber(op.aht_distribution?.p90_p50_ratio, 0);
|
||
const escRate = safeNumber(op.escalation_rate, 0);
|
||
|
||
let summary = `El AHT mediano se sitúa en ${Math.round(
|
||
ahtP50
|
||
)} segundos, con un P90 de ${Math.round(
|
||
ahtP90
|
||
)}s (ratio P90/P50 ≈ ${ratio.toFixed(
|
||
2
|
||
)}) y una tasa de escalación del ${escRate.toFixed(
|
||
1
|
||
)}%. `;
|
||
|
||
if (score >= 80) {
|
||
summary +=
|
||
'El rendimiento operativo es sólido y se encuentra claramente por encima de los umbrales objetivo.';
|
||
} else if (score >= 60) {
|
||
summary +=
|
||
'El rendimiento es aceptable pero existen oportunidades claras de optimización en algunos flujos.';
|
||
} else {
|
||
summary +=
|
||
'El rendimiento operativo está por debajo del nivel deseado y requiere un plan de mejora específico.';
|
||
}
|
||
|
||
const kpi: Kpi = {
|
||
label: 'AHT mediano (P50)',
|
||
value: ahtP50 ? `${Math.round(ahtP50)}s` : 'N/D',
|
||
};
|
||
|
||
const dimension: DimensionAnalysis = {
|
||
id: 'performance',
|
||
name: 'performance',
|
||
title: 'Rendimiento operativo',
|
||
score,
|
||
percentile: undefined,
|
||
summary,
|
||
kpi,
|
||
icon: Zap,
|
||
};
|
||
|
||
return dimension;
|
||
}
|
||
|
||
// ==== Satisfacción (customer_satisfaction) ====
|
||
|
||
function buildSatisfactionDimension(
|
||
raw: BackendRawResults
|
||
): DimensionAnalysis | undefined {
|
||
const cs = raw?.customer_satisfaction;
|
||
if (!cs) return undefined;
|
||
|
||
// CSAT global viene ya calculado en el backend (1–5)
|
||
const csatGlobalRaw = safeNumber(cs?.csat_global, NaN);
|
||
if (!Number.isFinite(csatGlobalRaw) || csatGlobalRaw <= 0) {
|
||
return undefined;
|
||
}
|
||
|
||
// Normalizamos 1–5 a 0–100
|
||
const csat = Math.max(1, Math.min(5, csatGlobalRaw));
|
||
const score = Math.max(
|
||
0,
|
||
Math.min(100, Math.round((csat / 5) * 100))
|
||
);
|
||
|
||
let summary = `CSAT global de ${csat.toFixed(1)}/5. `;
|
||
|
||
if (score >= 85) {
|
||
summary +=
|
||
'La satisfacción del cliente es muy alta y consistente en la mayoría de interacciones.';
|
||
} else if (score >= 70) {
|
||
summary +=
|
||
'La satisfacción del cliente es razonable, pero existen áreas claras de mejora en algunos journeys o motivos de contacto.';
|
||
} else {
|
||
summary +=
|
||
'La satisfacción del cliente se sitúa por debajo de los niveles objetivo y requiere un plan de mejora específico sobre los principales drivers de insatisfacción.';
|
||
}
|
||
|
||
const kpi: Kpi = {
|
||
label: 'CSAT global (backend)',
|
||
value: `${csat.toFixed(1)}/5`,
|
||
};
|
||
|
||
const dimension: DimensionAnalysis = {
|
||
id: 'satisfaction',
|
||
name: 'satisfaction',
|
||
title: 'Voz del cliente y satisfacción',
|
||
score,
|
||
percentile: undefined,
|
||
summary,
|
||
kpi,
|
||
icon: Smile,
|
||
};
|
||
|
||
return dimension;
|
||
}
|
||
|
||
// ==== Eficiencia (FCR + escalaciones + recurrencia) ====
|
||
|
||
function buildEfficiencyDimension(
|
||
raw: BackendRawResults
|
||
): DimensionAnalysis | undefined {
|
||
const op = raw?.operational_performance;
|
||
if (!op) return undefined;
|
||
|
||
// FCR: viene como porcentaje 0–100, o lo aproximamos a partir de escalaciones
|
||
const fcrPctRaw = safeNumber(op.fcr_rate, NaN);
|
||
const escRateRaw = safeNumber(op.escalation_rate, NaN);
|
||
const recurrenceRaw = safeNumber(op.recurrence_rate_7d, NaN);
|
||
|
||
const fcrPct = Number.isFinite(fcrPctRaw) && fcrPctRaw >= 0
|
||
? Math.max(0, Math.min(100, fcrPctRaw))
|
||
: Number.isFinite(escRateRaw)
|
||
? Math.max(0, Math.min(100, 100 - escRateRaw))
|
||
: NaN;
|
||
|
||
if (!Number.isFinite(fcrPct)) {
|
||
// Sin FCR ni escalaciones no podemos construir bien la dimensión
|
||
return undefined;
|
||
}
|
||
|
||
let score = fcrPct;
|
||
|
||
// Penalizar por escalaciones altas
|
||
if (Number.isFinite(escRateRaw)) {
|
||
const esc = escRateRaw as number;
|
||
if (esc > 20) score -= 20;
|
||
else if (esc > 10) score -= 10;
|
||
else if (esc > 5) score -= 5;
|
||
}
|
||
|
||
// Penalizar por recurrencia (repetición de contactos a 7 días)
|
||
if (Number.isFinite(recurrenceRaw)) {
|
||
const rec = recurrenceRaw as number; // asumimos ya en %
|
||
if (rec > 20) score -= 15;
|
||
else if (rec > 10) score -= 10;
|
||
else if (rec > 5) score -= 5;
|
||
}
|
||
|
||
score = Math.max(0, Math.min(100, Math.round(score)));
|
||
|
||
const escText = Number.isFinite(escRateRaw)
|
||
? `${(escRateRaw as number).toFixed(1)}%`
|
||
: 'N/D';
|
||
const recText = Number.isFinite(recurrenceRaw)
|
||
? `${(recurrenceRaw as number).toFixed(1)}%`
|
||
: 'N/D';
|
||
|
||
let summary = `FCR estimado de ${fcrPct.toFixed(
|
||
1
|
||
)}%, con una tasa de escalación del ${escText} y una recurrencia a 7 días de ${recText}. `;
|
||
|
||
if (score >= 80) {
|
||
summary +=
|
||
'La operación presenta una alta tasa de resolución en primer contacto y pocas escalaciones, lo que indica procesos eficientes.';
|
||
} else if (score >= 60) {
|
||
summary +=
|
||
'La eficiencia es razonable, aunque existen oportunidades de mejora en la resolución al primer contacto y en la reducción de contactos repetidos.';
|
||
} else {
|
||
summary +=
|
||
'La eficiencia operativa es baja: hay demasiadas escalaciones o contactos repetidos, lo que impacta negativamente en costes y experiencia de cliente.';
|
||
}
|
||
|
||
const kpi: Kpi = {
|
||
label: 'FCR estimado (backend)',
|
||
value: `${fcrPct.toFixed(1)}%`,
|
||
};
|
||
|
||
const dimension: DimensionAnalysis = {
|
||
id: 'efficiency',
|
||
name: 'efficiency',
|
||
title: 'Resolución y eficiencia',
|
||
score,
|
||
percentile: undefined,
|
||
summary,
|
||
kpi,
|
||
icon: Target,
|
||
};
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
function buildEconomyDimension(
|
||
raw: BackendRawResults
|
||
): DimensionAnalysis | undefined {
|
||
const econ = raw?.economy_costs;
|
||
if (!econ) return undefined;
|
||
|
||
const cost = econ.cost_breakdown || {};
|
||
const totalAnnual = safeNumber(cost.total_annual, 0);
|
||
const potential = econ.potential_savings || {};
|
||
const annualSavings = safeNumber(potential.annual_savings, 0);
|
||
|
||
if (!totalAnnual && !annualSavings) return undefined;
|
||
|
||
const savingsPct = totalAnnual
|
||
? (annualSavings / totalAnnual) * 100
|
||
: 0;
|
||
|
||
let summary = `El coste anual estimado de la operación es de aproximadamente €${totalAnnual.toFixed(
|
||
2
|
||
)}. `;
|
||
if (annualSavings > 0) {
|
||
summary += `El ahorro potencial anual asociado a la estrategia agentic se sitúa en torno a €${annualSavings.toFixed(
|
||
2
|
||
)}, equivalente a ~${savingsPct.toFixed(1)}% del coste actual.`;
|
||
} else {
|
||
summary +=
|
||
'Todavía no se dispone de una estimación robusta de ahorro potencial.';
|
||
}
|
||
|
||
const score =
|
||
totalAnnual && annualSavings
|
||
? Math.max(0, Math.min(100, Math.round(savingsPct)))
|
||
: 50;
|
||
|
||
const dimension: DimensionAnalysis = {
|
||
id: 'economy',
|
||
name: 'economy',
|
||
title: 'Economía y costes',
|
||
score,
|
||
percentile: undefined,
|
||
summary,
|
||
kpi: {
|
||
label: 'Coste anual actual',
|
||
value: totalAnnual
|
||
? `€${totalAnnual.toFixed(0)}`
|
||
: 'N/D',
|
||
},
|
||
icon: DollarSign,
|
||
};
|
||
|
||
return dimension;
|
||
}
|
||
|
||
/**
|
||
* 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))
|
||
);
|
||
|
||
// Dimensiones
|
||
const { dimension: volumetryDimension, extraKpis } =
|
||
buildVolumetryDimension(raw);
|
||
const performanceDimension = buildPerformanceDimension(raw);
|
||
const satisfactionDimension = buildSatisfactionDimension(raw);
|
||
const economyDimension = buildEconomyDimension(raw);
|
||
const efficiencyDimension = buildEfficiencyDimension(raw);
|
||
|
||
const dimensions: DimensionAnalysis[] = [];
|
||
if (volumetryDimension) dimensions.push(volumetryDimension);
|
||
if (performanceDimension) dimensions.push(performanceDimension);
|
||
if (satisfactionDimension) dimensions.push(satisfactionDimension);
|
||
if (economyDimension) dimensions.push(economyDimension);
|
||
if (efficiencyDimension) dimensions.push(efficiencyDimension);
|
||
|
||
|
||
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);
|
||
|
||
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);
|
||
const globalFcrPct = Math.max(
|
||
0,
|
||
Math.min(100, 100 - globalEscalation)
|
||
);
|
||
|
||
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) Complejidad inversa (usamos la tasa global de escalación como proxy)
|
||
const transfer_rate = globalEscalation; // %
|
||
const complexity_inverse_score = Math.max(
|
||
0,
|
||
Math.min(
|
||
10,
|
||
10 - ((transfer_rate / 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;
|
||
|
||
const transferMetric = Math.max(
|
||
0,
|
||
Math.min(
|
||
100,
|
||
Math.round(100 - transfer_rate)
|
||
)
|
||
);
|
||
|
||
// 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,
|
||
},
|
||
annual_cost,
|
||
variability: {
|
||
cv_aht: Math.round(cv_aht * 100), // %
|
||
cv_talk_time: 0,
|
||
cv_hold_time: 0,
|
||
transfer_rate,
|
||
},
|
||
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;
|
||
}
|
||
|
||
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;
|
||
}
|