Servicios de plataforma¶
Esta página explica los servicios transversales de la plataforma: piezas que no pertenecen a un dominio de negocio concreto (mesas, comanda, pago…) sino que dan soporte horizontal a todos los módulos. Son el "fontanería" del sistema: medición de comportamiento, auditoría, registro de errores, observabilidad, idempotencia, jobs periódicos, integración con TPV, cableado de eventos e internacionalización.
El hilo conductor es la regla de arquitectura ADR-004: ningún módulo importa directamente otro módulo. La comunicación cruzada va siempre por puertos (interfaces en app/shared/application/) o por eventos de dominio. Estos servicios transversales son precisamente los puertos y los buses que hacen esa regla viable.
Dónde encaja esto
Para el modelo hexagonal general ver Arquitectura hexagonal. Para el bus de eventos en tiempo real hacia el cliente ver Tiempo real (SSE). Para las convenciones de código ver Convenciones.
Analytics: eventos de comportamiento¶
El módulo analytics (app/modules/analytics/) es el dueño de la capa de medición que alimenta al motor de recomendación. Cada hecho de comportamiento del comensal se anexa (append-only) a la tabla behavior_events.
Los tipos de evento están en el enum compartido BehaviorEventType (app/shared/domain/enums.py):
| Valor | Significado |
|---|---|
pedido |
Un producto se añadió al carrito/comanda. |
rec_mostrada |
El motor de recomendación mostró un producto. |
rec_aceptada |
Un producto mostrado se pidió dentro de la ventana de aceptación (~15 min). |
El puerto IBehaviorEventRecorder¶
Otros módulos (chat, comanda) no importan analytics. Emiten eventos a través del puerto compartido IBehaviorEventRecorder (app/shared/application/ports.py), implementado por SqlBehaviorEventRecorder (app/modules/analytics/infrastructure/adapters/behavior_event_recorder.py).
Características del puerto:
- Es best-effort: registrar nunca debe romper el flujo de negocio que lo emitió. El adaptador captura las excepciones y las loguea (
logger.exception) en vez de propagarlas. - Se enlaza a una
AsyncSessionconcreta en construcción. record_cart_add(...)registra unpedidoy, si ese producto fue mostrado para la misma sesión dentro de la ventana deREC_ACEPTANCE_WINDOW(15 minutos), también registra unrec_aceptada. Así se mide automáticamente la conversión de la recomendación.
El campo user_id de BehaviorEvent es nullable desde el día 0 (fase 0 no tiene identidad de comensal); cuando exista identidad, los eventos nuevos la llevarán y los antiguos podrán enlazarse retroactivamente.
Cómo obtener el recorder
Un consumidor llama a get_behavior_event_recorder(db), que devuelve la implementación registrada al arrancar. Ver Cableado más abajo.
El módulo también expone consultas de agregación de solo lectura sobre las tablas compartidas de pedidos/menú vía el puerto IAnalyticsRepository (overview, pedidos, ingresos, top productos), consumidas por los routers admin de analytics.
Audit: registro de acciones admin destructivas¶
El módulo audit (app/modules/audit/) es el dueño del audit trail: el registro inmutable de acciones administrativas (crear, actualizar, borrar) sobre recursos del restaurante.
El puerto IAuditTrailWriter¶
Como con analytics, los demás módulos escriben en el audit log a través del puerto compartido IAuditTrailWriter (app/shared/application/ports.py), nunca importando el módulo. La implementación es AuditTrailWriter (app/modules/audit/infrastructure/adapters/audit_trail_writer.py), que delega en RecordAuditLogUseCase.
Firma del puerto (log_action): db, admin_id, action, resource_type, resource_id, old_values, new_values, restaurant_id. Persiste una fila en audit_logs y hace commit a través del caso de uso.
El módulo modela el resultado de la acción con el enum AuditStatus (success / error) y soporta filtrado/listado/exportación con AuditLogQueryFilters.
Obligatorio en operaciones destructivas
Toda operación admin que muta o borra recursos debe dejar rastro de auditoría. Es una de las garantías de seguridad del modelo multi-tenant. Ver Seguridad y multitenancy y Usuarios y roles admin.
Errors: registro de errores de frontend¶
El módulo errors (app/modules/errors/) recibe reportes de error del frontend y los persiste para depuración en producción.
Aspectos clave:
- Deduplicación por hash de contenido.
LogErrorUseCase(app/modules/errors/application/use_cases/log_error.py) calcula unErrorHasha partir deerror_message+error_type+component. Si ya existe un reporte con ese hash, no inserta una fila nueva: incrementaoccurrence_county avanzalast_occurrence(ErrorReport.register_recurrence). Una fila por error distinto. - Sin autenticación para escribir.
POST /errors/logno requiere auth (para poder capturar errores de usuarios no autenticados); devuelve200 OKporque es un upsert, no una creación de recurso. El listadoGET /errors/recentsí requiere admin. - Severidad modelada con
ErrorSeverity(low/medium/high/critical), usada para el icono del resumen de log.
Endpoints en app/modules/errors/interface/router.py. Ver Referencia API.
Observabilidad y Request-ID¶
app/core/observability.py implementa la correlación de logs por petición.
-
RequestIDMiddlewarees un middleware ASGI puro (noBaseHTTPMiddleware) que:- Lee el header entrante
X-Request-ID, o genera uno corto (8 hex) si no viene. - Lo guarda en un
ContextVar(request_id_var) durante toda la petición. - Lo refleja de vuelta en el header
X-Request-IDde la respuesta.
Se implementa como ASGI puro a propósito: así el
ContextVarse fija en el contexto async exacto donde corre el endpoint, y el header de respuesta se inyecta envolviendosend. - Lee el header entrante
-
RequestIdFilteres un filtro de logging que estamparequest_iden cadaLogRecord.configure_logging()instala un handler raíz cuyo formato incluye[request_id], de modo que todas las líneas de log de una misma petición son correlacionables. -
El sentinela
"-"marca las líneas emitidas fuera de cualquier petición (arranque, apagado, tareas de fondo).
get_request_id() permite a cualquier punto del código leer el id actual.
Orden del middleware
RequestIDMiddleware se registra el último en main.py para ser la capa más externa: así ve cada petición antes que el resto de middlewares y puede correlacionar todo lo que ocurra dentro.
Idempotencia¶
app/shared/infrastructure/idempotency/ proporciona procesamiento at-most-once para escrituras POST reintentables, basado en el header Idempotency-Key.
El núcleo es IdempotencyStore (store.py), respaldado por una restricción única (scope, key) en Postgres, que es la fuente de verdad de la serialización. El flujo de un handler reintentable:
claim(scope, key)antes del caso de uso:- Si existe una fila con respuesta cacheada (dentro del TTL de 24 h), se devuelve y el handler corta en seco (replay).
- Si existe una fila reclamada pero sin respuesta todavía, se lanza
IdempotencyInProgress→ el handler responde409. - Si no existe, se inserta una nueva fila de claim vía
SAVEPOINT(begin_nested), de modo que un claim concurrente pierde en la restricción única y el segundo llamante re-lee la fila existente.
- El caso de uso corre su transacción normal.
complete(scope, key, status_code, body)escribe la respuesta sobre la fila de claim para que el siguiente reintento pueda reproducirla.
Dónde se aplica
Idempotency-Key se exige en los POST críticos: validar sesión, añadir al carrito y llamada al manager. Ver Sesiones y carrito y Convenciones.
Worker / cron: jobs periódicos¶
app/worker/runner.py es una CLI de worker para tareas periódicas. No hay scheduler in-process, a propósito: el agendado lo hace el host (cron / sidecar de compose), no la aplicación.
Uso:
El subcomando recompute-recs recalcula los artefactos de recomendación (co-ocurrencia + embeddings de producto) para un restaurante o para todos los que no estén borrados. Internamente instancia RecomputeArtifactsUseCase con sus puertos (repositorio de artefactos, gateway de histórico de tickets, catálogo de producto) y hace commit por restaurante.
Los imports dentro del subcomando son locales a propósito: así la CLI arranca rápido y el cableado de main.py permanece explícito.
Por qué sin scheduler interno
Mantener el agendado fuera del proceso evita acoplar el ciclo de vida de los jobs al de la API y permite escalar/operar el recálculo de forma independiente. El recálculo también puede dispararse on-demand vía POST /api/v1/admin/recommendations/recompute. Ver Recomendación.
Seam POS / TPV¶
app/shared/application/pos_port.py define el límite hacia un sistema externo de punto de venta (TPV): empujar menú y mesas, enviar un pedido, consultar su estado y solicitar una factura.
- El contrato es el ABC
POSAdapter, con DTOsfrozen(POSMenu,POSTable,POSOrder,POSBillRequest/POSBillResponse…) y el enumPOSOrderStatus(pending→accepted→preparing→ready→served/rejected). - La implementación por defecto es
LocalPOSAdapter(app/shared/infrastructure/pos/local_pos_adapter.py), que responde el contrato desde los propios datos de la plataforma, alcanzados solo a través de puertos existentes (get_room_directory,get_billing_gateway) más unmenu_providerinyectado en el composition root. Así este módulo compartido nunca importa un módulo de feature directamente (ADR-004). - Como no hay TPV externo todavía,
send_orderes un passthrough: devuelve el pedido comoACCEPTED(el pipeline de comanda es la vía real de fulfilment).
El registro es vía factory: register_pos_adapter(factory) al arrancar y get_pos_adapter(db) para obtener un adaptador ligado a la sesión. Cuando llegue un TPV real (Hiopos), un HioposPOSAdapter implementa el mismo ABC y reemplaza al local sin tocar a los consumidores.
Bus de eventos de dominio in-process vs bus SSE¶
La plataforma tiene dos buses distintos, con propósitos diferentes. Es importante no confundirlos.
Bus de eventos de dominio (in-process)¶
app/shared/domain/event_bus.py define DomainEventBus, un bus síncrono e in-process (§4.4 de la arquitectura hexagonal). Sirve para que un módulo reaccione a un hecho de negocio ocurrido en otro sin importarlo (ADR-004).
subscribe(event_type, handler)registra un handler por tipo de evento (clase). Los handlers pueden ser sync o async.publish(event)invoca todos los handlers del tipo del evento y luego reenvía a un backend opcional (IEventBusBackend, p. ej. Valkey pub/sub para fan-out entre pods).- Los errores de un handler se capturan, loguean y se tragan: un suscriptor que falla nunca aborta el caso de uso que publicó.
- Hay un singleton de módulo
event_bus.
Ejemplos de eventos: ComandaEmptied, PaymentConfirmed, CourseServed. Por ejemplo, el módulo comanda se suscribe a PaymentConfirmed (publicado por el módulo pago) para cerrar las comandas abiertas cuando se paga la cuenta — sin que pago conozca a comanda.
Eventos de dominio: publicar SIEMPRE después del commit
Por convención, los eventos de dominio se publican después del commit, nunca antes (ver Convenciones).
Bus SSE (tiempo real hacia el cliente)¶
app/shared/infrastructure/event_bus.py define EventService, un servicio in-memory de pub/sub por sesión que alimenta los streams SSE hacia el navegador del cliente (carrito actualizado, mensajes de chat, estado de comanda…). Usa colas asyncio, buffer de eventos pendientes para reconexión y un singleton accesible vía get_event_service().
| Bus de eventos de dominio | Bus SSE | |
|---|---|---|
| Fichero | app/shared/domain/event_bus.py |
app/shared/infrastructure/event_bus.py |
| Tipo | DomainEventBus |
EventService |
| Propósito | Comunicación entre módulos del backend | Empujar eventos al frontend en tiempo real |
| Claves | Por tipo de evento (clase) | Por session_token |
| Consumidor | Otros módulos (subscribers) | Cliente (navegador) vía text/event-stream |
El detalle del bus SSE está en Tiempo real (SSE) y el inventario de eventos en Eventos SSE.
Cableado de subscribers¶
app/core/event_wiring.py es la única ubicación autorizada para importar subscribers de varios módulos (excepción explícita a ADR-004). Expone wire_subscribers(), invocado en el startup del lifespan de main.py, que llama al register(event_bus) de cada módulo (session, chat, comanda…).
wire_subscribers() es idempotente mediante un guard _wired: como subscribe anexa handlers, volver a cablear (p. ej. startup del lifespan y un test que cablea el bus porque ASGITransport se salta el lifespan) dispararía cada handler N veces. El guard hace que la reinvocación sea un no-op contra el bus singleton compartido.
Cableado de puertos (composition root)¶
Todos los puertos transversales anteriores se resuelven mediante el patrón registrar / obtener: al arrancar, main.py (el composition root) registra una factory; en tiempo de ejecución, el consumidor obtiene la implementación ligada a su sesión.
Registros que ocurren en app/main.py:
register_behavior_event_recorder(lambda db: SqlBehaviorEventRecorder(db))register_audit_trail_writer(AuditTrailWriter)register_billing_gateway(lambda db: ComandaBillingGateway(db))register_room_directory(...)register_pos_adapter(lambda db: LocalPOSAdapter(db, _local_pos_menu_provider))configure_logging()yapp.add_middleware(RequestIDMiddleware)wire_subscribers()en el startup del lifespan
Cada get_*() lanza RuntimeError si no se ha registrado una factory, lo que convierte un olvido de cableado en un fallo de arranque ruidoso en vez de un error silencioso.
i18n: contenido multi-idioma¶
app/core/i18n.py da soporte a campos traducibles (nombres/descripciones de menú y producto), almacenados como dicts JSON con claves de código ISO 639-1:
Este diseño admite idiomas futuros sin cambios de esquema. Funciones:
get_translated(text_dict, lang_code, default_fallback="es")devuelve la traducción del idioma pedido; si falta, cae al idioma por defecto (es); si tampoco, al primer valor disponible. Tolera datos legacy en string plano yNone(devuelve"").set_translation(text_dict, lang_code, value)añade o actualiza una traducción, devolviendo el dict actualizado.supported_languages(text_dict)lista los códigos con traducción no vacía.
El idioma por defecto es DEFAULT_LANGUAGE = "es".
Note
En los schemas Pydantic, los campos i18n se resuelven con field_validator(mode="before"). Ver Catálogo de menú y Convenciones.