1064 lines
43 KiB
Markdown
1064 lines
43 KiB
Markdown
# 📋 Лог сессии: Исправление SSE error handling
|
||
|
||
**Дата:** 28 октября 2025 (00:00 - 01:00 MSK)
|
||
**Задача:** Исправление ошибки "Ошибка подключения к серверу" при успешном распознавании полиса
|
||
**Статус:** ✅ Успешно завершено
|
||
|
||
---
|
||
|
||
## 🎯 Проблема
|
||
|
||
После успешного распознавания полиса через OCR/Vision, пользователь видел модальное окно с ошибкой:
|
||
```
|
||
❌ Ошибка распознавания
|
||
Ошибка подключения к серверу
|
||
|
||
Полный ответ: null
|
||
```
|
||
|
||
Хотя в логах backend видно, что:
|
||
- ✅ SSE подключение установлено
|
||
- ✅ Событие OCR получено из Redis
|
||
- ✅ Данные отправлены клиенту
|
||
- ✅ SSE соединение закрыто корректно
|
||
|
||
---
|
||
|
||
## 🔍 Диагностика
|
||
|
||
### Backend логи показывали успешную работу:
|
||
|
||
```
|
||
2025-10-28 00:41:15,187 - 🚀 SSE connection requested for task_id: CLM-2025-10-27-Y1KWA1
|
||
2025-10-28 00:41:15,202 - 📡 Client subscribed to ocr_events:CLM-2025-10-27-Y1KWA1
|
||
2025-10-28 00:41:15,203 - ⏳ Waiting for message on ocr_events:CLM-2025-10-27-Y1KWA1...
|
||
2025-10-28 00:41:49,729 - 📥 Received message type: message
|
||
2025-10-28 00:41:49,729 - 📦 Raw event data: {"claim_id":"CLM-2025-10-27-Y1KWA1","event":{"event_type":"ocr_completed","status":"completed","message":"OCR обработка завершена","data":{"output":{"is_policy":"yes","policy_number":"E1000-302545808"...
|
||
2025-10-28 00:41:49,730 - 📦 Unwrapped n8n Redis format for CLM-2025-10-27-Y1KWA1
|
||
2025-10-28 00:41:49,730 - 📤 Sending event to client: completed
|
||
2025-10-28 00:41:49,730 - ✅ Task CLM-2025-10-27-Y1KWA1 finished, closing SSE
|
||
```
|
||
|
||
**Вывод:** Backend работал корректно!
|
||
|
||
### Причина ошибки:
|
||
|
||
1. Backend отправляет событие OCR клиенту
|
||
2. Backend **закрывает SSE соединение** (это нормально)
|
||
3. Браузер получает событие закрытия SSE
|
||
4. Браузер триггерит `eventSource.onerror`
|
||
5. Frontend в `onerror` **перезаписывает успешный результат** ошибкой:
|
||
|
||
```typescript
|
||
// ❌ СТАРЫЙ КОД (неправильный)
|
||
eventSource.onerror = (error) => {
|
||
console.error('❌ SSE connection error:', error);
|
||
setOcrModalContent({
|
||
success: false,
|
||
data: null,
|
||
message: 'Ошибка подключения к серверу'
|
||
});
|
||
setWaitingForOcr(false);
|
||
eventSource.close();
|
||
};
|
||
```
|
||
|
||
**Проблема:** `onerror` вызывается **после** получения результата, когда backend закрывает SSE, и затирает успешный результат.
|
||
|
||
---
|
||
|
||
## 🛠️ Решение
|
||
|
||
Добавил проверку в `eventSource.onerror` — если уже получили результат OCR, не затираем его сообщением об ошибке:
|
||
|
||
```typescript
|
||
// ✅ НОВЫЙ КОД (правильный)
|
||
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();
|
||
};
|
||
```
|
||
|
||
**Логика:**
|
||
- Если `prev !== 'loading'` → значит уже получили результат → **не затираем** его
|
||
- Если `prev === 'loading'` → реальная ошибка подключения → показываем ошибку
|
||
|
||
---
|
||
|
||
## 📝 Изменённые файлы
|
||
|
||
### `/frontend/src/components/form/Step1Policy.tsx`
|
||
|
||
**Изменение:** Обработка `eventSource.onerror` с проверкой наличия результата
|
||
|
||
**Строки:** 147-162
|
||
|
||
**Было:**
|
||
```typescript
|
||
eventSource.onerror = (error) => {
|
||
console.error('❌ SSE connection error:', error);
|
||
setOcrModalContent({ success: false, data: null, message: 'Ошибка подключения к серверу' });
|
||
setWaitingForOcr(false);
|
||
eventSource.close();
|
||
};
|
||
```
|
||
|
||
**Стало:**
|
||
```typescript
|
||
eventSource.onerror = (error) => {
|
||
console.error('❌ SSE connection error:', error);
|
||
console.error('SSE readyState:', eventSource.readyState);
|
||
|
||
setOcrModalContent((prev) => {
|
||
if (prev && prev !== 'loading') {
|
||
console.log('✅ SSE закрыто после получения результата, не показываем ошибку');
|
||
return prev;
|
||
}
|
||
return { success: false, data: null, message: 'Ошибка подключения к серверу' };
|
||
});
|
||
|
||
setWaitingForOcr(false);
|
||
eventSource.close();
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 🐛 Проблемы в процессе исправления
|
||
|
||
### Проблема 1: Backend завис после kill -HUP
|
||
|
||
**Симптом:**
|
||
```bash
|
||
ERROR: [Errno 98] Address already in use
|
||
```
|
||
|
||
**Причина:** `kill -HUP` не перезапустил uvicorn корректно, порт 8100 остался занят зависшим процессом.
|
||
|
||
**Решение:**
|
||
```bash
|
||
# Убили все процессы на порту 8100
|
||
sudo lsof -ti :8100 | xargs -r kill -9
|
||
|
||
# Перезапустили backend
|
||
cd /var/www/fastuser/data/www/crm.clientright.ru/erv_platform/backend
|
||
source venv/bin/activate
|
||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8100 --reload > ../backend.log 2>&1 &
|
||
```
|
||
|
||
### Проблема 2: Изменения не применились во frontend
|
||
|
||
**Симптом:** После `docker-compose restart frontend` старый код всё ещё работал.
|
||
|
||
**Причина:** Frontend работает в Docker без volume mount — код встроен в образ при сборке.
|
||
|
||
**Решение:**
|
||
```bash
|
||
# Пересборка образа с новым кодом
|
||
docker-compose build frontend
|
||
|
||
# Пересоздание контейнера
|
||
docker-compose up -d frontend
|
||
```
|
||
|
||
**Проверка применения изменений:**
|
||
```bash
|
||
docker exec erv_platform_frontend_1 grep -A8 "eventSource.onerror" /app/src/components/form/Step1Policy.tsx
|
||
```
|
||
|
||
---
|
||
|
||
## 🚀 Git Commit
|
||
|
||
**Commit:** `0b75e01`
|
||
**Message:** "fix: Не затираем результат OCR при закрытии SSE соединения"
|
||
|
||
**Полное описание:**
|
||
```
|
||
Проблема: Backend закрывает SSE после отправки события, браузер триггерит onerror,
|
||
фронтенд перезаписывал успешный результат сообщением 'Ошибка подключения к серверу'.
|
||
|
||
Решение: Проверяем в onerror что если уже получили результат (prev !== 'loading'),
|
||
не затираем его ошибкой.
|
||
```
|
||
|
||
**Push:** ✅ `origin/main`
|
||
|
||
---
|
||
|
||
## ✅ Результат
|
||
|
||
### Что работает:
|
||
1. ✅ Backend запущен (PID 25931) на порту 8100
|
||
2. ✅ Frontend пересобран и работает на http://147.45.146.17:5173
|
||
3. ✅ SSE подключение устанавливается корректно
|
||
4. ✅ События OCR получаются из Redis через backend
|
||
5. ✅ Результат распознавания отображается в модальном окне
|
||
6. ✅ **Ошибка "Ошибка подключения к серверу" больше не появляется**
|
||
7. ✅ Git репозиторий синхронизирован
|
||
|
||
### Тестирование:
|
||
|
||
**Сценарий 1: Успешное распознавание полиса**
|
||
- Загрузка файла полиса → ✅
|
||
- SSE подключение → ✅
|
||
- OCR/Vision обработка → ✅
|
||
- Отображение результата → ✅ "Полис распознан: E1000-302545808"
|
||
- **Нет ошибки** при закрытии SSE → ✅
|
||
|
||
**Сценарий 2: Загрузка неподходящего документа**
|
||
- Загрузка не-полиса → ✅
|
||
- SSE подключение → ✅
|
||
- OCR/Vision обработка → ✅
|
||
- Отображение: "Документ не является полисом ERV" → ✅
|
||
|
||
**Сценарий 3: Реальная ошибка подключения**
|
||
- Если backend недоступен → ❌ "Ошибка подключения к серверу" (корректная ошибка)
|
||
|
||
---
|
||
|
||
## 📊 Архитектура (финальная)
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ USER BROWSER │
|
||
│ │
|
||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||
│ │ React Frontend (Vite Dev Server, port 3000) │ │
|
||
│ │ - Step1Policy.tsx (SSE Client) │ │
|
||
│ │ - Модалка с результатом OCR │ │
|
||
│ │ - EventSource(`/events/${claimId}`) │ │
|
||
│ │ - ✅ Защита от затирания результата в onerror │ │
|
||
│ └────────────┬─────────────────────────────────────────────────┘ │
|
||
│ │ Vite Proxy (/events → host:8100) │
|
||
└───────────────┼─────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ BACKEND (FastAPI, port 8100) │
|
||
│ PID: 25931 │
|
||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||
│ │ SSE Endpoint: GET /events/{task_id} │ │
|
||
│ │ - Подписка на Redis: ocr_events:{task_id} │ │
|
||
│ │ - Стриминг событий через SSE │ │
|
||
│ │ - Закрытие SSE после отправки результата │ │
|
||
│ └────────────┬─────────────────────────────────────────────────┘ │
|
||
└───────────────┼──────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ Redis Pub/Sub (crm.clientright.ru:6379) │
|
||
│ │
|
||
│ Channel: ocr_events:CLM-2025-10-27-XXXXX │
|
||
│ Format: { │
|
||
│ "claim_id": "CLM-...", │
|
||
│ "event": { │
|
||
│ "event_type": "ocr_completed", │
|
||
│ "status": "completed", │
|
||
│ "data": { "output": { "is_policy": "yes", ... } } │
|
||
│ } │
|
||
│ } │
|
||
└────────────────▲────────────────────────────────────────────────────┘
|
||
│
|
||
│ PUBLISH
|
||
│
|
||
┌────────────────┴────────────────────────────────────────────────────┐
|
||
│ n8n Workflow (OCR Processing) │
|
||
│ │
|
||
│ 1. Webhook trigger (file upload) │
|
||
│ 2. Upload to S3 │
|
||
│ 3. OCR Service (147.45.146.17:8001) │
|
||
│ 4. AI Vision (OpenRouter Gemini 2.0 Flash) │
|
||
│ 5. Redis Publish Node → ocr_events:{claim_id} │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 📈 Метрики
|
||
|
||
**Время выполнения сессии:** ~1 час
|
||
**Количество коммитов:** 1
|
||
**Изменённых файлов:** 1
|
||
**Строк изменено:** +10 / -1
|
||
**Перезапусков backend:** 2
|
||
**Rebuild frontend:** 1
|
||
|
||
**Проблемы решены:**
|
||
- ✅ Затирание результата OCR при закрытии SSE
|
||
- ✅ Backend завис после kill -HUP
|
||
- ✅ Изменения не применялись без rebuild
|
||
|
||
---
|
||
|
||
## 🔗 Ссылки
|
||
|
||
- Frontend: http://147.45.146.17:5173
|
||
- Backend API: http://localhost:8100
|
||
- Backend Health: http://localhost:8100/health
|
||
- Gitea: http://147.45.146.17:3002/negodiy/erv-platform
|
||
- n8n: http://147.45.146.17:5678
|
||
|
||
---
|
||
|
||
## 📝 Важные заметки
|
||
|
||
### Backend запущен вне Docker:
|
||
```bash
|
||
# Процесс
|
||
PID: 25931
|
||
Command: python -m uvicorn app.main:app --host 0.0.0.0 --port 8100 --reload
|
||
|
||
# Логи
|
||
tail -f /var/www/fastuser/data/www/crm.clientright.ru/erv_platform/backend.log
|
||
|
||
# Перезапуск
|
||
cd /var/www/fastuser/data/www/crm.clientright.ru/erv_platform/backend
|
||
source venv/bin/activate
|
||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8100 --reload > ../backend.log 2>&1 &
|
||
```
|
||
|
||
### Frontend требует rebuild при изменениях:
|
||
```bash
|
||
# Применение изменений
|
||
docker-compose build frontend
|
||
docker-compose up -d frontend
|
||
|
||
# Проверка кода в контейнере
|
||
docker exec erv_platform_frontend_1 cat /app/src/components/form/Step1Policy.tsx
|
||
```
|
||
|
||
### Redis credentials:
|
||
```
|
||
Host: crm.clientright.ru
|
||
Port: 6379
|
||
Password: cKSq8M11ZQIRi59OuUXb
|
||
Channels: ocr_events:{claim_id}
|
||
```
|
||
|
||
---
|
||
|
||
**Статус:** ✅ Успешно завершено
|
||
**Автор:** AI Assistant (Claude Sonnet 4.5)
|
||
**Дата:** 28 октября 2025, 01:00 MSK
|
||
|
||
---
|
||
---
|
||
|
||
# 📋 Лог сессии: Умная форма Step 2 с AI-обработкой документов
|
||
|
||
**Дата:** 28 октября 2025 (13:00 - 17:00 MSK)
|
||
**Задача:** Рефакторинг Step 2 в интеллектуальную форму с пошаговой загрузкой и AI-обработкой документов
|
||
**Статус:** ✅ Успешно завершено
|
||
|
||
---
|
||
|
||
## 🎯 Основные задачи
|
||
|
||
### 1. ✅ Улучшение UX на Step 1 (Policy)
|
||
- Добавлены динамические кнопки в модалке OCR:
|
||
- **"Продолжить →"** при успешном распознавании → переход на Step 2
|
||
- **"Загрузить другой файл"** при ошибке → очистка и повтор
|
||
- Добавлен **DEV MODE** панель с кнопкой быстрого перехода на Step 2 без валидации
|
||
|
||
### 2. ✅ Рефакторинг Step 2 (Details)
|
||
**Было:**
|
||
- Ручной ввод всех полей (тип события, дата, номер рейса/поезда/парома)
|
||
- Загрузка документов как дополнение к ручному вводу
|
||
|
||
**Стало:**
|
||
- **"Интеллектуальная форма"** — AI извлекает данные из документов
|
||
- **Пошаговая загрузка** каждого документа с индивидуальной обработкой
|
||
- **Модалка обработки** для каждого документа с результатами извлечения
|
||
- Ручной ввод только при необходимости (fallback)
|
||
|
||
### 3. ✅ Определение требований к документам
|
||
|
||
#### Задержка рейса (`delay_flight`)
|
||
1. **Посадочный талон ИЛИ Билет** (обязательно)
|
||
- `file_type: flight_delay_boarding_or_ticket`
|
||
- `event_type: flight_delay_boarding_or_ticket_processed`
|
||
- AI извлекает: номер рейса, дату, маршрут, ФИО, время вылета
|
||
|
||
2. **Подтверждение задержки** (обязательно, до 3 файлов)
|
||
- `file_type: flight_delay_confirmation`
|
||
- `event_type: flight_delay_confirmation_processed`
|
||
- Справка от АК, email/SMS, ИЛИ фото табло
|
||
- AI извлекает: время задержки, причину, фактическое время вылета
|
||
|
||
#### Отмена рейса (`cancel_flight`)
|
||
1. **Билет** (обязательно)
|
||
- `file_type: flight_cancel_ticket`
|
||
- `event_type: flight_cancel_ticket_processed`
|
||
|
||
2. **Уведомление об отмене** (обязательно, до 3 файлов)
|
||
- `file_type: flight_cancel_notice`
|
||
- `event_type: flight_cancel_notice_processed`
|
||
- Письмо/SMS от АК, фото табло
|
||
|
||
#### Пропуск стыковки (`missed_connection`)
|
||
1. **Рейс отправления: Посадочный талон ИЛИ Билет** (обязательно)
|
||
- `file_type: missed_connection_first_boarding_or_ticket`
|
||
- `event_type: missed_connection_first_boarding_or_ticket_processed`
|
||
|
||
2. **Рейс прибытия: Билет на пропущенный рейс** (обязательно)
|
||
- `file_type: missed_connection_second_ticket`
|
||
- `event_type: missed_connection_second_ticket_processed`
|
||
|
||
3. **Подтверждение задержки первого рейса** (опционально, до 3 файлов)
|
||
- `file_type: missed_connection_delay_proof`
|
||
- `event_type: missed_connection_delay_proof_processed`
|
||
|
||
#### Задержка/отмена поезда (`delay_train`, `cancel_train`)
|
||
1. **Билет** (обязательно)
|
||
- `file_type: train_delay_ticket` / `train_cancel_ticket`
|
||
|
||
2. **Справка о задержке/отмене** (обязательно, до 3 файлов)
|
||
- `file_type: train_delay_certificate` / `train_cancel_certificate`
|
||
- Справка от ЖД, фото табло
|
||
|
||
#### Задержка/отмена парома/круиза (`delay_ferry`, `cancel_ferry`)
|
||
1. **Билет/Бронь** (обязательно)
|
||
- `file_type: ferry_delay_ticket` / `ferry_cancel_ticket`
|
||
|
||
2. **Подтверждение задержки/отмены** (обязательно, до 3 файлов)
|
||
- `file_type: ferry_delay_confirmation` / `ferry_cancel_confirmation`
|
||
- Справка, email, фото расписания
|
||
|
||
### 4. ✅ Уникальные `file_type` для каждого документа
|
||
|
||
**Принцип:** Каждый тип документа → уникальный `file_type` → уникальный `event_type` в Redis
|
||
|
||
```typescript
|
||
// Пример для отмены рейса
|
||
{
|
||
file_type: "flight_cancel_ticket" // → S3, n8n, DB
|
||
event_type: "flight_cancel_ticket_processed" // → Redis pub/sub
|
||
}
|
||
|
||
{
|
||
file_type: "flight_cancel_notice"
|
||
event_type: "flight_cancel_notice_processed"
|
||
}
|
||
```
|
||
|
||
**Почему это важно:**
|
||
- n8n разделяет потоки обработки по `file_type`
|
||
- Разные AI промпты для каждого типа документа
|
||
- Frontend слушает уникальный `event_type` для каждого документа
|
||
|
||
### 5. ✅ Добавлены DEV MODE кнопки во все 3 шага
|
||
|
||
**Step 1 (Policy):**
|
||
- "Далее → (Step 2) [пропустить]" — авто-заполнение voucher и claim_id
|
||
|
||
**Step 2 (Details):**
|
||
- "← Назад (Step 1)" — возврат назад
|
||
- "Далее → (Step 3) [пропустить]" — авто-заполнение eventType, incidentDate, transportNumber
|
||
|
||
**Step 3 (Payment):**
|
||
- "← Назад (Step 2)" — возврат назад
|
||
- "✅ Автоподтверждение телефона [dev]" — автозаполнение всех полей + setIsPhoneVerified(true)
|
||
- "🚀 Отправить [пропустить]" — автозаполнение + submit
|
||
|
||
---
|
||
|
||
## 🛠️ Технические изменения
|
||
|
||
### Файл: `frontend/src/components/form/Step1Policy.tsx`
|
||
|
||
#### Изменение 1: Динамические кнопки в модалке OCR
|
||
|
||
**Было:**
|
||
```typescript
|
||
footer={[
|
||
<Button key="close" onClick={() => setOcrModalVisible(false)}>
|
||
Закрыть
|
||
</Button>
|
||
]}
|
||
```
|
||
|
||
**Стало:**
|
||
```typescript
|
||
footer={ocrModalContent === 'loading' ? null :
|
||
ocrModalContent?.success ? [
|
||
<Button key="next" type="primary" onClick={() => {
|
||
setOcrModalVisible(false);
|
||
onNext(); // Переход на следующий шаг
|
||
}}>
|
||
Продолжить →
|
||
</Button>
|
||
] : [
|
||
<Button key="retry" type="primary" onClick={() => {
|
||
setOcrModalVisible(false);
|
||
setFileList([]); // Очищаем список файлов
|
||
setPolicyNotFound(true); // Показываем форму загрузки снова
|
||
}}>
|
||
Загрузить другой файл
|
||
</Button>
|
||
]
|
||
}
|
||
```
|
||
|
||
#### Изменение 2: DEV MODE панель
|
||
|
||
```typescript
|
||
<div style={{
|
||
marginTop: 24,
|
||
padding: 16,
|
||
background: '#f0f0f0',
|
||
borderRadius: 8,
|
||
border: '2px dashed #999'
|
||
}}>
|
||
<div style={{ marginBottom: 8, fontSize: 12, color: '#666', fontWeight: 'bold' }}>
|
||
🔧 DEV MODE - Быстрая навигация (без валидации)
|
||
</div>
|
||
<Button
|
||
type="dashed"
|
||
onClick={() => {
|
||
const devData = {
|
||
voucher: 'E1000-123456789',
|
||
claim_id: `CLM-DEV-${Math.random().toString(36).substr(2, 6).toUpperCase()}`,
|
||
};
|
||
updateFormData(devData);
|
||
onNext();
|
||
}}
|
||
>
|
||
Далее → (Step 2) [пропустить]
|
||
</Button>
|
||
</div>
|
||
```
|
||
|
||
### Файл: `frontend/src/components/form/Step2Details.tsx`
|
||
|
||
#### Полный рефакторинг!
|
||
|
||
**Бэкап старой версии:** `Step2Details.OLD_MANUAL_INPUT.tsx`
|
||
|
||
**Новая структура:**
|
||
|
||
1. **`DOCUMENT_CONFIGS`** — конфигурация документов для каждого типа события:
|
||
```typescript
|
||
const DOCUMENT_CONFIGS = {
|
||
delay_flight: [
|
||
{
|
||
name: "Посадочный талон ИЛИ Билет",
|
||
field: "boarding_or_ticket",
|
||
file_type: "flight_delay_boarding_or_ticket",
|
||
required: true,
|
||
maxFiles: 1,
|
||
description: "Посадочный талон (boarding pass) или билет (ticket/booking)",
|
||
aiPromptFocus: "Извлеки: номер рейса, дату, маршрут, ФИО пассажира, время вылета"
|
||
},
|
||
// ... остальные документы
|
||
],
|
||
cancel_flight: [...],
|
||
// ... остальные типы событий
|
||
};
|
||
```
|
||
|
||
2. **Пошаговая загрузка документов:**
|
||
```typescript
|
||
const [currentDocIndex, setCurrentDocIndex] = useState(0);
|
||
const currentDoc = requiredDocs[currentDocIndex];
|
||
|
||
// После успешной загрузки
|
||
if (currentDocIndex < requiredDocs.length - 1) {
|
||
setCurrentDocIndex(prev => prev + 1);
|
||
} else {
|
||
// Все документы загружены
|
||
onNext();
|
||
}
|
||
```
|
||
|
||
3. **Модалка обработки для каждого документа:**
|
||
```typescript
|
||
<Modal
|
||
title="Обработка документа"
|
||
visible={processingModalVisible}
|
||
footer={processingModalContent === 'loading' ? null : [
|
||
<Button type="primary" onClick={handleContinueAfterProcessing}>
|
||
{currentDocIndex < requiredDocs.length - 1
|
||
? 'Продолжить к следующему документу →'
|
||
: 'Далее (Step 3) →'
|
||
}
|
||
</Button>
|
||
]}
|
||
>
|
||
{processingModalContent === 'loading' ? (
|
||
<div style={{textAlign: 'center', padding: 24}}>
|
||
<Spin size="large" />
|
||
<p>Обрабатываем документ...</p>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<Alert type="success" message="✅ Документ обработан" />
|
||
<pre>{JSON.stringify(processingModalContent, null, 2)}</pre>
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
```
|
||
|
||
4. **SSE для каждого документа с уникальным `event_type`:**
|
||
```typescript
|
||
const eventSource = new EventSource(
|
||
`${API_BASE_URL}/events/${claimId}?event_type=${currentDoc.file_type}_processed`
|
||
);
|
||
|
||
eventSource.onmessage = (event) => {
|
||
const result = JSON.parse(event.data);
|
||
if (result.event_type === `${currentDoc.file_type}_processed`) {
|
||
setProcessingModalContent(result.data);
|
||
}
|
||
};
|
||
```
|
||
|
||
#### DEV MODE панель:
|
||
```typescript
|
||
<div style={{ marginTop: 24, padding: 16, background: '#f0f0f0' }}>
|
||
<Button onClick={onPrev}>← Назад (Step 1)</Button>
|
||
<Button
|
||
type="dashed"
|
||
onClick={() => {
|
||
const devData = { eventType: 'delay_flight', incidentDate: dayjs(), transportNumber: 'TEST123' };
|
||
updateFormData(devData);
|
||
onNext();
|
||
}}
|
||
>
|
||
Далее → (Step 3) [пропустить]
|
||
</Button>
|
||
</div>
|
||
```
|
||
|
||
### Файл: `frontend/src/components/form/Step3Payment.tsx`
|
||
|
||
#### DEV MODE панель (3 кнопки):
|
||
```typescript
|
||
<Button onClick={onPrev}>← Назад (Step 2)</Button>
|
||
|
||
<Button
|
||
type="dashed"
|
||
onClick={() => {
|
||
setIsPhoneVerified(true);
|
||
const devData = {
|
||
fullName: 'Тест Тестов',
|
||
email: 'test@test.ru',
|
||
phone: '+79991234567',
|
||
paymentMethod: 'sbp',
|
||
bankName: 'sberbank',
|
||
};
|
||
updateFormData(devData);
|
||
message.success('DEV: Телефон автоматически подтверждён');
|
||
}}
|
||
>
|
||
✅ Автоподтверждение телефона [dev]
|
||
</Button>
|
||
|
||
<Button
|
||
type="primary"
|
||
onClick={() => {
|
||
setIsPhoneVerified(true);
|
||
const devData = {...};
|
||
updateFormData(devData);
|
||
onSubmit();
|
||
}}
|
||
>
|
||
🚀 Отправить [пропустить]
|
||
</Button>
|
||
```
|
||
|
||
---
|
||
|
||
## 🐛 Проблемы и их решения
|
||
|
||
### Проблема 1: Синтаксические ошибки на фронте
|
||
|
||
**Симптом:**
|
||
```
|
||
чета шляпа у нас на фронте
|
||
```
|
||
|
||
**Диагностика:**
|
||
- Пользователь сообщил "что то не того"
|
||
- Проверка файлов показала **дублирующийся код** после закрывающих тегов компонентов
|
||
|
||
**Найденные проблемы:**
|
||
|
||
1. **`Step1Policy.tsx`** (строки 659-820):
|
||
- Дублирован весь DEV MODE блок после `</div>` компонента
|
||
- Код был просто скопирован повторно
|
||
|
||
2. **`Step3Payment.tsx`** (после строки 381):
|
||
- Дублирован обрезанный фрагмент DEV панели
|
||
- Неполный JSX
|
||
|
||
**Решение:**
|
||
```bash
|
||
# Удалены дублирующиеся блоки
|
||
# Step1Policy.tsx: строки 659-820 удалены
|
||
# Step3Payment.tsx: строки после 381 удалены
|
||
|
||
# Rebuild frontend
|
||
docker-compose build frontend
|
||
docker-compose up -d frontend
|
||
```
|
||
|
||
**Коммиты:**
|
||
- `2999951` - fix: Удалён дублирующийся код в Step1Policy.tsx
|
||
- `1207222` - fix: Удалён дублирующийся код в Step3Payment.tsx
|
||
|
||
### Проблема 2: PostgreSQL INSERT не возвращает данные в n8n
|
||
|
||
**Симптом:**
|
||
```json
|
||
{
|
||
"s3_url": null,
|
||
"file_id": null,
|
||
"error": {
|
||
"message": "422 - \"{\\\"detail\\\":[{\\\"type\\\":\\\"string_type\\\",\\\"loc\\\":[\\\"body\\\",\\\"file_url\\\"],\\\"msg\\\":\\\"Input should be a valid string\\\",\\\"input\\\":null}]}\""
|
||
}
|
||
}
|
||
```
|
||
|
||
**Причина:**
|
||
1. `INSERT INTO claim_files` не вернул `file_id` и `s3_url`
|
||
2. Выяснилось: запись в `claims` с данным `claim_number` не существует
|
||
3. Foreign key `claim_id` не может быть установлен → INSERT падает
|
||
4. `file_size` передан как `"4.47 MB"` вместо числа в байтах
|
||
|
||
**Решение:**
|
||
Создан UPSERT запрос с CTE (Common Table Expression):
|
||
|
||
```sql
|
||
WITH upserted_claim AS (
|
||
INSERT INTO claims (
|
||
claim_number, voucher, session_id, status, created_at, updated_at
|
||
) VALUES (
|
||
$1, $2, $3, 'draft', NOW(), NOW()
|
||
)
|
||
ON CONFLICT (claim_number)
|
||
DO UPDATE SET
|
||
updated_at = NOW(),
|
||
voucher = COALESCE(EXCLUDED.voucher, claims.voucher)
|
||
RETURNING id as claim_id
|
||
)
|
||
INSERT INTO claim_files (
|
||
claim_id, file_type, original_name, s3_key, s3_url,
|
||
file_size, mime_type, ocr_status, uploaded_at
|
||
)
|
||
SELECT
|
||
upserted_claim.claim_id,
|
||
$4, $5, $6, $7, $8, $9, 'pending', NOW()
|
||
FROM upserted_claim
|
||
RETURNING id as file_id, s3_url, ocr_status;
|
||
```
|
||
|
||
**Параметры:**
|
||
```javascript
|
||
[
|
||
claim_number, // $1
|
||
voucher, // $2
|
||
session_id, // $3
|
||
file_type, // $4
|
||
original_name, // $5
|
||
s3_key, // $6
|
||
s3_url, // $7
|
||
file_size, // $8 (число в байтах!)
|
||
mime_type // $9
|
||
]
|
||
```
|
||
|
||
**Преимущества:**
|
||
- ✅ Атомарная операция
|
||
- ✅ Идемпотентность (можно запускать повторно)
|
||
- ✅ Всегда создаёт `claims` если его нет
|
||
- ✅ Обновляет `updated_at` если уже есть
|
||
- ✅ Возвращает `file_id` и `s3_url` для следующих шагов
|
||
|
||
---
|
||
|
||
## ✅ Тестирование
|
||
|
||
### Тест 1: Загрузка билета на отмену рейса
|
||
|
||
**Файл:** "Билет Романова.pdf"
|
||
**Claim ID:** CLM-2025-10-28-33ID32
|
||
**file_type:** `flight_cancel_ticket`
|
||
|
||
**Результат Redis:**
|
||
```json
|
||
{
|
||
"claim_id": "CLM-2025-10-28-33ID32",
|
||
"event": {
|
||
"event_type": "flight_cancel_ticket_processed",
|
||
"status": "completed",
|
||
"message": "✅ Документ обработан: flight_cancel_ticket",
|
||
"data": {
|
||
"output": {
|
||
"is_flight_doc": "yes",
|
||
"document_type": "e-ticket",
|
||
"ticket_number": "2222411714956",
|
||
"passengers": [{
|
||
"full_name": "ROMANOVA ANASTASIIA",
|
||
"doc_number": "774099576"
|
||
}],
|
||
"itinerary": [{
|
||
"flight_number": "A4-6025",
|
||
"departure": {
|
||
"airport_iata": "MRV",
|
||
"date_local": "2025-09-30",
|
||
"time_local": "16:25"
|
||
},
|
||
"arrival": {
|
||
"airport_iata": "TLV",
|
||
"time_local": "20:00"
|
||
}
|
||
}]
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Backend лог:**
|
||
```
|
||
16:46:29 - 📥 Received message type: message
|
||
16:46:29 - 📦 Raw event data: {"claim_id":"CLM-2025-10-28-33ID32",...}
|
||
16:46:29 - 📦 Unwrapped n8n Redis format for CLM-2025-10-28-33ID32
|
||
16:46:29 - 📤 Sending event to client: completed
|
||
16:46:29 - ✅ Task CLM-2025-10-28-33ID32 finished, closing SSE
|
||
```
|
||
|
||
**Результат:** ✅ Полный успех!
|
||
- S3 upload ✅
|
||
- PostgreSQL UPSERT ✅
|
||
- OCR/AI обработка ✅
|
||
- Redis publish ✅
|
||
- Backend SSE ✅
|
||
- Frontend получил данные ✅
|
||
|
||
---
|
||
|
||
## 📊 Архитектура Step 2 (новая)
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────┐
|
||
│ Step 2: Details (NEW) │
|
||
│ │
|
||
│ 1. Выбор типа события (eventType) │
|
||
│ ↓ │
|
||
│ 2. DOCUMENT_CONFIGS определяет список документов │
|
||
│ ↓ │
|
||
│ 3. Пошаговая загрузка каждого документа: │
|
||
│ │
|
||
│ ┌────────────────────────────────────────────────────┐ │
|
||
│ │ Документ 1: Посадочный талон │ │
|
||
│ │ - Upload компонент │ │
|
||
│ │ - POST /upload → n8n webhook │ │
|
||
│ │ - file_type: "flight_delay_boarding_or_ticket" │ │
|
||
│ │ - SSE: event_type = "..._processed" │ │
|
||
│ │ - Модалка: "Обрабатываем..." │ │
|
||
│ │ - Результат: extracted data │ │
|
||
│ │ - Кнопка: "Продолжить к следующему" │ │
|
||
│ └────────────────────────────────────────────────────┘ │
|
||
│ ↓ │
|
||
│ ┌────────────────────────────────────────────────────┐ │
|
||
│ │ Документ 2: Подтверждение задержки │ │
|
||
│ │ - (аналогично) │ │
|
||
│ │ - file_type: "flight_delay_confirmation" │ │
|
||
│ │ - Может быть до 3 файлов │ │
|
||
│ │ - Кнопка: "Далее (Step 3)" │ │
|
||
│ └────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ 4. После загрузки всех документов → Step 3 │
|
||
└─────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### Data Flow для одного документа:
|
||
|
||
```
|
||
Frontend n8n Backend Redis
|
||
│ │ │ │
|
||
│ POST /upload │ │ │
|
||
├────────────────────>│ │ │
|
||
│ {claim_id, │ │ │
|
||
│ file_type, │ │ │
|
||
│ file} │ │ │
|
||
│ │ │ │
|
||
│ SSE connect │ │ │
|
||
├────────────────────────────────────────────>│ │
|
||
│ /events/CLM-XXX? │ │ │
|
||
│ event_type= │ │ │
|
||
│ flight_..._processed│ │ │
|
||
│ │ │ │
|
||
│ │ 1. Upload to S3 │ │
|
||
│ │ 2. UPSERT claims │ │
|
||
│ │ 3. INSERT claim_files │ │
|
||
│ │ 4. OCR Service │ │
|
||
│ │ 5. AI Vision │ │
|
||
│ │ 6. PUBLISH │ │
|
||
│ ├────────────────────────────────────────────>│
|
||
│ │ {event_type: │ │
|
||
│ │ "..._processed", │ │
|
||
│ │ data: {...}} │ │
|
||
│ │ │ │
|
||
│ │ │<──────────────────┤
|
||
│ │ │ SUBSCRIBE │
|
||
│ │ │ ocr_events:CLM-XXX │
|
||
│<────────────────────────────────────────────┤ │
|
||
│ SSE: data: {event_type, data} │ │
|
||
│ │ │ │
|
||
│ Show modal: │ │ │
|
||
│ "✅ Обработано" │ │ │
|
||
│ Display data │ │ │
|
||
│ │ │ │
|
||
│ User clicks │ │ │
|
||
│ "Continue" → │ │ │
|
||
│ next document │ │ │
|
||
│ (or Step 3) │ │ │
|
||
```
|
||
|
||
---
|
||
|
||
## 🎯 Логика обработки результатов AI (спроектирована)
|
||
|
||
### Предложенная структура валидации:
|
||
|
||
```typescript
|
||
const handleOcrResult = (event) => {
|
||
const { output } = event.data;
|
||
|
||
// Проверка 1: Это правильный тип документа?
|
||
if (output.is_flight_doc !== "yes") {
|
||
return { success: false, message: "❌ Это не авиадокумент" };
|
||
}
|
||
|
||
// Проверка 2: Извлечены ли критичные данные?
|
||
const firstFlight = output.itinerary?.[0];
|
||
const criticalFields = {
|
||
flightNumber: firstFlight?.flight_number,
|
||
departureDate: firstFlight?.departure?.date_local,
|
||
departureAirport: firstFlight?.departure?.airport_iata,
|
||
arrivalAirport: firstFlight?.arrival?.airport_iata,
|
||
passengerName: output.passengers?.[0]?.full_name
|
||
};
|
||
|
||
const missing = Object.entries(criticalFields)
|
||
.filter(([_, value]) => !value)
|
||
.map(([key]) => key);
|
||
|
||
if (missing.length === 0) {
|
||
return { success: true, message: "✅ Билет распознан успешно!" };
|
||
} else {
|
||
return {
|
||
success: "partial",
|
||
message: "⚠️ Билет распознан, но не хватает данных",
|
||
missingFields: missing
|
||
};
|
||
}
|
||
};
|
||
```
|
||
|
||
### 3 сценария UI:
|
||
|
||
**SUCCESS:** Все данные извлечены
|
||
- ✅ Показать извлечённые данные
|
||
- Кнопка: "Продолжить к следующему документу →"
|
||
|
||
**PARTIAL:** Документ валидный, но данные неполные
|
||
- ⚠️ Показать что извлечено + что отсутствует
|
||
- 3 кнопки:
|
||
1. "📸 Загрузить документ лучшего качества"
|
||
2. "✍️ Ввести недостающие данные вручную"
|
||
3. "Продолжить с доступными данными"
|
||
|
||
**FAIL:** Неправильный тип документа
|
||
- ❌ Ошибка
|
||
- 2 кнопки:
|
||
1. "Загрузить другой файл"
|
||
2. "Ввести данные вручную"
|
||
|
||
---
|
||
|
||
## 📝 Git Commits
|
||
|
||
```bash
|
||
# Commit history (от старого к новому)
|
||
6fe1459 - backup: Сохранён старый Step2Details с ручным вводом полей
|
||
122af07 - feat: Умная форма Step2 с автоматическим распознаванием документов
|
||
9084d75 - feat: Пошаговая загрузка документов с модалкой на Step 2
|
||
2999951 - fix: Удалён дублирующийся код в Step1Policy.tsx
|
||
1207222 - fix: Удалён дублирующийся код в Step3Payment.tsx
|
||
```
|
||
|
||
**Push:** ✅ `origin/main` (все коммиты)
|
||
|
||
---
|
||
|
||
## 📈 Метрики
|
||
|
||
**Время выполнения сессии:** ~4 часа
|
||
**Количество коммитов:** 5
|
||
**Изменённых файлов:** 4 (Step1Policy, Step2Details, Step2Details.OLD, Step3Payment)
|
||
**Строк добавлено:** ~800
|
||
**Строк удалено:** ~200 (дубликаты) + ~400 (рефакторинг Step2)
|
||
|
||
**Frontend rebuilds:** 3
|
||
**Тестовых загрузок:** 3
|
||
**Redis событий обработано:** 3
|
||
|
||
---
|
||
|
||
## 🔗 Ссылки
|
||
|
||
- Frontend: http://147.45.146.17:5173
|
||
- Backend API: http://localhost:8100
|
||
- Gitea: http://147.45.146.17:3002/negodiy/erv-platform
|
||
- n8n: http://147.45.146.17:5678
|
||
- N8N SQL Queries: `/erv_platform/N8N_SQL_QUERIES.md`
|
||
|
||
---
|
||
|
||
## 📝 Важные заметки
|
||
|
||
### Redis Password (обновлено)
|
||
```
|
||
Host: crm.clientright.ru
|
||
Port: 6379
|
||
Password: CRM_Redis_Pass_2025_Secure!
|
||
(из /etc/redis/redis.conf)
|
||
```
|
||
|
||
### PostgreSQL UPSERT для n8n
|
||
Сохранён в `N8N_SQL_QUERIES.md` для использования в webhook nodes.
|
||
|
||
### Структура `file_type` → `event_type`
|
||
```
|
||
file_type: "flight_cancel_ticket"
|
||
event_type: "flight_cancel_ticket_processed"
|
||
|
||
Формула: event_type = file_type + "_processed"
|
||
```
|
||
|
||
### DEV MODE
|
||
Все три шага имеют панель для быстрой навигации без заполнения форм — ускоряет разработку и тестирование.
|
||
|
||
---
|
||
|
||
**Статус:** ✅ Успешно завершено
|
||
**Автор:** AI Assistant (Claude Sonnet 4.5)
|
||
**Дата:** 28 октября 2025, 17:00 MSK
|
||
|