Saltar a contenido

Comanda (pedido) y su ciclo de vida

Una comanda es la unidad de pedido del sistema: el cliente la crea desde la mesa, la IA la valida, el manager la aprueba (según política) y la cocina la prepara, sirve y cierra. El término "comanda" sustituye a los antiguos "order" y "order batch": un único modelo unificado los reemplaza a ambos.

Esta página es una explicación del concepto y de su máquina de estados. Para el detalle de capas y puertos, ver Arquitectura hexagonal.

Términos clave

  • Comanda — agregado raíz (Comanda), persistido en la tabla orders.
  • Línea de comanda — hijo del agregado (ComandaLine), persistido en order_lines. Solo se modifica a través de la raíz.
  • Ronda (batch_number) — cada vez que el cliente envía un nuevo grupo de productos en la misma sesión se materializa una comanda con batch_number = MAX + 1. No hay endpoint separado para "ronda N+1": se obtiene un DRAFT nuevo automáticamente.
  • Sesión — la mesa abierta (session_token). Una sesión agrupa varias comandas. El ticket de la sesión es la suma de sus comandas no canceladas.

1. Máquina de estados

La fuente de verdad de las transiciones es app/modules/comanda/domain/state_machine.py (clase ComandaStateMachine). Está expresada con match/case exhaustivo sobre ComandaStatus; ningún llamante debe hacer comprobaciones ad-hoc de estado.

stateDiagram-v2
    [*] --> DRAFT : el cliente crea la comanda
    DRAFT --> AI_REVIEWING : submit (modo full / ai_only)
    DRAFT --> PENDING : submit (modo none, sin IA)
    DRAFT --> CANCELED

    AI_REVIEWING --> AI_REJECTED : la IA rechaza
    AI_REVIEWING --> PENDING_MANAGER : la IA aprueba (modo full)
    AI_REVIEWING --> PENDING : la IA aprueba (modo ai_only)
    AI_REVIEWING --> CANCELED

    AI_REJECTED --> DRAFT : el cliente edita
    AI_REJECTED --> CANCELED

    PENDING_MANAGER --> PENDING : el manager aprueba
    PENDING_MANAGER --> MANAGER_REJECTED : el manager rechaza
    PENDING_MANAGER --> CANCELED

    MANAGER_REJECTED --> DRAFT : el cliente edita
    MANAGER_REJECTED --> CANCELED

    PENDING --> IN_PROGRESS : la cocina empieza
    PENDING --> CANCELED
    IN_PROGRESS --> READY : la cocina termina
    IN_PROGRESS --> SERVED
    IN_PROGRESS --> CANCELED
    READY --> SERVED : se sirve a la mesa
    READY --> CANCELED
    SERVED --> CLOSED : pago completado
    CLOSED --> [*]
    CANCELED --> [*]

1.1 Estados

La columna "¿Editable?" refleja EDITABLE_STATES (app/modules/comanda/domain/value_objects.py), el conjunto que consulta Comanda.is_editable() (domain/entities.py):

Estado value Descripción ¿Editable?
DRAFT draft Estado inicial; el cliente añade líneas. SÍ
AI_REVIEWING ai_reviewing Validación de IA en curso. NO
AI_REJECTED ai_rejected La IA encontró problemas; el cliente debe corregir. SÍ
PENDING_MANAGER pending_manager A la espera de aprobación del manager. SÍ
MANAGER_REJECTED manager_rejected El manager rechazó; el cliente debe corregir. SÍ
PENDING pending Aprobada; en cola de cocina. NO
IN_PROGRESS in_progress La cocina la está preparando. NO
READY ready Lista para servir. NO
SERVED served Servida en la mesa. NO
CLOSED closed Pago completado. Estado terminal. NO
CANCELED canceled Cancelada en cualquier fase previa al servicio. Estado terminal. NO

Edición de líneas: guard is_editable()

EDITABLE_STATES = {DRAFT, AI_REJECTED, MANAGER_REJECTED, PENDING_MANAGER}. Los use cases AddLineUseCase y UpdateLineUseCase comprueban comanda.is_editable() y, si el estado no lo permite, lanzan ComandaConflict (HTTP 409: Cannot add line: comanda is in {status} state.). Es decir, el cliente puede modificar líneas mientras la comanda espera al manager (PENDING_MANAGER) o tras un rechazo, sin pasar primero por DRAFT.

Transiciones reales (código vs. diseño)

