From 9a2dcbc3fe163d2da0737a63c39668afc420ec02 Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Tue, 12 May 2026 07:16:14 +0300 Subject: [PATCH] backend: client profile API (/api/clients, /api/lead) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NEW HANDLERS: - _handle_clients: groups manager's Leads by client (name or tg_id), returns list with leads_count, last_lead_at, client_phone (extracted from checklist), and full leads array per client. Sorted by recency desc. - _handle_lead: fetches single lead with parsed checklist + ai_response JSON. Validates ownership (manager_tg_id matches caller). NEW ENDPOINTS: - POST /api/clients — list of manager's clients with summary - POST /api/lead — single lead detail with ai_response for re-render Both accept initData auth, only manager role can call. Apps Script compat: ?path=clients and ?path=lead also work. --- backend-py/app/main.py | 135 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/backend-py/app/main.py b/backend-py/app/main.py index 45ab25b..1adb9a3 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -87,6 +87,8 @@ async def _dispatch_post(request: Request): "me": _handle_me, "measurement": _handle_measurement, "podbor": _handle_podbor, + "clients": _handle_clients, + "lead": _handle_lead, "ping": lambda b: {"pong": True, "time": _now_iso()}, "seed_admin": lambda b: _handle_seed_admin(), "test_ai": lambda b: _handle_test_ai(), @@ -135,6 +137,18 @@ async def api_podbor(request: Request): return await asyncio.to_thread(_handle_podbor, body) +@app.post("/api/clients") +async def api_clients(request: Request): + body = await _safe_json(request) + return _handle_clients(body) + + +@app.post("/api/lead") +async def api_lead(request: Request): + body = await _safe_json(request) + return _handle_lead(body) + + @app.get("/api/test_ai") async def api_test_ai(): return _handle_test_ai() @@ -526,6 +540,127 @@ def _enrich_ai_marketplaces(ai_result: dict[str, Any]) -> None: cat_data["models"] = parsers.enrich_models(models, delay_sec=0.4) +def _handle_clients(body: dict[str, Any]) -> dict[str, Any]: + """Возвращает список клиентов менеджера со сводкой по подборам.""" + cfg = get_config() + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if not auth or not auth.get("user"): + return {"error": "invalid_init_data"} + tg_id = auth["user"]["id"] + user = sheets.find_user(tg_id) + if not user or user.get("role") != "manager": + return {"error": "only_manager"} + + try: + ws = sheets.sheet("Leads") + rows = ws.get_all_values() + except Exception as e: + log.warning("Failed to read Leads: %s", e) + return {"ok": True, "clients": []} + + if not rows or len(rows) < 2: + return {"ok": True, "clients": []} + + headers = rows[0] + by_client: dict[str, dict[str, Any]] = {} + + for r in rows[1:]: + row = dict(zip(headers, r + [""] * (len(headers) - len(r)))) + if str(row.get("manager_tg_id", "")) != str(tg_id): + continue + client_name = (row.get("client_name") or "").strip() + client_tg_id = (row.get("client_tg_id") or "").strip() + # Ключ для группировки: tg_id если есть, иначе имя + key = client_tg_id or client_name.lower() + if not key: + continue + if key not in by_client: + by_client[key] = { + "client_name": client_name, + "client_tg_id": client_tg_id or None, + "client_phone": "", + "leads_count": 0, + "last_lead_at": "", + "last_lead_id": "", + "leads": [], + } + c = by_client[key] + c["leads_count"] += 1 + lead_id = row.get("id", "") + created_at = row.get("created_at", "") + status = row.get("status", "") + c["leads"].append({ + "id": lead_id, + "created_at": created_at, + "status": status, + }) + # Обновляем «последний» + if created_at > c["last_lead_at"]: + c["last_lead_at"] = created_at + c["last_lead_id"] = lead_id + # Достаём телефон из checklist JSON + checklist_str = row.get("checklist", "") + if checklist_str: + try: + cl = json.loads(checklist_str) + if cl.get("client_phone"): + c["client_phone"] = cl["client_phone"] + except (ValueError, TypeError): + pass + + # Сортируем по дате последнего подбора (новые сверху) + clients = sorted(by_client.values(), key=lambda x: x["last_lead_at"], reverse=True) + # Внутри каждого клиента — leads тоже по дате desc + for c in clients: + c["leads"].sort(key=lambda x: x.get("created_at", ""), reverse=True) + + return {"ok": True, "count": len(clients), "clients": clients} + + +def _handle_lead(body: dict[str, Any]) -> dict[str, Any]: + """Возвращает детали одного лида (включая AI-ответ и checklist).""" + cfg = get_config() + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if not auth or not auth.get("user"): + return {"error": "invalid_init_data"} + tg_id = auth["user"]["id"] + + lead_id = body.get("lead_id") or body.get("id") + if not lead_id: + return {"error": "missing_lead_id"} + + row = sheets.find_row("Leads", "id", lead_id) + if not row: + return {"error": "lead_not_found"} + + # Проверяем что это лид этого менеджера + if str(row.get("manager_tg_id", "")) != str(tg_id): + return {"error": "forbidden"} + + # Парсим JSONы + try: + checklist = json.loads(row.get("checklist") or "{}") + except (ValueError, TypeError): + checklist = {} + ai_response = row.get("ai_response") or "" + try: + ai_json = json.loads(ai_response) if ai_response else None + except (ValueError, TypeError): + ai_json = None + + return { + "ok": True, + "id": lead_id, + "created_at": row.get("created_at"), + "client_name": row.get("client_name"), + "client_tg_id": row.get("client_tg_id"), + "checklist": checklist, + "ai": ai_json, + "ai_text": ai_response if not ai_json else None, + "status": row.get("status", ""), + } + + def _handle_test_ai() -> dict[str, Any]: cfg = get_config() res = ai.call_ai("Скажи одной фразой: что за фабрика ЗОВ?",