Sesiones y carrito¶
Esta página explica cómo funcionan las sesiones de mesa (TableSession) y el carrito
compartido en camarero-ia: qué significan sus tiempos, cómo se calculan los precios de línea,
qué reglas de negocio bloquean operaciones y por qué. Está orientada a entender el porqué de las
decisiones, no a listar exhaustivamente la API (para eso, ver el contrato OpenAPI).
Términos clave:
- Sesión de mesa (
TableSession): unidad que representa a los comensales sentados en una mesa durante una visita. Se abre al escanear el QR y se identifica por unsession_token. - Carrito compartido: lista de líneas (productos) asociada a la sesión. Es único por sesión, de modo que todos los comensales de la mesa ven y editan el mismo carrito.
Fuentes de esta página: app/modules/session/interface/sessions_router.py,
app/modules/session/interface/cart_router.py,
app/modules/session/interface/schemas.py,
app/modules/session/domain/constants.py,
app/modules/session/infrastructure/models/session_model.py,
app/modules/session/infrastructure/repositories/cart_repository.py.
La sesión de mesa (QR)¶
El flujo de entrada es: el comensal escanea un QR → el frontend valida el JWT del QR →
se crea (o reutiliza) una TableSession.
Ciclo de vida de la entrada¶
POST /sessions/peek— valida el JWT del QR y responde si la mesa ya tiene una sesión activa, sin crear ninguna. La landing del cliente lo llama al montar para decidir entre reincorporarse en silencio (hay sesión activa) o mostrar los modales de idioma y número de comensales antes de validar. Sin estepeek, llamar a/validatedirectamente crearía la sesión con valores por defecto antes de que los comensales eligieran los correctos.POST /sessions/validate— valida el QR (JWT) y crea o reutiliza la sesión, devolviendosession_token,restaurant_id,table_id,table_name,expires_at,is_new_session, idioma,guest_countyversion. Un QR inválido o expirado devuelve 404; un conflicto de creación concurrente (SessionErrorVO.CONCURRENT_CONFLICT) devuelve 503 Service Unavailable ("Temporary conflict. Please try again.").
Idempotencia natural de validate
Una llamada repetida con el mismo JWT del QR reutiliza la sesión activa (vía
TableSessionRepository.get_or_create_for_qr) y devuelve el mismo session_token con
is_new_session=False. Por eso un reintento del cliente tras un timeout de red no crea una
segunda sesión y no necesita cabecera Idempotency-Key.
SESSION_TIMEOUT_MINUTES = 600 no es inactividad¶
La constante SESSION_TIMEOUT_MINUTES = 600 (10 horas, en app/modules/session/domain/constants.py)
es la ventana de jornada completa del restaurante (apertura → cierre), no un timeout de
inactividad.
No confundir con timeout de inactividad
El valor de 600 minutos existe para que una mesa que pasa toda la comida o cena sin recargar la app no vea su sesión expirar. No mide el tiempo desde la última interacción. No es un bug ni un valor "demasiado alto": es deliberado y se replica a propósito en el dominio para no introducir drift respecto al legacy.
POST /sessions/{token}/refresh— renueva la expiración de la sesión. El cliente la llama periódicamente para mantenerla viva mientras el comensal usa la app.POST /sessions/{token}/invalidate— finaliza la sesión (p.ej. cuando los comensales se van o piden la cuenta).
Número de comensales¶
PATCH /sessions/{token}/guest-count— cambia el número de comensales tras el escaneo inicial. El cuerpo lleva el campocount(UpdateGuestCountRequest, validado1 ≤ count ≤ 50).
Rareza heredada: 404 en vez de 422
Un count fuera de rango devuelve 404, no 422 (comportamiento bloqueado por
tests/session/e2e/test_session_lifecycle.py). Es una peculiaridad heredada que se conserva
a propósito.
Pausa y llamada al manager¶
POST /sessions/{token}/call-manager— crea una llamada al manager pendiente y pausa la sesión (is_paused,pause_reason,paused_at). Devuelve 429 si ya hay una llamada pendiente dentro de la ventana de cooldown (5 min,CALL_COOLDOWN_MINUTES), 400 si la sesión ya está pausada y 410 si la sesión expiró.POST /sessions/{token}/dismiss-manager-call— el cliente descarta su llamada y despausa la sesión.
El efecto de la pausa sobre el carrito se explica en Bloqueo de pedidos con sesión pausada.
Campos de ciclo de vida de mesa (control de sala)¶
Los campos del ciclo de vida operativo de la mesa viven en la propia sesión
(session_model.py), no en una entidad aparte. Son los que alimentan el dashboard de "control de
sala" del manager:
lifecycle_state(máquina de estados de la mesa) ylifecycle_note.- Marcas de tiempo:
seated_at,drinks_ordered_at,drinks_delivered_at,food_ordered_at,food_delivered_at,bill_requested_at. table_profile.
Optimistic locking¶
TableSession lleva una columna version (entero, default 0) para optimistic locking: las
escrituras concurrentes sobre la misma sesión se detectan por versión y un conflicto se traduce en
409 Conflict (OptimisticLockError). El mismo patrón se aplica a la comanda. El campo version
viaja en las respuestas de sesión para que el cliente pueda razonar sobre el estado.
El carrito compartido¶
El carrito es uno por sesión y se opera vía endpoints bajo /sessions/{token}/cart:
| Operación | Endpoint | Notas |
|---|---|---|
| Ver carrito | GET /sessions/{token}/cart |
Devuelve líneas, total y item_count (céntimos). |
| Añadir línea | POST /sessions/{token}/cart/items |
201; acepta Idempotency-Key. |
| Cambiar cantidad | PATCH /sessions/{token}/cart/items/{item_id} |
|
| Borrar línea | DELETE /sessions/{token}/cart/items/{item_id} |
204. |
Todas las mutaciones publican un evento cart_updated vía SSE para que el resto de comensales de la
mesa vean el carrito actualizado en tiempo real.
Precios en céntimos
Todos los importes (price, total_price, total) están en céntimos (enteros). 12,50 € se
representa como 1250.
El precio de línea incluye los extras¶
El precio unitario de una línea se resuelve del producto en el momento de añadir (no se confía en ningún precio enviado por el cliente) e incluye el precio de los extras seleccionados.
En cart_repository, al añadir una línea se parte de product.price y se le suma el total de los
extras vía el helper _extras_price_total, que suma los precios (en céntimos) de los ProductExtra
seleccionados que pertenezcan al producto (los extras ajenos al producto se ignoran de forma
defensiva). El resultado es el price (unitario) de la línea, y total_price = price * quantity.
Por qué importa
Esto significa que un mismo producto con distintos extras puede tener un price de línea
distinto. El total del carrito refleja los extras. Esta decisión fue explícita del owner; antes
el carrito hexagonal guardaba los extras como lista de ids sin sumar su precio a la línea
(ver docs/findings/2026-06-15-cart-extras-pricing-gap.md, resuelto).
Tope acumulado por producto: MAX_CART_QTY = 100¶
La cantidad acumulada de un mismo producto en el carrito no puede superar MAX_CART_QTY = 100.
Si añadir más líneas (o subir la cantidad de una existente) haría que el total acumulado del producto
superase 100, la operación se rechaza con 422 Unprocessable Entity (CartQuantityExceededError).
El tope se aplica también en el upsert de PostgreSQL
El "añadir" de carrito es un upsert: si el producto ya está, se suma la cantidad a la línea
existente. En producción esto usa ON CONFLICT DO UPDATE de PostgreSQL, que originalmente se
saltaba la comprobación de cantidad (esta solo se aplicaba en el camino de fallback no-Postgres).
Hoy el cap se enforce también dentro de _upsert_add_postgresql con una pre-comprobación de
la cantidad acumulada, de modo que el camino de producción respeta el tope. Ver
docs/findings/2026-06-15-cart-extras-pricing-gap.md.
Bloqueo de pedidos con sesión pausada¶
Cuando la sesión está pausada porque se ha llamado al manager, añadir al carrito está
bloqueado: POST /sessions/{token}/cart/items devuelve 409 Conflict con un detalle que
menciona "manager".
El router comprueba la pausa con el helper _raise_if_session_paused, llamado justo después de
verificar que la sesión está activa. El mismo bloqueo se aplica en el punto de "enviar comanda"
(submit, comanda/interface/customer_router.py::submit_for_review), que consulta is_paused vía
el puerto compartido get_session_cart_gateway(db).get_session(...) (ADR-004, sin import
cross-module; por eso is_paused se añadió a SessionSummary en app/shared/application/ports.py).
El bloqueo es solo en los dos puntos de 'pedir'
Solo se bloquean añadir al carrito (POST .../cart/items) y enviar comanda (submit).
Cambiar cantidad (PATCH) y borrar línea (DELETE) no comprueban la pausa: el comensal
puede corregir su carrito mientras espera al manager, pero no puede pedir más ni cursar.
Por qué el bloqueo es a nivel de API
En la arquitectura hexagonal, durante un tiempo el flag is_paused se escribía (la llamada
al manager lo ponía) pero nadie lo leía para bloquear pedidos; el único freno era el prompt
del chat (una instrucción al LLM). El owner pidió restaurar el bloqueo duro en la API, en los dos
puntos donde se "pide" (carrito y submit de comanda). Detalle en
docs/findings/2026-06-15-pause-blocking-gap.md.
Idempotencia al añadir¶
El endpoint de añadir acepta una cabecera Idempotency-Key (string opaco; recomendado un UUID v4
por petición lógica). Su propósito:
- Si la clave ya se vio y la operación se completó, se replica la primera respuesta (status + body) en vez de re-ejecutar el caso de uso → un reintento del cliente tras un timeout de red no duplica la subida de cantidad.
- Llamadas concurrentes con la misma clave reciben 409 ("Request already in progress").
La clave se scopea por session_token (scope = f"cart_add:{session_token}"), de modo que dos
mesas distintas no pueden colisionar usando el mismo string opaco.
Códigos de error al añadir¶
El caso de uso de añadir traduce excepciones de dominio a HTTP así:
| Situación | Excepción de dominio | HTTP |
|---|---|---|
| Producto inexistente | ProductNotFoundError |
404 |
| Producto eliminado (soft-delete) | ProductRemovedError |
410 |
| Producto no disponible | ProductUnavailableError |
409 |
| Producto de otro restaurante | ProductRestaurantMismatchError |
403 |
Supera MAX_CART_QTY |
CartQuantityExceededError |
422 |
| Sesión pausada (manager llamado) | — (_raise_if_session_paused) |
409 |
| Sesión cerrada / inexistente | — (_verify_session_active) |
400 / 404 |