1313 lines
62 KiB
Python
1313 lines
62 KiB
Python
"""
|
||
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}"
|
||
)
|
||
|