Saltar a contenido

Control de sala (manager)

El control de sala es el dashboard en tiempo real con el que el responsable (manager) vigila todas las mesas activas de un restaurante: en qué punto del servicio está cada una, cuáles llevan demasiado tiempo esperando comida y cuáles llevan rato calladas. Vive en el módulo app/modules/manager/ y es un contexto acotado (bounded context) de la arquitectura hexagonal.

Esta página explica el diseño: por qué existe, qué piezas lo componen y cómo encajan. Para el detalle de cada endpoint consulta la referencia de la API; para los principios transversales, la arquitectura hexagonal.

Idea central

El módulo manager no posee ORM propio. Lee y escribe el estado de otros módulos (sesiones, comandas) exclusivamente a través de puertos compartidos (ADR-004). Es un orquestador de sala, no un dueño de datos.

En tiempo real con SSE tipado (no WebSocket)

El feed en vivo de la tablet de sala usa Server-Sent Events (SSE), no WebSocket. La decisión es deliberada: el flujo es unidireccional (servidor → tablet), y SSE es más simple de operar (HTTP plano, reconexión nativa del navegador) que un WebSocket bidireccional.

"Tipado" significa que cada evento que viaja por el bus tiene un DTO frozen dedicado en app/modules/manager/domain/dtos.py que define exactamente la forma del payload. No hay dicts ad hoc: el código construye TableStateChanged, SilentTableAlert, etc., y solo en el último momento los serializa con to_dict().

El bus se abstrae tras el puerto IManagerRealtimeBus (domain/ports.py), con tres operaciones:

Operación Qué hace
publish(topic, event_type, data) Publica un evento a todos los suscriptores de topic.
subscribe(topic, timeout) Genera eventos del topic según llegan, con timeout por ciclo.
format(event_type, data) Devuelve la trama SSE data: {...}\n\n.

Forma de la trama

El data: serializado contiene un envoltorio {type, data, timestamp}: type es el nombre del evento (p. ej. table_state_changed), data el payload del DTO y timestamp el instante de emisión. Es exactamente lo que el frontend espera (sse-types.ts).

La implementación concreta EventServiceManagerBus (infrastructure/adapters/realtime_bus.py) es una fachada fina sobre el EventService compartido, resuelto perezosamente en cada llamada para que los monkeypatches de tests sigan siendo efectivos.

Los topics se calculan con manager_topic(restaurant_id), que devuelve manager:{id}. Cada restaurante tiene su propio canal de sala.

La máquina de estados de la mesa

El corazón del módulo es TableLifecycleState, la máquina de estados por la que avanza cada mesa durante un servicio. Está codificada como dominio puro en app/modules/manager/domain/lifecycle.py (sin ORM, HTTP ni frameworks).

Estados y transiciones

READING ─┬─> DRINKS_ORDERED ─┬─> DRINKS_DELIVERED ─┐
         │                    └─> FOOD_ORDERED ─────┤
         ├─> FOOD_ORDERED                           │
         └─> BILL_REQUESTED                         │
FOOD_ORDERED ─┬─> DELAY_ALERT ─> FOOD_DELIVERED ────┤
              ├─> FOOD_DELIVERED                     │
              └─> BILL_REQUESTED                     │
(cualquiera) ─> BILL_REQUESTED ─> CLOSED  <──────────┘

El flujo nominal es:

READING → DRINKS_ORDERED → DRINKS_DELIVERED → FOOD_ORDERED → (DELAY_ALERT) → FOOD_DELIVERED → BILL_REQUESTED → CLOSED

Las transiciones válidas se declaran en el dict TRANSITIONS. Cada estado puede saltar directamente a BILL_REQUESTED (el cliente puede pedir la cuenta en cualquier momento), y DELAY_ALERT es un desvío opcional desde FOOD_ORDERED.

Sellos de tiempo (milestones)

Entrar en ciertos estados estampa un timestamp de hito, mapeado en TIMESTAMP_FIELD:

Estado Campo de timestamp
DRINKS_ORDERED drinks_ordered_at
DRINKS_DELIVERED drinks_delivered_at
FOOD_ORDERED food_ordered_at
FOOD_DELIVERED food_delivered_at
BILL_REQUESTED bill_requested_at

READING, DELAY_ALERT y CLOSED no estampan ninguno (None).

Idempotencia

Las transiciones son idempotentes

Pedir mover una mesa a un estado no alcanzable desde el actual es un no-op, nunca un error. La función can_transition(current, target) devuelve False y el caso de uso no hace nada (changed=False). Esto es lo que permite reconciliar por polling sin miedo a aplicar la misma transición dos veces.

El dominio expone dos funciones puras:

  • can_transition(current, target) -> bool
  • timestamp_field_for(target) -> str | None

