| M1 Carta digital premium |
Activo |
Backend Gastro + public-menu + admin-panel |
PostgreSQL, Redis, R2/MinIO |
Capacidad nativa del workspace |
| M1 Menú por Horarios (Dayparting) |
Activo |
gastro_tenants.timezone + gastro_categories.availability; editor "Horario" en admin; filtro/atenuado + acordeón "No disponible" con platos en public-menu |
PostgreSQL |
Diferenciador de la fase LAND (G-MENU1/G-MENU2) |
| Self-service activation |
Activo |
signup, auth, marketing-site, tenant bootstrap |
PostgreSQL, Redis, Resend |
Capacidad nativa del workspace |
| Login world-class por email |
Activo |
authenticate_by_email (deriva tenant del founder, case-insensitive); slug de respaldo; staff por PIN |
PostgreSQL |
G-AUTH1. Sin migración |
| Cuándo y desde dónde puede entrar cada empleado |
Activo |
gastro_users.access_schedule (JSONB, misma forma que el dayparting del menú) · allow_personal_device · access_open_until; migración f3a4b5c6d7e8 con backfill a true para los existentes (nadie pierde acceso). core/access_window.py evalúa la franja en la TZ del local con 60 min de margen a cada lado y resuelve el cruce de medianoche (bar 20:00–02:00 pertenece al día que empieza, patrón Toast/Square). Se comprueba en _verificar_politica_de_acceso, después de validar la credencial (antes sería un oráculo de qué usuarios existen), tanto en authenticate_with_pin como en _authenticate_staff_password. 403 OUTSIDE_ACCESS_HOURS / 403 PERSONAL_DEVICE_NOT_ALLOWED. POST /users/{id}/open-access = la aprobación del gerente de Toast, vence sola a las 2 h |
PostgreSQL |
G-AUTH3. Solo aplica al ENTRAR: al que ya está adentro no se le corta la sesión. Dos salvaguardas: el bloqueo de celular personal se ignora si el local no tiene ninguna terminal vinculada, y el dueño (founder) no pasa por esta comprobación |
| Aparatos del local: compartidos y asignados |
Activo |
gastro_devices (RLS, e1f2a3b4c5d6) + auth/device_service.py estilo RFC 8628: el aparato muestra código+QR y el dueño lo aprueba desde su celular (POST /auth/device/{start,approve,token}). Dos modos (patrón de Microsoft para trabajadores de primera línea): compartido —la tablet del mostrador muestra la lista del personal— y asignado (assigned_user_id, migración e9fd724cd7d2), donde el aparato es de UNA persona, abre directo en su acceso y no enseña la nómina. POST /auth/devices/{id}/assign lo cambia sin volver a vincular. En un aparato asignado se entra con PIN o contraseña (pin-login acepta password; el schema rechaza mandar los dos). La contraseña NO se ofrece en uno compartido: escribir una clave larga delante del salón es peor que marcar cuatro dígitos. El token de aparato no es sesión: solo habilita GET /auth/device/staff (id/nombre/cargo, ya filtrado por asignación) y pin-login por user_id —que vuelve a comprobar que el id sea el del dueño, porque llega del cliente—. core/pin_throttle.py aplica demora creciente por cuenta. POST /users/{id}/reset-pin para el PIN olvidado. Frontend: StaffLoginPage (manual/vinculando/terminal + confirmación en el propio aparato al quedar vinculado), DevicesPage con escáner de QR en la página (jsqr en chunk perezoso, con respaldo de foto) y selector de dueño por fila |
PostgreSQL, Redis |
G-AUTH2 / G-AUTH4. El camino viejo (local + usuario + PIN) sigue vivo como respaldo. La sesión queda atada al aparato (device_id en el claim, comprobado en get_current_actor en cada request): revocar corta la sesión ya abierta en el siguiente request, no cuando venza el token 24 h después. tenant_has_devices exige last_seen_at: un aparato aprobado que nunca se conectó no arma la restricción de "solo en el local" — contarlo creó un candado en producción que dejó al personal sin ninguna puerta |
| Desde qué aparato entra cada empleado |
Activo |
La ficha del empleado en TeamPage dice "Solo desde {nombre del aparato}" cuando tiene uno asignado, en vez del genérico "Solo en el local" — con varias tablets, "en el local" no responde la pregunta que uno se hace cuando algo no funciona. Se muestra en Equipo pero se edita en Dispositivos, a propósito: un aparato tiene un solo estado (del local o de una persona) pero una persona puede usar varios, así que un selector en la ficha del usuario obligaría a mantener la misma verdad en dos sitios. Es el patrón de Microsoft para trabajadores de primera línea y el de Toast (la ficha del empleado gestiona permisos y accesos; los aparatos se gestionan como aparatos) |
— |
hayTerminales del frontend exige ahora last_seen_at además de status === 'approved', igual que tenant_has_devices en el backend: sin eso, un aparato aprobado que nunca se conectó hacía que la pantalla dijera "el local ya tiene aparatos" mientras el backend seguía tratándolo como si no tuviera ninguno — el dueño leía lo contrario de lo que pasaba, y en kasiri hubo 7 fantasmas a la vez |
| Quién cambió qué en el equipo |
Activo |
gastro_audit_log se llenaba desde el primer día y no había endpoint: para saber qué le pasó a un empleado había que entrar con SQL. GET /users/cambios (gate users.manage, filtrado por local) + «Últimos cambios del equipo» plegado en TeamPage, que traduce el crudo a español y solo nombra lo que de verdad cambió. El actor se resuelve a un nombre (founder o empleado) en un viaje por tabla, y se pinta solo cuando no fuiste tú: en un local con un dueño, repetir su nombre treinta veces convierte el dato en relleno |
PostgreSQL |
Sin migración: la tabla ya existía. Los patrones de permisos SaaS 2026 lo plantean como el requisito que convierte el control de acceso en algo verificable en vez de algo que se espera que esté bien |
| Páginas legales (Colombia) |
Activo |
marketing-site /terminos + /privacidad (Ley 1581) |
— |
G-LEGAL1. Borrador: rellenar shared/config/legal.ts + revisión abogado antes de publicar |
| AI onboarding de menú |
Activo |
ai_onboarding + ingesta autenticada |
Gemini |
Persistencia final en Gastro |
| M2 Fidelización & CRM |
Activo vía integración, gateado por add-on |
admin-panel + Foveo |
Foveo |
No es bounded context completo nativo del backend Gastro. Reempaque 2026-07-18: salió de la escalera de planes → es el add-on fidelizacion (Extra à-la-carte, features loyalty+customer_capture). Tenants con loyalty previa fueron grandfathered (migración c8d9e0f1a2b3) |
| Personalización de marca por tenant |
Activo |
Catálogo curado en backend (tenant/theme_catalog.py: 16 paletas y 12 parejas tipográficas con sectores, servido por GET /public/theme/catalog); paleta completa DERIVADA de 2 colores con pisos WCAG (shared/theme/derivePalette en public-menu, espejo en admin-panel); inyección del tema en :root vía SSR (cubre portales); Apariencia V3 en admin-panel (sector → filtra curación; tipografía compacta; portada automática según foto) |
PostgreSQL, Google Fonts |
Guardar propaga YA a la carta: invalidate_menu dispara webhook a public-menu /api/revalidate (secreto GASTRO_REVALIDATE_SECRET; sin la env var la feature queda off y rige el ISR 60s). hero_style quedó legacy. Fase 2 pendiente: barrido modo claro en widgets restantes, gate por plan. Fix 2026-07-16: el default para tenants sin personalizar era #1a1a1a/#ff5733 — un placeholder que nunca fue una paleta real del catálogo (anterior a Apariencia V3). Cambiado a "Dorado clásico" + pareja "Editorial elegante" en signup/service.py, tenant/model.py/schema.py, CLI de onboarding y migración c3d4e5f6a7b8 (con backfill de los tenants que seguían en el placeholder viejo). Acento dorado también reforzado en reposo (no solo :hover) en tarjetas/nav/footer de public-menu, usando siempre el token accent de Tailwind (nunca rgba(var()) a mano — causó una regresión real revertida en la misma sesión). |
| Pedido de mesa separado de la Caja |
Activo |
/pedido/:ticketId (mesero, UNA mesa) y /caja (cajero, todas las cuentas) son dos rutas sobre la MISMA pantalla: carta, buscador, grilla y ticket son el mismo trabajo. tableMode se deriva de la ruta (useCajaController), no del query. En modo mesa se cambia OpenTicketsBar —lista de todas las cuentas, herramienta del cajero— por TableOrderBar (qué mesa + volver al Salón). Los dos caminos del Salón (mesa libre y "Agregar plato") navegan al mismo sitio; /caja?ticket= redirige para no romper pestañas abiertas |
— |
G-POS5. Se eliminó floor-plan/components/QuickAddSheet.tsx (311 líneas): era una segunda implementación de "agregar plato", sin rail de categorías, que había que mantener sincronizada a mano — el mismo patrón que causó GPOS-SCOPE1 el mismo día. Su cobertura del buscador se trasladó a ProductPicker.test.tsx, donde vive la lógica |
| Áreas de acceso × plan contratado |
Activo |
AREA_REQUIRED_FEATURE (core/permissions.py) dice qué función del plan habilita cada área: orders/pay/kitchen/sales → pos; stock/menu → siempre. Son dos ejes que se combinan con un Y: qué compró el NEGOCIO (plan) y qué puede hacer la PERSONA (área). El backend rechaza con 402 al asignar un área que el plan no cubre (UserService._validar_areas_contra_el_plan), pero solo lo que se AGREGA — lo que el empleado ya tenía se respeta si el negocio baja de plan, y lo recupera intacto al volver a subir. En pantalla el área bloqueada no se deshabilita: sigue siendo un botón que responde y dice "Con Caja", llevando a Planes (NN/G: un control deshabilitado no explica, no se alcanza con teclado y tiene contraste insuficiente) |
PostgreSQL |
G-ENT2. Antes se ofrecían las 6 áreas a todos: un negocio en Carta podía prender "Cobrar" y quedaba un interruptor mudo — el menú sí filtraba por plan, así que el empleado nunca veía Caja y nadie explicaba por qué. Cambio de plan: sales_log salió de Carta (era una promesa que el código no cumplía: la bandera no se consultaba y el reporte vive bajo /pos) |
| Roles y áreas de acceso del personal |
Activo |
gastro_users.access_areas (ARRAY) es lo que MANDA al autorizar; role queda como el cargo con que se creó y como preset. Seis áreas en el idioma del dueño (core/permissions.py::ALL_AREAS): orders (tomar pedidos) · pay (cobrar) · kitchen (Cocina) · stock (marcar agotados) · menu (editar la carta) · sales (ver las ventas). Se guardan las áreas RESUELTAS, no "rol + excepciones" como Toast: sin herencia no hay que explicar qué está heredado y qué sobrescrito. deps.get_current_actor las lee de la BD en cada request (no del JWT), así que quitar un permiso surte efecto sin esperar a que venza el token. Frontend: TeamPage con presets + interruptores (accessAreas.ts) |
PostgreSQL |
Migración b2c3d4e5f6a7, con backfill que preserva lo que cada empleado podía hacer antes. Cambio deliberado: pos.sales.view es nuevo — la pantalla de Ventas se protegía con pos.view, el mismo permiso que necesita un mesero para tomar pedidos, así que TODO mesero veía la plata del día. Arranca apagada para todos |
| Planes / Entitlements (feature-gating) |
Activo |
core/entitlements.py (carta/crece/opera → features/límites) ∪ add-ons vía merge_entitlements(plan, addon_keys) (ADDON_CATALOG es la fuente de verdad de los Extras); require_entitlement() es add-on-aware (402); /auth/me expone plan + entitlements unidos + addons activos; escalera por operación, nombrada comercialmente Carta/Caja/Opera (carta=Presencia · crece="Caja"=POS+Salón, ciclo completo pedido→mesa→cocina→cobro · opera="Opera"=capa de innovación, hoy idéntica a Caja hasta que exista Order&Pay/IA); loyalty/captura ya NO son de plan (add-on fidelizacion); frontend authStore.hasFeature()/hasAddon() + Sidebar |
PostgreSQL |
G-ENT1. Reacomodo 2026-07-22: Salón se movió de opera a crece (antes exclusivo del plan tope). El derecho efectivo = features(plan) ∪ ⋃ features(add-ons); ver docs/standards/SV_Standard_Addons.md y .agent/context/plans.md |
| Producto "hero" de la carta pública |
Activo |
El dueño elige explícitamente qué plato es el tile grande del BentoGrid (POST /products/{id}/toggle-hero, gastro_products.is_hero, a lo sumo uno por tenant vía índice único parcial); antes se auto-seleccionaba el primero por orden. Botón corona en admin-panel; BentoGrid de public-menu usa is_hero con fallback a index===0 si el tenant nunca eligió uno |
PostgreSQL |
Migración 1b9f3a732d1e |
| Billing suscripciones (Wompi) |
Activo (sandbox) |
domains/billing: checkout server-side (PLAN_CATALOG + GET /billing/plans), webhook con firma properties + anti-replay 300s, tabla gastro_subscription_payments (evidencia raw_event JSONB, idempotencia por tx), audit log wompi_webhook. Paywall en runtime (402 en mutaciones si trial vencido/moroso/suspendido; carta pública OFF en suspended) + renovación automática (renewals/run diario: auto-cobro con payment source Wompi + dunning por email vía scripts/renewals-cron.sh; se instala manualmente en el host con crontab o un workflow n8n — sin evidencia en el repo de que ya esté instalado en el servidor de producción). Tenants fundadores en cortesía (active sin fecha, backfill a1c2e3f4b5d6) |
PostgreSQL, Wompi, Resend |
Verificado end-to-end en sandbox 2026-07-16 (Playwright): Widget real, tarjeta aprobada y rechazada, webhook firmado procesado, plan activado, entitlements aplicados. GBE-BILL1 resuelto 2026-07-17: Wompi no ofrece comercio por vertical (uno solo, una sola URL de eventos), así que se descartó el plan de "comercio propio para Gastro". Solución desplegada: router de pagos (apps/payments, payments.timeliber.com.co/api/v1/webhooks/wompi) recibe el webhook único, verifica firma, y lo reenvía tal cual a la vertical dueña según el prefijo de la referencia (GAS-SUB_/TRA-SUB_/TRA-RES_), con reintentos y buffer si la vertical destino está caída. Verificado con Wompi real en sandbox (no simulación): tenant pasó de carta/trialing a carta/active. Sigue pendiente para cobrar dinero real: ambas verticales están en llaves sandbox (pub_test_); pasar a producción es cambiar llaves y URL de eventos en el dashboard de Wompi (ambiente separado, gestión de negocio). Factura electrónica: manual vía Servicio Gratuito DIAN (docs/legal/ raíz). Falta captura de RUT del tenant. Ciclo de vida (2026-07-18): aviso proactivo D-2 de fin de prueba (send_trial_ending en el barrido de renovación), recibo tras pago aprobado (send_payment_receipt), y banner global de estado (widgets/SubscriptionBanner) visible en toda la app |
| Extras (add-ons) à-la-carte |
Activo (sandbox) |
Tienda self-serve de Extras sobre los planes base (patrón Shopify; evita recargar planes). domains/addons (tabla gastro_tenant_addons, AddonRepository, router /addons catalog·mine·charge), checkout reusando el motor con marca (referencia GAS-ADDON_{tenant}_{addon-con-guiones}_{qty}_{nonce}, webhook activa el add-on por upsert idempotente). Frontend: ExtrasPage (/configuracion/extras) + features/addon-store (compra en un clic con la tarjeta guardada) + paywall contextual features/entitlement-gate. Extras vigentes: multi_sede (per-sede), fidelizacion (loyalty+captura, flat) y multilingue (carta multi-idioma, flat) |
PostgreSQL, Wompi |
Falta la prueba con pago real en sandbox y el despliegue. Ver docs/standards/SV_Standard_Addons.md. Deuda: sin fila de evidencia del pago del add-on; proration y billing consolidado de grupo diferidos (ver TECH_DEBT.md) |
| Multi-sede (Opción A) |
Activo (sandbox) |
Un GastroFounder gestiona varias sedes, cada una un GastroTenant propio (fachada/precios/staff/POS/menú público por-sede). domains/sedes: GET/POST /sedes (crear gateado por el add-on multi_sede + límite max_sedes), POST /sedes/{id}/switch (cambia la sede activa re-emitiendo la sesión), POST /sedes/{id}/clonar-menu (copia categorías+productos con remapeo de ids, imagen compartida en v1). Frontend admin: SedeSwitcher en el shell + CreateSedeDialog. Público: GET /public/menu/{slug}/sedes + SedeSelector en public-menu (un enlace + selector, agrupado por founder, sin slug de marca nuevo) |
PostgreSQL |
Gate a nivel founder (unión de add-ons de todas sus sedes). El catálogo maestro compartido (franquicia) es un upgrade futuro del mismo add-on |
Carta multilingüe (Extra multilingue) |
Activo (sandbox) |
Traduce la carta a varios idiomas de un botón; el menú público se abre en el idioma del celular. domains/i18n: proveedor de traducción intercambiable (providers/: TranslationProvider con adaptadores Gemini —default— y DeepL —opt-in con DEEPL_API_KEY—, selección por TRANSLATION_PROVIDER); tabla gastro_menu_translations (RLS, source_text para detectar desactualización, is_overridden protege ediciones); I18nService (translate_menu un botón / only_pending incremental, enable_locale, set_translation, menu_status ok|outdated|missing, glosario). Router /i18n/* tras require_entitlement(I18N_MENU). Traduce contenido del negocio: nombres/descripciones de categorías y productos + slogan, historia y descripción del tenant (entity_type category|product|tenant). Público: resolve_locale (?lang → Accept-Language → base, nunca a inglés) + overlay de traducciones sobre el cache base en domains/public. Textos fijos de la plantilla (encabezados, pie, badges como "Platos Destacados") NO son contenido de BD → diccionario UI por idioma (public-menu/src/shared/i18n/ui.ts + LocaleProvider). Frontend admin: IdiomasPage (/configuracion/idiomas) + features/i18n. Público: LanguageSelector 🌐 pegado bajo FloatingNavbar (sticky top-[65px], altura medida de esa barra) que abre una hoja inferior con endónimos y lang por opción (WCAG H58), sin banderas —una bandera es un país, no un idioma (W3C, NN/g)—; recuerda la elección en localStorage y un ?lang explícito manda sobre lo guardado. HtmlLang corrige <html lang> en el documento vivo. Motor activo: DeepL (DEEPL_API_KEY en .env; fallback Gemini). El dueño elige a qué idiomas traducir |
PostgreSQL, DeepL/Gemini |
Ver docs/standards/SV_Standard_i18n.md. Precio placeholder ($19.000). Rediseño del selector 2026-08-09: la traducción nunca estuvo rota —el backend responde en el idioma pedido y la página ya respetaba el Accept-Language del celular—; lo que fallaba era llegar al selector. Medido en 390×844 con sky-green: la carta mide 2 234 px con las categorías cerradas y el selector vivía a 464 px sin pegarse, así que al bajar a mirar platos desaparecía; los botones medían 26 px (Apple pide 44, WCAG 2.5.8 pide 24); y tras tocar la pantalla quedaba idéntica ~489 ms sin acuse de recibo. Los "muchos clicks" eran scroll + toques repetidos. Eslogan por defecto, corregido el mismo día: al local que no escribió eslogan propio el backend le inyectaba una frase en español quemada (DEFAULT_SLOGAN), y el traductor nunca podía verla porque i18n/service.py solo recoge el campo si está en la BD — intraducible por construcción, siendo el texto más grande de la carta. Ahora se distingue lo que son dos cosas: el eslogan que escribió el local es contenido del negocio y lo traduce el backend; la frase de relleno es plantilla y vive en ui.ts::defaultSlogan, por idioma. El backend devuelve None (el contrato ya lo admitía). Deuda: glosario nativo DeepL, cache por-locale, strings menores de modales (ver GBE-I18N1/2/4) y <html lang> correcto ya en el HTML del servidor (GFE-LANG1) |
| M3.1 POS Caja registradora básica |
Activo |
Backend pos/ (tickets, ítems con precio congelado, impuesto inclusivo/exclusivo, pago inmutable idempotente, anulación auditada) + pantalla de Caja en admin-panel (/caja). Ventas (/ventas, grupo de nav "Reportes") con selector de rango (Hoy/Ayer/7 días/Este mes/personalizado). Los totales salen de GET /pos/sales/summary, agregado en SQL (pos/sales_queries.py): total, promedio, desglose por medio de pago (suma amount_applied, NUNCA amount_tendered — el recibido incluye el vuelto), ventas por hora LOCAL, lo más vendido por plata, y anuladas con su %. Antes se sumaba en el navegador sobre la lista paginada (size=100), que pasado el tope daba un total corto y silencioso. La pantalla compara contra el bloque anterior del mismo largo (salesCompare.ts) — un número solo no dice si el día fue bueno. GET /pos/tickets filtra por business_date con rango inclusivo date_from/date_to |
PostgreSQL |
El business_date se calcula en la zona horaria del tenant, no en UTC (ticket_queries._business_date): con utcnow().date() el día operativo cambiaba a las 19:00 en Colombia y Ventas se vaciaba a las 7 p.m. (incidente 2026-08-02, migración d8e9f0a1b2c3 renumeró 118 de 360 tickets mal archivados). Falta cierre Z formal (M5). Ver ADR-010 |
| M3.2.1 Editor de Salón |
Activo |
domains/floor (gastro_tables/gastro_zones/gastro_floor_settings) + features/floor-plan (dibujar mesas por capacidad, zonas, plantillas, candado Editar≠Operar) |
PostgreSQL |
El Lienzo |
| M3.2.2 Operar el Salón |
Activo |
gastro_tables.active_ticket_id (estado en vivo) + gastro_tickets.service_status/sent_at/table_id/table_label (eje de servicio, comanda y snapshot histórico); POST /floor/tables/{id}/open-ticket, POST /pos/tickets/{id}/send|/serve; modo Operar (color por estado + timer + leyenda) y "Ventas de hoy" (mesa+itemizado+hora) en admin-panel. Estados de mesa: libre · tomando pedido · sin enviar a cocina · por servir · servida · atascada |
PostgreSQL |
En main (PR #118, #120, #121). "Sin enviar a cocina" (TableRead.active_has_unsent_items, EXISTS correlacionado en get_open_tickets_live) existe por el error post-completion: la tarea se siente terminada al anotar el último plato y comandar queda como acción colgando: esa mesa se pintaba idéntica a una cuya comida ya se cocina. Falta C (tiempo real WebSocket, interim = refetch 30s) |
| M3.2.3 Fusión de mesas |
Activo |
POST /floor/tables/{id}/merge|/unmerge — reusan gastro_tables.active_ticket_id (ya admitía N mesas→1 ticket, sin migración); TableRead.merged_table_ids derivado en get_layout. Fusionar solo si la otra mesa está libre (mover ítems entre tickets ya abiertos, fuera de alcance). Frontend: flujo por toques en TableActionSheet/FloorEditor (sin arrastre) |
PostgreSQL |
Pendiente verificación E2E manual en navegador |
| M3.4 Cobro por platos + Arqueo de caja |
Activo |
Cada quien paga lo suyo: TicketItem.paid_by_payment_id marca qué líneas cubrió cada pago; pay() cobra la parte proporcional de lo seleccionado y el ÚLTIMO pago liquida el pendiente exacto (evita que los redondeos dejen centavos vivos). Pagos parciales con allow_partial; el ticket solo cierra cuando amount_paid >= total. Arqueo: gastro_cash_sessions (apertura/cierre con base declarada, esperado vs. contado, diferencia) + cash_session_id en cada pago; GET/POST /pos/cash-session/current|open|close. Frontend: TenderSheet con "Cada quien paga lo suyo", ItemSplitPicker, CashOpenSheet/CashCloseSheet/CashSessionCard en Ventas |
PostgreSQL |
Migraciones e9f0a1b2c3d4 (paid_by_payment_id) y f0a1b2c3d4e5 (cash sessions + RLS). Deuda: dividir un plato con cantidad > 1 (split de cantidad parcial) no está contemplado; propinas no modeladas |
| M3.2 Opciones y Extras (modificadores) |
Activo |
Bounded context menu/modifier/ — biblioteca de grupos por tenant (gastro_modifier_groups/gastro_modifiers) con price_delta que puede sumar, restar o ser 0. Una opción de $0 es un comentario predefinido; con precio, un extra — un solo catálogo a propósito. Alcance en 3 niveles resueltos en unión y deduplicados por el más específico: is_global (toda la carta) → gastro_category_modifier_groups (una categoría) → gastro_product_modifier_groups (un plato, único con overrides de cardinalidad). Snapshot inmutable en gastro_ticket_item_modifiers + modifiers_total en la línea (unit_price NUNCA se recalcula, ADR-010 #2). "Sin X" virtual derivado de product.ingredients, sin fila real, y descartando lo que los grupos reales ya ofrecen (dos formas de decir lo mismo a cocina es causa documentada de error de comanda). is_default = "la de siempre" que marca el dueño, solo en grupos de una obligatoria. Solo lo OBLIGATORIO interrumpe: la hoja se abre sola únicamente si has_required_modifiers; con grupos opcionales el tap agrega directo y el mesero abre la hoja con un botón en el tile (misma distinción de Toast entre "Required" y "Optional" sin POS prompt — forzarla en todo costaba un tap por plato para algo que casi nunca se usa). Endpoints /modifier-groups/* + /modifier-groups/{id}/scope + GET /products/{id}/modifier-groups. Frontend: OpcionesPage (/configuracion/opciones, con plantillas) + ModifierSheet/useModifierGate en Caja, ambos quick-add y Cocina |
PostgreSQL |
Migraciones c7d8e9f0a1b2 y a1b2c3d4e5f6. has_modifiers lo decide el CATÁLOGO (CategoryService.list_categories), no el producto: un grupo global o de su categoría también abre la hoja y eso el producto no lo ve desde su fila. Deuda: pre-modificadores tipo Toast (NO/EXTRA/AL LADO con multiplicador de precio) no existen |
| M3.3 Cocina (KDS MVP) |
Activo |
GET /pos/kitchen/tickets (comandas por servir agrupadas por mesa, solo líneas con sent_at), permiso pos.kitchen.view para el rol cocina, require_any_permission (cocina marca "Listo" sin necesitar pos.ticket.create); pantalla /cocina en admin-panel (tarjetas por mesa, cronómetro desde sent_at, botón "Listo"), sincronizada vía el canal /ws/floor existente. Jerarquía en la comanda: remociones primero, en rojo y mayúsculas, luego los extras y de último el comentario libre |
PostgreSQL |
Sin routing por estación (Fry/Grill/Bar) — queda para V2. Deuda conocida: lógica de agrupación vive en el router en vez del service (pendiente de mover); cronómetro no hace tick en vivo entre refrescos |
| Order & Pay / IA (Opera) |
Roadmap |
No activo |
— |
Sin decidir si serán feature de plan o add-on (dejémoslo así por ahora, decisión explícita del founder pendiente) |
| M4 Inventario |
Roadmap |
No activo |
— |
Referencias documentales solamente |
| M5 Caja / Pagos |
Roadmap |
No activo |
— |
OmniCortex pagos no integrado aún |
| M6 Gastos |
Roadmap |
No activo |
— |
Sin bounded context activo |
| M7 Reportes |
Roadmap |
No activo |
— |
Sin bounded context activo |
| M8 Integraciones ecosistema |
Parcial |
Integraciones puntuales, no suite completa |
Gemini, Foveo |
Travel, RAG Studio y AI Orchestrator no están vivos en Gastro hoy |