""" Claims API Routes - Обработка заявок """ from fastapi import APIRouter, HTTPException, Request, Query from typing import Optional, List import httpx from .models import ( ClaimCreateRequest, ClaimResponse, TicketFormDescriptionRequest, ) import uuid from datetime import datetime import json import logging from ..services.redis_service import redis_service from ..services.database import db from ..config import settings router = APIRouter(prefix="/api/v1/claims", tags=["Claims"]) logger = logging.getLogger(__name__) N8N_TICKET_FORM_FINAL_WEBHOOK = "https://n8n.clientright.pro/webhook/ecc93306-fadc-489a-afdb-d3e981013df3" @router.post("/wizard") async def submit_wizard(request: Request): """ Отправка данных визарда (вопросы + файлы) в n8n через multipart/form-data. Вход: multipart/form-data с полями (stage=wizard, form_id, session_id, claim_id, ...), JSON-строками (wizard_plan, wizard_answers, files_meta, ...) и файлами. """ try: form = await request.form() data: dict[str, str] = {} files: dict[str, tuple] = {} for key, value in form.multi_items(): # В starlette UploadFile — это другой класс, чем fastapi.UploadFile, # поэтому проверяем по наличию атрибутов, а не по isinstance. if hasattr(value, "filename") and hasattr(value, "read"): file_bytes = await value.read() files[key] = (value.filename, file_bytes, value.content_type) else: # Приводим всё к строкам, включая JSON-строки data[key] = str(value) logger.info( "📨 TicketForm wizard submit received", extra={ "claim_id": data.get("claim_id"), "session_id": data.get("session_id"), "files": list(files.keys()), }, ) async with httpx.AsyncClient(timeout=120.0) as client: response = await client.post( N8N_TICKET_FORM_FINAL_WEBHOOK, data=data, files=files or None, ) text = response.text or "" if response.status_code == 200: logger.info( "✅ TicketForm wizard webhook OK", extra={"response_preview": text[:500]}, ) try: return json.loads(text) except Exception: return { "success": True, "message": "Wizard workflow started (non-JSON response from n8n)", "raw": text, } logger.error( "❌ TicketForm wizard webhook error", extra={"status_code": response.status_code, "body": text[:500]}, ) raise HTTPException( status_code=response.status_code, detail=f"n8n error: {text}", ) except httpx.TimeoutException: logger.error("⏱️ n8n wizard webhook timeout") raise HTTPException(status_code=504, detail="Таймаут подключения к n8n (wizard)") except Exception as e: logger.exception("❌ Ошибка при отправке визарда") raise HTTPException( status_code=500, detail=f"Ошибка при отправке визарда: {str(e)}", ) @router.post("/create") async def create_claim(request: Request): """ Финальное создание заявки Ticket Form Принимает данные формы от фронтенда и пробрасывает их в n8n webhook. """ try: body = await request.json() logger.info( "📨 TicketForm final submit received", extra={ "claim_id": body.get("claim_id"), "event_type": body.get("event_type"), }, ) # Проксируем запрос к n8n async with httpx.AsyncClient(timeout=60.0) as client: response = await client.post( N8N_TICKET_FORM_FINAL_WEBHOOK, json=body, headers={"Content-Type": "application/json"}, ) text = response.text or "" if response.status_code == 200: logger.info( "✅ TicketForm final webhook OK", extra={"response_preview": text[:500]}, ) # Если n8n вернул JSON — пробрасываем как есть try: return json.loads(text) except Exception: # Если не JSON, возвращаем обёртку return { "success": True, "message": "Workflow started (non-JSON response from n8n)", "raw": text, } logger.error( "❌ TicketForm final webhook error", extra={ "status_code": response.status_code, "body": text[:500], }, ) raise HTTPException( status_code=response.status_code, detail=f"n8n error: {text}", ) except httpx.TimeoutException: logger.error("⏱️ n8n final webhook timeout") raise HTTPException(status_code=504, detail="Таймаут подключения к n8n") except Exception as e: logger.exception("❌ Ошибка при финальной отправке заявки") raise HTTPException( status_code=500, detail=f"Ошибка при создании заявки: {str(e)}", ) @router.get("/drafts/list") async def list_drafts( unified_id: Optional[str] = Query(None, description="Unified ID пользователя для поиска черновиков"), phone: Optional[str] = Query(None, description="Номер телефона для поиска (fallback, если unified_id не указан)"), session_id: Optional[str] = Query(None, description="Session ID для поиска (fallback, если unified_id не указан)") ): """ Получить список всех заявок для пользователя (все статусы) Приоритет поиска: 1. unified_id (основной способ) - ищет по clpr_claims.unified_id 2. phone (fallback) - ищет через clpr_user_accounts и clpr_users 3. session_id (fallback) - ищет по session_token Возвращает все заявки с колонкой status_code для фильтрации на фронтенде """ try: if not unified_id and not phone and not session_id: raise HTTPException(status_code=400, detail="Необходимо указать unified_id, phone или session_id") query = """ SELECT c.id, c.payload->>'claim_id' as claim_id, c.session_token, c.status_code, c.channel, c.payload, c.created_at, c.updated_at FROM clpr_claims c WHERE 1=1 """ params = [] if unified_id: # Основной способ - поиск по unified_id query += " AND c.unified_id = $1" params.append(unified_id) elif phone: # Fallback: ищем через clpr_user_accounts и clpr_users query += """ AND c.unified_id = ( SELECT u.unified_id FROM clpr_user_accounts ua JOIN clpr_users u ON u.id = ua.user_id WHERE ua.channel = 'web_form' AND ua.channel_user_id = $1 LIMIT 1 ) """ params.append(phone) elif session_id: # Fallback: поиск по session_token query += " AND c.session_token = $1" params.append(session_id) query += " ORDER BY c.updated_at DESC LIMIT 20" # Простой тест: проверяем, что unified_id вообще есть в базе test_count = 0 if unified_id: try: test_count = await db.fetch_val("SELECT COUNT(*) FROM clpr_claims WHERE unified_id = $1", unified_id) except Exception as e: logger.error(f"❌ Ошибка тестового COUNT: {e}") rows = await db.fetch_all(query, *params) # ВРЕМЕННО: возвращаем тестовые данные для отладки debug_info = { "unified_id": unified_id, "test_count": test_count, "rows_found": len(rows), "query": query[:100] if len(query) > 100 else query, "params": params } drafts = [] for row in rows: # Обрабатываем payload - может быть строкой (JSONB) или уже dict payload_raw = row.get('payload') if isinstance(payload_raw, str): try: payload = json.loads(payload_raw) if payload_raw else {} except (json.JSONDecodeError, TypeError): payload = {} elif isinstance(payload_raw, dict): payload = payload_raw else: payload = {} drafts.append({ "id": str(row['id']), "claim_id": row.get('claim_id'), "session_token": row.get('session_token'), "status_code": row.get('status_code'), "channel": row.get('channel'), # Добавляем канал в ответ "created_at": row['created_at'].isoformat() if row.get('created_at') else None, "updated_at": row['updated_at'].isoformat() if row.get('updated_at') else None, "problem_description": payload.get('problem_description', '')[:100] if payload.get('problem_description') else None, "wizard_plan": payload.get('wizard_plan') is not None, "wizard_answers": payload.get('answers') is not None, "has_documents": len(payload.get('documents_meta', [])) > 0 if payload.get('documents_meta') else False, }) return { "success": True, "count": len(drafts), "drafts": drafts } except HTTPException: raise except Exception as e: logger.exception("❌ Ошибка при получении списка черновиков") raise HTTPException(status_code=500, detail=f"Ошибка при получении черновиков: {str(e)}") @router.get("/drafts/{claim_id}") async def get_draft(claim_id: str): """ Получить полные данные черновика по claim_id Возвращает все данные формы для продолжения заполнения """ try: query = """ SELECT id, payload->>'claim_id' as claim_id, session_token, status_code, payload, created_at, updated_at FROM clpr_claims WHERE payload->>'claim_id' = $1 AND status_code = 'draft' AND channel = 'web_form' LIMIT 1 """ row = await db.fetch_one(query, claim_id) if not row: raise HTTPException(status_code=404, detail="Черновик не найден") # Обрабатываем payload - может быть строкой (JSONB) или уже dict payload_raw = row.get('payload') if isinstance(payload_raw, str): try: payload = json.loads(payload_raw) if payload_raw else {} except (json.JSONDecodeError, TypeError): payload = {} elif isinstance(payload_raw, dict): payload = payload_raw else: payload = {} return { "success": True, "claim": { "id": str(row['id']), "claim_id": row.get('claim_id'), "session_token": row.get('session_token'), "status_code": row.get('status_code'), "created_at": row['created_at'].isoformat() if row.get('created_at') else None, "updated_at": row['updated_at'].isoformat() if row.get('updated_at') else None, "payload": payload } } except HTTPException: raise except Exception as e: logger.exception("❌ Ошибка при получении черновика") raise HTTPException(status_code=500, detail=f"Ошибка при получении черновика: {str(e)}") @router.delete("/drafts/{claim_id}") async def delete_draft(claim_id: str): """ Удалить черновик по claim_id Удаляет только черновики (status_code = 'draft') """ try: query = """ DELETE FROM clpr_claims WHERE payload->>'claim_id' = $1 AND status_code = 'draft' AND channel = 'web_form' RETURNING id """ deleted_id = await db.fetch_val(query, claim_id) if not deleted_id: raise HTTPException(status_code=404, detail="Черновик не найден или уже удален") logger.info(f"✅ Черновик удален: {claim_id}") return { "success": True, "message": "Черновик успешно удален", "claim_id": claim_id } except HTTPException: raise except Exception as e: logger.exception("❌ Ошибка при удалении черновика") raise HTTPException(status_code=500, detail=f"Ошибка при удалении черновика: {str(e)}") @router.get("/{claim_id}") async def get_claim(claim_id: str): """Получить информацию о заявке по ID""" # TODO: Получить из БД return { "claim_id": claim_id, "status": "processing", "message": "Заявка в обработке" } @router.post("/description") async def publish_ticket_form_description(payload: TicketFormDescriptionRequest): """ Публикует свободное описание проблемы в Redis канал ticket_form:description (слушается воркфлоу в n8n) """ try: channel = payload.channel or f"{settings.redis_prefix}description" event = { "type": "ticket_form_description", "session_id": payload.session_id, "claim_id": payload.claim_id, "phone": payload.phone, "email": payload.email, "description": payload.problem_description.strip(), "source": payload.source, "timestamp": datetime.utcnow().isoformat(), } logger.info( "📝 TicketForm description received", extra={"session_id": payload.session_id, "claim_id": payload.claim_id}, ) await redis_service.publish(channel, json.dumps(event, ensure_ascii=False)) logger.info( "📡 TicketForm description published", extra={"channel": channel, "session_id": payload.session_id}, ) return { "success": True, "channel": channel, "event": event, } except Exception as e: logger.exception("❌ Failed to publish ticket form description") raise HTTPException( status_code=500, detail=f"Не удалось опубликовать описание: {e}" )