La aplicación de la transición + difusión vive en _transition_and_broadcast (application/use_cases.py): valida con can_transition, escribe el estado y el timestamp vía IManagerSessionGateway.apply_lifecycle, y publica dos eventos — table_state_changed en el canal de sala (manager:{id}) y lifecycle_state_changed en el canal del cliente (su session_token, que el chat consume). El parámetro at permite sobrescribir el sello de tiempo con el instante real del pedido durante la reconciliación; por defecto es "ahora".

Casos de uso que aplican transiciones

Caso de uso Disparador Comportamiento
MarkDeliveryUseCase Manager confirma entrega (REST) Mapea delivery_type a DRINKS_DELIVERED/FOOD_DELIVERED; 404 si la sesión no existe.
TransitionLifecycleUseCase Transición genérica (Fase 2, dirigida por eventos) Mueve a un estado arbitrario válido; devuelve False (no-op, sin error) si la sesión no existe o la transición no es alcanzable.
CreateCourtesyRoundUseCase Manager regala una ronda Crea la comanda de cortesía y emite courtesy_round (no aplica transición de ciclo de vida).

Reconciliación por polling, no por eventos

Hay dos clases de transición:

  1. Dirigidas por el manager: el responsable confirma una entrega desde la tablet (DRINKS_DELIVERED, FOOD_DELIVERED). Se aplican de inmediato vía endpoint REST.
  2. Dirigidas por el pedido (DRINKS_ORDERED, FOOD_ORDERED): NO se disparan por eventos de dominio. Se reconcilian por polling.

Por qué reconciliación y no eventos

En lugar de escuchar eventos de "comanda creada", el módulo lee periódicamente las comandas dirigidas a cocina (kitchen-bound) a través del puerto IManagerOrderGateway.get_order_summaries(restaurant_id). Cada OrderSummary indica si la mesa ya tiene bebidas (has_drinks, first_drinks_at) o comida (has_food, first_food_at). La función _reconcile_one aplica DRINKS_ORDERED y luego FOOD_ORDERED en secuencia, usando el instante real del pedido como timestamp (at=...). Como las transiciones son idempotentes, repetir la reconciliación es seguro.

La reconciliación ocurre en dos sitios:

  • Bajo demanda, al construir el snapshot (GetActiveTablesSnapshotUseCase), para que el dashboard esté al día sin esperar al poll.
  • De fondo, en la tarea periódica (ver abajo).

La tarea de fondo (cada 60 s)

run_lifecycle_background_task (interface/background.py) corre un bucle desde el lifespan de la app, cada BACKGROUND_POLL_SECONDS = 60. En cada ciclo (_check_all_sessions), para cada sesión activa:

  1. Reconcilia el estado del pedido desde las comandas de cocina (_reconcile_one).
  2. Escala a DELAY_ALERT las mesas en FOOD_ORDERED que llevan esperando más de FOOD_DELAY_MINUTES = 20 minutos desde food_ordered_at (_check_food_delay).
  3. Emite silent_table_alert para las mesas en DRINKS_DELIVERED cuya última actividad (last_activity_at) es de hace más de SILENT_TABLE_MINUTES = 15 minutos (_check_silent_table).

Resiliencia del bucle

El bucle de fondo nunca debe morir: captura Exception y lo registra (logger.exception), continuando con el siguiente ciclo. Solo asyncio.CancelledError lo detiene (parada limpia de la app). Cada ciclo abre su propia sesión de BD.

Flag de cocina saturada (in-memory)

El manager puede marcar la cocina como saturada desde la tablet. Este estado se gestiona vía IKitchenLoadGateway, con implementación InMemoryKitchenLoadGateway (infrastructure/adapters/kitchen_load.py).

Por qué in-memory / por proceso

El flag es in-memory y por proceso por diseño: se resetea al reiniciar, lo que encaja con una herramienta basada en turnos. Es un singleton de proceso (instance()). Si en el futuro se necesitara persistencia, se guardaría en el modelo Restaurant.

La API expone:

  • set_overloaded(restaurant_id, overloaded, note) — activa o limpia el flag. Al activar, sella since con el instante UTC; al limpiar, elimina la entrada.
  • get_status(restaurant_id) -> KitchenLoadStatus — {overloaded, note, since}. Cuando no hay flag, devuelve el estado por defecto {overloaded=False, note="", since=""}.
  • is_overloaded(restaurant_id) -> bool.

Perfil de mesa (profiling)

detect_profile (domain/profiling.py) infiere el "modo" de la mesa a partir de la hora de sentarse (seated_at) y la rapidez del primer pedido:

Perfil Condición
executive_lunch Sentados antes de las 16:00 y primer pedido en ≤ 8 min.
social_leisure Sentados después de las 19:00, o primer pedido tardó > 15 min.
None Señal insuficiente todavía (no se fija perfil).

El perfil se detecta y persiste de forma perezosa al construir el snapshot, solo si la sesión aún no tiene uno (GetActiveTablesSnapshotUseCase._maybe_profile). Alimenta el ritmo y tono del asistente de chat (Fase 5).

Endpoints

Todos los endpoints REST de admin requieren autenticación de admin y propiedad del restaurante (require_restaurant_access). Router en interface/admin_manager_router.py (prefijo /manager).

REST de administración

Método Ruta Función
GET /manager/restaurants/{restaurant_id}/sessions Snapshot de todas las mesas activas.
POST /manager/sessions/{session_token}/deliver Marcar bebidas/comida entregadas.
POST /manager/sessions/{session_token}/courtesy-round Regalar una ronda de cortesía (comanda 0 € SERVED). product_ids requiere ≥ 1 elemento.
GET /manager/restaurants/{restaurant_id}/kitchen/overload Leer el flag de cocina saturada.
POST /manager/restaurants/{restaurant_id}/kitchen/overload Activar/limpiar el flag de cocina saturada.

Feed SSE de sala

Método Ruta Función
GET /manager/restaurants/{restaurant_id}/events Feed SSE en tiempo real del canal manager:{id}.

Autenticación del SSE

EventSource del navegador no puede enviar cabeceras, así que el endpoint de eventos autentica vía query param ?token= (JWT de admin). Un token inválido o caducado devuelve 401.

Errores de la ronda de cortesía

Si algún product_id no existe, CreateCourtesyRoundUseCase lanza CourtesyRoundError y el router lo traduce a 404 (cuando hay missing_ids) o 422 (resto de casos). El producto debe pertenecer al restaurante de la sesión. La nota por defecto del evento al cliente es "¡Cortesía del restaurante!".

El feed emite primero una trama sala_snapshot (con {sessions: [...]}, estado inicial de todas las mesas) y luego, en vivo:

  • table_state_changed — una mesa cambió de estado (payload TableStateChanged: snapshot aplanado + previous_state).
  • silent_table_alert — una mesa lleva > 15 min en silencio (payload SilentTableAlert: session_id, table_id, session_token, minutes_silent, message).

El generador abre una sesión de BD fresca solo para el snapshot inicial (no mantiene abierta la de la request durante todo el stream), se suscribe al topic con SSE_SUBSCRIBE_TIMEOUT_SECONDS = 30.0 por ciclo y corta limpiamente al detectar request.is_disconnected() o CancelledError.

Cada cliente individual recibe además, en su propio canal (session_token):

  • lifecycle_state_changed (payload LifecycleStateChanged: state, session_id) — lo consume el chat para reaccionar (p. ej. a DELAY_ALERT).
  • courtesy_round (payload CourtesyRoundNotice: items, note).

Fase 5: el manager alimenta el prompt del chat

Dos señales del control de sala cruzan al asistente conversacional mediante puertos compartidos (ADR-004), sin imports cross-module:

  • kitchen_overloaded — cuando la cocina está saturada, el chat orienta al cliente hacia platos rápidos/fríos.
  • table_profile (executive_lunch / social_leisure) — ajusta ritmo y tono del asistente.

ADR-004 en acción

El módulo manager no importa nada del módulo chat ni viceversa. La comunicación es solo por puertos definidos en app/shared/application/ports.py (IManagerSessionGateway, IManagerOrderGateway, IManagerCourtesyGateway, IKitchenLoadGateway, ITableDirectory) y por el bus de eventos. Por eso el módulo no posee ORM: todo entra y sale por contratos. Ver arquitectura hexagonal.

Gap conocido: pausa por llamada al manager

En el legacy, llamar al manager pausaba la sesión (is_paused, pause_reason="manager_called") y eso bloqueaba crear/enviar comandas. Durante la migración hexagonal ese bloqueo se perdió: el flag is_paused se seguía escribiendo, pero nadie lo leía para bloquear pedidos.

Se restauró (2026-06-16), devolviendo 409 Conflict con un detalle que menciona "manager", en los dos puntos de "pedir" actuales:

  • Carrito (session/interface/cart_router.py): guarda _raise_if_session_paused en POST /sessions/{token}/cart/items.
  • Submit de comanda (comanda/interface/customer_router.py): comprueba la pausa vía el puerto compartido get_session_cart_gateway(db).get_session(...).is_paused. Para ello se añadió is_paused a SessionSummary.

Detalle completo en backend/docs/findings/2026-06-15-pause-blocking-gap.md.