|
|
@@ -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()
|