El documento de diseño docs/UNIFIED_COMANDA_DESIGN.md modela la edición como un edit-reset: al editar en PENDING_MANAGER la comanda volvería automáticamente a AI_REVIEWING reiniciando el ciclo, y las transiciones de recuperación irían AI_REJECTED/MANAGER_REJECTED → AI_REVIEWING. La implementación vigente NO hace ese auto-reset: la edición muta las líneas en el sitio (sin cambiar el estado) y el cliente reenvía con submit; además, desde AI_REJECTED / MANAGER_REJECTED la comanda solo puede volver a DRAFT (endpoint revert-to-draft) o a CANCELED. La máquina de estados del código (state_machine.py) es la referencia.

1.2 Transiciones permitidas

Resumen de ComandaStateMachine.allowed_targets():

Origen Destinos permitidos
DRAFT AI_REVIEWING, PENDING, CANCELED
AI_REVIEWING AI_REJECTED, PENDING_MANAGER, PENDING, CANCELED
AI_REJECTED DRAFT, CANCELED
PENDING_MANAGER MANAGER_REJECTED, PENDING, CANCELED
MANAGER_REJECTED DRAFT, CANCELED
PENDING IN_PROGRESS, CANCELED
IN_PROGRESS READY, SERVED, CANCELED
READY SERVED, CANCELED
SERVED CLOSED
CLOSED — (terminal)
CANCELED — (terminal)

ComandaStateMachine.assert_can_transition(current, target) lanza InvalidComandaTransition si la transición no está permitida; acepta tanto el enum como el str crudo. is_cancelable(status) devuelve False para SERVED, CLOSED y CANCELED.

Líneas espejo de la comanda

Las líneas siguen su propia máquina (ComandaLineStateMachine, dict LINE_TRANSITIONS). Cada cambio de estado de línea es una cascada de una transición de la raíz que ComandaStateMachine ya validó, así que el grafo de líneas es esencialmente el mismo. La clase separada da una señal de tipo explícita en los call sites y deja margen para divergir más adelante (p. ej. flujos READY/SERVED por línea).


2. order_approval_mode (full · ai_only · none)

Cada restaurante decide cómo se enruta una comanda enviada antes de que la cocina la vea. El valor vive en restaurants.order_approval_mode y se lee vía IRestaurantPolicy.get_order_approval_mode(). El enum es OrderApprovalMode (app/shared/domain/enums.py):

Modo value Comportamiento Camino de estados
FULL full Validación IA y aprobación de manager. Por defecto. DRAFT → AI_REVIEWING → PENDING_MANAGER → PENDING
AI_ONLY ai_only La IA valida, pero no hace falta manager. DRAFT → AI_REVIEWING → PENDING
NONE none El pedido va directo a cocina; se omite la IA. DRAFT → PENDING

Si la política no devuelve un modo, se asume FULL (default seguro). El branching vive en SubmissionPipeline.run() (app/modules/comanda/application/services/submission_pipeline.py):

  • En none, _submit_without_ai() salta la IA y lleva la comanda directamente a PENDING, dejando un ai_validation_result placeholder (skipped: True) para que los consumidores vean una forma consistente.
  • En ai_only, al aprobar la IA (_apply_ai_decision) se transiciona a PENDING saltando el gate de manager.
  • En full, al aprobar la IA se transiciona a PENDING_MANAGER y queda a la espera del manager.

3. Validación IA: FAIL-CLOSED

La validación es fail-closed: ante cualquier fallo del validador, la comanda se rechaza, nunca se auto-aprueba. Esto es una regla de resiliencia normativa del backend (ver backend/AGENTS.md, sección "Errores y resiliencia").

El SubmissionPipeline invoca el puerto IOrderValidationGateway.ai_validate(...); la implementación de reglas vive en call_ai_validation (app/modules/comanda/infrastructure/adapters/_ai_bridge.py), que devuelve siempre la misma forma de resultado:

  • Pedido vacío → decision="reject", bypassable=False (un pedido vacío no se puede enviar).
  • Excepción del validador (outage) → se captura, se loguea y se devuelve decision="reject", bypassable=False. La comanda vuelve al cliente como AI_REJECTED y se revalida al reenviar; bypassable=False impide forzar el override de un carrito sin cambios.
  • Con feedback (warnings/suggestions) → decision="reject" con bypassable calculado, para que el cliente vea las observaciones.
  • Carrito limpio → decision="approve", bypassable=True.

Nunca dejar pasar ante duda

Un corte del validador NO debe colar pedidos a cocina. El contrato del resultado siempre lleva decision, bypassable, confidence, notes, warnings y suggestions. Ver docs/findings/2026-06-04-ai01-robustez-integracion-ia.md.

