Files
hackathon-acapulquitos-boys…/server/app/services/simulador.py

215 lines
7.7 KiB
Python

"""
Simulador de Ruta — Módulo B
Avanza `truck_status.current_position_id` cada tick.
Antes de cada push verifica `notification_preferences` del usuario.
Nunca envía coordenadas al cliente — solo ETA + mensaje.
"""
import asyncio
import logging
from datetime import datetime
from typing import Optional
from app.core.config import settings
from app.data.repositories.ruta_repository import SupabaseRutaRepository
from app.domain.entities.ruta import EstadoCamion, TruckStatus, TipoNotificacion
from app.services.ws_manager import ws_manager
logger = logging.getLogger(__name__)
class SimuladorRuta:
def __init__(self, route_id: str, tick_segundos: int = None):
self.route_id = route_id
self.tick = tick_segundos or settings.sim_tick_seconds
self.repo = SupabaseRutaRepository()
self._tarea: Optional[asyncio.Task] = None
self._corriendo = False
# ── Control ────────────────────────────────────────────────────────
def iniciar(self) -> None:
if self._corriendo:
return
self._corriendo = True
self._tarea = asyncio.create_task(self._loop())
logger.info(f"[SIM] {self.route_id} iniciada")
def detener(self) -> None:
self._corriendo = False
if self._tarea:
self._tarea.cancel()
async def forzar_averia(self, mensaje: str = "Falla mecánica reportada.") -> None:
"""Endpoint /alerts/breakdown llama esto."""
ts = self.repo.obtener_truck_status(self.route_id)
if not ts:
return
ts.status = EstadoCamion.AVERIADA
ts.last_update = datetime.utcnow()
self.repo.guardar_truck_status(ts)
self.detener()
await self._broadcast_a_usuarios(
tipo=TipoNotificacion.FALLA_MECANICA,
eta_minutos=None,
mensaje=mensaje,
preferencia_key="notify_breakdown",
)
logger.warning(f"[SIM] Avería registrada en {self.route_id}")
async def forzar_retraso(self, mensaje: str = "El camión reportó un retraso.") -> None:
ts = self.repo.obtener_truck_status(self.route_id)
if not ts:
return
ts.status = EstadoCamion.RETRASADA
ts.last_update = datetime.utcnow()
self.repo.guardar_truck_status(ts)
await self._broadcast_a_usuarios(
tipo=TipoNotificacion.RUTA_TARDE,
eta_minutos=None,
mensaje=mensaje,
preferencia_key="notify_delay",
)
# ── Loop principal ─────────────────────────────────────────────────
async def _loop(self) -> None:
ruta = self.repo.obtener_ruta(self.route_id)
if not ruta or not ruta.puntos:
logger.error(f"[SIM] Ruta {self.route_id} sin puntos")
return
# Inicializar truck_status en posición 1
ts = TruckStatus(
route_id=self.route_id,
current_position_id=1,
last_update=datetime.utcnow(),
status=EstadoCamion.EN_RUTA,
)
self.repo.guardar_truck_status(ts)
await self._broadcast_a_usuarios(
tipo=TipoNotificacion.RUTA_INICIADA,
eta_minutos=ruta.puntos[-1].tiempo_estimado_min,
mensaje="El camión ha iniciado su ruta. Prepárate.",
preferencia_key="notify_route_start",
)
umbral = settings.sim_eta_alert_minutes
ultimo_punto = ruta.puntos[-1]
for punto in ruta.puntos[1:]:
if not self._corriendo:
break
await asyncio.sleep(self.tick)
eta = max(0, ultimo_punto.tiempo_estimado_min - punto.tiempo_estimado_min)
# Detectar umbral de proximidad
if eta <= umbral and ts.status == EstadoCamion.EN_RUTA:
ts.status = EstadoCamion.APROXIMANDOSE
tipo = TipoNotificacion.APROXIMANDOSE
pref_key = "notify_proximity"
msg = (
f"El camión llega en ~{eta} minutos. "
"Saca tu basura ahora."
)
else:
tipo = TipoNotificacion.RUTA_INICIADA
pref_key = "notify_route_start"
msg = f"El camión está en camino. Llegada estimada en ~{eta} min."
ts.current_position_id = punto.orden
ts.last_update = datetime.utcnow()
self.repo.guardar_truck_status(ts)
await self._broadcast_a_usuarios(
tipo=tipo,
eta_minutos=eta,
mensaje=msg,
preferencia_key=pref_key,
)
logger.info(f"[SIM] Pos {punto.orden} | ETA {eta} min | {ts.status}")
if self._corriendo:
ts.status = EstadoCamion.COMPLETADO
ts.last_update = datetime.utcnow()
self.repo.guardar_truck_status(ts)
await self._broadcast_a_usuarios(
tipo=TipoNotificacion.COMPLETADO,
eta_minutos=0,
mensaje="El camión completó su paso. ¡Hasta mañana!",
preferencia_key=None, # completado siempre se notifica
)
self._corriendo = False
logger.info(f"[SIM] {self.route_id} completada")
# ── Broadcast respetando preferencias ─────────────────────────────
async def _broadcast_a_usuarios(
self,
tipo: TipoNotificacion,
eta_minutos: Optional[int],
mensaje: str,
preferencia_key: Optional[str],
) -> None:
"""
Por cada domicilio en la ruta:
1. Consulta las preferencias del usuario.
2. Solo envía si la preferencia está activa.
3. Persiste la notificación en BD.
4. Empuja por WebSocket al address_id correspondiente.
"""
template = self.repo.obtener_template(tipo.value)
if template and "{eta}" in template["body"]:
mensaje = template["body"].replace("{eta}", str(eta_minutos or "?"))
usuarios = self.repo.obtener_usuarios_por_ruta(self.route_id)
for u in usuarios:
user_id = u["user_id"]
address_id = u["address_id"]
# Verificar preferencia
if preferencia_key:
prefs = self.repo.obtener_preferencias(user_id)
if not getattr(prefs, preferencia_key, True):
logger.debug(
f"[SIM] Usuario {user_id} desactivó {preferencia_key}, skip"
)
continue
payload = {
"tipo": tipo.value,
"address_id": address_id,
"eta_minutos": eta_minutos,
"mensaje": mensaje,
"hora_utc": datetime.utcnow().isoformat(),
}
# Push WebSocket — el cliente escucha en /ws/{address_id}
await ws_manager.broadcast_zona(str(address_id), payload)
# Persistir
self.repo.guardar_notificacion(
tipo=tipo.value,
route_id=self.route_id,
address_id=address_id,
mensaje=mensaje,
eta_minutos=eta_minutos,
)
# ── Registro global ────────────────────────────────────────────────────
_simuladores: dict[str, SimuladorRuta] = {}
def obtener_simulador(route_id: str) -> SimuladorRuta:
if route_id not in _simuladores:
_simuladores[route_id] = SimuladorRuta(route_id)
return _simuladores[route_id]