Files
aiform_prod/backend/app/api/claims.py

1313 lines
62 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, BackgroundTasks
from typing import Optional, List
import httpx
from .models import (
ClaimCreateRequest,
ClaimResponse,
TicketFormDescriptionRequest,
)
import uuid
from datetime import datetime
import json
import logging
import asyncio
import os
from ..services.redis_service import redis_service
from ..services.database import db
from ..services.crm_mysql_service import crm_mysql_service
# Убрали импорты из n8n_service - больше не нужны для webhook подхода
from ..config import settings
router = APIRouter(prefix="/api/v1/claims", tags=["Claims"])
logger = logging.getLogger(__name__)
def _get_ticket_form_webhook() -> str:
"""URL webhook n8n для wizard и create. Менять в .env: N8N_TICKET_FORM_FINAL_WEBHOOK"""
return (getattr(settings, "n8n_ticket_form_final_webhook", None) or "").strip() or "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()),
},
)
webhook_url = _get_ticket_form_webhook()
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
webhook_url,
data=data,
files=files or None,
)
text = response.text or ""
logger.info(
"n8n wizard response: status=%s, body_length=%s, body_preview=%s",
response.status_code,
len(text),
text[:1500] if len(text) > 1500 else text,
extra={"claim_id": data.get("claim_id"), "session_id": data.get("session_id")},
)
if response.status_code == 200:
try:
parsed = json.loads(text)
logger.info(
"n8n wizard response (parsed): keys=%s",
list(parsed.keys()) if isinstance(parsed, dict) else type(parsed).__name__,
extra={"session_id": data.get("session_id")},
)
except Exception:
pass
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
webhook_url = _get_ticket_form_webhook()
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
webhook_url,
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
-- ВРЕМЕННО: убираем все фильтры для диагностики
-- TODO: вернуть фильтры после выяснения проблемы
-- AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
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, ИЛИ напрямую по телефону в payload
# Поддерживаем разные форматы телефона: 71234543212, +71234543212, 81234543212
query = """
SELECT DISTINCT
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.channel = 'web_form'
AND (
-- Вариант 1: Поиск через unified_id (если есть запись в clpr_user_accounts)
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 OR ua.channel_user_id = $2 OR ua.channel_user_id = $3)
LIMIT 1
)
-- Вариант 2: Прямой поиск по телефону в payload (в разных форматах)
OR c.payload->>'phone' = $1
OR c.payload->>'phone' = $2
OR c.payload->>'phone' = $3
)
AND (c.status_code NOT IN ('approved', 'in_work', 'submitted', 'completed', 'rejected') OR c.status_code IS NULL)
AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
ORDER BY c.updated_at DESC
LIMIT 20
"""
# Подготавливаем варианты телефона для поиска
phone_variants = [
phone, # Оригинальный формат
f"+{phone}", # С плюсом
phone.replace('7', '8', 1) if phone.startswith('7') else phone # С 8 вместо 7
]
params = phone_variants
logger.info(f"🔍 Searching by phone (fallback): {phone}, variants: {phone_variants}")
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
AND (c.status_code NOT IN ('approved', 'in_work', 'submitted', 'completed', 'rejected') OR c.status_code IS NULL)
AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
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
test_count_approved = 0
test_count_confirmed = 0
if unified_id:
try:
# Все заявления с этим unified_id
test_count = await db.fetch_val("SELECT COUNT(*) FROM clpr_claims WHERE unified_id = $1", unified_id)
# Заявления со статусом approved
test_count_approved = await db.fetch_val("""
SELECT COUNT(*) FROM clpr_claims
WHERE unified_id = $1 AND status_code = 'approved'
""", unified_id)
# Заявления с is_confirmed = true
test_count_confirmed = await db.fetch_val("""
SELECT COUNT(*) FROM clpr_claims
WHERE unified_id = $1 AND is_confirmed = true
""", 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} total, {test_count_approved} approved, {test_count_confirmed} confirmed")
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)}")
# Если заявления есть, но не возвращаются - проверяем статусы
if len(rows) == 0 and test_count > 0 and unified_id:
logger.warning(f"⚠️ Заявления есть (test_count={test_count}), но запрос вернул 0 строк!")
try:
all_statuses = await db.fetch_all("""
SELECT status_code, is_confirmed, channel, id
FROM clpr_claims
WHERE unified_id = $1
""", unified_id)
logger.warning(f"⚠️ Все заявления для unified_id: {[dict(r) for r in all_statuses]}")
except Exception as e:
logger.error(f"❌ Ошибка при проверке статусов: {e}")
# ВРЕМЕННО: возвращаем тестовые данные для отладки
debug_info = {
"unified_id": unified_id,
"test_count": test_count,
"test_count_approved": test_count_approved or 0,
"test_count_confirmed": test_count_confirmed or 0,
"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 = {}
# Извлекаем данные из ai_analysis или wizard_plan
ai_analysis = payload.get('ai_analysis') or {}
wizard_plan = payload.get('wizard_plan') or {}
# Краткое описание проблемы (заголовок)
problem_title = ai_analysis.get('problem') or payload.get('problem') or None
# Категория проблемы
category = ai_analysis.get('category') or wizard_plan.get('category') or None
# Направление (для иконки плитки)
direction = payload.get('direction') or wizard_plan.get('direction') or category
# facts_short из AI Agent (краткие факты — заголовок плитки)
ai_agent1_facts = payload.get('ai_agent1_facts') or {}
ai_analysis_facts = (payload.get('ai_analysis') or {}).get('facts_short')
facts_short = ai_agent1_facts.get('facts_short') or ai_analysis_facts
if facts_short and len(facts_short) > 200:
facts_short = facts_short[:200].rstrip() + ''
# Подробное описание (для превью); n8n может сохранять в description/chatInput
problem_text = (
payload.get('problem_description')
or payload.get('description')
or payload.get('chatInput')
or ''
)
# Считаем документы
documents_meta = payload.get('documents_meta') or []
documents_required = payload.get('documents_required') or []
# Считаем загруженные (уникальные по field_label)
uploaded_labels = set()
for doc in documents_meta:
label = doc.get('field_label') or doc.get('field_name')
if label:
uploaded_labels.add(label)
documents_uploaded = len(uploaded_labels)
documents_total = len(documents_required) if documents_required else 0
# Формируем список документов со статусами
documents_list = []
for doc_req in documents_required:
# Пробуем разные поля для названия документа (field_label приоритетнее)
doc_name = doc_req.get('field_label') or doc_req.get('name') or 'Документ'
doc_id = doc_req.get('id', '')
is_required = doc_req.get('required', False)
# Проверяем загружен ли (по field_label или name)
is_uploaded = doc_name in uploaded_labels or doc_id in uploaded_labels
documents_list.append({
"name": doc_name,
"required": is_required,
"uploaded": is_uploaded,
})
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,
# Заголовок - краткое описание проблемы из AI
"problem_title": problem_title[:150] if problem_title else None,
# Полное описание
"problem_description": problem_text[:500] if problem_text else None,
"category": category,
"direction": direction,
"facts_short": facts_short,
"wizard_plan": payload.get('wizard_plan') is not None,
"wizard_answers": payload.get('answers') is not None,
"has_documents": documents_uploaded > 0,
# Прогресс документов
"documents_total": documents_total,
"documents_uploaded": documents_uploaded,
"documents_skipped": 0, # TODO: считать пропущенные
"documents_list": documents_list, # Список со статусами
})
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.
Поддерживаются форматы: голый UUID, claim_id_<uuid> (из MAX startapp).
"""
try:
# Формат из MAX диплинка: claim_id_<uuid> — извлекаем UUID
if claim_id.startswith("claim_id_"):
claim_id = claim_id[9:]
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')}")
# 🔍 ОТЛАДКА: Логируем наличие documents_required
documents_required = payload.get('documents_required', []) if isinstance(payload, dict) else []
documents_meta = payload.get('documents_meta', []) if isinstance(payload, dict) else []
logger.info(f"🔍 Черновик {final_claim_id}: status_code={row.get('status_code')}, documents_required count={len(documents_required) if isinstance(documents_required, list) else 0}")
if documents_required:
logger.info(f"🔍 documents_required: {documents_required[:2]}...") # Первые 2 для примера
# Подсчет документов (как в списке черновиков)
documents_required_list = documents_required if isinstance(documents_required, list) else []
documents_meta_list = documents_meta if isinstance(documents_meta, list) else []
# Считаем загруженные (уникальные по field_label)
uploaded_labels = set()
for doc in documents_meta_list:
label = doc.get('field_label') or doc.get('field_name')
if label:
uploaded_labels.add(label)
documents_uploaded = len(uploaded_labels)
documents_total = len(documents_required_list) if documents_required_list else 0
# Формируем список документов со статусами
documents_list = []
for doc_req in documents_required_list:
# Пробуем разные поля для названия документа (field_label приоритетнее)
doc_name = doc_req.get('field_label') or doc_req.get('name') or 'Документ'
doc_id = doc_req.get('id', '')
is_required = doc_req.get('required', False)
# Проверяем загружен ли (по field_label или name)
is_uploaded = doc_name in uploaded_labels or doc_id in uploaded_labels
documents_list.append({
"name": doc_name,
"required": is_required,
"uploaded": is_uploaded,
})
# ✅ Проверяем флаг подтверждения данных контакта из CRM (поле cf_2624)
# Простой способ: делаем прямой SQL запрос к БД (таблицы vtiger_*)
# ПРИМЕЧАНИЕ: Если таблицы vtiger_* находятся в MySQL (а не PostgreSQL),
# нужно использовать отдельный connection через policy_service или создать новый MySQL connection
unified_id = row.get('unified_id')
contact_data_confirmed = False
contact_data_can_edit = True
contact_data_from_crm = None
# Получаем contact_id из payload
contact_id = payload.get('contact_id') if isinstance(payload, dict) else None
# Преобразуем contact_id в строку, если он есть
if contact_id:
contact_id = str(contact_id).strip()
logger.info(f"🔍 Получен contact_id из черновика: {contact_id} (type: {type(contact_id)})")
if contact_id:
try:
# ✅ Прямой SQL запрос к MySQL для получения cf_2624
# Таблицы vtiger_* находятся в MySQL БД
contact_query = """
SELECT
cd.contactid,
cd.firstname,
cd.lastname,
cd.email,
cd.mobile,
cd.phone,
cs.birthday,
ca.mailingstreet,
ca.mailingcity,
ca.mailingstate,
ca.mailingzip,
ca.mailingcountry,
ccf.cf_1157 AS middle_name,
ccf.cf_1263 AS birthplace,
ccf.cf_1257 AS inn,
ccf.cf_1849 AS requisites,
ccf.cf_1580 AS code,
ccf.cf_1706 AS sms,
ccf.cf_2624 AS cf_2624
FROM vtiger_contactdetails cd
LEFT JOIN vtiger_contactscf ccf ON ccf.contactid = cd.contactid
LEFT JOIN vtiger_contactsubdetails cs ON cs.contactsubscriptionid = cd.contactid
LEFT JOIN vtiger_contactaddress ca ON ca.contactaddressid = cd.contactid
LEFT JOIN vtiger_crmentity ce ON ce.crmid = cd.contactid
WHERE cd.contactid = %s
AND ce.deleted = 0
LIMIT 1
"""
contact_row = await crm_mysql_service.fetch_one(contact_query, contact_id)
if contact_row:
# Формируем объект с данными контакта
contact_data_from_crm = {
"contactid": contact_row.get("contactid"),
"firstname": contact_row.get("firstname"),
"lastname": contact_row.get("lastname"),
"email": contact_row.get("email"),
"mobile": contact_row.get("mobile"),
"phone": contact_row.get("phone"),
"birthday": contact_row.get("birthday"),
"mailingstreet": contact_row.get("mailingstreet"),
"mailingcity": contact_row.get("mailingcity"),
"mailingstate": contact_row.get("mailingstate"),
"mailingzip": contact_row.get("mailingzip"),
"mailingcountry": contact_row.get("mailingcountry"),
"cf_1157": contact_row.get("middle_name"), # Отчество
"cf_1263": contact_row.get("birthplace"), # Место рождения
"cf_1257": contact_row.get("inn"), # ИНН
"cf_1849": contact_row.get("requisites"), # Реквизиты
"cf_1580": contact_row.get("code"), # Код
"cf_1706": contact_row.get("sms"), # SMS
"cf_2624": contact_row.get("cf_2624") or "0" # ✅ Данные подтверждены
}
# ✅ Проверяем кастомное поле "Данные подтверждены" (cf_2624)
confirmed_field = contact_data_from_crm.get("cf_2624", "0")
contact_data_confirmed = confirmed_field == "1" or confirmed_field == "true" or confirmed_field is True
contact_data_can_edit = not contact_data_confirmed
logger.info(
f"🔒 Статус данных контакта из MySQL CRM: confirmed={contact_data_confirmed}, "
f"field_value={confirmed_field}, contact_id={contact_id}"
)
else:
logger.warning(f"⚠️ Контакт не найден в MySQL CRM: contact_id={contact_id}")
except Exception as e:
logger.warning(f"⚠️ Не удалось загрузить данные контакта из MySQL CRM: {str(e)}")
return {
"success": True,
"claim": {
"id": str(row['id']),
"claim_id": final_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,
"payload": payload,
# Информация о документах
"documents_total": documents_total,
"documents_uploaded": documents_uploaded,
"documents_list": documents_list,
},
# ✅ Флаги подтверждения данных контакта (из CRM поля cf_2624)
"contact_data_confirmed": contact_data_confirmed,
"contact_data_can_edit": contact_data_can_edit,
"contact_data_from_crm": contact_data_from_crm # Данные из CRM (всегда загружаем, если есть contact_id)
}
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. Поддерживается формат claim_id_<uuid>.
"""
try:
if claim_id.startswith("claim_id_"):
claim_id = claim_id[9:]
query = """
DELETE FROM clpr_claims
WHERE (payload->>'claim_id' = $1 OR id::text = $1)
AND status_code NOT IN ('submitted', 'completed', 'rejected')
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",
"contact_data_confirmed_in_body": "contact_data_confirmed" in body if isinstance(body, dict) else False,
"cf_2624_in_body": "cf_2624" in body if isinstance(body, dict) else False,
"bank_id_in_body": "bank_id" in body if isinstance(body, dict) else False,
"bank_name_in_body": "bank_name" in body if isinstance(body, dict) else False,
},
)
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')}"
# ✅ Получаем флаг подтверждения данных контакта и данные банка
contact_data_confirmed = body.get("contact_data_confirmed", False)
cf_2624 = body.get("cf_2624", "0")
bank_id = body.get("bank_id", "")
bank_name = body.get("bank_name", "")
# Логируем полученные значения для отладки
logger.info(
f"📥 Извлеченные дополнительные поля",
extra={
"contact_data_confirmed": contact_data_confirmed,
"cf_2624": cf_2624,
"bank_id": bank_id,
"bank_name": bank_name,
"has_contact_data_confirmed": "contact_data_confirmed" in body,
"has_cf_2624": "cf_2624" in body,
"has_bank_id": "bank_id" in body,
"has_bank_name": "bank_name" in body,
},
)
# Формируем событие для 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(),
# ✅ Флаг редактирования перс данных (cf_2624)
"contact_data_confirmed": contact_data_confirmed,
"cf_2624": cf_2624, # Значение для CRM (1 = подтверждено, 0 = не подтверждено)
# ✅ Данные банка для СБП выплаты
"bank_id": bank_id,
"bank_name": bank_name,
# Данные формы подтверждения
"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_data перед сериализацией
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),
"sms_code_in_event_data": "sms_code" in event_data,
"event_data_sms_code_value": event_data.get("sms_code", "NOT_FOUND"),
"event_data_keys": list(event_data.keys()),
"contact_data_confirmed_in_event": "contact_data_confirmed" in event_data,
"cf_2624_in_event": "cf_2624" in event_data,
"bank_id_in_event": "bank_id" in event_data,
"bank_name_in_event": "bank_name" in event_data,
},
)
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,
},
)
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):
"""
Загрузить данные визарда по claim_id. Поддерживается формат claim_id_<uuid>.
"""
try:
if claim_id.startswith("claim_id_"):
claim_id = claim_id[9:]
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)}")
# Актуальный webhook для описания проблемы (n8n.clientright.ru). Старый aiform_description на .pro больше не используем.
DESCRIPTION_WEBHOOK_DEFAULT = "https://n8n.clientright.ru/webhook/ticket_form_description"
DEBUG_LOG_PATH = "/app/logs/debug-2a4d38.log"
def _debug_log(hy: str, msg: str, data: dict):
try:
import time
line = json.dumps({
"sessionId": "2a4d38",
"hypothesisId": hy,
"location": "claims.py:publish_ticket_form_description",
"message": msg,
"data": data,
"timestamp": int(time.time() * 1000),
}, ensure_ascii=False) + "\n"
with open(DEBUG_LOG_PATH, "a", encoding="utf-8") as f:
f.write(line)
except Exception:
pass
def _get_description_webhook_url() -> str:
"""URL webhook для описания проблемы: только env N8N_DESCRIPTION_WEBHOOK или константа (старый .pro не используем)."""
url = (os.environ.get("N8N_DESCRIPTION_WEBHOOK") or "").strip()
if url:
return url
return DESCRIPTION_WEBHOOK_DEFAULT
async def _send_buffered_messages_to_webhook():
"""
Отправляет все сообщения из буфера в n8n webhook (вместо Redis pub/sub)
"""
try:
description_webhook_url = _get_description_webhook_url()
if not description_webhook_url:
logger.error("❌ N8N description webhook не настроен, не могу отправить из буфера")
return
buffer_key = "description"
messages = await redis_service.buffer_get_all(buffer_key)
if not messages:
logger.info("📭 Буфер пуст, нечего отправлять")
return
logger.info(f"📤 Отправляю {len(messages)} сообщений из буфера в n8n webhook...")
sent_count = 0
failed_count = 0
async with httpx.AsyncClient(timeout=10.0) as client:
for buffered_message in messages:
try:
# Восстанавливаем формат для n8n: массив с channel и message
channel = buffered_message.get("channel", f"{settings.redis_prefix}description")
message_data = buffered_message.get("message", buffered_message.get("event", buffered_message))
webhook_payload = [
{
"channel": channel,
"message": message_data
}
]
response = await client.post(
description_webhook_url,
json=webhook_payload, # Отправляем в формате массива
headers={"Content-Type": "application/json"}
)
if response.status_code == 200:
sent_count += 1
logger.info(
f"✅ Буферированное сообщение отправлено: "
f"session_id={buffered_message.get('session_id', 'unknown')}"
)
# НЕ возвращаем в буфер - успешно отправили
else:
# HTTP ошибка - возвращаем в буфер
failed_count += 1
logger.warning(
f"⚠️ n8n вернул ошибку {response.status_code}, "
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}"
)
await redis_service.buffer_push(buffer_key, buffered_message)
except httpx.TimeoutException:
failed_count += 1
logger.warning(
f"⏱️ Таймаут при отправке из буфера, "
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}"
)
await redis_service.buffer_push(buffer_key, buffered_message)
except httpx.RequestError as e:
failed_count += 1
logger.error(
f"🔌 Ошибка подключения к n8n: {e}, "
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}"
)
await redis_service.buffer_push(buffer_key, buffered_message)
except Exception as e:
failed_count += 1
logger.error(
f"❌ Неожиданная ошибка при отправке из буфера: {e}, "
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}",
exc_info=True
)
await redis_service.buffer_push(buffer_key, buffered_message)
logger.info(
f"📊 Результат отправки буфера: {sent_count} отправлено, "
f"{failed_count} возвращено в буфер"
)
except Exception as e:
logger.exception(f"❌ Ошибка при отправке буфера: {e}")
@router.post("/description")
async def publish_ticket_form_description(
payload: TicketFormDescriptionRequest,
background_tasks: BackgroundTasks
):
"""
Отправляет описание проблемы в n8n через webhook. URL: N8N_DESCRIPTION_WEBHOOK из env или константа (n8n.clientright.ru).
"""
# #region agent log
_debug_log("H1_H4", "POST /description handler entered", {"session_id": getattr(payload, "session_id", None)})
# #endregion
try:
description_webhook_url = _get_description_webhook_url()
# #region agent log
_debug_log("H3_H5", "description webhook URL resolved", {"url": description_webhook_url[:80] if description_webhook_url else "", "env_N8N": (os.environ.get("N8N_DESCRIPTION_WEBHOOK") or "")[:80]})
# #endregion
if not description_webhook_url:
raise HTTPException(
status_code=500,
detail="N8N description webhook не настроен"
)
# Если unified_id не передан — подставляем из сессии в Redis (tg/max auth создают сессию с unified_id)
unified_id = payload.unified_id
contact_id = payload.contact_id
phone = payload.phone
if not unified_id and payload.session_id:
try:
session_key = f"session:{payload.session_id}"
session_raw = await redis_service.client.get(session_key)
if session_raw:
session_data = json.loads(session_raw)
unified_id = unified_id or session_data.get("unified_id")
contact_id = contact_id or session_data.get("contact_id")
phone = phone or session_data.get("phone")
if unified_id:
logger.info("📝 unified_id/contact_id/phone подставлены из сессии Redis: session_key=%s", session_key)
except Exception as e:
logger.warning("Не удалось прочитать сессию из Redis для подстановки unified_id: %s", e)
# Формируем данные в формате, который ожидает n8n workflow
channel = payload.channel or f"{settings.redis_prefix}description"
message = {
"type": "ticket_form_description",
"session_id": payload.session_id,
"claim_id": payload.claim_id, # Опционально - может быть None
"phone": phone,
"email": payload.email,
"unified_id": unified_id, # из запроса или из сессии Redis
"contact_id": contact_id,
"description": payload.problem_description.strip(),
"source": payload.source,
"entry_channel": (payload.entry_channel or "web").strip() or "web", # telegram | max | web — для роутинга в n8n
"timestamp": datetime.utcnow().isoformat(),
}
# n8n workflow ожидает массив с объектом, содержащим channel и message
webhook_payload = [
{
"channel": channel,
"message": message
}
]
logger.info(
"📝 TicketForm description received → webhook=%s",
description_webhook_url[:80] + ("..." if len(description_webhook_url) > 80 else ""),
extra={
"session_id": payload.session_id,
"claim_id": payload.claim_id or "not_set",
"description_length": len(payload.problem_description),
"channel": channel,
},
)
# Retry-логика: пытаемся отправить в n8n webhook
max_attempts = 3
initial_delay = 1 # секунды
for attempt in range(1, max_attempts + 1):
try:
logger.info(
f"🔄 Попытка {attempt}/{max_attempts}: отправка в n8n webhook",
extra={"session_id": payload.session_id}
)
# #region agent log
_debug_log("H2_H4", "about to POST to n8n webhook", {"attempt": attempt, "url_short": description_webhook_url[:60] if description_webhook_url else ""})
# #endregion
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
description_webhook_url,
json=webhook_payload, # Отправляем в формате массива
headers={"Content-Type": "application/json"}
)
# #region agent log
_debug_log("H4", "n8n webhook response", {"status": response.status_code, "url_short": description_webhook_url[:60] if description_webhook_url else ""})
# #endregion
if response.status_code == 200:
response_body = response.text or ""
logger.info(
"✅ Описание успешно отправлено в n8n webhook (попытка %s), ответ n8n (length=%s): %s",
attempt,
len(response_body),
response_body[:2000] if len(response_body) > 2000 else response_body,
extra={"session_id": payload.session_id},
)
try:
parsed_n8n = json.loads(response_body)
logger.info(
"n8n description response (parsed): keys=%s",
list(parsed_n8n.keys()) if isinstance(parsed_n8n, dict) else type(parsed_n8n).__name__,
extra={"session_id": payload.session_id},
)
except Exception:
pass
# После описания фронт подписывается на SSE — логируем, на что именно
logger.info(
"📡 После описания в n8n клиент подпишется на: "
"channel_ocr=ocr_events:%s (GET /api/v1/events/%s), "
"channel_plan=claim:plan:%s (GET /api/v1/claim-plan/%s)",
payload.session_id, payload.session_id, payload.session_id, payload.session_id,
extra={"session_id": payload.session_id},
)
# Успешно отправили - возвращаем успех
return {
"success": True,
"event": message,
"attempt": attempt,
}
else:
# HTTP ошибка (не 200)
logger.warning(
f"⚠️ Попытка {attempt}: n8n вернул статус {response.status_code}",
extra={
"session_id": payload.session_id,
"status_code": response.status_code,
"response_preview": response.text[:200],
}
)
except httpx.TimeoutException:
logger.warning(
f"⏱️ Попытка {attempt}: таймаут при отправке в n8n webhook",
extra={"session_id": payload.session_id}
)
except httpx.RequestError as e:
logger.warning(
f"🔌 Попытка {attempt}: ошибка подключения к n8n: {e}",
extra={"session_id": payload.session_id}
)
except Exception as e:
logger.error(
f"❌ Попытка {attempt}: неожиданная ошибка: {e}",
extra={"session_id": payload.session_id},
exc_info=True
)
# Если это не последняя попытка - ждём перед следующей
if attempt < max_attempts:
wait_time = initial_delay * (2 ** (attempt - 1)) # Экспоненциальный backoff
logger.info(f"⏳ Жду {wait_time} секунд перед следующей попыткой...")
await asyncio.sleep(wait_time)
# Все попытки исчерпаны - сохраняем в буфер
logger.error(
f"Все {max_attempts} попытки исчерпаны, сохраняю в буфер",
extra={"session_id": payload.session_id}
)
buffer_message = {
"session_id": payload.session_id,
"claim_id": payload.claim_id,
"channel": channel,
"message": message, # Сохраняем message для последующей отправки
"timestamp": datetime.utcnow().isoformat(),
}
await redis_service.buffer_push("description", buffer_message)
logger.info(f"💾 Сообщение сохранено в буфер: session_id={payload.session_id}")
# Запускаем фоновую задачу для отправки из буфера
background_tasks.add_task(_send_buffered_messages_to_webhook)
buffer_size = await redis_service.buffer_size("description")
return {
"success": True,
"event": message,
"buffered": True,
"warning": (
"Обработка вашего обращения займёт немного больше времени. "
"Идёт автоматическое восстановление системы. "
"Ваше сообщение сохранено и будет обработано в ближайшее время."
),
"buffer_size": buffer_size,
}
except HTTPException:
raise
except Exception as e:
logger.exception("❌ Failed to send ticket form description to n8n")
raise HTTPException(
status_code=500,
detail=f"Не удалось отправить описание: {e}"
)