import { useState, useEffect, useRef } from 'react'; import { Form, Input, Button, message, Upload, Spin, Alert, Modal } from 'antd'; import { FileProtectOutlined, UploadOutlined, LoadingOutlined } from '@ant-design/icons'; import type { UploadFile } from 'antd/es/upload/interface'; import { convertToPDF } from '../../utils/pdfConverter'; interface Props { formData: any; updateFormData: (data: any) => void; onNext: () => void; addDebugEvent?: (type: string, status: string, message: string, data?: any) => void; } // Расширенная функция автозамены кириллицы на латиницу const cyrillicToLatin = (text: string): string => { const map: Record = { 'А': 'A', 'а': 'A', 'В': 'B', 'в': 'B', 'С': 'C', 'с': 'C', 'Е': 'E', 'е': 'E', 'Н': 'H', 'н': 'H', 'К': 'K', 'к': 'K', 'М': 'M', 'м': 'M', 'О': 'O', 'о': 'O', 'Р': 'P', 'р': 'P', 'Т': 'T', 'т': 'T', 'Х': 'X', 'х': 'X', 'У': 'Y', 'у': 'Y' }; return text.split('').map(char => map[char] || char).join(''); }; // Функция форматирования полиса с маской E1000-302538524 const formatVoucher = (value: string): string => { // Удаляем все кроме букв и цифр const cleaned = value.replace(/[^A-Za-z0-9]/g, ''); // Применяем автозамену кириллицы и uppercase const latinUpper = cyrillicToLatin(cleaned).toUpperCase(); // Применяем маску: буква + 4 цифры + тире + 9 цифр if (latinUpper.length <= 1) { return latinUpper; } else if (latinUpper.length <= 5) { return latinUpper; } else if (latinUpper.length <= 14) { return latinUpper.slice(0, 5) + '-' + latinUpper.slice(5); } else { return latinUpper.slice(0, 5) + '-' + latinUpper.slice(5, 14); } }; export default function Step1Policy({ formData, updateFormData, onNext, addDebugEvent }: Props) { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const [policyNotFound, setPolicyNotFound] = useState(false); const [fileList, setFileList] = useState([]); const [uploading, setUploading] = useState(false); const [waitingForOcr, setWaitingForOcr] = useState(false); // ⬅️ НОВЫЙ state для ожидания SSE! const [uploadProgress, setUploadProgress] = useState(''); const [, setOcrResult] = useState(null); const [ocrModalVisible, setOcrModalVisible] = useState(false); // ⬅️ Видимость модалки const [ocrModalContent, setOcrModalContent] = useState(null); // ⬅️ Контент модалки const eventSourceRef = useRef(null); // SSE подключение для получения результатов OCR/Vision useEffect(() => { const claimId = formData.claim_id; if (!claimId || !waitingForOcr) { console.log('🔍 SSE useEffect: условие не выполнено', { claimId, waitingForOcr }); return; } console.log('🔌 SSE: Открываю соединение к', `/events/${claimId}`); // Открываем модалку с крутилкой setOcrModalVisible(true); setOcrModalContent('loading'); // Подключаемся к SSE для получения результатов OCR (через Vite proxy) const eventSource = new EventSource(`/events/${claimId}`); eventSourceRef.current = eventSource; console.log('✅ SSE: EventSource создан'); eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); console.log('📨 SSE event received:', data); if (data.event_type === 'ocr_completed') { console.log('✅ SSE: Получил событие ocr_completed!', data); setUploadProgress(''); setUploading(false); setWaitingForOcr(false); // Останавливаем ожидание setOcrResult(data); // Обрабатываем формат от n8n: data.output.is_policy или data.is_valid_document const aiOutput = data.data?.output || data.data; const isValidPolicy = aiOutput?.is_policy === 'yes' || data.data?.is_valid_document === true; // Обновляем содержимое модалки на результат (вместо крутилки) setOcrModalContent({ success: isValidPolicy, data: aiOutput, message: data.message }); if (data.status === 'completed' || data.status === 'success') { const policyNumber = aiOutput?.policy_number || 'неизвестно'; const holderName = aiOutput?.policyholder_full_name || ''; const insuredPersons = aiOutput?.insured_persons || []; if (isValidPolicy) { // ✅ Полис распознан - логируем в Debug Panel addDebugEvent?.('ocr_ai_result', 'success', `✅ AI анализ завершён`, { policy_number: policyNumber, holder: holderName, insured_persons: insuredPersons, policy_period: aiOutput?.policy_period, program_name: aiOutput?.program_name, full_ai_output: aiOutput }); // Сохраняем извлечённые AI данные updateFormData({ policyAiData: aiOutput, policyNumber: policyNumber, holderName: holderName }); } else { // ❌ Не полис addDebugEvent?.('ocr', 'error', '❌ Документ не является полисом ERV', aiOutput); setFileList([]); setPolicyNotFound(true); } } else { // Ошибка обработки addDebugEvent?.('ocr', 'error', data.message || 'Ошибка OCR', data.data); setFileList([]); setPolicyNotFound(true); } } } catch (error) { console.error('SSE parse error:', error); } }; eventSource.onerror = (error) => { console.error('❌ SSE connection error:', error); console.error('SSE readyState:', eventSource.readyState); // Не показываем ошибку если уже получили результат (backend закрыл SSE после успешной отправки) setOcrModalContent((prev) => { if (prev && prev !== 'loading') { console.log('✅ SSE закрыто после получения результата, не показываем ошибку'); return prev; // Оставляем текущий результат } return { success: false, data: null, message: 'Ошибка подключения к серверу' }; }); setWaitingForOcr(false); eventSource.close(); }; eventSource.onopen = () => { console.log('✅ SSE: Соединение открыто!'); }; return () => { if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } }; }, [formData.claim_id, waitingForOcr]); // Обработчик изменения поля полиса с автозаменой и маской const handleVoucherChange = (e: React.ChangeEvent) => { const formatted = formatVoucher(e.target.value); form.setFieldValue('voucher', formatted); }; // Обработчик paste для корректной обработки вставки const handleVoucherPaste = (e: React.ClipboardEvent) => { e.preventDefault(); const pastedText = e.clipboardData.getData('text'); const formatted = formatVoucher(pastedText); form.setFieldValue('voucher', formatted); }; const checkPolicy = async () => { try { const values = await form.validateFields(['voucher']); setLoading(true); setPolicyNotFound(false); addDebugEvent?.('policy_check', 'pending', `Проверяю полис: ${values.voucher}`, { voucher: values.voucher }); // Проверка полиса через n8n вебхук + создание записи в БД const response = await fetch('https://n8n.clientright.pro/webhook/9eb7bc5b-645f-477d-a5d8-5a346260a265', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ claim_id: formData.claim_id, // Передаём claim_id для создания записи policy_number: values.voucher, session_id: sessionStorage.getItem('session_id') || 'unknown' }), }); const result = await response.json(); if (response.ok) { // Новый формат ответа от n8n: {claim: {...}, policy: {...}} const policyFound = result.policy?.found === 1 || result.policy?.found === true; if (policyFound) { // Полис найден - переходим дальше addDebugEvent?.('policy_check', 'success', `✅ Полис найден в MySQL БД`, { found: true, claim: result.claim, policy: result.policy, voucher: values.voucher }); message.success(`Полис найден: ${result.policy.voucher}. Застрахованных: ${result.policy.count} чел.`); updateFormData(values); onNext(); } else { // Полис НЕ найден - показываем загрузку скана addDebugEvent?.('policy_check', 'warning', `▲ Полис не найден → требуется загрузка скана`, { found: false, claim: result.claim, message: result.policy?.message || 'Полис не найден', voucher: values.voucher }); message.warning('Полис не найден в базе. Загрузите скан полиса'); setPolicyNotFound(true); } } else { addDebugEvent?.('policy_check', 'error', `❌ Ошибка API: ${result.detail}`, { error: result.detail }); message.error(result.detail || 'Ошибка проверки полиса'); } } catch (error: any) { if (error.errorFields) { message.error('Заполните все обязательные поля'); } else { message.error('Ошибка соединения с сервером'); } } finally { setLoading(false); } }; const handleUploadChange = ({ fileList: newFileList }: any) => { setFileList(newFileList); }; // OCR теперь обрабатывается в n8n (через RabbitMQ + Redis Pub/Sub) // Polling не нужен! const handleSubmitWithScan = async () => { if (fileList.length === 0) { message.error('Загрузите скан полиса'); return; } if (fileList.length > 10) { message.error('Максимум 10 файлов'); return; } try { setUploading(true); setUploadProgress('📤 Подготавливаем документы...'); const values = await form.validateFields(['voucher']); addDebugEvent?.('upload', 'pending', `📤 Загружаю ${fileList.length} файл(ов) в S3 через n8n...`, { count: fileList.length }); // Генерируем claim_id если его нет const claimId = formData.claim_id || `CLM-${new Date().toISOString().split('T')[0]}-${Math.random().toString(36).substr(2, 6).toUpperCase()}`; // Загружаем каждый файл через n8n вебхук const uploadedFiles = []; for (let i = 0; i < fileList.length; i++) { const file = fileList[i]; if (!file.originFileObj) continue; // 🔄 Конвертируем в PDF перед отправкой let pdfFile: File; try { setUploadProgress(`🔄 Конвертируем ${file.name} в PDF...`); addDebugEvent?.('convert', 'pending', `🔄 Конвертирую ${file.name} в PDF...`, { original_size: `${(file.originFileObj.size / 1024 / 1024).toFixed(2)} MB`, original_type: file.originFileObj.type }); pdfFile = await convertToPDF(file.originFileObj); addDebugEvent?.('convert', 'success', `✅ PDF готов: ${pdfFile.name}`, { pdf_size: `${(pdfFile.size / 1024 / 1024).toFixed(2)} MB` }); } catch (error: any) { addDebugEvent?.('convert', 'error', `❌ Ошибка конвертации: ${error.message}`); message.error('Ошибка конвертации файла'); continue; } const uploadFormData = new FormData(); uploadFormData.append('claim_id', claimId); uploadFormData.append('file_type', 'policy_scan'); uploadFormData.append('filename', pdfFile.name); // PDF имя uploadFormData.append('voucher', values.voucher); uploadFormData.append('session_id', sessionStorage.getItem('session_id') || 'unknown'); uploadFormData.append('upload_timestamp', new Date().toISOString()); uploadFormData.append('file', pdfFile); // PDF файл! setUploadProgress(`📡 Загружаем ${pdfFile.name} в облако...`); const uploadResponse = await fetch('https://n8n.clientright.pro/webhook/7e2abc64-eaca-4671-86e4-12786700fe95', { method: 'POST', body: uploadFormData, }); setUploadProgress(`🔍 Распознаём текст и проверяем документ...`); const uploadResult = await uploadResponse.json(); // Логируем ответ от n8n для отладки console.log('n8n upload response:', uploadResult); const resultData = Array.isArray(uploadResult) ? uploadResult[0] : uploadResult; if (resultData?.success) { uploadedFiles.push({ filename: file.name, success: true }); } else { console.error('Upload failed for file:', file.name, 'Response:', uploadResult); } } const uploadResult = { success: uploadedFiles.length > 0, uploaded_count: uploadedFiles.length, total_count: fileList.length, files: uploadedFiles }; if (uploadResult.success) { addDebugEvent?.('upload', 'success', `✅ Загружено в S3: ${uploadResult.uploaded_count}/${uploadResult.total_count}`, { uploaded_count: uploadResult.uploaded_count, files: uploadResult.files }); // OCR запустится автоматически в n8n workflow (параллельно) addDebugEvent?.('ocr', 'pending', `🔄 OCR запущен в фоне через n8n`, { claim_id: claimId, message: 'Обработка продолжается асинхронно' }); updateFormData({ ...values, claim_id: claimId, policyScanUploaded: true, policyScanFiles: uploadResult.files, policyValidationWarning: '' // Silent validation }); // ⏳ Включаем режим ожидания SSE результата! console.log('🔄 Устанавливаю waitingForOcr=true для claim_id:', claimId); setWaitingForOcr(true); // ⬅️ Это откроет SSE соединение в useEffect! setUploadProgress('⏳ Ждём результат распознавания полиса...'); message.info('Файл загружен. Ожидаем результат OCR и AI анализа...'); console.log('📡 waitingForOcr установлен в true, useEffect должен сработать!'); // SSE событие обработается в useEffect и покажет модалку // НЕ вызываем onNext() здесь! } else { addDebugEvent?.('upload', 'error', `❌ Ошибка загрузки файлов`, { error: 'Upload failed' }); message.error('Ошибка загрузки файлов'); } } catch (error) { message.error('Ошибка загрузки файлов'); console.error(error); } finally { setUploading(false); setUploadProgress(''); } }; return (
} placeholder="E1000-302538524" size="large" onChange={handleVoucherChange} onPaste={handleVoucherPaste} maxLength={15} /> {!policyNotFound && ( )} {policyNotFound && ( <>

⚠️ Полис не найден в базе данных

Загрузите скан/фото полиса для продолжения

{ // Проверка размера (макс 15MB для сырого файла) 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}: неподдерживаемый формат. Используйте JPG, PNG, PDF, HEIC или WEBP`); return Upload.LIST_IGNORE; } return false; // Не загружать автоматически }} accept="image/*,.pdf,.heic,.heif,.webp" multiple={false} maxCount={1} showUploadList={{ showPreviewIcon: true, showRemoveIcon: true, }} >
Поддерживаются: JPG, PNG, HEIC, WEBP, PDF (макс 15MB) {fileList.length > 0 && ( (автоконвертация в PDF) )}
{/* Прогресс обработки */} {uploading && uploadProgress && ( } />} style={{ marginBottom: 16 }} /> )}
)} {!policyNotFound && (

💡 Введите номер полиса. Кириллица автоматически заменяется на латиницу, тире вставляется автоматически

)} {/* Модальное окно ожидания OCR результата */} { setOcrModalVisible(false); onNext(); // Переход на следующий шаг }}> Продолжить → ] : [ // ❌ Полис не распознан - кнопка "Загрузить другой файл" ] } width={700} centered > {ocrModalContent === 'loading' ? (
} />

