feat: Интеграция n8n + Redis Pub/Sub + SSE для real-time обработки заявок
🎯 Основные изменения: Backend: - ✅ Добавлен SSE endpoint для real-time событий (/api/v1/events/{task_id}) - ✅ Redis Pub/Sub для публикации/подписки на события OCR/Vision - ✅ Удален aioboto3 из requirements.txt (конфликт зависимостей) - ✅ Добавлен OCR worker (deprecated, логика перенесена в n8n) Frontend (React): - ✅ Автогенерация claim_id и session_id - ✅ Клиентская конвертация файлов в PDF (JPG/PNG/HEIC/WEBP) - ✅ Сжатие изображений до 2MB перед конвертацией - ✅ SSE подписка на события OCR/Vision в Step1Policy - ✅ Валидация документов (полис vs неподходящий контент) - ✅ Real-time прогресс загрузки и обработки файлов - ✅ Интеграция с n8n webhooks для проверки полиса и загрузки файлов n8n Workflows: - ✅ Проверка полиса в MySQL + запись в PostgreSQL - ✅ Загрузка файлов в S3 + OCR + Vision AI - ✅ Публикация событий в Redis через backend API - ✅ Валидация документов (распознавание полисов ERV) Документация: - 📝 N8N_INTEGRATION.md - интеграция с n8n - 📝 N8N_SQL_QUERIES.md - SQL запросы для workflows - 📝 N8N_PDF_COMPRESS.md - сжатие PDF - 📝 N8N_STIRLING_COMPRESS.md - интеграция Stirling-PDF Утилиты: - 🔧 monitor_redis.py/sh - мониторинг Redis Pub/Sub - 🔧 test_redis_events.sh - тестирование событий - 🔧 pdfConverter.ts - клиентская конвертация в PDF Архитектура: React → n8n webhooks (sync) → MySQL/PostgreSQL/S3 → n8n workflows (async) → OCR/Vision → Redis Pub/Sub → SSE → React
This commit is contained in:
292
N8N_INTEGRATION.md
Normal file
292
N8N_INTEGRATION.md
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
# 🔌 Интеграция n8n с React Frontend
|
||||||
|
|
||||||
|
## 📡 Redis Pub/Sub для real-time событий
|
||||||
|
|
||||||
|
### Публикация события из n8n (HTTP Request Node)
|
||||||
|
|
||||||
|
**POST** `http://147.45.146.17:8100/api/v1/events/{task_id}`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "processing|ocr_started|ocr_completed|ai_started|completed|error",
|
||||||
|
"message": "Описание для пользователя",
|
||||||
|
"data": {
|
||||||
|
"chars": 1500,
|
||||||
|
"confidence": 0.95,
|
||||||
|
"document_type": "policy",
|
||||||
|
"extracted_data": {...}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Примеры:**
|
||||||
|
|
||||||
|
1. **Начало обработки:**
|
||||||
|
```json
|
||||||
|
POST /api/v1/events/abc-123-def
|
||||||
|
{
|
||||||
|
"status": "processing",
|
||||||
|
"message": "Начата обработка файла",
|
||||||
|
"data": {
|
||||||
|
"filename": "Policy_123.pdf"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **OCR завершён:**
|
||||||
|
```json
|
||||||
|
POST /api/v1/events/abc-123-def
|
||||||
|
{
|
||||||
|
"status": "ocr_completed",
|
||||||
|
"message": "Распознано 1500 символов",
|
||||||
|
"data": {
|
||||||
|
"chars": 1500,
|
||||||
|
"ocr_text_preview": "ЕВРОИНС ПОЛИС E1000-..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **AI анализ:**
|
||||||
|
```json
|
||||||
|
POST /api/v1/events/abc-123-def
|
||||||
|
{
|
||||||
|
"status": "ai_started",
|
||||||
|
"message": "Запущен AI анализ документа",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Завершено:**
|
||||||
|
```json
|
||||||
|
POST /api/v1/events/abc-123-def
|
||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"message": "Обработка завершена",
|
||||||
|
"data": {
|
||||||
|
"document_type": "policy",
|
||||||
|
"is_valid": true,
|
||||||
|
"confidence": 0.95,
|
||||||
|
"extracted_data": {
|
||||||
|
"voucher": "E1000-302545808",
|
||||||
|
"holder_name": "ROMANOVA ANASTASIIA",
|
||||||
|
"insured_from": "22.09.2025",
|
||||||
|
"insured_to": "30.09.2025"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Ошибка:**
|
||||||
|
```json
|
||||||
|
POST /api/v1/events/abc-123-def
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Ошибка обработки: файл повреждён",
|
||||||
|
"data": {
|
||||||
|
"error_code": "OCR_FAILED"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Вебхуки для n8n
|
||||||
|
|
||||||
|
### 1. Проверка полиса (MySQL)
|
||||||
|
|
||||||
|
**POST** `/webhook/check-policy`
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"policy_number": "E1000-302545808",
|
||||||
|
"inn": "123456789012"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"found": true,
|
||||||
|
"policy": {
|
||||||
|
"voucher": "E1000-302545808",
|
||||||
|
"holder_name": "ROMANOVA ANASTASIIA",
|
||||||
|
"status": "active"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Загрузка файла в S3
|
||||||
|
|
||||||
|
**POST** `/webhook/upload-file`
|
||||||
|
|
||||||
|
**Request (multipart/form-data):**
|
||||||
|
- `file`: File
|
||||||
|
- `folder`: "policies" | "documents" | "tickets"
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"task_id": "abc-123-def",
|
||||||
|
"s3_url": "https://s3.twcstorage.ru/bucket/policies/file.pdf",
|
||||||
|
"message": "Файл загружен, обработка началась"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**n8n Flow:**
|
||||||
|
1. Загрузить в S3
|
||||||
|
2. Сгенерировать `task_id` (UUID)
|
||||||
|
3. Положить задачу в RabbitMQ (`erv_ocr_processing`)
|
||||||
|
4. Вернуть `task_id`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. OCR Worker (RabbitMQ Trigger)
|
||||||
|
|
||||||
|
**n8n Workflow:**
|
||||||
|
|
||||||
|
```
|
||||||
|
RabbitMQ Trigger (erv_ocr_processing)
|
||||||
|
↓
|
||||||
|
Скачать файл из S3
|
||||||
|
↓
|
||||||
|
POST /api/v1/events/{task_id}
|
||||||
|
status: "processing"
|
||||||
|
↓
|
||||||
|
HTTP Request → OCR API
|
||||||
|
POST http://147.45.146.17:8001/analyze-file
|
||||||
|
↓
|
||||||
|
POST /api/v1/events/{task_id}
|
||||||
|
status: "ocr_completed"
|
||||||
|
data: {chars: ..., ocr_text: "..."}
|
||||||
|
↓
|
||||||
|
HTTP Request → Gemini Vision (OpenRouter)
|
||||||
|
↓
|
||||||
|
POST /api/v1/events/{task_id}
|
||||||
|
status: "completed"
|
||||||
|
data: {document_type, is_valid, extracted_data}
|
||||||
|
↓
|
||||||
|
Сохранить результат в Redis
|
||||||
|
key: "ocr_result:{task_id}"
|
||||||
|
ttl: 3600
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Получение результата OCR
|
||||||
|
|
||||||
|
**GET** `/webhook/ocr-result/{task_id}`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"result": {
|
||||||
|
"document_type": "policy",
|
||||||
|
"is_valid": true,
|
||||||
|
"confidence": 0.95,
|
||||||
|
"ocr_text": "...",
|
||||||
|
"extracted_data": {...}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**n8n:** Читает из Redis `ocr_result:{task_id}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Создание заявки (финал)
|
||||||
|
|
||||||
|
**POST** `/webhook/create-claim`
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"voucher": "E1000-302545808",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"phone": "+79001234567",
|
||||||
|
"incident": {
|
||||||
|
"type": "flight_delay",
|
||||||
|
"date": "2025-10-25",
|
||||||
|
"flight_number": "SU123",
|
||||||
|
"description": "Задержка более 3 часов"
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"method": "sbp",
|
||||||
|
"bank": "sberbank"
|
||||||
|
},
|
||||||
|
"documents": [
|
||||||
|
"https://s3.../ticket1.pdf",
|
||||||
|
"https://s3.../boarding_pass.pdf"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"claim_id": "CLM-2025-001",
|
||||||
|
"crm_id": "12345",
|
||||||
|
"message": "Заявка успешно создана"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**n8n Flow:**
|
||||||
|
1. Проверить все данные
|
||||||
|
2. Создать запись в PostgreSQL
|
||||||
|
3. Отправить в Vtiger CRM
|
||||||
|
4. Отправить email подтверждение
|
||||||
|
5. Вернуть claim_id
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Draft (автосохранение)
|
||||||
|
|
||||||
|
**POST** `/webhook/draft/save`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "sess-abc-123",
|
||||||
|
"step": 1,
|
||||||
|
"form_data": {...}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**GET** `/webhook/draft/stats`
|
||||||
|
|
||||||
|
Возвращает статистику: сколько людей бросили на каждом шаге.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Redis Connection
|
||||||
|
|
||||||
|
**Host:** `crm.clientright.ru`
|
||||||
|
**Port:** `6379`
|
||||||
|
**Password:** `CRM_Redis_Pass_2025_Secure!`
|
||||||
|
**DB:** `0`
|
||||||
|
|
||||||
|
**Channels:**
|
||||||
|
- `ocr_events:{task_id}` - события обработки
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Примечания
|
||||||
|
|
||||||
|
1. **task_id** - генерируется как UUID в n8n
|
||||||
|
2. **Redis TTL** - результаты хранятся 1 час
|
||||||
|
3. **RabbitMQ** - `185.197.75.249:5672` (admin/tyejvtej)
|
||||||
|
4. **S3** - TWC Storage, креды в .env
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Готово для n8n! 🚀**
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
256
N8N_PDF_COMPRESS.md
Normal file
256
N8N_PDF_COMPRESS.md
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
# 🗜️ PDF Compression в n8n
|
||||||
|
|
||||||
|
## 📋 Проблема
|
||||||
|
Пользователь загружает PDF 5-10 MB → долгая обработка OCR
|
||||||
|
|
||||||
|
## ✅ Решение: 2-уровневая система
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Уровень 1: Frontend (React)
|
||||||
|
|
||||||
|
**Что делаем:**
|
||||||
|
- JPG/PNG → сжатие до 2MB → конвертация в PDF
|
||||||
|
- PDF < 5MB → пропускаем
|
||||||
|
- PDF > 10MB → **отклоняем** с сообщением
|
||||||
|
|
||||||
|
**Код:** `frontend/src/utils/pdfConverter.ts` ✅ УЖЕ ГОТОВО
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Уровень 2: Backend (n8n)
|
||||||
|
|
||||||
|
### Workflow для сжатия PDF > 5MB
|
||||||
|
|
||||||
|
```
|
||||||
|
Webhook (file upload)
|
||||||
|
↓
|
||||||
|
IF Node: file_size > 5 MB?
|
||||||
|
├─ FALSE → S3 Upload (оригинал)
|
||||||
|
└─ TRUE → Python Code Node (compress)
|
||||||
|
↓
|
||||||
|
S3 Upload (compressed)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐍 Python Code Node - PDF Compression
|
||||||
|
|
||||||
|
### Установка библиотеки в n8n
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# В контейнере n8n
|
||||||
|
docker exec -it <n8n_container_name> sh
|
||||||
|
apk add --no-cache python3 py3-pip
|
||||||
|
pip3 install pypdf
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Node конфигурация
|
||||||
|
|
||||||
|
**Language:** Python
|
||||||
|
**Mode:** Run Once for All Items
|
||||||
|
|
||||||
|
**Code:**
|
||||||
|
```python
|
||||||
|
import io
|
||||||
|
from pypdf import PdfReader, PdfWriter
|
||||||
|
|
||||||
|
# Получаем binary data из предыдущей ноды
|
||||||
|
input_data = items[0].binary['data']
|
||||||
|
pdf_bytes = input_data
|
||||||
|
|
||||||
|
# Читаем PDF
|
||||||
|
reader = PdfReader(io.BytesIO(pdf_bytes))
|
||||||
|
writer = PdfWriter()
|
||||||
|
|
||||||
|
# Копируем страницы с оптимизацией
|
||||||
|
for page in reader.pages:
|
||||||
|
# Удаляем неиспользуемые объекты
|
||||||
|
page.compress_content_streams()
|
||||||
|
writer.add_page(page)
|
||||||
|
|
||||||
|
# Применяем сжатие
|
||||||
|
writer.compress_identical_objects()
|
||||||
|
writer.remove_duplication()
|
||||||
|
|
||||||
|
# Сжимаем изображения (если есть)
|
||||||
|
for page in writer.pages:
|
||||||
|
for img in page.images:
|
||||||
|
img.replace(img.image, quality=70)
|
||||||
|
|
||||||
|
# Выводим в bytes
|
||||||
|
output = io.BytesIO()
|
||||||
|
writer.write(output)
|
||||||
|
compressed_bytes = output.getvalue()
|
||||||
|
|
||||||
|
# Логируем результат
|
||||||
|
original_size = len(pdf_bytes) / (1024 * 1024)
|
||||||
|
compressed_size = len(compressed_bytes) / (1024 * 1024)
|
||||||
|
compression_ratio = ((original_size - compressed_size) / original_size) * 100
|
||||||
|
|
||||||
|
print(f"✅ Compressed: {original_size:.2f}MB → {compressed_size:.2f}MB ({compression_ratio:.1f}% reduction)")
|
||||||
|
|
||||||
|
# Возвращаем binary data
|
||||||
|
return {
|
||||||
|
'binary': {
|
||||||
|
'data': compressed_bytes
|
||||||
|
},
|
||||||
|
'json': {
|
||||||
|
'original_size_mb': round(original_size, 2),
|
||||||
|
'compressed_size_mb': round(compressed_size, 2),
|
||||||
|
'compression_ratio': round(compression_ratio, 1),
|
||||||
|
'success': True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Вариант 2: Execute Command (Ghostscript)
|
||||||
|
|
||||||
|
**Требует:** `ghostscript` установлен в системе
|
||||||
|
|
||||||
|
### Execute Command Node:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
INPUT="/tmp/input_{{ $json.file_id }}.pdf"
|
||||||
|
OUTPUT="/tmp/output_{{ $json.file_id }}.pdf"
|
||||||
|
|
||||||
|
# Сохраняем binary в файл
|
||||||
|
echo "{{ $binary.data }}" | base64 -d > "$INPUT"
|
||||||
|
|
||||||
|
# Сжимаем через Ghostscript
|
||||||
|
gs -sDEVICE=pdfwrite \
|
||||||
|
-dCompatibilityLevel=1.4 \
|
||||||
|
-dPDFSETTINGS=/ebook \
|
||||||
|
-dNOPAUSE \
|
||||||
|
-dQUIET \
|
||||||
|
-dBATCH \
|
||||||
|
-sOutputFile="$OUTPUT" \
|
||||||
|
"$INPUT"
|
||||||
|
|
||||||
|
# Выводим compressed PDF
|
||||||
|
cat "$OUTPUT" | base64
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
rm -f "$INPUT" "$OUTPUT"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры `-dPDFSETTINGS`:**
|
||||||
|
- `/screen` - 72 DPI (минимальное качество, максимальное сжатие)
|
||||||
|
- `/ebook` - 150 DPI ⭐ **рекомендуется**
|
||||||
|
- `/printer` - 300 DPI
|
||||||
|
- `/prepress` - 300 DPI (максимальное качество)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Полный Workflow
|
||||||
|
|
||||||
|
### 1. Webhook (File Upload)
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"claim_id": "CLM-2025-10-26-ABC123",
|
||||||
|
"file_type": "policy_scan",
|
||||||
|
"filename": "policy.pdf",
|
||||||
|
"voucher": "E1000-302372730",
|
||||||
|
"session_id": "sess-xyz-456"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Binary Data:** `data` (PDF file)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. IF Node: Check File Size
|
||||||
|
|
||||||
|
**Condition:**
|
||||||
|
```
|
||||||
|
{{ $binary.data.length }} > 5242880
|
||||||
|
```
|
||||||
|
(5MB = 5 * 1024 * 1024 bytes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3a. FALSE → Direct Upload
|
||||||
|
|
||||||
|
**S3 Upload Node** → PostgreSQL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3b. TRUE → Compress First
|
||||||
|
|
||||||
|
```
|
||||||
|
Python Code (compress)
|
||||||
|
↓
|
||||||
|
Set Binary Data
|
||||||
|
↓
|
||||||
|
S3 Upload (compressed)
|
||||||
|
↓
|
||||||
|
PostgreSQL (update file_size)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Результаты сжатия
|
||||||
|
|
||||||
|
| Метод | Скорость | Сжатие | Качество |
|
||||||
|
|-------|----------|--------|----------|
|
||||||
|
| **pypdf** | Быстро | 30-50% | Хорошее ⭐ |
|
||||||
|
| **Ghostscript /ebook** | Средне | 50-70% | Среднее |
|
||||||
|
| **Ghostscript /screen** | Средне | 70-85% | Низкое |
|
||||||
|
| **Frontend (jspdf)** | Моментально | 60-80% | Хорошее ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Итоговая стратегия
|
||||||
|
|
||||||
|
```
|
||||||
|
📱 Пользователь загружает файл
|
||||||
|
↓
|
||||||
|
🔍 Frontend проверка:
|
||||||
|
├─ JPG/PNG → compress + convert → PDF (✅ готово)
|
||||||
|
├─ PDF < 5MB → отправить как есть
|
||||||
|
├─ PDF 5-10MB → отправить (n8n сожмёт)
|
||||||
|
└─ PDF > 10MB → ❌ отклонить
|
||||||
|
|
||||||
|
🚀 n8n workflow:
|
||||||
|
├─ file_size < 5MB → S3 + OCR
|
||||||
|
└─ file_size > 5MB → Python compress → S3 + OCR
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Тестирование
|
||||||
|
|
||||||
|
### curl пример:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Создаём большой PDF для теста
|
||||||
|
curl -o large.pdf https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf
|
||||||
|
|
||||||
|
# Отправляем в n8n
|
||||||
|
curl -X POST \
|
||||||
|
-F "claim_id=CLM-TEST-001" \
|
||||||
|
-F "file_type=policy_scan" \
|
||||||
|
-F "fileInput=@large.pdf" \
|
||||||
|
-F "voucher=TEST-123" \
|
||||||
|
-F "session_id=sess-test" \
|
||||||
|
https://n8n.clientright.pro/webhook/7e2abc64-eaca-4671-86e4-12786700fe95
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Готово!
|
||||||
|
|
||||||
|
**Frontend:** ✅ Ограничение 10MB + предупреждение
|
||||||
|
**n8n:** ⏳ Нужно добавить Python Code Node
|
||||||
|
|
||||||
|
**Следующий шаг:** Добавить Python Code Node в workflow для файлов > 5MB
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
434
N8N_SQL_QUERIES.md
Normal file
434
N8N_SQL_QUERIES.md
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
# 📝 SQL запросы для n8n вебхуков
|
||||||
|
|
||||||
|
## PostgreSQL Connection:
|
||||||
|
- **Host:** `147.45.189.234`
|
||||||
|
- **Port:** `5432`
|
||||||
|
- **Database:** `default_db`
|
||||||
|
- **User:** `gen_user`
|
||||||
|
- **Password:** `2~~9_^kVsU?2\S`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1️⃣ Создание заявки (при генерации claim_id)
|
||||||
|
|
||||||
|
**Вебхук:** `POST /webhook/create-claim`
|
||||||
|
|
||||||
|
**Input:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"claim_id": "CLM-2025-10-25-A3F7G2",
|
||||||
|
"voucher": "E1000-302372730",
|
||||||
|
"client_phone": "",
|
||||||
|
"client_email": "",
|
||||||
|
"session_id": "sess-abc-123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**SQL (PostgreSQL Node):**
|
||||||
|
```sql
|
||||||
|
INSERT INTO claims (
|
||||||
|
claim_number,
|
||||||
|
policy_number,
|
||||||
|
client_phone,
|
||||||
|
client_email,
|
||||||
|
status,
|
||||||
|
insurance_type,
|
||||||
|
source,
|
||||||
|
form_data,
|
||||||
|
created_at
|
||||||
|
) VALUES (
|
||||||
|
'{{ $json.body.claim_id }}',
|
||||||
|
'{{ $json.body.voucher }}',
|
||||||
|
'{{ $json.body.client_phone || "" }}',
|
||||||
|
'{{ $json.body.client_email || "" }}',
|
||||||
|
'draft',
|
||||||
|
'erv_travel',
|
||||||
|
'web_form',
|
||||||
|
'{{ JSON.stringify($json.body) }}',
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
ON CONFLICT (claim_number) DO UPDATE SET
|
||||||
|
updated_at = NOW(),
|
||||||
|
form_data = EXCLUDED.form_data
|
||||||
|
RETURNING id, claim_number, created_at;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"claim_id": "CLM-2025-10-25-A3F7G2",
|
||||||
|
"db_id": "uuid-from-db",
|
||||||
|
"created_at": "2025-10-25T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2️⃣ Сохранение файла в claim_files
|
||||||
|
|
||||||
|
**После S3 Upload в том же workflow!**
|
||||||
|
|
||||||
|
**SQL (PostgreSQL Node после S3):**
|
||||||
|
```sql
|
||||||
|
INSERT INTO claim_files (
|
||||||
|
claim_id,
|
||||||
|
file_name,
|
||||||
|
file_path,
|
||||||
|
file_size,
|
||||||
|
mime_type,
|
||||||
|
file_type,
|
||||||
|
s3_bucket,
|
||||||
|
s3_key,
|
||||||
|
s3_url,
|
||||||
|
ocr_status,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
'{{ $json.file.original_name }}',
|
||||||
|
'{{ $json.s3.key }}',
|
||||||
|
{{ $json.file.size || 0 }},
|
||||||
|
'{{ $json.file.mime_type }}',
|
||||||
|
'{{ $json.claim.file_type }}',
|
||||||
|
'f9825c87-4e3558f6-f9b6-405c-ad3d-d1535c49b61c',
|
||||||
|
'{{ $json.s3.key }}',
|
||||||
|
'{{ $('Upload a file1').item.json.Location }}',
|
||||||
|
'pending',
|
||||||
|
NOW()
|
||||||
|
FROM claims c
|
||||||
|
WHERE c.claim_number = '{{ $json.claim.claim_id }}'
|
||||||
|
RETURNING id as file_id, s3_url, ocr_status;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (добавь в Respond):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"claim_id": "CLM-2025-10-25-A3F7G2",
|
||||||
|
"file": {
|
||||||
|
"file_id": "uuid-from-db",
|
||||||
|
"type": "policy_scan",
|
||||||
|
"url": "https://s3.../policy_scan.pdf",
|
||||||
|
"s3_key": "files/erv/ticket/CLM-xxx/policy_scan.pdf",
|
||||||
|
"ocr_status": "pending"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3️⃣ Обновление OCR результата
|
||||||
|
|
||||||
|
**OCR Workflow (после обработки):**
|
||||||
|
|
||||||
|
**SQL:**
|
||||||
|
```sql
|
||||||
|
UPDATE claim_files
|
||||||
|
SET
|
||||||
|
ocr_status = 'completed',
|
||||||
|
ocr_text = '{{ $json.ocr_text }}',
|
||||||
|
processed_at = NOW()
|
||||||
|
WHERE id = '{{ $json.file_id }}'
|
||||||
|
RETURNING id, ocr_status;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4️⃣ Обновление Vision AI результата
|
||||||
|
|
||||||
|
**SQL:**
|
||||||
|
```sql
|
||||||
|
UPDATE claim_files
|
||||||
|
SET
|
||||||
|
ai_extracted_data = '{{ JSON.stringify($json.ai_analysis) }}',
|
||||||
|
processed_at = NOW()
|
||||||
|
WHERE id = '{{ $json.file_id }}'
|
||||||
|
RETURNING id, ai_extracted_data;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Пример ai_extracted_data:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"document_type": "policy",
|
||||||
|
"is_valid": true,
|
||||||
|
"confidence": 0.95,
|
||||||
|
"voucher": "E1000-302372730",
|
||||||
|
"holder_name": "IVANOV IVAN",
|
||||||
|
"insured_from": "01.11.2025",
|
||||||
|
"insured_to": "30.11.2025"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5️⃣ Получить все файлы заявки
|
||||||
|
|
||||||
|
**Вебхук:** `GET /webhook/get-claim-files/{claim_id}`
|
||||||
|
|
||||||
|
**SQL:**
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
cf.id,
|
||||||
|
cf.file_name,
|
||||||
|
cf.file_type,
|
||||||
|
cf.s3_url,
|
||||||
|
cf.file_size,
|
||||||
|
cf.ocr_status,
|
||||||
|
cf.ocr_text,
|
||||||
|
cf.ai_extracted_data,
|
||||||
|
cf.created_at,
|
||||||
|
cf.processed_at
|
||||||
|
FROM claim_files cf
|
||||||
|
JOIN claims c ON c.id = cf.claim_id
|
||||||
|
WHERE c.claim_number = '{{ $parameter.claim_id }}'
|
||||||
|
ORDER BY cf.created_at;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"claim_id": "CLM-2025-10-25-A3F7G2",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"file_id": "...",
|
||||||
|
"file_type": "policy_scan",
|
||||||
|
"s3_url": "...",
|
||||||
|
"ocr_status": "completed",
|
||||||
|
"ocr_text": "ЕВРОИНС...",
|
||||||
|
"ai_extracted_data": {...}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6️⃣ Финальная отправка заявки
|
||||||
|
|
||||||
|
**SQL (обновляем статус):**
|
||||||
|
```sql
|
||||||
|
UPDATE claims
|
||||||
|
SET
|
||||||
|
status = 'submitted',
|
||||||
|
client_phone = '{{ $json.phone }}',
|
||||||
|
client_email = '{{ $json.email }}',
|
||||||
|
form_data = '{{ JSON.stringify($json.form_data) }}',
|
||||||
|
submitted_at = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE claim_number = '{{ $json.claim_id }}'
|
||||||
|
RETURNING id, claim_number, status, submitted_at;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7️⃣ Публикация результатов OCR/Vision в Redis
|
||||||
|
|
||||||
|
**После OCR/Vision обработки - отправляем результат в React через Redis Pub/Sub**
|
||||||
|
|
||||||
|
### Webhook для публикации:
|
||||||
|
|
||||||
|
**POST** `http://147.45.189.234:8000/events/{claim_id}`
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body (n8n Code Node):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_type": "ocr_completed",
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"file_id": "{{ $json.file_id }}",
|
||||||
|
"file_type": "policy_scan",
|
||||||
|
"is_valid_document": true,
|
||||||
|
"document_type": "ERV Travel Insurance Policy",
|
||||||
|
"ocr_text": "E1000-302372730",
|
||||||
|
"confidence": 0.95,
|
||||||
|
"ai_analysis": {
|
||||||
|
"is_policy": true,
|
||||||
|
"contains_policy_number": true,
|
||||||
|
"is_nsfw": false,
|
||||||
|
"warnings": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"message": "✅ Распознан полис страхования ERV",
|
||||||
|
"timestamp": "{{ new Date().toISOString() }}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Code Node для валидации документа:
|
||||||
|
|
||||||
|
**После OCR + Vision:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Получаем результаты OCR и Vision
|
||||||
|
const ocrData = $json.ocr_result; // Из предыдущей ноды
|
||||||
|
const visionData = $json.vision_result;
|
||||||
|
|
||||||
|
// Валидация документа
|
||||||
|
const validation = {
|
||||||
|
is_valid_document: false,
|
||||||
|
document_type: 'unknown',
|
||||||
|
confidence: 0,
|
||||||
|
warnings: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Проверка на NSFW
|
||||||
|
if (visionData.nsfw === true || visionData.nsfw_score > 0.7) {
|
||||||
|
validation.warnings.push('Неподходящее содержимое изображения');
|
||||||
|
validation.is_valid_document = false;
|
||||||
|
validation.document_type = 'inappropriate_content';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Проверка текста OCR на наличие номера полиса
|
||||||
|
const policyNumberRegex = /[A-Z]\d{4}-\d{9}/;
|
||||||
|
const hasPolicyNumber = policyNumberRegex.test(ocrData.ocr_text);
|
||||||
|
|
||||||
|
if (hasPolicyNumber) {
|
||||||
|
validation.is_valid_document = true;
|
||||||
|
validation.document_type = 'ERV Travel Insurance Policy';
|
||||||
|
validation.confidence = 0.9;
|
||||||
|
} else {
|
||||||
|
validation.warnings.push('Номер полиса не найден');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Анализ Vision описания
|
||||||
|
const visionText = visionData.content?.toLowerCase() || '';
|
||||||
|
const insuranceKeywords = ['страхов', 'insurance', 'полис', 'policy', 'erv'];
|
||||||
|
const hasInsuranceKeywords = insuranceKeywords.some(kw => visionText.includes(kw));
|
||||||
|
|
||||||
|
if (hasInsuranceKeywords) {
|
||||||
|
validation.confidence += 0.05;
|
||||||
|
} else {
|
||||||
|
validation.warnings.push('Документ не похож на страховой полис');
|
||||||
|
validation.is_valid_document = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Формируем результат для публикации в Redis
|
||||||
|
const result = {
|
||||||
|
file_id: $json.file_id,
|
||||||
|
claim_id: $json.claim_id,
|
||||||
|
event_type: 'ocr_completed',
|
||||||
|
status: validation.is_valid_document ? 'success' : 'error',
|
||||||
|
data: {
|
||||||
|
file_id: $json.file_id,
|
||||||
|
file_type: $json.file_type,
|
||||||
|
is_valid_document: validation.is_valid_document,
|
||||||
|
document_type: validation.document_type,
|
||||||
|
ocr_text: ocrData.ocr_text,
|
||||||
|
confidence: validation.confidence,
|
||||||
|
ai_analysis: {
|
||||||
|
is_policy: validation.is_valid_document,
|
||||||
|
contains_policy_number: hasPolicyNumber,
|
||||||
|
is_nsfw: visionData.nsfw,
|
||||||
|
nsfw_score: visionData.nsfw_score,
|
||||||
|
warnings: validation.warnings
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: validation.is_valid_document
|
||||||
|
? '✅ Распознан полис страхования ERV'
|
||||||
|
: `❌ ${validation.warnings.join(', ')}`,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### HTTP Request Node (публикация в Redis):
|
||||||
|
|
||||||
|
**Method:** `POST`
|
||||||
|
**URL:** `http://147.45.189.234:8000/events/{{ $json.claim_id }}`
|
||||||
|
**Headers:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{{ $json }}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### React подписка на события:
|
||||||
|
|
||||||
|
**Frontend код:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
if (!claimId) return;
|
||||||
|
|
||||||
|
// Подключаемся к SSE
|
||||||
|
const eventSource = new EventSource(
|
||||||
|
`http://147.45.189.234:8000/events/${claimId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.event_type === 'ocr_completed') {
|
||||||
|
setUploadProgress(''); // Убираем крутилку
|
||||||
|
|
||||||
|
if (data.status === 'success' && data.data.is_valid_document) {
|
||||||
|
message.success(data.message);
|
||||||
|
// ✅ Полис распознан - можно продолжать
|
||||||
|
} else {
|
||||||
|
message.error(data.message);
|
||||||
|
// ❌ Это не полис - показываем предупреждение
|
||||||
|
Modal.error({
|
||||||
|
title: 'Документ не распознан',
|
||||||
|
content: data.data.ai_analysis.warnings.join('\n')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => eventSource.close();
|
||||||
|
}, [claimId]);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Полный workflow в n8n:
|
||||||
|
|
||||||
|
```
|
||||||
|
Webhook (file upload)
|
||||||
|
↓
|
||||||
|
S3 Upload
|
||||||
|
↓
|
||||||
|
PostgreSQL (INSERT claim_files)
|
||||||
|
↓
|
||||||
|
OCR Service (HTTP Request)
|
||||||
|
↓
|
||||||
|
Vision Service (HTTP Request)
|
||||||
|
↓
|
||||||
|
Code Node (валидация документа)
|
||||||
|
↓
|
||||||
|
IF Node: is_valid_document?
|
||||||
|
├─ TRUE → PostgreSQL UPDATE (ocr_status = 'valid')
|
||||||
|
│ ↓
|
||||||
|
│ HTTP POST → /events/{claim_id} (Redis Pub/Sub)
|
||||||
|
│ ↓
|
||||||
|
│ Respond to Webhook: {success: true}
|
||||||
|
│
|
||||||
|
└─ FALSE → PostgreSQL UPDATE (ocr_status = 'invalid')
|
||||||
|
↓
|
||||||
|
HTTP POST → /events/{claim_id} (Redis Pub/Sub)
|
||||||
|
↓
|
||||||
|
Respond to Webhook: {success: true, warning: true}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Готово! Теперь делаем вебхуки в n8n?** 🚀
|
||||||
|
|
||||||
145
N8N_STIRLING_COMPRESS.md
Normal file
145
N8N_STIRLING_COMPRESS.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# 🗜️ PDF Compression для n8n
|
||||||
|
|
||||||
|
## ⚠️ UPDATE: Stirling API недоступен!
|
||||||
|
|
||||||
|
**Альтернатива:** Используем **Ghostscript** или **Python pypdf**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐍 Вариант 1: Python Code Node (РЕКОМЕНДУЕТСЯ)
|
||||||
|
|
||||||
|
### 1️⃣ Базовая настройка
|
||||||
|
|
||||||
|
**Method:** `POST`
|
||||||
|
**URL:** `https://stirling.klientprav.tech/api/v1/general/compress-pdf`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2️⃣ Authentication
|
||||||
|
|
||||||
|
- **Type:** `Header Auth`
|
||||||
|
- **Name:** `X-API-Key`
|
||||||
|
- **Value:** `HTYgGMCZ64rlzoRbbmg6IeutXzJHEdVpKV1`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3️⃣ Body
|
||||||
|
|
||||||
|
**Content Type:** `Multipart-Form Data`
|
||||||
|
|
||||||
|
### Fields:
|
||||||
|
|
||||||
|
| Property Name | Type | Value |
|
||||||
|
|--------------|------|-------|
|
||||||
|
| `fileInput` | Binary Data | `{{ $binary.data }}` |
|
||||||
|
| `optimizeLevel` | String | `3` |
|
||||||
|
| `expectedOutputSize` | String | `2` |
|
||||||
|
|
||||||
|
**Схема:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "fileInput",
|
||||||
|
"data": "{{ $binary.data }}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "optimizeLevel",
|
||||||
|
"data": "3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "expectedOutputSize",
|
||||||
|
"data": "2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4️⃣ Send Binary Data
|
||||||
|
|
||||||
|
**Include Binary Data:** `Yes`
|
||||||
|
**Binary Property Name:** `data`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📥 Response
|
||||||
|
|
||||||
|
Stirling вернёт **сжатый PDF** в формате:
|
||||||
|
|
||||||
|
### Success:
|
||||||
|
- **Status:** `200 OK`
|
||||||
|
- **Body:** Binary PDF file
|
||||||
|
- **Headers:**
|
||||||
|
```
|
||||||
|
Content-Type: application/pdf
|
||||||
|
Content-Disposition: attachment; filename="compressed.pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Error description",
|
||||||
|
"status": 400
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 Использование в workflow
|
||||||
|
|
||||||
|
### Полная цепочка:
|
||||||
|
|
||||||
|
```
|
||||||
|
Webhook (получили PDF)
|
||||||
|
↓
|
||||||
|
IF Node: file_size > 5 MB?
|
||||||
|
├─ TRUE → HTTP Request (Stirling Compress)
|
||||||
|
│ ↓
|
||||||
|
│ Binary Data (сжатый PDF)
|
||||||
|
│ ↓
|
||||||
|
└─ FALSE → Binary Data (оригинал)
|
||||||
|
↓
|
||||||
|
S3 Upload (оба варианта)
|
||||||
|
↓
|
||||||
|
PostgreSQL (запись пути)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Curl пример для теста
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-H "X-API-Key: HTYgGMCZ64rlzoRbbmg6IeutXzJHEdVpKV1" \
|
||||||
|
-F "fileInput=@/path/to/file.pdf" \
|
||||||
|
-F "optimizeLevel=3" \
|
||||||
|
-F "expectedOutputSize=2" \
|
||||||
|
https://stirling.klientprav.tech/api/v1/general/compress-pdf \
|
||||||
|
--output compressed.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Параметры сжатия
|
||||||
|
|
||||||
|
- **optimizeLevel:**
|
||||||
|
- `1` = минимальное сжатие (быстро)
|
||||||
|
- `2` = среднее сжатие (баланс)
|
||||||
|
- `3` = максимальное сжатие (медленно, но эффективно) ⭐
|
||||||
|
|
||||||
|
- **expectedOutputSize:**
|
||||||
|
- Целевой размер в MB (опционально)
|
||||||
|
- Например: `2` = максимум 2MB
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Примечания
|
||||||
|
|
||||||
|
⚠️ **Важно:**
|
||||||
|
1. Stirling работает только с **PDF**
|
||||||
|
2. JPEG/PNG сначала конвертируются в PDF на **frontend**
|
||||||
|
3. В n8n приходит уже **PDF**
|
||||||
|
4. Если файл > 5MB → **сжимаем в Stirling**
|
||||||
|
5. Если файл ≤ 5MB → **пропускаем Stirling**
|
||||||
|
|
||||||
|
---
|
||||||
131
backend/app/api/events.py
Normal file
131
backend/app/api/events.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""
|
||||||
|
SSE (Server-Sent Events) для real-time обновлений через Redis Pub/Sub
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from fastapi import APIRouter, Body
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Dict, Any
|
||||||
|
from app.services.redis_service import redis_service
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class EventPublish(BaseModel):
|
||||||
|
"""Модель для публикации события"""
|
||||||
|
event_type: str = "ocr_completed"
|
||||||
|
status: str
|
||||||
|
message: str
|
||||||
|
data: Dict[str, Any] = {}
|
||||||
|
timestamp: str = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/events/{task_id}")
|
||||||
|
async def publish_event(task_id: str, event: EventPublish):
|
||||||
|
"""
|
||||||
|
Публикация события в Redis канал
|
||||||
|
|
||||||
|
Используется n8n для отправки событий (OCR, AI и т.д.)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: ID задачи
|
||||||
|
event: Данные события
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Статус публикации
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
channel = f"ocr_events:{task_id}"
|
||||||
|
event_data = {
|
||||||
|
"event_type": event.event_type,
|
||||||
|
"status": event.status,
|
||||||
|
"message": event.message,
|
||||||
|
"data": event.data,
|
||||||
|
"timestamp": event.timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
# Публикуем в Redis
|
||||||
|
event_json = json.dumps(event_data, ensure_ascii=False)
|
||||||
|
await redis_service.publish(channel, event_json)
|
||||||
|
|
||||||
|
logger.info(f"📢 Event published to {channel}: {event.status}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"channel": channel,
|
||||||
|
"event": event_data
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed to publish event: {e}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events/{task_id}")
|
||||||
|
async def stream_events(task_id: str):
|
||||||
|
"""
|
||||||
|
SSE стрим событий обработки OCR
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: ID задачи
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StreamingResponse с событиями
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
"""Генератор событий из Redis Pub/Sub"""
|
||||||
|
channel = f"ocr_events:{task_id}"
|
||||||
|
|
||||||
|
# Подписываемся на канал Redis
|
||||||
|
pubsub = redis_service.redis.pubsub()
|
||||||
|
await pubsub.subscribe(channel)
|
||||||
|
|
||||||
|
logger.info(f"📡 Client subscribed to {channel}")
|
||||||
|
|
||||||
|
# Отправляем начальное событие
|
||||||
|
yield f"data: {json.dumps({'status': 'connected', 'message': 'Подключено к событиям'})}\n\n"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Слушаем события
|
||||||
|
while True:
|
||||||
|
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=30.0)
|
||||||
|
|
||||||
|
if message and message['type'] == 'message':
|
||||||
|
event_data = message['data'].decode('utf-8')
|
||||||
|
|
||||||
|
# Отправляем событие клиенту
|
||||||
|
yield f"data: {event_data}\n\n"
|
||||||
|
|
||||||
|
# Если обработка завершена - закрываем соединение
|
||||||
|
event = json.loads(event_data)
|
||||||
|
if event.get('status') in ['completed', 'error']:
|
||||||
|
logger.info(f"✅ Task {task_id} finished, closing SSE")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Пинг каждые 30 сек чтобы соединение не закрылось
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info(f"❌ Client disconnected from {channel}")
|
||||||
|
finally:
|
||||||
|
await pubsub.unsubscribe(channel)
|
||||||
|
await pubsub.close()
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_generator(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no" # Отключаем буферизацию nginx
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ from .services.redis_service import redis_service
|
|||||||
from .services.rabbitmq_service import rabbitmq_service
|
from .services.rabbitmq_service import rabbitmq_service
|
||||||
from .services.policy_service import policy_service
|
from .services.policy_service import policy_service
|
||||||
from .services.s3_service import s3_service
|
from .services.s3_service import s3_service
|
||||||
from .api import sms, claims, policy, upload, draft
|
from .api import sms, claims, policy, upload, draft, events
|
||||||
|
|
||||||
# Настройка логирования
|
# Настройка логирования
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -98,6 +98,7 @@ app.include_router(claims.router)
|
|||||||
app.include_router(policy.router)
|
app.include_router(policy.router)
|
||||||
app.include_router(upload.router)
|
app.include_router(upload.router)
|
||||||
app.include_router(draft.router)
|
app.include_router(draft.router)
|
||||||
|
app.include_router(events.router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import logging
|
|||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
import json
|
import json
|
||||||
|
from .s3_service import s3_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -39,25 +40,104 @@ class OCRService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Шаг 1: OCR распознавание текста
|
# Шаг 0: Загружаем файл в S3 и получаем presigned URL
|
||||||
|
logger.info(f"📤 Uploading file to S3: {filename}")
|
||||||
|
|
||||||
|
# Определяем content_type
|
||||||
|
content_type = "image/jpeg"
|
||||||
|
if filename.lower().endswith('.pdf'):
|
||||||
|
content_type = "application/pdf"
|
||||||
|
elif filename.lower().endswith('.png'):
|
||||||
|
content_type = "image/png"
|
||||||
|
elif filename.lower().endswith(('.heic', '.heif')):
|
||||||
|
content_type = "image/heic"
|
||||||
|
|
||||||
|
# Загружаем в S3
|
||||||
|
s3_url = await s3_service.upload_file(
|
||||||
|
file_content=file_content,
|
||||||
|
filename=filename,
|
||||||
|
content_type=content_type,
|
||||||
|
folder="ocr_temp"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not s3_url:
|
||||||
|
logger.error("❌ Failed to upload file to S3")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Используем простой публичный URL
|
||||||
|
# Файлы в ocr_temp/ загружаются с ACL=public-read
|
||||||
|
ocr_file_url = s3_url # Уже публичный URL!
|
||||||
|
|
||||||
|
logger.info(f"✅ File uploaded to S3, using public URL for OCR")
|
||||||
|
|
||||||
|
# Шаг 1: OCR распознавание текста через URL
|
||||||
logger.info(f"🔍 Starting OCR for: {filename}")
|
logger.info(f"🔍 Starting OCR for: {filename}")
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
# Определяем file_type по расширению (OCR API требует строку!)
|
||||||
files = {"file": (filename, file_content, "image/jpeg")}
|
file_ext = filename.lower().split('.')[-1]
|
||||||
|
file_type_map = {
|
||||||
|
'pdf': 'pdf',
|
||||||
|
'jpg': 'jpeg',
|
||||||
|
'jpeg': 'jpeg',
|
||||||
|
'png': 'png',
|
||||||
|
'heic': 'heic',
|
||||||
|
'heif': 'heic',
|
||||||
|
'docx': 'docx',
|
||||||
|
'doc': 'doc'
|
||||||
|
}
|
||||||
|
file_type = file_type_map.get(file_ext, 'pdf') # По умолчанию pdf
|
||||||
|
|
||||||
|
logger.info(f"📄 File type detected: {file_type}")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=90.0) as client:
|
||||||
|
# OCR API ожидает JSON с file_url
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"{self.ocr_url}/analyze-file",
|
f"{self.ocr_url}/analyze-file",
|
||||||
files=files
|
json={
|
||||||
|
"file_url": ocr_file_url, # Публичный URL
|
||||||
|
"file_name": filename,
|
||||||
|
"file_type": file_type # ✅ Теперь строка, не None!
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
ocr_result = response.json()
|
ocr_result = response.json()
|
||||||
ocr_text = ocr_result.get("text", "")
|
|
||||||
|
# OCR API возвращает массив: [{text: "", pages_data: [...]}]
|
||||||
|
ocr_text = ""
|
||||||
|
|
||||||
|
if isinstance(ocr_result, list) and len(ocr_result) > 0:
|
||||||
|
data = ocr_result[0]
|
||||||
|
|
||||||
|
# Пробуем извлечь текст из pages_data
|
||||||
|
if "pages_data" in data and len(data["pages_data"]) > 0:
|
||||||
|
# Собираем текст со всех страниц
|
||||||
|
texts = []
|
||||||
|
for page in data["pages_data"]:
|
||||||
|
page_text = page.get("ocr_text", "")
|
||||||
|
if page_text:
|
||||||
|
texts.append(page_text)
|
||||||
|
ocr_text = "\n\n".join(texts)
|
||||||
|
|
||||||
|
# Если нет pages_data, пробуем text или full_text
|
||||||
|
if not ocr_text:
|
||||||
|
ocr_text = data.get("text", "") or data.get("full_text", "")
|
||||||
|
|
||||||
|
elif isinstance(ocr_result, dict):
|
||||||
|
# Старый формат (на всякий случай)
|
||||||
|
ocr_text = ocr_result.get("text", "") or ocr_result.get("full_text", "")
|
||||||
|
|
||||||
result["ocr_text"] = ocr_text
|
result["ocr_text"] = ocr_text
|
||||||
|
|
||||||
logger.info(f"📄 OCR completed: {len(ocr_text)} chars")
|
logger.info(f"📄 OCR completed: {len(ocr_text)} chars")
|
||||||
logger.debug(f"OCR Text preview: {ocr_text[:200]}...")
|
if ocr_text:
|
||||||
|
logger.info(f"OCR Text preview: {ocr_text[:200]}...")
|
||||||
|
else:
|
||||||
|
logger.warning("⚠️ OCR returned empty text!")
|
||||||
|
logger.debug(f"OCR response structure: {list(ocr_result.keys()) if isinstance(ocr_result, dict) else type(ocr_result)}")
|
||||||
else:
|
else:
|
||||||
logger.error(f"❌ OCR failed: {response.status_code}")
|
logger.error(f"❌ OCR failed: {response.status_code}")
|
||||||
|
logger.error(f"Response: {response.text[:500]}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Шаг 2: AI анализ - что это за документ?
|
# Шаг 2: AI анализ - что это за документ?
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ class RedisService:
|
|||||||
else:
|
else:
|
||||||
await self.client.set(full_key, value)
|
await self.client.set(full_key, value)
|
||||||
|
|
||||||
|
async def publish(self, channel: str, message: str):
|
||||||
|
"""Публикация сообщения в канал Redis Pub/Sub"""
|
||||||
|
try:
|
||||||
|
await self.client.publish(channel, message)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Redis publish error: {e}")
|
||||||
|
|
||||||
async def delete(self, key: str) -> bool:
|
async def delete(self, key: str) -> bool:
|
||||||
"""Удалить ключ"""
|
"""Удалить ключ"""
|
||||||
full_key = f"{settings.redis_prefix}{key}"
|
full_key = f"{settings.redis_prefix}{key}"
|
||||||
|
|||||||
@@ -64,18 +64,22 @@ class S3Service:
|
|||||||
unique_id = str(uuid.uuid4())[:8]
|
unique_id = str(uuid.uuid4())[:8]
|
||||||
safe_filename = f"{folder}/{timestamp}_{unique_id}_{filename}"
|
safe_filename = f"{folder}/{timestamp}_{unique_id}_{filename}"
|
||||||
|
|
||||||
# Загружаем файл
|
# Загружаем файл с публичным доступом (для OCR)
|
||||||
|
# ВРЕМЕННОЕ РЕШЕНИЕ: делаем файлы публичными пока presigned URL не работает
|
||||||
|
acl = 'public-read' if folder == 'ocr_temp' else 'private'
|
||||||
|
|
||||||
self.client.put_object(
|
self.client.put_object(
|
||||||
Bucket=self.bucket,
|
Bucket=self.bucket,
|
||||||
Key=safe_filename,
|
Key=safe_filename,
|
||||||
Body=file_content,
|
Body=file_content,
|
||||||
ContentType=content_type
|
ContentType=content_type,
|
||||||
|
ACL=acl # Делаем ocr_temp файлы публичными
|
||||||
)
|
)
|
||||||
|
|
||||||
# Генерируем URL
|
# Генерируем URL
|
||||||
file_url = f"{settings.s3_endpoint}/{self.bucket}/{safe_filename}"
|
file_url = f"{settings.s3_endpoint}/{self.bucket}/{safe_filename}"
|
||||||
|
|
||||||
logger.info(f"✅ File uploaded to S3: {safe_filename}")
|
logger.info(f"✅ File uploaded to S3: {safe_filename} (ACL: {acl})")
|
||||||
return file_url
|
return file_url
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -98,6 +102,51 @@ class S3Service:
|
|||||||
logger.error(f"❌ S3 delete error: {e}")
|
logger.error(f"❌ S3 delete error: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def generate_presigned_url(self, file_key: str, expiration: int = 3600) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Генерация временного публичного URL для файла
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_key: Ключ файла в S3 (путь)
|
||||||
|
expiration: Время жизни URL в секундах (по умолчанию 1 час)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Presigned URL или None при ошибке
|
||||||
|
"""
|
||||||
|
if not self.client:
|
||||||
|
self.connect()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Для Timeweb Cloud Storage нужно использовать ClientMethod вместо обычного метода
|
||||||
|
# И добавить HttpMethod явно
|
||||||
|
url = self.client.generate_presigned_url(
|
||||||
|
ClientMethod='get_object',
|
||||||
|
Params={
|
||||||
|
'Bucket': self.bucket,
|
||||||
|
'Key': file_key
|
||||||
|
},
|
||||||
|
ExpiresIn=expiration,
|
||||||
|
HttpMethod='GET'
|
||||||
|
)
|
||||||
|
logger.info(f"✅ Presigned URL generated for: {file_key} (expires in {expiration}s)")
|
||||||
|
return url
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Presigned URL generation error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_public_url(self, file_key: str) -> str:
|
||||||
|
"""
|
||||||
|
Простой публичный URL (без подписи)
|
||||||
|
ВНИМАНИЕ: Работает только если bucket публичный!
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_key: Ключ файла в S3
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Публичный URL
|
||||||
|
"""
|
||||||
|
return f"{settings.s3_endpoint}/{self.bucket}/{file_key}"
|
||||||
|
|
||||||
|
|
||||||
# Глобальный экземпляр
|
# Глобальный экземпляр
|
||||||
s3_service = S3Service()
|
s3_service = S3Service()
|
||||||
|
|||||||
158
backend/app/workers/ocr_worker.py
Normal file
158
backend/app/workers/ocr_worker.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"""
|
||||||
|
OCR Worker - обработка файлов в фоне через RabbitMQ + Redis Pub/Sub
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any
|
||||||
|
from aio_pika import connect_robust, IncomingMessage
|
||||||
|
from app.config import settings
|
||||||
|
from app.services.ocr_service import ocr_service
|
||||||
|
from app.services.redis_service import redis_service
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OCRWorker:
|
||||||
|
"""Worker для обработки OCR задач в фоне"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.connection = None
|
||||||
|
self.channel = None
|
||||||
|
self.queue_name = "erv_ocr_processing"
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""Подключение к RabbitMQ"""
|
||||||
|
self.connection = await connect_robust(settings.rabbitmq_url)
|
||||||
|
self.channel = await self.connection.channel()
|
||||||
|
await self.channel.set_qos(prefetch_count=1) # По одной задаче
|
||||||
|
|
||||||
|
self.queue = await self.channel.declare_queue(
|
||||||
|
self.queue_name,
|
||||||
|
durable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"✅ Worker connected to RabbitMQ: {self.queue_name}")
|
||||||
|
|
||||||
|
async def publish_event(self, task_id: str, event: Dict[str, Any]):
|
||||||
|
"""
|
||||||
|
Публикация события в Redis для real-time обновлений
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: ID задачи
|
||||||
|
event: Данные события
|
||||||
|
"""
|
||||||
|
channel = f"ocr_events:{task_id}"
|
||||||
|
event_json = json.dumps(event, ensure_ascii=False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await redis_service.publish(channel, event_json)
|
||||||
|
logger.info(f"📢 Event published to {channel}: {event['status']}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed to publish event: {e}")
|
||||||
|
|
||||||
|
async def process_task(self, message: IncomingMessage):
|
||||||
|
"""
|
||||||
|
Обработка задачи OCR
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Сообщение из RabbitMQ
|
||||||
|
"""
|
||||||
|
async with message.process():
|
||||||
|
try:
|
||||||
|
# Парсим задачу
|
||||||
|
task = json.loads(message.body.decode())
|
||||||
|
task_id = task["task_id"]
|
||||||
|
file_content = bytes.fromhex(task["file_content_hex"])
|
||||||
|
filename = task["filename"]
|
||||||
|
|
||||||
|
logger.info(f"🔄 Processing task {task_id}: {filename}")
|
||||||
|
|
||||||
|
# Событие: начало обработки
|
||||||
|
await self.publish_event(task_id, {
|
||||||
|
"status": "processing",
|
||||||
|
"message": "Начата обработка файла",
|
||||||
|
"filename": filename
|
||||||
|
})
|
||||||
|
|
||||||
|
# Шаг 1: OCR обработка
|
||||||
|
await self.publish_event(task_id, {
|
||||||
|
"status": "ocr_started",
|
||||||
|
"message": "Запущено распознавание текста"
|
||||||
|
})
|
||||||
|
|
||||||
|
result = await ocr_service.process_document(file_content, filename)
|
||||||
|
|
||||||
|
# Событие: OCR завершён
|
||||||
|
await self.publish_event(task_id, {
|
||||||
|
"status": "ocr_completed",
|
||||||
|
"message": f"Распознано {len(result['ocr_text'])} символов",
|
||||||
|
"chars": len(result['ocr_text'])
|
||||||
|
})
|
||||||
|
|
||||||
|
# Шаг 2: AI анализ (если есть текст)
|
||||||
|
if result['ocr_text']:
|
||||||
|
await self.publish_event(task_id, {
|
||||||
|
"status": "ai_started",
|
||||||
|
"message": "Запущен AI анализ документа"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Событие: всё готово
|
||||||
|
await self.publish_event(task_id, {
|
||||||
|
"status": "completed",
|
||||||
|
"message": "Обработка завершена",
|
||||||
|
"result": {
|
||||||
|
"document_type": result["document_type"],
|
||||||
|
"is_valid": result["is_valid"],
|
||||||
|
"confidence": result["confidence"],
|
||||||
|
"extracted_data": result["extracted_data"],
|
||||||
|
"ocr_text_length": len(result["ocr_text"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Сохраняем результат в Redis (TTL 1 час)
|
||||||
|
cache_key = f"ocr_result:{task_id}"
|
||||||
|
await redis_service.set_json(cache_key, result, ttl=3600)
|
||||||
|
|
||||||
|
logger.info(f"✅ Task {task_id} completed successfully")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Task processing error: {e}")
|
||||||
|
|
||||||
|
# Событие: ошибка
|
||||||
|
await self.publish_event(task_id, {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Ошибка обработки: {str(e)}"
|
||||||
|
})
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Запуск worker"""
|
||||||
|
await self.connect()
|
||||||
|
|
||||||
|
logger.info(f"🚀 OCR Worker started, waiting for tasks...")
|
||||||
|
|
||||||
|
# Слушаем очередь
|
||||||
|
await self.queue.consume(self.process_task)
|
||||||
|
|
||||||
|
# Держим worker живым
|
||||||
|
try:
|
||||||
|
await asyncio.Future()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("👋 Worker stopped")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Точка входа"""
|
||||||
|
worker = OCRWorker()
|
||||||
|
await worker.start()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -22,7 +22,6 @@ aiofiles==24.1.0
|
|||||||
|
|
||||||
# S3
|
# S3
|
||||||
boto3==1.35.56
|
boto3==1.35.56
|
||||||
aioboto3==13.2.0
|
|
||||||
|
|
||||||
# Validation
|
# Validation
|
||||||
pydantic==2.10.0
|
pydantic==2.10.0
|
||||||
|
|||||||
@@ -25,7 +25,9 @@
|
|||||||
"imask": "^7.6.1",
|
"imask": "^7.6.1",
|
||||||
"react-dropzone": "^14.3.5",
|
"react-dropzone": "^14.3.5",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"serve": "^14.2.1"
|
"serve": "^14.2.1",
|
||||||
|
"jspdf": "^2.5.2",
|
||||||
|
"browser-image-compression": "^2.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Form, Input, Button, message, Upload, Progress } from 'antd';
|
import { Form, Input, Button, message, Upload, Spin, Alert, Modal } from 'antd';
|
||||||
import { FileProtectOutlined, UploadOutlined, LoadingOutlined } from '@ant-design/icons';
|
import { FileProtectOutlined, UploadOutlined, LoadingOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd/es/upload/interface';
|
import type { UploadFile } from 'antd/es/upload/interface';
|
||||||
|
import { convertToPDF } from '../../utils/pdfConverter';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
formData: any;
|
formData: any;
|
||||||
@@ -56,7 +57,76 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
|||||||
const [policyNotFound, setPolicyNotFound] = useState(false);
|
const [policyNotFound, setPolicyNotFound] = useState(false);
|
||||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [ocrProgress, setOcrProgress] = useState<string>('');
|
const [uploadProgress, setUploadProgress] = useState('');
|
||||||
|
const [ocrResult, setOcrResult] = useState<any>(null);
|
||||||
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
|
||||||
|
// SSE подключение для получения результатов OCR/Vision
|
||||||
|
useEffect(() => {
|
||||||
|
const claimId = formData.claim_id;
|
||||||
|
if (!claimId || !uploading) return;
|
||||||
|
|
||||||
|
// Подключаемся к SSE для получения результатов OCR
|
||||||
|
const eventSource = new EventSource(`http://147.45.189.234:8000/events/${claimId}`);
|
||||||
|
eventSourceRef.current = eventSource;
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
console.log('📨 SSE event received:', data);
|
||||||
|
|
||||||
|
if (data.event_type === 'ocr_completed') {
|
||||||
|
setUploadProgress(''); // Убираем крутилку
|
||||||
|
setOcrResult(data);
|
||||||
|
|
||||||
|
if (data.status === 'success' && data.data?.is_valid_document) {
|
||||||
|
// ✅ Полис распознан успешно
|
||||||
|
message.success(data.message || '✅ Полис успешно распознан!');
|
||||||
|
addDebugEvent?.('ocr', 'success', data.message, data.data);
|
||||||
|
} else {
|
||||||
|
// ❌ Документ не распознан или это не полис
|
||||||
|
const warnings = data.data?.ai_analysis?.warnings || ['Документ не распознан'];
|
||||||
|
|
||||||
|
Modal.error({
|
||||||
|
title: '❌ Документ не распознан',
|
||||||
|
content: (
|
||||||
|
<div>
|
||||||
|
<p>{data.message}</p>
|
||||||
|
{warnings.length > 0 && (
|
||||||
|
<ul>
|
||||||
|
{warnings.map((w: string, i: number) => (
|
||||||
|
<li key={i}>{w}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<p style={{ marginTop: 12, color: '#666' }}>
|
||||||
|
Пожалуйста, загрузите скан страхового полиса ERV.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
addDebugEvent?.('ocr', 'error', data.message, data.data);
|
||||||
|
setFileList([]); // Очищаем список файлов
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SSE parse error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('SSE connection error:', error);
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [formData.claim_id, uploading]);
|
||||||
|
|
||||||
// Обработчик изменения поля полиса с автозаменой и маской
|
// Обработчик изменения поля полиса с автозаменой и маской
|
||||||
const handleVoucherChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleVoucherChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -81,32 +151,40 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
|||||||
|
|
||||||
addDebugEvent?.('policy_check', 'pending', `Проверяю полис: ${values.voucher}`, { voucher: values.voucher });
|
addDebugEvent?.('policy_check', 'pending', `Проверяю полис: ${values.voucher}`, { voucher: values.voucher });
|
||||||
|
|
||||||
// Проверка полиса через API
|
// Проверка полиса через n8n вебхук + создание записи в БД
|
||||||
const response = await fetch('http://147.45.146.17:8100/api/v1/policy/check', {
|
const response = await fetch('https://n8n.clientright.pro/webhook/9eb7bc5b-645f-477d-a5d8-5a346260a265', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
voucher: values.voucher,
|
claim_id: formData.claim_id, // Передаём claim_id для создания записи
|
||||||
email: 'temp@check.com', // Email не требуется на этом шаге
|
policy_number: values.voucher,
|
||||||
|
session_id: sessionStorage.getItem('session_id') || 'unknown'
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
if (result.found) {
|
// Новый формат ответа от n8n: {claim: {...}, policy: {...}}
|
||||||
|
const policyFound = result.policy?.found === 1 || result.policy?.found === true;
|
||||||
|
|
||||||
|
if (policyFound) {
|
||||||
// Полис найден - переходим дальше
|
// Полис найден - переходим дальше
|
||||||
addDebugEvent?.('policy_check', 'success', `✅ Полис найден в MySQL БД (33,963 полисов)`, {
|
addDebugEvent?.('policy_check', 'success', `✅ Полис найден в MySQL БД`, {
|
||||||
found: true,
|
found: true,
|
||||||
|
claim: result.claim,
|
||||||
|
policy: result.policy,
|
||||||
voucher: values.voucher
|
voucher: values.voucher
|
||||||
});
|
});
|
||||||
message.success('Полис найден в базе данных');
|
message.success(`Полис найден: ${result.policy.voucher}. Застрахованных: ${result.policy.count} чел.`);
|
||||||
updateFormData(values);
|
updateFormData(values);
|
||||||
onNext();
|
onNext();
|
||||||
} else {
|
} else {
|
||||||
// Полис НЕ найден - показываем загрузку скана
|
// Полис НЕ найден - показываем загрузку скана
|
||||||
addDebugEvent?.('policy_check', 'warning', `⚠️ Полис не найден → требуется загрузка скана`, {
|
addDebugEvent?.('policy_check', 'warning', `▲ Полис не найден → требуется загрузка скана`, {
|
||||||
found: false,
|
found: false,
|
||||||
|
claim: result.claim,
|
||||||
|
message: result.policy?.message || 'Полис не найден',
|
||||||
voucher: values.voucher
|
voucher: values.voucher
|
||||||
});
|
});
|
||||||
message.warning('Полис не найден в базе. Загрузите скан полиса');
|
message.warning('Полис не найден в базе. Загрузите скан полиса');
|
||||||
@@ -131,59 +209,8 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
|||||||
setFileList(newFileList);
|
setFileList(newFileList);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Polling для получения OCR результатов
|
// OCR теперь обрабатывается в n8n (через RabbitMQ + Redis Pub/Sub)
|
||||||
const pollOcrResults = async (fileIds: string[]) => {
|
// Polling не нужен!
|
||||||
if (fileIds.length === 0) return;
|
|
||||||
|
|
||||||
const maxAttempts = 10;
|
|
||||||
const interval = 3000; // 3 секунды
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, interval));
|
|
||||||
|
|
||||||
setOcrProgress(`🔍 Обработка OCR... (${attempt + 1}/${maxAttempts})`);
|
|
||||||
|
|
||||||
for (const fileId of fileIds) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`http://147.45.146.17:8100/api/v1/upload/ocr-result/${fileId}`);
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.found && result.ocr_result) {
|
|
||||||
const ocr = result.ocr_result;
|
|
||||||
|
|
||||||
addDebugEvent?.('ocr', 'success', `📄 OCR завершен: ${ocr.ocr_text?.length || 0} символов`, {
|
|
||||||
text: ocr.ocr_text?.substring(0, 300)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ocr.ai_analysis || ocr.document_type) {
|
|
||||||
const isGarbage = ocr.document_type === 'garbage';
|
|
||||||
|
|
||||||
addDebugEvent?.(
|
|
||||||
'ai_analysis',
|
|
||||||
isGarbage ? 'warning' : 'success',
|
|
||||||
isGarbage
|
|
||||||
? `🗑️ ШЛЯПА DETECTED! (пользователю не говорим)`
|
|
||||||
: `🤖 Gemini Vision: ${ocr.document_type}, confidence: ${(ocr.confidence * 100).toFixed(0)}%`,
|
|
||||||
{
|
|
||||||
document_type: ocr.document_type,
|
|
||||||
is_valid: ocr.is_valid,
|
|
||||||
confidence: ocr.confidence,
|
|
||||||
extracted_data: ocr.extracted_data
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
setOcrProgress(`✅ OCR завершен: ${ocr.document_type}`);
|
|
||||||
return; // Готово
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('OCR polling error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setOcrProgress('⏱️ OCR обрабатывается в фоне...');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmitWithScan = async () => {
|
const handleSubmitWithScan = async () => {
|
||||||
if (fileList.length === 0) {
|
if (fileList.length === 0) {
|
||||||
@@ -198,55 +225,97 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
setUploadProgress('📤 Подготавливаем документы...');
|
||||||
const values = await form.validateFields(['voucher']);
|
const values = await form.validateFields(['voucher']);
|
||||||
|
|
||||||
addDebugEvent?.('upload', 'pending', `📤 Загружаю ${fileList.length} файл(ов) в S3...`, {
|
addDebugEvent?.('upload', 'pending', `📤 Загружаю ${fileList.length} файл(ов) в S3 через n8n...`, {
|
||||||
count: fileList.length
|
count: fileList.length
|
||||||
});
|
});
|
||||||
|
|
||||||
// Загружаем файлы в S3 с OCR проверкой
|
// Генерируем claim_id если его нет
|
||||||
const formData = new FormData();
|
const claimId = formData.claim_id || `CLM-${new Date().toISOString().split('T')[0]}-${Math.random().toString(36).substr(2, 6).toUpperCase()}`;
|
||||||
fileList.forEach((file: any) => {
|
|
||||||
if (file.originFileObj) {
|
// Загружаем каждый файл через n8n вебхук
|
||||||
formData.append('files', file.originFileObj);
|
const uploadedFiles = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < fileList.length; i++) {
|
||||||
|
const file = fileList[i];
|
||||||
|
if (!file.originFileObj) continue;
|
||||||
|
|
||||||
|
// 🔄 Конвертируем в PDF перед отправкой
|
||||||
|
let pdfFile: File;
|
||||||
|
try {
|
||||||
|
setUploadProgress(`🔄 Конвертируем ${file.name} в PDF...`);
|
||||||
|
addDebugEvent?.('convert', 'pending', `🔄 Конвертирую ${file.name} в PDF...`, {
|
||||||
|
original_size: `${(file.originFileObj.size / 1024 / 1024).toFixed(2)} MB`,
|
||||||
|
original_type: file.originFileObj.type
|
||||||
|
});
|
||||||
|
|
||||||
|
pdfFile = await convertToPDF(file.originFileObj);
|
||||||
|
|
||||||
|
addDebugEvent?.('convert', 'success', `✅ PDF готов: ${pdfFile.name}`, {
|
||||||
|
pdf_size: `${(pdfFile.size / 1024 / 1024).toFixed(2)} MB`
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
addDebugEvent?.('convert', 'error', `❌ Ошибка конвертации: ${error.message}`);
|
||||||
|
message.error('Ошибка конвертации файла');
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
formData.append('folder', 'policies');
|
|
||||||
|
|
||||||
const uploadResponse = await fetch('http://147.45.146.17:8100/api/v1/upload/files?folder=policies', {
|
const uploadFormData = new FormData();
|
||||||
|
uploadFormData.append('claim_id', claimId);
|
||||||
|
uploadFormData.append('file_type', 'policy_scan');
|
||||||
|
uploadFormData.append('filename', pdfFile.name); // PDF имя
|
||||||
|
uploadFormData.append('voucher', values.voucher);
|
||||||
|
uploadFormData.append('session_id', sessionStorage.getItem('session_id') || 'unknown');
|
||||||
|
uploadFormData.append('upload_timestamp', new Date().toISOString());
|
||||||
|
uploadFormData.append('file', pdfFile); // PDF файл!
|
||||||
|
|
||||||
|
setUploadProgress(`📡 Загружаем ${pdfFile.name} в облако...`);
|
||||||
|
const uploadResponse = await fetch('https://n8n.clientright.pro/webhook/7e2abc64-eaca-4671-86e4-12786700fe95', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: uploadFormData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setUploadProgress(`🔍 Распознаём текст и проверяем документ...`);
|
||||||
const uploadResult = await uploadResponse.json();
|
const uploadResult = await uploadResponse.json();
|
||||||
|
|
||||||
|
// Логируем ответ от n8n для отладки
|
||||||
|
console.log('n8n upload response:', uploadResult);
|
||||||
|
|
||||||
|
const resultData = Array.isArray(uploadResult) ? uploadResult[0] : uploadResult;
|
||||||
|
if (resultData?.success) {
|
||||||
|
uploadedFiles.push({
|
||||||
|
filename: file.name,
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error('Upload failed for file:', file.name, 'Response:', uploadResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadResult = {
|
||||||
|
success: uploadedFiles.length > 0,
|
||||||
|
uploaded_count: uploadedFiles.length,
|
||||||
|
total_count: fileList.length,
|
||||||
|
files: uploadedFiles
|
||||||
|
};
|
||||||
|
|
||||||
if (uploadResult.success) {
|
if (uploadResult.success) {
|
||||||
addDebugEvent?.('upload', 'success', `✅ Загружено в S3: ${uploadResult.uploaded_count}/${uploadResult.total_count}`, {
|
addDebugEvent?.('upload', 'success', `✅ Загружено в S3: ${uploadResult.uploaded_count}/${uploadResult.total_count}`, {
|
||||||
uploaded_count: uploadResult.uploaded_count,
|
uploaded_count: uploadResult.uploaded_count,
|
||||||
files: uploadResult.files
|
files: uploadResult.files
|
||||||
});
|
});
|
||||||
|
|
||||||
// Проверяем OCR результаты
|
// OCR запустится автоматически в n8n workflow (параллельно)
|
||||||
if (uploadResult.files && uploadResult.files.length > 0) {
|
addDebugEvent?.('ocr', 'pending', `🔄 OCR запущен в фоне через n8n`, {
|
||||||
const fileIds = uploadResult.files
|
claim_id: claimId,
|
||||||
.filter((f: any) => f.file_id)
|
message: 'Обработка продолжается асинхронно'
|
||||||
.map((f: any) => f.file_id);
|
|
||||||
|
|
||||||
const firstFile = uploadResult.files[0];
|
|
||||||
|
|
||||||
addDebugEvent?.('ocr', 'pending', `🔍 Запущен OCR для: ${firstFile.filename}`, {
|
|
||||||
file_id: firstFile.file_id,
|
|
||||||
filename: firstFile.filename
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setOcrProgress('🔄 Запуск OCR...');
|
|
||||||
|
|
||||||
// Запускаем polling в фоне (не блокируем переход)
|
|
||||||
pollOcrResults(fileIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFormData({
|
updateFormData({
|
||||||
...values,
|
...values,
|
||||||
|
claim_id: claimId,
|
||||||
policyScanUploaded: true,
|
policyScanUploaded: true,
|
||||||
policyScanFiles: uploadResult.files,
|
policyScanFiles: uploadResult.files,
|
||||||
policyValidationWarning: '' // Silent validation
|
policyValidationWarning: '' // Silent validation
|
||||||
@@ -263,6 +332,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
|
setUploadProgress('');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -336,58 +406,56 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
|||||||
fileList={fileList}
|
fileList={fileList}
|
||||||
onChange={handleUploadChange}
|
onChange={handleUploadChange}
|
||||||
beforeUpload={(file) => {
|
beforeUpload={(file) => {
|
||||||
|
// Проверка размера (макс 15MB для сырого файла)
|
||||||
const isLt15M = file.size / 1024 / 1024 < 15;
|
const isLt15M = file.size / 1024 / 1024 < 15;
|
||||||
if (!isLt15M) {
|
if (!isLt15M) {
|
||||||
message.error(`${file.name}: файл больше 15MB`);
|
message.error(`${file.name}: файл больше 15MB`);
|
||||||
return Upload.LIST_IGNORE;
|
return Upload.LIST_IGNORE;
|
||||||
}
|
}
|
||||||
if (fileList.length >= 10) {
|
|
||||||
message.error('Максимум 10 файлов');
|
// Проверка формата
|
||||||
|
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'application/pdf'];
|
||||||
|
const validExtensions = /\.(jpg|jpeg|png|pdf|heic|heif|webp)$/i;
|
||||||
|
|
||||||
|
if (!validTypes.includes(file.type) && !validExtensions.test(file.name)) {
|
||||||
|
message.error(`${file.name}: неподдерживаемый формат. Используйте JPG, PNG, PDF, HEIC или WEBP`);
|
||||||
return Upload.LIST_IGNORE;
|
return Upload.LIST_IGNORE;
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
return false; // Не загружать автоматически
|
||||||
}}
|
}}
|
||||||
accept="image/*,.pdf,.heic,.heif"
|
accept="image/*,.pdf,.heic,.heif,.webp"
|
||||||
multiple
|
multiple={false}
|
||||||
maxCount={10}
|
maxCount={1}
|
||||||
showUploadList={{
|
showUploadList={{
|
||||||
showPreviewIcon: true,
|
showPreviewIcon: true,
|
||||||
showRemoveIcon: true,
|
showRemoveIcon: true,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button icon={<UploadOutlined />} size="large" block disabled={fileList.length >= 10}>
|
<Button icon={<UploadOutlined />} size="large" block disabled={fileList.length >= 1}>
|
||||||
Выбрать файлы (до 10 шт, макс 15MB каждый)
|
Загрузить скан полиса (JPG, PNG, HEIC, PDF)
|
||||||
</Button>
|
</Button>
|
||||||
</Upload>
|
</Upload>
|
||||||
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
|
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
|
||||||
Загружено: {fileList.length}/10 файлов
|
Поддерживаются: JPG, PNG, HEIC, WEBP, PDF (макс 15MB)
|
||||||
|
{fileList.length > 0 && (
|
||||||
|
<span style={{ marginLeft: 8, color: '#52c41a' }}>
|
||||||
|
(автоконвертация в PDF)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{/* OCR Progress */}
|
{/* Прогресс обработки */}
|
||||||
{ocrProgress && (
|
{uploading && uploadProgress && (
|
||||||
<div style={{
|
<Alert
|
||||||
padding: 16,
|
message={uploadProgress}
|
||||||
background: '#f0f9ff',
|
type="info"
|
||||||
border: '1px solid #91d5ff',
|
showIcon
|
||||||
borderRadius: 8,
|
icon={<Spin indicator={<LoadingOutlined style={{ fontSize: 16 }} spin />} />}
|
||||||
marginBottom: 16
|
style={{ marginBottom: 16 }}
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
||||||
{ocrProgress.includes('🔍') || ocrProgress.includes('🔄') ? (
|
|
||||||
<LoadingOutlined style={{ fontSize: 16, color: '#1890ff' }} />
|
|
||||||
) : null}
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 500 }}>{ocrProgress}</span>
|
|
||||||
</div>
|
|
||||||
{ocrProgress.includes('Обработка') && (
|
|
||||||
<Progress
|
|
||||||
percent={Math.min(((ocrProgress.match(/(\d+)\/\d+/)?.[1] || 0) as any) * 10, 90)}
|
|
||||||
status="active"
|
|
||||||
showInfo={false}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
@@ -397,6 +465,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
|||||||
setFileList([]);
|
setFileList([]);
|
||||||
}}
|
}}
|
||||||
size="large"
|
size="large"
|
||||||
|
disabled={uploading}
|
||||||
>
|
>
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
@@ -407,7 +476,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
|||||||
size="large"
|
size="large"
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
>
|
>
|
||||||
{uploading ? 'Загрузка...' : 'Продолжить со сканом'}
|
{uploading ? 'Обрабатываем...' : 'Продолжить со сканом'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Form, Input, Button, Select, DatePicker, Upload, message } from 'antd';
|
import { Form, Input, Button, Select, DatePicker, Upload, message, Spin, Alert } from 'antd';
|
||||||
import { UploadOutlined } from '@ant-design/icons';
|
import { UploadOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { UploadFile } from 'antd/es/upload/interface';
|
import type { UploadFile } from 'antd/es/upload/interface';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@@ -29,6 +29,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
|||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState('');
|
||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -37,28 +38,61 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
|||||||
// Если есть файлы - загружаем
|
// Если есть файлы - загружаем
|
||||||
if (fileList.length > 0) {
|
if (fileList.length > 0) {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
setUploadProgress('📤 Подготавливаем документы...');
|
||||||
|
|
||||||
addDebugEvent?.('upload', 'pending', `📤 Загружаю ${fileList.length} документ(ов) в S3...`, {
|
addDebugEvent?.('upload', 'pending', `📤 Загружаю ${fileList.length} документ(ов) в S3 через n8n...`, {
|
||||||
count: fileList.length
|
count: fileList.length
|
||||||
});
|
});
|
||||||
|
|
||||||
const formData = new FormData();
|
// Используем claim_id из formData (уже сгенерирован в Step1)
|
||||||
fileList.forEach((file: any) => {
|
const claimId = formData.claim_id;
|
||||||
if (file.originFileObj) {
|
|
||||||
formData.append('files', file.originFileObj);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadResponse = await fetch('http://147.45.146.17:8100/api/v1/upload/files?folder=documents', {
|
// Загружаем каждый документ через n8n вебхук
|
||||||
|
const uploadedFiles = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < fileList.length; i++) {
|
||||||
|
const file = fileList[i];
|
||||||
|
if (!file.originFileObj) continue;
|
||||||
|
|
||||||
|
setUploadProgress(`📡 Загружаем документ ${i + 1} из ${fileList.length}: ${file.name}...`);
|
||||||
|
|
||||||
|
const uploadFormData = new FormData();
|
||||||
|
uploadFormData.append('claim_id', claimId);
|
||||||
|
uploadFormData.append('file_type', `document_${i + 1}`); // document_1, document_2, etc
|
||||||
|
uploadFormData.append('filename', file.name);
|
||||||
|
uploadFormData.append('voucher', formData.voucher || '');
|
||||||
|
uploadFormData.append('session_id', sessionStorage.getItem('session_id') || 'unknown');
|
||||||
|
uploadFormData.append('upload_timestamp', new Date().toISOString());
|
||||||
|
uploadFormData.append('file', file.originFileObj);
|
||||||
|
|
||||||
|
const uploadResponse = await fetch('https://n8n.clientright.pro/webhook/7e2abc64-eaca-4671-86e4-12786700fe95', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: uploadFormData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setUploadProgress(`🔍 Обрабатываем документ ${i + 1} из ${fileList.length}...`);
|
||||||
const uploadResult = await uploadResponse.json();
|
const uploadResult = await uploadResponse.json();
|
||||||
|
|
||||||
|
const resultData = Array.isArray(uploadResult) ? uploadResult[0] : uploadResult;
|
||||||
|
if (resultData?.success) {
|
||||||
|
uploadedFiles.push({
|
||||||
|
filename: file.name,
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadResult = {
|
||||||
|
success: uploadedFiles.length > 0,
|
||||||
|
uploaded_count: uploadedFiles.length,
|
||||||
|
total_count: fileList.length,
|
||||||
|
files: uploadedFiles
|
||||||
|
};
|
||||||
|
|
||||||
if (uploadResult.success) {
|
if (uploadResult.success) {
|
||||||
addDebugEvent?.('upload', 'success', `✅ Документы загружены: ${uploadResult.uploaded_count}/${uploadResult.total_count}`, {
|
addDebugEvent?.('upload', 'success', `✅ Документы загружены через n8n: ${uploadResult.uploaded_count}/${uploadResult.total_count}`, {
|
||||||
files: uploadResult.files
|
files: uploadResult.files,
|
||||||
|
claim_id: claimId
|
||||||
});
|
});
|
||||||
|
|
||||||
updateFormData({
|
updateFormData({
|
||||||
@@ -68,10 +102,12 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
|||||||
} else {
|
} else {
|
||||||
message.error('Ошибка загрузки документов');
|
message.error('Ошибка загрузки документов');
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
|
setUploadProgress('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
|
setUploadProgress('');
|
||||||
} else {
|
} else {
|
||||||
updateFormData(values);
|
updateFormData(values);
|
||||||
}
|
}
|
||||||
@@ -80,6 +116,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('Заполните все обязательные поля');
|
message.error('Заполните все обязательные поля');
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
|
setUploadProgress('');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -226,9 +263,18 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
|||||||
message.error(`${file.name}: файл больше 15MB`);
|
message.error(`${file.name}: файл больше 15MB`);
|
||||||
return Upload.LIST_IGNORE;
|
return Upload.LIST_IGNORE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'application/pdf'];
|
||||||
|
const validExtensions = /\.(jpg|jpeg|png|pdf|heic|heif|webp)$/i;
|
||||||
|
|
||||||
|
if (!validTypes.includes(file.type) && !validExtensions.test(file.name)) {
|
||||||
|
message.error(`${file.name}: неподдерживаемый формат`);
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
accept="image/*,.pdf,.heic,.heif"
|
accept="image/*,.pdf,.heic,.heif,.webp"
|
||||||
multiple
|
multiple
|
||||||
maxCount={5}
|
maxCount={5}
|
||||||
>
|
>
|
||||||
@@ -254,13 +300,23 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
|||||||
message.error(`${file.name}: файл больше 15MB`);
|
message.error(`${file.name}: файл больше 15MB`);
|
||||||
return Upload.LIST_IGNORE;
|
return Upload.LIST_IGNORE;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileList.length >= 10) {
|
if (fileList.length >= 10) {
|
||||||
message.error('Максимум 10 файлов');
|
message.error('Максимум 10 файлов');
|
||||||
return Upload.LIST_IGNORE;
|
return Upload.LIST_IGNORE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'application/pdf'];
|
||||||
|
const validExtensions = /\.(jpg|jpeg|png|pdf|heic|heif|webp)$/i;
|
||||||
|
|
||||||
|
if (!validTypes.includes(file.type) && !validExtensions.test(file.name)) {
|
||||||
|
message.error(`${file.name}: неподдерживаемый формат`);
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
accept="image/*,.pdf,.heic,.heif"
|
accept="image/*,.pdf,.heic,.heif,.webp"
|
||||||
multiple
|
multiple
|
||||||
maxCount={10}
|
maxCount={10}
|
||||||
showUploadList={{
|
showUploadList={{
|
||||||
@@ -277,9 +333,20 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
|||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* Прогресс обработки */}
|
||||||
|
{uploading && uploadProgress && (
|
||||||
|
<Alert
|
||||||
|
message={uploadProgress}
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
icon={<Spin indicator={<LoadingOutlined style={{ fontSize: 16 }} spin />} />}
|
||||||
|
style={{ marginBottom: 16, marginTop: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 32 }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 32 }}>
|
||||||
<Button onClick={onPrev} size="large">Назад</Button>
|
<Button onClick={onPrev} size="large" disabled={uploading}>Назад</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
@@ -287,7 +354,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
|||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
size="large"
|
size="large"
|
||||||
>
|
>
|
||||||
{uploading ? 'Загрузка документов...' : 'Далее'}
|
{uploading ? 'Обрабатываем...' : 'Далее'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -28,6 +28,23 @@ interface FormData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ClaimForm() {
|
export default function ClaimForm() {
|
||||||
|
// Генерируем claim_id один раз при загрузке формы
|
||||||
|
const [claimId] = useState(() => {
|
||||||
|
const date = new Date().toISOString().split('T')[0];
|
||||||
|
const randomId = Math.random().toString(36).substr(2, 6).toUpperCase();
|
||||||
|
return `CLM-${date}-${randomId}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Генерируем session_id и сохраняем в sessionStorage
|
||||||
|
const [sessionId] = useState(() => {
|
||||||
|
let sid = sessionStorage.getItem('session_id');
|
||||||
|
if (!sid) {
|
||||||
|
sid = `sess-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
sessionStorage.setItem('session_id', sid);
|
||||||
|
}
|
||||||
|
return sid;
|
||||||
|
});
|
||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [formData, setFormData] = useState<FormData>({
|
const [formData, setFormData] = useState<FormData>({
|
||||||
voucher: '',
|
voucher: '',
|
||||||
@@ -44,11 +61,23 @@ export default function ClaimForm() {
|
|||||||
type,
|
type,
|
||||||
status,
|
status,
|
||||||
message,
|
message,
|
||||||
data
|
data: {
|
||||||
|
...data,
|
||||||
|
claim_id: claimId // Добавляем claim_id во все события
|
||||||
|
}
|
||||||
};
|
};
|
||||||
setDebugEvents(prev => [event, ...prev]);
|
setDebugEvents(prev => [event, ...prev]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Логируем генерацию claim_id и session_id при первой загрузке
|
||||||
|
useState(() => {
|
||||||
|
addDebugEvent('system', 'info', `🆔 Сгенерирован Claim ID: ${claimId}`, {
|
||||||
|
claim_id: claimId,
|
||||||
|
session_id: sessionId,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const updateFormData = (data: Partial<FormData>) => {
|
const updateFormData = (data: Partial<FormData>) => {
|
||||||
setFormData({ ...formData, ...data });
|
setFormData({ ...formData, ...data });
|
||||||
};
|
};
|
||||||
@@ -110,7 +139,7 @@ export default function ClaimForm() {
|
|||||||
title: 'Проверка полиса',
|
title: 'Проверка полиса',
|
||||||
content: (
|
content: (
|
||||||
<Step1Policy
|
<Step1Policy
|
||||||
formData={formData}
|
formData={{ ...formData, claim_id: claimId }}
|
||||||
updateFormData={updateFormData}
|
updateFormData={updateFormData}
|
||||||
onNext={nextStep}
|
onNext={nextStep}
|
||||||
addDebugEvent={addDebugEvent}
|
addDebugEvent={addDebugEvent}
|
||||||
@@ -121,7 +150,7 @@ export default function ClaimForm() {
|
|||||||
title: 'Детали происшествия',
|
title: 'Детали происшествия',
|
||||||
content: (
|
content: (
|
||||||
<Step2Details
|
<Step2Details
|
||||||
formData={formData}
|
formData={{ ...formData, claim_id: claimId }}
|
||||||
updateFormData={updateFormData}
|
updateFormData={updateFormData}
|
||||||
onNext={nextStep}
|
onNext={nextStep}
|
||||||
onPrev={prevStep}
|
onPrev={prevStep}
|
||||||
@@ -133,7 +162,7 @@ export default function ClaimForm() {
|
|||||||
title: 'Телефон и выплата',
|
title: 'Телефон и выплата',
|
||||||
content: (
|
content: (
|
||||||
<Step3Payment
|
<Step3Payment
|
||||||
formData={formData}
|
formData={{ ...formData, claim_id: claimId }}
|
||||||
updateFormData={updateFormData}
|
updateFormData={updateFormData}
|
||||||
onPrev={prevStep}
|
onPrev={prevStep}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
|||||||
118
frontend/src/utils/pdfConverter.ts
Normal file
118
frontend/src/utils/pdfConverter.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Конвертация любых файлов в оптимизированный PDF
|
||||||
|
*/
|
||||||
|
import jsPDF from 'jspdf';
|
||||||
|
import imageCompression from 'browser-image-compression';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертирует файл в оптимизированный PDF
|
||||||
|
*
|
||||||
|
* @param file - Исходный файл (JPG, PNG, HEIC, PDF)
|
||||||
|
* @returns PDF File (сжатый и оптимизированный)
|
||||||
|
*/
|
||||||
|
export async function convertToPDF(file: File): Promise<File> {
|
||||||
|
// Если уже PDF - проверяем размер
|
||||||
|
if (file.type === 'application/pdf') {
|
||||||
|
const sizeMB = file.size / (1024 * 1024);
|
||||||
|
console.log(`📄 File is already PDF: ${file.name} (${sizeMB.toFixed(2)}MB)`);
|
||||||
|
|
||||||
|
// Если PDF больше 10MB - отклоняем
|
||||||
|
if (sizeMB > 10) {
|
||||||
|
throw new Error(
|
||||||
|
`❌ PDF файл слишком большой: ${sizeMB.toFixed(1)} MB.\n\n` +
|
||||||
|
`Максимальный размер: 10 MB.\n` +
|
||||||
|
`Пожалуйста, сожмите PDF перед загрузкой (например, на https://www.ilovepdf.com/compress_pdf)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если PDF больше 5MB - предупреждаем
|
||||||
|
if (sizeMB > 5) {
|
||||||
|
console.warn(`⚠️ Large PDF: ${sizeMB.toFixed(2)}MB - will be sent to server compression`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Конвертируем изображения в PDF
|
||||||
|
if (file.type.startsWith('image/') || file.name.match(/\.(heic|heif)$/i)) {
|
||||||
|
console.log('🖼️ Converting image to PDF:', file.name, `(${file.type || 'unknown type'})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Для HEIC/HEIF браузер может не знать MIME type
|
||||||
|
// browser-image-compression автоматически конвертирует в JPEG
|
||||||
|
|
||||||
|
// 1. Сжимаем изображение (макс 2MB, 2000px)
|
||||||
|
const compressed = await imageCompression(file, {
|
||||||
|
maxSizeMB: 2,
|
||||||
|
maxWidthOrHeight: 2000,
|
||||||
|
useWebWorker: true,
|
||||||
|
fileType: 'image/jpeg' // Конвертируем всё в JPEG для PDF
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Compressed: ${(file.size / 1024 / 1024).toFixed(2)}MB → ${(compressed.size / 1024 / 1024).toFixed(2)}MB`);
|
||||||
|
|
||||||
|
// 2. Получаем data URL
|
||||||
|
const dataUrl = await imageCompression.getDataUrlFromFile(compressed);
|
||||||
|
|
||||||
|
// 3. Создаём PDF документ (A4)
|
||||||
|
const pdf = new jsPDF({
|
||||||
|
orientation: 'portrait',
|
||||||
|
unit: 'mm',
|
||||||
|
format: 'a4',
|
||||||
|
compress: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Получаем размеры изображения
|
||||||
|
const imgProps = pdf.getImageProperties(dataUrl);
|
||||||
|
const pdfWidth = pdf.internal.pageSize.getWidth();
|
||||||
|
const pdfHeight = pdf.internal.pageSize.getHeight();
|
||||||
|
|
||||||
|
// Подгоняем под размер A4 с сохранением пропорций
|
||||||
|
const imgWidth = pdfWidth;
|
||||||
|
const imgHeight = (imgProps.height * pdfWidth) / imgProps.width;
|
||||||
|
|
||||||
|
// Если изображение выше A4 - уменьшаем
|
||||||
|
const finalHeight = imgHeight > pdfHeight ? pdfHeight : imgHeight;
|
||||||
|
const finalWidth = imgHeight > pdfHeight ? (imgProps.width * pdfHeight) / imgProps.height : imgWidth;
|
||||||
|
|
||||||
|
// 5. Добавляем изображение на страницу
|
||||||
|
pdf.addImage(dataUrl, 'JPEG', 0, 0, finalWidth, finalHeight);
|
||||||
|
|
||||||
|
// 6. Получаем PDF blob
|
||||||
|
const pdfBlob = pdf.output('blob');
|
||||||
|
|
||||||
|
// 7. Создаём новый File объект
|
||||||
|
const pdfFileName = file.name.replace(/\.(jpg|jpeg|png|heic|heif)$/i, '.pdf');
|
||||||
|
const pdfFile = new File([pdfBlob], pdfFileName, {
|
||||||
|
type: 'application/pdf',
|
||||||
|
lastModified: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ PDF created: ${pdfFileName} (${(pdfFile.size / 1024 / 1024).toFixed(2)}MB)`);
|
||||||
|
|
||||||
|
return pdfFile;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ PDF conversion error:', error);
|
||||||
|
throw new Error('Ошибка конвертации в PDF');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOCX/DOC не поддерживается в браузере
|
||||||
|
if (file.name.match(/\.(doc|docx)$/i)) {
|
||||||
|
console.warn('⚠️ DOCX files - n8n will convert');
|
||||||
|
return file; // Отправляем как есть, n8n сконвертирует
|
||||||
|
}
|
||||||
|
|
||||||
|
// Неподдерживаемый формат
|
||||||
|
console.error('❌ Unsupported file type:', file.type);
|
||||||
|
throw new Error(`Неподдерживаемый формат файла: ${file.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет нужна ли конвертация
|
||||||
|
*/
|
||||||
|
export function needsConversion(file: File): boolean {
|
||||||
|
return !file.type.includes('pdf');
|
||||||
|
}
|
||||||
|
|
||||||
57
monitor_redis.py
Executable file
57
monitor_redis.py
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Redis Channel Monitor - слушает события на каналах ocr_events:*
|
||||||
|
"""
|
||||||
|
import redis
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
def monitor_redis_channels():
|
||||||
|
try:
|
||||||
|
# Подключаемся к Redis
|
||||||
|
r = redis.Redis(host='crm.clientright.ru', port=6379, decode_responses=True)
|
||||||
|
|
||||||
|
# Создаем PubSub объект
|
||||||
|
pubsub = r.pubsub()
|
||||||
|
|
||||||
|
# Подписываемся на pattern
|
||||||
|
pubsub.psubscribe('ocr_events:*')
|
||||||
|
|
||||||
|
print(f"🔊 Слушаем Redis каналы: ocr_events:*")
|
||||||
|
print(f"⏰ Начало мониторинга: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
print("=" * 80)
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Слушаем сообщения
|
||||||
|
for message in pubsub.listen():
|
||||||
|
if message['type'] == 'pmessage':
|
||||||
|
channel = message['channel']
|
||||||
|
data = message['data']
|
||||||
|
timestamp = datetime.now().strftime('%H:%M:%S.%f')[:-3]
|
||||||
|
|
||||||
|
print(f"⏰ {timestamp}")
|
||||||
|
print(f"📢 Канал: {channel}")
|
||||||
|
|
||||||
|
# Пытаемся распарсить JSON
|
||||||
|
try:
|
||||||
|
parsed_data = json.loads(data)
|
||||||
|
print(f"📦 Данные:")
|
||||||
|
print(json.dumps(parsed_data, indent=2, ensure_ascii=False))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(f"📦 Данные (raw): {data}")
|
||||||
|
|
||||||
|
print("-" * 80)
|
||||||
|
print()
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\n⛔ Мониторинг остановлен")
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Ошибка: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
monitor_redis_channels()
|
||||||
|
|
||||||
|
|
||||||
15
monitor_redis.sh
Executable file
15
monitor_redis.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Redis Channel Monitor - слушает события на каналах ocr_events:*
|
||||||
|
|
||||||
|
REDIS_PASSWORD="CRM_Redis_Pass_2025_Secure!"
|
||||||
|
|
||||||
|
echo "🔊 Слушаем Redis каналы: ocr_events:*"
|
||||||
|
echo "⏰ Начало мониторинга: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
echo "================================================================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
redis-cli -h crm.clientright.ru -p 6379 -a "$REDIS_PASSWORD" PSUBSCRIBE 'ocr_events:*' 2>/dev/null | while read -r line; do
|
||||||
|
timestamp=$(date '+%H:%M:%S.%3N')
|
||||||
|
echo "[$timestamp] $line"
|
||||||
|
done
|
||||||
|
|
||||||
14
start_worker.sh
Executable file
14
start_worker.sh
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Запуск OCR Worker
|
||||||
|
|
||||||
|
cd /var/www/fastuser/data/www/crm.clientright.ru/erv_platform/backend
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
echo "🚀 Starting OCR Worker..."
|
||||||
|
python -m app.workers.ocr_worker
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
86
test_redis_events.sh
Executable file
86
test_redis_events.sh
Executable file
@@ -0,0 +1,86 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Тест Redis Pub/Sub через HTTP эндпоинты
|
||||||
|
|
||||||
|
TASK_ID="test-$(date +%s)"
|
||||||
|
BASE_URL="http://localhost:8100/api/v1"
|
||||||
|
|
||||||
|
echo "🧪 Testing Redis Pub/Sub Events"
|
||||||
|
echo "================================"
|
||||||
|
echo "Task ID: $TASK_ID"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# В фоне запускаем SSE подписку
|
||||||
|
echo "📡 Starting SSE listener..."
|
||||||
|
curl -N "$BASE_URL/events/$TASK_ID" &
|
||||||
|
SSE_PID=$!
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Публикуем события
|
||||||
|
echo ""
|
||||||
|
echo "📢 Publishing events..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "1️⃣ Processing started..."
|
||||||
|
curl -X POST "$BASE_URL/events/$TASK_ID" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"status": "processing",
|
||||||
|
"message": "Начата обработка файла",
|
||||||
|
"data": {"filename": "test.pdf"}
|
||||||
|
}' | jq '.'
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "2️⃣ OCR started..."
|
||||||
|
curl -X POST "$BASE_URL/events/$TASK_ID" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"status": "ocr_started",
|
||||||
|
"message": "Запущено распознавание текста",
|
||||||
|
"data": {}
|
||||||
|
}' | jq '.'
|
||||||
|
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "3️⃣ OCR completed..."
|
||||||
|
curl -X POST "$BASE_URL/events/$TASK_ID" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"status": "ocr_completed",
|
||||||
|
"message": "Распознано 1500 символов",
|
||||||
|
"data": {"chars": 1500}
|
||||||
|
}' | jq '.'
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "4️⃣ Completed..."
|
||||||
|
curl -X POST "$BASE_URL/events/$TASK_ID" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"status": "completed",
|
||||||
|
"message": "Обработка завершена",
|
||||||
|
"data": {
|
||||||
|
"document_type": "policy",
|
||||||
|
"is_valid": true,
|
||||||
|
"confidence": 0.95
|
||||||
|
}
|
||||||
|
}' | jq '.'
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Убиваем SSE слушатель
|
||||||
|
echo ""
|
||||||
|
echo "🛑 Stopping SSE listener..."
|
||||||
|
kill $SSE_PID 2>/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Test completed!"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user