/** * Generation of analysis with real data (not synthetic) */ import type { AnalysisData, Kpi, DimensionAnalysis, HeatmapDataPoint, Opportunity, RoadmapInitiative, EconomicModelData, BenchmarkDataPoint, Finding, Recommendation, TierKey, CustomerSegment, RawInteraction, AgenticReadinessResult, SubFactor, SkillMetrics, DrilldownDataPoint } from '../types'; import { RoadmapPhase } from '../types'; import { BarChartHorizontal, Zap, Target, Brain, Bot, DollarSign, Smile } from 'lucide-react'; import { calculateAgenticReadinessScore, type AgenticReadinessInput } from './agenticReadinessV2'; import { classifyQueue } from './segmentClassifier'; /** * Calculate hourly distribution from interactions * NOTE: Uses unique interaction_id for consistency with backend (aggfunc="nunique") */ function calculateHourlyDistribution(interactions: RawInteraction[]): { hourly: number[]; off_hours_pct: number; peak_hours: number[] } { const hourly = new Array(24).fill(0); // Deduplicate by interaction_id for consistency with backend (nunique) const seenIds = new Set(); let duplicateCount = 0; for (const interaction of interactions) { // Skip duplicate interaction_id const id = interaction.interaction_id; if (id && seenIds.has(id)) { duplicateCount++; continue; } if (id) seenIds.add(id); try { const date = new Date(interaction.datetime_start); if (!isNaN(date.getTime())) { const hour = date.getHours(); hourly[hour]++; } } catch { // Ignore invalid dates } } if (duplicateCount > 0) { console.log(`⏰ calculateHourlyDistribution: ${duplicateCount} duplicate interaction_ids ignored`); } const total = hourly.reduce((a, b) => a + b, 0); // Off hours: 19:00-08:00 const offHoursVolume = hourly.slice(0, 8).reduce((a, b) => a + b, 0) + hourly.slice(19).reduce((a, b) => a + b, 0); const off_hours_pct = total > 0 ? Math.round((offHoursVolume / total) * 100) : 0; // Find peak hours (top 3 consecutive) let maxSum = 0; let peakStart = 0; for (let i = 0; i < 22; i++) { const sum = hourly[i] + hourly[i + 1] + hourly[i + 2]; if (sum > maxSum) { maxSum = sum; peakStart = i; } } const peak_hours = [peakStart, peakStart + 1, peakStart + 2]; // Log for debugging const hourlyNonZero = hourly.filter(v => v > 0); const peakVolume = Math.max(...hourlyNonZero, 1); const valleyVolume = Math.min(...hourlyNonZero.filter(v => v > 0), 1); console.log(`⏰ Hourly distribution: total=${total}, peak=${peakVolume}, valley=${valleyVolume}, ratio=${(peakVolume/valleyVolume).toFixed(2)}`); return { hourly, off_hours_pct, peak_hours }; } /** * Calculate date range from interactions (optimized for large files) */ function calculateDateRange(interactions: RawInteraction[]): { min: string; max: string } | undefined { let minTime = Infinity; let maxTime = -Infinity; let validCount = 0; for (const interaction of interactions) { const date = new Date(interaction.datetime_start); const time = date.getTime(); if (!isNaN(time)) { validCount++; if (time < minTime) minTime = time; if (time > maxTime) maxTime = time; } } if (validCount === 0) return undefined; return { min: new Date(minTime).toISOString().split('T')[0], max: new Date(maxTime).toISOString().split('T')[0] }; } /** * Generar analysis completo con datos reales */ export function generateAnalysisFromRealData( tier: TierKey, interactions: RawInteraction[], costPerHour: number, avgCsat: number, segmentMapping?: { high_value_queues: string[]; medium_value_queues: string[]; low_value_queues: string[] } ): AnalysisData { console.log(`πŸ”„ Generating analysis from ${interactions.length} real interactions`); // STEP 0: Detectar si tenemos datos de repeat_call_7d const repeatCallTrueCount = interactions.filter(i => i.repeat_call_7d === true).length; const repeatCallFalseCount = interactions.filter(i => i.repeat_call_7d === false).length; const repeatCallUndefinedCount = interactions.filter(i => i.repeat_call_7d === undefined).length; const transferTrueCount = interactions.filter(i => i.transfer_flag === true).length; const transferFalseCount = interactions.filter(i => i.transfer_flag === false).length; const hasRepeatCallData = repeatCallTrueCount > 0; console.log('πŸ“ž DETAILED DATA CHECK:'); console.log(` - repeat_call_7d TRUE: ${repeatCallTrueCount} (${((repeatCallTrueCount/interactions.length)*100).toFixed(1)}%)`); console.log(` - repeat_call_7d FALSE: ${repeatCallFalseCount} (${((repeatCallFalseCount/interactions.length)*100).toFixed(1)}%)`); console.log(` - repeat_call_7d UNDEFINED: ${repeatCallUndefinedCount}`); console.log(` - transfer_flag TRUE: ${transferTrueCount} (${((transferTrueCount/interactions.length)*100).toFixed(1)}%)`); console.log(` - transfer_flag FALSE: ${transferFalseCount} (${((transferFalseCount/interactions.length)*100).toFixed(1)}%)`); // Calculate FCR esperado mannualmente const fcrRecords = interactions.filter(i => i.transfer_flag !== true && i.repeat_call_7d !== true); const expectedFCR = (fcrRecords.length / interactions.length) * 100; console.log(`πŸ“Š EXPECTED FCR (mannual): ${expectedFCR.toFixed(1)}% (${fcrRecords.length}/${interactions.length} calls without transfer AND without repeat)`); // Mostrar sample de datos for debugging if (interactions.length > 0) { console.log('πŸ“‹ SAMPLE DATA (first 5 rows):', interactions.slice(0, 5).map(i => ({ id: i.interaction_id?.substring(0, 8), transfer_flag: i.transfer_flag, repeat_call_7d: i.repeat_call_7d, is_abandoned: i.is_abandoned }))); } console.log(`πŸ“ž Repeat call data: ${repeatCallTrueCount} calls marked as repeat (${hasRepeatCallData ? 'USING repeat_call_7d' : 'NO repeat_call_7d data - FCR = 100% - transfer_rate'})`); // STEP 0.5: Calculate date range const dateRange = calculateDateRange(interactions); console.log(`πŸ“… Date range: ${dateRange?.min} to ${dateRange?.max}`); // STEP 1: Analizar record_status (ya no filtramos, filtering is done internally en calculateSkillMetrics) // Normalize to uppercase for case-insensitive comparison const getStatus = (i: RawInteraction) => (i.record_status || '').toString().toUpperCase().trim(); const statusCounts = { valid: interactions.filter(i => !i.record_status || getStatus(i) === 'VALID').length, noise: interactions.filter(i => getStatus(i) === 'NOISE').length, zombie: interactions.filter(i => getStatus(i) === 'ZOMBIE').length, abandon: interactions.filter(i => getStatus(i) === 'ABANDON').length }; console.log(`πŸ“Š Record status breakdown:`, statusCounts); // STEP 1.5: Calculate hourly distribution (on ALL interactions to see complete patterns) const hourlyDistribution = calculateHourlyDistribution(interactions); console.log(`⏰ Off-hours: ${hourlyDistribution.off_hours_pct}%, Peak hours: ${hourlyDistribution.peak_hours.join('-')}h`); // STEP 2: Calcular metrics por skill (passes ALL interactions, filtering is done internally) const skillMetrics = calculateSkillMetrics(interactions, costPerHour); console.log(`πŸ“Š Calculated metrics for ${skillMetrics.length} skills`); // STEP 3: Generar heatmap data con dimensions const heatmapData = generateHeatmapFromMetrics(skillMetrics, avgCsat, segmentMapping); // STEP 4: Calcular metrics globales // Volumen total: ALL interactions const totalInteractions = interactions.length; // Valid volume for AHT: sum of volume_valid from each skill const totalValidInteractions = skillMetrics.reduce((sum, s) => sum + s.volume_valid, 0); // AHT average: calculated only on valid interactions (weighted by volume) const totalWeightedAHT = skillMetrics.reduce((sum, s) => sum + (s.aht_mean * s.volume_valid), 0); const avgAHT = totalValidInteractions > 0 ? Math.round(totalWeightedAHT / totalValidInteractions) : 0; // Technical FCR: 100 - transfer_rate (comparable with industry benchmarks) // Weighted by volume of each skill const totalVolumeForFCR = skillMetrics.reduce((sum, s) => sum + s.volume_valid, 0); const avgFCR = totalVolumeForFCR > 0 ? Math.round(skillMetrics.reduce((sum, s) => sum + (s.fcr_tecnico * s.volume_valid), 0) / totalVolumeForFCR) : 0; // Coste total const totalCost = Math.round(skillMetrics.reduce((sum, s) => sum + s.total_cost, 0)); // === CPI CENTRALIZADO: Calcular UNA sola vez from heatmapData === // This is the ONLY source of truth for CPI, same as ExecutiveSummaryTab const totalCostVolume = heatmapData.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0); const totalAnnualCost = heatmapData.reduce((sum, h) => sum + (h.annual_cost || 0), 0); const hasCpiField = heatmapData.some(h => h.cpi !== undefined && h.cpi > 0); const globalCPI = hasCpiField ? (totalCostVolume > 0 ? heatmapData.reduce((sum, h) => sum + (h.cpi || 0) * (h.cost_volume || h.volume), 0) / totalCostVolume : 0) : (totalCostVolume > 0 ? totalAnnualCost / totalCostVolume : 0); // KPIs main const summaryKpis: Kpi[] = [ { label: "Total Interactions", value: totalInteractions.toLocaleString('es-ES') }, { label: "Average AHT", value: `${avgAHT}s` }, { label: "Technical FCR", value: `${avgFCR}%` }, { label: "CSAT", value: `${(avgCsat / 20).toFixed(1)}/5` } ]; // Health Score based on real metrics const overallHealthScore = calculateHealthScore(heatmapData); // Dimensiones (simplified for real data) - pass centralized CPI const dimensions: DimensionAnalysis[] = generateDimensionsFromRealData( interactions, skillMetrics, avgCsat, avgAHT, hourlyDistribution, globalCPI // CPI calculated from heatmapData ); // Agentic Readiness Score const agenticReadiness = calculateAgenticReadinessFromRealData(skillMetrics); // Findings y Recommendations (including analysis of off hours) const findings = generateFindingsFromRealData(skillMetrics, interactions, hourlyDistribution); const recommendations = generateRecommendationsFromRealData(skillMetrics, hourlyDistribution, interactions.length); // v3.3: Drill-down by Queue + Typification - CALCULATE FIRST to use in opportunities y roadmap const drilldownData = calculateDrilldownMetrics(interactions, costPerHour); // v3.3: Opportunities y Roadmap based on drilldownData (queues with CV < 75% = automatable) const opportunities = generateOpportunitiesFromDrilldown(drilldownData, costPerHour); // Roadmap based on drilldownData const roadmap = generateRoadmapFromDrilldown(drilldownData, costPerHour); // Economic Model (v3.10: aligned with TCO of Roadmap) const economicModel = generateEconomicModelFromRealData(skillMetrics, costPerHour, roadmap, drilldownData); // Benchmark const benchmarkData = generateBenchmarkFromRealData(skillMetrics); return { tier, overallHealthScore, summaryKpis, dimensions, heatmapData, agenticReadiness, findings, recommendations, opportunities, roadmap, economicModel, benchmarkData, dateRange, drilldownData }; } /** * STEP 2: Calcular metrics base por skill * * FILTERING LOGIC BY record_status: * - valid: normal valid calls * - noise: calls < 10 segundos (exclude from AHT, but sum in volumen/coste) * - zombie: calls > 3 hours (exclude from AHT, but sum in volumen/coste) * - abandon: customer hangs up (exclude from AHT, no sum conversation cost, but occupies line) * * Dashboard quality/efficiency: filter only valid + abandon para AHT * Financial calculations: use all (volume, total cost) */ interface SkillMetrics { skill: string; volume: number; // Total of interactions (all) volume_valid: number; // Valid interactions for AHT (valid + abandon) aht_mean: number; // AHT "clean" calculado only above valid (without noise/zombie/abandon) - for metrics of quality, CV aht_total: number; // AHT "total" calculado con TODAS las filas (noise/zombie/abandon included) - only informative aht_benchmark: number; // AHT "traditional" (includes noise, excludes zombie/abandon) - for comparison with industry benchmarks aht_std: number; cv_aht: number; transfer_rate: number; // Calculado above valid + abandon fcr_rate: number; // FCR Real: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE) - without recontact 7 days fcr_tecnico: number; // Technical FCR: (transfer_flag == FALSE) - only without transferencia, comparable with industry benchmarks abandonment_rate: number; // % de abandonments of total total_cost: number; // Coste total (all las interactions except abandon) cost_volume: number; // Volumen used to calculate coste (non-abandon) cpi: number; // Coste per interaction = total_cost / cost_volume hold_time_mean: number; // Calculado above valid cv_talk_time: number; // Additional metrics for debug noise_count: number; zombie_count: number; abandon_count: number; } export function calculateSkillMetrics(interactions: RawInteraction[], costPerHour: number): SkillMetrics[] { // Group por skill const skillGroups = new Map(); interactions.forEach(i => { if (!skillGroups.has(i.queue_skill)) { skillGroups.set(i.queue_skill, []); } skillGroups.get(i.queue_skill)!.push(i); }); // Calculate metrics for each skill const metrics: SkillMetrics[] = []; skillGroups.forEach((group, skill) => { const volume = group.length; if (volume === 0) return; // === SIMPLE AND DIRECT CALCULATIONS FROM CSV === // Abandonment: DIRECTO del campo is_abandoned del CSV const abandon_count = group.filter(i => i.is_abandoned === true).length; const abandonment_rate = (abandon_count / volume) * 100; // FCR Real: DIRECTO del campo fcr_real_flag del CSV // Definition: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE) // This is the STRICTEST metric - without transfer AND without recontact in 7 days const fcrTrueCount = group.filter(i => i.fcr_real_flag === true).length; const fcr_rate = (fcrTrueCount / volume) * 100; // Transfer rate: DIRECTO del campo transfer_flag del CSV const transfers = group.filter(i => i.transfer_flag === true).length; const transfer_rate = (transfers / volume) * 100; // Technical FCR: 100 - transfer_rate // Definition: (transfer_flag == FALSE) - only without transferencia // This metric is COMPARABLE with industry benchmarks (COPC, Dimension Data) // Industry benchmarks (~70%) measure FCR without transfer, NOT without recontact const fcr_tecnico = 100 - transfer_rate; // Separate by record_status for AHT (normalize to uppercase for case-insensitive comparison) const getStatus = (i: RawInteraction) => (i.record_status || '').toString().toUpperCase().trim(); const noiseRecords = group.filter(i => getStatus(i) === 'NOISE'); const zombieRecords = group.filter(i => getStatus(i) === 'ZOMBIE'); const validRecords = group.filter(i => !i.record_status || getStatus(i) === 'VALID'); // Registros que generan coste (todo except abandonments) const nonAbandonRecords = group.filter(i => i.is_abandoned !== true); const noise_count = noiseRecords.length; const zombie_count = zombieRecords.length; // AHT se calcula above records 'valid' (excludes noise, zombie) const ahtRecords = validRecords; const volume_valid = ahtRecords.length; let aht_mean = 0; let aht_std = 0; let cv_aht = 0; let hold_time_mean = 0; let cv_talk_time = 0; if (volume_valid > 0) { // AHT = duration_talk + hold_time + wrap_up_time const ahts = ahtRecords.map(i => i.duration_talk + i.hold_time + i.wrap_up_time); aht_mean = ahts.reduce((sum, v) => sum + v, 0) / volume_valid; const aht_variance = ahts.reduce((sum, v) => sum + Math.pow(v - aht_mean, 2), 0) / volume_valid; aht_std = Math.sqrt(aht_variance); cv_aht = aht_mean > 0 ? aht_std / aht_mean : 0; // Talk time CV const talkTimonth = ahtRecords.map(i => i.duration_talk); const talk_mean = talkTimonth.reduce((sum, v) => sum + v, 0) / volume_valid; const talk_std = Math.sqrt(talkTimonth.reduce((sum, v) => sum + Math.pow(v - talk_mean, 2), 0) / volume_valid); cv_talk_time = talk_mean > 0 ? talk_std / talk_mean : 0; // Hold time average hold_time_mean = ahtRecords.reduce((sum, i) => sum + i.hold_time, 0) / volume_valid; } // === AHT BENCHMARK: for comparison with industry benchmarks === // Incluye NOISE (calls cortas son traunder real), excludes ZOMBIE (errores) y ABANDON (without handle time) // Industry benchmarks (COPC, Dimension Data) NO filtran calls cortas const benchmarkRecords = group.filter(i => getStatus(i) !== 'ZOMBIE' && getStatus(i) !== 'ABANDON' && i.is_abandoned !== true ); const volume_benchmark = benchmarkRecords.length; let aht_benchmark = aht_mean; // Fallback al AHT clean si no hay records benchmark if (volume_benchmark > 0) { const benchmarkAhts = benchmarkRecords.map(i => i.duration_talk + i.hold_time + i.wrap_up_time); aht_benchmark = benchmarkAhts.reduce((sum, v) => sum + v, 0) / volume_benchmark; } // === AHT TOTAL: calculado con TODAS las filas (only informative) === // Incluye NOISE, ZOMBIE, ABANDON - for comparison with AHT clean let aht_total = 0; if (volume > 0) { const allAhts = group.map(i => i.duration_talk + i.hold_time + i.wrap_up_time); aht_total = allAhts.reduce((sum, v) => sum + v, 0) / volume; } // === FINANCIAL CALCULATIONS: use ALL interactions === // Total cost with effective productivity of 70% const effectiveProductivity = 0.70; // For cost, we use all interactions EXCEPT abandonments (which do not generate conversation cost) // noise y zombie DO generate cost (occupy agent even if little/much time) // Usar nonAbandonRecords que ya filtra por is_abandoned y record_status const costRecords = nonAbandonRecords; const costVolume = costRecords.length; // Calculate AHT for cost using all records that generate cost let aht_for_cost = 0; if (costVolume > 0) { const costAhts = costRecords.map(i => i.duration_talk + i.hold_time + i.wrap_up_time); aht_for_cost = costAhts.reduce((sum, v) => sum + v, 0) / costVolume; } // Real Cost = (AHT in hours Γ— Cost/hour Γ— Volume) / Effective Productivity const rawCost = (aht_for_cost / 3600) * costPerHour * costVolume; const total_cost = rawCost / effectiveProductivity; // CPI = Coste per interaction (usando el volumen correcto) const cpi = costVolume > 0 ? total_cost / costVolume : 0; metrics.push({ skill, volume, volume_valid, aht_mean, aht_total, // AHT con TODAS las filas (only informative) aht_benchmark, aht_std, cv_aht, transfer_rate, fcr_rate, fcr_tecnico, abandonment_rate, total_cost, cost_volume: costVolume, cpi, hold_time_mean, cv_talk_time, noise_count, zombie_count, abandon_count }); }); // === DEBUG: Verify calculations === const totalVolume = metrics.reduce((sum, m) => sum + m.volume, 0); const totalValidVolume = metrics.reduce((sum, m) => sum + m.volume_valid, 0); const totalAbandons = metrics.reduce((sum, m) => sum + m.abandon_count, 0); const globalAbandonRate = totalVolume > 0 ? (totalAbandons / totalVolume) * 100 : 0; // FCR y Transfer rate globales (ponderados por volumen) const avgFCRRate = totalVolume > 0 ? metrics.reduce((sum, m) => sum + m.fcr_rate * m.volume, 0) / totalVolume : 0; const avgFCRTecnicoRate = totalVolume > 0 ? metrics.reduce((sum, m) => sum + m.fcr_tecnico * m.volume, 0) / totalVolume : 0; const avgTransferRate = totalVolume > 0 ? metrics.reduce((sum, m) => sum + m.transfer_rate * m.volume, 0) / totalVolume : 0; console.log(''); console.log('═══════════════════════════════════════════════════════════════'); console.log('πŸ“Š METRICS CALCULATED BY SKILL'); console.log('═══════════════════════════════════════════════════════════════'); console.log(`Total skills: ${metrics.length}`); console.log(`Total volumen: ${totalVolume}`); console.log(`Total abandonments (is_abandoned=TRUE): ${totalAbandons}`); console.log(''); console.log('GLOBAL METRICS (weighted by volume):'); console.log(` Abandonment Rate: ${globalAbandonRate.toFixed(2)}%`); console.log(` FCR Real (without transfer + without recontact 7d): ${avgFCRRate.toFixed(2)}%`); console.log(` Technical FCR (only without transfer, comparable with benchmarks): ${avgFCRTecnicoRate.toFixed(2)}%`); console.log(` Transfer Rate: ${avgTransferRate.toFixed(2)}%`); console.log(''); console.log('Detalle por skill (top 5):'); metrics.slice(0, 5).forEach(m => { console.log(` ${m.skill}: vol=${m.volume}, abandon=${m.abandon_count} (${m.abandonment_rate.toFixed(1)}%), FCR Real=${m.fcr_rate.toFixed(1)}%, Technical FCR=${m.fcr_tecnico.toFixed(1)}%, transfer=${m.transfer_rate.toFixed(1)}%`); }); console.log('═══════════════════════════════════════════════════════════════'); console.log(''); // Mostrar detalle del primer skill for debug if (metrics[0]) { console.log('πŸ“‹ Sample skill detail:', { skill: metrics[0].skill, volume: metrics[0].volume, volume_valid: metrics[0].volume_valid, transfer_rate: `${metrics[0].transfer_rate.toFixed(2)}%`, fcr_rate: `${metrics[0].fcr_rate.toFixed(2)}%`, abandon_count: metrics[0].abandon_count, abandonment_rate: `${metrics[0].abandonment_rate.toFixed(2)}%` }); } return metrics.sort((a, b) => b.volume - a.volume); // Sort by descending volume } /** * v4.4: Classify automation tier with heatmap data * * This function replicates the logic of clasificarTier() usando los datos * disponibles en el heatmap. Accepts optional parameters (fcr, volume) * for greater precision when available. * * Used in generateDrilldownFromHeatmap() de analysisGenerator.ts para * asegurar consistencia between the fresh path (complete data) y la ruta * cached (heatmap data). * * @param score - Agentic Readiness Score (0-10) * @param cv - Coefficient of Variation of AHT as decimal (0.75 = 75%) * @param transfer - Transfer rate as decimal (0.20 = 20%) * @param fcr - FCR rate as decimal (0.80 = 80%), optional * @param volume - Monthly volume of interactions, optional * @returns AgenticTier ('AUTOMATE' | 'ASSIST' | 'AUGMENT' | 'HUMAN-ONLY') */ export function clasificarTierSimple( score: number, cv: number, // CV as decimal (0.75 = 75%) transfer: number, // Transfer as decimal (0.20 = 20%) fcr?: number, // FCR as decimal (0.80 = 80%) volume?: number // Monthly volume ): import('../types').AgenticTier { // critical RED FLAGS - same as clasificarTier() complete // CV > 120% o Transfer > 50% are absolute red flags if (cv > 1.20 || transfer > 0.50) { return 'HUMAN-ONLY'; } // Volume < 50/month is a red flag if we have the data if (volume !== undefined && volume < 50) { return 'HUMAN-ONLY'; } // TIER 1: AUTOMATE - requires optimal metrics // Same criterion as clasificarTier(): score >= 7.5, cv <= 0.75, transfer <= 0.20, fcr >= 0.50 const fcrOk = fcr === undefined || fcr >= 0.50; // If we dont have FCR, we assume OK if (score >= 7.5 && cv <= 0.75 && transfer <= 0.20 && fcrOk) { return 'AUTOMATE'; } // TIER 2: ASSIST - suitable for copilot/assistance if (score >= 5.5 && cv <= 0.90 && transfer <= 0.30) { return 'ASSIST'; } // TIER 3: AUGMENT - requires prior optimization if (score >= 3.5) { return 'AUGMENT'; } // TIER 4: HUMAN-ONLY - complex process return 'HUMAN-ONLY'; } /** * v3.4: Calculate drill-down metrics with new formula for Agentic Readiness Score * * SCORE BY QUEUE (0-10): * - Factor 1: PREDICTABILITY (30%) - based on CV AHT * - Factor 2: RESOLUTION (25%) - FCR (60%) + Transfer (40%) * - Factor 3: VOLUME (25%) - based on volumen monthly * - Factor 4: DATA QUALITY (10%) - % valid records * - Factor 5: SIMPLICITY (10%) - based on AHT * * TIER CLASSIFICATION: * - AUTOMATE: score >= 7.5, CV <= 75%, transfer <= 20%, FCR >= 50% * - ASSIST: score >= 5.5, CV <= 90%, transfer <= 30% * - AUGMENT: score >= 3.5 * - HUMAN-ONLY: score < 3.5 or red flags * * RED FLAGS (automatic HUMAN-ONLY): * - CV > 120% * - Transfer > 50% * - Vol < 50/month * - Valid < 30% */ export function calculateDrilldownMetrics( interactions: RawInteraction[], costPerHour: number ): DrilldownDataPoint[] { const effectiveProductivity = 0.70; // ═══════════════════════════════════════════════════════════════════════════ // FUNCTION: Calculate Score by Queue (new formula v3.4) // ═══════════════════════════════════════════════════════════════════════════ function calculateScoreCola( cv: number, // CV AHT (0-2+, where 1 = 100%) fcr: number, // FCR rate (0-1) transfer: number, // Transfer rate (0-1) vol: number, // Monthly volume aht: number, // AHT in seconds validPct: number // % valid records (0-1) ): { score: number; breakdown: import('../types').AgenticScoreBreakdown } { // FACTOR 1: PREDICTABILITY (30%) - based on CV AHT let scorePred: number; if (cv <= 0.50) { scorePred = 10; } else if (cv <= 0.65) { scorePred = 8 + (0.65 - cv) / 0.15 * 2; } else if (cv <= 0.75) { scorePred = 6 + (0.75 - cv) / 0.10 * 2; } else if (cv <= 0.90) { scorePred = 3 + (0.90 - cv) / 0.15 * 3; } else if (cv <= 1.10) { scorePred = 1 + (1.10 - cv) / 0.20 * 2; } else { scorePred = Math.max(0, 1 - (cv - 1.10) / 0.50); } // FACTOR 2: RESOLUTION (25%) = FCR (60%) + Transfer (40%) let scoreFcr: number; if (fcr >= 0.80) { scoreFcr = 10; } else if (fcr >= 0.70) { scoreFcr = 7 + (fcr - 0.70) / 0.10 * 3; } else if (fcr >= 0.50) { scoreFcr = 4 + (fcr - 0.50) / 0.20 * 3; } else if (fcr >= 0.30) { scoreFcr = 2 + (fcr - 0.30) / 0.20 * 2; } else { scoreFcr = fcr / 0.30 * 2; } let scoreTrans: number; if (transfer <= 0.05) { scoreTrans = 10; } else if (transfer <= 0.15) { scoreTrans = 7 + (0.15 - transfer) / 0.10 * 3; } else if (transfer <= 0.25) { scoreTrans = 4 + (0.25 - transfer) / 0.10 * 3; } else if (transfer <= 0.40) { scoreTrans = 1 + (0.40 - transfer) / 0.15 * 3; } else { scoreTrans = Math.max(0, 1 - (transfer - 0.40) / 0.30); } const scoreResol = scoreFcr * 0.6 + scoreTrans * 0.4; // FACTOR 3: VOLUME (25%) let scoreVol: number; if (vol >= 10000) { scoreVol = 10; } else if (vol >= 5000) { scoreVol = 8 + (vol - 5000) / 5000 * 2; } else if (vol >= 1000) { scoreVol = 5 + (vol - 1000) / 4000 * 3; } else if (vol >= 500) { scoreVol = 3 + (vol - 500) / 500 * 2; } else if (vol >= 100) { scoreVol = 1 + (vol - 100) / 400 * 2; } else { scoreVol = vol / 100; } // FACTOR 4: DATA QUALITY (10%) let scoreCal: number; if (validPct >= 0.90) { scoreCal = 10; } else if (validPct >= 0.75) { scoreCal = 7 + (validPct - 0.75) / 0.15 * 3; } else if (validPct >= 0.50) { scoreCal = 4 + (validPct - 0.50) / 0.25 * 3; } else { scoreCal = validPct / 0.50 * 4; } // FACTOR 5: SIMPLICITY (10%) - based on AHT let scoreSimp: number; if (aht <= 180) { scoreSimp = 10; } else if (aht <= 300) { scoreSimp = 8 + (300 - aht) / 120 * 2; } else if (aht <= 480) { scoreSimp = 5 + (480 - aht) / 180 * 3; } else if (aht <= 720) { scoreSimp = 2 + (720 - aht) / 240 * 3; } else { scoreSimp = Math.max(0, 2 - (aht - 720) / 600 * 2); } // SCORE TOTAL PONDERADO const scoreTotal = ( scorePred * 0.30 + scoreResol * 0.25 + scoreVol * 0.25 + scoreCal * 0.10 + scoreSimp * 0.10 ); return { score: Math.round(scoreTotal * 10) / 10, breakdown: { predictibilidad: Math.round(scorePred * 10) / 10, resolutividad: Math.round(scoreResol * 10) / 10, volumen: Math.round(scoreVol * 10) / 10, qualityDatos: Math.round(scoreCal * 10) / 10, simplicidad: Math.round(scoreSimp * 10) / 10 } }; } // ═══════════════════════════════════════════════════════════════════════════ // FUNCTION: Clasificar Tier del Roadmap // ═══════════════════════════════════════════════════════════════════════════ function clasificarTier( score: number, cv: number, // CV as decimal (0.75 = 75%) transfer: number, // Transfer as decimal (0.20 = 20%) fcr: number, // FCR as decimal (0.80 = 80%) vol: number, validPct: number ): { tier: import('../types').AgenticTier; motivo: string } { // RED FLAGS β†’ HUMAN-ONLY automatic const redFlags: string[] = []; if (cv > 1.20) redFlags.push("CV > 120%"); if (transfer > 0.50) redFlags.push("Transfer > 50%"); if (vol < 50) redFlags.push("Vol < 50/month"); if (validPct < 0.30) redFlags.push("Data < 30% valid"); if (redFlags.length > 0) { return { tier: 'HUMAN-ONLY', motivo: `Red flags: ${redFlags.join(', ')}` }; } // TIER 1: AUTOMATE if (score >= 7.5 && cv <= 0.75 && transfer <= 0.20 && fcr >= 0.50) { return { tier: 'AUTOMATE', motivo: `Score ${score}, optimal metrics for automation` }; } // TIER 2: ASSIST if (score >= 5.5 && cv <= 0.90 && transfer <= 0.30) { return { tier: 'ASSIST', motivo: `Score ${score}, suitable for copilot/assistance` }; } // TIER 3: AUGMENT if (score >= 3.5) { return { tier: 'AUGMENT', motivo: `Score ${score}, requires prior optimization` }; } // TIER 4: HUMAN-ONLY return { tier: 'HUMAN-ONLY', motivo: `Score ${score}, complex process for automation` }; } // ═══════════════════════════════════════════════════════════════════════════ // FUNCTION: Calcular metrics de un group of interactions // ═══════════════════════════════════════════════════════════════════════════ function calculateQueueMetrics(group: RawInteraction[]): import('../types').OriginalQueueMetrics | null { const volume = group.length; if (volume < 5) return null; // Filter only VALID for CV calculation (normalize to uppercase for case-insensitive comparison) const getStatus = (i: RawInteraction) => (i.record_status || '').toString().toUpperCase().trim(); const validRecords = group.filter(i => !i.record_status || getStatus(i) === 'VALID'); const volumeValid = validRecords.length; if (volumeValid < 3) return null; const validPct = volumeValid / volume; // AHT y CV on valid records const ahts = validRecords.map(i => i.duration_talk + i.hold_time + i.wrap_up_time); const aht_mean = ahts.reduce((sum, v) => sum + v, 0) / volumeValid; const aht_variance = ahts.reduce((sum, v) => sum + Math.pow(v - aht_mean, 2), 0) / volumeValid; const aht_std = Math.sqrt(aht_variance); const cv_aht_decimal = aht_mean > 0 ? aht_std / aht_mean : 1.5; // CV as decimal const cv_aht_percent = cv_aht_decimal * 100; // CV como % // Transfer y FCR (as decimals for calculation, as % for display) const transfers = group.filter(i => i.transfer_flag === true).length; const transfer_decimal = transfers / volume; const transfer_percent = transfer_decimal * 100; // FCR Real: usa fcr_real_flag del CSV (without transferencia Y without recontact 7d) const fcrCount = group.filter(i => i.fcr_real_flag === true).length; const fcr_decimal = fcrCount / volume; const fcr_percent = fcr_decimal * 100; // Technical FCR: 100 - transfer_rate (comparable with industry benchmarks) const fcr_tecnico_percent = 100 - transfer_percent; // Calculate score con new formula v3.4 const { score, breakdown } = calculateScoreCola( cv_aht_decimal, fcr_decimal, transfer_decimal, volume, aht_mean, validPct ); // Clasificar tier const { tier, motivo } = clasificarTier( score, cv_aht_decimal, transfer_decimal, fcr_decimal, volume, validPct ); // v4.2: Convert volume from 11 months a annual para el coste const annualVolume = (volume / 11) * 12; // 11 months β†’ annual const annualCost = Math.round((aht_mean / 3600) * costPerHour * annualVolume / effectiveProductivity); return { original_queue_id: '', // Assigned later volume, volumeValid, aht_mean: Math.round(aht_mean), cv_aht: Math.round(cv_aht_percent * 10) / 10, transfer_rate: Math.round(transfer_percent * 10) / 10, fcr_rate: Math.round(fcr_percent * 10) / 10, fcr_tecnico: Math.round(fcr_tecnico_percent * 10) / 10, // Technical FCR for consistency with Summary agenticScore: score, scoreBreakdown: breakdown, tier, tierMotivo: motivo, isPriorityCandidate: tier === 'AUTOMATE', annualCost }; } // ═══════════════════════════════════════════════════════════════════════════ // STEP 1: Group by queue_skill (strategic level) // ═══════════════════════════════════════════════════════════════════════════ const skillGroups = new Map(); for (const interaction of interactions) { const skill = interaction.queue_skill; if (!skill) continue; if (!skillGroups.has(skill)) { skillGroups.set(skill, []); } skillGroups.get(skill)!.push(interaction); } console.log(`πŸ“Š Drill-down v3.4: ${skillGroups.size} queue_skills found`); const drilldownData: DrilldownDataPoint[] = []; // ═══════════════════════════════════════════════════════════════════════════ // STEP 2: For each queue_skill, group by original_queue_id // ═══════════════════════════════════════════════════════════════════════════ skillGroups.forEach((skillGroup, skill) => { if (skillGroup.length < 10) return; const queueGroups = new Map(); for (const interaction of skillGroup) { const queueId = interaction.original_queue_id || 'Without identification'; if (!queueGroups.has(queueId)) { queueGroups.set(queueId, []); } queueGroups.get(queueId)!.push(interaction); } // Calculate metrics for each original_queue_id const originalQueues: import('../types').OriginalQueueMetrics[] = []; queueGroups.forEach((queueGroup, queueId) => { const metrics = calculateQueueMetrics(queueGroup); if (metrics) { metrics.original_queue_id = queueId; originalQueues.push(metrics); } }); if (originalQueues.length === 0) return; // Sort by descending score, then by volume originalQueues.sort((a, b) => { if (Math.abs(a.agenticScore - b.agenticScore) > 0.5) { return b.agenticScore - a.agenticScore; } return b.volume - a.volume; }); // ═══════════════════════════════════════════════════════════════════════ // Calculate aggregated metrics of the skill (volume-weighted average) // ═══════════════════════════════════════════════════════════════════════ const totalVolume = originalQueues.reduce((sum, q) => sum + q.volume, 0); const totalVolumeValid = originalQueues.reduce((sum, q) => sum + q.volumeValid, 0); const totalCost = originalQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0); const avgAht = originalQueues.reduce((sum, q) => sum + q.aht_mean * q.volume, 0) / totalVolume; const avgCv = originalQueues.reduce((sum, q) => sum + q.cv_aht * q.volume, 0) / totalVolume; const avgTransfer = originalQueues.reduce((sum, q) => sum + q.transfer_rate * q.volume, 0) / totalVolume; const avgFcr = originalQueues.reduce((sum, q) => sum + q.fcr_rate * q.volume, 0) / totalVolume; const avgFcrTecnico = originalQueues.reduce((sum, q) => sum + q.fcr_tecnico * q.volume, 0) / totalVolume; // Score global weighted by volume const avgScore = originalQueues.reduce((sum, q) => sum + q.agenticScore * q.volume, 0) / totalVolume; // Tier predominante (el de mayor volumen) const tierCounts = { 'AUTOMATE': 0, 'ASSIST': 0, 'AUGMENT': 0, 'HUMAN-ONLY': 0 }; originalQueues.forEach(q => { tierCounts[q.tier] += q.volume; }); // isPriorityCandidate si hay al menos una queue AUTOMATE const hasAutomateQueue = originalQueues.some(q => q.tier === 'AUTOMATE'); drilldownData.push({ skill, originalQueues, volume: totalVolume, volumeValid: totalVolumeValid, aht_mean: Math.round(avgAht), cv_aht: Math.round(avgCv * 10) / 10, transfer_rate: Math.round(avgTransfer * 10) / 10, fcr_rate: Math.round(avgFcr * 10) / 10, fcr_tecnico: Math.round(avgFcrTecnico * 10) / 10, // Technical FCR para consistencia agenticScore: Math.round(avgScore * 10) / 10, isPriorityCandidate: hasAutomateQueue, annualCost: totalCost }); }); // ═══════════════════════════════════════════════════════════════════════════ // STEP 3: Ordenar y log resumen // ═══════════════════════════════════════════════════════════════════════════ drilldownData.sort((a, b) => b.agenticScore - a.agenticScore); // Count tiers const allQueues = drilldownData.flatMap(s => s.originalQueues); const tierSummary = { AUTOMATE: allQueues.filter(q => q.tier === 'AUTOMATE').length, ASSIST: allQueues.filter(q => q.tier === 'ASSIST').length, AUGMENT: allQueues.filter(q => q.tier === 'AUGMENT').length, 'HUMAN-ONLY': allQueues.filter(q => q.tier === 'HUMAN-ONLY').length }; console.log(`πŸ“Š Drill-down v3.4: ${drilldownData.length} skills, ${allQueues.length} queues`); console.log(`🎯 Tiers: AUTOMATE=${tierSummary.AUTOMATE}, ASSIST=${tierSummary.ASSIST}, AUGMENT=${tierSummary.AUGMENT}, HUMAN-ONLY=${tierSummary['HUMAN-ONLY']}`); return drilldownData; } /** * STEP 3: Transformar metrics a dimensions (0-10) */ export function generateHeatmapFromMetrics( metrics: SkillMetrics[], avgCsat: number, segmentMapping?: { high_value_queues: string[]; medium_value_queues: string[]; low_value_queues: string[] } ): HeatmapDataPoint[] { console.log('πŸ” generateHeatmapFromMetrics called with:', { metricsLength: metrics.length, firstMetric: metrics[0], avgCsat, hasSegmentMapping: !!segmentMapping }); const result = metrics.map(m => { // Dimension 1: Predictability (CV AHT) const predictability = Math.max(0, Math.min(10, 10 - ((m.cv_aht - 0.3) / 1.2 * 10))); // Dimension 2: Inverse Complexity (Transfer Rate) const complexity_inverse = Math.max(0, Math.min(10, 10 - ((m.transfer_rate / 100 - 0.05) / 0.25 * 10))); // Dimension 3: Repeatability (Volumen) let repetitiveness = 0; if (m.volume >= 5000) { repetitiveness = 10; } else if (m.volume <= 100) { repetitiveness = 0; } else { // Linear interpolation between 100 y 5000 repetitiveness = ((m.volume - 100) / (5000 - 100)) * 10; } // Agentic Readiness Score (average ponderado) const agentic_readiness = ( predictability * 0.40 + complexity_inverse * 0.35 + repetitiveness * 0.25 ); // Category let category: 'automate' | 'assist' | 'optimize'; if (agentic_readiness >= 8.0) { category = 'automate'; } else if (agentic_readiness >= 5.0) { category = 'assist'; } else { category = 'optimize'; } // Segmentation const segment = segmentMapping ? classifyQueue(m.skill, segmentMapping.high_value_queues, segmentMapping.medium_value_queues, segmentMapping.low_value_queues) : 'medium' as CustomerSegment; // Scores de performance (normalizados 0-100) // FCR Real: (transfer_flag == FALSE) AND (repeat_call_7d == FALSE) // This is the strictest metric - without transfer AND without recontact in 7 days const fcr_score = Math.round(m.fcr_rate); // Technical FCR: only without transferencia (comparable with industry benchmarks COPC, Dimension Data) const fcr_tecnico_score = Math.round(m.fcr_tecnico); const aht_score = Math.round(Math.max(0, Math.min(100, 100 - ((m.aht_mean - 240) / 310) * 100))); const csat_score = avgCsat; const hold_time_score = Math.round(Math.max(0, Math.min(100, 100 - (m.hold_time_mean / 60) * 10))); // Transfer rate es el % real de transferencias (NO el complemento) const actual_transfer_rate = Math.round(m.transfer_rate); // Abandonment rate es el % real de abandonments const actual_abandonment_rate = Math.round(m.abandonment_rate * 10) / 10; // 1 decimal return { skill: m.skill, volume: m.volume, cost_volume: m.cost_volume, // Volumen used to calculate coste (non-abandon) aht_seconds: Math.round(m.aht_mean), aht_total: Math.round(m.aht_total), // AHT con TODAS las filas (only informative) aht_benchmark: Math.round(m.aht_benchmark), // AHT traditional for comparison with industry benchmarks annual_cost: Math.round(m.total_cost), // Coste calculado con TODOS los records (noise + zombie + valid) cpi: m.cpi, // Coste per interaction (calculado correctamente) metrics: { fcr: fcr_score, // FCR Real (stricter, with recontact filter 7d) fcr_tecnico: fcr_tecnico_score, // Technical FCR (comparable with benchmarks industria) aht: aht_score, csat: csat_score, hold_time: hold_time_score, transfer_rate: actual_transfer_rate, abandonment_rate: actual_abandonment_rate }, automation_readiness: Math.round(agentic_readiness * 10), variability: { cv_aht: Math.round(m.cv_aht * 100), cv_talk_time: Math.round(m.cv_talk_time * 100), cv_hold_time: Math.round(m.cv_talk_time * 80), // Approximation transfer_rate: Math.round(m.transfer_rate) }, dimensions: { predictability: Math.round(predictability * 10) / 10, complexity_inverse: Math.round(complexity_inverse * 10) / 10, repetitiveness: Math.round(repetitiveness * 10) / 10 }, agentic_readiness: Math.round(agentic_readiness * 10) / 10, category, segment }; }); console.log('πŸ“Š Heatmap data generated from real data:', { length: result.length, firstItem: result[0], objectKeys: result[0] ? Object.keys(result[0]) : [], hasMetricsObject: result[0] && typeof result[0].metrics !== 'undefined', metricsKeys: result[0] && result[0].metrics ? Object.keys(result[0].metrics) : [], firstMetrics: result[0] && result[0].metrics ? result[0].metrics : null, automation_readiness: result[0] ? result[0].automation_readiness : null }); return result; } /** * Calculate global Health Score - New formula based on industry benchmarks * * STEP 1: Normalization of components using industry percentiles * STEP 2: Weighting (FCR 35%, Abandono 30%, CSAT Proxy 20%, AHT 15%) * STEP 3: Penalizaciones por umbrales criticals * * Benchmarks de industria (Cross-Industry): * - Technical FCR: P10=85%, P50=68%, P90=50% * - Abandono: P10=3%, P50=5%, P90=10% * - AHT: P10=240s, P50=380s, P90=540s */ function calculateHealthScore(heatmapData: HeatmapDataPoint[]): number { if (heatmapData.length === 0) return 50; const totalVolume = heatmapData.reduce((sum, d) => sum + d.volume, 0); if (totalVolume === 0) return 50; // ═══════════════════════════════════════════════════════════════ // STEP 0: Extraer metrics ponderadas por volumen // ═══════════════════════════════════════════════════════════════ // Technical FCR (%) const fcrTecnico = heatmapData.reduce((sum, d) => sum + (d.metrics?.fcr_tecnico ?? (100 - d.metrics.transfer_rate)) * d.volume, 0) / totalVolume; // Abandono (%) const abandonment = heatmapData.reduce((sum, d) => sum + (d.metrics?.abandonment_rate || 0) * d.volume, 0) / totalVolume; // AHT (segundos) - usar aht_seconds (AHT clean without noise/zombies) const aht = heatmapData.reduce((sum, d) => sum + d.aht_seconds * d.volume, 0) / totalVolume; // Transferencia (%) const transferencia = heatmapData.reduce((sum, d) => sum + (d.metrics?.transfer_rate || 0) * d.volume, 0) / totalVolume; // ═══════════════════════════════════════════════════════════════ // STEP 1: Component normalization (0-100 score) // ═══════════════════════════════════════════════════════════════ // Technical FCR: P10=85%, P50=68%, P90=50% // Higher = better let fcrScore: number; if (fcrTecnico >= 85) { fcrScore = 95 + 5 * Math.min(1, (fcrTecnico - 85) / 15); // 95-100 } else if (fcrTecnico >= 68) { fcrScore = 50 + 50 * (fcrTecnico - 68) / (85 - 68); // 50-100 } else if (fcrTecnico >= 50) { fcrScore = 20 + 30 * (fcrTecnico - 50) / (68 - 50); // 20-50 } else { fcrScore = Math.max(0, 20 * fcrTecnico / 50); // 0-20 } // Abandono: P10=3%, P50=5%, P90=10% // Lower = better (inverted) let abandonmentScore: number; if (abandonment <= 3) { abandonmentScore = 95 + 5 * Math.max(0, (3 - abandonment) / 3); // 95-100 } else if (abandonment <= 5) { abandonmentScore = 50 + 45 * (5 - abandonment) / (5 - 3); // 50-95 } else if (abandonment <= 10) { abandonmentScore = 20 + 30 * (10 - abandonment) / (10 - 5); // 20-50 } else { // Above P90 (critical): strong penalty abandonmentScore = Math.max(0, 20 - 2 * (abandonment - 10)); // 0-20, decreases rapidly } // AHT: P10=240s, P50=380s, P90=540s // Lower = better (inverted) // PERO: Si FCR es under, AHT under puede indicar calls rushed (mala quality) let ahtScore: number; if (aht <= 240) { // Por deunder de P10 (excellent efficiency) // Si FCR > 65%, es genuinamente eficiente; si no, puede ser rushed if (fcrTecnico > 65) { ahtScore = 95 + 5 * Math.max(0, (240 - aht) / 60); // 95-100 } else { ahtScore = 70; // Cap score si FCR es under (posible rushed calls) } } else if (aht <= 380) { ahtScore = 50 + 45 * (380 - aht) / (380 - 240); // 50-95 } else if (aht <= 540) { ahtScore = 20 + 30 * (540 - aht) / (540 - 380); // 20-50 } else { ahtScore = Math.max(0, 20 * (600 - aht) / 60); // 0-20 } // CSAT Proxy: Calculado from FCR + Abandono // Sin datos reales de CSAT, usamos proxy const csatProxy = 0.60 * fcrScore + 0.40 * abandonmentScore; // ═══════════════════════════════════════════════════════════════ // STEP 2: Aplicar pesos // FCR 35% + Abandono 30% + CSAT Proxy 20% + AHT 15% // ═══════════════════════════════════════════════════════════════ const subtotal = ( fcrScore * 0.35 + abandonmentScore * 0.30 + csatProxy * 0.20 + ahtScore * 0.15 ); // ═══════════════════════════════════════════════════════════════ // STEP 3: Calcular penalizesciones // ═══════════════════════════════════════════════════════════════ let penalties = 0; // Penalty for abandonment critical (>10%) if (abandonment > 10) { penalties += 10; } // Penalty for transferencia alta (>20%) if (transferencia > 20) { penalties += 5; } // Combo penalty: Abandono high + FCR under // Indicates systemic problems of capacity AND resolution if (abandonment > 8 && fcrTecnico < 78) { penalties += 5; } // ═══════════════════════════════════════════════════════════════ // STEP 4: Final Score // ═══════════════════════════════════════════════════════════════ const finalScore = Math.max(0, Math.min(100, subtotal - penalties)); // Debug logging console.log('πŸ“Š Health Score Calculation:', { inputs: { fcrTecnico: fcrTecnico.toFixed(1), abandonment: abandonment.toFixed(1), aht: Math.round(aht), transferencia: transferencia.toFixed(1) }, scores: { fcrScore: fcrScore.toFixed(1), abandonmentScore: abandonmentScore.toFixed(1), ahtScore: ahtScore.toFixed(1), csatProxy: csatProxy.toFixed(1) }, weighted: { subtotal: subtotal.toFixed(1), penalties, final: Math.round(finalScore) } }); return Math.round(finalScore); } /** * v4.0: Generate 7 viable dimensions from real data * Airline sector benchmarks: AHT P50=380s, FCR=70%, Abandono=5%, Ratio P90/P50 healthy<2.0 */ function generateDimensionsFromRealData( interactions: RawInteraction[], metrics: SkillMetrics[], avgCsat: number, avgAHT: number, hourlyDistribution: { hourly: number[]; off_hours_pct: number; peak_hours: number[] }, globalCPI: number // CPI calculated centrally from heatmapData ): DimensionAnalysis[] { const totalVolume = interactions.length; const avgCV = metrics.reduce((sum, m) => sum + m.cv_aht, 0) / metrics.length; const avgTransferRate = metrics.reduce((sum, m) => sum + m.transfer_rate, 0) / metrics.length; const avgHoldTime = metrics.reduce((sum, m) => sum + m.hold_time_mean, 0) / metrics.length; const totalCost = metrics.reduce((sum, m) => sum + m.total_cost, 0); // Technical FCR (100 - transfer_rate, weighted by volume) - comparable with benchmarks const totalVolumeForFCR = metrics.reduce((sum, m) => sum + m.volume_valid, 0); const avgFCR = totalVolumeForFCR > 0 ? metrics.reduce((sum, m) => sum + (m.fcr_tecnico * m.volume_valid), 0) / totalVolumeForFCR : 0; // Calculate ratio P90/P50 approximated from CV const avgRatio = 1 + avgCV * 1.5; // Approximation: ratio β‰ˆ 1 + 1.5*CV // === SCORE EFICIENCIA: Scale based on ratio P90/P50 === // <1.5 = 100pts, 1.5-2.0 = 70pts, 2.0-2.5 = 50pts, 2.5-3.0 = 30pts, >3.0 = 20pts let efficiencyScore: number; if (avgRatio < 1.5) efficiencyScore = 100; else if (avgRatio < 2.0) efficiencyScore = 70 + (2.0 - avgRatio) * 60; // 70-100 else if (avgRatio < 2.5) efficiencyScore = 50 + (2.5 - avgRatio) * 40; // 50-70 else if (avgRatio < 3.0) efficiencyScore = 30 + (3.0 - avgRatio) * 40; // 30-50 else efficiencyScore = 20; // === VOLUMETRY SCORE: Based on % off hours and peak/valley ratio === // % off hours >30% penalizes, peak/valley ratio >3x penalizes const offHoursPct = hourlyDistribution.off_hours_pct; // Calculate peak/valley ratio (consistent with backendMapper.ts) const hourlyValues = hourlyDistribution.hourly.filter(v => v > 0); const peakVolume = hourlyValues.length > 0 ? Math.max(...hourlyValues) : 0; const valleyVolume = hourlyValues.length > 0 ? Math.min(...hourlyValues) : 1; const peakValleyRatio = valleyVolume > 0 ? peakVolume / valleyVolume : 1; // Volumetry score: 100 base, penalize by off hours y peak/valley ratio // NOTA: Formulas synchronized with backendMapper.ts buildVolumetryDimension() let volumetryScore = 100; // Penalty for off hours (same formula as backendMapper) if (offHoursPct > 30) { volumetryScore -= Math.min(40, (offHoursPct - 30) * 2); // -2 pts per each % above 30% } else if (offHoursPct > 20) { volumetryScore -= (offHoursPct - 20); // -1 pt per each % between 20-30% } // Penalty for peak/valley ratio high (same formula as backendMapper) if (peakValleyRatio > 5) { volumetryScore -= 30; } else if (peakValleyRatio > 3) { volumetryScore -= 20; } else if (peakValleyRatio > 2) { volumetryScore -= 10; } volumetryScore = Math.max(0, Math.min(100, Math.round(volumetryScore))); // === CPI: Use the centralized value passed as parameter === // globalCPI was already calculated in generateAnalysisFromRealData from heatmapData // This ensures consistency with ExecutiveSummaryTab const costPerInteraction = globalCPI; // Calculate Agentic Score const predictability = Math.max(0, Math.min(10, 10 - ((avgCV - 0.3) / 1.2 * 10))); const complexityInverse = Math.max(0, Math.min(10, 10 - (avgTransferRate / 10))); const repetitivity = Math.min(10, totalVolume / 500); const agenticScore = predictability * 0.30 + complexityInverse * 0.30 + repetitivity * 0.25 + 2.5; // Determine percentile of Eficiencia based on benchmark airline sector (ratio <2.0 healthy) const efficiencyPercentile = avgRatio < 2.0 ? 75 : avgRatio < 2.5 ? 50 : avgRatio < 3.0 ? 35 : 20; // Determine percentile of FCR based on benchmark airline sector (70%) const fcrPercentile = avgFCR >= 70 ? 75 : avgFCR >= 60 ? 50 : avgFCR >= 50 ? 35 : 20; return [ // 1. VOLUMETRY & DISTRIBUTION { id: 'volumetry_distribution', name: 'volumetry_distribution', title: 'Volumetry & Distribution', score: volumetryScore, percentile: offHoursPct <= 20 ? 80 : offHoursPct <= 30 ? 60 : 40, summary: `${offHoursPct.toFixed(1)}% off hours. Ratio pico/valle: ${peakValleyRatio.toFixed(1)}x. ${totalVolume.toLocaleString('es-ES')} total interactions.`, kpi: { label: 'Off Hours', value: `${offHoursPct.toFixed(0)}%` }, icon: BarChartHorizontal, distribution_data: { hourly: hourlyDistribution.hourly, off_hours_pct: hourlyDistribution.off_hours_pct, peak_hours: hourlyDistribution.peak_hours } }, // 2. OPERATIONAL EFFICIENCY - KPI principal: AHT P50 (industry standard) { id: 'operational_efficiency', name: 'operational_efficiency', title: 'Operational Efficiency', score: Math.round(efficiencyScore), percentile: efficiencyPercentile, summary: `AHT P50: ${avgAHT}s (benchmark: 300s). Ratio P90/P50: ${avgRatio.toFixed(2)} (benchmark: <2.0). Hold time: ${Math.round(avgHoldTime)}s.`, kpi: { label: 'AHT P50', value: `${avgAHT}s` }, icon: Zap }, // 3. EFFECTIVENESS & RESOLUTION (Technical FCR = 100 - transfer_rate) { id: 'effectiveness_resolution', name: 'effectiveness_resolution', title: 'Effectiveness & Resolution', score: avgFCR >= 90 ? 100 : avgFCR >= 85 ? 80 : avgFCR >= 80 ? 60 : avgFCR >= 75 ? 40 : 20, percentile: fcrPercentile, summary: `Technical FCR: ${avgFCR.toFixed(1)}% (benchmark: 85-90%). Transfer: ${avgTransferRate.toFixed(1)}%.`, kpi: { label: 'Technical FCR', value: `${Math.round(avgFCR)}%` }, icon: Target }, // 4. COMPLEXITY COMPLEJIDAD & PREDICTABILITY PREDICTABILITY - KPI principal: CV AHT (industry standard for predictability) { id: 'complexity_predictability', name: 'complexity_predictability', title: 'Complexity Complejidad & Predictibilidad Predictability', score: avgCV <= 0.75 ? 100 : avgCV <= 1.0 ? 80 : avgCV <= 1.25 ? 60 : avgCV <= 1.5 ? 40 : 20, // Based on CV AHT percentile: avgCV <= 0.75 ? 75 : avgCV <= 1.0 ? 55 : avgCV <= 1.25 ? 40 : 25, summary: `CV AHT: ${(avgCV * 100).toFixed(0)}% (benchmark: <75%). Hold time: ${Math.round(avgHoldTime)}s. ${avgCV <= 0.75 ? 'High predictability for WFM.' : avgCV <= 1.0 ? 'Acceptable predictability.' : 'High variability, complicates planning.'}`, kpi: { label: 'CV AHT', value: `${(avgCV * 100).toFixed(0)}%` }, icon: Brain }, // 5. SATISFACTION - CSAT { id: 'customer_satisfaction', name: 'customer_satisfaction', title: 'Customer Satisfaction', score: avgCsat > 0 ? Math.round(avgCsat) : 0, percentile: avgCsat > 0 ? (avgCsat >= 80 ? 70 : avgCsat >= 60 ? 50 : 30) : 0, summary: avgCsat > 0 ? `CSAT: ${avgCsat.toFixed(1)}/100. ${avgCsat >= 80 ? 'High satisfaction.' : avgCsat >= 60 ? 'Acceptable satisfaction.' : 'Requires attention.'}` : 'CSAT: Not available in dataset. Consider implementing post-call surveys.', kpi: { label: 'CSAT', value: avgCsat > 0 ? `${Math.round(avgCsat)}/100` : 'N/A' }, icon: Smile }, // 6. ECONOMY - CPI (airline benchmark: p25=2.20, p50=3.50, p75=4.50, p90=5.50) { id: 'economy_cpi', name: 'economy_cpi', title: 'Operational Economy', // Score based on percentiles airlines (CPI inverted: lower = better) score: costPerInteraction <= 2.20 ? 100 : costPerInteraction <= 3.50 ? 80 : costPerInteraction <= 4.50 ? 60 : costPerInteraction <= 5.50 ? 40 : 20, percentile: costPerInteraction <= 2.20 ? 90 : costPerInteraction <= 3.50 ? 70 : costPerInteraction <= 4.50 ? 50 : costPerInteraction <= 5.50 ? 25 : 10, summary: `CPI: €${costPerInteraction.toFixed(2)} per interaction. Annual cost: €${totalCost.toLocaleString('es-ES')}. Airline sector benchmark: €3.50.`, kpi: { label: 'Cost/Interaction', value: `€${costPerInteraction.toFixed(2)}` }, icon: DollarSign }, // 7. AGENTIC READINESS { id: 'agentic_readiness', name: 'agentic_readiness', title: 'Agentic Readiness', score: Math.round(agenticScore * 10), percentile: agenticScore >= 7 ? 75 : agenticScore >= 5 ? 55 : 35, summary: `Score: ${agenticScore.toFixed(1)}/10. ${agenticScore >= 8 ? 'Excellent for automation.' : agenticScore >= 5 ? 'Candidate for AI assistance.' : 'Requires prior optimization.'}`, kpi: { label: 'Score', value: `${agenticScore.toFixed(1)}/10` }, icon: Bot } ]; } /** * Calcular Agentic Readiness from datos reales * Score = Ξ£(factor_i Γ— weight_i) with 6 unique factors */ function calculateAgenticReadinessFromRealData(metrics: SkillMetrics[]): AgenticReadinessResult { const totalVolume = metrics.reduce((sum, m) => sum + m.volume, 0); const avgCV = metrics.reduce((sum, m) => sum + m.cv_aht, 0) / metrics.length; const avgCVTalk = metrics.reduce((sum, m) => sum + m.cv_talk_time, 0) / metrics.length; const avgTransferRate = metrics.reduce((sum, m) => sum + m.transfer_rate, 0) / metrics.length; const totalCost = metrics.reduce((sum, m) => sum + m.total_cost, 0); // === 6 UNIQUE FACTORS === // 1. Predictibilidad (CV AHT) - Peso 25% // Score = 10 - (CV_AHT Γ— 10). CV < 30% = Score > 7 const predictability = Math.max(0, Math.min(10, 10 - (avgCV * 10))); // 2. Simplicidad Operativa (Transfer Rate) - Peso 20% // Score = 10 - (Transfer / 5). Transfer < 10% = Score > 8 const complexity_inverse = Math.max(0, Math.min(10, 10 - (avgTransferRate / 5))); // 3. Volumen e Impacto - Peso 15% // Score lineal: < 100 = 0, 100-5000 interpolation, > 5000 = 10 let repetitiveness = 0; if (totalVolume >= 5000) repetitiveness = 10; else if (totalVolume <= 100) repetitiveness = 0; else repetitiveness = ((totalVolume - 100) / (5000 - 100)) * 10; // 4. Structuring (CV Talk Time) - Peso 15% // Score = 10 - (CV_Talk Γ— 8). Low variability = high structuring const estructuracion = Math.max(0, Math.min(10, 10 - (avgCVTalk * 8))); // 5. Estabilidad (peak/valley ratio simplificado) - Peso 10% // Simplification: based on CV general as proxy const estabilidad = Math.max(0, Math.min(10, 10 - (avgCV * 5))); // 6. ROI Potencial (based on coste y volumen) - Peso 15% // Score = min(10, log10(Coste) - 2) para costes > €100 const roiPotencial = totalCost > 100 ? Math.max(0, Math.min(10, (Math.log10(totalCost) - 2) * 2.5)) : 0; // Final weighted Score: (10Γ—0.25)+(5Γ—0.20)+(10Γ—0.15)+(0Γ—0.15)+(10Γ—0.10)+(10Γ—0.15) const score = Math.round(( predictability * 0.25 + complexity_inverse * 0.20 + repetitiveness * 0.15 + estructuracion * 0.15 + estabilidad * 0.10 + roiPotencial * 0.15 ) * 10) / 10; // Tier based on score (umbrales actualizados) let tier: TierKey; if (score >= 6) tier = 'gold'; // Listo para Copilot else if (score >= 4) tier = 'silver'; // Optimizar primero else tier = 'bronze'; // Requires human management // Sub-factors with unique descriptions and specific methodologies const sub_factors: SubFactor[] = [ { name: 'predictibilidad', displayName: 'Predictibilidad', score: Math.round(predictability * 10) / 10, weight: 0.25, description: `CV AHT: ${Math.round(avgCV * 100)}%. Score = 10 - (CV Γ— 10)` }, { name: 'complejidad_inversa', displayName: 'Simplicidad Operativa', score: Math.round(complexity_inverse * 10) / 10, weight: 0.20, description: `Transfer rate: ${Math.round(avgTransferRate)}%. Score = 10 - (Transfer / 5)` }, { name: 'repetitividad', displayName: 'Volumen e Impacto', score: Math.round(repetitiveness * 10) / 10, weight: 0.15, description: `${totalVolume.toLocaleString('es-ES')} interactions. Scale lineal 100-5000` }, { name: 'estructuracion', displayName: 'Structuring', score: Math.round(estructuracion * 10) / 10, weight: 0.15, description: `CV Talk: ${Math.round(avgCVTalk * 100)}%. Score = 10 - (CV_Talk Γ— 8)` }, { name: 'estabilidad', displayName: 'Estabilidad Temporal', score: Math.round(estabilidad * 10) / 10, weight: 0.10, description: `Based on general variability. Score = 10 - (CV Γ— 5)` }, { name: 'roi_potencial', displayName: 'ROI Potencial', score: Math.round(roiPotencial * 10) / 10, weight: 0.15, description: `Annual cost: €${totalCost.toLocaleString('es-ES')}. Logarithmic score` } ]; // Interpretation based on umbrales actualizados let interpretation: string; if (score >= 6) { interpretation = 'Listo para Copilot. Procesos con predictibilidad y simplicidad suficientes para asistencia IA.'; } else if (score >= 4) { interpretation = 'Requires optimization. Standardize processes and reduce variability before implementing AI.'; } else { interpretation = 'Human management recommended. Complex or variable processes that require human intervention.'; } return { score, sub_factors, tier, confidence: totalVolume > 1000 ? 'high' as const : totalVolume > 500 ? 'medium' as const : 'low' as const, interpretation }; } /** * Generar findings from datos reales - SOLO datos calculados del dataset */ function generateFindingsFromRealData( metrics: SkillMetrics[], interactions: RawInteraction[], hourlyDistribution?: { hourly: number[]; off_hours_pct: number; peak_hours: number[] } ): Finding[] { const findings: Finding[] = []; const totalVolume = interactions.length; // Calculate metrics globales const avgCV = metrics.reduce((sum, m) => sum + m.cv_aht, 0) / metrics.length; const avgTransferRate = metrics.reduce((sum, m) => sum + m.transfer_rate, 0) / metrics.length; const avgRatio = 1 + avgCV * 1.5; // Calculate abandonment real const totalAbandoned = metrics.reduce((sum, m) => sum + m.abandon_count, 0); const abandonRate = totalVolume > 0 ? (totalAbandoned / totalVolume) * 100 : 0; // Finding 0: Alto off hours volume - oportunidad para agent virtual const offHoursPct = hourlyDistribution?.off_hours_pct ?? 0; if (offHoursPct > 20) { const offHoursVolume = Math.round(totalVolume * offHoursPct / 100); findings.push({ type: offHoursPct > 30 ? 'critical' : 'warning', title: 'Alto Volumen Off Hours', text: `${offHoursPct.toFixed(0)}% of interactions off hours (8-19h)`, dimensionId: 'volumetry_distribution', description: `${offHoursVolume.toLocaleString()} interactions (${offHoursPct.toFixed(1)}%) occur outside business hours. Ideal opportunity to implement virtual agents 24/7.`, impact: offHoursPct > 30 ? 'high' : 'medium' }); } // Finding 1: Ratio P90/P50 if outside benchmark if (avgRatio > 2.0) { findings.push({ type: avgRatio > 3.0 ? 'critical' : 'warning', title: 'Ratio P90/P50 elevado', text: `Ratio P90/P50: ${avgRatio.toFixed(2)}`, dimensionId: 'operational_efficiency', description: `Ratio P90/P50 de ${avgRatio.toFixed(2)} exceeds the benchmark of 2.0. Indicates high dispersion in handling times.` }); } // Finding 2: Variabilidad alta (CV AHT) const highVariabilitySkills = metrics.filter(m => m.cv_aht > 0.45); if (highVariabilitySkills.length > 0) { findings.push({ type: 'warning', title: 'Alta Variabilidad AHT', text: `${highVariabilitySkills.length} skills con CV > 45%`, dimensionId: 'complexity_predictability', description: `${highVariabilitySkills.length} de ${metrics.length} skills show CV AHT > 45%, suggesting poorly standardized processes.` }); } // Finding 3: Transferencias altas if (avgTransferRate > 15) { findings.push({ type: avgTransferRate > 25 ? 'critical' : 'warning', title: 'Tasa de Transferencia', text: `Transfer rate: ${avgTransferRate.toFixed(1)}%`, dimensionId: 'complexity_predictability', description: `Tasa de transferencia average de ${avgTransferRate.toFixed(1)}% indicates need for training or routing.` }); } // Finding 4: Abandono si supera benchmark if (abandonRate > 5) { findings.push({ type: abandonRate > 10 ? 'critical' : 'warning', title: 'Tasa de Abandono', text: `Abandono: ${abandonRate.toFixed(1)}%`, dimensionId: 'effectiveness_resolution', description: `Tasa de abandonment de ${abandonRate.toFixed(1)}% exceeds the benchmark of 5%. Review capacity and wait times.` }); } // Finding 5: Volume concentration (only if there are enough skills) if (metrics.length >= 3) { const topSkill = metrics[0]; const topSkillPct = (topSkill.volume / totalVolume) * 100; if (topSkillPct > 30) { findings.push({ type: 'info', title: 'Volume Concentration', text: `${topSkill.skill}: ${topSkillPct.toFixed(0)}% del total`, dimensionId: 'volumetry_distribution', description: `El skill "${topSkill.skill}" concentrates ${topSkillPct.toFixed(1)}% of total volume (${topSkill.volume.toLocaleString()} interactions).` }); } } return findings; } /** * Generar recomendaciones from datos reales */ function generateRecommendationsFromRealData( metrics: SkillMetrics[], hourlyDistribution?: { hourly: number[]; off_hours_pct: number; peak_hours: number[] }, totalVolume?: number ): Recommendation[] { const recommendations: Recommendation[] = []; // Priority recommendation: Virtual agent for off hours const offHoursPct = hourlyDistribution?.off_hours_pct ?? 0; const volume = totalVolume ?? metrics.reduce((sum, m) => sum + m.volume, 0); if (offHoursPct > 20) { const offHoursVolume = Math.round(volume * offHoursPct / 100); const estimatedContainment = offHoursPct > 30 ? 60 : 45; // % que puede resolver el bot const estimatedSavings = Math.round(offHoursVolume * estimatedContainment / 100); recommendations.push({ priority: 'high', title: 'Implement Virtual Agent 24/7', text: `Desplegar agent virtual para atender ${offHoursPct.toFixed(0)}% of interactions off hours`, description: `${offHoursVolume.toLocaleString()} interactions occur outside business hours (19:00-08:00). A virtual agent can resolve ~${estimatedContainment}% of these queries automatically, freeing human resources and improving customer experience with immediate attention 24/7.`, dimensionId: 'volumetry_distribution', impact: `Containment potential: ${estimatedSavings.toLocaleString()} interactions/period`, timeline: '1-3 months' }); } const highVariabilitySkills = metrics.filter(m => m.cv_aht > 0.45); if (highVariabilitySkills.length > 0) { recommendations.push({ priority: 'high', title: 'Estandarizar Procesos', text: `Create guides and scripts for the ${highVariabilitySkills.length} skills with high variability`, description: `Create guides and scripts for the ${highVariabilitySkills.length} skills with high variability.`, impact: 'Reduction of 20-30% en AHT' }); } const highVolumeSkills = metrics.filter(m => m.volume > 500); if (highVolumeSkills.length > 0) { recommendations.push({ priority: 'high', title: 'Automate Skills de Alto Volumen', text: `Implement bots for the ${highVolumeSkills.length} skills con > 500 interactions`, description: `Implement bots for the ${highVolumeSkills.length} skills con > 500 interactions.`, impact: 'Ahorro estimado del 40-60%' }); } return recommendations; } /** * v3.3: Generar opportunities from drilldownData (based on queues with CV < 75%) * Las oportunidades se clasifican en 3 categorys: * - Automatizar: Colas con CV < 75% (estables, listas para IA) * - Asistir: Colas con CV 75-100% (necesitan copilot) * - Optimize: Queues with CV > 100% (need standardization first) */ /** * v3.5: Calculate realistic savings using TCO formula by tier * * TCO formula by tier: * - AUTOMATE (Tier 1): 70% containment β†’ savings = vol_annual Γ— 0.70 Γ— (CPI_human - CPI_ai) * - ASSIST (Tier 2): 30% efficiency β†’ savings = vol_annual Γ— 0.30 Γ— (CPI_human - CPI_copilot) * - AUGMENT (Tier 3): 15% optimization β†’ savings = vol_annual Γ— 0.15 Γ— (CPI_human - CPI_optimized) * - HUMAN-ONLY (Tier 4): 0% β†’ without ahorro * * Costes per interaction (CPI): * - CPI_human: Se calcula from AHT y cost_per_hour (~€4-5/interaction) * - CPI_ai: €0.15/interaction (chatbot/IVR) * - CPI_copilot: ~60% del CPI human (agent asistido) * - CPI_optimized: ~85% del CPI human (mejora marginal) */ /** * v3.6: CPI constants for TCO savings calculation * Values aligned with Beyond methodology */ const CPI_CONFIG = { CPI_HUMANO: 2.33, // €/interaction - coste actual agent human CPI_BOT: 0.15, // €/interaction - cost bot/automation CPI_ASSIST: 1.50, // €/interaction - coste con copilot CPI_AUGMENT: 2.00, // €/interaction - coste optimizado // Success/containment rates by tier RATE_AUTOMATE: 0.70, // 70% containment in automation RATE_ASSIST: 0.30, // 30% efficiency en asistencia RATE_AUGMENT: 0.15 // 15% improvement in optimization }; // Data period: the volume in the data corresponds to 11 months, is not monthly const DATA_PERIOD_MONTHS = 11; /** * v4.2: Calculate realistic TCO savings using explicit formula with fixed CPI * IMPORTANT: The volume in the data corresponds to 11 months, therefore: * - First we calculate monthly volume: Vol / 11 * - Then we annualize: Γ— 12 * Formulas: * - AUTOMATE: (Vol/11) Γ— 12 Γ— 70% Γ— (CPI_human - CPI_bot) * - ASSIST: (Vol/11) Γ— 12 Γ— 30% Γ— (CPI_human - CPI_assist) * - AUGMENT: (Vol/11) Γ— 12 Γ— 15% Γ— (CPI_human - CPI_augment) * - HUMAN-ONLY: 0€ */ function calculateRealisticSavings( volume: number, _annualCost: number, // Kept for compatibility but not used tier: 'AUTOMATE' | 'ASSIST' | 'AUGMENT' | 'HUMAN-ONLY' ): number { if (volume === 0) return 0; const { CPI_HUMANO, CPI_BOT, CPI_ASSIST, CPI_AUGMENT, RATE_AUTOMATE, RATE_ASSIST, RATE_AUGMENT } = CPI_CONFIG; // Convert volume from period (11 months) to annual volume const annualVolume = (volume / DATA_PERIOD_MONTHS) * 12; switch (tier) { case 'AUTOMATE': // Savings = VolAnual Γ— 70% Γ— (CPI_human - CPI_bot) return Math.round(annualVolume * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT)); case 'ASSIST': // Savings = VolAnual Γ— 30% Γ— (CPI_human - CPI_assist) return Math.round(annualVolume * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST)); case 'AUGMENT': // Savings = VolAnual Γ— 15% Γ— (CPI_human - CPI_augment) return Math.round(annualVolume * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT)); case 'HUMAN-ONLY': default: return 0; } } export function generateOpportunitiesFromDrilldown(drilldownData: DrilldownDataPoint[], costPerHour: number): Opportunity[] { // v4.3: Top 10 initiatives by economic potential (all tiers, not only AUTOMATE) // Each queue = 1 bubble with its real score and real TCO savings according to its tier // Extract all queues with their parent skill (exclude HUMAN-ONLY, have no savings) const allQueues = drilldownData.flatMap(skill => skill.originalQueues .filter(q => q.tier !== 'HUMAN-ONLY') // HUMAN-ONLY generates no savings .map(q => ({ ...q, skillName: skill.skill })) ); if (allQueues.length === 0) { console.warn('⚠️ No queues with savings potential to show in Opportunity Matrix'); return []; } // Calculate TCO savings per individual queue according to its tier const queuesWithSavings = allQueues.map(q => { const savings = calculateRealisticSavings(q.volume, q.annualCost || 0, q.tier); return { ...q, savings }; }); // Sort by descending savings queuesWithSavings.sort((a, b) => b.savings - a.savings); // Calculate max savings to scale impact to 0-10 const maxSavings = Math.max(...queuesWithSavings.map(q => q.savings), 1); // Mapping of tier to dimensionId and customer_segment const tierToDimension: Record = { 'AUTOMATE': 'agentic_readiness', 'ASSIST': 'effectiveness_resolution', 'AUGMENT': 'complexity_predictability' }; const tierToSegment: Record = { 'AUTOMATE': 'high', 'ASSIST': 'medium', 'AUGMENT': 'low' }; // Generate individual opportunities (TOP 10 by economic potential) const opportunities: Opportunity[] = queuesWithSavings .slice(0, 10) .map((q, idx) => { // Impact: savings scaled to 0-10 const impactRaw = (q.savings / maxSavings) * 10; const impact = Math.max(1, Math.min(10, Math.round(impactRaw * 10) / 10)); // Feasibility: direct agenticScore (already is 0-10) const feasibility = Math.round(q.agenticScore * 10) / 10; // Nombre con prefijo de tier para claridad const tierPrefix = q.tier === 'AUTOMATE' ? 'πŸ€–' : q.tier === 'ASSIST' ? '🀝' : 'πŸ“š'; const shortName = q.original_queue_id.length > 22 ? `${tierPrefix} ${q.original_queue_id.substring(0, 19)}...` : `${tierPrefix} ${q.original_queue_id}`; return { id: `opp-${q.tier.toLowerCase()}-${idx + 1}`, name: shortName, impact, feasibility, savings: q.savings, dimensionId: tierToDimension[q.tier] || 'agentic_readiness', customer_segment: tierToSegment[q.tier] || 'medium' }; }); console.log(`πŸ“Š Opportunity Matrix: Top ${opportunities.length} initiatives by economic potential (de ${allQueues.length} queues with ahorro)`); return opportunities; } /** * v3.5: Generar roadmap from drilldownData usando sistema de Tiers * Initiatives structured in 3 phases based on Tier classification: * - Phase 1 (Automate): AUTOMATE tier Queues - direct AI implementation (70% containment) * - Phase 2 (Assist): Colas tier ASSIST - copilot y asistencia (30% efficiency) * - Phase 3 (Augment): AUGMENT/HUMAN-ONLY tier Queues - standardization first (15%) */ export function generateRoadmapFromDrilldown(drilldownData: DrilldownDataPoint[], costPerHour: number): RoadmapInitiative[] { const initiatives: RoadmapInitiative[] = []; let initCounter = 1; // Extract y clasificar all las queues por TIER const allQueues = drilldownData.flatMap(skill => skill.originalQueues.map(q => ({ ...q, skillName: skill.skill })) ); // v3.5: Clasificar por TIER const automateQueues = allQueues.filter(q => q.tier === 'AUTOMATE'); const assistQueues = allQueues.filter(q => q.tier === 'ASSIST'); const augmentQueues = allQueues.filter(q => q.tier === 'AUGMENT'); const humanQueues = allQueues.filter(q => q.tier === 'HUMAN-ONLY'); // Calculate metrics por tier const automateVolume = automateQueues.reduce((sum, q) => sum + q.volume, 0); const automateCost = automateQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0); const assistVolume = assistQueues.reduce((sum, q) => sum + q.volume, 0); const assistCost = assistQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0); const augmentVolume = augmentQueues.reduce((sum, q) => sum + q.volume, 0); const augmentCost = augmentQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0); // Helper para obtain top skills por volumen const getTopSkillNamonth = (queues: typeof allQueues, limit: number = 3): string[] => { const skillVolumonth = new Map(); queues.forEach(q => { skillVolumonth.set(q.skillName, (skillVolumonth.get(q.skillName) || 0) + q.volume); }); return Array.from(skillVolumonth.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, limit) .map(([name]) => name); }; // ============ PHASE 1: AUTOMATE (Tier AUTOMATE - 70% containment) ============ if (automateQueues.length > 0) { const topSkills = getTopSkillNamonth(automateQueues); const avgScore = automateQueues.reduce((sum, q) => sum + q.agenticScore, 0) / automateQueues.length; const avgCv = automateQueues.reduce((sum, q) => sum + q.cv_aht, 0) / automateQueues.length; // v3.5: Ahorro REALISTA con TCO const realisticSavings = calculateRealisticSavings(automateVolume, automateCost, 'AUTOMATE'); // Chatbot para queues with score muy high (>8) const highScoreQueues = automateQueues.filter(q => q.agenticScore >= 8); if (highScoreQueues.length > 0) { const hsVolume = highScoreQueues.reduce((sum, q) => sum + q.volume, 0); const hsCost = highScoreQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0); const hsSavings = calculateRealisticSavings(hsVolume, hsCost, 'AUTOMATE'); initiatives.push({ id: `init-${initCounter++}`, name: `Chatbot IA para ${highScoreQueues.length} queues score β‰₯8`, phase: RoadmapPhase.Automate, timeline: 'Q1 2026', investment: Math.round(hsSavings * 0.3), // Investment = 30% of savings resources: ['1x Bot Developer', 'API Integration', 'QA Team'], dimensionId: 'agentic_readiness', risk: 'low', skillsImpacted: getTopSkillNamonth(highScoreQueues, 2), volumeImpacted: hsVolume, kpiObjective: `Contain 70% of volume via chatbot`, rationale: `${highScoreQueues.length} queues tier AUTOMATE con score average ${avgScore.toFixed(1)}/10. Optimal metrics for complete automation.`, savingsDetail: `70% containment Γ— (CPI human - CPI AI) = ${hsSavings.toLocaleString()}€/year`, estimatedSavings: hsSavings, resourceHours: 400 }); } // IVR para resto de queues AUTOMATE const otherAutomateQueues = automateQueues.filter(q => q.agenticScore < 8); if (otherAutomateQueues.length > 0) { const oaVolume = otherAutomateQueues.reduce((sum, q) => sum + q.volume, 0); const oaCost = otherAutomateQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0); const oaSavings = calculateRealisticSavings(oaVolume, oaCost, 'AUTOMATE'); initiatives.push({ id: `init-${initCounter++}`, name: `IVR inteligente para ${otherAutomateQueues.length} queues AUTOMATE`, phase: RoadmapPhase.Automate, timeline: 'Q2 2026', investment: Math.round(oaSavings * 0.25), resources: ['1x Voice UX Designer', 'Integration Team', 'QA'], dimensionId: 'agentic_readiness', risk: 'low', skillsImpacted: getTopSkillNamonth(otherAutomateQueues, 2), volumeImpacted: oaVolume, kpiObjective: `Pre-calificar y desviar 70% a self-service`, rationale: `${otherAutomateQueues.length} queues tier AUTOMATE listas para IVR con NLU.`, savingsDetail: `70% containment Γ— CPI differential = ${oaSavings.toLocaleString()}€/year`, estimatedSavings: oaSavings, resourceHours: 320 }); } } // ============ PHASE 2: ASSIST (Tier ASSIST - 30% efficiency) ============ if (assistQueues.length > 0) { const topSkills = getTopSkillNamonth(assistQueues); const avgScore = assistQueues.reduce((sum, q) => sum + q.agenticScore, 0) / assistQueues.length; // v3.5: Ahorro REALISTA const realisticSavings = calculateRealisticSavings(assistVolume, assistCost, 'ASSIST'); // Knowledge Base con IA initiatives.push({ id: `init-${initCounter++}`, name: `Knowledge Base IA para ${assistQueues.length} queues ASSIST`, phase: RoadmapPhase.Assist, timeline: 'Q2 2026', investment: Math.round(realisticSavings * 0.4), resources: ['1x PM', 'Content Team', 'AI Developer'], dimensionId: 'effectiveness_resolution', risk: 'low', skillsImpacted: topSkills, volumeImpacted: assistVolume, kpiObjective: `Reduce AHT 30% with AI suggestions`, rationale: `${assistQueues.length} queues tier ASSIST (score ${avgScore.toFixed(1)}/10) se benefician de copilot contextual.`, savingsDetail: `30% efficiency Γ— CPI differential = ${realisticSavings.toLocaleString()}€/year`, estimatedSavings: realisticSavings, resourceHours: 360 }); // Copilot para agents si hay volumen high if (assistVolume > 50000) { const copilotSavings = Math.round(realisticSavings * 0.6); initiatives.push({ id: `init-${initCounter++}`, name: `Copilot IA para agents (${topSkills.slice(0, 2).join(', ')})`, phase: RoadmapPhase.Assist, timeline: 'Q3 2026', investment: Math.round(copilotSavings * 0.5), resources: ['2x AI Developers', 'QA Team', 'Training'], dimensionId: 'effectiveness_resolution', risk: 'medium', skillsImpacted: topSkills.slice(0, 3), volumeImpacted: assistVolume, kpiObjective: `Reduce variability and migrate queues to tier AUTOMATE`, rationale: `Copilot pre-fills fields, suggests answers and guides agent to standardize.`, savingsDetail: `Mejora efficiency 30% en ${assistVolume.toLocaleString()} int/month`, estimatedSavings: copilotSavings, resourceHours: 520 }); } } // ============ PHASE 3: AUGMENT (Tier AUGMENT + HUMAN-ONLY - 15%) ============ const optimizeQueues = [...augmentQueues, ...humanQueues]; const optimizeVolume = optimizeQueues.reduce((sum, q) => sum + q.volume, 0); const optimizeCost = optimizeQueues.reduce((sum, q) => sum + (q.annualCost || 0), 0); if (optimizeQueues.length > 0) { const topSkills = getTopSkillNamonth(optimizeQueues); const avgScore = optimizeQueues.reduce((sum, q) => sum + q.agenticScore, 0) / optimizeQueues.length; // v3.5: Ahorro REALISTA (muy conservative para AUGMENT) const realisticSavings = calculateRealisticSavings(optimizeVolume, optimizeCost, 'AUGMENT'); // Process Standardization initiatives.push({ id: `init-${initCounter++}`, name: `Standardization (${optimizeQueues.length} variable queues)`, phase: RoadmapPhase.Augment, timeline: 'Q3 2026', investment: Math.round(realisticSavings * 0.8), resources: ['Process Analyst', 'Training Team', 'QA'], dimensionId: 'complexity_predictability', risk: 'medium', skillsImpacted: topSkills, volumeImpacted: optimizeVolume, kpiObjective: `Reduce CV to migrate queues to tier ASSIST/AUTOMATE`, rationale: `${optimizeQueues.length} queues tier AUGMENT/HUMAN (score ${avgScore.toFixed(1)}/10) require process redesign.`, savingsDetail: `15% optimization = ${realisticSavings.toLocaleString()}€/year (conservative)`, estimatedSavings: realisticSavings, resourceHours: 400 }); // Post-standardization Automation (futuro) if (optimizeVolume > 30000) { const futureSavings = calculateRealisticSavings(Math.round(optimizeVolume * 0.4), Math.round(optimizeCost * 0.4), 'ASSIST'); initiatives.push({ id: `init-${initCounter++}`, name: `Post-standardization Automation`, phase: RoadmapPhase.Augment, timeline: 'Q1 2027', investment: Math.round(futureSavings * 0.5), resources: ['Lead AI Engineer', 'Process Team', 'QA'], dimensionId: 'agentic_readiness', risk: 'medium', skillsImpacted: topSkills.slice(0, 2), volumeImpacted: Math.round(optimizeVolume * 0.4), kpiObjective: `Automate 40% of volume after standardization`, rationale: `Once CV is reduced, queues will be suitable for automation.`, savingsDetail: `Future potential: ${futureSavings.toLocaleString()}€/year`, estimatedSavings: futureSavings, resourceHours: 480 }); } } return initiatives; } /** * Use generateOpportunitiesFromDrilldown instead * Generate opportunities from real data */ function generateOpportunitiesFromRealData(metrics: SkillMetrics[], costPerHour: number): Opportunity[] { // Find the maximum savings to calculate relative impact const maxSavings = Math.max(...metrics.map(m => m.total_cost * 0.4), 1); return metrics.slice(0, 10).map((m, index) => { const potentialSavings = m.total_cost * 0.4; // 40% of potential savings // Impact: relative to maximum savings (scale 1-10) const impactRaw = (potentialSavings / maxSavings) * 10; const impact = Math.max(3, Math.min(10, Math.round(impactRaw))); // Feasibility: based on CV and transfer_rate (low variability = high feasibility) const feasibilityRaw = 10 - (m.cv_aht * 5) - (m.transfer_rate / 10); const feasibility = Math.max(3, Math.min(10, Math.round(feasibilityRaw))); // Determine dimension according to characteristics let dimensionId: string; if (m.cv_aht < 0.3 && m.transfer_rate < 15) { dimensionId = 'agentic_readiness'; // Ready to automate } else if (m.cv_aht < 0.5) { dimensionId = 'effectiveness_resolution'; // Can improve with assistance } else { dimensionId = 'complexity_predictability'; // Needs optimization } // Descriptive name const prefix = m.cv_aht < 0.3 && m.transfer_rate < 15 ? 'Automate ' : m.cv_aht < 0.5 ? 'Assist with AI in ' : 'Optimize process in '; return { id: `opp-${index + 1}`, name: `${prefix}${m.skill}`, impact, feasibility, savings: Math.round(potentialSavings), dimensionId, customer_segment: 'medium' as CustomerSegment }; }); } /** * Generate roadmap from opportunities and skill metrics * v3.0: Initiatives connected to real skills with volumeImpacted, kpiObjective, rationale */ function generateRoadmapFromRealData(opportunities: Opportunity[], metrics?: SkillMetrics[]): RoadmapInitiative[] { // Sort by descending savings to prioritize const sortedOpps = [...opportunities].sort((a, b) => (b.savings || 0) - (a.savings || 0)); // Create map of metrics per skill for quick lookup const metricsMap = new Map(); if (metrics) { for (const m of metrics) { metricsMap.set(m.skill.toLowerCase(), m); } } // Helper para obtain metrics de un skill const getSkillMetrics = (skillName: string): SkillMetrics | undefined => { return metricsMap.get(skillName.toLowerCase()) || Array.from(metricsMap.values()).find(m => m.skill.toLowerCase().includes(skillName.toLowerCase()) || skillName.toLowerCase().includes(m.skill.toLowerCase()) ); }; const initiatives: RoadmapInitiative[] = []; let initCounter = 1; // WAVE 1: Automate - Skills with high automation potential const wave1Opps = sortedOpps.slice(0, 2); for (const opp of wave1Opps) { const skillName = opp.name?.replace(/^(Automate |Assist with AI in |Optimize process in )/, '') || `Skill ${initCounter}`; const savings = opp.savings || 0; const skillMetrics = getSkillMetrics(skillName); const volume = skillMetrics?.volume || Math.round(savings / 5); const cvAht = skillMetrics?.cv_aht || 50; const offHoursPct = skillMetrics?.off_hours_pct || 28; // Determine initiative type based on skill characteristics const isHighVolume = volume > 100000; const hasOffHoursOpportunity = offHoursPct > 25; initiatives.push({ id: `init-${initCounter}`, name: hasOffHoursOpportunity ? `Chatbot consultas ${skillName} (24/7)` : `IVR inteligente ${skillName}`, phase: RoadmapPhase.Automate, timeline: 'Q1 2026', investment: Math.round(savings * 0.3), resources: hasOffHoursOpportunity ? ['1x Bot Developer', 'API Integration', 'QA Team'] : ['1x Voice UX Designer', 'Integration Team'], dimensionId: 'agentic_readiness', risk: 'low', skillsImpacted: [skillName], volumeImpacted: volume, kpiObjective: hasOffHoursOpportunity ? `Automate ${Math.round(offHoursPct)}% consultas off hours` : `Desviar 25% a self-service para gestiones simples`, rationale: hasOffHoursOpportunity ? `${Math.round(offHoursPct)}% del volumen ocurre off hours. Chatbot puede resolver consultas de status without agent.` : `CV AHT ${Math.round(cvAht)}% indica process variables. IVR puede pre-cualificar y resolver casos simples.`, savingsDetail: `Automation ${Math.round(offHoursPct)}% off hours volume`, estimatedSavings: savings, resourceHours: 440 }); initCounter++; } // WAVE 2: Assist - Knowledge Base + Copilot const wave2Opps = sortedOpps.slice(2, 4); // Iniciativa 1: Knowledge Base (agrupa varios skills) if (wave2Opps.length > 0) { const kbSkills = wave2Opps.map(o => o.name?.replace(/^(Automate |Assist with AI in |Optimize process in )/, '') || ''); const kbSavings = wave2Opps.reduce((sum, o) => sum + (o.savings || 0), 0) * 0.4; const kbVolume = wave2Opps.reduce((sum, o) => { const m = getSkillMetrics(o.name || ''); return sum + (m?.volume || 10000); }, 0); initiatives.push({ id: `init-${initCounter}`, name: 'Dynamic Knowledge Base with AI', phase: RoadmapPhase.Assist, timeline: 'Q2 2026', investment: Math.round(kbSavings * 0.25), resources: ['1x PM', 'Content Team', 'AI Developer'], dimensionId: 'effectiveness_resolution', risk: 'low', skillsImpacted: kbSkills.filter(s => s), volumeImpacted: kbVolume, kpiObjective: 'Reduce Hold Time 30% through real-time suggestions', rationale: 'FCR low indicates that agents do not find information quickly. KB with AI suggests contextual responses.', savingsDetail: `Hold Time Reduction 30% en ${kbSkills.length} skills`, estimatedSavings: Math.round(kbSavings), resourceHours: 400 }); initCounter++; } // Iniciativa 2: Copilot para skill principal if (wave2Opps.length > 0) { const mainOpp = wave2Opps[0]; const skillName = mainOpp.name?.replace(/^(Automate |Assist with AI in |Optimize process in )/, '') || 'Principal'; const savings = mainOpp.savings || 0; const skillMetrics = getSkillMetrics(skillName); const volume = skillMetrics?.volume || Math.round(savings / 5); const cvAht = skillMetrics?.cv_aht || 100; initiatives.push({ id: `init-${initCounter}`, name: `Copilot para ${skillName}`, phase: RoadmapPhase.Assist, timeline: 'Q3 2026', investment: Math.round(savings * 0.35), resources: ['2x AI Developers', 'QA Team', 'Training'], dimensionId: 'effectiveness_resolution', risk: 'medium', skillsImpacted: [skillName], volumeImpacted: volume, kpiObjective: `Reduce AHT 15% and CV AHT from ${Math.round(cvAht)}% a <80%`, rationale: `Skill con high volumen y variabilidad. Copilot puede pre-llenar formularios, sugerir respuestas y guiar al agent.`, savingsDetail: `AHT Reduction 15% + FCR improvement 10%`, estimatedSavings: savings, resourceHours: 600 }); initCounter++; } // WAVE 3: Augment - Standardization and extended coverage const wave3Opps = sortedOpps.slice(4, 6); // Iniciativa 1: Standardization (skill con mayor CV) if (wave3Opps.length > 0) { const highCvOpp = wave3Opps.reduce((max, o) => { const m = getSkillMetrics(o.name || ''); const maxM = getSkillMetrics(max.name || ''); return (m?.cv_aht || 0) > (maxM?.cv_aht || 0) ? o : max; }, wave3Opps[0]); const skillName = highCvOpp.name?.replace(/^(Automate |Assist with AI in |Optimize process in )/, '') || 'Variable'; const savings = highCvOpp.savings || 0; const skillMetrics = getSkillMetrics(skillName); const volume = skillMetrics?.volume || Math.round(savings / 5); const cvAht = skillMetrics?.cv_aht || 150; initiatives.push({ id: `init-${initCounter}`, name: `Process Standardization ${skillName}`, phase: RoadmapPhase.Augment, timeline: 'Q4 2026', investment: Math.round(savings * 0.4), resources: ['Process Analyst', 'Training Team', 'QA'], dimensionId: 'complexity_predictability', risk: 'medium', skillsImpacted: [skillName], volumeImpacted: volume, kpiObjective: `Reduce CV AHT from ${Math.round(cvAht)}% a <100%`, rationale: `CV AHT ${Math.round(cvAht)}% indicates non-standardized processes. Requires redesign and documentation before automation.`, savingsDetail: `Standardization reduces variability and enables future automation`, estimatedSavings: savings, resourceHours: 440 }); initCounter++; } // Iniciativa 2: Cobertura nocturna (si hay off hours volume) const totalOffHoursVolume = metrics?.reduce((sum, m) => sum + (m.volume * (m.off_hours_pct || 0) / 100), 0) || 0; if (totalOffHoursVolume > 10000 && wave3Opps.length > 1) { const offHoursSkills = metrics?.filter(m => (m.off_hours_pct || 0) > 20).map(m => m.skill).slice(0, 3) || []; const offHoursSavings = totalOffHoursVolume * 5 * 0.6; // CPI €5, 60% automatizable initiatives.push({ id: `init-${initCounter}`, name: 'Cobertura nocturna con agents virtuales', phase: RoadmapPhase.Augment, timeline: 'Q1 2027', investment: Math.round(offHoursSavings * 0.5), resources: ['Lead AI Engineer', 'Data Scientist', 'QA Team'], dimensionId: 'agentic_readiness', risk: 'high', skillsImpacted: offHoursSkills.length > 0 ? offHoursSkills : ['Customer Service', 'Support'], volumeImpacted: Math.round(totalOffHoursVolume), kpiObjective: '24/7 Coverage with 60% automatic overnight resolution', rationale: `${Math.round(totalOffHoursVolume).toLocaleString()} interactions off hours. Agente virtual puede resolver consultas y programar callbacks.`, savingsDetail: `Cobertura 24/7 without incremento plantilla nocturna`, estimatedSavings: Math.round(offHoursSavings), resourceHours: 600 }); } return initiatives; } /** * v3.10: Generar economic model from datos reales * ALINEADO CON ROADMAP: Usa modelo TCO con CPI por tier * - AUTOMATE: 70% Γ— (€2.33 - €0.15) = €1.526/interaction * - ASSIST: 30% Γ— (€2.33 - €1.50) = €0.249/interaction * - AUGMENT: 15% Γ— (€2.33 - €2.00) = €0.050/interaction */ function generateEconomicModelFromRealData( metrics: SkillMetrics[], costPerHour: number, roadmap?: RoadmapInitiative[], drilldownData?: DrilldownDataPoint[] ): EconomicModelData { const totalCost = metrics.reduce((sum, m) => sum + m.total_cost, 0); // v3.10: Calcular ahorro usando modelo TCO alineado con Roadmap const CPI_HUMANO = 2.33; const CPI_BOT = 0.15; const CPI_ASSIST = 1.50; const CPI_AUGMENT = 2.00; // Containment/deflection rates by tier const RATE_AUTOMATE = 0.70; const RATE_ASSIST = 0.30; const RATE_AUGMENT = 0.15; let annualSavingsTCO = 0; let volumeByTier = { AUTOMATE: 0, ASSIST: 0, AUGMENT: 0, 'HUMAN-ONLY': 0 }; // Si tenemos drilldownData, calculate ahorro por tier real if (drilldownData && drilldownData.length > 0) { drilldownData.forEach(skill => { skill.originalQueues.forEach(queue => { volumeByTier[queue.tier] += queue.volume; }); }); // Annual savings = Volume Γ— 12 months Γ— Rate Γ— CPI Differential const savingsAUTOMATE = volumeByTier.AUTOMATE * 12 * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT); const savingsASSIST = volumeByTier.ASSIST * 12 * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST); const savingsAUGMENT = volumeByTier.AUGMENT * 12 * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT); annualSavingsTCO = Math.round(savingsAUTOMATE + savingsASSIST + savingsAUGMENT); } else { // Fallback: estimar 35% del total cost (legacy) annualSavingsTCO = Math.round(totalCost * 0.35); } // Initial investment: from aligned Roadmap // Wave 1: €47K, Wave 2: €35K, Wave 3: €70K, Wave 4: €85K = €237K total let initialInvestment: number; if (roadmap && roadmap.length > 0) { initialInvestment = roadmap.reduce((sum, init) => sum + (init.investment || 0), 0); } else { // Default: Escenario conservative Wave 1-2 initialInvestment = 82000; // €47K + €35K } // Costes recurrentes annuales (alineado con Roadmap) // Wave 2: €40K, Wave 3: €78K, Wave 4: €108K const recurrentCostAnnual = drilldownData && drilldownData.length > 0 ? Math.round(initialInvestment * 0.5) // 50% of investment as recurring : Math.round(initialInvestment * 0.15); // Margen neto annual (ahorro - recurrente) const netAnnualSavings = annualSavingsTCO - recurrentCostAnnual; // Payback: Implementation + Recovery (aligned with Roadmap v3.9) const monthsImplementacion = 9; // Wave 1 (6m) + mitad Wave 2 (3m/2) const margenMensual = netAnnualSavings / 12; const monthsRecuperacion = margenMensual > 0 ? Math.ceil(initialInvestment / margenMensual) : -1; const paybackMonths = margenMensual > 0 ? monthsImplementacion + monthsRecuperacion : -1; // ROI 3 years: ((SavingsΓ—3) - (Investment + RecurringΓ—3)) / (Investment + RecurringΓ—3) Γ— 100 const costeTotalTresAnos = initialInvestment + (recurrentCostAnnual * 3); const ahorroTotalTresAnos = annualSavingsTCO * 3; const roi3yr = costeTotalTresAnos > 0 ? ((ahorroTotalTresAnos - costeTotalTresAnos) / costeTotalTresAnos) * 100 : 0; // NPV con tasa de descuento 10% const discountRate = 0.10; const npv = -initialInvestment + (netAnnualSavings / (1 + discountRate)) + (netAnnualSavings / Math.pow(1 + discountRate, 2)) + (netAnnualSavings / Math.pow(1 + discountRate, 3)); // Desglose de ahorro por tier (alineado con TCO) const savingsBreakdown: { category: string; amount: number; percentage: number }[] = []; if (drilldownData && drilldownData.length > 0) { const savingsAUTOMATE = Math.round(volumeByTier.AUTOMATE * 12 * RATE_AUTOMATE * (CPI_HUMANO - CPI_BOT)); const savingsASSIST = Math.round(volumeByTier.ASSIST * 12 * RATE_ASSIST * (CPI_HUMANO - CPI_ASSIST)); const savingsAUGMENT = Math.round(volumeByTier.AUGMENT * 12 * RATE_AUGMENT * (CPI_HUMANO - CPI_AUGMENT)); const totalSav = savingsAUTOMATE + savingsASSIST + savingsAUGMENT || 1; if (savingsAUTOMATE > 0) { savingsBreakdown.push({ category: `AUTOMATE (${volumeByTier.AUTOMATE.toLocaleString()} int/month)`, amount: savingsAUTOMATE, percentage: Math.round((savingsAUTOMATE / totalSav) * 100) }); } if (savingsASSIST > 0) { savingsBreakdown.push({ category: `ASSIST (${volumeByTier.ASSIST.toLocaleString()} int/month)`, amount: savingsASSIST, percentage: Math.round((savingsASSIST / totalSav) * 100) }); } if (savingsAUGMENT > 0) { savingsBreakdown.push({ category: `AUGMENT (${volumeByTier.AUGMENT.toLocaleString()} int/month)`, amount: savingsAUGMENT, percentage: Math.round((savingsAUGMENT / totalSav) * 100) }); } } else { // Fallback legacy const topSkills = metrics.slice(0, 4); topSkills.forEach((skill, idx) => { const skillSavings = Math.round(skill.total_cost * 0.4); savingsBreakdown.push({ category: `AHT Reduction 15% ${skill.skill}`, amount: skillSavings, percentage: Math.round((skillSavings / (annualSavingsTCO || 1)) * 100) }); }); } const costBreakdown = [ { category: 'Software y licencias', amount: Math.round(initialInvestment * 0.40), percentage: 40 }, { category: 'Development and implementation', amount: Math.round(initialInvestment * 0.30), percentage: 30 }, { category: 'Training y change mgmt', amount: Math.round(initialInvestment * 0.20), percentage: 20 }, { category: 'Contingencia', amount: Math.round(initialInvestment * 0.10), percentage: 10 }, ]; return { currentAnnualCost: Math.round(totalCost), futureAnnualCost: Math.round(totalCost - netAnnualSavings), annualSavings: annualSavingsTCO, // Ahorro bruto TCO (para comparar con Roadmap) initialInvestment, paybackMonths: paybackMonths > 0 ? paybackMonths : 0, roi3yr: parseFloat(roi3yr.toFixed(1)), npv: Math.round(npv), savingsBreakdown, costBreakdown }; } /** * Generar benchmark from datos reales * AIRLINE SECTOR BENCHMARKS: AHT P50=380s, FCR=70%, Abandono=5%, Ratio P90/P50<2.0 */ function generateBenchmarkFromRealData(metrics: SkillMetrics[]): BenchmarkDataPoint[] { const avgAHT = metrics.reduce((sum, m) => sum + m.aht_mean, 0) / (metrics.length || 1); const avgCV = metrics.reduce((sum, m) => sum + m.cv_aht, 0) / (metrics.length || 1); const avgRatio = 1 + avgCV * 1.5; // Ratio P90/P50 aproximado // Technical FCR: 100 - transfer_rate (weighted by volume) const totalVolume = metrics.reduce((sum, m) => sum + m.volume_valid, 0); const avgFCR = totalVolume > 0 ? metrics.reduce((sum, m) => sum + (m.fcr_tecnico * m.volume_valid), 0) / totalVolume : 0; // Abandono real const totalInteractions = metrics.reduce((sum, m) => sum + m.volume, 0); const totalAbandoned = metrics.reduce((sum, m) => sum + m.abandon_count, 0); const abandonRate = totalInteractions > 0 ? (totalAbandoned / totalInteractions) * 100 : 0; // CPI: Coste total / Total interactions const totalCost = metrics.reduce((sum, m) => sum + m.total_cost, 0); const avgCPI = totalInteractions > 0 ? totalCost / totalInteractions : 3.5; // Calculate percentiles based on benchmarks airline sector const ahtPercentile = avgAHT <= 380 ? 75 : avgAHT <= 420 ? 60 : avgAHT <= 480 ? 40 : 25; const fcrPercentile = avgFCR >= 70 ? 70 : avgFCR >= 60 ? 50 : avgFCR >= 50 ? 35 : 20; const abandonPercentile = abandonRate <= 5 ? 75 : abandonRate <= 8 ? 55 : abandonRate <= 12 ? 35 : 20; const ratioPercentile = avgRatio <= 2.0 ? 75 : avgRatio <= 2.5 ? 50 : avgRatio <= 3.0 ? 30 : 15; return [ { kpi: 'AHT P50', userValue: Math.round(avgAHT), userDisplay: `${Math.round(avgAHT)}s`, industryValue: 380, industryDisplay: '380s', percentile: ahtPercentile, p25: 320, p50: 380, p75: 450, p90: 520 }, { kpi: 'FCR', userValue: avgFCR, userDisplay: `${Math.round(avgFCR)}%`, industryValue: 70, industryDisplay: '70%', percentile: fcrPercentile, p25: 55, p50: 70, p75: 80, p90: 88 }, { kpi: 'Abandono', userValue: abandonRate, userDisplay: `${abandonRate.toFixed(1)}%`, industryValue: 5, industryDisplay: '5%', percentile: abandonPercentile, p25: 8, p50: 5, p75: 3, p90: 2 }, { kpi: 'Ratio P90/P50', userValue: avgRatio, userDisplay: avgRatio.toFixed(2), industryValue: 2.0, industryDisplay: '<2.0', percentile: ratioPercentile, p25: 2.5, p50: 2.0, p75: 1.7, p90: 1.4 }, { kpi: 'Cost/Interaction', userValue: avgCPI, userDisplay: `€${avgCPI.toFixed(2)}`, industryValue: 3.5, industryDisplay: '€3.50', percentile: avgCPI <= 3.5 ? 65 : avgCPI <= 4.5 ? 45 : 25, p25: 4.5, p50: 3.5, p75: 2.8, p90: 2.2 } ]; }