Added comprehensive logging to track SMS code: - Log all keys in request body - Log SMS code presence and value - Log extracted SMS code before adding to event_data - This will help identify why SMS code might not appear in Redis Files: - backend/app/api/claims.py
698 lines
28 KiB
Python
698 lines
28 KiB
Python
"""
|
||
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")
|
||
|
||
# Используем запрос из документации SQL_GET_ALL_CLAIMS_BY_UNIFIED_ID.sql
|
||
if unified_id:
|
||
# Основной способ - поиск по unified_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 c.unified_id = $1
|
||
ORDER BY c.updated_at DESC
|
||
LIMIT 20
|
||
"""
|
||
params = [unified_id]
|
||
logger.info(f"🔍 Searching by unified_id: {unified_id}")
|
||
elif phone:
|
||
# Fallback: ищем через clpr_user_accounts и clpr_users
|
||
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 = (
|
||
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
|
||
)
|
||
ORDER BY c.updated_at DESC
|
||
LIMIT 20
|
||
"""
|
||
params = [phone]
|
||
logger.info(f"🔍 Searching by phone (fallback): {phone}")
|
||
elif session_id:
|
||
# Fallback: поиск по session_token
|
||
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")
|
||
|
||
# Простой тест: проверяем, что unified_id вообще есть в базе
|
||
test_count = 0
|
||
test_count_null = 0
|
||
if unified_id:
|
||
try:
|
||
test_count = await db.fetch_val("SELECT COUNT(*) FROM clpr_claims WHERE unified_id = $1", unified_id)
|
||
# Также проверяем, сколько записей с 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}")
|
||
except Exception as e:
|
||
logger.error(f"❌ Ошибка тестового COUNT: {e}")
|
||
|
||
rows = await db.fetch_all(query, *params)
|
||
|
||
# Детальное логирование для отладки
|
||
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)}")
|
||
|
||
# ВРЕМЕННО: возвращаем тестовые данные для отладки
|
||
debug_info = {
|
||
"unified_id": unified_id,
|
||
"test_count": test_count,
|
||
"test_count_null": test_count_null,
|
||
"rows_found": len(rows),
|
||
"query": query[:200] if len(query) > 200 else query,
|
||
"params": params,
|
||
"phone": phone,
|
||
"session_id": session_id
|
||
}
|
||
|
||
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,
|
||
"debug": debug_info # ВРЕМЕННО: для отладки
|
||
}
|
||
|
||
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:
|
||
logger.info(f"🔍 Загрузка черновика: claim_id={claim_id}")
|
||
|
||
# Ищем черновик по claim_id (может быть в payload->>'claim_id' или id = UUID)
|
||
# Убираем фильтры по channel и status_code, чтобы находить черновики из всех каналов
|
||
# ✅ Сортируем по updated_at DESC, чтобы получить самую свежую запись (которая может иметь send_to_form_approve)
|
||
query = """
|
||
SELECT
|
||
id,
|
||
payload->>'claim_id' as claim_id,
|
||
session_token,
|
||
status_code,
|
||
channel,
|
||
payload,
|
||
created_at,
|
||
updated_at
|
||
FROM clpr_claims
|
||
WHERE (payload->>'claim_id' = $1 OR id::text = $1)
|
||
ORDER BY updated_at DESC
|
||
LIMIT 1
|
||
"""
|
||
|
||
row = await db.fetch_one(query, claim_id)
|
||
|
||
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')}")
|
||
|
||
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
|
||
|
||
logger.info(f"🔍 Загружен черновик: id={row.get('id')}, claim_id={final_claim_id}, channel={row.get('channel')}")
|
||
|
||
return {
|
||
"success": True,
|
||
"claim": {
|
||
"id": str(row['id']),
|
||
"claim_id": final_claim_id, # ✅ Используем claim_id из payload, если его нет в row
|
||
"session_token": row.get('session_token'),
|
||
"status_code": row.get('status_code'),
|
||
"channel": row.get('channel'), # ✅ Добавляем 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,
|
||
"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.post("/approve")
|
||
async def publish_form_approval(request: Request):
|
||
"""
|
||
Публикация данных подтвержденной формы в Redis канал
|
||
|
||
После SMS-апрува отправляет данные формы в Redis канал clientright:webform:approve
|
||
для обработки в n8n workflow.
|
||
|
||
В будущем можно подключить RabbitMQ для очереди и защиты от дублей.
|
||
"""
|
||
try:
|
||
body = await request.json()
|
||
|
||
# Детальное логирование всего 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",
|
||
},
|
||
)
|
||
|
||
claim_id = body.get("claim_id")
|
||
session_token = body.get("session_token") or body.get("session_id")
|
||
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),
|
||
},
|
||
)
|
||
|
||
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"),
|
||
"sms_code": sms_code, # SMS код для верификации
|
||
"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"
|
||
event_json = json.dumps(event_data, ensure_ascii=False)
|
||
|
||
# Логируем перед публикацией
|
||
logger.info(
|
||
f"📢 Публикуем событие в Redis канал {channel}",
|
||
extra={
|
||
"claim_id": claim_id,
|
||
"idempotency_key": idempotency_key,
|
||
"sms_code": sms_code if sms_code else "(пусто)",
|
||
"has_sms_code": bool(sms_code),
|
||
"event_data_keys": list(event_data.keys()),
|
||
},
|
||
)
|
||
|
||
await redis_service.publish(channel, event_json)
|
||
|
||
logger.info(
|
||
f"✅ Form approval published to {channel}",
|
||
extra={
|
||
"claim_id": claim_id,
|
||
"idempotency_key": idempotency_key,
|
||
"sms_code_included": bool(sms_code),
|
||
},
|
||
)
|
||
|
||
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)}",
|
||
)
|
||
|
||
|
||
@router.get("/{claim_id}")
|
||
async def get_claim(claim_id: str):
|
||
"""Получить информацию о заявке по ID"""
|
||
# TODO: Получить из БД
|
||
return {
|
||
"claim_id": claim_id,
|
||
"status": "processing",
|
||
"message": "Заявка в обработке"
|
||
}
|
||
|
||
|
||
@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)}")
|
||
|
||
|
||
@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, # Опционально - может быть None
|
||
"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 or "not_set"},
|
||
)
|
||
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}"
|
||
)
|
||
|