3.1 Conflictos de alérgenos (soft-reject)

Antes de aplicar la decisión de IA, el pipeline ejecuta detect_flagged_allergen_conflicts sobre los alérgenos marcados por el comensal en cada línea (_merge_allergen_conflicts). Si hay colisión, una decisión que iba a aprobar se degrada a REJECT con bypassable=True, de modo que el comensal pueda reenviar para confirmar (el flujo bypass corta sobre el hash de carrito sin cambios).

3.2 Bypass al reenviar un carrito sin cambios

SubmissionPipeline._maybe_bypass_ai permite confirmar un rechazo "blando" sin re-llamar al validador: si el ai_validation_cart_hash persistido coincide con el hash actual del carrito (compute_cart_hash) y el resultado previo era bypassable=True, el pipeline reutiliza ese resultado forzando decision=approve (overridden_by_customer=True). Así, ante warnings no críticos, el segundo submit sobre el mismo carrito aprueba. Un rechazo bypassable=False (carrito vacío u outage del validador) NO se puede forzar de este modo.

3.3 course_type_filter

El pipeline puede pasar al validador un course_type_filter (p. ej. ["drink"]) para validar solo una rama de cursos durante la fase WELCOME; el firer de coursing lo usa para validar solo el curso disparado. El filtro viaja en el payload de ai_validate y se respeta en validate_order.


4. Coursing (plan de cursos y auto-fire)

El coursing permite planificar por qué orden salen los platos a cocina. El plan vive por sesión (course_plan) y ordena las entradas por order_index. Cada entrada tiene un course_type (CourseType: drink, appetizer, first, main, second, side, dessert, other) y un release_trigger que decide cuándo se "dispara" (fire) esa parte del DRAFT a una comanda nueva.

ReleaseTrigger (app/modules/comanda/domain/value_objects.py):

Trigger value Cuándo dispara
NOW now Eagerly, al declarar el plan.
MANUAL manual Solo vía llamada explícita. Default de los planes nuevos.
ON_PREVIOUS_SERVED on_previous_served Auto-fire cuando el curso anterior se sirve. Lo dispara el subscriber del evento de dominio CourseServed.

4.1 Mecanismo de fire

El "firer" (FireCourseUseCase, app/modules/comanda/application/use_cases/fire_course.py) es el mecanismo de disparo:

  1. La sesión debe tener un DRAFT actual.
  2. Toma las líneas del DRAFT cuyo producto coincide con el course_type; si no hay ninguna, lanza CourseHasNoLines (HTTP 409). Esto se comprueba antes del claim_for_fire atómico, para que un curso sin líneas no consuma el fire y la cadena de auto-fire pueda seguir.
  3. Materializa una comanda nueva (nace en DRAFT) y la conduce por el pipeline estándar (SubmissionPipeline.run), respetando el order_approval_mode igual que un submit normal.
  4. Al éxito: la nueva comanda queda en PENDING / PENDING_MANAGER / AI_REJECTED según política, la entrada del plan se enlaza al comanda_id y se emite el evento CourseFired.

Endpoints del router cliente (app/modules/comanda/interface/customer_router.py):

  • PUT /sessions/{token}/tab/course-plan — reemplaza el plan; los cursos ya disparados conservan su flag fired y su comanda_id si reaparecen.
  • POST /sessions/{token}/tab/fire-next-course — dispara el siguiente curso disparable (menor order_index, no disparado).
  • POST /sessions/{token}/courses/{course_type}/fire — dispara un curso concreto por tipo.

Ambos endpoints de fire aceptan Idempotency-Key; el ámbito natural es (session_token, course_type), así dos reintentos con la misma clave reproducen la primera respuesta 201. Un doble-fire genuino (clave distinta o ausente sobre un curso ya disparado) lo rechaza el lock a nivel de BD (409).


5. Líneas: selected_options y extras

Cada línea (ComandaLine) puede llevar dos blobs JSON:

  • extras — extras del producto (snapshot de precios incluido en el total de la línea).
  • selected_options — elecciones de grupos de opciones (option-groups) de la línea.

En el contrato cliente (app/modules/comanda/interface/schemas.py):

  • ComandaLineCreate acepta extras: list[int] | None y selected_options: list[dict] | None.
  • ComandaLineResponse (y ComandaBatchLineResponse) devuelven ambos como list[dict] | None.

5.1 Serialización SSE (BatchLineSummary)

Cuatro representaciones que deben moverse juntas

