Files
BeyondCXAnalytics_AE/frontend/utils/backendMapper.ts

978 lines
25 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 } 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 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;
}
// ==== 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 economyDimension = buildEconomyDimension(raw);
const dimensions: DimensionAnalysis[] = [];
if (volumetryDimension) dimensions.push(volumetryDimension);
if (performanceDimension) dimensions.push(performanceDimension);
if (economyDimension) dimensions.push(economyDimension);
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 = aht_mean
? Math.max(
0,
Math.min(
100,
Math.round(
100 - ((aht_mean - 240) / 310) * 100
)
)
)
: 0;
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;
}