Files
aiform_prod/frontend/src/components/form/Step1Policy.tsx
AI Assistant 647abf6578 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
2025-10-27 08:33:16 +03:00

496 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
updateFormData: (data: any) => void;
onNext: () => void;
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
}
// Расширенная функция автозамены кириллицы на латиницу
const cyrillicToLatin = (text: string): string => {
const map: Record<string, string> = {
'А': 'A', 'а': 'A',
'В': 'B', 'в': 'B',
'С': 'C', 'с': 'C',
'Е': 'E', 'е': 'E',
'Н': 'H', 'н': 'H',
'К': 'K', 'к': 'K',
'М': 'M', 'м': 'M',
'О': 'O', 'о': 'O',
'Р': 'P', 'р': 'P',
'Т': 'T', 'т': 'T',
'Х': 'X', 'х': 'X',
'У': 'Y', 'у': 'Y'
};
return text.split('').map(char => map[char] || char).join('');
};
// Функция форматирования полиса с маской E1000-302538524
const formatVoucher = (value: string): string => {
// Удаляем все кроме букв и цифр
const cleaned = value.replace(/[^A-Za-z0-9]/g, '');
// Применяем автозамену кириллицы и uppercase
const latinUpper = cyrillicToLatin(cleaned).toUpperCase();
// Применяем маску: буква + 4 цифры + тире + 9 цифр
if (latinUpper.length <= 1) {
return latinUpper;
} else if (latinUpper.length <= 5) {
return latinUpper;
} else if (latinUpper.length <= 14) {
return latinUpper.slice(0, 5) + '-' + latinUpper.slice(5);
} else {
return latinUpper.slice(0, 5) + '-' + latinUpper.slice(5, 14);
}
};
export default function Step1Policy({ formData, updateFormData, onNext, addDebugEvent }: Props) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [policyNotFound, setPolicyNotFound] = useState(false);
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [uploading, setUploading] = useState(false);
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 formatted = formatVoucher(e.target.value);
form.setFieldValue('voucher', formatted);
};
// Обработчик paste для корректной обработки вставки
const handleVoucherPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault();
const pastedText = e.clipboardData.getData('text');
const formatted = formatVoucher(pastedText);
form.setFieldValue('voucher', formatted);
};
const checkPolicy = async () => {
try {
const values = await form.validateFields(['voucher']);
setLoading(true);
setPolicyNotFound(false);
addDebugEvent?.('policy_check', 'pending', `Проверяю полис: ${values.voucher}`, { voucher: values.voucher });
// Проверка полиса через 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({
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) {
// Новый формат ответа от n8n: {claim: {...}, policy: {...}}
const policyFound = result.policy?.found === 1 || result.policy?.found === true;
if (policyFound) {
// Полис найден - переходим дальше
addDebugEvent?.('policy_check', 'success', `✅ Полис найден в MySQL БД`, {
found: true,
claim: result.claim,
policy: result.policy,
voucher: values.voucher
});
message.success(`Полис найден: ${result.policy.voucher}. Застрахованных: ${result.policy.count} чел.`);
updateFormData(values);
onNext();
} else {
// Полис НЕ найден - показываем загрузку скана
addDebugEvent?.('policy_check', 'warning', `▲ Полис не найден → требуется загрузка скана`, {
found: false,
claim: result.claim,
message: result.policy?.message || 'Полис не найден',
voucher: values.voucher
});
message.warning('Полис не найден в базе. Загрузите скан полиса');
setPolicyNotFound(true);
}
} else {
addDebugEvent?.('policy_check', 'error', `❌ Ошибка API: ${result.detail}`, { error: result.detail });
message.error(result.detail || 'Ошибка проверки полиса');
}
} catch (error: any) {
if (error.errorFields) {
message.error('Заполните все обязательные поля');
} else {
message.error('Ошибка соединения с сервером');
}
} finally {
setLoading(false);
}
};
const handleUploadChange = ({ fileList: newFileList }: any) => {
setFileList(newFileList);
};
// OCR теперь обрабатывается в n8n (через RabbitMQ + Redis Pub/Sub)
// Polling не нужен!
const handleSubmitWithScan = async () => {
if (fileList.length === 0) {
message.error('Загрузите скан полиса');
return;
}
if (fileList.length > 10) {
message.error('Максимум 10 файлов');
return;
}
try {
setUploading(true);
setUploadProgress('📤 Подготавливаем документы...');
const values = await form.validateFields(['voucher']);
addDebugEvent?.('upload', 'pending', `📤 Загружаю ${fileList.length} файл(ов) в S3 через n8n...`, {
count: fileList.length
});
// Генерируем 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;
}
const uploadFormData = new FormData();
uploadFormData.append('claim_id', claimId);
uploadFormData.append('file_type', 'policy_scan');
uploadFormData.append('filename', pdfFile.name); // PDF имя
uploadFormData.append('voucher', values.voucher);
uploadFormData.append('session_id', sessionStorage.getItem('session_id') || 'unknown');
uploadFormData.append('upload_timestamp', new Date().toISOString());
uploadFormData.append('file', pdfFile); // PDF файл!
setUploadProgress(`📡 Загружаем ${pdfFile.name} в облако...`);
const uploadResponse = await fetch('https://n8n.clientright.pro/webhook/7e2abc64-eaca-4671-86e4-12786700fe95', {
method: 'POST',
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}`, {
uploaded_count: uploadResult.uploaded_count,
files: uploadResult.files
});
// 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
});
message.success(`Загружено файлов: ${uploadResult.uploaded_count}`);
onNext();
} else {
addDebugEvent?.('upload', 'error', `❌ Ошибка загрузки файлов`, { error: 'Upload failed' });
message.error('Ошибка загрузки файлов');
}
} catch (error) {
message.error('Ошибка загрузки файлов');
console.error(error);
} finally {
setUploading(false);
setUploadProgress('');
}
};
return (
<Form
form={form}
layout="vertical"
initialValues={formData}
style={{ marginTop: 24 }}
>
<Form.Item
label="Номер полиса"
name="voucher"
rules={[
{ required: true, message: 'Введите номер полиса' },
{
pattern: /^[A-Z]\d{4}-\d{9}$/,
message: 'Формат: E1000-302538524'
}
]}
tooltip="Формат: E1000-302538524. Тире вставляется автоматически"
>
<Input
prefix={<FileProtectOutlined />}
placeholder="E1000-302538524"
size="large"
onChange={handleVoucherChange}
onPaste={handleVoucherPaste}
maxLength={15}
/>
</Form.Item>
{!policyNotFound && (
<Form.Item>
<Button
type="primary"
onClick={checkPolicy}
loading={loading}
size="large"
block
>
Проверить полис и продолжить
</Button>
</Form.Item>
)}
{policyNotFound && (
<>
<div style={{
marginBottom: 16,
padding: 16,
background: '#fff7e6',
borderRadius: 8,
border: '1px solid #ffa940'
}}>
<p style={{ margin: 0, color: '#d46b08', fontWeight: 500 }}>
Полис не найден в базе данных
</p>
<p style={{ margin: '8px 0 0 0', fontSize: 13, color: '#666' }}>
Загрузите скан/фото полиса для продолжения
</p>
</div>
<Form.Item
label="Скан полиса"
name="policyScan"
rules={[{ required: true, message: 'Загрузите скан полиса' }]}
>
<Upload
listType="picture"
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;
}
// Проверка формата
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; // Не загружать автоматически
}}
accept="image/*,.pdf,.heic,.heif,.webp"
multiple={false}
maxCount={1}
showUploadList={{
showPreviewIcon: true,
showRemoveIcon: true,
}}
>
<Button icon={<UploadOutlined />} size="large" block disabled={fileList.length >= 1}>
Загрузить скан полиса (JPG, PNG, HEIC, PDF)
</Button>
</Upload>
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
Поддерживаются: JPG, PNG, HEIC, WEBP, PDF (макс 15MB)
{fileList.length > 0 && (
<span style={{ marginLeft: 8, color: '#52c41a' }}>
(автоконвертация в PDF)
</span>
)}
</div>
</Form.Item>
{/* Прогресс обработки */}
{uploading && uploadProgress && (
<Alert
message={uploadProgress}
type="info"
showIcon
icon={<Spin indicator={<LoadingOutlined style={{ fontSize: 16 }} spin />} />}
style={{ marginBottom: 16 }}
/>
)}
<Form.Item>
<div style={{ display: 'flex', gap: 8 }}>
<Button
onClick={() => {
setPolicyNotFound(false);
setFileList([]);
}}
size="large"
disabled={uploading}
>
Отмена
</Button>
<Button
type="primary"
onClick={handleSubmitWithScan}
loading={uploading}
size="large"
style={{ flex: 1 }}
>
{uploading ? 'Обрабатываем...' : 'Продолжить со сканом'}
</Button>
</div>
</Form.Item>
</>
)}
{!policyNotFound && (
<div style={{ marginTop: 16, padding: 12, background: '#f0f9ff', borderRadius: 8 }}>
<p style={{ margin: 0, fontSize: 13, color: '#666' }}>
💡 Введите номер полиса. Кириллица автоматически заменяется на латиницу, тире вставляется автоматически
</p>
</div>
)}
</Form>
);
}