Al añadir un campo a una línea de comanda hay cuatro sitios que actualizar a la vez: el ORM (orm.py), la entidad de dominio (entities.py), la respuesta REST (interface/schemas.py) y el dataclass SSE BatchLineSummary más el dict de serializers.py. Es fácil olvidar el último porque el SSE no está en el snapshot OpenAPI ni tenía test unitario propio. Ver backend/docs/findings/2026-06-15-comanda-selected-options-sse.md.

El dataclass BatchLineSummary (app/modules/comanda/domain/events.py) es la línea dentro del payload batch_updated Forma A. Incluye, entre otros, extras: object | None y selected_options: object | None (JSON sin tipar a propósito). El serializer construye BatchLineSummary(**line) por cada línea; si una clave del dict no existe como campo del dataclass, falla con TypeError. Por eso el dataclass SSE y el dict del builder deben mantenerse sincronizados.

Esta adición de campo al contrato SSE es legítima por la regla del contrato aditivo: backend y frontend viajan juntos (lib/api/types.ts añadió selected_options en OrderBatchLine). Ver cómo se trabaja el contrato y tests/contract/sse_events_inventory.md.


6. Submit bloqueado si la sesión está pausada (409)

El endpoint POST /sessions/{session_token}/submit (customer_router.py) bloquea el envío mientras hay una llamada al manager abierta (sesión pausada): el cliente no puede mandar un pedido a cocina mientras el manager está de camino.

Comprobación al inicio del handler:

session = await get_session_cart_gateway(db).get_session(session_token)
if session is not None and session.is_paused:
    raise HTTPException(
        status_code=409,
        detail="Session paused — the manager has been called. Please wait before submitting.",
    )

Respuesta esperada

Si la sesión está pausada, el submit devuelve HTTP 409 y la comanda permanece en DRAFT. Ver Control de sala para el ciclo de pausa/llamada al manager.


7. Eventos SSE del ciclo de vida

SubmissionPipeline._publish_lifecycle publica, en este orden, sobre el bus de tiempo real:

  1. chat_message (opcional) — aviso de split de fase WELCOME, cuando aplica.
  2. batch_ai_result (BatchAiResult) — resultado de validación (passed, validation_result).
  3. batch_updated (BatchStatusUpdated, Forma B) — estado nuevo de la comanda.

Además, según el resultado, persiste tarjetas de chat (ORDER_CONFIRMED / ORDER_REJECTED) y, en rechazo, lanza un turno proactivo de la IA para ayudar al cliente a corregir.

Eventos de dominio tras commit

Los eventos de dominio se publican después del commit (commit_and_publish), nunca antes, conforme a las convenciones del backend.


8. Tablas orders y order_lines

El modelo se persiste con nombres de tabla heredados del batch (Comanda.__tablename__ = "orders", ComandaLine.__tablename__ = "order_lines").

8.1 orders

Columnas relevantes (precios en céntimos, int):

Columna Tipo Notas
id int PK
session_token str FK → table_sessions ondelete=CASCADE, indexada
restaurant_id int FK → restaurants indexada
table_id int FK → tables
customer_id int \| None FK → customers
batch_number int número de ronda (default 1)
number str \| None número de display (p. ej. "A-042")
status str ComandaStatus.value, indexada
nb_customers int comensales (default 1)
comment str \| None
ai_validation_result / ai_validation_notes / ai_validated_at JSON / text / datetime validación IA
manager_notes / manager_id / manager_validated_at text / FK / datetime validación manager
submitted_at, approved_at, confirmed_at, ready_at, served_at, closed_at, canceled_at datetime tz-aware timestamps por transición
subtotal, tax_amount, total int (céntimos) totales

Constraint único (session_token, batch_number) (una comanda por ronda en la sesión) e índice compuesto (restaurant_id, status). Optimistic locking vía columna version.

8.2 order_lines

Columna Tipo Notas
id int PK
order_id int FK → orders ondelete=CASCADE
product_id int FK → products ondelete=RESTRICT
quantity int
unit_price / total_price int (céntimos)
name JSON snapshot multi-idioma
comment str \| None
extras JSON snapshot de extras
selected_options JSON elecciones de option-groups
status str ComandaStatus.value (espejo de la raíz)
position int orden dentro de la comanda

8.3 Ticket de sesión

El total del ticket de la sesión se calcula como la suma de las comandas no canceladas y no en estados pre-aprobación (DRAFT, AI_REVIEWING, AI_REJECTED, MANAGER_REJECTED, CANCELED quedan excluidas). No hay denormalización; se computa bajo demanda. Endpoint: GET /sessions/{token}/ticket (404 si no hay comandas activas).


Páginas relacionadas