feat: Получение cf_2624 из MySQL и блокировка полей при подтверждении данных
- Добавлен сервис CrmMySQLService для прямого подключения к MySQL CRM - Обновлён метод get_draft() для получения cf_2624 напрямую из БД - Реализована блокировка полей (readonly) при contact_data_confirmed = true - Добавлен выбор банка для СБП выплат с динамической загрузкой из API - Обновлена документация по работе с cf_2624 и MySQL - Добавлен network_mode: host в docker-compose для доступа к MySQL - Обновлены компоненты формы для поддержки блокировки полей
This commit is contained in:
@@ -15,6 +15,7 @@ import json
|
||||
import logging
|
||||
from ..services.redis_service import redis_service
|
||||
from ..services.database import db
|
||||
from ..services.crm_mysql_service import crm_mysql_service
|
||||
from ..config import settings
|
||||
|
||||
router = APIRouter(prefix="/api/v1/claims", tags=["Claims"])
|
||||
@@ -201,17 +202,19 @@ async def list_drafts(
|
||||
c.updated_at
|
||||
FROM clpr_claims c
|
||||
WHERE c.unified_id = $1
|
||||
AND (c.status_code != 'approved' OR c.status_code IS NULL)
|
||||
AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
|
||||
-- ВРЕМЕННО: убираем все фильтры для диагностики
|
||||
-- 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
|
||||
# Fallback: ищем через clpr_user_accounts и clpr_users, ИЛИ напрямую по телефону в payload
|
||||
# Поддерживаем разные форматы телефона: 71234543212, +71234543212, 81234543212
|
||||
query = """
|
||||
SELECT
|
||||
SELECT DISTINCT
|
||||
c.id,
|
||||
c.payload->>'claim_id' as claim_id,
|
||||
c.session_token,
|
||||
@@ -221,21 +224,35 @@ async def list_drafts(
|
||||
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
|
||||
)
|
||||
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 != 'approved' 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 = [phone]
|
||||
logger.info(f"🔍 Searching by phone (fallback): {phone}")
|
||||
# Подготавливаем варианты телефона для поиска
|
||||
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 = """
|
||||
@@ -264,9 +281,22 @@ async def list_drafts(
|
||||
# Простой тест: проверяем, что 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("""
|
||||
@@ -275,7 +305,7 @@ async def list_drafts(
|
||||
AND c.channel = 'web_form'
|
||||
AND c.payload->>'phone' = $1
|
||||
""", phone)
|
||||
logger.info(f"🔍 Test COUNT: unified_id={unified_id} → {test_count} records")
|
||||
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:
|
||||
@@ -290,10 +320,25 @@ async def list_drafts(
|
||||
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,
|
||||
@@ -316,18 +361,68 @@ async def list_drafts(
|
||||
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
|
||||
|
||||
# Подробное описание (для превью)
|
||||
problem_text = payload.get('problem_description', '')
|
||||
|
||||
# Считаем документы
|
||||
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:
|
||||
doc_name = doc_req.get('name', 'Документ')
|
||||
doc_id = doc_req.get('id', '')
|
||||
is_required = doc_req.get('required', False)
|
||||
# Проверяем загружен ли (по name или id)
|
||||
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'), # Добавляем канал в ответ
|
||||
"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,
|
||||
# Заголовок - краткое описание проблемы из AI
|
||||
"problem_title": problem_title[:150] if problem_title else None,
|
||||
# Полное описание
|
||||
"problem_description": problem_text[:500] if problem_text else None,
|
||||
"category": category,
|
||||
"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,
|
||||
"has_documents": documents_uploaded > 0,
|
||||
# Прогресс документов
|
||||
"documents_total": documents_total,
|
||||
"documents_uploaded": documents_uploaded,
|
||||
"documents_skipped": 0, # TODO: считать пропущенные
|
||||
"documents_list": documents_list, # Список со статусами
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -406,18 +501,114 @@ async def get_draft(claim_id: str):
|
||||
if documents_required:
|
||||
logger.info(f"🔍 documents_required: {documents_required[:2]}...") # Первые 2 для примера
|
||||
|
||||
# ✅ Проверяем флаг подтверждения данных контакта из 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, # ✅ Используем claim_id из payload, если его нет в row
|
||||
"claim_id": final_claim_id,
|
||||
"session_token": row.get('session_token'),
|
||||
"status_code": row.get('status_code'),
|
||||
"channel": row.get('channel'), # ✅ Добавляем channel для отладки
|
||||
"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
|
||||
}
|
||||
},
|
||||
# ✅ Флаги подтверждения данных контакта (из 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:
|
||||
@@ -483,6 +674,10 @@ async def publish_form_approval(request: Request):
|
||||
"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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -508,6 +703,27 @@ async def publish_form_approval(request: Request):
|
||||
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",
|
||||
@@ -522,6 +738,14 @@ async def publish_form_approval(request: Request):
|
||||
"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", {}),
|
||||
@@ -547,6 +771,10 @@ async def publish_form_approval(request: Request):
|
||||
"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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -8,9 +8,11 @@ from typing import Optional, List
|
||||
import httpx
|
||||
import json
|
||||
import uuid
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from ..services.redis_service import redis_service
|
||||
from ..services.database import db
|
||||
from ..config import settings
|
||||
|
||||
router = APIRouter(prefix="/api/v1/documents", tags=["Documents"])
|
||||
@@ -20,6 +22,22 @@ logger = logging.getLogger(__name__)
|
||||
N8N_DOCUMENT_UPLOAD_WEBHOOK = "https://n8n.clientright.pro/webhook/webform_document_upload"
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Получить реальный IP клиента (с учётом proxy заголовков)"""
|
||||
# Сначала проверяем заголовки от reverse proxy
|
||||
forwarded_for = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
||||
real_ip = request.headers.get("x-real-ip", "").strip()
|
||||
|
||||
# X-Forwarded-For имеет приоритет
|
||||
if forwarded_for and forwarded_for not in ("127.0.0.1", "192.168.0.1", "::1"):
|
||||
return forwarded_for
|
||||
if real_ip and real_ip not in ("127.0.0.1", "192.168.0.1", "::1"):
|
||||
return real_ip
|
||||
|
||||
# Fallback на request.client
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_document(
|
||||
request: Request,
|
||||
@@ -29,6 +47,7 @@ async def upload_document(
|
||||
document_type: str = Form(...),
|
||||
document_name: Optional[str] = Form(None),
|
||||
document_description: Optional[str] = Form(None),
|
||||
group_index: Optional[str] = Form(None),
|
||||
unified_id: Optional[str] = Form(None),
|
||||
contact_id: Optional[str] = Form(None),
|
||||
phone: Optional[str] = Form(None),
|
||||
@@ -64,10 +83,7 @@ async def upload_document(
|
||||
file_size = len(file_content)
|
||||
|
||||
# Получаем IP клиента
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
forwarded_for = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
||||
if forwarded_for:
|
||||
client_ip = forwarded_for
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
# Формируем данные в формате совместимом с существующим n8n воркфлоу
|
||||
form_data = {
|
||||
@@ -92,14 +108,21 @@ async def upload_document(
|
||||
"upload_timestamp": datetime.utcnow().isoformat(),
|
||||
|
||||
# Формат uploads_* для совместимости
|
||||
"uploads_field_names[0]": document_type,
|
||||
"uploads_field_labels[0]": document_name or document_type,
|
||||
"uploads_descriptions[0]": document_description or "",
|
||||
# ✅ Используем group_index для правильной индексации (по умолчанию 0)
|
||||
"uploads_field_names[{idx}]".format(idx=group_index or "0"): document_type,
|
||||
"uploads_field_labels[{idx}]".format(idx=group_index or "0"): document_name or document_type,
|
||||
"uploads_descriptions[{idx}]".format(idx=group_index or "0"): document_description or "",
|
||||
}
|
||||
|
||||
# Файл для multipart (ключ uploads[0] для совместимости)
|
||||
# ✅ Добавляем group_index в данные формы
|
||||
if group_index:
|
||||
form_data["group_index"] = group_index
|
||||
logger.info(f"📋 group_index передан в n8n: {group_index}")
|
||||
|
||||
# Файл для multipart (ключ uploads[group_index] для совместимости)
|
||||
idx = group_index or "0"
|
||||
files = {
|
||||
"uploads[0]": (file.filename, file_content, file.content_type or "application/octet-stream")
|
||||
f"uploads[{idx}]": (file.filename, file_content, file.content_type or "application/octet-stream")
|
||||
}
|
||||
|
||||
# Отправляем в n8n
|
||||
@@ -213,10 +236,7 @@ async def upload_multiple_documents(
|
||||
)
|
||||
|
||||
# Получаем IP клиента
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
forwarded_for = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
||||
if forwarded_for:
|
||||
client_ip = forwarded_for
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
# Генерируем ID для каждого файла и читаем контент
|
||||
file_ids = []
|
||||
@@ -386,145 +406,43 @@ async def get_documents_status(claim_id: str):
|
||||
)
|
||||
|
||||
|
||||
@router.post("/generate-list")
|
||||
async def generate_documents_list(request: Request):
|
||||
"""
|
||||
Запрос на генерацию списка документов для проблемы.
|
||||
|
||||
Принимает описание проблемы, отправляет в n8n для быстрого AI-анализа.
|
||||
n8n публикует результат в Redis канал ocr_events:{session_id} с event_type=documents_list_ready.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
|
||||
session_id = body.get("session_id")
|
||||
problem_description = body.get("problem_description")
|
||||
|
||||
if not session_id or not problem_description:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="session_id и problem_description обязательны",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"📝 Generate documents list request",
|
||||
extra={
|
||||
"session_id": session_id,
|
||||
"description_length": len(problem_description),
|
||||
},
|
||||
)
|
||||
|
||||
# Публикуем событие в Redis для n8n
|
||||
event_data = {
|
||||
"type": "generate_documents_list",
|
||||
"session_id": session_id,
|
||||
"claim_id": body.get("claim_id"),
|
||||
"unified_id": body.get("unified_id"),
|
||||
"phone": body.get("phone"),
|
||||
"problem_description": problem_description,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
channel = f"{settings.redis_prefix}documents_list"
|
||||
|
||||
subscribers = await redis_service.publish(
|
||||
channel,
|
||||
json.dumps(event_data, ensure_ascii=False)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Documents list request published",
|
||||
extra={
|
||||
"channel": channel,
|
||||
"subscribers": subscribers,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Запрос на генерацию списка документов отправлен",
|
||||
"channel": channel,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("❌ Error generating documents list")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ошибка генерации списка: {str(e)}",
|
||||
)
|
||||
from typing import Optional, List
|
||||
import httpx
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from ..services.redis_service import redis_service
|
||||
from ..config import settings
|
||||
|
||||
router = APIRouter(prefix="/api/v1/documents", tags=["Documents"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# n8n webhook для загрузки документов
|
||||
N8N_DOCUMENT_UPLOAD_WEBHOOK = "https://n8n.clientright.pro/webhook/webform_document_upload"
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_document(
|
||||
async def skip_document(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
claim_id: str = Form(...),
|
||||
session_id: str = Form(...),
|
||||
document_type: str = Form(...),
|
||||
document_name: Optional[str] = Form(None),
|
||||
document_description: Optional[str] = Form(None),
|
||||
group_index: Optional[str] = Form(None),
|
||||
unified_id: Optional[str] = Form(None),
|
||||
contact_id: Optional[str] = Form(None),
|
||||
phone: Optional[str] = Form(None),
|
||||
):
|
||||
"""
|
||||
Загрузка одного документа.
|
||||
Пропуск документа (пользователь указал, что документа нет).
|
||||
|
||||
Принимает файл и метаданные, отправляет в n8n для:
|
||||
1. Сохранения в S3
|
||||
2. OCR обработки
|
||||
3. Обновления черновика в PostgreSQL
|
||||
|
||||
После успешной обработки n8n публикует событие document_ocr_completed в Redis.
|
||||
Отправляет событие в n8n на тот же webhook, что и загрузка файлов,
|
||||
но с флагом skipped=true для обработки пропуска.
|
||||
"""
|
||||
try:
|
||||
# Генерируем уникальный ID файла
|
||||
file_id = f"doc_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
logger.info(
|
||||
"📤 Document upload received",
|
||||
"⏭️ Document skip received",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"session_id": session_id,
|
||||
"document_type": document_type,
|
||||
"file_name": file.filename,
|
||||
"file_size": file.size if hasattr(file, 'size') else 'unknown',
|
||||
"content_type": file.content_type,
|
||||
"group_index": group_index,
|
||||
},
|
||||
)
|
||||
|
||||
# Читаем содержимое файла
|
||||
file_content = await file.read()
|
||||
file_size = len(file_content)
|
||||
|
||||
# Получаем IP клиента
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
forwarded_for = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
||||
if forwarded_for:
|
||||
client_ip = forwarded_for
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
# Формируем данные в формате совместимом с существующим n8n воркфлоу
|
||||
form_data = {
|
||||
# Основные идентификаторы
|
||||
"form_id": "ticket_form",
|
||||
"stage": "document_upload",
|
||||
"stage": "document_skip",
|
||||
"session_id": session_id,
|
||||
"claim_id": claim_id,
|
||||
"client_ip": client_ip,
|
||||
@@ -536,40 +454,39 @@ async def upload_document(
|
||||
|
||||
# Информация о документе
|
||||
"document_type": document_type,
|
||||
"file_id": file_id,
|
||||
"original_filename": file.filename,
|
||||
"content_type": file.content_type or "application/octet-stream",
|
||||
"file_size": str(file_size),
|
||||
"upload_timestamp": datetime.utcnow().isoformat(),
|
||||
"document_name": document_name or document_type,
|
||||
"skipped": "true", # ✅ Флаг пропуска документа
|
||||
"action": "skip", # ✅ Действие: пропуск
|
||||
"skip_timestamp": datetime.utcnow().isoformat(),
|
||||
|
||||
# Формат uploads_* для совместимости
|
||||
"uploads_field_names[0]": document_type,
|
||||
"uploads_field_labels[0]": document_name or document_type,
|
||||
"uploads_descriptions[0]": document_description or "",
|
||||
# Формат uploads_* для совместимости (без файлов)
|
||||
# ✅ Используем group_index для правильной индексации (по умолчанию 0)
|
||||
"uploads_field_names[{idx}]".format(idx=group_index or "0"): document_type,
|
||||
"uploads_field_labels[{idx}]".format(idx=group_index or "0"): document_name or document_type,
|
||||
"uploads_descriptions[{idx}]".format(idx=group_index or "0"): "",
|
||||
"files_count": "0", # ✅ Нет файлов
|
||||
}
|
||||
|
||||
# Файл для multipart (ключ uploads[0] для совместимости)
|
||||
files = {
|
||||
"uploads[0]": (file.filename, file_content, file.content_type or "application/octet-stream")
|
||||
}
|
||||
# ✅ Добавляем group_index в данные формы
|
||||
if group_index:
|
||||
form_data["group_index"] = group_index
|
||||
logger.info(f"📋 group_index передан в n8n: {group_index}")
|
||||
|
||||
# Отправляем в n8n
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
# Отправляем в n8n на тот же webhook (без файлов)
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
N8N_DOCUMENT_UPLOAD_WEBHOOK,
|
||||
data=form_data,
|
||||
files=files,
|
||||
)
|
||||
|
||||
response_text = response.text or ""
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
"✅ Document uploaded to n8n",
|
||||
"✅ Document skip sent to n8n",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"document_type": document_type,
|
||||
"file_id": file_id,
|
||||
"response_preview": response_text[:200],
|
||||
},
|
||||
)
|
||||
@@ -582,13 +499,12 @@ async def upload_document(
|
||||
|
||||
# Публикуем событие в Redis для фронтенда
|
||||
event_data = {
|
||||
"event_type": "document_uploaded",
|
||||
"status": "processing",
|
||||
"event_type": "document_skipped",
|
||||
"status": "skipped",
|
||||
"claim_id": claim_id,
|
||||
"session_id": session_id,
|
||||
"document_type": document_type,
|
||||
"file_id": file_id,
|
||||
"original_filename": file.filename,
|
||||
"document_name": document_name or document_type,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
@@ -599,16 +515,15 @@ async def upload_document(
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"file_id": file_id,
|
||||
"document_type": document_type,
|
||||
"ocr_status": "processing",
|
||||
"message": "Документ загружен и отправлен на обработку",
|
||||
"status": "skipped",
|
||||
"message": "Документ пропущен и сохранён",
|
||||
"n8n_response": n8n_response,
|
||||
}
|
||||
|
||||
else:
|
||||
logger.error(
|
||||
"❌ n8n document upload error",
|
||||
"❌ n8n document skip error",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"body": response_text[:500],
|
||||
@@ -620,222 +535,17 @@ async def upload_document(
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("⏱️ n8n document upload timeout")
|
||||
raise HTTPException(status_code=504, detail="Таймаут загрузки документа")
|
||||
logger.error("⏱️ n8n document skip timeout")
|
||||
raise HTTPException(status_code=504, detail="Таймаут отправки пропуска документа")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("❌ Document upload error")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ошибка загрузки документа: {str(e)}",
|
||||
)
|
||||
logger.exception("❌ Document skip error")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка пропуска документа: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/upload-multiple")
|
||||
async def upload_multiple_documents(
|
||||
request: Request,
|
||||
files: List[UploadFile] = File(...),
|
||||
claim_id: str = Form(...),
|
||||
session_id: str = Form(...),
|
||||
document_type: str = Form(...),
|
||||
document_name: Optional[str] = Form(None),
|
||||
document_description: Optional[str] = Form(None),
|
||||
unified_id: Optional[str] = Form(None),
|
||||
contact_id: Optional[str] = Form(None),
|
||||
phone: Optional[str] = Form(None),
|
||||
):
|
||||
"""
|
||||
Загрузка нескольких файлов для одного документа (например, несколько страниц паспорта).
|
||||
Все файлы отправляются одним запросом в n8n.
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"📤 Multiple documents upload received",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"session_id": session_id,
|
||||
"document_type": document_type,
|
||||
"files_count": len(files),
|
||||
"file_names": [f.filename for f in files],
|
||||
},
|
||||
)
|
||||
|
||||
# Получаем IP клиента
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
forwarded_for = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
||||
if forwarded_for:
|
||||
client_ip = forwarded_for
|
||||
|
||||
# Генерируем ID для каждого файла и читаем контент
|
||||
file_ids = []
|
||||
files_multipart = {}
|
||||
|
||||
for i, file in enumerate(files):
|
||||
file_id = f"doc_{uuid.uuid4().hex[:12]}"
|
||||
file_ids.append(file_id)
|
||||
|
||||
file_content = await file.read()
|
||||
files_multipart[f"uploads[{i}]"] = (
|
||||
file.filename,
|
||||
file_content,
|
||||
file.content_type or "application/octet-stream"
|
||||
)
|
||||
|
||||
# Формируем данные формы
|
||||
form_data = {
|
||||
# Основные идентификаторы
|
||||
"form_id": "ticket_form",
|
||||
"stage": "document_upload",
|
||||
"session_id": session_id,
|
||||
"claim_id": claim_id,
|
||||
"client_ip": client_ip,
|
||||
|
||||
# Идентификаторы пользователя
|
||||
"unified_id": unified_id or "",
|
||||
"contact_id": contact_id or "",
|
||||
"phone": phone or "",
|
||||
|
||||
# Информация о документе
|
||||
"document_type": document_type,
|
||||
"files_count": str(len(files)),
|
||||
"upload_timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
# ✅ Получаем group_index из Form (индекс документа в documents_required)
|
||||
form_params = await request.form()
|
||||
group_index = form_params.get("group_index")
|
||||
if group_index:
|
||||
form_data["group_index"] = group_index
|
||||
logger.info(f"📋 group_index передан в n8n: {group_index}")
|
||||
|
||||
# Добавляем информацию о каждом файле
|
||||
for i, (file, file_id) in enumerate(zip(files, file_ids)):
|
||||
form_data[f"file_ids[{i}]"] = file_id
|
||||
form_data[f"uploads_field_names[{i}]"] = document_type
|
||||
form_data[f"uploads_field_labels[{i}]"] = document_name or document_type
|
||||
form_data[f"uploads_descriptions[{i}]"] = document_description or ""
|
||||
form_data[f"original_filenames[{i}]"] = file.filename
|
||||
|
||||
# Отправляем в n8n одним запросом
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
response = await client.post(
|
||||
N8N_DOCUMENT_UPLOAD_WEBHOOK,
|
||||
data=form_data,
|
||||
files=files_multipart,
|
||||
)
|
||||
|
||||
response_text = response.text or ""
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
"✅ Multiple documents uploaded to n8n",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"document_type": document_type,
|
||||
"file_ids": file_ids,
|
||||
"files_count": len(files),
|
||||
},
|
||||
)
|
||||
|
||||
# Парсим ответ от n8n
|
||||
try:
|
||||
n8n_response = json.loads(response_text)
|
||||
except json.JSONDecodeError:
|
||||
n8n_response = {"raw": response_text}
|
||||
|
||||
# Публикуем событие в Redis
|
||||
event_data = {
|
||||
"event_type": "documents_uploaded",
|
||||
"status": "processing",
|
||||
"claim_id": claim_id,
|
||||
"session_id": session_id,
|
||||
"document_type": document_type,
|
||||
"file_ids": file_ids,
|
||||
"files_count": len(files),
|
||||
"original_filenames": [f.filename for f in files],
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
await redis_service.publish(
|
||||
f"ocr_events:{session_id}",
|
||||
json.dumps(event_data, ensure_ascii=False)
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"file_ids": file_ids,
|
||||
"files_count": len(files),
|
||||
"document_type": document_type,
|
||||
"ocr_status": "processing",
|
||||
"message": f"Загружено {len(files)} файл(ов)",
|
||||
"n8n_response": n8n_response,
|
||||
}
|
||||
|
||||
else:
|
||||
logger.error(
|
||||
"❌ n8n multiple upload error",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"body": response_text[:500],
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=f"Ошибка n8n: {response_text}",
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("⏱️ n8n multiple upload timeout")
|
||||
raise HTTPException(status_code=504, detail="Таймаут загрузки документов")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("❌ Multiple upload error")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ошибка загрузки документов: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status/{claim_id}")
|
||||
async def get_documents_status(claim_id: str):
|
||||
"""
|
||||
Получить статус обработки документов для заявки.
|
||||
|
||||
Возвращает:
|
||||
- Список загруженных документов и их OCR статус
|
||||
- Общий прогресс обработки
|
||||
"""
|
||||
try:
|
||||
# TODO: Запрос в PostgreSQL для получения статуса документов
|
||||
# Пока возвращаем mock данные
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"claim_id": claim_id,
|
||||
"documents": [],
|
||||
"ocr_progress": {
|
||||
"total": 0,
|
||||
"completed": 0,
|
||||
"processing": 0,
|
||||
"failed": 0,
|
||||
},
|
||||
"wizard_ready": False,
|
||||
"claim_ready": False,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("❌ Error getting documents status")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ошибка получения статуса: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/generate-list")
|
||||
async def generate_documents_list(request: Request):
|
||||
@@ -907,3 +617,193 @@ async def generate_documents_list(request: Request):
|
||||
detail=f"Ошибка генерации списка: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
def compute_documents_hash(doc_ids: List[str]) -> str:
|
||||
"""
|
||||
Вычисляет hash от списка document_id для проверки актуальности черновика.
|
||||
Должен совпадать с JS алгоритмом в n8n build_form_draft.
|
||||
"""
|
||||
import ctypes
|
||||
|
||||
sorted_ids = sorted([d for d in doc_ids if d])
|
||||
hash_input = ','.join(sorted_ids)
|
||||
|
||||
# djb2 hash — эмуляция JS поведения
|
||||
# В JS: (hash << 5) возвращает 32-битный signed int
|
||||
hash_val = 5381
|
||||
for char in hash_input:
|
||||
# ctypes.c_int32 эмулирует JS 32-битный signed int при сдвиге
|
||||
shifted = ctypes.c_int32(hash_val << 5).value
|
||||
hash_val = shifted + hash_val + ord(char)
|
||||
|
||||
# В JS: Math.abs(hash).toString(16).padStart(8, '0')
|
||||
return format(abs(hash_val), 'x').zfill(8)
|
||||
|
||||
|
||||
@router.post("/check-ocr-status")
|
||||
async def check_ocr_status(request: Request):
|
||||
"""
|
||||
Проверка статуса OCR обработки документов.
|
||||
|
||||
Вызывается при нажатии "Продолжить" после загрузки документов.
|
||||
|
||||
Логика:
|
||||
1. Проверяем наличие form_draft в payload
|
||||
2. Если черновик есть и documents_hash совпадает — возвращаем его
|
||||
3. Если черновика нет или он устарел — запускаем RAG workflow
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
|
||||
claim_id = body.get("claim_id")
|
||||
session_id = body.get("session_id")
|
||||
force_refresh = body.get("force_refresh", False) # Принудительное обновление
|
||||
|
||||
if not claim_id or not session_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="claim_id и session_id обязательны",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"🔍 Check OCR status request",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"session_id": session_id,
|
||||
"force_refresh": force_refresh,
|
||||
},
|
||||
)
|
||||
|
||||
# =====================================================
|
||||
# ШАГ 1: Проверяем наличие черновика в БД
|
||||
# =====================================================
|
||||
if not force_refresh:
|
||||
try:
|
||||
# Получаем form_draft и список документов
|
||||
claim_data = await db.fetch_one("""
|
||||
SELECT
|
||||
c.payload->'form_draft' AS form_draft,
|
||||
(
|
||||
SELECT array_agg(cd.id::text ORDER BY cd.id)
|
||||
FROM clpr_claim_documents cd
|
||||
WHERE cd.claim_id::uuid = c.id
|
||||
) AS document_ids
|
||||
FROM clpr_claims c
|
||||
WHERE c.id = $1::uuid
|
||||
""", claim_id)
|
||||
|
||||
if claim_data and claim_data.get('form_draft'):
|
||||
form_draft = claim_data['form_draft']
|
||||
# Если form_draft — строка, парсим JSON
|
||||
if isinstance(form_draft, str):
|
||||
form_draft = json.loads(form_draft)
|
||||
|
||||
saved_hash = form_draft.get('documents_hash', '')
|
||||
document_ids = claim_data.get('document_ids') or []
|
||||
current_hash = compute_documents_hash(document_ids)
|
||||
|
||||
logger.info(
|
||||
"📋 Draft check",
|
||||
extra={
|
||||
"saved_hash": saved_hash,
|
||||
"current_hash": current_hash,
|
||||
"docs_count": len(document_ids),
|
||||
},
|
||||
)
|
||||
|
||||
# ✅ Черновик актуален — возвращаем его!
|
||||
if saved_hash == current_hash:
|
||||
logger.info(
|
||||
"✅ Using cached form_draft",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"hash": saved_hash,
|
||||
},
|
||||
)
|
||||
|
||||
# Публикуем событие что данные готовы
|
||||
event_data = {
|
||||
"event_type": "form_draft_ready",
|
||||
"status": "ready",
|
||||
"message": "Черновик формы готов",
|
||||
"claim_id": claim_id,
|
||||
"session_id": session_id,
|
||||
"form_draft": form_draft,
|
||||
"from_cache": True,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
await redis_service.publish(
|
||||
f"ocr_events:{session_id}",
|
||||
json.dumps(event_data, ensure_ascii=False)
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"status": "ready",
|
||||
"message": "Черновик формы готов (из кэша)",
|
||||
"from_cache": True,
|
||||
"form_draft": form_draft,
|
||||
"listen_channel": f"ocr_events:{session_id}",
|
||||
}
|
||||
else:
|
||||
logger.info(
|
||||
"🔄 Draft outdated, running RAG",
|
||||
extra={
|
||||
"reason": "documents_hash mismatch",
|
||||
"saved_hash": saved_hash,
|
||||
"current_hash": current_hash,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Draft check failed: {e}, proceeding with RAG")
|
||||
|
||||
# =====================================================
|
||||
# ШАГ 2: Черновика нет или устарел — запускаем RAG
|
||||
# =====================================================
|
||||
event_data = {
|
||||
"claim_id": claim_id,
|
||||
"session_token": session_id,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
channel = "clpr:check:ocr_status"
|
||||
|
||||
subscribers = await redis_service.publish(
|
||||
channel,
|
||||
json.dumps(event_data, ensure_ascii=False)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ OCR status check published (running RAG)",
|
||||
extra={
|
||||
"channel": channel,
|
||||
"subscribers": subscribers,
|
||||
"claim_id": claim_id,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"status": "processing",
|
||||
"message": "Запрос на обработку документов отправлен",
|
||||
"from_cache": False,
|
||||
"channel": channel,
|
||||
"listen_channel": f"ocr_events:{session_id}",
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("❌ Error checking OCR status")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ошибка проверки статуса: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
router.add_api_route("/skip", skip_document, methods=["POST"], tags=["Documents"])
|
||||
|
||||
@@ -13,7 +13,7 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(prefix="/api/v1", tags=["Events"])
|
||||
|
||||
|
||||
class EventPublish(BaseModel):
|
||||
@@ -215,11 +215,97 @@ async def stream_events(task_id: str):
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error loading wizard data from PostgreSQL: {e}")
|
||||
|
||||
# ✅ Обработка ocr_status ready: загружаем form_draft из PostgreSQL
|
||||
if actual_event.get('event_type') == 'ocr_status' and actual_event.get('status') == 'ready':
|
||||
claim_id = actual_event.get('claim_id') or actual_event.get('data', {}).get('claim_id')
|
||||
# ✅ Получаем cf_2624 из события (Данные подтверждены)
|
||||
cf_2624 = actual_event.get('cf_2624')
|
||||
|
||||
if claim_id:
|
||||
logger.info(f"🔍 OCR ready event received, loading form_draft for claim_id={claim_id}, cf_2624={cf_2624}")
|
||||
|
||||
try:
|
||||
# ✅ Если есть cf_2624 в событии - сохраняем в черновик
|
||||
if cf_2624 is not None:
|
||||
try:
|
||||
update_query = """
|
||||
UPDATE clpr_claims
|
||||
SET payload = jsonb_set(
|
||||
COALESCE(payload, '{}'::jsonb),
|
||||
'{cf_2624}',
|
||||
$1::jsonb
|
||||
)
|
||||
WHERE id::text = $2 OR payload->>'claim_id' = $2
|
||||
RETURNING id;
|
||||
"""
|
||||
await db.execute(update_query, json.dumps(cf_2624), claim_id)
|
||||
logger.info(f"✅ Сохранён cf_2624={cf_2624} в черновик claim_id={claim_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось сохранить cf_2624 в черновик: {e}")
|
||||
|
||||
# Загружаем form_draft и documents из PostgreSQL
|
||||
query = """
|
||||
SELECT
|
||||
c.id,
|
||||
c.payload->'form_draft' as form_draft,
|
||||
c.payload->'documents_required' as documents_required,
|
||||
c.payload->'documents_meta' as documents_meta,
|
||||
c.payload->>'cf_2624' as cf_2624
|
||||
FROM clpr_claims c
|
||||
WHERE c.id::text = $1 OR c.payload->>'claim_id' = $1
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
row = await db.fetch_one(query, claim_id)
|
||||
|
||||
if row:
|
||||
# Парсим JSONB поля (могут быть строками)
|
||||
form_draft_raw = row.get('form_draft')
|
||||
documents_required_raw = row.get('documents_required')
|
||||
documents_meta_raw = row.get('documents_meta')
|
||||
cf_2624_from_db = row.get('cf_2624') # ✅ Получаем cf_2624 из БД
|
||||
|
||||
# Парсим если строка
|
||||
def parse_json_field(val):
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
return json.loads(val)
|
||||
except:
|
||||
return val
|
||||
return val
|
||||
|
||||
form_draft = parse_json_field(form_draft_raw)
|
||||
documents_required = parse_json_field(documents_required_raw)
|
||||
documents_meta = parse_json_field(documents_meta_raw)
|
||||
|
||||
# Обогащаем событие данными из БД
|
||||
actual_event['data'] = {
|
||||
'claim_id': claim_id,
|
||||
'all_ready': True,
|
||||
'form_draft': form_draft,
|
||||
'documents_required': documents_required,
|
||||
'documents_meta': documents_meta,
|
||||
}
|
||||
|
||||
# ✅ Добавляем cf_2624 в событие (из БД или из события)
|
||||
actual_event['cf_2624'] = cf_2624_from_db or cf_2624 or "0"
|
||||
|
||||
logger.info(f"✅ Form draft loaded from PostgreSQL for claim_id={claim_id}, has_form_draft={form_draft is not None}, cf_2624={actual_event.get('cf_2624')}")
|
||||
else:
|
||||
logger.warning(f"⚠️ Claim not found in PostgreSQL: claim_id={claim_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error loading form_draft from PostgreSQL: {e}")
|
||||
|
||||
# Отправляем событие клиенту (плоский формат)
|
||||
event_json = json.dumps(actual_event, ensure_ascii=False)
|
||||
event_json = json.dumps(actual_event, ensure_ascii=False, default=str)
|
||||
event_type_sent = actual_event.get('event_type', 'unknown')
|
||||
event_status = actual_event.get('status', 'unknown')
|
||||
logger.info(f"📤 Sending event to client: type={event_type_sent}, status={event_status}")
|
||||
# Логируем размер и наличие данных
|
||||
data_info = actual_event.get('data', {})
|
||||
has_form_draft = 'form_draft' in data_info if isinstance(data_info, dict) else False
|
||||
logger.info(f"📤 Sending event to client: type={event_type_sent}, status={event_status}, json_len={len(event_json)}, has_form_draft={has_form_draft}")
|
||||
yield f"data: {event_json}\n\n"
|
||||
|
||||
# Если обработка завершена - закрываем соединение
|
||||
@@ -232,6 +318,11 @@ async def stream_events(task_id: str):
|
||||
if event_type_sent in ['claim_ready', 'claim_plan_ready']:
|
||||
logger.info(f"✅ Final event {event_type_sent} sent, closing SSE")
|
||||
break
|
||||
|
||||
# Закрываем для ocr_status ready (форма заявления готова)
|
||||
if event_type_sent == 'ocr_status' and event_status == 'ready':
|
||||
logger.info(f"✅ OCR ready event sent, closing SSE")
|
||||
break
|
||||
else:
|
||||
logger.info(f"⏰ Timeout waiting for message on {channel}")
|
||||
|
||||
|
||||
@@ -44,7 +44,8 @@ class ClaimCreateRequest(BaseModel):
|
||||
|
||||
# Шаг 3: Данные для выплаты
|
||||
payment_method: str = "sbp" # "sbp", "card", "bank_transfer"
|
||||
bank_name: Optional[str] = None
|
||||
bank_id: Optional[str] = None # ID банка из NSPK API (bankid)
|
||||
bank_name: Optional[str] = None # Название банка для отображения
|
||||
card_number: Optional[str] = None
|
||||
account_number: Optional[str] = None
|
||||
|
||||
|
||||
@@ -42,6 +42,15 @@ class Settings(BaseSettings):
|
||||
mysql_user: str = "root"
|
||||
mysql_password: str = ""
|
||||
|
||||
# ============================================
|
||||
# MYSQL CRM (vtiger CRM)
|
||||
# ============================================
|
||||
mysql_crm_host: str = "localhost" # В режиме network_mode: host используем localhost # Доступ к хосту из Docker контейнера
|
||||
mysql_crm_port: int = 3306
|
||||
mysql_crm_db: str = "ci20465_72new"
|
||||
mysql_crm_user: str = "ci20465_72new"
|
||||
mysql_crm_password: str = "EcY979Rn"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""Формирует URL для подключения к PostgreSQL"""
|
||||
|
||||
@@ -11,6 +11,7 @@ from .services.database import db
|
||||
from .services.redis_service import redis_service
|
||||
from .services.rabbitmq_service import rabbitmq_service
|
||||
from .services.policy_service import policy_service
|
||||
from .services.crm_mysql_service import crm_mysql_service
|
||||
from .services.s3_service import s3_service
|
||||
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents
|
||||
|
||||
@@ -56,6 +57,12 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ MySQL Policy DB not available: {e}")
|
||||
|
||||
try:
|
||||
# Подключаем MySQL CRM (vtiger)
|
||||
await crm_mysql_service.connect()
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ MySQL CRM DB not available: {e}")
|
||||
|
||||
try:
|
||||
# Подключаем S3 (для загрузки файлов)
|
||||
s3_service.connect()
|
||||
@@ -73,6 +80,7 @@ async def lifespan(app: FastAPI):
|
||||
await redis_service.disconnect()
|
||||
await rabbitmq_service.disconnect()
|
||||
await policy_service.close()
|
||||
await crm_mysql_service.close()
|
||||
|
||||
logger.info("👋 Ticket Form Intake Platform stopped")
|
||||
|
||||
|
||||
118
backend/app/services/crm_mysql_service.py
Normal file
118
backend/app/services/crm_mysql_service.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
CRM MySQL Service - Подключение к MySQL БД vtiger CRM
|
||||
"""
|
||||
import aiomysql
|
||||
from typing import Optional, Dict, Any, List
|
||||
from ..config import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CrmMySQLService:
|
||||
"""Сервис для работы с MySQL БД vtiger CRM"""
|
||||
|
||||
def __init__(self):
|
||||
self.pool: Optional[aiomysql.Pool] = None
|
||||
|
||||
async def connect(self):
|
||||
"""Подключение к MySQL БД vtiger CRM"""
|
||||
try:
|
||||
self.pool = await aiomysql.create_pool(
|
||||
host=settings.mysql_crm_host,
|
||||
port=settings.mysql_crm_port,
|
||||
user=settings.mysql_crm_user,
|
||||
password=settings.mysql_crm_password,
|
||||
db=settings.mysql_crm_db,
|
||||
autocommit=True,
|
||||
minsize=1,
|
||||
maxsize=5
|
||||
)
|
||||
logger.info(f"✅ MySQL CRM DB connected: {settings.mysql_crm_host}:{settings.mysql_crm_port}/{settings.mysql_crm_db}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ MySQL CRM DB connection error: {e}")
|
||||
raise
|
||||
|
||||
async def fetch_one(self, query: str, *args) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Выполнить SQL запрос и вернуть одну запись
|
||||
|
||||
Args:
|
||||
query: SQL запрос с плейсхолдерами %s
|
||||
*args: Параметры для запроса
|
||||
|
||||
Returns:
|
||||
Dict с данными или None если не найдено
|
||||
"""
|
||||
if not self.pool:
|
||||
await self.connect()
|
||||
|
||||
try:
|
||||
async with self.pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(query, args)
|
||||
result = await cursor.fetchone()
|
||||
return dict(result) if result else None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error executing query: {e}")
|
||||
raise
|
||||
|
||||
async def fetch_all(self, query: str, *args) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Выполнить SQL запрос и вернуть все записи
|
||||
|
||||
Args:
|
||||
query: SQL запрос с плейсхолдерами %s
|
||||
*args: Параметры для запроса
|
||||
|
||||
Returns:
|
||||
List[Dict] с данными
|
||||
"""
|
||||
if not self.pool:
|
||||
await self.connect()
|
||||
|
||||
try:
|
||||
async with self.pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(query, args)
|
||||
results = await cursor.fetchall()
|
||||
return [dict(row) for row in results] if results else []
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error executing query: {e}")
|
||||
raise
|
||||
|
||||
async def execute(self, query: str, *args) -> int:
|
||||
"""
|
||||
Выполнить SQL запрос (INSERT, UPDATE, DELETE)
|
||||
|
||||
Args:
|
||||
query: SQL запрос с плейсхолдерами %s
|
||||
*args: Параметры для запроса
|
||||
|
||||
Returns:
|
||||
Количество затронутых строк
|
||||
"""
|
||||
if not self.pool:
|
||||
await self.connect()
|
||||
|
||||
try:
|
||||
async with self.pool.acquire() as conn:
|
||||
async with conn.cursor() as cursor:
|
||||
await cursor.execute(query, args)
|
||||
return cursor.rowcount
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error executing query: {e}")
|
||||
raise
|
||||
|
||||
async def close(self):
|
||||
"""Закрыть пул подключений"""
|
||||
if self.pool:
|
||||
self.pool.close()
|
||||
await self.pool.wait_closed()
|
||||
logger.info("MySQL CRM DB pool closed")
|
||||
|
||||
|
||||
# Глобальный экземпляр
|
||||
crm_mysql_service = CrmMySQLService()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user