fix: 3 критических исправления - OCR прогресс, условные поля, убран некорректный статус
1. ✅ OCR Progress Bar: - Добавлен polling OCR результатов каждые 3 сек - Визуальный индикатор: 🔍 Обработка OCR... (1/10) - Progress bar с анимацией - Статусы: 🔄 Запуск → 🔍 Обработка → ✅ Завершен - Gemini Vision результаты в Debug панели 2. ✅ Убран некорректный 'Полис найден': - Было: показывался сразу после загрузки файла - Проблема: OCR еще не закончился, может быть шляпа - Решение: убрана зеленая плашка с Step2 - Статус полиса только после реальной проверки 3. ✅ Условные поля для стыковочного рейса: - Если выбран 'miss_connection' → показываются 4 доп поля: • Номер рейса прибытия • Дата рейса прибытия • Номер рейса отправления • Дата рейса отправления - Если выбран 'cancel_flight' → доп поле: • Подтверждение отмены от АК - Для обычных рейсов: только номер рейса Frontend изменения: - Step1Policy: OCR polling, progress bar - Step2Details: условная логика полей (как в erv_ticket) - useState для eventType - handleEventTypeChange для динамики Теперь: ✅ Видно прогресс OCR ✅ Видно результаты Gemini Vision ✅ Условные поля работают ✅ Нет ложных статусов
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Form, Input, Button, message, Upload } from 'antd';
|
||||
import { FileProtectOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import { Form, Input, Button, message, Upload, Progress } from 'antd';
|
||||
import { FileProtectOutlined, UploadOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
|
||||
interface Props {
|
||||
@@ -56,6 +56,7 @@ 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 handleVoucherChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -130,6 +131,60 @@ 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 обрабатывается в фоне...');
|
||||
};
|
||||
|
||||
const handleSubmitWithScan = async () => {
|
||||
if (fileList.length === 0) {
|
||||
message.error('Загрузите скан полиса');
|
||||
@@ -173,6 +228,10 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
||||
|
||||
// Проверяем 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}`, {
|
||||
@@ -180,32 +239,10 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
||||
filename: firstFile.filename
|
||||
});
|
||||
|
||||
// Если есть OCR результат
|
||||
if (firstFile.ocr_result) {
|
||||
const ocr = firstFile.ocr_result;
|
||||
setOcrProgress('🔄 Запуск OCR...');
|
||||
|
||||
addDebugEvent?.('ocr', 'success', `📄 OCR завершен: ${ocr.ocr_text?.length || 0} символов`, {
|
||||
text: ocr.ocr_text?.substring(0, 300)
|
||||
});
|
||||
|
||||
if (ocr.ai_analysis) {
|
||||
const isGarbage = ocr.document_type === 'garbage';
|
||||
|
||||
addDebugEvent?.(
|
||||
'ai_analysis',
|
||||
isGarbage ? 'warning' : 'success',
|
||||
isGarbage
|
||||
? `🗑️ ШЛЯПА DETECTED! (пользователю не говорим)`
|
||||
: `🤖 AI: ${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
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
// Запускаем polling в фоне (не блокируем переход)
|
||||
pollOcrResults(fileIds);
|
||||
}
|
||||
|
||||
updateFormData({
|
||||
@@ -327,6 +364,31 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
||||
</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>
|
||||
)}
|
||||
|
||||
<Form.Item>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
|
||||
@@ -87,6 +87,17 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
||||
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}
|
||||
@@ -94,21 +105,6 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
||||
initialValues={formData}
|
||||
style={{ marginTop: 24 }}
|
||||
>
|
||||
{/* Индикатор что полис найден */}
|
||||
{formData.voucher && (
|
||||
<div style={{
|
||||
padding: 12,
|
||||
background: '#f6ffed',
|
||||
border: '1px solid #b7eb8f',
|
||||
borderRadius: 8,
|
||||
marginBottom: 24,
|
||||
color: '#52c41a',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
✅ Полис найден
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label="Выберите тип события"
|
||||
name="eventType"
|
||||
@@ -117,6 +113,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
||||
<Select
|
||||
placeholder="Выберите тип события"
|
||||
size="large"
|
||||
onChange={handleEventTypeChange}
|
||||
>
|
||||
{EVENT_TYPES.map(type => (
|
||||
<Option key={type.value} value={type.value}>
|
||||
@@ -140,16 +137,107 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Номер рейса/поезда/парома"
|
||||
name="transportNumber"
|
||||
rules={[{ required: true, message: 'Введите номер' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="Введите номер"
|
||||
size="large"
|
||||
/>
|
||||
</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;
|
||||
}
|
||||
return false;
|
||||
}}
|
||||
accept="image/*,.pdf,.heic,.heif"
|
||||
multiple
|
||||
maxCount={5}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} size="large" block>
|
||||
Загрузить подтверждение отмены
|
||||
</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label="Подтверждающие документы"
|
||||
|
||||
Reference in New Issue
Block a user