🎯 Основные изменения: 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
364 lines
13 KiB
TypeScript
364 lines
13 KiB
TypeScript
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';
|
||
|
||
const { Option } = Select;
|
||
|
||
interface Props {
|
||
formData: any;
|
||
updateFormData: (data: any) => void;
|
||
onNext: () => void;
|
||
onPrev: () => void;
|
||
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
|
||
}
|
||
|
||
// Типы страховых случаев из erv_ticket
|
||
const EVENT_TYPES = [
|
||
{ value: 'delay_flight', label: 'Задержка авиарейса (более 3 часов)' },
|
||
{ value: 'cancel_flight', label: 'Отмена авиарейса' },
|
||
{ value: 'miss_connection', label: 'Пропуск (задержка прибытия) стыковочного рейса (авиа/жд/паром и тд)' },
|
||
{ value: 'emergency_landing', label: 'Посадка воздушного судна на запасной аэродром' },
|
||
{ value: 'delay_train', label: 'Задержка отправки поезда' },
|
||
{ value: 'cancel_train', label: 'Отмена поезда' },
|
||
{ value: 'delay_ferry', label: 'Задержка/отмена отправки парома/круизного судна' },
|
||
];
|
||
|
||
export default function Step2Details({ formData, updateFormData, onNext, onPrev, addDebugEvent }: Props) {
|
||
const [form] = Form.useForm();
|
||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [uploadProgress, setUploadProgress] = useState('');
|
||
|
||
const handleNext = async () => {
|
||
try {
|
||
const values = await form.validateFields();
|
||
|
||
// Если есть файлы - загружаем
|
||
if (fileList.length > 0) {
|
||
setUploading(true);
|
||
setUploadProgress('📤 Подготавливаем документы...');
|
||
|
||
addDebugEvent?.('upload', 'pending', `📤 Загружаю ${fileList.length} документ(ов) в S3 через n8n...`, {
|
||
count: fileList.length
|
||
});
|
||
|
||
// Используем 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 uploadResult = {
|
||
success: uploadedFiles.length > 0,
|
||
uploaded_count: uploadedFiles.length,
|
||
total_count: fileList.length,
|
||
files: uploadedFiles
|
||
};
|
||
|
||
if (uploadResult.success) {
|
||
addDebugEvent?.('upload', 'success', `✅ Документы загружены через n8n: ${uploadResult.uploaded_count}/${uploadResult.total_count}`, {
|
||
files: uploadResult.files,
|
||
claim_id: claimId
|
||
});
|
||
|
||
updateFormData({
|
||
...values,
|
||
uploadedFiles: uploadResult.files
|
||
});
|
||
} else {
|
||
message.error('Ошибка загрузки документов');
|
||
setUploading(false);
|
||
setUploadProgress('');
|
||
return;
|
||
}
|
||
|
||
setUploading(false);
|
||
setUploadProgress('');
|
||
} else {
|
||
updateFormData(values);
|
||
}
|
||
|
||
onNext();
|
||
} catch (error) {
|
||
message.error('Заполните все обязательные поля');
|
||
setUploading(false);
|
||
setUploadProgress('');
|
||
}
|
||
};
|
||
|
||
const handleUploadChange = ({ fileList: newFileList }: any) => {
|
||
setFileList(newFileList);
|
||
};
|
||
|
||
const [eventType, setEventType] = useState(formData.eventType || '');
|
||
|
||
const handleEventTypeChange = (value: string) => {
|
||
setEventType(value);
|
||
form.setFieldValue('eventType', value);
|
||
};
|
||
|
||
// Проверяем нужны ли дополнительные поля для стыковочного рейса
|
||
const showConnectionFields = eventType === 'miss_connection';
|
||
const showCancelFlightDocs = eventType === 'cancel_flight';
|
||
|
||
return (
|
||
<Form
|
||
form={form}
|
||
layout="vertical"
|
||
initialValues={formData}
|
||
style={{ marginTop: 24 }}
|
||
>
|
||
<Form.Item
|
||
label="Выберите тип события"
|
||
name="eventType"
|
||
rules={[{ required: true, message: 'Выберите тип события' }]}
|
||
>
|
||
<Select
|
||
placeholder="Выберите тип события"
|
||
size="large"
|
||
onChange={handleEventTypeChange}
|
||
>
|
||
{EVENT_TYPES.map(type => (
|
||
<Option key={type.value} value={type.value}>
|
||
{type.label}
|
||
</Option>
|
||
))}
|
||
</Select>
|
||
</Form.Item>
|
||
|
||
<Form.Item
|
||
label="Дата наступления страхового случая"
|
||
name="incidentDate"
|
||
rules={[{ required: true, message: 'Укажите дату' }]}
|
||
>
|
||
<DatePicker
|
||
placeholder="Выберите дату"
|
||
size="large"
|
||
style={{ width: '100%' }}
|
||
format="DD.MM.YYYY"
|
||
disabledDate={(current) => current && current > dayjs().endOf('day')}
|
||
/>
|
||
</Form.Item>
|
||
|
||
{/* Для стыковочного рейса - номер рейса прибытия */}
|
||
{showConnectionFields && (
|
||
<Form.Item
|
||
label="Укажите номер рейса прибытия"
|
||
name="arrivalFlightNumber"
|
||
rules={[{ required: true, message: 'Введите номер рейса прибытия' }]}
|
||
>
|
||
<Input
|
||
placeholder="Введите номер"
|
||
size="large"
|
||
/>
|
||
</Form.Item>
|
||
)}
|
||
|
||
{showConnectionFields && (
|
||
<Form.Item
|
||
label="Дата рейса прибытия"
|
||
name="arrivalFlightDate"
|
||
rules={[{ required: true, message: 'Укажите дату прибытия' }]}
|
||
>
|
||
<DatePicker
|
||
placeholder="Выберите дату"
|
||
size="large"
|
||
style={{ width: '100%' }}
|
||
format="DD.MM.YYYY"
|
||
disabledDate={(current) => current && current > dayjs().endOf('day')}
|
||
/>
|
||
</Form.Item>
|
||
)}
|
||
|
||
{/* Для стыковочного рейса - номер рейса отправления */}
|
||
{showConnectionFields && (
|
||
<Form.Item
|
||
label="Укажите номер рейса отправления"
|
||
name="departureFlightNumber"
|
||
rules={[{ required: true, message: 'Введите номер рейса отправления' }]}
|
||
>
|
||
<Input
|
||
placeholder="Введите номер рейса отправления"
|
||
size="large"
|
||
/>
|
||
</Form.Item>
|
||
)}
|
||
|
||
{showConnectionFields && (
|
||
<Form.Item
|
||
label="Дата рейса отправления"
|
||
name="departureFlightDate"
|
||
rules={[{ required: true, message: 'Укажите дату отправления' }]}
|
||
>
|
||
<DatePicker
|
||
placeholder="Выберите дату"
|
||
size="large"
|
||
style={{ width: '100%' }}
|
||
format="DD.MM.YYYY"
|
||
disabledDate={(current) => current && current > dayjs().endOf('day')}
|
||
/>
|
||
</Form.Item>
|
||
)}
|
||
|
||
{/* Для обычных рейсов */}
|
||
{!showConnectionFields && (
|
||
<Form.Item
|
||
label="Номер рейса/поезда/парома"
|
||
name="transportNumber"
|
||
rules={[{ required: true, message: 'Введите номер' }]}
|
||
>
|
||
<Input
|
||
placeholder="Введите номер"
|
||
size="large"
|
||
/>
|
||
</Form.Item>
|
||
)}
|
||
|
||
{/* Дополнительные документы для отмены рейса */}
|
||
{showCancelFlightDocs && (
|
||
<Form.Item
|
||
label="Подтверждение уведомления об отмене рейса от АК"
|
||
name="cancelConfirmation"
|
||
tooltip="Уведомление от авиакомпании об отмене"
|
||
>
|
||
<Upload
|
||
listType="picture"
|
||
beforeUpload={(file) => {
|
||
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}: неподдерживаемый формат`);
|
||
return Upload.LIST_IGNORE;
|
||
}
|
||
|
||
return false;
|
||
}}
|
||
accept="image/*,.pdf,.heic,.heif,.webp"
|
||
multiple
|
||
maxCount={5}
|
||
>
|
||
<Button icon={<UploadOutlined />} size="large" block>
|
||
Загрузить подтверждение отмены
|
||
</Button>
|
||
</Upload>
|
||
</Form.Item>
|
||
)}
|
||
|
||
<Form.Item
|
||
label="Подтверждающие документы"
|
||
name="documents"
|
||
tooltip="Посадочный талон, билет, справка о задержке и т.д."
|
||
>
|
||
<Upload
|
||
listType="picture"
|
||
fileList={fileList}
|
||
onChange={handleUploadChange}
|
||
beforeUpload={(file) => {
|
||
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 файлов');
|
||
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,.webp"
|
||
multiple
|
||
maxCount={10}
|
||
showUploadList={{
|
||
showPreviewIcon: true,
|
||
showRemoveIcon: true,
|
||
}}
|
||
>
|
||
<Button icon={<UploadOutlined />} size="large" block disabled={fileList.length >= 10}>
|
||
Загрузить файлы (до 10 шт, макс 15MB каждый)
|
||
</Button>
|
||
</Upload>
|
||
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
|
||
Загружено: {fileList.length}/10 файлов
|
||
</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" disabled={uploading}>Назад</Button>
|
||
<Button
|
||
type="primary"
|
||
onClick={handleNext}
|
||
loading={uploading}
|
||
style={{ flex: 1 }}
|
||
size="large"
|
||
>
|
||
{uploading ? 'Обрабатываем...' : 'Далее'}
|
||
</Button>
|
||
</div>
|
||
</Form.Item>
|
||
</Form>
|
||
);
|
||
}
|