""" 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://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]] = {} # Mapa inverso orderId real → estado de venta. Permite que `create_item` sea # llamado con el orderId real de Toteat (items 2+ en una misma orden) sin # perder el contexto de mesa y tabla necesario para el POST /orders. _order_to_state: 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`. Si en el futuro se necesita enrutar items por # alguna dimensión distinta, agregar un mapping configurable aquí. 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=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=mapped["relationships"]["kitchen"]["data"]["id"] or None, 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:". 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=` 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: str, 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: str, 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) or _order_to_state.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) _order_to_state[order_id_returned] = state 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()[/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()[/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()