feat: Умная форма Step2 с автоматическим распознаванием документов

🤖 Переход на OCR/AI для извлечения данных из документов:

 Изменения:
- Убран ручной ввод полей (дата, номер рейса и тд)
- Добавлена умная загрузка документов в зависимости от типа события
- Каждый тип документа получает уникальный file_type для n8n
- Валидация обязательных документов перед переходом

📋 Типы документов и их file_type:
1. Задержка рейса:
   - flight_delay_boarding_or_ticket (обяз)
   - flight_delay_confirmation (обяз)

2. Отмена рейса:
   - flight_cancel_ticket (обяз)
   - flight_cancel_notice (обяз)

3. Пропуск стыковки:
   - connection_arrival_boarding (обяз)
   - connection_departure_boarding_or_ticket (обяз)
   - connection_delay_proof (опц)

4. Поезд (задержка):
   - train_ticket (обяз)
   - train_delay_proof (обяз)

5. Поезд (отмена):
   - train_ticket (обяз)
   - train_cancel_proof (обяз)

6. Паром:
   - ferry_ticket (обяз)
   - ferry_delay_proof (обяз)

7. Запасной аэродром:
   - emergency_boarding_or_ticket (обяз)
   - emergency_landing_proof (обяз)

🔑 file_type позволяет n8n разделять потоки и применять разные AI промпты
для каждого типа документа.

Backup старой версии: Step2Details.OLD_MANUAL_INPUT.tsx
This commit is contained in:
AI Assistant
2025-10-28 12:03:12 +03:00
parent 6fe14598a1
commit 122af07779

View File

