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:
AI Assistant
2025-10-27 08:33:16 +03:00
parent 1be922fdc3
commit 647abf6578
20 changed files with 2177 additions and 168 deletions

292
N8N_INTEGRATION.md Normal file
View 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
View 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
View 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
View 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
View 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
}
)

View File

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

View File

@@ -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 анализ - что это за документ?

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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,27 +225,81 @@ 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();
method: 'POST', uploadFormData.append('claim_id', claimId);
body: formData, 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) { if (uploadResult.success) {
addDebugEvent?.('upload', 'success', `✅ Загружено в S3: ${uploadResult.uploaded_count}/${uploadResult.total_count}`, { 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 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,57 +406,55 @@ 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>
@@ -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>

View File

@@ -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); // Загружаем каждый документ через 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', { const uploadResult = {
method: 'POST', success: uploadedFiles.length > 0,
body: formData, uploaded_count: uploadedFiles.length,
}); total_count: fileList.length,
files: uploadedFiles
const uploadResult = await uploadResponse.json(); };
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>

View File

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

View 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
View 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
View 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
View 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
View 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!"