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 { Form, Button, Select, Upload, message, Spin, Alert, Card } from 'antd';
import { UploadOutlined, LoadingOutlined } from '@ant-design/icons'; import { UploadOutlined, LoadingOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { useState } from 'react'; import { useState, useEffect, useRef } 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';
@@ -14,105 +14,276 @@ interface Props {
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void; addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
} }
// Типы страховых случаев из erv_ticket // Типы страховых случаев
const EVENT_TYPES = [ const EVENT_TYPES = [
{ value: 'delay_flight', label: 'Задержка авиарейса (более 3 часов)' }, { value: 'delay_flight', label: 'Задержка авиарейса (более 3 часов)' },
{ value: 'cancel_flight', label: 'Отмена авиарейса' }, { value: 'cancel_flight', label: 'Отмена авиарейса' },
{ value: 'miss_connection', label: 'Пропуск (задержка прибытия) стыковочного рейса (авиа/жд/паром и тд)' }, { value: 'miss_connection', label: 'Пропуск (задержка прибытия) стыковочного рейса' },
{ value: 'emergency_landing', label: 'Посадка воздушного судна на запасной аэродром' }, { value: 'emergency_landing', label: 'Посадка воздушного судна на запасной аэродром' },
{ value: 'delay_train', label: 'Задержка отправки поезда' }, { value: 'delay_train', label: 'Задержка отправки поезда' },
{ value: 'cancel_train', label: 'Отмена поезда' }, { value: 'cancel_train', label: 'Отмена поезда' },
{ value: 'delay_ferry', 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) { export default function Step2Details({ formData, updateFormData, onNext, onPrev, addDebugEvent }: Props) {
const [form] = Form.useForm(); 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 [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(''); 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 () => { const handleNext = async () => {
try { try {
const values = await form.validateFields(); const values = await form.validateFields();
// Если есть файлы - загружаем // Проверяем что все обязательные документы загружены
if (fileList.length > 0) { const missingDocs = currentDocuments.filter(doc =>
setUploading(true); doc.required && (!documentFiles[doc.field] || documentFiles[doc.field].length === 0)
setUploadProgress('📤 Подготавливаем документы...'); );
addDebugEvent?.('upload', 'pending', `📤 Загружаю ${fileList.length} документ(ов) в S3 через n8n...`, { if (missingDocs.length > 0) {
count: fileList.length message.error(`Загрузите обязательные документы: ${missingDocs.map(d => d.name).join(', ')}`);
}); return;
}
// Используем claim_id из formData (уже сгенерирован в Step1) // Загружаем все документы в S3 через n8n
const claimId = formData.claim_id; setUploading(true);
setUploadProgress('📤 Загружаем документы...');
const claimId = formData.claim_id;
const uploadedFiles: any[] = [];
// Загружаем каждый документ через n8n вебхук for (const docConfig of currentDocuments) {
const uploadedFiles = []; const files = documentFiles[docConfig.field] || [];
for (let i = 0; i < fileList.length; i++) { for (let i = 0; i < files.length; i++) {
const file = fileList[i]; const file = files[i];
if (!file.originFileObj) continue; if (!file.originFileObj) continue;
setUploadProgress(`📡 Загружаем документ ${i + 1} из ${fileList.length}: ${file.name}...`); setUploadProgress(`📡 Загружаем: ${docConfig.name} (${i + 1}/${files.length})...`);
const uploadFormData = new FormData(); const uploadFormData = new FormData();
uploadFormData.append('claim_id', claimId); 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('filename', file.name);
uploadFormData.append('voucher', formData.voucher || ''); uploadFormData.append('voucher', formData.voucher || '');
uploadFormData.append('session_id', sessionStorage.getItem('session_id') || 'unknown'); uploadFormData.append('session_id', sessionStorage.getItem('session_id') || 'unknown');
uploadFormData.append('upload_timestamp', new Date().toISOString()); uploadFormData.append('upload_timestamp', new Date().toISOString());
uploadFormData.append('file', file.originFileObj); 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', { const uploadResponse = await fetch('https://n8n.clientright.pro/webhook/7e2abc64-eaca-4671-86e4-12786700fe95', {
method: 'POST', method: 'POST',
body: uploadFormData, body: uploadFormData,
}); });
setUploadProgress(`🔍 Обрабатываем документ ${i + 1} из ${fileList.length}...`);
const uploadResult = await uploadResponse.json(); const uploadResult = await uploadResponse.json();
const resultData = Array.isArray(uploadResult) ? uploadResult[0] : uploadResult; const resultData = Array.isArray(uploadResult) ? uploadResult[0] : uploadResult;
if (resultData?.success) { if (resultData?.success) {
uploadedFiles.push({ uploadedFiles.push({
filename: file.name, filename: file.name,
file_type: docConfig.file_type,
field: docConfig.field,
success: true success: true
}); });
addDebugEvent?.('upload', 'success', `✅ Документ загружен: ${docConfig.name}`, {
file_type: docConfig.file_type,
filename: file.name
});
} }
} }
}
const uploadResult = { if (uploadedFiles.length > 0) {
success: uploadedFiles.length > 0, setUploadProgress('🤖 AI анализирует документы...');
uploaded_count: uploadedFiles.length,
total_count: fileList.length, updateFormData({
files: uploadedFiles ...values,
}; uploadedDocuments: 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;
}
// TODO: Здесь будет ожидание SSE события с результатами OCR/AI
// Пока просто переходим дальше
setUploadProgress('');
setUploading(false);
message.success(`Загружено документов: ${uploadedFiles.length}. Переходим дальше...`);
onNext();
} else {
message.error('Не удалось загрузить документы');
setUploading(false); setUploading(false);
setUploadProgress(''); setUploadProgress('');
} else {
updateFormData(values);
} }
onNext();
} catch (error) { } catch (error) {
message.error('Заполните все обязательные поля'); message.error('Заполните все обязательные поля');
setUploading(false); 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 ( return (
<Form <Form
form={form} form={form}
@@ -160,178 +316,85 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item {/* Показываем документы только после выбора типа события */}
label="Дата наступления страхового случая" {eventType && currentDocuments.length > 0 && (
name="incidentDate" <Card
rules={[{ required: true, message: 'Укажите дату' }]} title="📋 Загрузите документы для обработки"
> style={{ marginTop: 24 }}
<DatePicker headStyle={{ background: '#f0f9ff', borderBottom: '2px solid #91d5ff' }}
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 <div style={{ marginBottom: 16, padding: 12, background: '#e6f7ff', borderRadius: 8 }}>
placeholder="Введите номер" <p style={{ margin: 0, fontSize: 13, color: '#0050b3' }}>
size="large" 💡 Просто загрузите документы наш AI автоматически распознает все данные
/> (номера рейсов, даты, время, причины задержек)
</Form.Item> </p>
)} </div>
{showConnectionFields && ( {currentDocuments.map((doc, index) => (
<Form.Item <div key={doc.field} style={{ marginBottom: 24 }}>
label="Дата рейса прибытия" <div style={{ marginBottom: 8 }}>
name="arrivalFlightDate" <span style={{ fontSize: 15, fontWeight: 500 }}>
rules={[{ required: true, message: 'Укажите дату прибытия' }]} {doc.required ? '✅' : ''} {doc.name}
> {doc.required && <span style={{ color: '#ff4d4f', marginLeft: 4 }}>*</span>}
<DatePicker </span>
placeholder="Выберите дату" </div>
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']; <div style={{ marginBottom: 8, fontSize: 12, color: '#666' }}>
const validExtensions = /\.(jpg|jpeg|png|pdf|heic|heif|webp)$/i; 💡 {doc.description}
</div>
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 <Upload
label="Подтверждающие документы" listType="picture"
name="documents" fileList={documentFiles[doc.field] || []}
tooltip="Посадочный талон, билет, справка о задержке и т.д." onChange={(info) => handleUploadChange(doc.field, info)}
> beforeUpload={(file) => {
<Upload const isLt15M = file.size / 1024 / 1024 < 15;
listType="picture" if (!isLt15M) {
fileList={fileList} message.error(`${file.name}: файл больше 15MB`);
onChange={handleUploadChange} return Upload.LIST_IGNORE;
beforeUpload={(file) => { }
const isLt15M = file.size / 1024 / 1024 < 15;
if (!isLt15M) { const currentFiles = documentFiles[doc.field] || [];
message.error(`${file.name}: файл больше 15MB`); if (currentFiles.length >= doc.maxFiles) {
return Upload.LIST_IGNORE; message.error(`Максимум ${doc.maxFiles} файл(ов) для этого документа`);
} return Upload.LIST_IGNORE;
}
if (fileList.length >= 10) {
message.error('Максимум 10 файлов'); const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'application/pdf'];
return Upload.LIST_IGNORE; const validExtensions = /\.(jpg|jpeg|png|pdf|heic|heif|webp)$/i;
}
if (!validTypes.includes(file.type) && !validExtensions.test(file.name)) {
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'application/pdf']; message.error(`${file.name}: неподдерживаемый формат`);
const validExtensions = /\.(jpg|jpeg|png|pdf|heic|heif|webp)$/i; return Upload.LIST_IGNORE;
}
if (!validTypes.includes(file.type) && !validExtensions.test(file.name)) {
message.error(`${file.name}: неподдерживаемый формат`); return false;
return Upload.LIST_IGNORE; }}
} accept="image/*,.pdf,.heic,.heif,.webp"
multiple={doc.maxFiles > 1}
return false; maxCount={doc.maxFiles}
}} showUploadList={{
accept="image/*,.pdf,.heic,.heif,.webp" showPreviewIcon: true,
multiple showRemoveIcon: true,
maxCount={10} }}
showUploadList={{ >
showPreviewIcon: true, <Button
showRemoveIcon: true, icon={<UploadOutlined />}
}} size="large"
> block
<Button icon={<UploadOutlined />} size="large" block disabled={fileList.length >= 10}> disabled={(documentFiles[doc.field] || []).length >= doc.maxFiles}
Загрузить файлы (до 10 шт, макс 15MB каждый) >
</Button> Загрузить файл{doc.maxFiles > 1 ? 'ы' : ''} (до {doc.maxFiles} шт, макс 15MB)
</Upload> </Button>
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}> </Upload>
Загружено: {fileList.length}/10 файлов
</div> <div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
</Form.Item> Загружено: {(documentFiles[doc.field] || []).length}/{doc.maxFiles} файл(ов)
</div>
</div>
))}
</Card>
)}
{/* Прогресс обработки */} {/* Прогресс обработки */}
{uploading && uploadProgress && ( {uploading && uploadProgress && (
@@ -384,8 +447,6 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
// Пропускаем валидацию, заполняем минимальные данные // Пропускаем валидацию, заполняем минимальные данные
const devData = { const devData = {
eventType: 'delay_flight', eventType: 'delay_flight',
incidentDate: dayjs(),
transportNumber: 'TEST123',
}; };
updateFormData(devData); updateFormData(devData);
onNext(); onNext();