@@ -1,6 +1,6 @@
import { Form, Input, Button, Select, DatePicker, Upload, message, Spin, Alert } from 'antd';
import { UploadOutlined, LoadingOutlined } from '@ant-design/icons';
import { useState } from 'react';
import { Form, Button, Select, Upload, message, Spin, Alert, Card } from 'antd';
import { UploadOutlined, LoadingOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { useState, useEffect, useRef } from 'react';
import type { UploadFile } from 'antd/es/upload/interface';
import dayjs from 'dayjs';
@@ -14,105 +14,276 @@ interface Props {
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: 'miss_connection', label: 'Пропуск (задержка прибытия) стыковочного рейса' },
{ value: 'emergency_landing', label: 'Посадка воздушного судна на запасной аэродром' },
{ value: 'delay_train', label: 'Задержка отправки поезда' },
{ value: 'cancel_train', label: 'Отмена поезда' },
{ value: 'delay_ferry', label: 'Задержка/отмена отправки парома/круизного судна' },
];
// Конфигурация документов для каждого типа события с уникальными file_type
const DOCUMENT_CONFIGS: Record<string, any[]> = {
delay_flight: [
{
name: "Посадочный талон или Билет",
field: "boarding_or_ticket",
file_type: "flight_delay_boarding_or_ticket",
required: true,
maxFiles: 1,
description: "Boarding pass или ticket/booking confirmation"
},
{
name: "Подтверждение задержки",
field: "delay_confirmation",
file_type: "flight_delay_confirmation",
required: true,
maxFiles: 3,
description: "Справка от АК, email/SMS, или фото табло"
}
],
cancel_flight: [
{
name: "Билет",
field: "ticket",
file_type: "flight_cancel_ticket",
required: true,
maxFiles: 1,
description: "Ticket/booking confirmation"
},
{
name: "Уведомление об отмене",
field: "cancellation_notice",
file_type: "flight_cancel_notice",
required: true,
maxFiles: 3,
description: "Email, SMS или скриншот из приложения АК"
}
],
miss_connection: [
{
name: "Посадочный талон рейса ПРИБЫТИЯ",
field: "arrival_boarding",
file_type: "connection_arrival_boarding",
required: true,
maxFiles: 1,
description: "Boarding pass рейса, который задержался"
},
{
name: "Посадочный талон ИЛИ Билет рейса ОТПРАВЛЕНИЯ",
field: "departure_boarding_or_ticket",
file_type: "connection_departure_boarding_or_ticket",
required: true,
maxFiles: 1,
description: "Boarding pass (если успели) ИЛИ билет (если не успели)"
},
{
name: "Доказательство задержки (опционально)",
field: "delay_proof",
file_type: "connection_delay_proof",
required: false,
maxFiles: 5,
description: "Справка, фото табло, email/SMS"
}
],
delay_train: [
{
name: "Билет на поезд",
field: "train_ticket",
file_type: "train_ticket",
required: true,
maxFiles: 1,
description: "Билет РЖД или другого перевозчика"
},
{
name: "Подтверждение задержки",
field: "delay_proof",
file_type: "train_delay_proof",
required: true,
maxFiles: 3,
description: "Справка от РЖД, фото табло, скриншот приложения"
}
],
cancel_train: [
{
name: "Билет на поезд",
field: "train_ticket",
file_type: "train_ticket",
required: true,
maxFiles: 1,
description: "Билет РЖД или другого перевозчика"
},
{
name: "Подтверждение отмены",
field: "cancel_proof",
file_type: "train_cancel_proof",
required: true,
maxFiles: 3,
description: "Справка от РЖД, фото табло, скриншот приложения"
}
],
delay_ferry: [
{
name: "Билет на паром/круиз",
field: "ferry_ticket",
file_type: "ferry_ticket",
required: true,
maxFiles: 1,
description: "Билет или booking confirmation"
},
{
name: "Подтверждение задержки/отмены",
field: "delay_proof",
file_type: "ferry_delay_proof",
required: true,
maxFiles: 3,
description: "Справка от перевозчика, фото расписания, email/SMS"
}
],
emergency_landing: [
{
name: "Посадочный талон или Билет",
field: "boarding_or_ticket",
file_type: "emergency_boarding_or_ticket",
required: true,
maxFiles: 1,
description: "Boarding pass или ticket"
},
{
name: "Подтверждение посадки на запасной аэродром",
field: "emergency_proof",
file_type: "emergency_landing_proof",
required: true,
maxFiles: 3,
description: "Справка от АК, email/SMS, документы"
}
]
};
export default function Step2Details({ formData, updateFormData, onNext, onPrev, addDebugEvent }: Props) {
const [form] = Form.useForm();
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [eventType, setEventType] = useState(formData.eventType || '');
const [documentFiles, setDocumentFiles] = useState<Record<string, UploadFile[]>>({});
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState('');
const [waitingForOcr, setWaitingForOcr] = useState(false);
const [ocrResults, setOcrResults] = useState<any>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const handleEventTypeChange = (value: string) => {
setEventType(value);
setDocumentFiles({}); // Очищаем загруженные файлы при смене типа
form.setFieldValue('eventType', value);
};
// Получаем конфигурацию документов для выбранного типа события
const currentDocuments = eventType ? DOCUMENT_CONFIGS[eventType] || [] : [];
const handleUploadChange = (field: string, { fileList: newFileList }: any) => {
setDocumentFiles(prev => ({
...prev,
[field]: newFileList
}));
};
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
});
// Проверяем что все обязательные документы загружены
const missingDocs = currentDocuments.filter(doc =>
doc.required && (!documentFiles[doc.field] || documentFiles[doc.field].length === 0)
);
if (missingDocs.length > 0) {
message.error(`Загрузите обязательные документы: ${missingDocs.map(d => d.name).join(', ')}`);
return;
}
// Используем claim_id из formData (уже сгенерирован в Step1)
const claimId = formData.claim_id;
// Загружаем все документы в S3 через n8n
setUploading(true);
setUploadProgress('📤 Загружаем документы...');
const claimId = formData.claim_id;
const uploadedFiles: any[] = [];
// Загружаем каждый документ через n8n вебхук
const uploadedFiles = [];
for (const docConfig of currentDocuments) {
const files = documentFiles[docConfig.field] || [];
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (!file.originFileObj) continue;
setUploadProgress(`📡 Загружаем документ ${i + 1} из ${fileList.length}: ${file.name}...`);
setUploadProgress(`📡 Загружаем: ${docConfig.name} (${i + 1}/${files.length})...`);
const uploadFormData = new FormData();
uploadFormData.append('claim_id', claimId);
uploadFormData.append('file_type', `document_${i + 1}`); // document_1, document_2, etc
uploadFormData.append('file_type', docConfig.file_type); // 🔑 Уникальный file_type для n8n
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);
addDebugEvent?.('upload', 'pending', `📤 Загружаю документ: ${docConfig.name} (${docConfig.file_type})`, {
file_type: docConfig.file_type,
filename: file.name
});
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,
file_type: docConfig.file_type,
field: docConfig.field,
success: true
});
addDebugEvent?.('upload', 'success', `✅ Документ загружен: ${docConfig.name}`, {
file_type: docConfig.file_type,
filename: file.name
});
}
}
}
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;
}
if (uploadedFiles.length > 0) {
setUploadProgress('🤖 AI анализирует документы...');
updateFormData({
...values,
uploadedDocuments: uploadedFiles
});
// TODO: Здесь будет ожидание SSE события с результатами OCR/AI
// Пока просто переходим дальше
setUploadProgress('');
setUploading(false);
message.success(`Загружено документов: ${uploadedFiles.length}. Переходим дальше...`);
onNext();
} else {
message.error('Не удалось загрузить документы');
setUploading(false);
setUploadProgress('');
} else {
updateFormData(values);
}
onNext();
} catch (error) {
message.error('Заполните все обязательные поля');
setUploading(false);
@@ -120,21 +291,6 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
}
};
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}
@@ -160,178 +316,85 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
</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: 'Введите номер рейса прибытия' }]}
{/* Показываем документы только после выбора типа события */}
{eventType && currentDocuments.length > 0 && (
<Card
title="📋 Загрузите документы для обработки"
style={{ marginTop: 24 }}
headStyle={{ background: '#f0f9ff', borderBottom: '2px solid #91d5ff' }}
>
<Input
placeholder="Введите номер"
size="large"
/>
</Form.Item>
)}
<div style={{ marginBottom: 16, padding: 12, background: '#e6f7ff', borderRadius: 8 }}>
<p style={{ margin: 0, fontSize: 13, color: '#0050b3' }}>
💡 Просто загрузите документы наш AI автоматически распознает все данные
(номера рейсов, даты, время, причины задержек)
</p>
</div>
{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;
}
{currentDocuments.map((doc, index) => (
<div key={doc.field} style={{ marginBottom: 24 }}>
<div style={{ marginBottom: 8 }}>
<span style={{ fontSize: 15, fontWeight: 500 }}>
{doc.required ? '✅' : ''} {doc.name}
{doc.required && <span style={{ color: '#ff4d4f', marginLeft: 4 }}>*</span>}
</span>
</div>
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>
)}
<div style={{ marginBottom: 8, fontSize: 12, color: '#666' }}>
💡 {doc.description}
</div>
<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>
<Upload
listType="picture"
fileList={documentFiles[doc.field] || []}
onChange={(info) => handleUploadChange(doc.field, info)}
beforeUpload={(file) => {
const isLt15M = file.size / 1024 / 1024 < 15;
if (!isLt15M) {
message.error(`${file.name}: файл больше 15MB`);
return Upload.LIST_IGNORE;
}
const currentFiles = documentFiles[doc.field] || [];
if (currentFiles.length >= doc.maxFiles) {
message.error(`Максимум ${doc.maxFiles} файл(ов) для этого документа`);
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={doc.maxFiles > 1}
maxCount={doc.maxFiles}
showUploadList={{
showPreviewIcon: true,
showRemoveIcon: true,
}}
>
<Button
icon={<UploadOutlined />}
size="large"
block
disabled={(documentFiles[doc.field] || []).length >= doc.maxFiles}
>
Загрузить файл{doc.maxFiles > 1 ? 'ы' : ''} (до {doc.maxFiles} шт, макс 15MB)
</Button>
</Upload>
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
Загружено: {(documentFiles[doc.field] || []).length}/{doc.maxFiles} файл(ов)
</div>
</div>
))}
</Card>
)}
{/* Прогресс обработки */}
{uploading && uploadProgress && (
@@ -384,8 +447,6 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
// Пропускаем валидацию, заполняем минимальные данные
const devData = {
eventType: 'delay_flight',
incidentDate: dayjs(),
transportNumber: 'TEST123',
};
updateFormData(devData);
onNext();