Initial commit: frontend + backend integration
This commit is contained in:
653
frontend/utils/backendMapper.ts
Normal file
653
frontend/utils/backendMapper.ts
Normal file
@@ -0,0 +1,653 @@
|
||||
// utils/backendMapper.ts
|
||||
import type {
|
||||
AnalysisData,
|
||||
AgenticReadinessResult,
|
||||
SubFactor,
|
||||
TierKey,
|
||||
DimensionAnalysis,
|
||||
Kpi,
|
||||
EconomicModelData,
|
||||
} from '../types';
|
||||
import type { BackendRawResults } from './apiClient';
|
||||
import { BarChartHorizontal, Zap, DollarSign } from 'lucide-react';
|
||||
|
||||
function safeNumber(value: any, fallback = 0): number {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function inferTierFromScore(score: number): TierKey {
|
||||
if (score >= 8) return 'gold';
|
||||
if (score >= 5) return 'silver';
|
||||
return 'bronze';
|
||||
}
|
||||
|
||||
function computeBalanceScore(values: number[]): number {
|
||||
if (!values.length) return 50;
|
||||
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
if (mean === 0) return 50;
|
||||
const variance =
|
||||
values.reduce((acc, v) => acc + Math.pow(v - mean, 2), 0) /
|
||||
values.length;
|
||||
const std = Math.sqrt(variance);
|
||||
const cv = std / mean;
|
||||
|
||||
const rawScore = 100 - cv * 100;
|
||||
return Math.max(0, Math.min(100, Math.round(rawScore)));
|
||||
}
|
||||
|
||||
function getTopLabel(
|
||||
labels: any,
|
||||
values: number[]
|
||||
): string | undefined {
|
||||
if (!Array.isArray(labels) || !labels.length || !values.length) {
|
||||
return undefined;
|
||||
}
|
||||
const len = Math.min(labels.length, values.length);
|
||||
let maxIdx = 0;
|
||||
let maxVal = values[0];
|
||||
for (let i = 1; i < len; i++) {
|
||||
if (values[i] > maxVal) {
|
||||
maxVal = values[i];
|
||||
maxIdx = i;
|
||||
}
|
||||
}
|
||||
return String(labels[maxIdx]);
|
||||
}
|
||||
|
||||
// ==== Helpers para distribución horaria (desde heatmap_24x7) ====
|
||||
|
||||
function computeHourlyFromHeatmap(heatmap24x7: any): number[] {
|
||||
if (!Array.isArray(heatmap24x7) || !heatmap24x7.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hours = Array(24).fill(0);
|
||||
|
||||
for (const day of heatmap24x7) {
|
||||
for (let h = 0; h < 24; h++) {
|
||||
const key = String(h);
|
||||
const v = safeNumber(day?.[key], 0);
|
||||
hours[h] += v;
|
||||
}
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
function calcOffHoursPct(hourly: number[]): number {
|
||||
const total = hourly.reduce((a, b) => a + b, 0);
|
||||
if (!total) return 0;
|
||||
const offHours =
|
||||
hourly.slice(0, 8).reduce((a, b) => a + b, 0) +
|
||||
hourly.slice(19, 24).reduce((a, b) => a + b, 0);
|
||||
return offHours / total;
|
||||
}
|
||||
|
||||
function findPeakHours(hourly: number[]): number[] {
|
||||
if (!hourly.length) return [];
|
||||
const sorted = [...hourly].sort((a, b) => b - a);
|
||||
const threshold = sorted[Math.min(2, sorted.length - 1)] || 0;
|
||||
return hourly
|
||||
.map((val, idx) => (val >= threshold ? idx : -1))
|
||||
.filter((idx) => idx !== -1);
|
||||
}
|
||||
|
||||
// ==== Agentic readiness ====
|
||||
|
||||
function mapAgenticReadiness(
|
||||
raw: any,
|
||||
fallbackTier: TierKey
|
||||
): AgenticReadinessResult | undefined {
|
||||
const ar = raw?.agentic_readiness?.agentic_readiness;
|
||||
if (!ar) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const score = safeNumber(ar.final_score, 5);
|
||||
const classification = ar.classification || {};
|
||||
const weights = ar.weights || {};
|
||||
const sub_scores = ar.sub_scores || {};
|
||||
|
||||
const baseWeights = weights.base_weights || {};
|
||||
const normalized = weights.normalized_weights || {};
|
||||
|
||||
const subFactors: SubFactor[] = Object.entries(sub_scores).map(
|
||||
([key, value]: [string, any]) => {
|
||||
const subScore = safeNumber(value?.score, 0);
|
||||
const weight =
|
||||
safeNumber(normalized?.[key], NaN) ||
|
||||
safeNumber(baseWeights?.[key], 0);
|
||||
|
||||
return {
|
||||
name: key,
|
||||
displayName: key.replace(/_/g, ' '),
|
||||
score: subScore,
|
||||
weight,
|
||||
description:
|
||||
value?.reason ||
|
||||
value?.details?.description ||
|
||||
'Sub-factor calculado a partir de KPIs agregados.',
|
||||
details: value?.details || {},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const tier = inferTierFromScore(score) || fallbackTier;
|
||||
|
||||
const interpretation =
|
||||
classification?.description ||
|
||||
`Puntuación de preparación agentic: ${score.toFixed(1)}/10`;
|
||||
|
||||
const computedCount = Object.values(sub_scores).filter(
|
||||
(s: any) => s?.computed
|
||||
).length;
|
||||
const totalCount = Object.keys(sub_scores).length || 1;
|
||||
const ratio = computedCount / totalCount;
|
||||
|
||||
const confidence: AgenticReadinessResult['confidence'] =
|
||||
ratio >= 0.75 ? 'high' : ratio >= 0.4 ? 'medium' : 'low';
|
||||
|
||||
return {
|
||||
score,
|
||||
sub_factors: subFactors,
|
||||
tier,
|
||||
confidence,
|
||||
interpretation,
|
||||
};
|
||||
}
|
||||
|
||||
// ==== Volumetría (dimensión + KPIs) ====
|
||||
|
||||
function buildVolumetryDimension(
|
||||
raw: BackendRawResults
|
||||
): { dimension?: DimensionAnalysis; extraKpis: Kpi[] } {
|
||||
const volumetry = raw?.volumetry;
|
||||
const volumeByChannel = volumetry?.volume_by_channel;
|
||||
const volumeBySkill = volumetry?.volume_by_skill;
|
||||
|
||||
const channelValues: number[] = Array.isArray(volumeByChannel?.values)
|
||||
? volumeByChannel.values.map((v: any) => safeNumber(v, 0))
|
||||
: [];
|
||||
|
||||
const rawSkillLabels =
|
||||
volumeBySkill?.labels ??
|
||||
volumeBySkill?.skills ??
|
||||
volumeBySkill?.skill_names ??
|
||||
[];
|
||||
|
||||
const skillLabels: string[] = Array.isArray(rawSkillLabels)
|
||||
? rawSkillLabels.map((s: any) => String(s))
|
||||
: [];
|
||||
|
||||
const skillValues: number[] = Array.isArray(volumeBySkill?.values)
|
||||
? volumeBySkill.values.map((v: any) => safeNumber(v, 0))
|
||||
: [];
|
||||
|
||||
const totalVolumeChannels = channelValues.reduce((a, b) => a + b, 0);
|
||||
const totalVolumeSkills = skillValues.reduce((a, b) => a + b, 0);
|
||||
const totalVolume =
|
||||
totalVolumeChannels || totalVolumeSkills || 0;
|
||||
|
||||
const numChannels = Array.isArray(volumeByChannel?.labels)
|
||||
? volumeByChannel.labels.length
|
||||
: 0;
|
||||
const numSkills = skillLabels.length;
|
||||
|
||||
const topChannel = getTopLabel(volumeByChannel?.labels, channelValues);
|
||||
const topSkill = getTopLabel(skillLabels, skillValues);
|
||||
|
||||
// Heatmap 24x7 -> distribución horaria
|
||||
const heatmap24x7 = volumetry?.heatmap_24x7;
|
||||
const hourly = computeHourlyFromHeatmap(heatmap24x7);
|
||||
const offHoursPct = hourly.length ? calcOffHoursPct(hourly) : 0;
|
||||
const peakHours = hourly.length ? findPeakHours(hourly) : [];
|
||||
|
||||
console.log('📊 Volumetría backend (mapper):', {
|
||||
volumetry,
|
||||
volumeByChannel,
|
||||
volumeBySkill,
|
||||
totalVolume,
|
||||
numChannels,
|
||||
numSkills,
|
||||
skillLabels,
|
||||
skillValues,
|
||||
hourly,
|
||||
offHoursPct,
|
||||
peakHours,
|
||||
});
|
||||
|
||||
const extraKpis: Kpi[] = [];
|
||||
|
||||
if (totalVolume > 0) {
|
||||
extraKpis.push({
|
||||
label: 'Volumen total (backend)',
|
||||
value: totalVolume.toLocaleString('es-ES'),
|
||||
});
|
||||
}
|
||||
|
||||
if (numChannels > 0) {
|
||||
extraKpis.push({
|
||||
label: 'Canales analizados',
|
||||
value: String(numChannels),
|
||||
});
|
||||
}
|
||||
|
||||
if (numSkills > 0) {
|
||||
extraKpis.push({
|
||||
label: 'Skills analizadas',
|
||||
value: String(numSkills),
|
||||
});
|
||||
|
||||
extraKpis.push({
|
||||
label: 'Skills (backend)',
|
||||
value: skillLabels.join(', '),
|
||||
});
|
||||
} else {
|
||||
extraKpis.push({
|
||||
label: 'Skills (backend)',
|
||||
value: 'N/A',
|
||||
});
|
||||
}
|
||||
|
||||
if (topChannel) {
|
||||
extraKpis.push({
|
||||
label: 'Canal principal',
|
||||
value: topChannel,
|
||||
});
|
||||
}
|
||||
|
||||
if (topSkill) {
|
||||
extraKpis.push({
|
||||
label: 'Skill principal',
|
||||
value: topSkill,
|
||||
});
|
||||
}
|
||||
|
||||
if (!totalVolume) {
|
||||
return { dimension: undefined, extraKpis };
|
||||
}
|
||||
|
||||
const summaryParts: string[] = [];
|
||||
summaryParts.push(
|
||||
`Se han analizado aproximadamente ${totalVolume.toLocaleString(
|
||||
'es-ES'
|
||||
)} interacciones mensuales.`
|
||||
);
|
||||
if (numChannels > 0) {
|
||||
summaryParts.push(
|
||||
`El tráfico se reparte en ${numChannels} canales${
|
||||
topChannel ? `, destacando ${topChannel} como el canal con mayor volumen` : ''
|
||||
}.`
|
||||
);
|
||||
}
|
||||
if (numSkills > 0) {
|
||||
const skillsList =
|
||||
skillLabels.length > 0 ? skillLabels.join(', ') : undefined;
|
||||
summaryParts.push(
|
||||
`Se han identificado ${numSkills} skills${
|
||||
skillsList ? ` (${skillsList})` : ''
|
||||
}${
|
||||
topSkill ? `, siendo ${topSkill} la de mayor carga` : ''
|
||||
}.`
|
||||
);
|
||||
}
|
||||
|
||||
const dimension: DimensionAnalysis = {
|
||||
id: 'volumetry_distribution',
|
||||
name: 'volumetry_distribution',
|
||||
title: 'Volumetría y distribución de demanda',
|
||||
score: computeBalanceScore(
|
||||
skillValues.length ? skillValues : channelValues
|
||||
),
|
||||
percentile: undefined,
|
||||
summary: summaryParts.join(' '),
|
||||
kpi: {
|
||||
label: 'Interacciones mensuales (backend)',
|
||||
value: totalVolume.toLocaleString('es-ES'),
|
||||
},
|
||||
icon: BarChartHorizontal,
|
||||
distribution_data: hourly.length
|
||||
? {
|
||||
hourly,
|
||||
off_hours_pct: offHoursPct,
|
||||
peak_hours: peakHours,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return { dimension, extraKpis };
|
||||
}
|
||||
|
||||
// ==== Performance (operational_performance) ====
|
||||
|
||||
function buildPerformanceDimension(
|
||||
raw: BackendRawResults
|
||||
): DimensionAnalysis | undefined {
|
||||
const op = raw?.operational_performance;
|
||||
if (!op) return undefined;
|
||||
|
||||
const perfScore0_10 = safeNumber(op.performance_score?.score, NaN);
|
||||
if (!Number.isFinite(perfScore0_10)) return undefined;
|
||||
|
||||
const score = Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(perfScore0_10 * 10))
|
||||
);
|
||||
|
||||
const ahtP50 = safeNumber(op.aht_distribution?.p50, 0);
|
||||
const ahtP90 = safeNumber(op.aht_distribution?.p90, 0);
|
||||
const ratio = safeNumber(op.aht_distribution?.p90_p50_ratio, 0);
|
||||
const escRate = safeNumber(op.escalation_rate, 0);
|
||||
|
||||
let summary = `El AHT mediano se sitúa en ${Math.round(
|
||||
ahtP50
|
||||
)} segundos, con un P90 de ${Math.round(
|
||||
ahtP90
|
||||
)}s (ratio P90/P50 ≈ ${ratio.toFixed(
|
||||
2
|
||||
)}) y una tasa de escalación del ${escRate.toFixed(
|
||||
1
|
||||
)}%. `;
|
||||
|
||||
if (score >= 80) {
|
||||
summary +=
|
||||
'El rendimiento operativo es sólido y se encuentra claramente por encima de los umbrales objetivo.';
|
||||
} else if (score >= 60) {
|
||||
summary +=
|
||||
'El rendimiento es aceptable pero existen oportunidades claras de optimización en algunos flujos.';
|
||||
} else {
|
||||
summary +=
|
||||
'El rendimiento operativo está por debajo del nivel deseado y requiere un plan de mejora específico.';
|
||||
}
|
||||
|
||||
const kpi: Kpi = {
|
||||
label: 'AHT mediano (P50)',
|
||||
value: ahtP50 ? `${Math.round(ahtP50)}s` : 'N/D',
|
||||
};
|
||||
|
||||
const dimension: DimensionAnalysis = {
|
||||
id: 'performance',
|
||||
name: 'performance',
|
||||
title: 'Rendimiento operativo',
|
||||
score,
|
||||
percentile: undefined,
|
||||
summary,
|
||||
kpi,
|
||||
icon: Zap,
|
||||
};
|
||||
|
||||
return dimension;
|
||||
}
|
||||
|
||||
// ==== Economía y costes (economy_costs) ====
|
||||
|
||||
function buildEconomicModel(raw: BackendRawResults): EconomicModelData {
|
||||
const econ = raw?.economy_costs;
|
||||
const cost = econ?.cost_breakdown || {};
|
||||
const totalAnnual = safeNumber(cost.total_annual, 0);
|
||||
const laborAnnual = safeNumber(cost.labor_annual, 0);
|
||||
const overheadAnnual = safeNumber(cost.overhead_annual, 0);
|
||||
const techAnnual = safeNumber(cost.tech_annual, 0);
|
||||
|
||||
const potential = econ?.potential_savings || {};
|
||||
const annualSavings = safeNumber(potential.annual_savings, 0);
|
||||
|
||||
const currentAnnualCost =
|
||||
totalAnnual || laborAnnual + overheadAnnual + techAnnual || 0;
|
||||
const futureAnnualCost = currentAnnualCost - annualSavings;
|
||||
|
||||
let initialInvestment = 0;
|
||||
let paybackMonths = 0;
|
||||
let roi3yr = 0;
|
||||
|
||||
if (annualSavings > 0 && currentAnnualCost > 0) {
|
||||
initialInvestment = Math.round(currentAnnualCost * 0.15);
|
||||
paybackMonths = Math.ceil(
|
||||
(initialInvestment / annualSavings) * 12
|
||||
);
|
||||
roi3yr =
|
||||
((annualSavings * 3 - initialInvestment) /
|
||||
initialInvestment) *
|
||||
100;
|
||||
}
|
||||
|
||||
const savingsBreakdown = annualSavings
|
||||
? [
|
||||
{
|
||||
category: 'Ineficiencias operativas (AHT, escalaciones)',
|
||||
amount: Math.round(annualSavings * 0.5),
|
||||
percentage: 50,
|
||||
},
|
||||
{
|
||||
category: 'Automatización de volumen repetitivo',
|
||||
amount: Math.round(annualSavings * 0.3),
|
||||
percentage: 30,
|
||||
},
|
||||
{
|
||||
category: 'Otros beneficios (calidad, CX)',
|
||||
amount: Math.round(annualSavings * 0.2),
|
||||
percentage: 20,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const costBreakdown = currentAnnualCost
|
||||
? [
|
||||
{
|
||||
category: 'Coste laboral',
|
||||
amount: laborAnnual,
|
||||
percentage: Math.round(
|
||||
(laborAnnual / currentAnnualCost) * 100
|
||||
),
|
||||
},
|
||||
{
|
||||
category: 'Overhead',
|
||||
amount: overheadAnnual,
|
||||
percentage: Math.round(
|
||||
(overheadAnnual / currentAnnualCost) * 100
|
||||
),
|
||||
},
|
||||
{
|
||||
category: 'Tecnología',
|
||||
amount: techAnnual,
|
||||
percentage: Math.round(
|
||||
(techAnnual / currentAnnualCost) * 100
|
||||
),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return {
|
||||
currentAnnualCost,
|
||||
futureAnnualCost,
|
||||
annualSavings,
|
||||
initialInvestment,
|
||||
paybackMonths,
|
||||
roi3yr: parseFloat(roi3yr.toFixed(1)),
|
||||
savingsBreakdown,
|
||||
npv: 0,
|
||||
costBreakdown,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEconomyDimension(
|
||||
raw: BackendRawResults
|
||||
): DimensionAnalysis | undefined {
|
||||
const econ = raw?.economy_costs;
|
||||
if (!econ) return undefined;
|
||||
|
||||
const cost = econ.cost_breakdown || {};
|
||||
const totalAnnual = safeNumber(cost.total_annual, 0);
|
||||
const potential = econ.potential_savings || {};
|
||||
const annualSavings = safeNumber(potential.annual_savings, 0);
|
||||
|
||||
if (!totalAnnual && !annualSavings) return undefined;
|
||||
|
||||
const savingsPct = totalAnnual
|
||||
? (annualSavings / totalAnnual) * 100
|
||||
: 0;
|
||||
|
||||
let summary = `El coste anual estimado de la operación es de aproximadamente €${totalAnnual.toFixed(
|
||||
2
|
||||
)}. `;
|
||||
if (annualSavings > 0) {
|
||||
summary += `El ahorro potencial anual asociado a la estrategia agentic se sitúa en torno a €${annualSavings.toFixed(
|
||||
2
|
||||
)}, equivalente a ~${savingsPct.toFixed(1)}% del coste actual.`;
|
||||
} else {
|
||||
summary +=
|
||||
'Todavía no se dispone de una estimación robusta de ahorro potencial.';
|
||||
}
|
||||
|
||||
const score =
|
||||
totalAnnual && annualSavings
|
||||
? Math.max(0, Math.min(100, Math.round(savingsPct)))
|
||||
: 50;
|
||||
|
||||
const dimension: DimensionAnalysis = {
|
||||
id: 'economy',
|
||||
name: 'economy',
|
||||
title: 'Economía y costes',
|
||||
score,
|
||||
percentile: undefined,
|
||||
summary,
|
||||
kpi: {
|
||||
label: 'Coste anual actual',
|
||||
value: totalAnnual
|
||||
? `€${totalAnnual.toFixed(0)}`
|
||||
: 'N/D',
|
||||
},
|
||||
icon: DollarSign,
|
||||
};
|
||||
|
||||
return dimension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforma el JSON del backend (results) al AnalysisData
|
||||
* que espera el frontend.
|
||||
*/
|
||||
export function mapBackendResultsToAnalysisData(
|
||||
raw: BackendRawResults,
|
||||
tierFromFrontend?: TierKey
|
||||
): AnalysisData {
|
||||
const volumetry = raw?.volumetry;
|
||||
const volumeByChannel = volumetry?.volume_by_channel;
|
||||
const volumeBySkill = volumetry?.volume_by_skill;
|
||||
|
||||
const channelValues: number[] = Array.isArray(volumeByChannel?.values)
|
||||
? volumeByChannel.values.map((v: any) => safeNumber(v, 0))
|
||||
: [];
|
||||
const skillValues: number[] = Array.isArray(volumeBySkill?.values)
|
||||
? volumeBySkill.values.map((v: any) => safeNumber(v, 0))
|
||||
: [];
|
||||
|
||||
const totalVolumeChannels = channelValues.reduce((a, b) => a + b, 0);
|
||||
const totalVolumeSkills = skillValues.reduce((a, b) => a + b, 0);
|
||||
const totalVolume =
|
||||
totalVolumeChannels || totalVolumeSkills || 0;
|
||||
|
||||
const numChannels = Array.isArray(volumeByChannel?.labels)
|
||||
? volumeByChannel.labels.length
|
||||
: 0;
|
||||
const numSkills = Array.isArray(volumeBySkill?.labels)
|
||||
? volumeBySkill.labels.length
|
||||
: 0;
|
||||
|
||||
// Agentic readiness
|
||||
const agenticReadiness = mapAgenticReadiness(
|
||||
raw,
|
||||
tierFromFrontend || 'silver'
|
||||
);
|
||||
const arScore = agenticReadiness?.score ?? 5;
|
||||
const overallHealthScore = Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(arScore * 10))
|
||||
);
|
||||
|
||||
// Dimensiones
|
||||
const { dimension: volumetryDimension, extraKpis } =
|
||||
buildVolumetryDimension(raw);
|
||||
const performanceDimension = buildPerformanceDimension(raw);
|
||||
const economyDimension = buildEconomyDimension(raw);
|
||||
|
||||
const dimensions: DimensionAnalysis[] = [];
|
||||
if (volumetryDimension) dimensions.push(volumetryDimension);
|
||||
if (performanceDimension) dimensions.push(performanceDimension);
|
||||
if (economyDimension) dimensions.push(economyDimension);
|
||||
|
||||
// KPIs de resumen
|
||||
const summaryKpis: Kpi[] = [];
|
||||
|
||||
summaryKpis.push({
|
||||
label: 'Volumen total (estimado)',
|
||||
value:
|
||||
totalVolume > 0
|
||||
? totalVolume.toLocaleString('es-ES')
|
||||
: 'N/A',
|
||||
});
|
||||
|
||||
if (numChannels > 0) {
|
||||
summaryKpis.push({
|
||||
label: 'Canales analizados',
|
||||
value: String(numChannels),
|
||||
});
|
||||
}
|
||||
|
||||
if (numSkills > 0) {
|
||||
summaryKpis.push({
|
||||
label: 'Skills analizadas',
|
||||
value: String(numSkills),
|
||||
});
|
||||
}
|
||||
|
||||
summaryKpis.push({
|
||||
label: 'Agentic readiness',
|
||||
value: `${arScore.toFixed(1)}/10`,
|
||||
});
|
||||
|
||||
// KPIs de economía
|
||||
const econ = raw?.economy_costs;
|
||||
const totalAnnual = safeNumber(
|
||||
econ?.cost_breakdown?.total_annual,
|
||||
0
|
||||
);
|
||||
const annualSavings = safeNumber(
|
||||
econ?.potential_savings?.annual_savings,
|
||||
0
|
||||
);
|
||||
|
||||
if (totalAnnual) {
|
||||
summaryKpis.push({
|
||||
label: 'Coste anual actual (backend)',
|
||||
value: `€${totalAnnual.toFixed(0)}`,
|
||||
});
|
||||
}
|
||||
if (annualSavings) {
|
||||
summaryKpis.push({
|
||||
label: 'Ahorro potencial anual (backend)',
|
||||
value: `€${annualSavings.toFixed(0)}`,
|
||||
});
|
||||
}
|
||||
|
||||
const mergedKpis: Kpi[] = [...summaryKpis, ...extraKpis];
|
||||
|
||||
const economicModel = buildEconomicModel(raw);
|
||||
|
||||
return {
|
||||
tier: tierFromFrontend,
|
||||
overallHealthScore,
|
||||
summaryKpis: mergedKpis,
|
||||
dimensions,
|
||||
heatmapData: [], // el heatmap por skill lo seguimos generando en el front
|
||||
findings: [],
|
||||
recommendations: [],
|
||||
opportunities: [],
|
||||
roadmap: [],
|
||||
economicModel,
|
||||
benchmarkData: [],
|
||||
agenticReadiness,
|
||||
staticConfig: undefined,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user