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 tablaorders. - Línea de comanda — hijo del agregado (
ComandaLine), persistido enorder_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 conbatch_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 aPENDING, dejando unai_validation_resultplaceholder (skipped: True) para que los consumidores vean una forma consistente. - En
ai_only, al aprobar la IA (_apply_ai_decision) se transiciona aPENDINGsaltando el gate de manager. - En
full, al aprobar la IA se transiciona aPENDING_MANAGERy 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 comoAI_REJECTEDy se revalida al reenviar;bypassable=Falseimpide forzar el override de un carrito sin cambios. - Con feedback (warnings/suggestions) →
decision="reject"conbypassablecalculado, 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:
- La sesión debe tener un DRAFT actual.
- Toma las líneas del DRAFT cuyo producto coincide con el
course_type; si no hay ninguna, lanzaCourseHasNoLines(HTTP 409). Esto se comprueba antes delclaim_for_fireatómico, para que un curso sin líneas no consuma el fire y la cadena de auto-fire pueda seguir. - Materializa una comanda nueva (nace en
DRAFT) y la conduce por el pipeline estándar (SubmissionPipeline.run), respetando elorder_approval_modeigual que un submit normal. - Al éxito: la nueva comanda queda en
PENDING/PENDING_MANAGER/AI_REJECTEDsegún política, la entrada del plan se enlaza alcomanda_idy se emite el eventoCourseFired.
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 flagfiredy sucomanda_idsi reaparecen.POST /sessions/{token}/tab/fire-next-course— dispara el siguiente curso disparable (menororder_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):
ComandaLineCreateaceptaextras: list[int] | Noneyselected_options: list[dict] | None.ComandaLineResponse(yComandaBatchLineResponse) devuelven ambos comolist[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:
chat_message(opcional) — aviso de split de fase WELCOME, cuando aplica.batch_ai_result(BatchAiResult) — resultado de validación (passed,validation_result).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).