toteat.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782
  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. print(f"producto recibido: {product_id}")
  258. pid = str(product_id)
  259. print(f"pid: {pid}")
  260. for it in _fetch_menu():
  261. if str(it.get("id")) == pid:
  262. mapped = _to_fudo_product(it)
  263. cat_id = mapped["relationships"]["productCategory"]["data"]["id"]
  264. return Product(
  265. id=mapped["id"],
  266. name=mapped["attributes"]["name"],
  267. type=get_category(cat_id)["name"] or "Producto",
  268. price=int(mapped["attributes"]["price"] or 0),
  269. image=mapped["attributes"]["imageUrl"],
  270. description=mapped["attributes"]["description"],
  271. status=1,
  272. kitchen_id=mapped["relationships"]["kitchen"]["data"]["id"] or None,
  273. promo_day=None,
  274. promo_price=None,
  275. promo_id=None,
  276. )
  277. logger.error(f"Producto {product_id} no encontrado en menú Toteat")
  278. return None
  279. def get_products(page: int = 1) -> List[Dict[str, Any]]:
  280. """Toteat no pagina /products; devolvemos todo en page=1 y vacío después
  281. para mantener el contrato de paginación de Fudo (loops `while data`)."""
  282. if page != 1:
  283. return []
  284. return [_to_fudo_product(p) for p in _fetch_menu() if not p.get("isModifier")]
  285. async def get_all_indexed_products() -> Dict[str, Dict[str, Any]]:
  286. """Equivalente al método async de Fudo. Como Toteat trae todo en una
  287. sola request, lo hacemos en un thread para no bloquear el event loop."""
  288. items = await asyncio.to_thread(_fetch_menu)
  289. indexed: Dict[str, Dict[str, Any]] = {}
  290. for it in items:
  291. if it.get("isModifier"):
  292. continue
  293. mapped = _to_fudo_product(it)
  294. indexed[mapped["id"]] = mapped
  295. return indexed
  296. async def get_all_products() -> List[Dict[str, Any]]:
  297. return list((await get_all_indexed_products()).values())
  298. # ---------------------------------------------------------------------------
  299. # Mesas
  300. # ---------------------------------------------------------------------------
  301. def _fetch_tables(force: bool = False) -> List[Dict[str, Any]]:
  302. now = time.time()
  303. if not force and _tables_cache["data"] is not None and _tables_cache["expires"] > now:
  304. return _tables_cache["data"]
  305. payload = _get("/tables")
  306. if not payload or not payload.get("ok"):
  307. logger.error(f"Toteat /tables no devolvió data útil: {payload}")
  308. return _tables_cache["data"] or []
  309. raw = payload.get("data") or []
  310. _tables_cache["data"] = raw
  311. _tables_cache["expires"] = now + _TABLES_CACHE_TTL
  312. return raw
  313. def _to_fudo_table(toteat_table: Dict[str, Any]) -> Dict[str, Any]:
  314. """Toteat /tables devuelve registros con campos como `tableId`, `name`,
  315. `available`, `capacity`. Algunos ambientes exponen `number`. Mapeamos al
  316. shape JSON:API que espera el código existente."""
  317. table_id = toteat_table.get("tableId") or toteat_table.get("id")
  318. number = toteat_table.get("number")
  319. if number is None:
  320. # Algunos ambientes guardan el número como `name` (string).
  321. try:
  322. number = int(toteat_table.get("name", "0"))
  323. except (TypeError, ValueError):
  324. number = 0
  325. available = toteat_table.get("available", True)
  326. # Active sale: Toteat no la embebe en /tables. Si tenemos un orderId
  327. # registrado en memoria/Redis para esta mesa lo exponemos. Si la mesa
  328. # está marcada como ocupada pero no tenemos orderId, dejamos vacío
  329. # (el caller terminará creando una venta nueva).
  330. cached_order = _load_table_order(int(number)) if number else None
  331. active_sales: List[Dict[str, str]] = []
  332. if cached_order:
  333. active_sales.append({"id": cached_order, "type": "Sale"})
  334. elif not available:
  335. # Mesa ocupada pero sin orderId conocido → intentamos descubrirlo.
  336. discovered = _discover_open_order_for_table(table_id)
  337. if discovered:
  338. _persist_table_order(int(number), discovered)
  339. active_sales.append({"id": discovered, "type": "Sale"})
  340. return {
  341. "id": str(table_id) if table_id is not None else "",
  342. "type": "Table",
  343. "attributes": {
  344. "number": number,
  345. "available": available,
  346. "capacity": toteat_table.get("capacity"),
  347. "name": toteat_table.get("name"),
  348. },
  349. "relationships": {
  350. "activeSales": {"data": active_sales},
  351. },
  352. "_toteat_raw": toteat_table,
  353. }
  354. def _discover_open_order_for_table(table_id: Any) -> Optional[str]:
  355. """Busca en Toteat órdenes abiertas y filtra por tableId."""
  356. payload = _get("/orderstatus", params={"listing": "true"})
  357. if not payload or not payload.get("ok"):
  358. return None
  359. data = payload.get("data") or []
  360. if not isinstance(data, list):
  361. return None
  362. target = str(table_id)
  363. for order in data:
  364. otable = order.get("tableId") or order.get("table") or order.get("table_id")
  365. if otable is not None and str(otable) == target:
  366. oid = order.get("orderId") or order.get("id")
  367. if oid is not None:
  368. return str(oid)
  369. return None
  370. def get_table(number: int) -> Optional[Dict[str, Any]]:
  371. for raw in _fetch_tables():
  372. raw_number = raw.get("number")
  373. if raw_number is None:
  374. try:
  375. raw_number = int(raw.get("name", "0"))
  376. except (TypeError, ValueError):
  377. raw_number = None
  378. if raw_number == number:
  379. return _to_fudo_table(raw)
  380. return None
  381. def get_active_sale(table: Optional[Dict[str, Any]]) -> Optional[Dict[str, str]]:
  382. if not table:
  383. return None
  384. data = table.get("relationships", {}).get("activeSales", {}).get("data") or []
  385. if not data:
  386. return None
  387. return data[0]
  388. def get_sale(sale_id) -> Optional[Dict[str, Any]]:
  389. payload = _get("/orderstatus", params={"ic": str(sale_id), "det": "true"})
  390. if not payload or not payload.get("ok"):
  391. return None
  392. return payload
  393. # ---------------------------------------------------------------------------
  394. # Creación / actualización de órdenes
  395. # ---------------------------------------------------------------------------
  396. #
  397. # Estrategia para mantener el flujo line-by-line de Fudo:
  398. #
  399. # 1. `create_sale(table_id)` registra una venta pendiente en memoria y
  400. # devuelve un id sintético "pending:<uuid>". No hace HTTP todavía.
  401. #
  402. # 2. `create_item(product_id, qty, sale_id, comment)`:
  403. # - Si `sale_id` empieza con "pending:" y aún no hay orderId real, hace
  404. # POST /orders con `orderId=0` y la línea actual. Toma el orderId de
  405. # la respuesta (cuando está disponible) y lo guarda contra la mesa.
  406. # - Si ya hay orderId real, hace POST /orders con `orderId=<real>` y la
  407. # línea nueva (Toteat permite añadir líneas a órdenes abiertas en mesa).
  408. #
  409. # 3. `get_active_sale(table)` devuelve el orderId real si está cacheado.
  410. def _table_id_for_pending(sale_id: str) -> Optional[int]:
  411. state = _pending_sales.get(sale_id)
  412. if not state:
  413. return None
  414. return state.get("table_number")
  415. def create_sale(table_id) -> Optional[Dict[str, str]]:
  416. """Equivalente a `fudo.create_sale`. No hace HTTP: registra una venta
  417. pendiente vinculada al `table_id` (que en este módulo es el `tableId`
  418. de Toteat). Para resolver el número de mesa hacemos lookup inverso."""
  419. table_number: Optional[int] = None
  420. for raw in _fetch_tables():
  421. if str(raw.get("tableId") or raw.get("id")) == str(table_id):
  422. n = raw.get("number")
  423. if n is None:
  424. try:
  425. n = int(raw.get("name", "0"))
  426. except (TypeError, ValueError):
  427. n = None
  428. table_number = n
  429. break
  430. if table_number is None:
  431. logger.error(f"create_sale: no se pudo resolver número de mesa para tableId={table_id}")
  432. return None
  433. sale_id = f"pending:{uuid4().hex}"
  434. _pending_sales[sale_id] = {
  435. "table_id": str(table_id),
  436. "table_number": int(table_number),
  437. "items": [],
  438. "order_id": None,
  439. }
  440. return {"id": sale_id, "type": "Sale"}
  441. def _build_line(product_id: int, quantity: int, comment: Optional[str]) -> Dict[str, Any]:
  442. line: Dict[str, Any] = {
  443. "productCode": str(product_id),
  444. "quantity": quantity,
  445. }
  446. if comment:
  447. line["comment"] = comment
  448. return line
  449. def _post_order(table_id: str, lines: List[Dict[str, Any]], order_id: int = 0,
  450. comment: Optional[str] = None) -> Optional[Dict[str, Any]]:
  451. body: Dict[str, Any] = {
  452. "restaurantId": int(RESTAURANT_ID) if RESTAURANT_ID else 0,
  453. "localNumber": int(LOCAL_ID) if LOCAL_ID else 0,
  454. "orderId": order_id,
  455. "tableId": int(table_id),
  456. "orderReference": uuid4().hex,
  457. "status": "new",
  458. "type": "order",
  459. "channel": "app",
  460. "vendorName": "Pedidos Express",
  461. "comment": comment or "Pedido desde pedidos express",
  462. "document": {
  463. "line": lines,
  464. "payments": [],
  465. },
  466. "operationDate": time.strftime("%Y-%m-%dT%H:%M:%S"),
  467. "modifiedDate": time.strftime("%Y-%m-%dT%H:%M:%S"),
  468. }
  469. return _post("/orders", body, params={"orderDetail": "true"})
  470. def create_item(product_id: int, quantity: int, sale_id: str,
  471. comment: Optional[str] = None) -> Optional[Dict[str, Any]]:
  472. """Agrega un producto a una venta. Si la venta es pendiente (todavía no
  473. existe en Toteat) hace el POST /orders inicial; si ya existe le añade la
  474. línea. Devuelve un dict truthy con `id` para mantener compatibilidad con
  475. los callers de Fudo."""
  476. state = _pending_sales.get(sale_id)
  477. if state is None:
  478. logger.error(f"create_item: sale_id desconocido {sale_id}")
  479. return None
  480. line = _build_line(product_id, quantity, comment)
  481. state["items"].append(line)
  482. table_id = state["table_id"]
  483. table_number = state["table_number"]
  484. existing_order = state["order_id"] or _load_table_order(table_number)
  485. order_id_int = int(existing_order) if existing_order else 0
  486. response = _post_order(table_id, [line], order_id=order_id_int, comment=comment)
  487. if response is None or not response.get("ok"):
  488. logger.error(f"create_item: POST /orders falló para sale_id={sale_id} body={response}")
  489. return None
  490. # Toteat puede devolver el orderId en `data.orderId` o en el msg cuando
  491. # el ambiente es legacy. Hacemos lookup defensivo.
  492. order_id_returned: Optional[str] = None
  493. data = response.get("data") if isinstance(response.get("data"), dict) else None
  494. if data:
  495. for key in ("orderId", "id"):
  496. if data.get(key):
  497. order_id_returned = str(data[key])
  498. break
  499. if order_id_returned and not state["order_id"]:
  500. state["order_id"] = order_id_returned
  501. _persist_table_order(table_number, order_id_returned)
  502. return {
  503. "id": state["order_id"] or order_id_returned or sale_id,
  504. "type": "Item",
  505. "attributes": {"quantity": quantity},
  506. }
  507. # ---------------------------------------------------------------------------
  508. # Lectura de items en una mesa
  509. # ---------------------------------------------------------------------------
  510. def get_table_items(table_number: int) -> Optional[List[Dict[str, Any]]]:
  511. """Equivalente a `fudo.get_table_items`. Resuelve la mesa, encuentra su
  512. orden activa y devuelve `[{id, quantity}]` para cada línea."""
  513. table = get_table(table_number)
  514. if not table:
  515. return None
  516. active = get_active_sale(table)
  517. if not active:
  518. return None
  519. order = get_sale(active["id"])
  520. if not order:
  521. return None
  522. data = order.get("data") if isinstance(order.get("data"), dict) else order.get("data")
  523. if not data:
  524. return []
  525. # Toteat con det=true devuelve la orden completa. Las líneas suelen estar
  526. # en data.document.line o data.line; nos defendemos contra ambas.
  527. lines: List[Dict[str, Any]] = []
  528. if isinstance(data, dict):
  529. document = data.get("document") or {}
  530. lines = document.get("line") or data.get("line") or []
  531. elif isinstance(data, list):
  532. lines = data
  533. result: List[Dict[str, Any]] = []
  534. for line in lines:
  535. try:
  536. pid_raw = line.get("productCode") or line.get("productId") or line.get("product_id")
  537. if pid_raw is None:
  538. continue
  539. qty = line.get("quantity") or 1
  540. result.append({"id": int(pid_raw), "quantity": int(qty)})
  541. except (TypeError, ValueError):
  542. continue
  543. return result
  544. # ---------------------------------------------------------------------------
  545. # Paridad async con Fudo
  546. # ---------------------------------------------------------------------------
  547. async def fetch_page(session: aiohttp.ClientSession, url_template: str,
  548. token: str, page_num: int):
  549. """Stub de paridad. Toteat no expone /products paginado; devolvemos
  550. listas vacías para páginas > 1 y delegamos a `_fetch_menu` para la 1."""
  551. if page_num != 1:
  552. return page_num, []
  553. return page_num, await asyncio.to_thread(_fetch_menu)
  554. __all__ = [
  555. "get_token",
  556. "clear_token",
  557. "get_modifiers",
  558. "get_categories",
  559. "get_category_dict",
  560. "get_category",
  561. "get_product",
  562. "get_products",
  563. "get_all_indexed_products",
  564. "get_all_products",
  565. "get_table",
  566. "get_active_sale",
  567. "get_sale",
  568. "create_sale",
  569. "create_item",
  570. "get_table_items",
  571. ]
  572. # ---------------------------------------------------------------------------
  573. # Smoke tests
  574. # ---------------------------------------------------------------------------
  575. #
  576. # Ejecutar con: python -m toteat.toteat
  577. #
  578. # Requiere las env vars TOTEAT_API_TOKEN, TOTEAT_RESTAURANT_ID,
  579. # TOTEAT_LOCAL_ID, TOTEAT_USER_ID. Solo prueba lecturas seguras por defecto;
  580. # el bloque que crea órdenes está comentado y requiere descomentar
  581. # explícitamente para no impactar el ambiente real.
  582. def _smoke_tests() -> None:
  583. from rich import print as rprint
  584. rprint("[bold cyan]== Toteat smoke tests ==[/bold cyan]")
  585. rprint(f"BASE_URL = {BASE_URL}")
  586. missing = [
  587. name for name, val in [
  588. ("TOTEAT_API_TOKEN", API_TOKEN),
  589. ("TOTEAT_RESTAURANT_ID", RESTAURANT_ID),
  590. ("TOTEAT_LOCAL_ID", LOCAL_ID),
  591. ("TOTEAT_USER_ID", USER_ID),
  592. ] if not val
  593. ]
  594. if missing:
  595. rprint(f"[redFaltan env vars:[/red] {missing}")
  596. return
  597. rprint("\n[yellow]1. get_token()[/yellow]")
  598. rprint(f" → token len={len(get_token())}")
  599. rprint("\n[yellow]2. get_categories()[/yellow]")
  600. cats = get_categories()
  601. rprint(f" → {len(cats)} categorías")
  602. if cats:
  603. rprint(f" → ejemplo: {cats[0]}")
  604. rprint("\n[yellow]3. get_category_dict()[/yellow]")
  605. cat_dict = get_category_dict()
  606. rprint(f" → {len(cat_dict)} entradas")
  607. rprint("\n[yellow]4. get_products(page=1) (primeras 3)[/yellow]")
  608. prods = get_products(1)
  609. rprint(f" → {len(prods)} productos en página 1")
  610. for p in prods[:3]:
  611. rprint(
  612. f" • id={p['id']} name={p['attributes']['name']!r} "
  613. f"price={p['attributes']['price']} cat={p['relationships']['productCategory']['data']['id']}"
  614. )
  615. rprint("\n[yellow]5. get_products(page=2) (debe ser [])[/yellow]")
  616. rprint(f" → {get_products(2)}")
  617. rprint("\n[yellow]6. get_product(<primer id>)[/yellow]")
  618. if prods:
  619. first_id = prods[0]["id"]
  620. product = get_product(first_id)
  621. rprint(f" → {product}")
  622. rprint("\n[yellow]7. get_all_products() async[/yellow]")
  623. all_prods = asyncio.run(get_all_products())
  624. rprint(f" → {len(all_prods)} productos totales")
  625. rprint("\n[yellow]8. get_all_indexed_products() async[/yellow]")
  626. indexed = asyncio.run(get_all_indexed_products())
  627. rprint(f" → {len(indexed)} entradas indexadas (claves: {list(indexed)[:3]}...)")
  628. rprint("\n[yellow]9. get_modifiers() (puede ser []) [/yellow]")
  629. rprint(f" → {len(get_modifiers().get('data', []))} modificadores")
  630. rprint("\n[yellow]10. get_table(106)[/yellow]")
  631. table = get_table(106)
  632. rprint(f" → {table}")
  633. if table:
  634. rprint("\n[yellow]11. get_active_sale(table)[/yellow]")
  635. active = get_active_sale(table)
  636. rprint(f" → {active}")
  637. if active:
  638. rprint("\n[yellow]12. get_sale(<active.id>)[/yellow]")
  639. rprint(f" → keys: {list((get_sale(active['id']) or {}).keys())}")
  640. rprint("\n[yellow]13. get_table_items(106)[/yellow]")
  641. rprint(f" → {get_table_items(106)}")
  642. # ------------------------------------------------------------------
  643. # Pruebas de escritura — DESCOMENTAR SOLO EN AMBIENTE DEV
  644. # ------------------------------------------------------------------
  645. # rprint("\n[red]14. create_sale + create_item (ESCRITURA)[/red]")
  646. # if table:
  647. # sale = create_sale(table["id"])
  648. # rprint(f" → create_sale → {sale}")
  649. # if sale and prods:
  650. # item = create_item(int(prods[0]["id"]), 1, sale["id"], comment="smoke test")
  651. # rprint(f" → create_item → {item}")
  652. rprint("\n[bold green]✓ smoke tests OK[/bold green]")
  653. #pichula pal que lee
  654. if __name__ == "__main__":
  655. from dotenv import load_dotenv
  656. load_dotenv(".env")
  657. _smoke_tests()