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 { useState } from 'react';
|
||||||
import { Form, Input, Button, message, Upload } from 'antd';
|
import { Form, Input, Button, message, Upload, Progress } from 'antd';
|
||||||
import { FileProtectOutlined, UploadOutlined } from '@ant-design/icons';
|
import { FileProtectOutlined, UploadOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd/es/upload/interface';
|
import type { UploadFile } from 'antd/es/upload/interface';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -56,6 +56,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
|||||||
const [policyNotFound, setPolicyNotFound] = useState(false);
|
const [policyNotFound, setPolicyNotFound] = useState(false);
|
||||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [ocrProgress, setOcrProgress] = useState<string>('');
|
||||||
|
|
||||||
// Обработчик изменения поля полиса с автозаменой и маской
|
// Обработчик изменения поля полиса с автозаменой и маской
|
||||||
const handleVoucherChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleVoucherChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -130,6 +131,60 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
|||||||
setFileList(newFileList);
|
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 () => {
|
const handleSubmitWithScan = async () => {
|
||||||
if (fileList.length === 0) {
|
if (fileList.length === 0) {
|
||||||
message.error('Загрузите скан полиса');
|
message.error('Загрузите скан полиса');
|
||||||
@@ -173,6 +228,10 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
|||||||
|
|
||||||
// Проверяем OCR результаты
|
// Проверяем OCR результаты
|
||||||
if (uploadResult.files && uploadResult.files.length > 0) {
|
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];
|
const firstFile = uploadResult.files[0];
|
||||||
|
|
||||||
addDebugEvent?.('ocr', 'pending', `🔍 Запущен OCR для: ${firstFile.filename}`, {
|
addDebugEvent?.('ocr', 'pending', `🔍 Запущен OCR для: ${firstFile.filename}`, {
|
||||||
@@ -180,32 +239,10 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
|||||||
filename: firstFile.filename
|
filename: firstFile.filename
|
||||||
});
|
});
|
||||||
|
|
||||||
// Если есть OCR результат
|
setOcrProgress('🔄 Запуск OCR...');
|
||||||
if (firstFile.ocr_result) {
|
|
||||||
const ocr = firstFile.ocr_result;
|
|
||||||
|
|
||||||
addDebugEvent?.('ocr', 'success', `📄 OCR завершен: ${ocr.ocr_text?.length || 0} символов`, {
|
// Запускаем polling в фоне (не блокируем переход)
|
||||||
text: ocr.ocr_text?.substring(0, 300)
|
pollOcrResults(fileIds);
|
||||||
});
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateFormData({
|
updateFormData({
|
||||||
@@ -327,6 +364,31 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
|||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
</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>
|
<Form.Item>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -87,6 +87,17 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
|||||||
setFileList(newFileList);
|
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}
|
||||||
@@ -94,21 +105,6 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
|||||||
initialValues={formData}
|
initialValues={formData}
|
||||||
style={{ marginTop: 24 }}
|
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
|
<Form.Item
|
||||||
label="Выберите тип события"
|
label="Выберите тип события"
|
||||||
name="eventType"
|
name="eventType"
|
||||||
@@ -117,6 +113,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
|||||||
<Select
|
<Select
|
||||||
placeholder="Выберите тип события"
|
placeholder="Выберите тип события"
|
||||||
size="large"
|
size="large"
|
||||||
|
onChange={handleEventTypeChange}
|
||||||
>
|
>
|
||||||
{EVENT_TYPES.map(type => (
|
{EVENT_TYPES.map(type => (
|
||||||
<Option key={type.value} value={type.value}>
|
<Option key={type.value} value={type.value}>
|
||||||
@@ -140,16 +137,107 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
{/* Для стыковочного рейса - номер рейса прибытия */}
|
||||||
label="Номер рейса/поезда/парома"
|
{showConnectionFields && (
|
||||||
name="transportNumber"
|
<Form.Item
|
||||||
rules={[{ required: true, message: 'Введите номер' }]}
|
label="Укажите номер рейса прибытия"
|
||||||
>
|
name="arrivalFlightNumber"
|
||||||
<Input
|
rules={[{ required: true, message: 'Введите номер рейса прибытия' }]}
|
||||||
placeholder="Введите номер"
|
>
|
||||||
size="large"
|
<Input
|
||||||
/>
|
placeholder="Введите номер"
|
||||||
</Form.Item>
|
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
|
<Form.Item
|
||||||
label="Подтверждающие документы"
|
label="Подтверждающие документы"
|
||||||
|
|||||||
Reference in New Issue
Block a user