Files
aiform_prod/backend/app/api/claims.py
AI Assistant de092755af feat: Switch form approval to Redis channel instead of webhook
Changed from webhook to Redis Pub/Sub channel:
- Created endpoint POST /api/v1/claims/approve
- Publishes to Redis channel: form_approve:{claim_id}
- Added idempotency_key for future RabbitMQ integration
- Fire-and-forget approach (no waiting for response)

Benefits:
- Higher performance (Redis Pub/Sub is faster)
- Better scalability
- Ready for RabbitMQ queue integration
- Idempotency key included for duplicate protection

Files:
- backend/app/api/claims.py (new /approve endpoint)
- frontend/src/components/form/StepClaimConfirmation.tsx (updated saveFormData)
- docs/REDIS_FORM_APPROVE.md (documentation)
2025-11-25 12:29:36 +03:00

660 lines
26 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")
# Используем запрос из документации 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.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("/approve")
async def publish_form_approval(request: Request):
"""
Публикация данных подтвержденной формы в Redis канал
После SMS-апрува отправляет данные формы в Redis канал form_approve:{claim_id}
для обработки в n8n workflow.
В будущем можно подключить RabbitMQ для очереди и защиты от дублей.
"""
try:
body = await request.json()
claim_id = body.get("claim_id")
session_token = body.get("session_token") or body.get("session_id")
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_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 канал form_approve:{claim_id}
channel = f"form_approve:{claim_id}"
event_json = json.dumps(event_data, ensure_ascii=False)
await redis_service.publish(channel, event_json)
logger.info(
f"📢 Form approval published to {channel}",
extra={
"claim_id": claim_id,
"idempotency_key": idempotency_key,
},
)
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.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}"
)