Compare commits
21 Commits
33d25871ae
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 675573a472 | |||
| 148c86563b | |||
| d3af112bdf | |||
| 4b0f93540f | |||
| 3caea45730 | |||
| 88a7edba5c | |||
| 5b98143117 | |||
| b488c1bff6 | |||
| 3ed079542a | |||
| 2f6ad3b52c | |||
|
|
152b5c0628 | ||
|
|
eb804d7fb0 | ||
|
|
c9f6db9882 | ||
|
|
a48aca0a26 | ||
|
|
20e9d213bb | ||
|
|
c5c88f6f21 | ||
|
|
cbea968776 | ||
|
|
820e8b4887 | ||
|
|
728ba5772e | ||
|
|
5df79d436f | ||
|
|
0063d299c9 |
@@ -12,6 +12,9 @@ security = HTTPBasic(auto_error=False)
|
|||||||
BASIC_USER = os.getenv("BASIC_AUTH_USERNAME", "beyond")
|
BASIC_USER = os.getenv("BASIC_AUTH_USERNAME", "beyond")
|
||||||
BASIC_PASS = os.getenv("BASIC_AUTH_PASSWORD", "beyond2026")
|
BASIC_PASS = os.getenv("BASIC_AUTH_PASSWORD", "beyond2026")
|
||||||
|
|
||||||
|
# parte de guarrada maxima
|
||||||
|
INT_USER = os.getenv("INT_AUTH_USERNAME", "beyond")
|
||||||
|
INT_PASS = os.getenv("INT_AUTH_PASSWORD", "beyond2026")
|
||||||
|
|
||||||
def get_current_user(credentials: HTTPBasicCredentials | None = Depends(security)) -> str:
|
def get_current_user(credentials: HTTPBasicCredentials | None = Depends(security)) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -29,9 +32,13 @@ def get_current_user(credentials: HTTPBasicCredentials | None = Depends(security
|
|||||||
correct_password = secrets.compare_digest(credentials.password, BASIC_PASS)
|
correct_password = secrets.compare_digest(credentials.password, BASIC_PASS)
|
||||||
|
|
||||||
if not (correct_username and correct_password):
|
if not (correct_username and correct_password):
|
||||||
raise HTTPException(
|
# Guarrada maxima, yo no he sido
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
correct_username = secrets.compare_digest(credentials.username, INT_USER)
|
||||||
detail="Credenciales incorrectas",
|
correct_password = secrets.compare_digest(credentials.password, INT_PASS)
|
||||||
)
|
if not (correct_username and correct_password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Credenciales incorrectas",
|
||||||
|
)
|
||||||
|
|
||||||
return credentials.username
|
return credentials.username
|
||||||
|
|||||||
@@ -61,24 +61,25 @@ function generateCausalAnalysis(
|
|||||||
annualizationFactor = 365 / daysCovered;
|
annualizationFactor = 365 / daysCovered;
|
||||||
}
|
}
|
||||||
|
|
||||||
// v3.11: CPI consistente con Executive Summary
|
// v3.11: CPI consistente con Executive Summary - benchmark aerolíneas p50
|
||||||
const CPI_TCO = 2.33;
|
const CPI_TCO = 3.50; // Benchmark aerolíneas (p50) para cálculos de impacto
|
||||||
// Usar CPI pre-calculado de heatmapData si existe, sino calcular desde annual_cost/cost_volume
|
// Usar CPI pre-calculado de heatmapData si existe, sino calcular desde annual_cost/cost_volume
|
||||||
|
// IMPORTANTE: Mismo cálculo que ExecutiveSummaryTab para consistencia
|
||||||
const totalCostVolume = heatmapData.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0);
|
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 totalAnnualCost = heatmapData.reduce((sum, h) => sum + (h.annual_cost || 0), 0);
|
||||||
const hasCpiField = heatmapData.some(h => h.cpi !== undefined && h.cpi > 0);
|
const hasCpiField = heatmapData.some(h => h.cpi !== undefined && h.cpi > 0);
|
||||||
const CPI = hasCpiField
|
const CPI = hasCpiField
|
||||||
? (totalCostVolume > 0
|
? (totalCostVolume > 0
|
||||||
? heatmapData.reduce((sum, h) => sum + (h.cpi || 0) * (h.cost_volume || h.volume), 0) / totalCostVolume
|
? heatmapData.reduce((sum, h) => sum + (h.cpi || 0) * (h.cost_volume || h.volume), 0) / totalCostVolume
|
||||||
: CPI_TCO)
|
: 0)
|
||||||
: (totalCostVolume > 0 ? totalAnnualCost / totalCostVolume : CPI_TCO);
|
: (totalCostVolume > 0 ? totalAnnualCost / totalCostVolume : 0);
|
||||||
|
|
||||||
// Calcular métricas agregadas
|
// Calcular métricas agregadas
|
||||||
const avgCVAHT = totalVolume > 0
|
const avgCVAHT = totalVolume > 0
|
||||||
? heatmapData.reduce((sum, h) => sum + (h.variability?.cv_aht || 0) * h.volume, 0) / totalVolume
|
? heatmapData.reduce((sum, h) => sum + (h.variability?.cv_aht || 0) * h.volume, 0) / totalVolume
|
||||||
: 0;
|
: 0;
|
||||||
const avgTransferRate = totalVolume > 0
|
const avgTransferRate = totalVolume > 0
|
||||||
? heatmapData.reduce((sum, h) => sum + (h.variability?.transfer_rate || 0) * h.volume, 0) / totalVolume
|
? heatmapData.reduce((sum, h) => sum + h.metrics.transfer_rate * h.volume, 0) / totalVolume
|
||||||
: 0;
|
: 0;
|
||||||
// Usar FCR Técnico (100 - transfer_rate) en lugar de FCR Real (con filtro recontacto 7d)
|
// Usar FCR Técnico (100 - transfer_rate) en lugar de FCR Real (con filtro recontacto 7d)
|
||||||
// FCR Técnico es más comparable con benchmarks de industria
|
// FCR Técnico es más comparable con benchmarks de industria
|
||||||
@@ -99,7 +100,7 @@ function generateCausalAnalysis(
|
|||||||
const skillsHighCV = heatmapData.filter(h => (h.variability?.cv_aht || 0) > 100);
|
const skillsHighCV = heatmapData.filter(h => (h.variability?.cv_aht || 0) > 100);
|
||||||
// Usar FCR Técnico para identificar skills con bajo FCR
|
// Usar FCR Técnico para identificar skills con bajo FCR
|
||||||
const skillsLowFCR = heatmapData.filter(h => (h.metrics.fcr_tecnico ?? (100 - h.metrics.transfer_rate)) < 50);
|
const skillsLowFCR = heatmapData.filter(h => (h.metrics.fcr_tecnico ?? (100 - h.metrics.transfer_rate)) < 50);
|
||||||
const skillsHighTransfer = heatmapData.filter(h => (h.variability?.transfer_rate || 0) > 20);
|
const skillsHighTransfer = heatmapData.filter(h => h.metrics.transfer_rate > 20);
|
||||||
|
|
||||||
// Parsear P50 AHT del KPI del header para consistencia visual
|
// Parsear P50 AHT del KPI del header para consistencia visual
|
||||||
// El KPI puede ser "345s (P50)" o similar
|
// El KPI puede ser "345s (P50)" o similar
|
||||||
@@ -310,6 +311,7 @@ function generateCausalAnalysis(
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'economy_cpi':
|
case 'economy_cpi':
|
||||||
|
case 'economy_costs': // También manejar el ID del backend
|
||||||
// Análisis de CPI
|
// Análisis de CPI
|
||||||
if (CPI > 3.5) {
|
if (CPI > 3.5) {
|
||||||
const excessCPI = CPI - CPI_TCO;
|
const excessCPI = CPI - CPI_TCO;
|
||||||
@@ -572,6 +574,29 @@ function DimensionCard({
|
|||||||
// ========== v3.16: COMPONENTE PRINCIPAL ==========
|
// ========== v3.16: COMPONENTE PRINCIPAL ==========
|
||||||
|
|
||||||
export function DimensionAnalysisTab({ data }: DimensionAnalysisTabProps) {
|
export function DimensionAnalysisTab({ data }: DimensionAnalysisTabProps) {
|
||||||
|
// DEBUG: Verificar CPI en dimensión vs heatmapData
|
||||||
|
const economyDim = data.dimensions.find(d =>
|
||||||
|
d.id === 'economy_costs' || d.name === 'economy_costs' ||
|
||||||
|
d.id === 'economy_cpi' || d.name === 'economy_cpi'
|
||||||
|
);
|
||||||
|
const heatmapData = data.heatmapData;
|
||||||
|
const totalCostVolume = heatmapData.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0);
|
||||||
|
const hasCpiField = heatmapData.some(h => h.cpi !== undefined && h.cpi > 0);
|
||||||
|
const calculatedCPI = hasCpiField
|
||||||
|
? (totalCostVolume > 0
|
||||||
|
? heatmapData.reduce((sum, h) => sum + (h.cpi || 0) * (h.cost_volume || h.volume), 0) / totalCostVolume
|
||||||
|
: 0)
|
||||||
|
: (totalCostVolume > 0
|
||||||
|
? heatmapData.reduce((sum, h) => sum + (h.annual_cost || 0), 0) / totalCostVolume
|
||||||
|
: 0);
|
||||||
|
|
||||||
|
console.log('🔍 DimensionAnalysisTab DEBUG:');
|
||||||
|
console.log(' - economyDim found:', !!economyDim, economyDim?.id || economyDim?.name);
|
||||||
|
console.log(' - economyDim.kpi.value:', economyDim?.kpi?.value);
|
||||||
|
console.log(' - calculatedCPI from heatmapData:', `€${calculatedCPI.toFixed(2)}`);
|
||||||
|
console.log(' - hasCpiField:', hasCpiField);
|
||||||
|
console.log(' - MATCH:', economyDim?.kpi?.value === `€${calculatedCPI.toFixed(2)}`);
|
||||||
|
|
||||||
// Filter out agentic_readiness (has its own tab)
|
// Filter out agentic_readiness (has its own tab)
|
||||||
const coreDimensions = data.dimensions.filter(d => d.name !== 'agentic_readiness');
|
const coreDimensions = data.dimensions.filter(d => d.name !== 'agentic_readiness');
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ const BENCHMARKS_INDUSTRIA: Record<IndustryKey, IndustryBenchmarks> = {
|
|||||||
metricas: {
|
metricas: {
|
||||||
aht: { p25: 320, p50: 380, p75: 450, p90: 520, unidad: 's', invertida: true },
|
aht: { p25: 320, p50: 380, p75: 450, p90: 520, unidad: 's', invertida: true },
|
||||||
fcr: { p25: 55, p50: 68, p75: 78, p90: 85, unidad: '%', invertida: false },
|
fcr: { p25: 55, p50: 68, p75: 78, p90: 85, unidad: '%', invertida: false },
|
||||||
abandono: { p25: 8, p50: 5, p75: 3, p90: 2, unidad: '%', invertida: true },
|
abandono: { p25: 2, p50: 5, p75: 8, p90: 12, unidad: '%', invertida: true },
|
||||||
cpi: { p25: 4.50, p50: 3.50, p75: 2.80, p90: 2.20, unidad: '€', invertida: true }
|
cpi: { p25: 2.20, p50: 3.50, p75: 4.50, p90: 5.50, unidad: '€', invertida: true }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
telecomunicaciones: {
|
telecomunicaciones: {
|
||||||
@@ -67,8 +67,8 @@ const BENCHMARKS_INDUSTRIA: Record<IndustryKey, IndustryBenchmarks> = {
|
|||||||
metricas: {
|
metricas: {
|
||||||
aht: { p25: 380, p50: 420, p75: 500, p90: 600, unidad: 's', invertida: true },
|
aht: { p25: 380, p50: 420, p75: 500, p90: 600, unidad: 's', invertida: true },
|
||||||
fcr: { p25: 50, p50: 65, p75: 75, p90: 82, unidad: '%', invertida: false },
|
fcr: { p25: 50, p50: 65, p75: 75, p90: 82, unidad: '%', invertida: false },
|
||||||
abandono: { p25: 10, p50: 6, p75: 4, p90: 2, unidad: '%', invertida: true },
|
abandono: { p25: 2, p50: 6, p75: 10, p90: 15, unidad: '%', invertida: true },
|
||||||
cpi: { p25: 5.00, p50: 4.00, p75: 3.20, p90: 2.50, unidad: '€', invertida: true }
|
cpi: { p25: 2.50, p50: 4.00, p75: 5.00, p90: 6.00, unidad: '€', invertida: true }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
banca: {
|
banca: {
|
||||||
@@ -77,8 +77,8 @@ const BENCHMARKS_INDUSTRIA: Record<IndustryKey, IndustryBenchmarks> = {
|
|||||||
metricas: {
|
metricas: {
|
||||||
aht: { p25: 280, p50: 340, p75: 420, p90: 500, unidad: 's', invertida: true },
|
aht: { p25: 280, p50: 340, p75: 420, p90: 500, unidad: 's', invertida: true },
|
||||||
fcr: { p25: 58, p50: 72, p75: 82, p90: 88, unidad: '%', invertida: false },
|
fcr: { p25: 58, p50: 72, p75: 82, p90: 88, unidad: '%', invertida: false },
|
||||||
abandono: { p25: 6, p50: 4, p75: 2, p90: 1, unidad: '%', invertida: true },
|
abandono: { p25: 1, p50: 4, p75: 6, p90: 10, unidad: '%', invertida: true },
|
||||||
cpi: { p25: 6.00, p50: 4.50, p75: 3.50, p90: 2.80, unidad: '€', invertida: true }
|
cpi: { p25: 2.80, p50: 4.50, p75: 6.00, p90: 7.50, unidad: '€', invertida: true }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
utilities: {
|
utilities: {
|
||||||
@@ -87,8 +87,8 @@ const BENCHMARKS_INDUSTRIA: Record<IndustryKey, IndustryBenchmarks> = {
|
|||||||
metricas: {
|
metricas: {
|
||||||
aht: { p25: 350, p50: 400, p75: 480, p90: 560, unidad: 's', invertida: true },
|
aht: { p25: 350, p50: 400, p75: 480, p90: 560, unidad: 's', invertida: true },
|
||||||
fcr: { p25: 52, p50: 67, p75: 77, p90: 84, unidad: '%', invertida: false },
|
fcr: { p25: 52, p50: 67, p75: 77, p90: 84, unidad: '%', invertida: false },
|
||||||
abandono: { p25: 9, p50: 6, p75: 4, p90: 2, unidad: '%', invertida: true },
|
abandono: { p25: 2, p50: 6, p75: 9, p90: 14, unidad: '%', invertida: true },
|
||||||
cpi: { p25: 4.20, p50: 3.30, p75: 2.60, p90: 2.00, unidad: '€', invertida: true }
|
cpi: { p25: 2.00, p50: 3.30, p75: 4.20, p90: 5.20, unidad: '€', invertida: true }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
retail: {
|
retail: {
|
||||||
@@ -97,8 +97,8 @@ const BENCHMARKS_INDUSTRIA: Record<IndustryKey, IndustryBenchmarks> = {
|
|||||||
metricas: {
|
metricas: {
|
||||||
aht: { p25: 240, p50: 300, p75: 380, p90: 450, unidad: 's', invertida: true },
|
aht: { p25: 240, p50: 300, p75: 380, p90: 450, unidad: 's', invertida: true },
|
||||||
fcr: { p25: 60, p50: 73, p75: 82, p90: 89, unidad: '%', invertida: false },
|
fcr: { p25: 60, p50: 73, p75: 82, p90: 89, unidad: '%', invertida: false },
|
||||||
abandono: { p25: 7, p50: 4, p75: 2, p90: 1, unidad: '%', invertida: true },
|
abandono: { p25: 1, p50: 4, p75: 7, p90: 12, unidad: '%', invertida: true },
|
||||||
cpi: { p25: 3.80, p50: 2.80, p75: 2.10, p90: 1.60, unidad: '€', invertida: true }
|
cpi: { p25: 1.60, p50: 2.80, p75: 3.80, p90: 4.80, unidad: '€', invertida: true }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
general: {
|
general: {
|
||||||
@@ -107,8 +107,8 @@ const BENCHMARKS_INDUSTRIA: Record<IndustryKey, IndustryBenchmarks> = {
|
|||||||
metricas: {
|
metricas: {
|
||||||
aht: { p25: 320, p50: 380, p75: 460, p90: 540, unidad: 's', invertida: true },
|
aht: { p25: 320, p50: 380, p75: 460, p90: 540, unidad: 's', invertida: true },
|
||||||
fcr: { p25: 55, p50: 70, p75: 80, p90: 87, unidad: '%', invertida: false },
|
fcr: { p25: 55, p50: 70, p75: 80, p90: 87, unidad: '%', invertida: false },
|
||||||
abandono: { p25: 8, p50: 5, p75: 3, p90: 2, unidad: '%', invertida: true },
|
abandono: { p25: 2, p50: 5, p75: 8, p90: 12, unidad: '%', invertida: true },
|
||||||
cpi: { p25: 4.50, p50: 3.50, p75: 2.80, p90: 2.20, unidad: '€', invertida: true }
|
cpi: { p25: 2.20, p50: 3.50, p75: 4.50, p90: 5.50, unidad: '€', invertida: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -427,6 +427,9 @@ function UnifiedKPIBenchmark({ heatmapData }: { heatmapData: HeatmapDataPoint[]
|
|||||||
: 0)
|
: 0)
|
||||||
: (totalCostVolume > 0 ? totalAnnualCost / totalCostVolume : 0);
|
: (totalCostVolume > 0 ? totalAnnualCost / totalCostVolume : 0);
|
||||||
|
|
||||||
|
// DEBUG: Log CPI calculation
|
||||||
|
console.log('🔍 ExecutiveSummaryTab CPI:', `€${cpi.toFixed(2)}`, { hasCpiField, totalCostVolume });
|
||||||
|
|
||||||
// Volume-weighted metrics
|
// Volume-weighted metrics
|
||||||
const operacion = {
|
const operacion = {
|
||||||
aht: aht,
|
aht: aht,
|
||||||
|
|||||||
@@ -811,6 +811,60 @@ export const generateAnalysis = async (
|
|||||||
console.log('📊 Heatmap generado desde backend (fallback - sin parsedInteractions)');
|
console.log('📊 Heatmap generado desde backend (fallback - sin parsedInteractions)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v4.5: SINCRONIZAR CPI de dimensión economía con heatmapData para consistencia entre tabs
|
||||||
|
// El heatmapData contiene el CPI calculado correctamente (con cost_volume ponderado)
|
||||||
|
// La dimensión economía fue calculada en mapBackendResultsToAnalysisData con otra fórmula
|
||||||
|
// Actualizamos la dimensión para que muestre el mismo valor que Executive Summary
|
||||||
|
if (mapped.heatmapData && mapped.heatmapData.length > 0) {
|
||||||
|
const heatmapData = mapped.heatmapData;
|
||||||
|
const totalCostVolume = heatmapData.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0);
|
||||||
|
const hasCpiField = heatmapData.some(h => h.cpi !== undefined && h.cpi > 0);
|
||||||
|
|
||||||
|
let globalCPI: number;
|
||||||
|
if (hasCpiField) {
|
||||||
|
// CPI real disponible: promedio ponderado por cost_volume
|
||||||
|
globalCPI = totalCostVolume > 0
|
||||||
|
? heatmapData.reduce((sum, h) => sum + (h.cpi || 0) * (h.cost_volume || h.volume), 0) / totalCostVolume
|
||||||
|
: 0;
|
||||||
|
} else {
|
||||||
|
// Fallback: annual_cost / cost_volume
|
||||||
|
const totalAnnualCost = heatmapData.reduce((sum, h) => sum + (h.annual_cost || 0), 0);
|
||||||
|
globalCPI = totalCostVolume > 0 ? totalAnnualCost / totalCostVolume : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar la dimensión de economía con el CPI calculado desde heatmap
|
||||||
|
// Buscar tanto economy_costs (backend) como economy_cpi (frontend fallback)
|
||||||
|
const economyDimIdx = mapped.dimensions.findIndex(d =>
|
||||||
|
d.id === 'economy_costs' || d.name === 'economy_costs' ||
|
||||||
|
d.id === 'economy_cpi' || d.name === 'economy_cpi'
|
||||||
|
);
|
||||||
|
if (economyDimIdx >= 0 && globalCPI > 0) {
|
||||||
|
// Usar benchmark de aerolíneas (€3.50) para consistencia con ExecutiveSummaryTab
|
||||||
|
// Percentiles: p25=2.20, p50=3.50, p75=4.50, p90=5.50
|
||||||
|
const CPI_BENCHMARK = 3.50;
|
||||||
|
const cpiDiff = globalCPI - CPI_BENCHMARK;
|
||||||
|
// Para CPI invertido: menor es mejor
|
||||||
|
const cpiStatus = cpiDiff <= 0 ? 'positive' : cpiDiff <= 0.5 ? 'neutral' : 'negative';
|
||||||
|
|
||||||
|
// Calcular score basado en percentiles aerolíneas
|
||||||
|
let newScore: number;
|
||||||
|
if (globalCPI <= 2.20) newScore = 100;
|
||||||
|
else if (globalCPI <= 3.50) newScore = 80;
|
||||||
|
else if (globalCPI <= 4.50) newScore = 60;
|
||||||
|
else if (globalCPI <= 5.50) newScore = 40;
|
||||||
|
else newScore = 20;
|
||||||
|
|
||||||
|
mapped.dimensions[economyDimIdx].score = newScore;
|
||||||
|
mapped.dimensions[economyDimIdx].kpi = {
|
||||||
|
label: 'Coste por Interacción',
|
||||||
|
value: `€${globalCPI.toFixed(2)}`,
|
||||||
|
change: `vs benchmark €${CPI_BENCHMARK.toFixed(2)}`,
|
||||||
|
changeType: cpiStatus as 'positive' | 'neutral' | 'negative'
|
||||||
|
};
|
||||||
|
console.log(`💰 CPI sincronizado: €${globalCPI.toFixed(2)}, score: ${newScore}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// v3.5: Calcular drilldownData PRIMERO (necesario para opportunities y roadmap)
|
// v3.5: Calcular drilldownData PRIMERO (necesario para opportunities y roadmap)
|
||||||
if (parsedInteractions && parsedInteractions.length > 0) {
|
if (parsedInteractions && parsedInteractions.length > 0) {
|
||||||
mapped.drilldownData = calculateDrilldownMetrics(parsedInteractions, costPerHour);
|
mapped.drilldownData = calculateDrilldownMetrics(parsedInteractions, costPerHour);
|
||||||
@@ -1020,6 +1074,78 @@ export const generateAnalysisFromCache = async (
|
|||||||
);
|
);
|
||||||
console.log('📊 Heatmap data points:', mapped.heatmapData?.length || 0);
|
console.log('📊 Heatmap data points:', mapped.heatmapData?.length || 0);
|
||||||
|
|
||||||
|
// v4.6: SINCRONIZAR CPI de dimensión economía con heatmapData para consistencia entre tabs
|
||||||
|
// (Mismo fix que en generateAnalysis - necesario para path de cache)
|
||||||
|
if (mapped.heatmapData && mapped.heatmapData.length > 0) {
|
||||||
|
const heatmapData = mapped.heatmapData;
|
||||||
|
const totalCostVolume = heatmapData.reduce((sum, h) => sum + (h.cost_volume || h.volume), 0);
|
||||||
|
const hasCpiField = heatmapData.some(h => h.cpi !== undefined && h.cpi > 0);
|
||||||
|
|
||||||
|
// DEBUG: Log CPI calculation details
|
||||||
|
console.log('🔍 CPI SYNC DEBUG (cache):');
|
||||||
|
console.log(' - heatmapData length:', heatmapData.length);
|
||||||
|
console.log(' - hasCpiField:', hasCpiField);
|
||||||
|
console.log(' - totalCostVolume:', totalCostVolume);
|
||||||
|
if (hasCpiField) {
|
||||||
|
console.log(' - Sample CPIs:', heatmapData.slice(0, 3).map(h => ({ skill: h.skill, cpi: h.cpi, cost_volume: h.cost_volume })));
|
||||||
|
}
|
||||||
|
|
||||||
|
let globalCPI: number;
|
||||||
|
if (hasCpiField) {
|
||||||
|
globalCPI = totalCostVolume > 0
|
||||||
|
? heatmapData.reduce((sum, h) => sum + (h.cpi || 0) * (h.cost_volume || h.volume), 0) / totalCostVolume
|
||||||
|
: 0;
|
||||||
|
} else {
|
||||||
|
const totalAnnualCost = heatmapData.reduce((sum, h) => sum + (h.annual_cost || 0), 0);
|
||||||
|
console.log(' - totalAnnualCost (fallback):', totalAnnualCost);
|
||||||
|
globalCPI = totalCostVolume > 0 ? totalAnnualCost / totalCostVolume : 0;
|
||||||
|
}
|
||||||
|
console.log(' - globalCPI calculated:', globalCPI.toFixed(4));
|
||||||
|
|
||||||
|
// Buscar tanto economy_costs (backend) como economy_cpi (frontend fallback)
|
||||||
|
const dimensionIds = mapped.dimensions.map(d => ({ id: d.id, name: d.name }));
|
||||||
|
console.log(' - Available dimensions:', dimensionIds);
|
||||||
|
|
||||||
|
const economyDimIdx = mapped.dimensions.findIndex(d =>
|
||||||
|
d.id === 'economy_costs' || d.name === 'economy_costs' ||
|
||||||
|
d.id === 'economy_cpi' || d.name === 'economy_cpi'
|
||||||
|
);
|
||||||
|
console.log(' - economyDimIdx:', economyDimIdx);
|
||||||
|
|
||||||
|
if (economyDimIdx >= 0 && globalCPI > 0) {
|
||||||
|
const oldKpi = mapped.dimensions[economyDimIdx].kpi;
|
||||||
|
console.log(' - OLD KPI value:', oldKpi?.value);
|
||||||
|
|
||||||
|
// Usar benchmark de aerolíneas (€3.50) para consistencia con ExecutiveSummaryTab
|
||||||
|
// Percentiles: p25=2.20, p50=3.50, p75=4.50, p90=5.50
|
||||||
|
const CPI_BENCHMARK = 3.50;
|
||||||
|
const cpiDiff = globalCPI - CPI_BENCHMARK;
|
||||||
|
// Para CPI invertido: menor es mejor
|
||||||
|
const cpiStatus = cpiDiff <= 0 ? 'positive' : cpiDiff <= 0.5 ? 'neutral' : 'negative';
|
||||||
|
|
||||||
|
// Calcular score basado en percentiles aerolíneas
|
||||||
|
let newScore: number;
|
||||||
|
if (globalCPI <= 2.20) newScore = 100;
|
||||||
|
else if (globalCPI <= 3.50) newScore = 80;
|
||||||
|
else if (globalCPI <= 4.50) newScore = 60;
|
||||||
|
else if (globalCPI <= 5.50) newScore = 40;
|
||||||
|
else newScore = 20;
|
||||||
|
|
||||||
|
mapped.dimensions[economyDimIdx].score = newScore;
|
||||||
|
mapped.dimensions[economyDimIdx].kpi = {
|
||||||
|
label: 'Coste por Interacción',
|
||||||
|
value: `€${globalCPI.toFixed(2)}`,
|
||||||
|
change: `vs benchmark €${CPI_BENCHMARK.toFixed(2)}`,
|
||||||
|
changeType: cpiStatus as 'positive' | 'neutral' | 'negative'
|
||||||
|
};
|
||||||
|
console.log(' - NEW KPI value:', mapped.dimensions[economyDimIdx].kpi.value);
|
||||||
|
console.log(' - NEW score:', newScore);
|
||||||
|
console.log(`💰 CPI sincronizado (cache): €${globalCPI.toFixed(2)}`);
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ CPI sync skipped: economyDimIdx=', economyDimIdx, 'globalCPI=', globalCPI);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// === DrilldownData: usar cacheado (rápido) o fallback a heatmap ===
|
// === DrilldownData: usar cacheado (rápido) o fallback a heatmap ===
|
||||||
if (cachedDrilldownData && cachedDrilldownData.length > 0) {
|
if (cachedDrilldownData && cachedDrilldownData.length > 0) {
|
||||||
// Usar drilldownData cacheado directamente (ya calculado al subir archivo)
|
// Usar drilldownData cacheado directamente (ya calculado al subir archivo)
|
||||||
|
|||||||
@@ -637,8 +637,9 @@ function buildEconomyDimension(
|
|||||||
const op = raw?.operational_performance;
|
const op = raw?.operational_performance;
|
||||||
const totalAnnual = safeNumber(econ?.cost_breakdown?.total_annual, 0);
|
const totalAnnual = safeNumber(econ?.cost_breakdown?.total_annual, 0);
|
||||||
|
|
||||||
// Benchmark CPI sector contact center (Fuente: Gartner Contact Center Cost Benchmark 2024)
|
// Benchmark CPI aerolíneas (consistente con ExecutiveSummaryTab)
|
||||||
const CPI_BENCHMARK = 5.00;
|
// p25: 2.20, p50: 3.50, p75: 4.50, p90: 5.50
|
||||||
|
const CPI_BENCHMARK = 3.50; // p50 aerolíneas
|
||||||
|
|
||||||
if (totalAnnual <= 0 || totalInteractions <= 0) {
|
if (totalAnnual <= 0 || totalInteractions <= 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -651,20 +652,20 @@ function buildEconomyDimension(
|
|||||||
// Calcular CPI usando cost_volume (non-abandoned) como denominador
|
// Calcular CPI usando cost_volume (non-abandoned) como denominador
|
||||||
const cpi = costVolume > 0 ? totalAnnual / costVolume : totalAnnual / totalInteractions;
|
const cpi = costVolume > 0 ? totalAnnual / costVolume : totalAnnual / totalInteractions;
|
||||||
|
|
||||||
// Score basado en comparación con benchmark (€5.00)
|
// Score basado en percentiles de aerolíneas (CPI invertido: menor = mejor)
|
||||||
// CPI <= 4.00 = 100pts (excelente)
|
// CPI <= 2.20 (p25) = 100pts (excelente, top 25%)
|
||||||
// CPI 4.00-5.00 = 80pts (en benchmark)
|
// CPI 2.20-3.50 (p25-p50) = 80pts (bueno, top 50%)
|
||||||
// CPI 5.00-6.00 = 60pts (por encima)
|
// CPI 3.50-4.50 (p50-p75) = 60pts (promedio)
|
||||||
// CPI 6.00-7.00 = 40pts (alto)
|
// CPI 4.50-5.50 (p75-p90) = 40pts (por debajo)
|
||||||
// CPI > 7.00 = 20pts (crítico)
|
// CPI > 5.50 (>p90) = 20pts (crítico)
|
||||||
let score: number;
|
let score: number;
|
||||||
if (cpi <= 4.00) {
|
if (cpi <= 2.20) {
|
||||||
score = 100;
|
score = 100;
|
||||||
} else if (cpi <= 5.00) {
|
} else if (cpi <= 3.50) {
|
||||||
score = 80;
|
score = 80;
|
||||||
} else if (cpi <= 6.00) {
|
} else if (cpi <= 4.50) {
|
||||||
score = 60;
|
score = 60;
|
||||||
} else if (cpi <= 7.00) {
|
} else if (cpi <= 5.50) {
|
||||||
score = 40;
|
score = 40;
|
||||||
} else {
|
} else {
|
||||||
score = 20;
|
score = 20;
|
||||||
@@ -676,7 +677,7 @@ function buildEconomyDimension(
|
|||||||
let summary = `Coste por interacción: €${cpi.toFixed(2)} vs benchmark €${CPI_BENCHMARK.toFixed(2)}. `;
|
let summary = `Coste por interacción: €${cpi.toFixed(2)} vs benchmark €${CPI_BENCHMARK.toFixed(2)}. `;
|
||||||
if (cpi <= CPI_BENCHMARK) {
|
if (cpi <= CPI_BENCHMARK) {
|
||||||
summary += 'Eficiencia de costes óptima, por debajo del benchmark del sector.';
|
summary += 'Eficiencia de costes óptima, por debajo del benchmark del sector.';
|
||||||
} else if (cpi <= 6.00) {
|
} else if (cpi <= 4.50) {
|
||||||
summary += 'Coste ligeramente por encima del benchmark, oportunidad de optimización.';
|
summary += 'Coste ligeramente por encima del benchmark, oportunidad de optimización.';
|
||||||
} else {
|
} else {
|
||||||
summary += 'Coste elevado respecto al sector. Priorizar iniciativas de eficiencia.';
|
summary += 'Coste elevado respecto al sector. Priorizar iniciativas de eficiencia.';
|
||||||
|
|||||||
@@ -189,6 +189,17 @@ export function generateAnalysisFromRealData(
|
|||||||
// Coste total
|
// Coste total
|
||||||
const totalCost = Math.round(skillMetrics.reduce((sum, s) => sum + s.total_cost, 0));
|
const totalCost = Math.round(skillMetrics.reduce((sum, s) => sum + s.total_cost, 0));
|
||||||
|
|
||||||
|
// === CPI CENTRALIZADO: Calcular UNA sola vez desde heatmapData ===
|
||||||
|
// Esta es la ÚNICA fuente de verdad para CPI, igual que 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 principales
|
// KPIs principales
|
||||||
const summaryKpis: Kpi[] = [
|
const summaryKpis: Kpi[] = [
|
||||||
{ label: "Interacciones Totales", value: totalInteractions.toLocaleString('es-ES') },
|
{ label: "Interacciones Totales", value: totalInteractions.toLocaleString('es-ES') },
|
||||||
@@ -196,17 +207,18 @@ export function generateAnalysisFromRealData(
|
|||||||
{ label: "FCR Técnico", value: `${avgFCR}%` },
|
{ label: "FCR Técnico", value: `${avgFCR}%` },
|
||||||
{ label: "CSAT", value: `${(avgCsat / 20).toFixed(1)}/5` }
|
{ label: "CSAT", value: `${(avgCsat / 20).toFixed(1)}/5` }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Health Score basado en métricas reales
|
// Health Score basado en métricas reales
|
||||||
const overallHealthScore = calculateHealthScore(heatmapData);
|
const overallHealthScore = calculateHealthScore(heatmapData);
|
||||||
|
|
||||||
// Dimensiones (simplificadas para datos reales)
|
// Dimensiones (simplificadas para datos reales) - pasar CPI centralizado
|
||||||
const dimensions: DimensionAnalysis[] = generateDimensionsFromRealData(
|
const dimensions: DimensionAnalysis[] = generateDimensionsFromRealData(
|
||||||
interactions,
|
interactions,
|
||||||
skillMetrics,
|
skillMetrics,
|
||||||
avgCsat,
|
avgCsat,
|
||||||
avgAHT,
|
avgAHT,
|
||||||
hourlyDistribution
|
hourlyDistribution,
|
||||||
|
globalCPI // CPI calculado desde heatmapData
|
||||||
);
|
);
|
||||||
|
|
||||||
// Agentic Readiness Score
|
// Agentic Readiness Score
|
||||||
@@ -1212,7 +1224,8 @@ function generateDimensionsFromRealData(
|
|||||||
metrics: SkillMetrics[],
|
metrics: SkillMetrics[],
|
||||||
avgCsat: number,
|
avgCsat: number,
|
||||||
avgAHT: number,
|
avgAHT: number,
|
||||||
hourlyDistribution: { hourly: number[]; off_hours_pct: number; peak_hours: number[] }
|
hourlyDistribution: { hourly: number[]; off_hours_pct: number; peak_hours: number[] },
|
||||||
|
globalCPI: number // CPI calculado centralmente desde heatmapData
|
||||||
): DimensionAnalysis[] {
|
): DimensionAnalysis[] {
|
||||||
const totalVolume = interactions.length;
|
const totalVolume = interactions.length;
|
||||||
const avgCV = metrics.reduce((sum, m) => sum + m.cv_aht, 0) / metrics.length;
|
const avgCV = metrics.reduce((sum, m) => sum + m.cv_aht, 0) / metrics.length;
|
||||||
@@ -1270,13 +1283,10 @@ function generateDimensionsFromRealData(
|
|||||||
|
|
||||||
volumetryScore = Math.max(0, Math.min(100, Math.round(volumetryScore)));
|
volumetryScore = Math.max(0, Math.min(100, Math.round(volumetryScore)));
|
||||||
|
|
||||||
// === CPI: Coste por interacción (consistente con Executive Summary) ===
|
// === CPI: Usar el valor centralizado pasado como parámetro ===
|
||||||
// Usar cost_volume (non-abandon) como denominador, igual que heatmapData
|
// globalCPI ya fue calculado en generateAnalysisFromRealData desde heatmapData
|
||||||
const totalCostVolume = metrics.reduce((sum, m) => sum + m.cost_volume, 0);
|
// Esto garantiza consistencia con ExecutiveSummaryTab
|
||||||
// Usar CPI pre-calculado si disponible, sino calcular desde total_cost / cost_volume
|
const costPerInteraction = globalCPI;
|
||||||
const costPerInteraction = totalCostVolume > 0
|
|
||||||
? metrics.reduce((sum, m) => sum + (m.cpi * m.cost_volume), 0) / totalCostVolume
|
|
||||||
: (totalCost / totalVolume);
|
|
||||||
|
|
||||||
// Calcular Agentic Score
|
// Calcular Agentic Score
|
||||||
const predictability = Math.max(0, Math.min(10, 10 - ((avgCV - 0.3) / 1.2 * 10)));
|
const predictability = Math.max(0, Math.min(10, 10 - ((avgCV - 0.3) / 1.2 * 10)));
|
||||||
@@ -1353,14 +1363,15 @@ function generateDimensionsFromRealData(
|
|||||||
kpi: { label: 'CSAT', value: avgCsat > 0 ? `${Math.round(avgCsat)}/100` : 'N/A' },
|
kpi: { label: 'CSAT', value: avgCsat > 0 ? `${Math.round(avgCsat)}/100` : 'N/A' },
|
||||||
icon: Smile
|
icon: Smile
|
||||||
},
|
},
|
||||||
// 6. ECONOMÍA - CPI
|
// 6. ECONOMÍA - CPI (benchmark aerolíneas: p25=2.20, p50=3.50, p75=4.50, p90=5.50)
|
||||||
{
|
{
|
||||||
id: 'economy_cpi',
|
id: 'economy_cpi',
|
||||||
name: 'economy_cpi',
|
name: 'economy_cpi',
|
||||||
title: 'Economía Operacional',
|
title: 'Economía Operacional',
|
||||||
score: costPerInteraction < 4 ? 85 : costPerInteraction < 5 ? 70 : costPerInteraction < 6 ? 55 : 40,
|
// Score basado en percentiles aerolíneas (CPI invertido: menor = mejor)
|
||||||
percentile: costPerInteraction < 4.5 ? 70 : costPerInteraction < 5.5 ? 50 : 30,
|
score: costPerInteraction <= 2.20 ? 100 : costPerInteraction <= 3.50 ? 80 : costPerInteraction <= 4.50 ? 60 : costPerInteraction <= 5.50 ? 40 : 20,
|
||||||
summary: `CPI: €${costPerInteraction.toFixed(2)} por interacción. Coste anual: €${totalCost.toLocaleString('es-ES')}. Benchmark sector: €5.00 (Fuente: Gartner 2024).`,
|
percentile: costPerInteraction <= 2.20 ? 90 : costPerInteraction <= 3.50 ? 70 : costPerInteraction <= 4.50 ? 50 : costPerInteraction <= 5.50 ? 25 : 10,
|
||||||
|
summary: `CPI: €${costPerInteraction.toFixed(2)} por interacción. Coste anual: €${totalCost.toLocaleString('es-ES')}. Benchmark sector aerolíneas: €3.50.`,
|
||||||
kpi: { label: 'Coste/Interacción', value: `€${costPerInteraction.toFixed(2)}` },
|
kpi: { label: 'Coste/Interacción', value: `€${costPerInteraction.toFixed(2)}` },
|
||||||
icon: DollarSign
|
icon: DollarSign
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user