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.policy_service import policy_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(
|
||||
@@ -98,6 +98,7 @@ app.include_router(claims.router)
|
||||
app.include_router(policy.router)
|
||||
app.include_router(upload.router)
|
||||
app.include_router(draft.router)
|
||||
app.include_router(events.router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from ..config import settings
|
||||
import json
|
||||
from .s3_service import s3_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,25 +40,104 @@ class OCRService:
|
||||
}
|
||||
|
||||
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}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
files = {"file": (filename, file_content, "image/jpeg")}
|
||||
# Определяем file_type по расширению (OCR API требует строку!)
|
||||
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(
|
||||
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:
|
||||
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
|
||||
|
||||
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:
|
||||
logger.error(f"❌ OCR failed: {response.status_code}")
|
||||
logger.error(f"Response: {response.text[:500]}")
|
||||
return result
|
||||
|
||||
# Шаг 2: AI анализ - что это за документ?
|
||||
|
||||
@@ -51,6 +51,13 @@ class RedisService:
|
||||
else:
|
||||
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:
|
||||
"""Удалить ключ"""
|
||||
full_key = f"{settings.redis_prefix}{key}"
|
||||
|
||||
@@ -64,18 +64,22 @@ class S3Service:
|
||||
unique_id = str(uuid.uuid4())[:8]
|
||||
safe_filename = f"{folder}/{timestamp}_{unique_id}_{filename}"
|
||||
|
||||
# Загружаем файл
|
||||
# Загружаем файл с публичным доступом (для OCR)
|
||||
# ВРЕМЕННОЕ РЕШЕНИЕ: делаем файлы публичными пока presigned URL не работает
|
||||
acl = 'public-read' if folder == 'ocr_temp' else 'private'
|
||||
|
||||
self.client.put_object(
|
||||
Bucket=self.bucket,
|
||||
Key=safe_filename,
|
||||
Body=file_content,
|
||||
ContentType=content_type
|
||||
ContentType=content_type,
|
||||
ACL=acl # Делаем ocr_temp файлы публичными
|
||||
)
|
||||
|
||||
# Генерируем URL
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
@@ -98,6 +102,51 @@ class S3Service:
|
||||
logger.error(f"❌ S3 delete error: {e}")
|
||||
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()
|
||||
|
||||
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
|
||||
boto3==1.35.56
|
||||
aioboto3==13.2.0
|
||||
|
||||
# Validation
|
||||
pydantic==2.10.0
|
||||
|
||||
@@ -25,7 +25,9 @@
|
||||
"imask": "^7.6.1",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"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": {
|
||||
"@types/react": "^18.3.11",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Form, Input, Button, message, Upload, Progress } from 'antd';
|
||||
import { FileProtectOutlined, UploadOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Form, Input, Button, message, Upload, Spin, Alert, Modal } from 'antd';
|
||||
import { FileProtectOutlined, UploadOutlined, LoadingOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
import { convertToPDF } from '../../utils/pdfConverter';
|
||||
|
||||
interface Props {
|
||||
formData: any;
|
||||
@@ -56,7 +57,76 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
||||
const [policyNotFound, setPolicyNotFound] = useState(false);
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
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>) => {
|
||||
@@ -81,32 +151,40 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
||||
|
||||
addDebugEvent?.('policy_check', 'pending', `Проверяю полис: ${values.voucher}`, { voucher: values.voucher });
|
||||
|
||||
// Проверка полиса через API
|
||||
const response = await fetch('http://147.45.146.17:8100/api/v1/policy/check', {
|
||||
// Проверка полиса через n8n вебхук + создание записи в БД
|
||||
const response = await fetch('https://n8n.clientright.pro/webhook/9eb7bc5b-645f-477d-a5d8-5a346260a265', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
voucher: values.voucher,
|
||||
email: 'temp@check.com', // Email не требуется на этом шаге
|
||||
claim_id: formData.claim_id, // Передаём claim_id для создания записи
|
||||
policy_number: values.voucher,
|
||||
session_id: sessionStorage.getItem('session_id') || 'unknown'
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
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,
|
||||
claim: result.claim,
|
||||
policy: result.policy,
|
||||
voucher: values.voucher
|
||||
});
|
||||
message.success('Полис найден в базе данных');
|
||||
message.success(`Полис найден: ${result.policy.voucher}. Застрахованных: ${result.policy.count} чел.`);
|
||||
updateFormData(values);
|
||||
onNext();
|
||||
} else {
|
||||
// Полис НЕ найден - показываем загрузку скана
|
||||
addDebugEvent?.('policy_check', 'warning', `⚠️ Полис не найден → требуется загрузка скана`, {
|
||||
addDebugEvent?.('policy_check', 'warning', `▲ Полис не найден → требуется загрузка скана`, {
|
||||
found: false,
|
||||
claim: result.claim,
|
||||
message: result.policy?.message || 'Полис не найден',
|
||||
voucher: values.voucher
|
||||
});
|
||||
message.warning('Полис не найден в базе. Загрузите скан полиса');
|
||||
@@ -131,59 +209,8 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
||||
setFileList(newFileList);
|
||||
};
|
||||
|
||||
// Polling для получения OCR результатов
|
||||
const pollOcrResults = async (fileIds: string[]) => {
|
||||
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 обрабатывается в фоне...');
|
||||
};
|
||||
// OCR теперь обрабатывается в n8n (через RabbitMQ + Redis Pub/Sub)
|
||||
// Polling не нужен!
|
||||
|
||||
const handleSubmitWithScan = async () => {
|
||||
if (fileList.length === 0) {
|
||||
@@ -198,27 +225,81 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
setUploadProgress('📤 Подготавливаем документы...');
|
||||
const values = await form.validateFields(['voucher']);
|
||||
|
||||
addDebugEvent?.('upload', 'pending', `📤 Загружаю ${fileList.length} файл(ов) в S3...`, {
|
||||
addDebugEvent?.('upload', 'pending', `📤 Загружаю ${fileList.length} файл(ов) в S3 через n8n...`, {
|
||||
count: fileList.length
|
||||
});
|
||||
|
||||
// Загружаем файлы в S3 с OCR проверкой
|
||||
const formData = new FormData();
|
||||
fileList.forEach((file: any) => {
|
||||
if (file.originFileObj) {
|
||||
formData.append('files', file.originFileObj);
|
||||
// Генерируем claim_id если его нет
|
||||
const claimId = formData.claim_id || `CLM-${new Date().toISOString().split('T')[0]}-${Math.random().toString(36).substr(2, 6).toUpperCase()}`;
|
||||
|
||||
// Загружаем каждый файл через n8n вебхук
|
||||
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', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
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 файл!
|
||||
|
||||
const uploadResult = await uploadResponse.json();
|
||||
setUploadProgress(`📡 Загружаем ${pdfFile.name} в облако...`);
|
||||
const uploadResponse = await fetch('https://n8n.clientright.pro/webhook/7e2abc64-eaca-4671-86e4-12786700fe95', {
|
||||
method: 'POST',
|
||||
body: uploadFormData,
|
||||
});
|
||||
|
||||
setUploadProgress(`🔍 Распознаём текст и проверяем документ...`);
|
||||
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) {
|
||||
addDebugEvent?.('upload', 'success', `✅ Загружено в S3: ${uploadResult.uploaded_count}/${uploadResult.total_count}`, {
|
||||
@@ -226,27 +307,15 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
||||
files: uploadResult.files
|
||||
});
|
||||
|
||||
// Проверяем OCR результаты
|
||||
if (uploadResult.files && uploadResult.files.length > 0) {
|
||||
const fileIds = uploadResult.files
|
||||
.filter((f: any) => f.file_id)
|
||||
.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);
|
||||
}
|
||||
// OCR запустится автоматически в n8n workflow (параллельно)
|
||||
addDebugEvent?.('ocr', 'pending', `🔄 OCR запущен в фоне через n8n`, {
|
||||
claim_id: claimId,
|
||||
message: 'Обработка продолжается асинхронно'
|
||||
});
|
||||
|
||||
updateFormData({
|
||||
...values,
|
||||
claim_id: claimId,
|
||||
policyScanUploaded: true,
|
||||
policyScanFiles: uploadResult.files,
|
||||
policyValidationWarning: '' // Silent validation
|
||||
@@ -263,6 +332,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
||||
console.error(error);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setUploadProgress('');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -336,57 +406,55 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
||||
fileList={fileList}
|
||||
onChange={handleUploadChange}
|
||||
beforeUpload={(file) => {
|
||||
// Проверка размера (макс 15MB для сырого файла)
|
||||
const isLt15M = file.size / 1024 / 1024 < 15;
|
||||
if (!isLt15M) {
|
||||
message.error(`${file.name}: файл больше 15MB`);
|
||||
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 false;
|
||||
|
||||
return false; // Не загружать автоматически
|
||||
}}
|
||||
accept="image/*,.pdf,.heic,.heif"
|
||||
multiple
|
||||
maxCount={10}
|
||||
accept="image/*,.pdf,.heic,.heif,.webp"
|
||||
multiple={false}
|
||||
maxCount={1}
|
||||
showUploadList={{
|
||||
showPreviewIcon: true,
|
||||
showRemoveIcon: true,
|
||||
}}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} size="large" block disabled={fileList.length >= 10}>
|
||||
Выбрать файлы (до 10 шт, макс 15MB каждый)
|
||||
<Button icon={<UploadOutlined />} size="large" block disabled={fileList.length >= 1}>
|
||||
Загрузить скан полиса (JPG, PNG, HEIC, PDF)
|
||||
</Button>
|
||||
</Upload>
|
||||
<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>
|
||||
</Form.Item>
|
||||
|
||||
{/* OCR Progress */}
|
||||
{ocrProgress && (
|
||||
<div style={{
|
||||
padding: 16,
|
||||
background: '#f0f9ff',
|
||||
border: '1px solid #91d5ff',
|
||||
borderRadius: 8,
|
||||
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>
|
||||
{/* Прогресс обработки */}
|
||||
{uploading && uploadProgress && (
|
||||
<Alert
|
||||
message={uploadProgress}
|
||||
type="info"
|
||||
showIcon
|
||||
icon={<Spin indicator={<LoadingOutlined style={{ fontSize: 16 }} spin />} />}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form.Item>
|
||||
@@ -397,6 +465,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
||||
setFileList([]);
|
||||
}}
|
||||
size="large"
|
||||
disabled={uploading}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
@@ -407,7 +476,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
||||
size="large"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{uploading ? 'Загрузка...' : 'Продолжить со сканом'}
|
||||
{uploading ? 'Обрабатываем...' : 'Продолжить со сканом'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Form, Input, Button, Select, DatePicker, Upload, message } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import { Form, Input, Button, Select, DatePicker, Upload, message, Spin, Alert } from 'antd';
|
||||
import { UploadOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import { useState } from 'react';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -29,6 +29,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
||||
const [form] = Form.useForm();
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState('');
|
||||
|
||||
const handleNext = async () => {
|
||||
try {
|
||||
@@ -37,28 +38,61 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
||||
// Если есть файлы - загружаем
|
||||
if (fileList.length > 0) {
|
||||
setUploading(true);
|
||||
setUploadProgress('📤 Подготавливаем документы...');
|
||||
|
||||
addDebugEvent?.('upload', 'pending', `📤 Загружаю ${fileList.length} документ(ов) в S3...`, {
|
||||
addDebugEvent?.('upload', 'pending', `📤 Загружаю ${fileList.length} документ(ов) в S3 через n8n...`, {
|
||||
count: fileList.length
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
fileList.forEach((file: any) => {
|
||||
if (file.originFileObj) {
|
||||
formData.append('files', file.originFileObj);
|
||||
// Используем claim_id из formData (уже сгенерирован в Step1)
|
||||
const claimId = formData.claim_id;
|
||||
|
||||
// Загружаем каждый документ через 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',
|
||||
body: uploadFormData,
|
||||
});
|
||||
|
||||
setUploadProgress(`🔍 Обрабатываем документ ${i + 1} из ${fileList.length}...`);
|
||||
const uploadResult = await uploadResponse.json();
|
||||
|
||||
const resultData = Array.isArray(uploadResult) ? uploadResult[0] : uploadResult;
|
||||
if (resultData?.success) {
|
||||
uploadedFiles.push({
|
||||
filename: file.name,
|
||||
success: true
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const uploadResponse = await fetch('http://147.45.146.17:8100/api/v1/upload/files?folder=documents', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const uploadResult = await uploadResponse.json();
|
||||
const uploadResult = {
|
||||
success: uploadedFiles.length > 0,
|
||||
uploaded_count: uploadedFiles.length,
|
||||
total_count: fileList.length,
|
||||
files: uploadedFiles
|
||||
};
|
||||
|
||||
if (uploadResult.success) {
|
||||
addDebugEvent?.('upload', 'success', `✅ Документы загружены: ${uploadResult.uploaded_count}/${uploadResult.total_count}`, {
|
||||
files: uploadResult.files
|
||||
addDebugEvent?.('upload', 'success', `✅ Документы загружены через n8n: ${uploadResult.uploaded_count}/${uploadResult.total_count}`, {
|
||||
files: uploadResult.files,
|
||||
claim_id: claimId
|
||||
});
|
||||
|
||||
updateFormData({
|
||||
@@ -68,10 +102,12 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
||||
} else {
|
||||
message.error('Ошибка загрузки документов');
|
||||
setUploading(false);
|
||||
setUploadProgress('');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(false);
|
||||
setUploadProgress('');
|
||||
} else {
|
||||
updateFormData(values);
|
||||
}
|
||||
@@ -80,6 +116,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
||||
} catch (error) {
|
||||
message.error('Заполните все обязательные поля');
|
||||
setUploading(false);
|
||||
setUploadProgress('');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -226,9 +263,18 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
||||
message.error(`${file.name}: файл больше 15MB`);
|
||||
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;
|
||||
}}
|
||||
accept="image/*,.pdf,.heic,.heif"
|
||||
accept="image/*,.pdf,.heic,.heif,.webp"
|
||||
multiple
|
||||
maxCount={5}
|
||||
>
|
||||
@@ -254,13 +300,23 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
||||
message.error(`${file.name}: файл больше 15MB`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
if (fileList.length >= 10) {
|
||||
message.error('Максимум 10 файлов');
|
||||
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;
|
||||
}}
|
||||
accept="image/*,.pdf,.heic,.heif"
|
||||
accept="image/*,.pdf,.heic,.heif,.webp"
|
||||
multiple
|
||||
maxCount={10}
|
||||
showUploadList={{
|
||||
@@ -277,9 +333,20 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
||||
</div>
|
||||
</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>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 32 }}>
|
||||
<Button onClick={onPrev} size="large">Назад</Button>
|
||||
<Button onClick={onPrev} size="large" disabled={uploading}>Назад</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleNext}
|
||||
@@ -287,7 +354,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
||||
style={{ flex: 1 }}
|
||||
size="large"
|
||||
>
|
||||
{uploading ? 'Загрузка документов...' : 'Далее'}
|
||||
{uploading ? 'Обрабатываем...' : 'Далее'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
@@ -28,6 +28,23 @@ interface FormData {
|
||||
}
|
||||
|
||||
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 [formData, setFormData] = useState<FormData>({
|
||||
voucher: '',
|
||||
@@ -44,11 +61,23 @@ export default function ClaimForm() {
|
||||
type,
|
||||
status,
|
||||
message,
|
||||
data
|
||||
data: {
|
||||
...data,
|
||||
claim_id: claimId // Добавляем claim_id во все события
|
||||
}
|
||||
};
|
||||
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>) => {
|
||||
setFormData({ ...formData, ...data });
|
||||
};
|
||||
@@ -110,7 +139,7 @@ export default function ClaimForm() {
|
||||
title: 'Проверка полиса',
|
||||
content: (
|
||||
<Step1Policy
|
||||
formData={formData}
|
||||
formData={{ ...formData, claim_id: claimId }}
|
||||
updateFormData={updateFormData}
|
||||
onNext={nextStep}
|
||||
addDebugEvent={addDebugEvent}
|
||||
@@ -121,7 +150,7 @@ export default function ClaimForm() {
|
||||
title: 'Детали происшествия',
|
||||
content: (
|
||||
<Step2Details
|
||||
formData={formData}
|
||||
formData={{ ...formData, claim_id: claimId }}
|
||||
updateFormData={updateFormData}
|
||||
onNext={nextStep}
|
||||
onPrev={prevStep}
|
||||
@@ -133,7 +162,7 @@ export default function ClaimForm() {
|
||||
title: 'Телефон и выплата',
|
||||
content: (
|
||||
<Step3Payment
|
||||
formData={formData}
|
||||
formData={{ ...formData, claim_id: claimId }}
|
||||
updateFormData={updateFormData}
|
||||
onPrev={prevStep}
|
||||
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