2025-10-24 16:19:58 +03:00
|
|
|
|
"""
|
|
|
|
|
|
Claims API Routes - Обработка заявок
|
|
|
|
|
|
"""
|
2025-11-19 18:46:48 +03:00
|
|
|
|
from fastapi import APIRouter, HTTPException, Request, Query
|
|
|
|
|
|
from typing import Optional, List
|
|
|
|
|
|
import httpx
|
2025-11-14 19:06:36 +03:00
|
|
|
|
from .models import (
|
|
|
|
|
|
ClaimCreateRequest,
|
|
|
|
|
|
ClaimResponse,
|
|
|
|
|
|
TicketFormDescriptionRequest,
|
|
|
|
|
|
)
|
2025-10-24 16:19:58 +03:00
|
|
|
|
import uuid
|
|
|
|
|
|
from datetime import datetime
|
2025-11-14 19:06:36 +03:00
|
|
|
|
import json
|
|
|
|
|
|
import logging
|
|
|
|
|
|
from ..services.redis_service import redis_service
|
2025-11-19 18:46:48 +03:00
|
|
|
|
from ..services.database import db
|
2025-11-14 19:06:36 +03:00
|
|
|
|
from ..config import settings
|
2025-10-24 16:19:58 +03:00
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api/v1/claims", tags=["Claims"])
|
2025-11-14 19:06:36 +03:00
|
|
|
|
logger = logging.getLogger(__name__)
|
2025-10-24 16:19:58 +03:00
|
|
|
|
|
2025-11-19 18:46:48 +03:00
|
|
|
|
N8N_TICKET_FORM_FINAL_WEBHOOK = "https://n8n.clientright.pro/webhook/ecc93306-fadc-489a-afdb-d3e981013df3"
|
2025-10-24 16:19:58 +03:00
|
|
|
|
|
2025-11-19 18:46:48 +03:00
|
|
|
|
|
|
|
|
|
|
@router.post("/wizard")
|
|
|
|
|
|
async def submit_wizard(request: Request):
|
2025-10-24 16:19:58 +03:00
|
|
|
|
"""
|
2025-11-19 18:46:48 +03:00
|
|
|
|
Отправка данных визарда (вопросы + файлы) в n8n через multipart/form-data.
|
|
|
|
|
|
|
|
|
|
|
|
Вход: multipart/form-data с полями (stage=wizard, form_id, session_id, claim_id, ...),
|
|
|
|
|
|
JSON-строками (wizard_plan, wizard_answers, files_meta, ...) и файлами.
|
2025-10-24 16:19:58 +03:00
|
|
|
|
"""
|
|
|
|
|
|
try:
|
2025-11-19 18:46:48 +03:00
|
|
|
|
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()),
|
|
|
|
|
|
},
|
2025-10-24 16:19:58 +03:00
|
|
|
|
)
|
2025-11-19 18:46:48 +03:00
|
|
|
|
|
|
|
|
|
|
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")
|
2025-10-24 16:19:58 +03:00
|
|
|
|
except Exception as e:
|
2025-11-19 18:46:48 +03:00
|
|
|
|
logger.exception("❌ Ошибка при финальной отправке заявки")
|
2025-10-24 16:19:58 +03:00
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=500,
|
2025-11-19 18:46:48 +03:00
|
|
|
|
detail=f"Ошибка при создании заявки: {str(e)}",
|
2025-10-24 16:19:58 +03:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-11-19 18:46:48 +03:00
|
|
|
|
@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")
|
|
|
|
|
|
|
2025-11-20 18:31:42 +03:00
|
|
|
|
# Используем запрос из документации SQL_GET_ALL_CLAIMS_BY_UNIFIED_ID.sql
|
2025-11-19 18:46:48 +03:00
|
|
|
|
if unified_id:
|
|
|
|
|
|
# Основной способ - поиск по unified_id
|
2025-11-20 18:31:42 +03:00
|
|
|
|
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 c.unified_id = $1
|
|
|
|
|
|
ORDER BY c.updated_at DESC
|
|
|
|
|
|
LIMIT 20
|
|
|
|
|
|
"""
|
|
|
|
|
|
params = [unified_id]
|
|
|
|
|
|
logger.info(f"🔍 Searching by unified_id: {unified_id}")
|
2025-11-19 18:46:48 +03:00
|
|
|
|
elif phone:
|
|
|
|
|
|
# Fallback: ищем через clpr_user_accounts и clpr_users
|
2025-11-20 18:31:42 +03:00
|
|
|
|
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 c.unified_id = (
|
2025-11-19 18:46:48 +03:00
|
|
|
|
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
|
|
|
|
|
|
)
|
2025-11-20 18:31:42 +03:00
|
|
|
|
ORDER BY c.updated_at DESC
|
|
|
|
|
|
LIMIT 20
|
2025-11-19 18:46:48 +03:00
|
|
|
|
"""
|
2025-11-20 18:31:42 +03:00
|
|
|
|
params = [phone]
|
|
|
|
|
|
logger.info(f"🔍 Searching by phone (fallback): {phone}")
|
2025-11-19 18:46:48 +03:00
|
|
|
|
elif session_id:
|
|
|
|
|
|
# Fallback: поиск по session_token
|
2025-11-20 18:31:42 +03:00
|
|
|
|
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 c.session_token = $1
|
|
|
|
|
|
ORDER BY c.updated_at DESC
|
|
|
|
|
|
LIMIT 20
|
|
|
|
|
|
"""
|
|
|
|
|
|
params = [session_id]
|
|
|
|
|
|
logger.info(f"🔍 Searching by session_id (fallback): {session_id}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Это не должно произойти, т.к. проверка выше
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Необходимо указать unified_id, phone или session_id")
|
2025-11-19 18:46:48 +03:00
|
|
|
|
|
|
|
|
|
|
# Простой тест: проверяем, что unified_id вообще есть в базе
|
|
|
|
|
|
test_count = 0
|
2025-11-20 18:31:42 +03:00
|
|
|
|
test_count_null = 0
|
2025-11-19 18:46:48 +03:00
|
|
|
|
if unified_id:
|
|
|
|
|
|
try:
|
|
|
|
|
|
test_count = await db.fetch_val("SELECT COUNT(*) FROM clpr_claims WHERE unified_id = $1", unified_id)
|
2025-11-20 18:31:42 +03:00
|
|
|
|
# Также проверяем, сколько записей с NULL unified_id для этого пользователя (через phone)
|
|
|
|
|
|
if phone:
|
|
|
|
|
|
test_count_null = await db.fetch_val("""
|
|
|
|
|
|
SELECT COUNT(*) FROM clpr_claims c
|
|
|
|
|
|
WHERE c.unified_id IS NULL
|
|
|
|
|
|
AND c.channel = 'web_form'
|
|
|
|
|
|
AND c.payload->>'phone' = $1
|
|
|
|
|
|
""", phone)
|
|
|
|
|
|
logger.info(f"🔍 Test COUNT: unified_id={unified_id} → {test_count} records")
|
|
|
|
|
|
if test_count_null > 0:
|
|
|
|
|
|
logger.warning(f"⚠️ Found {test_count_null} records with NULL unified_id for phone={phone}")
|
2025-11-19 18:46:48 +03:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Ошибка тестового COUNT: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
rows = await db.fetch_all(query, *params)
|
|
|
|
|
|
|
2025-11-20 18:31:42 +03:00
|
|
|
|
# Детальное логирование для отладки
|
|
|
|
|
|
logger.info(f"🔍 Drafts query: unified_id={unified_id}, phone={phone}, session_id={session_id}")
|
|
|
|
|
|
logger.info(f"🔍 SQL query: {query}")
|
|
|
|
|
|
logger.info(f"🔍 SQL params: {params}")
|
|
|
|
|
|
logger.info(f"🔍 Test COUNT result: {test_count}")
|
|
|
|
|
|
logger.info(f"🔍 Rows found: {len(rows)}")
|
|
|
|
|
|
|
2025-11-19 18:46:48 +03:00
|
|
|
|
# ВРЕМЕННО: возвращаем тестовые данные для отладки
|
|
|
|
|
|
debug_info = {
|
|
|
|
|
|
"unified_id": unified_id,
|
|
|
|
|
|
"test_count": test_count,
|
2025-11-20 18:31:42 +03:00
|
|
|
|
"test_count_null": test_count_null,
|
2025-11-19 18:46:48 +03:00
|
|
|
|
"rows_found": len(rows),
|
2025-11-20 18:31:42 +03:00
|
|
|
|
"query": query[:200] if len(query) > 200 else query,
|
|
|
|
|
|
"params": params,
|
|
|
|
|
|
"phone": phone,
|
|
|
|
|
|
"session_id": session_id
|
2025-11-19 18:46:48 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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),
|
2025-11-20 18:31:42 +03:00
|
|
|
|
"drafts": drafts,
|
|
|
|
|
|
"debug": debug_info # ВРЕМЕННО: для отладки
|
2025-11-19 18:46:48 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2025-11-20 18:31:42 +03:00
|
|
|
|
logger.info(f"🔍 Загрузка черновика: claim_id={claim_id}")
|
|
|
|
|
|
|
|
|
|
|
|
# Ищем черновик по claim_id (может быть в payload->>'claim_id' или id = UUID)
|
|
|
|
|
|
# Убираем фильтры по channel и status_code, чтобы находить черновики из всех каналов
|
2025-11-24 16:46:56 +03:00
|
|
|
|
# ✅ Сортируем по updated_at DESC, чтобы получить самую свежую запись (которая может иметь send_to_form_approve)
|
2025-11-19 18:46:48 +03:00
|
|
|
|
query = """
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
id,
|
|
|
|
|
|
payload->>'claim_id' as claim_id,
|
|
|
|
|
|
session_token,
|
|
|
|
|
|
status_code,
|
2025-11-20 18:31:42 +03:00
|
|
|
|
channel,
|
2025-11-19 18:46:48 +03:00
|
|
|
|
payload,
|
|
|
|
|
|
created_at,
|
|
|
|
|
|
updated_at
|
|
|
|
|
|
FROM clpr_claims
|
2025-11-20 18:31:42 +03:00
|
|
|
|
WHERE (payload->>'claim_id' = $1 OR id::text = $1)
|
2025-11-24 16:46:56 +03:00
|
|
|
|
ORDER BY updated_at DESC
|
2025-11-19 18:46:48 +03:00
|
|
|
|
LIMIT 1
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
row = await db.fetch_one(query, claim_id)
|
|
|
|
|
|
|
2025-11-20 18:31:42 +03:00
|
|
|
|
logger.info(f"🔍 Найдено записей: {1 if row else 0}")
|
|
|
|
|
|
if row:
|
|
|
|
|
|
logger.info(f"🔍 Найден черновик: id={row.get('id')}, claim_id={row.get('claim_id')}, channel={row.get('channel')}, status={row.get('status_code')}")
|
|
|
|
|
|
|
2025-11-19 18:46:48 +03:00
|
|
|
|
if not row:
|
2025-11-20 18:31:42 +03:00
|
|
|
|
raise HTTPException(status_code=404, detail=f"Черновик не найден: {claim_id}")
|
2025-11-19 18:46:48 +03:00
|
|
|
|
|
|
|
|
|
|
# Обрабатываем 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 = {}
|
|
|
|
|
|
|
2025-11-20 18:31:42 +03:00
|
|
|
|
# Извлекаем claim_id из payload, если его нет в row
|
|
|
|
|
|
claim_id_from_payload = payload.get('claim_id') if isinstance(payload, dict) else None
|
|
|
|
|
|
final_claim_id = row.get('claim_id') or claim_id_from_payload
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"🔍 Загружен черновик: id={row.get('id')}, claim_id={final_claim_id}, channel={row.get('channel')}")
|
|
|
|
|
|
|
2025-11-19 18:46:48 +03:00
|
|
|
|
return {
|
|
|
|
|
|
"success": True,
|
|
|
|
|
|
"claim": {
|
|
|
|
|
|
"id": str(row['id']),
|
2025-11-20 18:31:42 +03:00
|
|
|
|
"claim_id": final_claim_id, # ✅ Используем claim_id из payload, если его нет в row
|
2025-11-19 18:46:48 +03:00
|
|
|
|
"session_token": row.get('session_token'),
|
|
|
|
|
|
"status_code": row.get('status_code'),
|
2025-11-20 18:31:42 +03:00
|
|
|
|
"channel": row.get('channel'), # ✅ Добавляем channel для отладки
|
2025-11-19 18:46:48 +03:00
|
|
|
|
"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)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-11-25 13:09:42 +03:00
|
|
|
|
@router.post("/approve")
|
|
|
|
|
|
async def publish_form_approval(request: Request):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Публикация данных подтвержденной формы в Redis канал
|
|
|
|
|
|
|
|
|
|
|
|
После SMS-апрува отправляет данные формы в Redis канал clientright:webform:approve
|
|
|
|
|
|
для обработки в n8n workflow.
|
|
|
|
|
|
|
|
|
|
|
|
В будущем можно подключить RabbitMQ для очереди и защиты от дублей.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
body = await request.json()
|
|
|
|
|
|
|
2025-11-25 15:38:07 +03:00
|
|
|
|
# Детальное логирование всего body для отладки
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"📥 Получен запрос на публикацию формы подтверждения",
|
|
|
|
|
|
extra={
|
|
|
|
|
|
"body_keys": list(body.keys()) if isinstance(body, dict) else "not_dict",
|
|
|
|
|
|
"body_type": type(body).__name__,
|
|
|
|
|
|
"sms_code_in_body": "sms_code" in body if isinstance(body, dict) else False,
|
|
|
|
|
|
"sms_code_value": body.get("sms_code", "NOT_FOUND") if isinstance(body, dict) else "NOT_DICT",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-11-25 13:09:42 +03:00
|
|
|
|
claim_id = body.get("claim_id")
|
|
|
|
|
|
session_token = body.get("session_token") or body.get("session_id")
|
2025-11-25 15:38:07 +03:00
|
|
|
|
sms_code = body.get("sms_code", "")
|
|
|
|
|
|
|
|
|
|
|
|
# Логируем полученные данные для отладки
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"📥 Извлеченные данные из запроса",
|
|
|
|
|
|
extra={
|
|
|
|
|
|
"claim_id": claim_id,
|
|
|
|
|
|
"sms_code": sms_code if sms_code else "(пусто)",
|
|
|
|
|
|
"sms_code_length": len(sms_code) if sms_code else 0,
|
|
|
|
|
|
"has_sms_code": bool(sms_code),
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
2025-11-25 13:09:42 +03:00
|
|
|
|
|
|
|
|
|
|
if not claim_id:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="claim_id обязателен")
|
|
|
|
|
|
|
|
|
|
|
|
# Генерируем idempotency key для защиты от дублей (для будущей интеграции с RabbitMQ)
|
|
|
|
|
|
import time
|
|
|
|
|
|
idempotency_key = f"{claim_id}_{int(time.time() * 1000)}_{body.get('user_id', 'unknown')}"
|
|
|
|
|
|
|
|
|
|
|
|
# Формируем событие для Redis
|
|
|
|
|
|
event_data = {
|
|
|
|
|
|
"event_type": "form_approve",
|
|
|
|
|
|
"status": "approved",
|
|
|
|
|
|
"message": "Форма подтверждена после SMS-верификации",
|
|
|
|
|
|
"claim_id": claim_id,
|
|
|
|
|
|
"session_token": session_token,
|
|
|
|
|
|
"unified_id": body.get("unified_id"),
|
|
|
|
|
|
"phone": body.get("phone"),
|
2025-11-25 15:38:07 +03:00
|
|
|
|
"sms_code": sms_code, # SMS код для верификации
|
2025-11-25 13:09:42 +03:00
|
|
|
|
"sms_verified": True,
|
|
|
|
|
|
"idempotency_key": idempotency_key, # Для защиты от дублей в RabbitMQ
|
|
|
|
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
|
|
|
|
|
|
|
|
|
|
# Данные формы подтверждения
|
|
|
|
|
|
"form_data": body.get("form_data", {}),
|
|
|
|
|
|
"user": body.get("user", {}),
|
|
|
|
|
|
"project": body.get("project", {}),
|
|
|
|
|
|
"offenders": body.get("offenders", []),
|
|
|
|
|
|
"meta": body.get("meta", {}),
|
|
|
|
|
|
|
|
|
|
|
|
# Оригинальные данные для сравнения
|
|
|
|
|
|
"original_data": body.get("original_data", {}),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Публикуем в Redis канал clientright:webform:approve
|
|
|
|
|
|
channel = "clientright:webform:approve"
|
2025-11-25 15:38:07 +03:00
|
|
|
|
|
2025-11-25 15:55:06 +03:00
|
|
|
|
# Логируем event_data перед сериализацией
|
2025-11-25 15:38:07 +03:00
|
|
|
|
logger.info(
|
2025-11-25 15:55:06 +03:00
|
|
|
|
f"📢 Формируем событие для Redis канала {channel}",
|
2025-11-25 15:38:07 +03:00
|
|
|
|
extra={
|
|
|
|
|
|
"claim_id": claim_id,
|
|
|
|
|
|
"idempotency_key": idempotency_key,
|
|
|
|
|
|
"sms_code": sms_code if sms_code else "(пусто)",
|
|
|
|
|
|
"has_sms_code": bool(sms_code),
|
2025-11-25 15:55:06 +03:00
|
|
|
|
"sms_code_in_event_data": "sms_code" in event_data,
|
|
|
|
|
|
"event_data_sms_code_value": event_data.get("sms_code", "NOT_FOUND"),
|
2025-11-25 15:38:07 +03:00
|
|
|
|
"event_data_keys": list(event_data.keys()),
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-11-25 15:55:06 +03:00
|
|
|
|
event_json = json.dumps(event_data, ensure_ascii=False)
|
|
|
|
|
|
|
|
|
|
|
|
# Логируем после сериализации
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"📢 JSON для публикации готов",
|
|
|
|
|
|
extra={
|
|
|
|
|
|
"json_length": len(event_json),
|
|
|
|
|
|
"sms_code_in_json": '"sms_code"' in event_json,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-11-25 13:09:42 +03:00
|
|
|
|
await redis_service.publish(channel, event_json)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(
|
2025-11-25 15:38:07 +03:00
|
|
|
|
f"✅ Form approval published to {channel}",
|
2025-11-25 13:09:42 +03:00
|
|
|
|
extra={
|
|
|
|
|
|
"claim_id": claim_id,
|
|
|
|
|
|
"idempotency_key": idempotency_key,
|
2025-11-25 15:38:07 +03:00
|
|
|
|
"sms_code_included": bool(sms_code),
|
2025-11-25 13:09:42 +03:00
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"success": True,
|
|
|
|
|
|
"channel": channel,
|
|
|
|
|
|
"idempotency_key": idempotency_key,
|
|
|
|
|
|
"message": "Данные формы отправлены на обработку",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.exception("❌ Failed to publish form approval")
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=500,
|
|
|
|
|
|
detail=f"Ошибка при отправке данных формы: {str(e)}",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-10-24 16:19:58 +03:00
|
|
|
|
@router.get("/{claim_id}")
|
|
|
|
|
|
async def get_claim(claim_id: str):
|
|
|
|
|
|
"""Получить информацию о заявке по ID"""
|
|
|
|
|
|
# TODO: Получить из БД
|
|
|
|
|
|
return {
|
|
|
|
|
|
"claim_id": claim_id,
|
|
|
|
|
|
"status": "processing",
|
|
|
|
|
|
"message": "Заявка в обработке"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-14 19:06:36 +03:00
|
|
|
|
|
2025-11-20 18:31:42 +03:00
|
|
|
|
@router.get("/wizard/load/{claim_id}")
|
|
|
|
|
|
async def load_wizard_data(claim_id: str):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Загрузить данные визарда из PostgreSQL по claim_id
|
|
|
|
|
|
|
|
|
|
|
|
Используется после получения claim_id из ocr_events.
|
|
|
|
|
|
Возвращает полные данные для построения формы (wizard_plan, problem_description и т.д.)
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
logger.info(f"🔍 Загрузка данных визарда для claim_id={claim_id}")
|
|
|
|
|
|
|
|
|
|
|
|
# Ищем заявку по claim_id (может быть UUID или строка CLM-...)
|
|
|
|
|
|
query = """
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
id,
|
|
|
|
|
|
payload->>'claim_id' as claim_id,
|
|
|
|
|
|
session_token,
|
|
|
|
|
|
unified_id,
|
|
|
|
|
|
status_code,
|
|
|
|
|
|
channel,
|
|
|
|
|
|
payload,
|
|
|
|
|
|
created_at,
|
|
|
|
|
|
updated_at
|
|
|
|
|
|
FROM clpr_claims
|
|
|
|
|
|
WHERE (payload->>'claim_id' = $1 OR id::text = $1)
|
|
|
|
|
|
LIMIT 1
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
row = await db.fetch_one(query, claim_id)
|
|
|
|
|
|
|
|
|
|
|
|
if not row:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Заявка не найдена: {claim_id}")
|
|
|
|
|
|
|
|
|
|
|
|
# Обрабатываем 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 = {}
|
|
|
|
|
|
|
|
|
|
|
|
# Извлекаем claim_id из payload, если его нет в row
|
|
|
|
|
|
claim_id_from_payload = payload.get('claim_id') if isinstance(payload, dict) else None
|
|
|
|
|
|
final_claim_id = row.get('claim_id') or claim_id_from_payload or str(row['id'])
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Загружены данные визарда: claim_id={final_claim_id}, has_wizard_plan={payload.get('wizard_plan') is not None}")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"success": True,
|
|
|
|
|
|
"claim_id": final_claim_id,
|
|
|
|
|
|
"session_token": row.get('session_token'),
|
|
|
|
|
|
"unified_id": row.get('unified_id'),
|
|
|
|
|
|
"status_code": row.get('status_code'),
|
|
|
|
|
|
"channel": row.get('channel'),
|
|
|
|
|
|
"wizard_plan": payload.get('wizard_plan'),
|
|
|
|
|
|
"problem_description": payload.get('problem_description'),
|
|
|
|
|
|
"wizard_answers": payload.get('answers'),
|
|
|
|
|
|
"answers_prefill": payload.get('answers_prefill'),
|
|
|
|
|
|
"documents_meta": payload.get('documents_meta', []),
|
|
|
|
|
|
"ai_agent1_facts": payload.get('ai_agent1_facts'),
|
|
|
|
|
|
"ai_agent13_rag": payload.get('ai_agent13_rag'),
|
|
|
|
|
|
"coverage_report": payload.get('coverage_report'),
|
|
|
|
|
|
"phone": payload.get('phone'),
|
|
|
|
|
|
"email": payload.get('email'),
|
|
|
|
|
|
"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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.exception("❌ Ошибка при загрузке данных визарда")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=f"Ошибка при загрузке данных визарда: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-11-14 19:06:36 +03:00
|
|
|
|
@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,
|
2025-11-20 18:31:42 +03:00
|
|
|
|
"claim_id": payload.claim_id, # Опционально - может быть None
|
2025-11-14 19:06:36 +03:00
|
|
|
|
"phone": payload.phone,
|
|
|
|
|
|
"email": payload.email,
|
|
|
|
|
|
"description": payload.problem_description.strip(),
|
|
|
|
|
|
"source": payload.source,
|
|
|
|
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
|
|
|
|
}
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"📝 TicketForm description received",
|
2025-11-20 18:31:42 +03:00
|
|
|
|
extra={"session_id": payload.session_id, "claim_id": payload.claim_id or "not_set"},
|
2025-11-14 19:06:36 +03:00
|
|
|
|
)
|
|
|
|
|
|
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}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|