Files
BeyondCXAnalytics_AE/frontend/utils/backendMapper.ts

1128 lines
30 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.
// 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 (15)
const csatGlobalRaw = safeNumber(cs?.csat_global, NaN);
if (!Number.isFinite(csatGlobalRaw) || csatGlobalRaw <= 0) {
return undefined;
}
// Normalizamos 15 a 0100
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 0100, 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;
}