toteat.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781
  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 = "https://api.toteat.com/mw/or/1.0"
  45. REQUEST_TIMEOUT = 15
  46. # Cache del menú: Toteat trae todos los productos en una sola request, pero
  47. # está limitado a 3 req/min. Cacheamos por unos minutos.
  48. _MENU_CACHE_TTL = 60 * 5 # 5 minutos
  49. _menu_cache: Dict[str, Any] = {"data": None, "expires": 0.0}
  50. # Cache de mesas: 3 req/min también.
  51. _TABLES_CACHE_TTL = 60
  52. _tables_cache: Dict[str, Any] = {"data": None, "expires": 0.0}
  53. # Mapeo en memoria de número de mesa → orderId committed en Toteat. Se llena
  54. # cuando `create_item` crea/actualiza una orden y se consulta desde
  55. # `get_active_sale` para devolver el id real.
  56. _table_to_order: Dict[int, str] = {}
  57. # Buffers de venta pendientes (sale_id sintético → estado interno).
  58. _pending_sales: Dict[str, Dict[str, Any]] = {}
  59. # Mapa inverso orderId real → estado de venta. Permite que `create_item` sea
  60. # llamado con el orderId real de Toteat (items 2+ en una misma orden) sin
  61. # perder el contexto de mesa y tabla necesario para el POST /orders.
  62. _order_to_state: 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`. Si en el futuro se necesita enrutar items por
  154. # alguna dimensión distinta, agregar un mapping configurable aquí.
  155. def _to_fudo_product(toteat_item: Dict[str, Any]) -> Dict[str, Any]:
  156. images = toteat_item.get("images") or []
  157. image_url = ""
  158. if images:
  159. first = images[0]
  160. if isinstance(first, dict):
  161. image_url = first.get("url") or first.get("image") or ""
  162. elif isinstance(first, str):
  163. image_url = first
  164. category_id = toteat_item.get("categoryId")
  165. is_modifier = bool(toteat_item.get("isModifier"))
  166. return {
  167. "id": str(toteat_item.get("id", "")),
  168. "type": "Product",
  169. "attributes": {
  170. "name": toteat_item.get("name", ""),
  171. "price": toteat_item.get("price", 0),
  172. "active": True, # Toteat solo devuelve activos por defecto
  173. "imageUrl": image_url,
  174. "description": toteat_item.get("description", ""),
  175. "enableQrMenu": not is_modifier,
  176. "isModifier": is_modifier,
  177. "sorting": toteat_item.get("sorting", ""),
  178. "localCode": toteat_item.get("localCode", ""),
  179. "referencePrice": toteat_item.get("referencePrice"),
  180. "modificationDate": toteat_item.get("modificationDate"),
  181. },
  182. "relationships": {
  183. "productCategory": {
  184. "data": {
  185. "id": str(category_id) if category_id is not None else "0",
  186. "type": "ProductCategory",
  187. }
  188. },
  189. "kitchen": {
  190. "data": {
  191. "id": str(category_id) if category_id is not None else "0",
  192. "type": "Kitchen",
  193. }
  194. },
  195. },
  196. "_toteat_raw": toteat_item,
  197. }
  198. # ---------------------------------------------------------------------------
  199. # API pública – mismo nombre que en fudo/fudo.py
  200. # ---------------------------------------------------------------------------
  201. def get_token() -> str:
  202. """Toteat no usa OAuth; mantenemos la firma para compatibilidad."""
  203. return API_TOKEN
  204. def clear_token() -> None:
  205. """No-op para compatibilidad con fudo.fudo.clear_token."""
  206. return None
  207. def _fetch_menu(force: bool = False) -> List[Dict[str, Any]]:
  208. now = time.time()
  209. if not force and _menu_cache["data"] is not None and _menu_cache["expires"] > now:
  210. return _menu_cache["data"]
  211. payload = _get("/products", params={"activeProducts": "true"})
  212. if not payload or not payload.get("ok"):
  213. logger.error(f"Toteat /products no devolvió data útil: {payload}")
  214. return _menu_cache["data"] or []
  215. raw = payload.get("data") or []
  216. _menu_cache["data"] = raw
  217. _menu_cache["expires"] = now + _MENU_CACHE_TTL
  218. return raw
  219. def get_modifiers() -> Dict[str, Any]:
  220. """En Toteat los modificadores vienen embebidos en `/products`. Para
  221. mantener compatibilidad devolvemos un dict similar al de Fudo."""
  222. items = _fetch_menu()
  223. modifiers = [i for i in items if i.get("isModifier")]
  224. return {"data": [_to_fudo_product(m) for m in modifiers]}
  225. def get_categories() -> List[Dict[str, Any]]:
  226. """Devuelve las categorías derivadas del menú de Toteat en forma JSON:API."""
  227. items = _fetch_menu()
  228. seen: Dict[str, str] = {}
  229. for it in items:
  230. cat_id = it.get("categoryId")
  231. cat_name = it.get("category")
  232. if cat_id is None or cat_name is None:
  233. continue
  234. seen[str(cat_id)] = cat_name
  235. return [
  236. {
  237. "id": cid,
  238. "type": "ProductCategory",
  239. "attributes": {"name": cname, "enableOnlineMenu": True},
  240. }
  241. for cid, cname in seen.items()
  242. ]
  243. def get_category_dict() -> Dict[str, str]:
  244. return {c["id"]: c["attributes"]["name"] for c in get_categories()}
  245. def get_category(id_category) -> Dict[str, Any]:
  246. cid = str(id_category)
  247. for c in get_categories():
  248. if c["id"] == cid:
  249. return {
  250. "id": c["id"],
  251. "name": c["attributes"]["name"],
  252. "enableOnlineMenu": c["attributes"].get("enableOnlineMenu", True),
  253. }
  254. return {"id": cid, "name": "Producto", "enableOnlineMenu": False}
  255. def get_product(product_id) -> Optional[Product]:
  256. pid = str(product_id)
  257. for it in _fetch_menu():
  258. if str(it.get("id")) == pid:
  259. mapped = _to_fudo_product(it)
  260. cat_id = mapped["relationships"]["productCategory"]["data"]["id"]
  261. return Product(
  262. id=mapped["id"],
  263. name=mapped["attributes"]["name"],
  264. type=get_category(cat_id)["name"] or "Producto",
  265. price=int(mapped["attributes"]["price"] or 0),
  266. image=mapped["attributes"]["imageUrl"],
  267. description=mapped["attributes"]["description"],
  268. status=1,
  269. kitchen_id=mapped["relationships"]["kitchen"]["data"]["id"] or None,
  270. promo_day=None,
  271. promo_price=None,
  272. promo_id=None,
  273. )
  274. logger.error(f"Producto {product_id} no encontrado en menú Toteat")
  275. return None
  276. def get_products(page: int = 1) -> List[Dict[str, Any]]:
  277. """Toteat no pagina /products; devolvemos todo en page=1 y vacío después
  278. para mantener el contrato de paginación de Fudo (loops `while data`)."""
  279. if page != 1:
  280. return []
  281. return [_to_fudo_product(p) for p in _fetch_menu() if not p.get("isModifier")]
  282. async def get_all_indexed_products() -> Dict[str, Dict[str, Any]]:
  283. """Equivalente al método async de Fudo. Como Toteat trae todo en una
  284. sola request, lo hacemos en un thread para no bloquear el event loop."""
  285. items = await asyncio.to_thread(_fetch_menu)
  286. indexed: Dict[str, Dict[str, Any]] = {}
  287. for it in items:
  288. if it.get("isModifier"):
  289. continue
  290. mapped = _to_fudo_product(it)
  291. indexed[mapped["id"]] = mapped
  292. return indexed
  293. async def get_all_products() -> List[Dict[str, Any]]:
  294. return list((await get_all_indexed_products()).values())
  295. # ---------------------------------------------------------------------------
  296. # Mesas
  297. # ---------------------------------------------------------------------------
  298. def _fetch_tables(force: bool = False) -> List[Dict[str, Any]]:
  299. now = time.time()
  300. if not force and _tables_cache["data"] is not None and _tables_cache["expires"] > now:
  301. return _tables_cache["data"]
  302. payload = _get("/tables")
  303. if not payload or not payload.get("ok"):
  304. logger.error(f"Toteat /tables no devolvió data útil: {payload}")
  305. return _tables_cache["data"] or []
  306. raw = payload.get("data") or []
  307. _tables_cache["data"] = raw
  308. _tables_cache["expires"] = now + _TABLES_CACHE_TTL
  309. return raw
  310. def _to_fudo_table(toteat_table: Dict[str, Any]) -> Dict[str, Any]:
  311. """Toteat /tables devuelve registros con campos como `tableId`, `name`,
  312. `available`, `capacity`. Algunos ambientes exponen `number`. Mapeamos al
  313. shape JSON:API que espera el código existente."""
  314. table_id = toteat_table.get("tableId") or toteat_table.get("id")
  315. number = toteat_table.get("number")
  316. if number is None:
  317. # Algunos ambientes guardan el número como `name` (string).
  318. try:
  319. number = int(toteat_table.get("name", "0"))
  320. except (TypeError, ValueError):
  321. number = 0
  322. available = toteat_table.get("available", True)
  323. # Active sale: Toteat no la embebe en /tables. Si tenemos un orderId
  324. # registrado en memoria/Redis para esta mesa lo exponemos. Si la mesa
  325. # está marcada como ocupada pero no tenemos orderId, dejamos vacío
  326. # (el caller terminará creando una venta nueva).
  327. cached_order = _load_table_order(int(number)) if number else None
  328. active_sales: List[Dict[str, str]] = []
  329. if cached_order:
  330. active_sales.append({"id": cached_order, "type": "Sale"})
  331. elif not available:
  332. # Mesa ocupada pero sin orderId conocido → intentamos descubrirlo.
  333. discovered = _discover_open_order_for_table(table_id)
  334. if discovered:
  335. _persist_table_order(int(number), discovered)
  336. active_sales.append({"id": discovered, "type": "Sale"})
  337. return {
  338. "id": str(table_id) if table_id is not None else "",
  339. "type": "Table",
  340. "attributes": {
  341. "number": number,
  342. "available": available,
  343. "capacity": toteat_table.get("capacity"),
  344. "name": toteat_table.get("name"),
  345. },
  346. "relationships": {
  347. "activeSales": {"data": active_sales},
  348. },
  349. "_toteat_raw": toteat_table,
  350. }
  351. def _discover_open_order_for_table(table_id: Any) -> Optional[str]:
  352. """Busca en Toteat órdenes abiertas y filtra por tableId."""
  353. payload = _get("/orderstatus", params={"listing": "true"})
  354. if not payload or not payload.get("ok"):
  355. return None
  356. data = payload.get("data") or []
  357. if not isinstance(data, list):
  358. return None
  359. target = str(table_id)
  360. for order in data:
  361. otable = order.get("tableId") or order.get("table") or order.get("table_id")
  362. if otable is not None and str(otable) == target:
  363. oid = order.get("orderId") or order.get("id")
  364. if oid is not None:
  365. return str(oid)
  366. return None
  367. def get_table(number: int) -> Optional[Dict[str, Any]]:
  368. for raw in _fetch_tables():
  369. raw_number = raw.get("number")
  370. if raw_number is None:
  371. try:
  372. raw_number = int(raw.get("name", "0"))
  373. except (TypeError, ValueError):
  374. raw_number = None
  375. if raw_number == number:
  376. return _to_fudo_table(raw)
  377. return None
  378. def get_active_sale(table: Optional[Dict[str, Any]]) -> Optional[Dict[str, str]]:
  379. if not table:
  380. return None
  381. data = table.get("relationships", {}).get("activeSales", {}).get("data") or []
  382. if not data:
  383. return None
  384. return data[0]
  385. def get_sale(sale_id) -> Optional[Dict[str, Any]]:
  386. payload = _get("/orderstatus", params={"ic": str(sale_id), "det": "true"})
  387. if not payload or not payload.get("ok"):
  388. return None
  389. return payload
  390. # ---------------------------------------------------------------------------
  391. # Creación / actualización de órdenes
  392. # ---------------------------------------------------------------------------
  393. #
  394. # Estrategia para mantener el flujo line-by-line de Fudo:
  395. #
  396. # 1. `create_sale(table_id)` registra una venta pendiente en memoria y
  397. # devuelve un id sintético "pending:<uuid>". No hace HTTP todavía.
  398. #
  399. # 2. `create_item(product_id, qty, sale_id, comment)`:
  400. # - Si `sale_id` empieza con "pending:" y aún no hay orderId real, hace
  401. # POST /orders con `orderId=0` y la línea actual. Toma el orderId de
  402. # la respuesta (cuando está disponible) y lo guarda contra la mesa.
  403. # - Si ya hay orderId real, hace POST /orders con `orderId=<real>` y la
  404. # línea nueva (Toteat permite añadir líneas a órdenes abiertas en mesa).
  405. #
  406. # 3. `get_active_sale(table)` devuelve el orderId real si está cacheado.
  407. def _table_id_for_pending(sale_id: str) -> Optional[int]:
  408. state = _pending_sales.get(sale_id)
  409. if not state:
  410. return None
  411. return state.get("table_number")
  412. def create_sale(table_id) -> Optional[Dict[str, str]]:
  413. """Equivalente a `fudo.create_sale`. No hace HTTP: registra una venta
  414. pendiente vinculada al `table_id` (que en este módulo es el `tableId`
  415. de Toteat). Para resolver el número de mesa hacemos lookup inverso."""
  416. table_number: Optional[int] = None
  417. for raw in _fetch_tables():
  418. if str(raw.get("tableId") or raw.get("id")) == str(table_id):
  419. n = raw.get("number")
  420. if n is None:
  421. try:
  422. n = int(raw.get("name", "0"))
  423. except (TypeError, ValueError):
  424. n = None
  425. table_number = n
  426. break
  427. if table_number is None:
  428. logger.error(f"create_sale: no se pudo resolver número de mesa para tableId={table_id}")
  429. return None
  430. sale_id = f"pending:{uuid4().hex}"
  431. _pending_sales[sale_id] = {
  432. "table_id": str(table_id),
  433. "table_number": int(table_number),
  434. "items": [],
  435. "order_id": None,
  436. }
  437. return {"id": sale_id, "type": "Sale"}
  438. def _build_line(product_id: str, quantity: int, comment: Optional[str]) -> Dict[str, Any]:
  439. line: Dict[str, Any] = {
  440. "productCode": str(product_id),
  441. "quantity": quantity,
  442. }
  443. if comment:
  444. line["comment"] = comment
  445. return line
  446. def _post_order(table_id: str, lines: List[Dict[str, Any]], order_id: int = 0,
  447. comment: Optional[str] = None) -> Optional[Dict[str, Any]]:
  448. body: Dict[str, Any] = {
  449. "restaurantId": int(RESTAURANT_ID) if RESTAURANT_ID else 0,
  450. "localNumber": int(LOCAL_ID) if LOCAL_ID else 0,
  451. "orderId": order_id,
  452. "tableId": int(table_id),
  453. "orderReference": uuid4().hex,
  454. "status": "new",
  455. "type": "order",
  456. "channel": "app",
  457. "vendorName": "Pedidos Express",
  458. "comment": comment or "Pedido desde pedidos express",
  459. "document": {
  460. "line": lines,
  461. "payments": [],
  462. },
  463. "operationDate": time.strftime("%Y-%m-%dT%H:%M:%S"),
  464. "modifiedDate": time.strftime("%Y-%m-%dT%H:%M:%S"),
  465. }
  466. return _post("/orders", body, params={"orderDetail": "true"})
  467. def create_item(product_id: str, quantity: int, sale_id: str,
  468. comment: Optional[str] = None) -> Optional[Dict[str, Any]]:
  469. """Agrega un producto a una venta. Si la venta es pendiente (todavía no
  470. existe en Toteat) hace el POST /orders inicial; si ya existe le añade la
  471. línea. Devuelve un dict truthy con `id` para mantener compatibilidad con
  472. los callers de Fudo."""
  473. state = _pending_sales.get(sale_id) or _order_to_state.get(sale_id)
  474. if state is None:
  475. logger.error(f"create_item: sale_id desconocido {sale_id}")
  476. return None
  477. line = _build_line(product_id, quantity, comment)
  478. state["items"].append(line)
  479. table_id = state["table_id"]
  480. table_number = state["table_number"]
  481. existing_order = state["order_id"] or _load_table_order(table_number)
  482. order_id_int = int(existing_order) if existing_order else 0
  483. response = _post_order(table_id, [line], order_id=order_id_int, comment=comment)
  484. if response is None or not response.get("ok"):
  485. logger.error(f"create_item: POST /orders falló para sale_id={sale_id} body={response}")
  486. return None
  487. # Toteat puede devolver el orderId en `data.orderId` o en el msg cuando
  488. # el ambiente es legacy. Hacemos lookup defensivo.
  489. order_id_returned: Optional[str] = None
  490. data = response.get("data") if isinstance(response.get("data"), dict) else None
  491. if data:
  492. for key in ("orderId", "id"):
  493. if data.get(key):
  494. order_id_returned = str(data[key])
  495. break
  496. if order_id_returned and not state["order_id"]:
  497. state["order_id"] = order_id_returned
  498. _persist_table_order(table_number, order_id_returned)
  499. _order_to_state[order_id_returned] = state
  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()