Menú y catálogo¶
El módulo menu (app/modules/menu/) es el catálogo del restaurante: define qué se
puede pedir y con qué metadatos. Modela la jerarquía Menu → Category → Product, los
añadidos de pago (ProductExtra), las preguntas estructuradas (ProductOptionGroup /
ProductOption), las promociones (ProductPromotion) y la base de conocimiento del
local (RestaurantKnowledge). También es la fuente de verdad que otros módulos
(p. ej. comanda, chat) consultan para validar pedidos y recomendar platos.
Diátaxis: explicación
Esta página explica el porqué y la forma del catálogo. Para los nombres exactos de endpoints y campos, ver la Referencia API y la referencia de módulos.
La jerarquía del catálogo¶
El agregado raíz es Menu. Cada restaurante puede tener varios menús (desayuno, comida,
cena…); cada menú agrupa Category y cada categoría agrupa Product.
| Entidad de dominio | Modelo ORM | Tabla | Padre |
|---|---|---|---|
MenuEntity |
Menu |
menus |
restaurant_id |
CategoryEntity |
Category |
categories |
menu_id |
ProductEntity |
Product |
products |
category_id |
ProductExtraEntity |
ProductExtra |
product_extras |
product_id |
ProductOptionGroupEntity |
ProductOptionGroup |
product_option_groups |
product_id |
ProductOptionEntity |
ProductOption |
product_options |
group_id |
PromotionEntity |
ProductPromotion |
product_promotions |
product_id |
RestaurantKnowledgeEntity |
— | — | restaurant_id |
Las entidades de dominio viven en app/modules/menu/domain/entities.py; los modelos
SQLAlchemy en app/modules/menu/infrastructure/models/. Los mappers
(infrastructure/mappers/) traducen entre ambos mundos.
Producto: el corazón del catálogo¶
ProductEntity (domain/entities.py) lleva, además de los datos comerciales básicos,
metadatos pensados para la IA:
- Comerciales:
price,cost_price,profit_margin,stock_level,preparation_time_minutes,portion_size. - Para la IA y recomendación:
ai_description,selling_points,flavor_profile,pairing_suggestions,tags. - Para la validación de pedidos:
course_type(tiempo del menú: entrante, principal, postre…) yallergens. - Relaciones en memoria:
extras,option_groups,category.
ProductExtra vs. grupos de opciones¶
Son dos mecanismos distintos y conviene no confundirlos:
ProductExtra— un añadido de pago (p. ej. "extra de queso") con supriceen céntimos eis_default.ProductOptionGroup+ProductOption— una pregunta estructurada que pide al cliente elegir una o varias opciones (p. ej. "Elige guarnición"). El grupo tieneprompt(i18n),is_required(obliga a elegir antes de confirmar) yallows_multiple(cardinalidad); cadaProductOptiontienename(i18n) ydisplay_order.
Por qué importan los grupos de opciones
is_required alimenta la puerta de confirmación determinista del flujo de pedido:
no se puede cerrar una comanda con una opción obligatoria sin resolver. La selección
del cliente viaja como selected_options en la línea de carrito/comanda.
Promociones¶
ProductPromotion (PromotionEntity) marca un producto para priorizarlo en las
recomendaciones de la IA. Tiene un promotion_type (enum PromotionType:
dish_of_day, excess_stock, high_margin, time_based, special_event,
marketing_campaign, chef_recommendation, low_stock, seasonal), un
priority_score (0–100), un interruptor maestro is_active y ventanas opcionales de
validez: fechas (valid_from/valid_to), días de la semana (days_of_week, 0=lunes) y
hora del día (time_start/time_end).
El dominio decide si una promo está vigente con
PromotionEntity.is_currently_active(now), que combina las tres ventanas. La respuesta
de la API expone ese cálculo como el campo is_currently_active.
Disponibilidad y borrado lógico (soft-delete)¶
Hay dos conceptos separados:
- Disponibilidad —
Product.is_availableindica si un producto se puede pedir ahora (se agotó, está fuera de carta del día…). Es un toggle reversible; existe un endpoint de bulk toggle para activarlo/desactivarlo en lote. - Soft-delete —
Menu,CategoryyProductheredanSoftDeleteMixin(is_deleted+deleted_at). Borrar no destruye la fila: la marca como eliminada para preservar el audit trail y no romper líneas de comanda históricas.ProductExtra,ProductOption(Group)yProductPromotionno tienen soft-delete.
No se borra un producto con pedidos activos
DeleteProductUseCase consulta el puerto IComandaChecker
(has_active_lines_for_product) antes de borrar. Si el producto está referenciado por
una línea de comanda no terminal, lanza ProductHasActiveOrdersError (HTTP 409). Toda
eliminación admin registra una entrada de auditoría vía el puerto de audit trail.
i18n: los campos name/description son diccionarios¶
Los campos traducibles (name, description, y en Product también ai_description y
selling_points) se almacenan como dicts JSON con claves de idioma ISO 639-1:
Este diseño admite idiomas nuevos sin cambiar el esquema. Las utilidades viven en
app/core/i18n.py: get_translated(text_dict, lang_code) resuelve a un idioma (con
fallback a es y luego al primer valor disponible), set_translation() añade/actualiza
una traducción y supported_languages() lista los idiomas presentes.
Regla clave: el caso de uso mantiene los dicts; la resolución ocurre en interface¶
El reparto de responsabilidades es deliberado:
- El caso de uso / dominio conserva los
dictíntegros. Nunca aplana a un solo idioma. - La capa
interfaceresuelve a un idioma según el parámetro de query?lang=(por defectoes), usandoget_translated()o los validadores_resolve_name()/_resolve_description()deinterface/schemas.py.
No romper a los consumidores que esperan el dict crudo
Algunos consumidores (p. ej. el POS, vía el puerto del catálogo) reciben el name como
dict i18n sin resolver para mantener la forma del contrato. Por eso los snapshots
del gateway (ProductCatalogEntry.name, OptionGroupSnapshot.prompt,
OptionSnapshot.name) llevan el dict tal cual y la resolución a idioma es siempre del
lado del que muestra, no del que almacena.
Precios en céntimos¶
Todos los importes (price, cost_price, profit_margin, el price de los extras) se
guardan como enteros en céntimos (12,50 € → 1250). En el dominio se modelan con el
value object Money; los mappers convierten a/desde céntimos para el ORM. La conversión a
texto con divisa la hace price_formatted (p. ej. "12.50€").
Cómo lo consultan otros módulos sin tocar su ORM¶
Por ADR-004 ningún módulo importa el ORM de otro. El catálogo se expone hacia fuera
mediante el puerto compartido IMenuCatalogGateway
(app/shared/application/ports.py), implementado por un adaptador dentro de menu
(infrastructure/adapters/comanda_catalog_gateway.py) y registrado en el arranque. El
puerto está pensado para que comanda valide pedidos sin depender de
app.modules.menu.infrastructure.
| Método | Devuelve | Para qué |
|---|---|---|
get_product(product_id, restaurant_id) |
ProductCatalogEntry \| None |
Precio, disponibilidad, soft-delete, name (dict), course_type, allergens |
get_extras(extra_ids, product_id) |
list[ProductExtraEntry] |
Validar y valorar los extras de una línea |
get_course_types(product_ids) |
dict[int, str \| None] |
Tiempo del menú por producto (coursing) |
get_option_groups(product_ids) |
dict[int, list[OptionGroupSnapshot]] |
Validar selected_options y opciones obligatorias |
Los DTO de respuesta (ProductCatalogEntry, ProductExtraEntry, OptionGroupSnapshot,
OptionSnapshot) son dataclasses frozen de solo lectura: aíslan a los consumidores de la
entidad y del ORM del catálogo. Ver el detalle del flujo de pedido en
Comanda y la idea general en Arquitectura hexagonal.
Endpoints¶
El catálogo expone una superficie pública (cliente) y otra de administración.
Públicos (cliente)¶
Montados bajo /api/v1 (interface/public_menu_router.py):
GET /restaurants/{slug}/menu— menú completo del restaurante porslug(acepta también elidnumérico por retrocompatibilidad). Devuelve categorías con sus productos. Parámetro?lang=para el idioma. Respuesta cacheada por(restaurant_id, lang).GET /products/{product_id}— detalle de un producto.
Admin¶
Montados bajo /api/v1/admin y protegidos por multitenancy
(require_restaurant_access). Cada router añade su propio prefijo:
/menu(admin_menu_router.py): CRUD demenus,categoriesyproducts;option-groupsde un producto;products/bulk-toggle(disponibilidad en lote);categories/{id}/reorder;menus/{id}/toggle./knowledge(admin_knowledge_router.py): lectura/edición deRestaurantKnowledge, estado decompletion, y gestión defaqsyevents./promotions(admin_promotions_router.py): CRUD de promociones, listado deactiveytoggle.
Para la lista exacta de rutas, verbos y esquemas, consultar la Referencia API.
Base de conocimiento del restaurante¶
RestaurantKnowledgeEntity es la información verificada que el asistente de IA puede usar
para responder (horarios, dirección, políticas, servicios como has_terrace/has_wifi,
FAQs, eventos y ajustes de tono/persona del bot). Acota lo que el LLM puede afirmar y así
reduce alucinaciones. La capa de chat la lee a través de un puerto, no del ORM del menú.
Ver también¶
- Comanda — cómo el pedido valida productos contra el catálogo.
- Recomendación — uso de promociones y metadatos de IA.
- Arquitectura hexagonal — puertos, adaptadores y ADR-004.
- Convenciones — i18n, precios en céntimos, soft-delete.