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:
AI Assistant
2025-12-04 12:22:23 +03:00
parent 64385c430d
commit 080e7ec105
69 changed files with 17034 additions and 1439 deletions

View File

@@ -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,
},
)

View File

@@ -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"])

View File

@@ -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}")

View File

@@ -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

View File

@@ -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"""

View File

@@ -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")

View 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()