""" Documents API Routes - Загрузка и обработка документов Новый флоу: поэкранная загрузка документов """ from fastapi import APIRouter, HTTPException, UploadFile, File, Form, Request 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( 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), 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. """ try: # Генерируем уникальный ID файла file_id = f"doc_{uuid.uuid4().hex[:12]}" logger.info( "📤 Document upload 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, }, ) # Читаем содержимое файла 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 # Формируем данные в формате совместимом с существующим n8n воркфлоу 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, "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(), # Формат uploads_* для совместимости "uploads_field_names[0]": document_type, "uploads_field_labels[0]": document_name or document_type, "uploads_descriptions[0]": document_description or "", } # Файл для multipart (ключ uploads[0] для совместимости) files = { "uploads[0]": (file.filename, file_content, file.content_type or "application/octet-stream") } # Отправляем в n8n async with httpx.AsyncClient(timeout=120.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", extra={ "claim_id": claim_id, "document_type": document_type, "file_id": file_id, "response_preview": response_text[:200], }, ) # Парсим ответ от n8n try: n8n_response = json.loads(response_text) except json.JSONDecodeError: n8n_response = {"raw": response_text} # Публикуем событие в Redis для фронтенда event_data = { "event_type": "document_uploaded", "status": "processing", "claim_id": claim_id, "session_id": session_id, "document_type": document_type, "file_id": file_id, "original_filename": file.filename, "timestamp": datetime.utcnow().isoformat(), } await redis_service.publish( f"ocr_events:{session_id}", json.dumps(event_data, ensure_ascii=False) ) return { "success": True, "file_id": file_id, "document_type": document_type, "ocr_status": "processing", "message": "Документ загружен и отправлен на обработку", "n8n_response": n8n_response, } else: logger.error( "❌ n8n document 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 document upload 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)}", ) @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): """ Запрос на генерацию списка документов для проблемы. Принимает описание проблемы, отправляет в 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( 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), 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. """ try: # Генерируем уникальный ID файла file_id = f"doc_{uuid.uuid4().hex[:12]}" logger.info( "📤 Document upload 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, }, ) # Читаем содержимое файла 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 # Формируем данные в формате совместимом с существующим n8n воркфлоу 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, "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(), # Формат uploads_* для совместимости "uploads_field_names[0]": document_type, "uploads_field_labels[0]": document_name or document_type, "uploads_descriptions[0]": document_description or "", } # Файл для multipart (ключ uploads[0] для совместимости) files = { "uploads[0]": (file.filename, file_content, file.content_type or "application/octet-stream") } # Отправляем в n8n async with httpx.AsyncClient(timeout=120.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", extra={ "claim_id": claim_id, "document_type": document_type, "file_id": file_id, "response_preview": response_text[:200], }, ) # Парсим ответ от n8n try: n8n_response = json.loads(response_text) except json.JSONDecodeError: n8n_response = {"raw": response_text} # Публикуем событие в Redis для фронтенда event_data = { "event_type": "document_uploaded", "status": "processing", "claim_id": claim_id, "session_id": session_id, "document_type": document_type, "file_id": file_id, "original_filename": file.filename, "timestamp": datetime.utcnow().isoformat(), } await redis_service.publish( f"ocr_events:{session_id}", json.dumps(event_data, ensure_ascii=False) ) return { "success": True, "file_id": file_id, "document_type": document_type, "ocr_status": "processing", "message": "Документ загружен и отправлен на обработку", "n8n_response": n8n_response, } else: logger.error( "❌ n8n document 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 document upload 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)}", ) @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): """ Запрос на генерацию списка документов для проблемы. Принимает описание проблемы, отправляет в 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)}", )