Добавлено логирование для отладки черновиков

- Добавлены логи в frontend (ClaimForm.tsx) для отслеживания unified_id и запросов к API
- Добавлены логи в backend (claims.py) для отладки SQL запросов
- Создан лог сессии с описанием проблемы и текущего состояния
- Проблема: API возвращает 0 черновиков, хотя в БД есть данные
This commit is contained in:
AI Assistant
2025-11-19 18:46:48 +03:00
parent cbab1c0fe6
commit 4c8fda5f55
57 changed files with 6574 additions and 304 deletions

View File

@@ -1,7 +1,9 @@
"""
Claims API Routes - Обработка заявок
"""
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Request, Query
from typing import Optional, List
import httpx
from .models import (
ClaimCreateRequest,
ClaimResponse,
@@ -12,42 +14,374 @@ 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("/create", response_model=ClaimResponse)
async def create_claim(claim: ClaimCreateRequest):
@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:
# Генерируем ID и номер заявки
claim_id = str(uuid.uuid4())
claim_number = f"ERV-{datetime.now().strftime('%Y%m%d')}-{claim_id[:8].upper()}"
# TODO: Сохранить в PostgreSQL
# TODO: Отправить в очередь RabbitMQ для обработки
# TODO: Интеграция с CRM
return ClaimResponse(
success=True,
claim_id=claim_id,
claim_number=claim_number,
message=f"Заявка {claim_number} успешно создана"
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)}"
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"""

View File

@@ -98,7 +98,7 @@ async def stream_events(task_id: str):
# Слушаем события
while True:
logger.info(f"⏳ Waiting for message on {channel}...")
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=30.0)
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=60.0) # Увеличено для RAG обработки
if message:
logger.info(f"📥 Received message type: {message['type']}")

View File

@@ -36,6 +36,7 @@ async def proxy_policy_check(request: Request):
try:
# Получаем JSON body от фронтенда
body = await request.json()
body.setdefault('form_id', 'ticket_form')
logger.info(f"🔄 Proxy policy check: {body.get('policy_number', 'unknown')}")
@@ -85,7 +86,12 @@ async def proxy_create_contact(request: Request):
try:
body = await request.json()
logger.info(f"🔄 Proxy create contact: phone={body.get('phone', 'unknown')}, session_id={body.get('session_id', 'unknown')}")
logger.info(
"🔄 Proxy create contact: phone=%s, session_id=%s, form_id=%s",
body.get('phone', 'unknown'),
body.get('session_id', 'unknown'),
body.get('form_id', 'missing')
)
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
@@ -175,8 +181,27 @@ async def proxy_file_upload(
)
if response.status_code == 200:
response_text = response.text
logger.info(f"✅ File upload success")
return response.json()
if not response_text or response_text.strip() == '':
# n8n может вернуть пустой ответ, возвращаем заглушку
logger.warning("⚠️ N8N upload webhook вернул пустой ответ, подставляю default payload")
return {"success": True, "message": "n8n: empty response"}
try:
return response.json()
except Exception as e:
logger.error(f"Не удалось распарсить JSON от n8n: {e}. Response: {response_text[:500]}")
# Возвращаем текстовое содержимое чтобы фронт мог показать пользователю
return JSONResponse(
status_code=200,
content={
"success": True,
"message": "n8n upload returned non-JSON response",
"raw": response_text
}
)
else:
logger.error(f"❌ N8N returned {response.status_code}: {response.text}")
raise HTTPException(

View File

@@ -1,7 +1,7 @@
"""
Ticket Form Intake Platform - FastAPI Backend
"""
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import logging
@@ -189,6 +189,15 @@ async def test():
}
@app.get("/api/v1/utils/client-ip")
async def get_client_ip(request: Request):
"""Возвращает IP-адрес клиента по HTTP-запросу"""
client_host = request.client.host if request.client else None
return {
"ip": client_host
}
@app.get("/api/v1/info")
async def info():
"""Информация о платформе"""