Forráskód Böngészése

Deploy desde script deploy.sh

Erwin Jacimino 2 hete
szülő
commit
c9dca18f90

+ 0 - 0
AGENTS.md


+ 63 - 0
CLAUDE.md

@@ -0,0 +1,63 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+**Biergarten Klein** - FastAPI backend for a craft beer bar ordering system. Serves a web frontend and provides an AI assistant ("Camilo Klein") powered by OpenAI GPT-4o-mini.
+
+## Commands
+
+### Development
+```bash
+python main.py
+```
+
+### Production
+```bash
+gunicorn -w 4 -k uvicorn.workers.UvicornWorker app:app --bind 0.0.0.0:6001
+```
+
+### Frontend (Tailwind CSS)
+```bash
+npm install
+npx tailwindcss -i ./public/styles/input.css -o ./public/styles/output.css --watch
+```
+
+### Requirements
+```bash
+pip install -r requirements.txt
+```
+
+## Architecture
+
+### Entry Point Flow
+`main.py` → `app.py:create_app()` → `app.py:setup_routes()` → mounts routers from `routes/`
+
+### Key Directories
+- **config/** - Settings, messages, email templates
+- **routes/** - FastAPI routers (chat, orders, products, users, sales, store)
+- **services/** - Business logic (openai_service/, data_service.py, print_service.py, fudo_service.py)
+- **models/** - Pydantic schemas for request/response validation
+- **auth/** - Token generation and validation (security.py)
+- **middleware/** - NoCacheMiddleware, InTimeMiddleware (currently disabled)
+- **data/** - JSON data files (products.json, llm_data.json)
+- **public/** - Frontend (Vanilla JS, Tailwind CSS)
+
+### Dependencies
+- **Redis** (localhost:6379) - Session tokens, anti-abuse tokens, connected users list
+- **PostgreSQL** - Configured via POSTGRES_* env vars (db: pedidos_express_pruebas by default)
+- **OpenAI API** - GPT-4o-mini for AI assistant
+- **Fudo POS** - External ordering system integration
+
+### Route Protection
+Most API routes require `Depends(get_current_user)` from `auth/security.py`. Chat endpoints use a separate anti-abuse token system (`/api/chat/init-chat` → `/api/chat/completions`).
+
+### Configuration
+All settings loaded from `.env` via `config/settings.py` using Pydantic. `validate_config()` checks required vars on startup.
+
+## Commit Convention
+Use Spanish conventional commits: `tipo(ámbito): descripción` (see `.github/commit-instructions.md`)
+
+## Database
+PostgreSQL connection config in `config/settings.py:POSTGRESQL_DB_CONFIG`. Redis DB 0 for production, DB 1 for development (clears `connected_users` and `chat_history` on startup).

+ 251 - 0
docs/migracion_fudo_toteat.md

@@ -0,0 +1,251 @@
+# Auditoría Fudo → Toteat e Impresoras
+
+Fecha del informe: 2026-05-05
+Repositorio: `pedidos_express_server` (FastAPI, "Biergarten Klein")
+
+Este documento contiene:
+
+1. Auditoría completa del uso de la API de Fudo en el código actual.
+2. Descripción del sistema de impresoras y su acoplamiento con Fudo.
+3. Notas operativas para migrar a la API de Toteat usando el nuevo módulo
+   `toteat/toteat.py`.
+
+---
+
+## 1. Auditoría del uso de la API de Fudo
+
+### 1.1 Funciones definidas en `fudo/fudo.py`
+
+| Función | Tipo | Propósito |
+|---|---|---|
+| `get_token()` | sync | OAuth con `FUDO_API_KEY` / `FUDO_API_SECRET`, cachea token en Redis. |
+| `get_modifiers()` | sync | GET `/v1alpha1/product-modifiers`. **Definida pero nunca llamada.** |
+| `get_categories()` | sync | GET `/v1alpha1/product-categories`. |
+| `get_category_dict()` | sync | Diccionario `{id: nombre}` derivado de `get_categories`. |
+| `get_category(id)` | sync | GET de una categoría puntual. |
+| `get_product(id)` | sync | GET de un producto, devuelve `models.items.Product`. |
+| `get_products(page)` | sync | GET paginado de `/v1alpha1/products`. |
+| `get_all_indexed_products()` | async | Concurrencia con `aiohttp` (8 páginas en paralelo). Filtra `active && enableQrMenu`. |
+| `get_all_products()` | async | Wrapper que devuelve la lista. |
+| `_get_page_bounds(page, token)` | sync | Helper interno, retorna `(first, last, data)` de una página de mesas. |
+| `get_table(number)` | sync | Búsqueda exponencial + binaria sobre `/v1alpha1/tables` (Fudo no permite filtrar por número). |
+| `get_table_items(number)` | sync | Encadena `get_table` → `get_active_sale` → `get_sale` → `ThreadPoolExecutor` (max_workers=10) sobre los items. |
+| `get_sale(id)` | sync | GET `/v1alpha1/sales/{id}`. |
+| `create_sale(table_id)` | sync | POST `/v1alpha1/sales` con `people=1`, `saleType=EAT-IN`, `waiter.id=76`. |
+| `create_item(product_id, qty, sale_id, comment)` | sync | POST `/v1alpha1/items`, prefija comentario con "Pedido desde pedidos express". |
+| `get_active_sale(table)` | sync | Lee `relationships.activeSales.data[0]`. |
+| `clear_token()` | sync | Borra el token cacheado en Redis. |
+
+### 1.2 Uso por archivo
+
+#### `routes/orders.py`
+
+| Línea | Llamado | Propósito |
+|---|---|---|
+| 180 | `fudo.get_token()` | Validación defensiva de conectividad antes de procesar el pedido. |
+| 190 | `add_product_to_fudo(item.id, item.quantity, table)` | Loop por cada item del pedido (wrapper de `services/fudo_service.py`). |
+| 221 | `fudo.get_active_sale(fudo.get_table(table))` | Recupera el `fudo_id` para guardar la venta en la BD local. |
+
+#### `routes/products.py`
+
+| Línea | Llamado | Propósito |
+|---|---|---|
+| 250 | `get_products_by_table(table_number)` | Endpoint `GET /products/table/{table_number}`. |
+
+#### `routes/store.py`
+
+| Línea | Llamado | Propósito |
+|---|---|---|
+| 33 | `get_table(int(q))` | Endpoint `GET /store/tables/exists?q=N`. |
+
+#### `services/fudo_service.py`
+
+| Línea | Llamado | Propósito |
+|---|---|---|
+| 8 | `fd.get_table(table_number)` | Resuelve mesa antes de añadir item. |
+| 13 | `fd.get_active_sale(table)` | Si no existe, crea venta. |
+| 15 | `fd.create_sale(table['id'])` | Crea venta nueva en Fudo. |
+| 20 | `fd.create_item(product_id, quantity, sale_id, comment)` | Añade item. |
+| 29 | `fd.get_table_items(table_number)` | Lectura de items de mesa. |
+
+#### `services/data_service.py`
+
+| Línea | Llamado | Propósito |
+|---|---|---|
+| 10 | `from fudo.fudo import get_all_products, get_category_dict` | Imports. |
+| 563 | `await get_all_products()` | Carga del catálogo en `ProductDataService.get_all` (con cache local). |
+| 569 | `get_category_dict()` | Resuelve nombre de categoría a partir de `categoryId`. |
+
+#### `load_products.py`
+
+| Línea | Llamado | Propósito |
+|---|---|---|
+| 1 | `from fudo.fudo import get_all_products, get_product` | Imports (script). |
+
+#### `update_prices.py`
+
+| Línea | Llamado | Propósito |
+|---|---|---|
+| 4 | `from fudo.fudo import get_product, get_products, get_category, get_all_products` | Imports (script). |
+| 72 | `get_category(int(...))` | Resuelve nombre de categoría para insertar productos nuevos. |
+| 121 | `get_all_products()` (sync, sin `await` — bug del script) | Carga el catálogo para sincronizar precios. |
+
+### 1.3 Resumen por función
+
+| Función | Frecuencia | Archivos que la usan |
+|---|---|---|
+| `get_token` | 1+ por request | `fudo.py`, `routes/orders.py:180` |
+| `get_table` | 3-5 por pedido | `services/fudo_service.py:8`, `routes/orders.py:221`, `routes/store.py:33` |
+| `get_active_sale` | 2-3 por pedido | `services/fudo_service.py:13`, `routes/orders.py:221` |
+| `create_sale` | 0-1 por mesa nueva | `services/fudo_service.py:15` |
+| `create_item` | N por pedido | `services/fudo_service.py:20` |
+| `get_table_items` | 1 por endpoint | `services/fudo_service.py:29`, `routes/products.py:250` |
+| `get_all_products` (async) | 1 al iniciar / refrescar cache | `services/data_service.py:563`, `load_products.py`, `update_prices.py` |
+| `get_category_dict` | 1 al iniciar | `services/data_service.py:569` |
+| `get_category` | N (script) | `update_prices.py:72` |
+| `get_products` | raro (script) | `update_prices.py` |
+| `get_product` | raro (script) | `load_products.py`, `update_prices.py` |
+| `get_modifiers` | nunca | — |
+
+### 1.4 Transformaciones notables
+
+- `get_product` (`fudo.py:138-150`): mapea el JSON:API de Fudo a `models.items.Product`, con conversión `active: bool → status: 0/1`.
+- `get_all_indexed_products` (`fudo.py:217-219`): filtra `active && enableQrMenu` antes de indexar.
+- `create_item` (`fudo.py:452`): siempre prefija el comentario con "Pedido desde pedidos express".
+- `get_table_items` (`fudo.py:388-392`): proyecta cada línea a `{id: int, quantity: int}` y descarta el resto.
+- `update_prices.add_missing_products` (`update_prices.py:80`): construye la URL de imagen como
+  `https://fudo-apps-storage.s3.sa-east-1.amazonaws.com/production/113378/common/products/{id}` (acoplamiento al bucket S3 de Fudo).
+
+---
+
+## 2. Sistema de impresoras
+
+### 2.1 Arquitectura
+
+El servicio `services/print_service.py` **no habla con hardware local**: es un
+cliente HTTP a un servidor remoto que se encarga de la impresión física
+(probablemente ESC/POS o CUPS detrás de él).
+
+| Item | Valor |
+|---|---|
+| Endpoint base | `http://10.10.12.3:8000` (hardcoded en `print_service.py`) |
+| Auth | `Authorization: Bearer PRINTER123cerveza@` (hardcoded) |
+| Timeouts | 10s pedidos/tickets · **1000s facturación (anómalo)** |
+| Librerías Python | `requests` y `subprocess` (para `ping`). No usa `python-escpos` ni `pycups`. |
+
+### 2.2 Endpoints del servidor de impresión
+
+| Función Python | HTTP | Disparado por |
+|---|---|---|
+| `print_order(Order)` | `POST /print` | `POST /api/orders/send` (`routes/orders.py:281`) |
+| `print_ticket(table_id)` | `GET /ticket/{table}` | `GET /api/products/free-beer/{table_id}` y `routes/users.py:313` |
+| `print_billing(OrderBilling)` | `GET /billing/{table}/{payment}` | `POST /api/orders/billing` |
+| `get_status()` | `GET /status` | No invocada en routes (solo manual). |
+
+### 2.3 Validación de conectividad
+
+En `routes/orders.py:126-142`, antes de procesar el pedido:
+
+```python
+printers = {"ServerPrincipal": "10.10.12.3"}
+for name, ip in printers.items():
+    response = subprocess.run(
+        ["/usr/bin/ping", "-c", "1", "-W", "10", ip],
+        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+    )
+```
+
+Si el ping falla:
+
+- HTTP 424 al cliente con `ErrorResponse.PRINTER_DISCONNECTED`.
+- Email a `erwinjacimino2003@gmail.com` y `mompyn@gmail.com` usando la
+  plantilla `PRINTER_DISCONNECTED_MAIL` (`config/mails.py`).
+
+### 2.4 Acoplamiento con Fudo
+
+1. `kitchen_id` enviado al servidor de impresión sale de `Product.kitchen_id`,
+   y este venía originalmente de `fudo.relationships.kitchen.data.id`
+   (`fudo.py:146`). El módulo `toteat/toteat.py` mantiene este campo
+   mapeándolo desde `categoryId` (Toteat no expone "kitchen" como Fudo).
+2. Orden de operaciones en `printer_order`:
+   ping → POST a Fudo (loop de items) → guardar venta en BD local con
+   `fudo_id` (de Fudo) → POST `/print`.
+3. Riesgo: si Fudo cae **después** del ping, todo el pedido falla con 500.
+   Si la impresora se desconecta **después** de los `create_item` exitosos en
+   Fudo, hay venta registrada en Fudo + BD pero sin comanda física.
+
+### 2.5 Configuración
+
+No hay variables de entorno para impresoras. Toda la configuración (IP, token,
+timeouts) está hardcoded en `services/print_service.py`. Mover a
+`config/settings.py` es trabajo futuro.
+
+---
+
+## 3. Plan de migración usando `toteat/toteat.py`
+
+### 3.1 Variables de entorno nuevas
+
+```env
+TOTEAT_API_TOKEN=...
+TOTEAT_RESTAURANT_ID=...
+TOTEAT_LOCAL_ID=...
+TOTEAT_USER_ID=...
+```
+
+La URL base alterna entre `apidev.toteat.com` y `api.toteat.com` según la flag
+`DEVELOPMENT` ya existente.
+
+### 3.2 Equivalencias
+
+El módulo `toteat/toteat.py` expone **exactamente** las mismas funciones
+públicas que `fudo/fudo.py`, devolviendo datos en la forma JSON:API estilo
+Fudo. Para activarlo:
+
+1. En `services/fudo_service.py`: cambiar `import fudo.fudo as fd` por
+   `import toteat.toteat as fd`.
+2. En `services/data_service.py:10`: cambiar
+   `from fudo.fudo import get_all_products, get_category_dict` por
+   `from toteat.toteat import get_all_products, get_category_dict`.
+3. En `routes/orders.py:13`: cambiar `from fudo import fudo` por
+   `from toteat import toteat as fudo`.
+4. En `routes/store.py:8`: cambiar `from fudo.fudo import get_table` por
+   `from toteat.toteat import get_table`.
+5. En `load_products.py` y `update_prices.py`: idem.
+
+`fudo/` queda intacto durante la transición; cuando la migración esté
+validada en producción se puede borrar.
+
+### 3.3 Diferencias funcionales conocidas
+
+- **`get_modifiers`**: nunca se usa, solo está por compatibilidad. En Toteat
+  los modificadores vienen embebidos en `/products`.
+- **`get_products(page)`**: Toteat no pagina. Retorna todo en `page=1` y
+  lista vacía para `page > 1` (compatible con loops `while data`).
+- **`create_sale` + `create_item`**: el flujo `crear venta vacía + N items`
+  de Fudo se preserva. En Toteat la primera llamada a `create_item` para una
+  venta hace `POST /orders` con `orderId=0`; las siguientes hacen
+  `POST /orders` con `orderId=<real>` para añadir líneas a la orden de mesa
+  existente. El `orderId` se cachea en Redis (`toteat:table_order:{N}`,
+  TTL 8h) para que `get_active_sale` siga devolviendo el mismo ID después
+  del loop.
+- **`kitchen_id`**: por defecto se mapea a `categoryId`. Si en el futuro hace
+  falta enrutar items a impresoras distintas según otra dimensión, hay que
+  agregar un mapping configurable en `_to_fudo_product`.
+- **Rate limits Toteat**:
+  - `/products`: 3 req/min → cache local de 5 min.
+  - `/tables`: 3 req/min → cache local de 1 min.
+  - `/orders`: 1 req/seg → para pedidos largos puede agregar latencia. Si se
+    vuelve cuello de botella, se puede refactorizar `routes/orders.py` para
+    enviar todos los items en una sola llamada (el módulo ya soporta el
+    payload `line=[...]`).
+
+### 3.4 Auditoría rápida post-migración
+
+Antes de borrar `fudo/`:
+
+1. Probar `python -m toteat.toteat` (suite de smoke tests embebida).
+2. Validar un pedido end-to-end en ambiente dev (`apidev.toteat.com`).
+3. Revisar logs por `Toteat GET ... falló` o `status=4xx`.
+4. Confirmar que `fudo_id` en la tabla `sales` ahora guarda el `orderId` de
+   Toteat (es un entero largo, no string corto).

+ 1 - 2
fudo/fudo.py

@@ -1,5 +1,4 @@
 import itertools
-import math
 from time import time
 import requests
 from config.settings import DEVELOPMENT
@@ -512,4 +511,4 @@ Configuración de Redis:
 
 if __name__ == "__main__":
     from rich import print
-    print(get_table_items(106))
+    print(get_table_items(106))

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
public/main/assets/index-CRUsByxL.css


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 16
public/main/assets/index-LC7je4Q6.js


BIN
public/main/assets/no_image-DuvbomyT.png


+ 0 - 26
public/main/index.html

@@ -1,26 +0,0 @@
-<!DOCTYPE html>
-<html lang="es" class="dark">
-  <head>
-    <meta charset="utf-8" />
-    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>Pedidos Express</title>
-    <link rel="preconnect" href="https://fonts.googleapis.com" />
-    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
-        <!-- <script>
-      const params = new URLSearchParams(window.location.search);
-      const debug = params.get("debug") === "1";
-      if (!debug) {
-        window.location.replace("https://menu.fu.do/klein/qr-menu")
-      }
-    </script> -->
-    <link
-      href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Noto+Sans:wght@300;400;500;600;700&family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"
-      rel="stylesheet"
-    />
-  <script type="module" crossorigin src="/express/assets/index-LC7je4Q6.js"></script>
-  <link rel="stylesheet" crossorigin href="/express/assets/index-CRUsByxL.css">
-</head>
-  <body>
-    <div id="root"></div>
-</body>
-</html>

+ 0 - 0
toteat/__init__.py


+ 780 - 0
toteat/toteat.py

@@ -0,0 +1,780 @@
+"""
+Cliente para la API de Toteat POS.
+
+Expone la misma superficie pública que `fudo/fudo.py` para que
+`services/fudo_service.py`, `services/data_service.py`, los routes y los
+scripts (`load_products.py`, `update_prices.py`) puedan migrar cambiando
+solo el import.
+
+Diferencias principales con Fudo cubiertas en este módulo:
+- Toteat no usa flujo de OAuth; las credenciales viajan como query params
+  (`xir`, `xil`, `xiu`, `xapitoken`) en cada request. `get_token()` queda
+  como no-op para mantener compatibilidad.
+- `/products` devuelve todo el menú en una sola llamada (con categorías y
+  modificadores embebidos). Las funciones que en Fudo eran paginadas o
+  separadas se construyen sobre un cache compartido.
+- El menú se traduce a la forma JSON:API que esperan los callers de Fudo
+  (`{"id", "attributes", "relationships"}`), para mantener la compatibilidad
+  con `services/data_service.py:get_all` y `update_prices.py`.
+- El flujo Fudo de crear venta vacía y luego agregar items uno por uno se
+  mapea a POST /orders incremental: la primera llamada a `create_item`
+  para una venta crea la orden en Toteat, las siguientes la actualizan
+  (Toteat permite añadir productos a una orden de mesa existente enviando
+  el `orderId` real).
+"""
+import os
+import time
+import asyncio
+from logging import getLogger
+from typing import Any, Dict, List, Optional
+from uuid import uuid4
+
+import requests
+import aiohttp
+import redis
+from config.settings import DEVELOPMENT
+from models.items import Product
+logger = getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Configuración
+# ---------------------------------------------------------------------------
+
+API_TOKEN = os.getenv("TOTEAT_API_TOKEN", "")
+RESTAURANT_ID = os.getenv("TOTEAT_RESTAURANT_ID", "")
+LOCAL_ID = os.getenv("TOTEAT_LOCAL_ID", "")
+USER_ID = os.getenv("TOTEAT_USER_ID", "")
+
+# Toteat recomienda usar siempre las URLs nuevas; el sistema redirige al
+# ambiente legacy si corresponde.
+BASE_URL = (
+    "https://apidev.toteat.com/mw/or/1.0"
+    if DEVELOPMENT
+    else "https://api.toteat.com/mw/or/1.0"
+)
+
+REQUEST_TIMEOUT = 15
+
+# Cache del menú: Toteat trae todos los productos en una sola request, pero
+# está limitado a 3 req/min. Cacheamos por unos minutos.
+_MENU_CACHE_TTL = 60 * 5  # 5 minutos
+_menu_cache: Dict[str, Any] = {"data": None, "expires": 0.0}
+
+# Cache de mesas: 3 req/min también.
+_TABLES_CACHE_TTL = 60
+_tables_cache: Dict[str, Any] = {"data": None, "expires": 0.0}
+
+# Mapeo en memoria de número de mesa → orderId committed en Toteat. Se llena
+# cuando `create_item` crea/actualiza una orden y se consulta desde
+# `get_active_sale` para devolver el id real.
+_table_to_order: Dict[int, str] = {}
+
+# Buffers de venta pendientes (sale_id sintético → estado interno).
+_pending_sales: Dict[str, Dict[str, Any]] = {}
+
+redis_client = redis.Redis(
+    host=os.getenv("REDIS_HOST", "localhost"),
+    port=int(os.getenv("REDIS_PORT", 6379)),
+    db=1 if DEVELOPMENT else 0,
+    decode_responses=True,
+)
+
+REDIS_TABLE_ORDER_KEY = "toteat:table_order:{table_number}"
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _auth_params() -> Dict[str, str]:
+    return {
+        "xir": RESTAURANT_ID,
+        "xil": LOCAL_ID,
+        "xiu": USER_ID,
+        "xapitoken": API_TOKEN,
+    }
+
+
+def _get(path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
+    url = f"{BASE_URL}{path}"
+    full_params = {**_auth_params(), **(params or {})}
+    try:
+        r = requests.get(url, params=full_params, timeout=REQUEST_TIMEOUT)
+    except requests.RequestException as e:
+        logger.error(f"Toteat GET {path} falló: {e}")
+        return None
+    if r.status_code != 200:
+        logger.error(f"Toteat GET {path} status={r.status_code} body={r.text[:300]}")
+        return None
+    try:
+        return r.json()
+    except ValueError:
+        logger.error(f"Toteat GET {path} respuesta no-JSON: {r.text[:300]}")
+        return None
+
+
+def _post(path: str, body: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
+    url = f"{BASE_URL}{path}"
+    full_params = {**_auth_params(), **(params or {})}
+    try:
+        r = requests.post(url, params=full_params, json=body, timeout=REQUEST_TIMEOUT)
+    except requests.RequestException as e:
+        logger.error(f"Toteat POST {path} falló: {e}")
+        return None
+    if r.status_code not in (200, 201):
+        logger.error(f"Toteat POST {path} status={r.status_code} body={r.text[:300]}")
+        return None
+    try:
+        return r.json()
+    except ValueError:
+        logger.error(f"Toteat POST {path} respuesta no-JSON: {r.text[:300]}")
+        return None
+
+
+def _persist_table_order(table_number: int, order_id: str) -> None:
+    _table_to_order[table_number] = order_id
+    try:
+        redis_client.setex(
+            REDIS_TABLE_ORDER_KEY.format(table_number=table_number),
+            60 * 60 * 8,  # 8h de validez para una orden abierta
+            order_id,
+        )
+    except Exception as e:
+        logger.warning(f"No se pudo persistir order_id en Redis: {e}")
+
+
+def _load_table_order(table_number: int) -> Optional[str]:
+    if table_number in _table_to_order:
+        return _table_to_order[table_number]
+    try:
+        cached = redis_client.get(REDIS_TABLE_ORDER_KEY.format(table_number=table_number))
+        if cached:
+            _table_to_order[table_number] = str(cached)
+            return str(cached)
+    except Exception as e:
+        logger.warning(f"No se pudo leer order_id desde Redis: {e}")
+    return None
+
+
+def _clear_table_order(table_number: int) -> None:
+    _table_to_order.pop(table_number, None)
+    try:
+        redis_client.delete(REDIS_TABLE_ORDER_KEY.format(table_number=table_number))
+    except Exception:
+        pass
+
+
+# ---------------------------------------------------------------------------
+# Mapeo Toteat → forma JSON:API estilo Fudo
+# ---------------------------------------------------------------------------
+#
+# Los callers existentes (data_service.ProductDataService.get_all,
+# update_prices.add_missing_products) leen los productos como:
+#   product["id"]
+#   product["attributes"]["name"|"price"|"active"|"imageUrl"|"description"|"enableQrMenu"]
+#   product["relationships"]["productCategory"]["data"]["id"]
+#   product["relationships"]["kitchen"]["data"]["id"]
+#
+# Para mantenerlos sin cambios, traducimos el shape de Toteat al de Fudo.
+#
+# Nota sobre kitchen_id: Toteat no expone "kitchen" como Fudo. Se mapea por
+# defecto al `categoryId` para que `print_service` siga ruteando items por
+# categoría. Si en el futuro hay que enviar items específicos a otra
+# impresora se puede agregar un mapping configurable.
+
+def _to_fudo_product(toteat_item: Dict[str, Any]) -> Dict[str, Any]:
+    images = toteat_item.get("images") or []
+    image_url = ""
+    if images:
+        first = images[0]
+        if isinstance(first, dict):
+            image_url = first.get("url") or first.get("image") or ""
+        elif isinstance(first, str):
+            image_url = first
+
+    category_id = toteat_item.get("categoryId")
+    is_modifier = bool(toteat_item.get("isModifier"))
+
+    return {
+        "id": str(toteat_item.get("id", "")),
+        "type": "Product",
+        "attributes": {
+            "name": toteat_item.get("name", ""),
+            "price": toteat_item.get("price", 0),
+            "active": True,  # Toteat solo devuelve activos por defecto
+            "imageUrl": image_url,
+            "description": toteat_item.get("description", ""),
+            "enableQrMenu": not is_modifier,
+            "isModifier": is_modifier,
+            "sorting": toteat_item.get("sorting", ""),
+            "localCode": toteat_item.get("localCode", ""),
+            "referencePrice": toteat_item.get("referencePrice"),
+            "modificationDate": toteat_item.get("modificationDate"),
+        },
+        "relationships": {
+            "productCategory": {
+                "data": {
+                    "id": str(category_id) if category_id is not None else "0",
+                    "type": "ProductCategory",
+                }
+            },
+            "kitchen": {
+                "data": {
+                    "id": str(category_id) if category_id is not None else "0",
+                    "type": "Kitchen",
+                }
+            },
+        },
+        "_toteat_raw": toteat_item,
+    }
+
+
+# ---------------------------------------------------------------------------
+# API pública – mismo nombre que en fudo/fudo.py
+# ---------------------------------------------------------------------------
+
+def get_token() -> str:
+    """Toteat no usa OAuth; mantenemos la firma para compatibilidad."""
+    return API_TOKEN
+
+
+def clear_token() -> None:
+    """No-op para compatibilidad con fudo.fudo.clear_token."""
+    return None
+
+
+def _fetch_menu(force: bool = False) -> List[Dict[str, Any]]:
+    now = time.time()
+    if not force and _menu_cache["data"] is not None and _menu_cache["expires"] > now:
+        return _menu_cache["data"]
+
+    payload = _get("/products", params={"activeProducts": "true"})
+    if not payload or not payload.get("ok"):
+        logger.error(f"Toteat /products no devolvió data útil: {payload}")
+        return _menu_cache["data"] or []
+
+    raw = payload.get("data") or []
+    _menu_cache["data"] = raw
+    _menu_cache["expires"] = now + _MENU_CACHE_TTL
+    return raw
+
+
+def get_modifiers() -> Dict[str, Any]:
+    """En Toteat los modificadores vienen embebidos en `/products`. Para
+    mantener compatibilidad devolvemos un dict similar al de Fudo."""
+    items = _fetch_menu()
+    modifiers = [i for i in items if i.get("isModifier")]
+    return {"data": [_to_fudo_product(m) for m in modifiers]}
+
+
+def get_categories() -> List[Dict[str, Any]]:
+    """Devuelve las categorías derivadas del menú de Toteat en forma JSON:API."""
+    items = _fetch_menu()
+    seen: Dict[str, str] = {}
+    for it in items:
+        cat_id = it.get("categoryId")
+        cat_name = it.get("category")
+        if cat_id is None or cat_name is None:
+            continue
+        seen[str(cat_id)] = cat_name
+    return [
+        {
+            "id": cid,
+            "type": "ProductCategory",
+            "attributes": {"name": cname, "enableOnlineMenu": True},
+        }
+        for cid, cname in seen.items()
+    ]
+
+
+def get_category_dict() -> Dict[str, str]:
+    return {c["id"]: c["attributes"]["name"] for c in get_categories()}
+
+
+def get_category(id_category) -> Dict[str, Any]:
+    cid = str(id_category)
+    for c in get_categories():
+        if c["id"] == cid:
+            return {
+                "id": c["id"],
+                "name": c["attributes"]["name"],
+                "enableOnlineMenu": c["attributes"].get("enableOnlineMenu", True),
+            }
+    return {"id": cid, "name": "Producto", "enableOnlineMenu": False}
+
+
+def get_product(product_id) -> Optional[Product]:
+    pid = str(product_id)
+    for it in _fetch_menu():
+        if str(it.get("id")) == pid:
+            mapped = _to_fudo_product(it)
+            cat_id = mapped["relationships"]["productCategory"]["data"]["id"]
+            return Product(
+                id=int(mapped["id"]),
+                name=mapped["attributes"]["name"],
+                type=get_category(cat_id)["name"] or "Producto",
+                price=int(mapped["attributes"]["price"] or 0),
+                image=mapped["attributes"]["imageUrl"],
+                description=mapped["attributes"]["description"],
+                status=1,
+                kitchen_id=int(mapped["relationships"]["kitchen"]["data"]["id"] or 0),
+                promo_day=None,
+                promo_price=None,
+                promo_id=None,
+            )
+    logger.error(f"Producto {product_id} no encontrado en menú Toteat")
+    return None
+
+
+def get_products(page: int = 1) -> List[Dict[str, Any]]:
+    """Toteat no pagina /products; devolvemos todo en page=1 y vacío después
+    para mantener el contrato de paginación de Fudo (loops `while data`)."""
+    if page != 1:
+        return []
+    return [_to_fudo_product(p) for p in _fetch_menu() if not p.get("isModifier")]
+
+
+async def get_all_indexed_products() -> Dict[str, Dict[str, Any]]:
+    """Equivalente al método async de Fudo. Como Toteat trae todo en una
+    sola request, lo hacemos en un thread para no bloquear el event loop."""
+    items = await asyncio.to_thread(_fetch_menu)
+    indexed: Dict[str, Dict[str, Any]] = {}
+    for it in items:
+        if it.get("isModifier"):
+            continue
+        mapped = _to_fudo_product(it)
+        indexed[mapped["id"]] = mapped
+    return indexed
+
+
+async def get_all_products() -> List[Dict[str, Any]]:
+    return list((await get_all_indexed_products()).values())
+
+
+# ---------------------------------------------------------------------------
+# Mesas
+# ---------------------------------------------------------------------------
+
+def _fetch_tables(force: bool = False) -> List[Dict[str, Any]]:
+    now = time.time()
+    if not force and _tables_cache["data"] is not None and _tables_cache["expires"] > now:
+        return _tables_cache["data"]
+
+    payload = _get("/tables")
+    if not payload or not payload.get("ok"):
+        logger.error(f"Toteat /tables no devolvió data útil: {payload}")
+        return _tables_cache["data"] or []
+
+    raw = payload.get("data") or []
+    _tables_cache["data"] = raw
+    _tables_cache["expires"] = now + _TABLES_CACHE_TTL
+    return raw
+
+
+def _to_fudo_table(toteat_table: Dict[str, Any]) -> Dict[str, Any]:
+    """Toteat /tables devuelve registros con campos como `tableId`, `name`,
+    `available`, `capacity`. Algunos ambientes exponen `number`. Mapeamos al
+    shape JSON:API que espera el código existente."""
+    table_id = toteat_table.get("tableId") or toteat_table.get("id")
+    number = toteat_table.get("number")
+    if number is None:
+        # Algunos ambientes guardan el número como `name` (string).
+        try:
+            number = int(toteat_table.get("name", "0"))
+        except (TypeError, ValueError):
+            number = 0
+
+    available = toteat_table.get("available", True)
+
+    # Active sale: Toteat no la embebe en /tables. Si tenemos un orderId
+    # registrado en memoria/Redis para esta mesa lo exponemos. Si la mesa
+    # está marcada como ocupada pero no tenemos orderId, dejamos vacío
+    # (el caller terminará creando una venta nueva).
+    cached_order = _load_table_order(int(number)) if number else None
+    active_sales: List[Dict[str, str]] = []
+    if cached_order:
+        active_sales.append({"id": cached_order, "type": "Sale"})
+    elif not available:
+        # Mesa ocupada pero sin orderId conocido → intentamos descubrirlo.
+        discovered = _discover_open_order_for_table(table_id)
+        if discovered:
+            _persist_table_order(int(number), discovered)
+            active_sales.append({"id": discovered, "type": "Sale"})
+
+    return {
+        "id": str(table_id) if table_id is not None else "",
+        "type": "Table",
+        "attributes": {
+            "number": number,
+            "available": available,
+            "capacity": toteat_table.get("capacity"),
+            "name": toteat_table.get("name"),
+        },
+        "relationships": {
+            "activeSales": {"data": active_sales},
+        },
+        "_toteat_raw": toteat_table,
+    }
+
+
+def _discover_open_order_for_table(table_id: Any) -> Optional[str]:
+    """Busca en Toteat órdenes abiertas y filtra por tableId."""
+    payload = _get("/orderstatus", params={"listing": "true"})
+    if not payload or not payload.get("ok"):
+        return None
+    data = payload.get("data") or []
+    if not isinstance(data, list):
+        return None
+    target = str(table_id)
+    for order in data:
+        otable = order.get("tableId") or order.get("table") or order.get("table_id")
+        if otable is not None and str(otable) == target:
+            oid = order.get("orderId") or order.get("id")
+            if oid is not None:
+                return str(oid)
+    return None
+
+
+def get_table(number: int) -> Optional[Dict[str, Any]]:
+    for raw in _fetch_tables():
+        raw_number = raw.get("number")
+        if raw_number is None:
+            try:
+                raw_number = int(raw.get("name", "0"))
+            except (TypeError, ValueError):
+                raw_number = None
+        if raw_number == number:
+            return _to_fudo_table(raw)
+    return None
+
+
+def get_active_sale(table: Optional[Dict[str, Any]]) -> Optional[Dict[str, str]]:
+    if not table:
+        return None
+    data = table.get("relationships", {}).get("activeSales", {}).get("data") or []
+    if not data:
+        return None
+    return data[0]
+
+
+def get_sale(sale_id) -> Optional[Dict[str, Any]]:
+    payload = _get("/orderstatus", params={"ic": str(sale_id), "det": "true"})
+    if not payload or not payload.get("ok"):
+        return None
+    return payload
+
+
+# ---------------------------------------------------------------------------
+# Creación / actualización de órdenes
+# ---------------------------------------------------------------------------
+#
+# Estrategia para mantener el flujo line-by-line de Fudo:
+#
+# 1. `create_sale(table_id)` registra una venta pendiente en memoria y
+#    devuelve un id sintético "pending:<uuid>". No hace HTTP todavía.
+#
+# 2. `create_item(product_id, qty, sale_id, comment)`:
+#    - Si `sale_id` empieza con "pending:" y aún no hay orderId real, hace
+#      POST /orders con `orderId=0` y la línea actual. Toma el orderId de
+#      la respuesta (cuando está disponible) y lo guarda contra la mesa.
+#    - Si ya hay orderId real, hace POST /orders con `orderId=<real>` y la
+#      línea nueva (Toteat permite añadir líneas a órdenes abiertas en mesa).
+#
+# 3. `get_active_sale(table)` devuelve el orderId real si está cacheado.
+
+def _table_id_for_pending(sale_id: str) -> Optional[int]:
+    state = _pending_sales.get(sale_id)
+    if not state:
+        return None
+    return state.get("table_number")
+
+
+def create_sale(table_id) -> Optional[Dict[str, str]]:
+    """Equivalente a `fudo.create_sale`. No hace HTTP: registra una venta
+    pendiente vinculada al `table_id` (que en este módulo es el `tableId`
+    de Toteat). Para resolver el número de mesa hacemos lookup inverso."""
+    table_number: Optional[int] = None
+    for raw in _fetch_tables():
+        if str(raw.get("tableId") or raw.get("id")) == str(table_id):
+            n = raw.get("number")
+            if n is None:
+                try:
+                    n = int(raw.get("name", "0"))
+                except (TypeError, ValueError):
+                    n = None
+            table_number = n
+            break
+
+    if table_number is None:
+        logger.error(f"create_sale: no se pudo resolver número de mesa para tableId={table_id}")
+        return None
+
+    sale_id = f"pending:{uuid4().hex}"
+    _pending_sales[sale_id] = {
+        "table_id": str(table_id),
+        "table_number": int(table_number),
+        "items": [],
+        "order_id": None,
+    }
+    return {"id": sale_id, "type": "Sale"}
+
+
+def _build_line(product_id: int, quantity: int, comment: Optional[str]) -> Dict[str, Any]:
+    line: Dict[str, Any] = {
+        "productCode": str(product_id),
+        "quantity": quantity,
+    }
+    if comment:
+        line["comment"] = comment
+    return line
+
+
+def _post_order(table_id: str, lines: List[Dict[str, Any]], order_id: int = 0,
+                comment: Optional[str] = None) -> Optional[Dict[str, Any]]:
+    body: Dict[str, Any] = {
+        "restaurantId": int(RESTAURANT_ID) if RESTAURANT_ID else 0,
+        "localNumber": int(LOCAL_ID) if LOCAL_ID else 0,
+        "orderId": order_id,
+        "tableId": int(table_id),
+        "orderReference": uuid4().hex,
+        "status": "new",
+        "type": "order",
+        "channel": "app",
+        "vendorName": "Pedidos Express",
+        "comment": comment or "Pedido desde pedidos express",
+        "document": {
+            "line": lines,
+            "payments": [],
+        },
+        "operationDate": time.strftime("%Y-%m-%dT%H:%M:%S"),
+        "modifiedDate": time.strftime("%Y-%m-%dT%H:%M:%S"),
+    }
+    return _post("/orders", body, params={"orderDetail": "true"})
+
+
+def create_item(product_id: int, quantity: int, sale_id: str,
+                comment: Optional[str] = None) -> Optional[Dict[str, Any]]:
+    """Agrega un producto a una venta. Si la venta es pendiente (todavía no
+    existe en Toteat) hace el POST /orders inicial; si ya existe le añade la
+    línea. Devuelve un dict truthy con `id` para mantener compatibilidad con
+    los callers de Fudo."""
+    state = _pending_sales.get(sale_id)
+    if state is None:
+        logger.error(f"create_item: sale_id desconocido {sale_id}")
+        return None
+
+    line = _build_line(product_id, quantity, comment)
+    state["items"].append(line)
+
+    table_id = state["table_id"]
+    table_number = state["table_number"]
+    existing_order = state["order_id"] or _load_table_order(table_number)
+    order_id_int = int(existing_order) if existing_order else 0
+
+    response = _post_order(table_id, [line], order_id=order_id_int, comment=comment)
+    if response is None or not response.get("ok"):
+        logger.error(f"create_item: POST /orders falló para sale_id={sale_id} body={response}")
+        return None
+
+    # Toteat puede devolver el orderId en `data.orderId` o en el msg cuando
+    # el ambiente es legacy. Hacemos lookup defensivo.
+    order_id_returned: Optional[str] = None
+    data = response.get("data") if isinstance(response.get("data"), dict) else None
+    if data:
+        for key in ("orderId", "id"):
+            if data.get(key):
+                order_id_returned = str(data[key])
+                break
+
+    if order_id_returned and not state["order_id"]:
+        state["order_id"] = order_id_returned
+        _persist_table_order(table_number, order_id_returned)
+
+    return {
+        "id": state["order_id"] or order_id_returned or sale_id,
+        "type": "Item",
+        "attributes": {"quantity": quantity},
+    }
+
+
+# ---------------------------------------------------------------------------
+# Lectura de items en una mesa
+# ---------------------------------------------------------------------------
+
+def get_table_items(table_number: int) -> Optional[List[Dict[str, Any]]]:
+    """Equivalente a `fudo.get_table_items`. Resuelve la mesa, encuentra su
+    orden activa y devuelve `[{id, quantity}]` para cada línea."""
+    table = get_table(table_number)
+    if not table:
+        return None
+    active = get_active_sale(table)
+    if not active:
+        return None
+    order = get_sale(active["id"])
+    if not order:
+        return None
+
+    data = order.get("data") if isinstance(order.get("data"), dict) else order.get("data")
+    if not data:
+        return []
+
+    # Toteat con det=true devuelve la orden completa. Las líneas suelen estar
+    # en data.document.line o data.line; nos defendemos contra ambas.
+    lines: List[Dict[str, Any]] = []
+    if isinstance(data, dict):
+        document = data.get("document") or {}
+        lines = document.get("line") or data.get("line") or []
+    elif isinstance(data, list):
+        lines = data
+
+    result: List[Dict[str, Any]] = []
+    for line in lines:
+        try:
+            pid_raw = line.get("productCode") or line.get("productId") or line.get("product_id")
+            if pid_raw is None:
+                continue
+            qty = line.get("quantity") or 1
+            result.append({"id": int(pid_raw), "quantity": int(qty)})
+        except (TypeError, ValueError):
+            continue
+    return result
+
+
+# ---------------------------------------------------------------------------
+# Paridad async con Fudo
+# ---------------------------------------------------------------------------
+
+async def fetch_page(session: aiohttp.ClientSession, url_template: str,
+                     token: str, page_num: int):
+    """Stub de paridad. Toteat no expone /products paginado; devolvemos
+    listas vacías para páginas > 1 y delegamos a `_fetch_menu` para la 1."""
+    if page_num != 1:
+        return page_num, []
+    return page_num, await asyncio.to_thread(_fetch_menu)
+
+
+__all__ = [
+    "get_token",
+    "clear_token",
+    "get_modifiers",
+    "get_categories",
+    "get_category_dict",
+    "get_category",
+    "get_product",
+    "get_products",
+    "get_all_indexed_products",
+    "get_all_products",
+    "get_table",
+    "get_active_sale",
+    "get_sale",
+    "create_sale",
+    "create_item",
+    "get_table_items",
+]
+
+
+# ---------------------------------------------------------------------------
+# Smoke tests
+# ---------------------------------------------------------------------------
+#
+# Ejecutar con:  python -m toteat.toteat
+#
+# Requiere las env vars TOTEAT_API_TOKEN, TOTEAT_RESTAURANT_ID,
+# TOTEAT_LOCAL_ID, TOTEAT_USER_ID. Solo prueba lecturas seguras por defecto;
+# el bloque que crea órdenes está comentado y requiere descomentar
+# explícitamente para no impactar el ambiente real.
+
+def _smoke_tests() -> None:
+    from rich import print as rprint
+
+    rprint("[bold cyan]== Toteat smoke tests ==[/bold cyan]")
+    rprint(f"BASE_URL = {BASE_URL}")
+    missing = [
+        name for name, val in [
+            ("TOTEAT_API_TOKEN", API_TOKEN),
+            ("TOTEAT_RESTAURANT_ID", RESTAURANT_ID),
+            ("TOTEAT_LOCAL_ID", LOCAL_ID),
+            ("TOTEAT_USER_ID", USER_ID),
+        ] if not val
+    ]
+    if missing:
+        rprint(f"[redFaltan env vars:[/red] {missing}")
+        return
+
+    rprint("\n[yellow]1. get_token()[/yellow]")
+    rprint(f"  → token len={len(get_token())}")
+
+    rprint("\n[yellow]2. get_categories()[/yellow]")
+    cats = get_categories()
+    rprint(f"  → {len(cats)} categorías")
+    if cats:
+        rprint(f"  → ejemplo: {cats[0]}")
+
+    rprint("\n[yellow]3. get_category_dict()[/yellow]")
+    cat_dict = get_category_dict()
+    rprint(f"  → {len(cat_dict)} entradas")
+
+    rprint("\n[yellow]4. get_products(page=1) (primeras 3)[/yellow]")
+    prods = get_products(1)
+    rprint(f"  → {len(prods)} productos en página 1")
+    for p in prods[:3]:
+        rprint(
+            f"    • id={p['id']} name={p['attributes']['name']!r} "
+            f"price={p['attributes']['price']} cat={p['relationships']['productCategory']['data']['id']}"
+        )
+
+    rprint("\n[yellow]5. get_products(page=2) (debe ser [])[/yellow]")
+    rprint(f"  → {get_products(2)}")
+
+    rprint("\n[yellow]6. get_product(<primer id>)[/yellow]")
+    if prods:
+        first_id = prods[0]["id"]
+        product = get_product(first_id)
+        rprint(f"  → {product}")
+
+    rprint("\n[yellow]7. get_all_products() async[/yellow]")
+    all_prods = asyncio.run(get_all_products())
+    rprint(f"  → {len(all_prods)} productos totales")
+
+    rprint("\n[yellow]8. get_all_indexed_products() async[/yellow]")
+    indexed = asyncio.run(get_all_indexed_products())
+    rprint(f"  → {len(indexed)} entradas indexadas (claves: {list(indexed)[:3]}...)")
+
+    rprint("\n[yellow]9. get_modifiers() (puede ser []) [/yellow]")
+    rprint(f"  → {len(get_modifiers().get('data', []))} modificadores")
+
+    rprint("\n[yellow]10. get_table(106)[/yellow]")
+    table = get_table(106)
+    rprint(f"  → {table}")
+
+    if table:
+        rprint("\n[yellow]11. get_active_sale(table)[/yellow]")
+        active = get_active_sale(table)
+        rprint(f"  → {active}")
+
+        if active:
+            rprint("\n[yellow]12. get_sale(<active.id>)[/yellow]")
+            rprint(f"  → keys: {list((get_sale(active['id']) or {}).keys())}")
+
+            rprint("\n[yellow]13. get_table_items(106)[/yellow]")
+            rprint(f"  → {get_table_items(106)}")
+
+    # ------------------------------------------------------------------
+    # Pruebas de escritura — DESCOMENTAR SOLO EN AMBIENTE DEV
+    # ------------------------------------------------------------------
+    # rprint("\n[red]14. create_sale + create_item (ESCRITURA)[/red]")
+    # if table:
+    #     sale = create_sale(table["id"])
+    #     rprint(f"  → create_sale → {sale}")
+    #     if sale and prods:
+    #         item = create_item(int(prods[0]["id"]), 1, sale["id"], comment="smoke test")
+    #         rprint(f"  → create_item → {item}")
+
+    rprint("\n[bold green]✓ smoke tests OK[/bold green]")
+#pichula pal que lee
+
+if __name__ == "__main__":
+    from dotenv import load_dotenv
+    load_dotenv(".env")
+
+    _smoke_tests()

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott