toteat.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780
  1. """
  2. Cliente para la API de Toteat POS.
  3. Expone la misma superficie pública que `fudo/fudo.py` para que
  4. `services/fudo_service.py`, `services/data_service.py`, los routes y los
  5. scripts (`load_products.py`, `update_prices.py`) puedan migrar cambiando
  6. solo el import.
  7. Diferencias principales con Fudo cubiertas en este módulo:
  8. - Toteat no usa flujo de OAuth; las credenciales viajan como query params
  9. (`xir`, `xil`, `xiu`, `xapitoken`) en cada request. `get_token()` queda
  10. como no-op para mantener compatibilidad.
  11. - `/products` devuelve todo el menú en una sola llamada (con categorías y
  12. modificadores embebidos). Las funciones que en Fudo eran paginadas o
  13. separadas se construyen sobre un cache compartido.
  14. - El menú se traduce a la forma JSON:API que esperan los callers de Fudo
  15. (`{"id", "attributes", "relationships"}`), para mantener la compatibilidad
  16. con `services/data_service.py:get_all` y `update_prices.py`.
  17. - El flujo Fudo de crear venta vacía y luego agregar items uno por uno se
  18. mapea a POST /orders incremental: la primera llamada a `create_item`
  19. para una venta crea la orden en Toteat, las siguientes la actualizan
  20. (Toteat permite añadir productos a una orden de mesa existente enviando
  21. el `orderId` real).
  22. """
  23. import os
  24. import time
  25. import asyncio
  26. from logging import getLogger
  27. from typing import Any, Dict, List, Optional
  28. from uuid import uuid4
  29. import requests
  30. import aiohttp
  31. import redis
  32. from config.settings import DEVELOPMENT
  33. from models.items import Product
  34. logger = getLogger(__name__)
  35. # ---------------------------------------------------------------------------
  36. # Configuración
  37. # ---------------------------------------------------------------------------
  38. API_TOKEN = os.getenv("TOTEAT_API_TOKEN", "")
  39. RESTAURANT_ID = os.getenv("TOTEAT_RESTAURANT_ID", "")
  40. LOCAL_ID = os.getenv("TOTEAT_LOCAL_ID", "")
  41. USER_ID = os.getenv("TOTEAT_USER_ID", "")
  42. # Toteat recomienda usar siempre las URLs nuevas; el sistema redirige al
  43. # ambiente legacy si corresponde.
  44. BASE_URL = (
  45. "https://apidev.toteat.com/mw/or/1.0"
  46. if DEVELOPMENT
  47. else "https://api.toteat.com/mw/or/1.0"
  48. )
  49. REQUEST_TIMEOUT = 15
  50. # Cache del menú: Toteat trae todos los productos en una sola request, pero
  51. # está limitado a 3 req/min. Cacheamos por unos minutos.
  52. _MENU_CACHE_TTL = 60 * 5 # 5 minutos
  53. _menu_cache: Dict[str, Any] = {"data": None, "expires": 0.0}
  54. # Cache de mesas: 3 req/min también.
  55. _TABLES_CACHE_TTL = 60
  56. _tables_cache: Dict[str, Any] = {"data": None, "expires": 0.0}
  57. # Mapeo en memoria de número de mesa → orderId committed en Toteat. Se llena
  58. # cuando `create_item` crea/actualiza una orden y se consulta desde
  59. # `get_active_sale` para devolver el id real.
  60. _table_to_order: Dict[int, str] = {}
  61. # Buffers de venta pendientes (sale_id sintético → estado interno).
  62. _pending_sales: Dict[str, Dict[str, Any]] = {}
  63. redis_client = redis.Redis(
  64. host=os.getenv("REDIS_HOST", "localhost"),
  65. port=int(os.getenv("REDIS_PORT", 6379)),
  66. db=1 if DEVELOPMENT else 0,
  67. decode_responses=True,
  68. )
  69. REDIS_TABLE_ORDER_KEY = "toteat:table_order:{table_number}"
  70. # ---------------------------------------------------------------------------
  71. # Helpers
  72. # ---------------------------------------------------------------------------
  73. def _auth_params() -> Dict[str, str]:
  74. return {
  75. "xir": RESTAURANT_ID,
  76. "xil": LOCAL_ID,
  77. "xiu": USER_ID,
  78. "xapitoken": API_TOKEN,
  79. }
  80. def _get(path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
  81. url = f"{BASE_URL}{path}"
  82. full_params = {**_auth_params(), **(params or {})}
  83. try:
  84. r = requests.get(url, params=full_params, timeout=REQUEST_TIMEOUT)
  85. except requests.RequestException as e:
  86. logger.error(f"Toteat GET {path} falló: {e}")
  87. return None
  88. if r.status_code != 200:
  89. logger.error(f"Toteat GET {path} status={r.status_code} body={r.text[:300]}")
  90. return None
  91. try:
  92. return r.json()
  93. except ValueError:
  94. logger.error(f"Toteat GET {path} respuesta no-JSON: {r.text[:300]}")
  95. return None
  96. def _post(path: str, body: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
  97. url = f"{BASE_URL}{path}"
  98. full_params = {**_auth_params(), **(params or {})}
  99. try:
  100. r = requests.post(url, params=full_params, json=body, timeout=REQUEST_TIMEOUT)
  101. except requests.RequestException as e:
  102. logger.error(f"Toteat POST {path} falló: {e}")
  103. return None
  104. if r.status_code not in (200, 201):
  105. logger.error(f"Toteat POST {path} status={r.status_code} body={r.text[:300]}")
  106. return None
  107. try:
  108. return r.json()
  109. except ValueError:
  110. logger.error(f"Toteat POST {path} respuesta no-JSON: {r.text[:300]}")
  111. return None
  112. def _persist_table_order(table_number: int, order_id: str) -> None:
  113. _table_to_order[table_number] = order_id
  114. try:
  115. redis_client.setex(
  116. REDIS_TABLE_ORDER_KEY.format(table_number=table_number),
  117. 60 * 60 * 8, # 8h de validez para una orden abierta
  118. order_id,
  119. )
  120. except Exception as e:
  121. logger.warning(f"No se pudo persistir order_id en Redis: {e}")
  122. def _load_table_order(table_number: int) -> Optional[str]:
  123. if table_number in _table_to_order:
  124. return _table_to_order[table_number]
  125. try:
  126. cached = redis_client.get(REDIS_TABLE_ORDER_KEY.format(table_number=table_number))
  127. if cached:
  128. _table_to_order[table_number] = str(cached)
  129. return str(cached)
  130. except Exception as e:
  131. logger.warning(f"No se pudo leer order_id desde Redis: {e}")
  132. return None
  133. def _clear_table_order(table_number: int) -> None:
  134. _table_to_order.pop(table_number, None)
  135. try:
  136. redis_client.delete(REDIS_TABLE_ORDER_KEY.format(table_number=table_number))
  137. except Exception:
  138. pass
  139. # ---------------------------------------------------------------------------
  140. # Mapeo Toteat → forma JSON:API estilo Fudo
  141. # ---------------------------------------------------------------------------
  142. #
  143. # Los callers existentes (data_service.ProductDataService.get_all,
  144. # update_prices.add_missing_products) leen los productos como:
  145. # product["id"]
  146. # product["attributes"]["name"|"price"|"active"|"imageUrl"|"description"|"enableQrMenu"]
  147. # product["relationships"]["productCategory"]["data"]["id"]
  148. # product["relationships"]["kitchen"]["data"]["id"]
  149. #
  150. # Para mantenerlos sin cambios, traducimos el shape de Toteat al de Fudo.
  151. #
  152. # Nota sobre kitchen_id: Toteat no expone "kitchen" como Fudo. Se mapea por
  153. # defecto al `categoryId` para que `print_service` siga ruteando items por
  154. # categoría. Si en el futuro hay que enviar items específicos a otra
  155. # impresora se puede agregar un mapping configurable.
  156. def _to_fudo_product(toteat_item: Dict[str, Any]) -> Dict[str, Any]:
  157. images = toteat_item.get("images") or []
  158. image_url = ""
  159. if images:
  160. first = images[0]
  161. if isinstance(first, dict):
  162. image_url = first.get("url") or first.get("image") or ""
  163. elif isinstance(first, str):
  164. image_url = first
  165. category_id = toteat_item.get("categoryId")
  166. is_modifier = bool(toteat_item.get("isModifier"))
  167. return {
  168. "id": str(toteat_item.get("id", "")),
  169. "type": "Product",
  170. "attributes": {
  171. "name": toteat_item.get("name", ""),
  172. "price": toteat_item.get("price", 0),
  173. "active": True, # Toteat solo devuelve activos por defecto
  174. "imageUrl": image_url,
  175. "description": toteat_item.get("description", ""),
  176. "enableQrMenu": not is_modifier,
  177. "isModifier": is_modifier,
  178. "sorting": toteat_item.get("sorting", ""),
  179. "localCode": toteat_item.get("localCode", ""),
  180. "referencePrice": toteat_item.get("referencePrice"),
  181. "modificationDate": toteat_item.get("modificationDate"),
  182. },
  183. "relationships": {
  184. "productCategory": {
  185. "data": {
  186. "id": str(category_id) if category_id is not None else "0",
  187. "type": "ProductCategory",
  188. }
  189. },
  190. "kitchen": {
  191. "data": {
  192. "id": str(category_id) if category_id is not None else "0",
  193. "type": "Kitchen",
  194. }
  195. },
  196. },
  197. "_toteat_raw": toteat_item,
  198. }
  199. # ---------------------------------------------------------------------------
  200. # API pública – mismo nombre que en fudo/fudo.py
  201. # ---------------------------------------------------------------------------
  202. def get_token() -> str:
  203. """Toteat no usa OAuth; mantenemos la firma para compatibilidad."""
  204. return API_TOKEN
  205. def clear_token() -> None:
  206. """No-op para compatibilidad con fudo.fudo.clear_token."""
  207. return None
  208. def _fetch_menu(force: bool = False) -> List[Dict[str, Any]]:
  209. now = time.time()
  210. if not force and _menu_cache["data"] is not None and _menu_cache["expires"] > now:
  211. return _menu_cache["data"]
  212. payload = _get("/products", params={"activeProducts": "true"})
  213. if not payload or not payload.get("ok"):
  214. logger.error(f"Toteat /products no devolvió data útil: {payload}")
  215. return _menu_cache["data"] or []
  216. raw = payload.get("data") or []
  217. _menu_cache["data"] = raw
  218. _menu_cache["expires"] = now + _MENU_CACHE_TTL
  219. return raw
  220. def get_modifiers() -> Dict[str, Any]:
  221. """En Toteat los modificadores vienen embebidos en `/products`. Para
  222. mantener compatibilidad devolvemos un dict similar al de Fudo."""
  223. items = _fetch_menu()
  224. modifiers = [i for i in items if i.get("isModifier")]
  225. return {"data": [_to_fudo_product(m) for m in modifiers]}
  226. def get_categories() -> List[Dict[str, Any]]:
  227. """Devuelve las categorías derivadas del menú de Toteat en forma JSON:API."""
  228. items = _fetch_menu()
  229. seen: Dict[str, str] = {}
  230. for it in items:
  231. cat_id = it.get("categoryId")
  232. cat_name = it.get("category")
  233. if cat_id is None or cat_name is None:
  234. continue
  235. seen[str(cat_id)] = cat_name
  236. return [
  237. {
  238. "id": cid,
  239. "type": "ProductCategory",
  240. "attributes": {"name": cname, "enableOnlineMenu": True},
  241. }
  242. for cid, cname in seen.items()
  243. ]
  244. def get_category_dict() -> Dict[str, str]:
  245. return {c["id"]: c["attributes"]["name"] for c in get_categories()}
  246. def get_category(id_category) -> Dict[str, Any]:
  247. cid = str(id_category)
  248. for c in get_categories():
  249. if c["id"] == cid:
  250. return {
  251. "id": c["id"],
  252. "name": c["attributes"]["name"],
  253. "enableOnlineMenu": c["attributes"].get("enableOnlineMenu", True),
  254. }
  255. return {"id": cid, "name": "Producto", "enableOnlineMenu": False}
  256. def get_product(product_id) -> Optional[Product]:
  257. pid = str(product_id)
  258. for it in _fetch_menu():
  259. if str(it.get("id")) == pid:
  260. mapped = _to_fudo_product(it)
  261. cat_id = mapped["relationships"]["productCategory"]["data"]["id"]
  262. return Product(
  263. id=int(mapped["id"]),
  264. name=mapped["attributes"]["name"],
  265. type=get_category(cat_id)["name"] or "Producto",
  266. price=int(mapped["attributes"]["price"] or 0),
  267. image=mapped["attributes"]["imageUrl"],
  268. description=mapped["attributes"]["description"],
  269. status=1,
  270. kitchen_id=int(mapped["relationships"]["kitchen"]["data"]["id"] or 0),
  271. promo_day=None,
  272. promo_price=None,
  273. promo_id=None,
  274. )
  275. logger.error(f"Producto {product_id} no encontrado en menú Toteat")
  276. return None
  277. def get_products(page: int = 1) -> List[Dict[str, Any]]:
  278. """Toteat no pagina /products; devolvemos todo en page=1 y vacío después
  279. para mantener el contrato de paginación de Fudo (loops `while data`)."""
  280. if page != 1:
  281. return []
  282. return [_to_fudo_product(p) for p in _fetch_menu() if not p.get("isModifier")]
  283. async def get_all_indexed_products() -> Dict[str, Dict[str, Any]]:
  284. """Equivalente al método async de Fudo. Como Toteat trae todo en una
  285. sola request, lo hacemos en un thread para no bloquear el event loop."""
  286. items = await asyncio.to_thread(_fetch_menu)
  287. indexed: Dict[str, Dict[str, Any]] = {}
  288. for it in items:
  289. if it.get("isModifier"):
  290. continue
  291. mapped = _to_fudo_product(it)
  292. indexed[mapped["id"]] = mapped
  293. return indexed
  294. async def get_all_products() -> List[Dict[str, Any]]:
  295. return list((await get_all_indexed_products()).values())
  296. # ---------------------------------------------------------------------------
  297. # Mesas
  298. # ---------------------------------------------------------------------------
  299. def _fetch_tables(force: bool = False) -> List[Dict[str, Any]]:
  300. now = time.time()
  301. if not force and _tables_cache["data"] is not None and _tables_cache["expires"] > now:
  302. return _tables_cache["data"]
  303. payload = _get("/tables")
  304. if not payload or not payload.get("ok"):
  305. logger.error(f"Toteat /tables no devolvió data útil: {payload}")
  306. return _tables_cache["data"] or []
  307. raw = payload.get("data") or []
  308. _tables_cache["data"] = raw
  309. _tables_cache["expires"] = now + _TABLES_CACHE_TTL
  310. return raw
  311. def _to_fudo_table(toteat_table: Dict[str, Any]) -> Dict[str, Any]:
  312. """Toteat /tables devuelve registros con campos como `tableId`, `name`,
  313. `available`, `capacity`. Algunos ambientes exponen `number`. Mapeamos al
  314. shape JSON:API que espera el código existente."""
  315. table_id = toteat_table.get("tableId") or toteat_table.get("id")
  316. number = toteat_table.get("number")
  317. if number is None:
  318. # Algunos ambientes guardan el número como `name` (string).
  319. try:
  320. number = int(toteat_table.get("name", "0"))
  321. except (TypeError, ValueError):
  322. number = 0
  323. available = toteat_table.get("available", True)
  324. # Active sale: Toteat no la embebe en /tables. Si tenemos un orderId
  325. # registrado en memoria/Redis para esta mesa lo exponemos. Si la mesa
  326. # está marcada como ocupada pero no tenemos orderId, dejamos vacío
  327. # (el caller terminará creando una venta nueva).
  328. cached_order = _load_table_order(int(number)) if number else None
  329. active_sales: List[Dict[str, str]] = []
  330. if cached_order:
  331. active_sales.append({"id": cached_order, "type": "Sale"})
  332. elif not available:
  333. # Mesa ocupada pero sin orderId conocido → intentamos descubrirlo.
  334. discovered = _discover_open_order_for_table(table_id)
  335. if discovered:
  336. _persist_table_order(int(number), discovered)
  337. active_sales.append({"id": discovered, "type": "Sale"})
  338. return {
  339. "id": str(table_id) if table_id is not None else "",
  340. "type": "Table",
  341. "attributes": {
  342. "number": number,
  343. "available": available,
  344. "capacity": toteat_table.get("capacity"),
  345. "name": toteat_table.get("name"),
  346. },
  347. "relationships": {
  348. "activeSales": {"data": active_sales},
  349. },
  350. "_toteat_raw": toteat_table,
  351. }
  352. def _discover_open_order_for_table(table_id: Any) -> Optional[str]:
  353. """Busca en Toteat órdenes abiertas y filtra por tableId."""
  354. payload = _get("/orderstatus", params={"listing": "true"})
  355. if not payload or not payload.get("ok"):
  356. return None
  357. data = payload.get("data") or []
  358. if not isinstance(data, list):
  359. return None
  360. target = str(table_id)
  361. for order in data:
  362. otable = order.get("tableId") or order.get("table") or order.get("table_id")
  363. if otable is not None and str(otable) == target:
  364. oid = order.get("orderId") or order.get("id")
  365. if oid is not None:
  366. return str(oid)
  367. return None
  368. def get_table(number: int) -> Optional[Dict[str, Any]]:
  369. for raw in _fetch_tables():
  370. raw_number = raw.get("number")
  371. if raw_number is None:
  372. try:
  373. raw_number = int(raw.get("name", "0"))
  374. except (TypeError, ValueError):
  375. raw_number = None
  376. if raw_number == number:
  377. return _to_fudo_table(raw)
  378. return None
  379. def get_active_sale(table: Optional[Dict[str, Any]]) -> Optional[Dict[str, str]]:
  380. if not table:
  381. return None
  382. data = table.get("relationships", {}).get("activeSales", {}).get("data") or []
  383. if not data:
  384. return None
  385. return data[0]
  386. def get_sale(sale_id) -> Optional[Dict[str, Any]]:
  387. payload = _get("/orderstatus", params={"ic": str(sale_id), "det": "true"})
  388. if not payload or not payload.get("ok"):
  389. return None
  390. return payload
  391. # ---------------------------------------------------------------------------
  392. # Creación / actualización de órdenes
  393. # ---------------------------------------------------------------------------
  394. #
  395. # Estrategia para mantener el flujo line-by-line de Fudo:
  396. #
  397. # 1. `create_sale(table_id)` registra una venta pendiente en memoria y
  398. # devuelve un id sintético "pending:<uuid>". No hace HTTP todavía.
  399. #
  400. # 2. `create_item(product_id, qty, sale_id, comment)`:
  401. # - Si `sale_id` empieza con "pending:" y aún no hay orderId real, hace
  402. # POST /orders con `orderId=0` y la línea actual. Toma el orderId de
  403. # la respuesta (cuando está disponible) y lo guarda contra la mesa.
  404. # - Si ya hay orderId real, hace POST /orders con `orderId=<real>` y la
  405. # línea nueva (Toteat permite añadir líneas a órdenes abiertas en mesa).
  406. #
  407. # 3. `get_active_sale(table)` devuelve el orderId real si está cacheado.
  408. def _table_id_for_pending(sale_id: str) -> Optional[int]:
  409. state = _pending_sales.get(sale_id)
  410. if not state:
  411. return None
  412. return state.get("table_number")
  413. def create_sale(table_id) -> Optional[Dict[str, str]]:
  414. """Equivalente a `fudo.create_sale`. No hace HTTP: registra una venta
  415. pendiente vinculada al `table_id` (que en este módulo es el `tableId`
  416. de Toteat). Para resolver el número de mesa hacemos lookup inverso."""
  417. table_number: Optional[int] = None
  418. for raw in _fetch_tables():
  419. if str(raw.get("tableId") or raw.get("id")) == str(table_id):
  420. n = raw.get("number")
  421. if n is None:
  422. try:
  423. n = int(raw.get("name", "0"))
  424. except (TypeError, ValueError):
  425. n = None
  426. table_number = n
  427. break
  428. if table_number is None:
  429. logger.error(f"create_sale: no se pudo resolver número de mesa para tableId={table_id}")
  430. return None
  431. sale_id = f"pending:{uuid4().hex}"
  432. _pending_sales[sale_id] = {
  433. "table_id": str(table_id),
  434. "table_number": int(table_number),
  435. "items": [],
  436. "order_id": None,
  437. }
  438. return {"id": sale_id, "type": "Sale"}
  439. def _build_line(product_id: int, quantity: int, comment: Optional[str]) -> Dict[str, Any]:
  440. line: Dict[str, Any] = {
  441. "productCode": str(product_id),
  442. "quantity": quantity,
  443. }
  444. if comment:
  445. line["comment"] = comment
  446. return line
  447. def _post_order(table_id: str, lines: List[Dict[str, Any]], order_id: int = 0,
  448. comment: Optional[str] = None) -> Optional[Dict[str, Any]]:
  449. body: Dict[str, Any] = {
  450. "restaurantId": int(RESTAURANT_ID) if RESTAURANT_ID else 0,
  451. "localNumber": int(LOCAL_ID) if LOCAL_ID else 0,
  452. "orderId": order_id,
  453. "tableId": int(table_id),
  454. "orderReference": uuid4().hex,
  455. "status": "new",
  456. "type": "order",
  457. "channel": "app",
  458. "vendorName": "Pedidos Express",
  459. "comment": comment or "Pedido desde pedidos express",
  460. "document": {
  461. "line": lines,
  462. "payments": [],
  463. },
  464. "operationDate": time.strftime("%Y-%m-%dT%H:%M:%S"),
  465. "modifiedDate": time.strftime("%Y-%m-%dT%H:%M:%S"),
  466. }
  467. return _post("/orders", body, params={"orderDetail": "true"})
  468. def create_item(product_id: int, quantity: int, sale_id: str,
  469. comment: Optional[str] = None) -> Optional[Dict[str, Any]]:
  470. """Agrega un producto a una venta. Si la venta es pendiente (todavía no
  471. existe en Toteat) hace el POST /orders inicial; si ya existe le añade la
  472. línea. Devuelve un dict truthy con `id` para mantener compatibilidad con
  473. los callers de Fudo."""
  474. state = _pending_sales.get(sale_id)
  475. if state is None:
  476. logger.error(f"create_item: sale_id desconocido {sale_id}")
  477. return None
  478. line = _build_line(product_id, quantity, comment)
  479. state["items"].append(line)
  480. table_id = state["table_id"]
  481. table_number = state["table_number"]
  482. existing_order = state["order_id"] or _load_table_order(table_number)
  483. order_id_int = int(existing_order) if existing_order else 0
  484. response = _post_order(table_id, [line], order_id=order_id_int, comment=comment)
  485. if response is None or not response.get("ok"):
  486. logger.error(f"create_item: POST /orders falló para sale_id={sale_id} body={response}")
  487. return None
  488. # Toteat puede devolver el orderId en `data.orderId` o en el msg cuando
  489. # el ambiente es legacy. Hacemos lookup defensivo.
  490. order_id_returned: Optional[str] = None
  491. data = response.get("data") if isinstance(response.get("data"), dict) else None
  492. if data:
  493. for key in ("orderId", "id"):
  494. if data.get(key):
  495. order_id_returned = str(data[key])
  496. break
  497. if order_id_returned and not state["order_id"]:
  498. state["order_id"] = order_id_returned
  499. _persist_table_order(table_number, order_id_returned)
  500. return {
  501. "id": state["order_id"] or order_id_returned or sale_id,
  502. "type": "Item",
  503. "attributes": {"quantity": quantity},
  504. }
  505. # ---------------------------------------------------------------------------
  506. # Lectura de items en una mesa
  507. # ---------------------------------------------------------------------------
  508. def get_table_items(table_number: int) -> Optional[List[Dict[str, Any]]]:
  509. """Equivalente a `fudo.get_table_items`. Resuelve la mesa, encuentra su
  510. orden activa y devuelve `[{id, quantity}]` para cada línea."""
  511. table = get_table(table_number)
  512. if not table:
  513. return None
  514. active = get_active_sale(table)
  515. if not active:
  516. return None
  517. order = get_sale(active["id"])
  518. if not order:
  519. return None
  520. data = order.get("data") if isinstance(order.get("data"), dict) else order.get("data")
  521. if not data:
  522. return []
  523. # Toteat con det=true devuelve la orden completa. Las líneas suelen estar
  524. # en data.document.line o data.line; nos defendemos contra ambas.
  525. lines: List[Dict[str, Any]] = []
  526. if isinstance(data, dict):
  527. document = data.get("document") or {}
  528. lines = document.get("line") or data.get("line") or []
  529. elif isinstance(data, list):
  530. lines = data
  531. result: List[Dict[str, Any]] = []
  532. for line in lines:
  533. try:
  534. pid_raw = line.get("productCode") or line.get("productId") or line.get("product_id")
  535. if pid_raw is None:
  536. continue
  537. qty = line.get("quantity") or 1
  538. result.append({"id": int(pid_raw), "quantity": int(qty)})
  539. except (TypeError, ValueError):
  540. continue
  541. return result
  542. # ---------------------------------------------------------------------------
  543. # Paridad async con Fudo
  544. # ---------------------------------------------------------------------------
  545. async def fetch_page(session: aiohttp.ClientSession, url_template: str,
  546. token: str, page_num: int):
  547. """Stub de paridad. Toteat no expone /products paginado; devolvemos
  548. listas vacías para páginas > 1 y delegamos a `_fetch_menu` para la 1."""
  549. if page_num != 1:
  550. return page_num, []
  551. return page_num, await asyncio.to_thread(_fetch_menu)
  552. __all__ = [
  553. "get_token",
  554. "clear_token",
  555. "get_modifiers",
  556. "get_categories",
  557. "get_category_dict",
  558. "get_category",
  559. "get_product",
  560. "get_products",
  561. "get_all_indexed_products",
  562. "get_all_products",
  563. "get_table",
  564. "get_active_sale",
  565. "get_sale",
  566. "create_sale",
  567. "create_item",
  568. "get_table_items",
  569. ]
  570. # ---------------------------------------------------------------------------
  571. # Smoke tests
  572. # ---------------------------------------------------------------------------
  573. #
  574. # Ejecutar con: python -m toteat.toteat
  575. #
  576. # Requiere las env vars TOTEAT_API_TOKEN, TOTEAT_RESTAURANT_ID,
  577. # TOTEAT_LOCAL_ID, TOTEAT_USER_ID. Solo prueba lecturas seguras por defecto;
  578. # el bloque que crea órdenes está comentado y requiere descomentar
  579. # explícitamente para no impactar el ambiente real.
  580. def _smoke_tests() -> None:
  581. from rich import print as rprint
  582. rprint("[bold cyan]== Toteat smoke tests ==[/bold cyan]")
  583. rprint(f"BASE_URL = {BASE_URL}")
  584. missing = [
  585. name for name, val in [
  586. ("TOTEAT_API_TOKEN", API_TOKEN),
  587. ("TOTEAT_RESTAURANT_ID", RESTAURANT_ID),
  588. ("TOTEAT_LOCAL_ID", LOCAL_ID),
  589. ("TOTEAT_USER_ID", USER_ID),
  590. ] if not val
  591. ]
  592. if missing:
  593. rprint(f"[redFaltan env vars:[/red] {missing}")
  594. return
  595. rprint("\n[yellow]1. get_token()[/yellow]")
  596. rprint(f" → token len={len(get_token())}")
  597. rprint("\n[yellow]2. get_categories()[/yellow]")
  598. cats = get_categories()
  599. rprint(f" → {len(cats)} categorías")
  600. if cats:
  601. rprint(f" → ejemplo: {cats[0]}")
  602. rprint("\n[yellow]3. get_category_dict()[/yellow]")
  603. cat_dict = get_category_dict()
  604. rprint(f" → {len(cat_dict)} entradas")
  605. rprint("\n[yellow]4. get_products(page=1) (primeras 3)[/yellow]")
  606. prods = get_products(1)
  607. rprint(f" → {len(prods)} productos en página 1")
  608. for p in prods[:3]:
  609. rprint(
  610. f" • id={p['id']} name={p['attributes']['name']!r} "
  611. f"price={p['attributes']['price']} cat={p['relationships']['productCategory']['data']['id']}"
  612. )
  613. rprint("\n[yellow]5. get_products(page=2) (debe ser [])[/yellow]")
  614. rprint(f" → {get_products(2)}")
  615. rprint("\n[yellow]6. get_product(<primer id>)[/yellow]")
  616. if prods:
  617. first_id = prods[0]["id"]
  618. product = get_product(first_id)
  619. rprint(f" → {product}")
  620. rprint("\n[yellow]7. get_all_products() async[/yellow]")
  621. all_prods = asyncio.run(get_all_products())
  622. rprint(f" → {len(all_prods)} productos totales")
  623. rprint("\n[yellow]8. get_all_indexed_products() async[/yellow]")
  624. indexed = asyncio.run(get_all_indexed_products())
  625. rprint(f" → {len(indexed)} entradas indexadas (claves: {list(indexed)[:3]}...)")
  626. rprint("\n[yellow]9. get_modifiers() (puede ser []) [/yellow]")
  627. rprint(f" → {len(get_modifiers().get('data', []))} modificadores")
  628. rprint("\n[yellow]10. get_table(106)[/yellow]")
  629. table = get_table(106)
  630. rprint(f" → {table}")
  631. if table:
  632. rprint("\n[yellow]11. get_active_sale(table)[/yellow]")
  633. active = get_active_sale(table)
  634. rprint(f" → {active}")
  635. if active:
  636. rprint("\n[yellow]12. get_sale(<active.id>)[/yellow]")
  637. rprint(f" → keys: {list((get_sale(active['id']) or {}).keys())}")
  638. rprint("\n[yellow]13. get_table_items(106)[/yellow]")
  639. rprint(f" → {get_table_items(106)}")
  640. # ------------------------------------------------------------------
  641. # Pruebas de escritura — DESCOMENTAR SOLO EN AMBIENTE DEV
  642. # ------------------------------------------------------------------
  643. # rprint("\n[red]14. create_sale + create_item (ESCRITURA)[/red]")
  644. # if table:
  645. # sale = create_sale(table["id"])
  646. # rprint(f" → create_sale → {sale}")
  647. # if sale and prods:
  648. # item = create_item(int(prods[0]["id"]), 1, sale["id"], comment="smoke test")
  649. # rprint(f" → create_item → {item}")
  650. rprint("\n[bold green]✓ smoke tests OK[/bold green]")
  651. #pichula pal que lee
  652. if __name__ == "__main__":
  653. from dotenv import load_dotenv
  654. load_dotenv(".env")
  655. _smoke_tests()