La Fase 2 desarrolla las herramientas operativas esenciales que empoderan a los usuarios para tomar mejores decisiones basadas en datos, con un dashboard en tiempo real y una calculadora de escenarios what-if.
| Métrica | Target | Criticidad |
|---|---|---|
| Adopción dashboard | >80% usuarios | Alta |
| Uso calculadora | >50 veces/semana | Alta |
| Tiempo respuesta | <2 segundos | Media |
| Satisfacción usuarios | >4/5 | Alta |
| Reducción km vacíos | -15% | Crítica |
// Arquitectura de componentes del dashboard
interface DashboardArchitecture {
frontend: {
framework: 'React 18';
stateManagement: 'Redux Toolkit';
styling: 'Tailwind CSS';
charts: 'Recharts + D3.js';
maps: 'Mapbox GL';
};
backend: {
api: 'FastAPI';
cache: 'Redis';
database: 'PostgreSQL + TimescaleDB';
realtime: 'WebSockets';
};
deployment: {
hosting: 'AWS ECS';
cdn: 'CloudFront';
monitoring: 'Datadog';
};
}
// Componente principal del dashboard
const OperationalDashboard: React.FC = () => {
const [filters, setFilters] = useState<DashboardFilters>(defaultFilters);
const [refreshInterval, setRefreshInterval] = useState(30000); // 30s
const { fleetData, isLoading } = useFleetData(filters, refreshInterval);
const { kpis } = useKPICalculations(fleetData);
return (
<DashboardLayout>
<Header>
<Title>SIGA - Centro de Control Operativo</Title>
<TimePeriodSelector onChange={setFilters} />
<RefreshIndicator lastUpdate={fleetData?.timestamp} />
</Header>
<KPICards metrics={kpis} trend="week" />
<MainContent>
<FleetMap
vehicles={fleetData?.vehicles}
heatmapData={fleetData?.demandHeatmap}
onVehicleClick={handleVehicleDetails}
/>
<SidePanel>
<AlertsWidget alerts={fleetData?.activeAlerts} />
<DecisionQueue pending={fleetData?.pendingDecisions} />
<ZoneBalance zones={fleetData?.zoneStats} />
</SidePanel>
</MainContent>
<BottomPanel>
<TrendCharts data={fleetData?.historicalTrends} />
</BottomPanel>
</DashboardLayout>
);
};
// Componente de mapa con capas de información
const FleetMap: React.FC<FleetMapProps> = ({ vehicles, heatmapData }) => {
const mapRef = useRef<mapboxgl.Map>();
useEffect(() => {
// Inicializar mapa
mapRef.current = new mapboxgl.Map({
container: 'fleet-map',
style: 'mapbox://styles/mapbox/dark-v10',
center: [-3.7038, 40.4168], // Madrid
zoom: 6
});
// Añadir capa de vehículos
mapRef.current.on('load', () => {
// Capa de calor de demanda
mapRef.current.addLayer({
id: 'demand-heatmap',
type: 'heatmap',
source: {
type: 'geojson',
data: heatmapData
},
paint: {
'heatmap-weight': ['get', 'demand_score'],
'heatmap-intensity': 0.7,
'heatmap-color': [
'interpolate',
['linear'],
['heatmap-density'],
0, 'rgba(33,102,172,0)',
0.2, 'rgb(103,169,207)',
0.4, 'rgb(209,229,240)',
0.6, 'rgb(253,219,199)',
0.8, 'rgb(239,138,98)',
1, 'rgb(178,24,43)'
]
}
});
// Marcadores de vehículos
vehicles.forEach(vehicle => {
const marker = createVehicleMarker(vehicle);
marker.setLngLat([vehicle.lng, vehicle.lat])
.addTo(mapRef.current);
});
});
}, [vehicles, heatmapData]);
return (
<div id="fleet-map" className="w-full h-full relative">
<MapControls onZoomIn={handleZoomIn} onZoomOut={handleZoomOut} />
<LayerToggle
layers={['vehicles', 'demand', 'routes', 'zones']}
onChange={handleLayerToggle}
/>
</div>
);
};
class KPICalculator:
"""Calcula KPIs en tiempo real para el dashboard"""
def __init__(self):
self.redis_client = redis.Redis(decode_responses=True)
self.cache_ttl = 30 # segundos
async def calculate_fleet_kpis(self) -> Dict[str, Any]:
"""Calcula todos los KPIs principales"""
# Intentar obtener de cache
cached = self.redis_client.get('fleet_kpis')
if cached:
return json.loads(cached)
# Calcular KPIs frescos
kpis = {}
# KPI 1: Ratio de km vacíos
empty_km_data = await self._calculate_empty_km_ratio()
kpis['empty_km_ratio'] = {
'value': empty_km_data['current'],
'trend': empty_km_data['trend'],
'target': 0.20, # 20%
'status': 'good' if empty_km_data['current'] < 0.25 else 'warning'
}
# KPI 2: Utilización de flota
utilization = await self._calculate_fleet_utilization()
kpis['fleet_utilization'] = {
'value': utilization['percentage'],
'vehicles_active': utilization['active'],
'vehicles_total': utilization['total'],
'trend': utilization['trend']
}
# KPI 3: Margen promedio por km
margin = await self._calculate_average_margin()
kpis['avg_margin_per_km'] = {
'value': margin['current'],
'currency': 'EUR',
'trend': margin['trend'],
'vs_last_week': margin['weekly_change']
}
# KPI 4: Decisiones pendientes
pending = await self._count_pending_decisions()
kpis['pending_decisions'] = {
'count': pending['total'],
'urgent': pending['urgent'],
'avg_age_minutes': pending['avg_age']
}
# KPI 5: Balance geográfico
balance = await self._calculate_geographic_balance()
kpis['geographic_balance'] = {
'score': balance['score'], # 0-100
'imbalanced_zones': balance['problem_zones'],
'recommendation': balance['recommendation']
}
# Guardar en cache
self.redis_client.setex(
'fleet_kpis',
self.cache_ttl,
json.dumps(kpis)
)
return kpis
class WhatIfSimulator:
"""Motor de simulación para análisis de escenarios"""
def __init__(self):
self.current_state = FleetStateManager()
self.predictive_models = PredictiveModels()
def simulate_decision(self, vehicle_id: str,
decision: Decision) -> SimulationResult:
"""Simula el impacto de una decisión"""
# Clonar estado actual
simulated_state = copy.deepcopy(self.current_state.get())
# Aplicar decisión
simulated_state.apply_decision(vehicle_id, decision)
# Simular evolución temporal
timeline = []
for hour in range(72): # 72 horas futuras
# Evolucionar estado
simulated_state.advance_time(hours=1)
# Predecir demanda
demand = self.predictive_models.predict_demand(
simulated_state,
hour_offset=hour
)
# Calcular métricas
metrics = self._calculate_metrics(simulated_state, demand)
timeline.append({
'hour': hour,
'state': simulated_state.snapshot(),
'metrics': metrics,
'events': simulated_state.get_events()
})
# Análisis de resultados
result = SimulationResult()
result.timeline = timeline
result.total_value = sum(t['metrics']['value'] for t in timeline)
result.total_cost = sum(t['metrics']['cost'] for t in timeline)
result.empty_km = sum(t['metrics']['empty_km'] for t in timeline)
result.opportunities_captured = self._count_opportunities(timeline)
return result
def compare_scenarios(self, vehicle_id: str,
scenarios: List[Decision]) -> ComparisonResult:
"""Compara múltiples escenarios"""
results = []
for scenario in scenarios:
sim_result = self.simulate_decision(vehicle_id, scenario)
results.append({
'decision': scenario,
'result': sim_result,
'score': self._calculate_score(sim_result)
})
# Ordenar por score
results.sort(key=lambda x: x['score'], reverse=True)
# Crear comparación
comparison = ComparisonResult()
comparison.best_option = results[0]
comparison.alternatives = results[1:]
comparison.recommendation = self._generate_recommendation(results)
return comparison
// Componente principal de la calculadora
const WhatIfCalculator: React.FC = () => {
const [vehicle, setVehicle] = useState<Vehicle | null>(null);
const [scenarios, setScenarios] = useState<Scenario[]>([]);
const [results, setResults] = useState<SimulationResults | null>(null);
const [isCalculating, setIsCalculating] = useState(false);
const addScenario = (scenario: Scenario) => {
setScenarios([...scenarios, scenario]);
};
const runSimulation = async () => {
if (!vehicle || scenarios.length === 0) return;
setIsCalculating(true);
try {
const simulationRequest = {
vehicleId: vehicle.id,
currentPosition: vehicle.position,
scenarios: scenarios.map(s => ({
action: s.action,
destination: s.destination,
load: s.load,
timing: s.timing
}))
};
const response = await api.post('/simulator/compare', simulationRequest);
setResults(response.data);
} catch (error) {
showError('Error en simulación');
} finally {
setIsCalculating(false);
}
};
return (
<div className="what-if-calculator">
<Header>
<h2>Calculadora de Decisiones What-If</h2>
<VehicleSelector onSelect={setVehicle} />
</Header>
<ScenarioBuilder>
<CurrentSituation vehicle={vehicle} />
<ScenarioList
scenarios={scenarios}
onAdd={addScenario}
onRemove={handleRemoveScenario}
onEdit={handleEditScenario}
/>
<SimulationControls>
<TimeHorizonSlider
value={simulationHorizon}
onChange={setSimulationHorizon}
/>
<Button
onClick={runSimulation}
loading={isCalculating}
disabled={scenarios.length === 0}
>
Simular Escenarios
</Button>
</SimulationControls>
</ScenarioBuilder>
{results && (
<ResultsPanel>
<ComparisonChart scenarios={results.scenarios} />
<RecommendationCard recommendation={results.recommendation} />
<DetailedMetrics results={results} />
</ResultsPanel>
)}
</div>
);
};
// Componente de comparación visual
const ComparisonChart: React.FC<{ scenarios: ScenarioResult[] }> = ({ scenarios }) => {
const chartData = scenarios.map(scenario => ({
name: scenario.name,
valor: scenario.totalValue,
costoKmVacio: scenario.emptyKmCost,
margen: scenario.netMargin,
riesgo: scenario.riskScore
}));
return (
<div className="comparison-chart">
<h3>Comparación de Escenarios</h3>
<ResponsiveContainer width="100%" height={400}>
<RadarChart data={chartData}>
<PolarGrid strokeDasharray="3 3" />
<PolarAngleAxis dataKey="name" />
<PolarRadiusAxis angle={90} domain={[0, 100]} />
<Radar
name="Valor Total"
dataKey="valor"
stroke="#8884d8"
fill="#8884d8"
fillOpacity={0.6}
/>
<Radar
name="Margen Neto"
dataKey="margen"
stroke="#82ca9d"
fill="#82ca9d"
fillOpacity={0.6}
/>
<Radar
name="Riesgo"
dataKey="riesgo"
stroke="#ff7c7c"
fill="#ff7c7c"
fillOpacity={0.6}
/>
<Legend />
<Tooltip />
</RadarChart>
</ResponsiveContainer>
<MetricBars>
{scenarios.map(scenario => (
<ScenarioBar key={scenario.id}>
<Label>{scenario.name}</Label>
<ValueBar
value={scenario.totalValue}
max={Math.max(...scenarios.map(s => s.totalValue))}
color={scenario.isRecommended ? '#10b981' : '#6b7280'}
/>
<Value>€{scenario.totalValue.toFixed(0)}</Value>
</ScenarioBar>
))}
</MetricBars>
</div>
);
};
class AlertEngine:
"""Motor de procesamiento de alertas en tiempo real"""
def __init__(self):
self.rules = self._load_alert_rules()
self.notification_service = NotificationService()
self.alert_history = AlertHistory()
async def process_fleet_state(self, fleet_state: FleetState):
"""Procesa estado de flota y genera alertas"""
triggered_alerts = []
# Evaluar cada regla
for rule in self.rules:
if rule.is_active and rule.evaluate(fleet_state):
alert = Alert(
rule_id=rule.id,
severity=rule.severity,
type=rule.alert_type,
message=rule.format_message(fleet_state),
context=rule.extract_context(fleet_state),
timestamp=datetime.now()
)
# Verificar si no es duplicada
if not self.alert_history.is_duplicate(alert):
triggered_alerts.append(alert)
# Procesar alertas triggered
for alert in triggered_alerts:
await self._process_alert(alert)
async def _process_alert(self, alert: Alert):
"""Procesa una alerta individual"""
# Guardar en historial
self.alert_history.add(alert)
# Determinar destinatarios
recipients = self._determine_recipients(alert)
# Enviar notificaciones
for recipient in recipients:
await self.notification_service.send(
recipient=recipient,
alert=alert,
channel=self._select_channel(alert.severity, recipient)
)
# Si es crítica, escalar
if alert.severity == 'critical':
await self._escalate_alert(alert)
// Interfaz de configuración de alertas
const AlertConfiguration: React.FC = () => {
const [alertRules, setAlertRules] = useState<AlertRule[]>([]);
const [showBuilder, setShowBuilder] = useState(false);
return (
<div className="alert-configuration">
<Header>
<h2>Configuración de Alertas</h2>
<Button onClick={() => setShowBuilder(true)}>
Nueva Alerta
</Button>
</Header>
<AlertRulesList>
{alertRules.map(rule => (
<AlertRuleCard key={rule.id}>
<RuleHeader>
<RuleName>{rule.name}</RuleName>
<RuleStatus active={rule.isActive} />
</RuleHeader>
<RuleConditions>
<ConditionBadge>
{rule.triggerCondition.description}
</ConditionBadge>
</RuleConditions>
<RuleActions>
<Toggle
checked={rule.isActive}
onChange={(active) => handleToggleRule(rule.id, active)}
/>
<IconButton onClick={() => handleEditRule(rule.id)}>
<EditIcon />
</IconButton>
<IconButton onClick={() => handleDeleteRule(rule.id)}>
<DeleteIcon />
</IconButton>
</RuleActions>
</AlertRuleCard>
))}
</AlertRulesList>
{showBuilder && (
<AlertRuleBuilder
onSave={handleSaveRule}
onCancel={() => setShowBuilder(false)}
/>
)}
</div>
);
};
// Constructor de reglas de alerta
const AlertRuleBuilder: React.FC<AlertRuleBuilderProps> = ({ onSave, onCancel }) => {
const [rule, setRule] = useState<Partial<AlertRule>>({
name: '',
triggerCondition: {},
severity: 'medium',
notifications: {
channels: ['app'],
recipients: []
}
});
return (
<Modal title="Nueva Regla de Alerta">
<Form onSubmit={handleSubmit}>
<FormField label="Nombre de la regla">
<Input
value={rule.name}
onChange={(e) => setRule({...rule, name: e.target.value})}
placeholder="Ej: Demasiados camiones en zona"
/>
</FormField>
<FormField label="Condición de activación">
<ConditionBuilder
value={rule.triggerCondition}
onChange={(condition) => setRule({...rule, triggerCondition: condition})}
/>
</FormField>
<FormField label="Severidad">
<Select
value={rule.severity}
onChange={(severity) => setRule({...rule, severity})}
>
<Option value="low">Baja</Option>
<Option value="medium">Media</Option>
<Option value="high">Alta</Option>
<Option value="critical">Crítica</Option>
</Select>
</FormField>
<FormField label="Canales de notificación">
<CheckboxGroup
value={rule.notifications.channels}
onChange={(channels) => setRule({
...rule,
notifications: {...rule.notifications, channels}
})}
>
<Checkbox value="app">Aplicación</Checkbox>
<Checkbox value="email">Email</Checkbox>
<Checkbox value="sms">SMS</Checkbox>
<Checkbox value="webhook">Webhook</Checkbox>
</CheckboxGroup>
</FormField>
<FormActions>
<Button type="button" variant="secondary" onClick={onCancel}>
Cancelar
</Button>
<Button type="submit" variant="primary">
Crear Alerta
</Button>
</FormActions>
</Form>
</Modal>
);
};
class Phase2IntegrationTests:
"""Tests de integración para componentes de Fase 2"""
@pytest.mark.integration
async def test_dashboard_data_flow(self):
"""Verifica flujo completo de datos en dashboard"""
# Simular estado de flota
fleet_state = create_test_fleet_state()
# Actualizar base de datos
await update_fleet_positions(fleet_state)
# Llamar API de dashboard
response = await client.get('/api/dashboard/overview')
assert response.status_code == 200
data = response.json()
# Verificar KPIs calculados
assert 'empty_km_ratio' in data['kpis']
assert data['kpis']['empty_km_ratio']['value'] < 0.35
# Verificar datos de mapa
assert len(data['vehicles']) == fleet_state.vehicle_count
assert 'heatmap_data' in data
@pytest.mark.integration
async def test_calculator_simulation(self):
"""Prueba simulación what-if completa"""
# Crear escenarios de prueba
scenarios = [
create_wait_scenario(),
create_reposition_scenario('Barcelona'),
create_load_scenario('marginal_load')
]
# Ejecutar simulación
result = await simulator.compare_scenarios('TR-001', scenarios)
# Verificar resultados
assert result.best_option is not None
assert result.best_option['score'] > 0
assert len(result.alternatives) == 2
# Verificar recomendación
assert 'Barcelona' in result.recommendation.text
class PerformanceTests:
"""Tests de rendimiento para componentes críticos"""
@pytest.mark.performance
def test_dashboard_load_time(self):
"""Dashboard debe cargar en menos de 2 segundos"""
start_time = time.time()
# Simular carga de dashboard con 50 vehículos
response = client.get('/api/dashboard/overview?vehicles=50')
load_time = time.time() - start_time
assert response.status_code == 200
assert load_time < 2.0, f"Dashboard tardó {load_time}s en cargar"
@pytest.mark.performance
def test_calculator_response_time(self):
"""Calculadora debe responder en menos de 3 segundos"""
# Preparar request compleja
request = create_complex_simulation_request()
start_time = time.time()
response = client.post('/api/simulator/calculate', json=request)
response_time = time.time() - start_time
assert response.status_code == 200
assert response_time < 3.0, f"Simulación tardó {response_time}s"
┌─────────────────────────────────────────────────────────────┐
│ SIGA - CENTRO DE CONTROL │
├─────────────────────────────────────────────────────────────┤
│ KPIs Principales Actualizado: 14:32│
│ ┌─────────────┬─────────────┬─────────────┬─────────────┐ │
│ │ KM VACÍOS │ UTILIZACIÓN │ MARGEN/KM │ DECISIONES │ │
│ │ 23.5% ↓ │ 87% ↑ │ €1.24 ↑ │ 3 │ │
│ │ -8.5% mes │ +5% semana │ +€0.12 día │ 2 urgentes │ │
│ └─────────────┴─────────────┴─────────────┴─────────────┘ │
│ │
│ [MAPA INTERACTIVO CON 47 VEHÍCULOS Y ZONAS DE CALOR] │
│ │
│ Alertas Activas (2) │
│ ⚠️ Alta concentración en Barcelona (5 camiones) │
│ ⚠️ Zona Sevilla sin cobertura próximas 24h │
│ │
│ [Calculadora] [Histórico] [Configuración] [Exportar] │
└─────────────────────────────────────────────────────────────┘
# Ejemplo de uso de la calculadora
calculator_example = {
'vehicle': 'MAD-4521',
'current_position': 'Valencia',
'scenarios_evaluated': [
{
'name': 'Esperar carga local',
'value': 420,
'risk': 'medium',
'recommendation': 'No recomendado'
},
{
'name': 'Ir a Barcelona vacío',
'value': 850,
'risk': 'low',
'recommendation': 'RECOMENDADO'
},
{
'name': 'Carga marginal Castellón',
'value': 320,
'risk': 'low',
'recommendation': 'Viable si urgente'
}
],
'simulation_time': 1.8 # segundos
}
// Alertas configuradas en producción
const activeAlerts = [
{
id: 'alert-001',
name: 'Concentración excesiva zona',
condition: 'vehicles_in_zone > 4',
severity: 'high',
notifications: ['app', 'email'],
triggers_last_week: 12,
prevented_losses: '€3,200'
},
{
id: 'alert-002',
name: 'Ruta problemática viernes',
condition: 'route === "VAL-BCN" && day === 5 && hour > 14',
severity: 'medium',
notifications: ['app'],
triggers_last_week: 4,
prevented_losses: '€1,800'
},
{
id: 'alert-003',
name: 'Desequilibrio flota crítico',
condition: 'zone_imbalance_score > 0.7',
severity: 'critical',
notifications: ['app', 'email', 'sms'],
triggers_last_week: 2,
prevented_losses: '€2,400'
}
];
phase2_metrics = {
'dashboard': {
'usuarios_activos_diarios': 42, # de 45 totales
'sesiones_promedio_dia': 127,
'tiempo_promedio_sesion': '18:34',
'features_mas_usadas': ['mapa', 'kpis', 'alertas'],
'satisfaccion_usuarios': 4.3 # de 5
},
'calculadora': {
'simulaciones_semana': 89,
'escenarios_promedio': 3.2,
'decisiones_influenciadas': '67%',
'ahorro_por_decision_mejorada': '€340'
},
'alertas': {
'alertas_configuradas': 12,
'triggers_semanales': 78,
'false_positives': '8%',
'tiempo_respuesta_promedio': '4:23'
},
'impacto_negocio': {
'reduccion_km_vacios': '15.7%',
'mejora_margen_km': '€0.18',
'roi_fase_2': '187%',
'payback_period': '6 semanas'
}
}
Decisión: ✅ PROCEDER A FASE 3
⬅️ Volver a Implementación | ➡️ Siguiente: Fase 3 - Inteligencia