Saltar a contenido

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 AsyncSession concreta en construcción.
  • record_cart_add(...) registra un pedido y, si ese producto fue mostrado para la misma sesión dentro de la ventana de REC_ACEPTANCE_WINDOW (15 minutos), también registra un rec_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 un ErrorHash a partir de error_message + error_type + component. Si ya existe un reporte con ese hash, no inserta una fila nueva: incrementa occurrence_count y avanza last_occurrence (ErrorReport.register_recurrence). Una fila por error distinto.
  • Sin autenticación para escribir. POST /errors/log no requiere auth (para poder capturar errores de usuarios no autenticados); devuelve 200 OK porque es un upsert, no una creación de recurso. El listado GET /errors/recent sí 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.

  • RequestIDMiddleware es un middleware ASGI puro (no BaseHTTPMiddleware) que:

    1. Lee el header entrante X-Request-ID, o genera uno corto (8 hex) si no viene.
    2. Lo guarda en un ContextVar (request_id_var) durante toda la petición.
    3. Lo refleja de vuelta en el header X-Request-ID de la respuesta.

    Se implementa como ASGI puro a propósito: así el ContextVar se fija en el contexto async exacto donde corre el endpoint, y el header de respuesta se inyecta envolviendo send.

  • RequestIdFilter es un filtro de logging que estampa request_id en cada LogRecord. 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:

  1. 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 responde 409.
    • 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.
  2. El caso de uso corre su transacción normal.
  3. 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:

python -m app.worker.runner recompute-recs [--restaurant-id N]

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 DTOs frozen (POSMenu, POSTable, POSOrder, POSBillRequest/POSBillResponse…) y el enum POSOrderStatus (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 un menu_provider inyectado 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_order es un passthrough: devuelve el pedido como ACCEPTED (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() y app.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:

{"es": "Patatas Bravas", "en": "Bravas", "fr": "Pommes de terre bravas"}

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 y None (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.


Véase también