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

View File

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

View File

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