⏳ Обрабатываем документ

OCR распознавание текста...

AI анализ содержимого...

Проверка валидности полиса...

Это может занять 20-30 секунд. Пожалуйста, подождите...

) : ocrModalContent ? (

{ocrModalContent.success ? '✅ Результат распознавания' : '❌ Ошибка распознавания'}

{ocrModalContent.success ? (

Номер полиса: {ocrModalContent.data?.policy_number || 'н/д'}

Владелец: {ocrModalContent.data?.policyholder_full_name || 'н/д'}

{ocrModalContent.data?.insured_persons?.length > 0 && ( <>

Застрахованные лица:

    {ocrModalContent.data.insured_persons.map((person: any, i: number) => (
  • {person.full_name} (ДР: {person.birth_date || 'н/д'})
  • ))}
)} {ocrModalContent.data?.policy_period && (

Период: {ocrModalContent.data.policy_period.insured_from} - {ocrModalContent.data.policy_period.insured_to}

)}

Полный ответ AI:

                  {JSON.stringify(ocrModalContent.data, null, 2)}
                
) : (

{ocrModalContent.message || 'Документ не распознан'}

Полный ответ:

                  {JSON.stringify(ocrModalContent.data, null, 2)}
                
)}
) : null}
{/* 🔧 Технические кнопки для разработки */}
🔧 DEV MODE - Быстрая навигация (без валидации)
); } )} {/* Прогресс обработки */} {uploading && uploadProgress && ( } />} style={{ marginBottom: 16 }} /> )}
)} {!policyNotFound && (

💡 Введите номер полиса. Кириллица автоматически заменяется на латиницу, тире вставляется автоматически

)} {/* Модальное окно ожидания OCR результата */} { setOcrModalVisible(false); onNext(); // Переход на следующий шаг }}> Продолжить → ] : [ // ❌ Полис не распознан - кнопка "Загрузить другой файл" ] } width={700} centered > {ocrModalContent === 'loading' ? (
} />

