Files
aiform_dev/backend/app/api/claims.py
AI Assistant 4c8fda5f55 Добавлено логирование для отладки черновиков
- Добавлены логи в frontend (ClaimForm.tsx) для отслеживания unified_id и запросов к API
- Добавлены логи в backend (claims.py) для отладки SQL запросов
- Создан лог сессии с описанием проблемы и текущего состояния
- Проблема: API возвращает 0 черновиков, хотя в БД есть данные
2025-11-19 18:46:48 +03:00

435 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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}"
)