Saltar a contenido

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 un session_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 este peek, llamar a /validate directamente 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, devolviendo session_token, restaurant_id, table_id, table_name, expires_at, is_new_session, idioma, guest_count y version. 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 campo count (UpdateGuestCountRequest, validado 1 ≤ 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) y lifecycle_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

Páginas relacionadas