⏳ Обрабатываем документ

OCR распознавание текста...

AI анализ содержимого...

Проверка валидности полиса...

Это может занять 20-30 секунд. Пожалуйста, подождите...

) : ocrModalContent ? (

{ocrModalContent.success ? '✅ Результат распознавания' : '❌ Ошибка распознавания'}

{ocrModalContent.success ? (

Номер полиса: {ocrModalContent.data?.policy_number || 'н/д'}

Владелец: {ocrModalContent.data?.policyholder_full_name || 'н/д'}

{ocrModalContent.data?.insured_persons?.length > 0 && ( <>

Застрахованные лица:

    {ocrModalContent.data.insured_persons.map((person: any, i: number) => (
  • {person.full_name} (ДР: {person.birth_date || 'н/д'})
  • ))}
)} {ocrModalContent.data?.policy_period && (

Период: {ocrModalContent.data.policy_period.insured_from} - {ocrModalContent.data.policy_period.insured_to}

)}

Полный ответ AI:

                  {JSON.stringify(ocrModalContent.data, null, 2)}
                
) : (

{ocrModalContent.message || 'Документ не распознан'}

Полный ответ:

                  {JSON.stringify(ocrModalContent.data, null, 2)}
                
)}
) : null}
{/* 🔧 Технические кнопки для разработки */}
🔧 DEV MODE - Быстрая навигация (без валидации)
); }