Добавлено логирование для отладки черновиков
- Добавлены логи в frontend (ClaimForm.tsx) для отслеживания unified_id и запросов к API - Добавлены логи в backend (claims.py) для отладки SQL запросов - Создан лог сессии с описанием проблемы и текущего состояния - Проблема: API возвращает 0 черновиков, хотя в БД есть данные
This commit is contained in:
@@ -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"""
|
||||
|
||||
@@ -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']}")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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():
|
||||
"""Информация о платформе"""
|
||||
|
||||
Reference in New Issue
Block a user