Saltar a contenido

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.

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.

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…) y allergens.
  • 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 su price en céntimos e is_default.
  • ProductOptionGroup + ProductOption — una pregunta estructurada que pide al cliente elegir una o varias opciones (p. ej. "Elige guarnición"). El grupo tiene prompt (i18n), is_required (obliga a elegir antes de confirmar) y allows_multiple (cardinalidad); cada ProductOption tiene name (i18n) y display_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_available indica 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, Category y Product heredan SoftDeleteMixin (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) y ProductPromotion no 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:

{"es": "Patatas Bravas", "en": "Bravas"}

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 interface resuelve a un idioma según el parámetro de query ?lang= (por defecto es), usando get_translated() o los validadores _resolve_name() / _resolve_description() de interface/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 por slug (acepta también el id numé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 de menus, categories y products; option-groups de un producto; products/bulk-toggle (disponibilidad en lote); categories/{id}/reorder; menus/{id}/toggle.
  • /knowledge (admin_knowledge_router.py): lectura/edición de RestaurantKnowledge, estado de completion, y gestión de faqs y events.
  • /promotions (admin_promotions_router.py): CRUD de promociones, listado de active y toggle.

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