feat(ticket_form): Новая архитектура загрузки документов

- StepDocumentsNew.tsx: поэкранная загрузка документов
- StepWaitingClaim.tsx: ожидание формирования заявления с SSE
- StepDraftSelection.tsx: поддержка новых статусов черновиков
- documents.py: API для загрузки документов
- NEW_FLOW_ARCHITECTURE.md: документация новой архитектуры

Флоу: Description → Documents → Waiting → Claim Review → SMS
Статусы: draft_new, draft_docs_progress, draft_docs_complete, draft_claim_ready
This commit is contained in:
Fedor
2025-11-26 12:52:54 +03:00
parent 0868d37484
commit 6c770f0a87
8 changed files with 1817 additions and 102 deletions

View File

@@ -189,3 +189,4 @@
3. `field_label` из формы визарда используется для генерации slug файлов
4. Все ноды n8n должны безопасно обрабатывать отсутствие данных

View File

@@ -0,0 +1,143 @@
# 📝 Лог сессии: Новая архитектура загрузки документов
**Дата:** 2025-11-26
**Время:** ~13:00 MSK
---
## 🎯 Цель сессии
Концептуальная переработка флоу подачи заявки:
- **Проблема:** Визард генерируется слишком долго (2 минуты), анкета слишком длинная
- **Решение:** Сразу запрашиваем документы, параллельно генерируем визард в бэке
---
## ✅ Что сделано
### 1. Документация архитектуры
- **Файл:** `docs/NEW_FLOW_ARCHITECTURE.md`
- Описан новый флоу: Description → Documents → Waiting → Claim Review → SMS
- Определены статусы черновиков: `draft_new`, `draft_docs_progress`, `draft_docs_complete`, `draft_claim_ready`, `awaiting_sms`
- Структура payload черновика с новыми полями
### 2. Frontend компоненты
#### StepDocumentsNew.tsx (НОВЫЙ)
- Поэкранная загрузка документов (один документ на экран)
- Критичные документы помечены предупреждением
- Возможность пропустить любой документ
- Прогресс-бар загрузки
- Отображение уже загруженных документов
#### StepWaitingClaim.tsx (НОВЫЙ)
- Экран ожидания формирования заявления
- SSE подписка на события: `document_ocr_completed`, `claim_ready`
- Шаги обработки: OCR → Анализ → Формирование → Готово
- Таймер ожидания
- Таймаут 5 минут с обработкой ошибок
#### StepDraftSelection.tsx (ОБНОВЛЁН)
- Поддержка новых статусов черновиков
- Визуальное отображение разных статусов (цвета, иконки, описания)
- Прогресс документов (X из Y загружено)
- Legacy черновики помечаются как "устаревший формат"
- Разные действия для разных статусов
### 3. Backend API
#### documents.py (НОВЫЙ)
- `POST /api/v1/documents/upload` — загрузка одного документа
- `GET /api/v1/documents/status/{claim_id}` — статус обработки документов
- `POST /api/v1/documents/generate-list` — запрос на генерацию списка документов
- Интеграция с n8n webhook
- Публикация событий в Redis
#### main.py (ОБНОВЛЁН)
- Добавлен роутер `documents`
---
## 📁 Изменённые файлы
```
ticket_form/
├── docs/
│ └── NEW_FLOW_ARCHITECTURE.md # НОВЫЙ
├── frontend/src/components/form/
│ ├── StepDocumentsNew.tsx # НОВЫЙ
│ ├── StepWaitingClaim.tsx # НОВЫЙ
│ └── StepDraftSelection.tsx # ОБНОВЛЁН
├── backend/app/
│ ├── api/
│ │ └── documents.py # НОВЫЙ
│ └── main.py # ОБНОВЛЁН
└── SESSION_LOG_2025-11-26_NEW_FLOW.md # НОВЫЙ
```
---
## ⏳ Что осталось сделать
### Frontend
- [ ] Обновить `ClaimForm.tsx` — интегрировать новые компоненты в флоу
- [ ] Обновить `StepDescription.tsx` — после описания переходить к документам (не к визарду)
### Backend
- [ ] Эндпоинт получения списка документов из черновика
- [ ] SSE события для прогресса OCR
### n8n
- [ ] Воркфлоу: генерация списка документов (быстрый AI запрос)
- [ ] Воркфлоу: OCR документа → заполнение полей визарда
- [ ] Воркфлоу: формирование заявления после всех документов
- [ ] Webhook: `/webhook/document-upload`
### Тестирование
- [ ] Полный цикл с реальными данными
- [ ] Обработка ошибок
- [ ] Legacy черновики
---
## 🔧 Технические детали
### Новые SSE события
```javascript
// Список документов готов
{ event_type: "documents_list_ready", documents_required: [...] }
// Документ загружен (начало OCR)
{ event_type: "document_uploaded", document_type: "contract", status: "processing" }
// OCR завершён
{ event_type: "document_ocr_completed", document_type: "contract", ocr_data: {...} }
// Заявление готово
{ event_type: "claim_ready", claim_data: {...} }
```
### Статусы черновиков
| Статус | Описание |
|--------|----------|
| `draft_new` | Только описание проблемы |
| `draft_docs_progress` | Часть документов загружена |
| `draft_docs_complete` | Все документы, ждём заявление |
| `draft_claim_ready` | Заявление готово |
| `awaiting_sms` | Ждёт SMS подтверждения |
### Legacy черновики
- Определяются по отсутствию `documents_required` в payload
- Показываются с пометкой "устаревший формат"
- Кнопка "Начать заново" копирует description в новый черновик
---
## 📌 Примечания
1. **Ветка backup:** `backup-wizard-ui-2025-11-26` содержит состояние до изменений
2. **n8n:** Webhook `/webhook/document-upload` нужно создать
3. **Redis каналы:**
- `ocr_events:{session_id}` — события для конкретного пользователя
- `ticket_form:documents_list` — запрос на генерацию списка документов

View File

@@ -0,0 +1,270 @@
"""
Documents API Routes - Загрузка и обработка документов
Новый флоу: поэкранная загрузка документов
"""
from fastapi import APIRouter, HTTPException, UploadFile, File, Form, Request
from typing import Optional
import httpx
import json
import uuid
from datetime import datetime
import logging
from ..services.redis_service import redis_service
from ..config import settings
router = APIRouter(prefix="/api/v1/documents", tags=["Documents"])
logger = logging.getLogger(__name__)
# n8n webhook для загрузки документов
N8N_DOCUMENT_UPLOAD_WEBHOOK = "https://n8n.clientright.pro/webhook/document-upload"
@router.post("/upload")
async def upload_document(
request: Request,
file: UploadFile = File(...),
claim_id: str = Form(...),
session_id: str = Form(...),
document_type: str = Form(...),
unified_id: Optional[str] = Form(None),
contact_id: Optional[str] = Form(None),
):
"""
Загрузка одного документа.
Принимает файл и метаданные, отправляет в n8n для:
1. Сохранения в S3
2. OCR обработки
3. Обновления черновика в PostgreSQL
После успешной обработки n8n публикует событие document_ocr_completed в Redis.
"""
try:
# Генерируем уникальный ID файла
file_id = f"doc_{uuid.uuid4().hex[:12]}"
logger.info(
"📤 Document upload received",
extra={
"claim_id": claim_id,
"session_id": session_id,
"document_type": document_type,
"file_name": file.filename,
"file_size": file.size if hasattr(file, 'size') else 'unknown',
"content_type": file.content_type,
},
)
# Читаем содержимое файла
file_content = await file.read()
file_size = len(file_content)
# Формируем данные для отправки в n8n
form_data = {
"claim_id": claim_id,
"session_id": session_id,
"document_type": document_type,
"file_id": file_id,
"original_filename": file.filename,
"content_type": file.content_type or "application/octet-stream",
"file_size": str(file_size),
"upload_timestamp": datetime.utcnow().isoformat(),
}
if unified_id:
form_data["unified_id"] = unified_id
if contact_id:
form_data["contact_id"] = contact_id
# Файл для multipart
files = {
"file": (file.filename, file_content, file.content_type or "application/octet-stream")
}
# Отправляем в n8n
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
N8N_DOCUMENT_UPLOAD_WEBHOOK,
data=form_data,
files=files,
)
response_text = response.text or ""
if response.status_code == 200:
logger.info(
"✅ Document uploaded to n8n",
extra={
"claim_id": claim_id,
"document_type": document_type,
"file_id": file_id,
"response_preview": response_text[:200],
},
)
# Парсим ответ от n8n
try:
n8n_response = json.loads(response_text)
except json.JSONDecodeError:
n8n_response = {"raw": response_text}
# Публикуем событие в Redis для фронтенда
event_data = {
"event_type": "document_uploaded",
"status": "processing",
"claim_id": claim_id,
"session_id": session_id,
"document_type": document_type,
"file_id": file_id,
"original_filename": file.filename,
"timestamp": datetime.utcnow().isoformat(),
}
await redis_service.publish(
f"ocr_events:{session_id}",
json.dumps(event_data, ensure_ascii=False)
)
return {
"success": True,
"file_id": file_id,
"document_type": document_type,
"ocr_status": "processing",
"message": "Документ загружен и отправлен на обработку",
"n8n_response": n8n_response,
}
else:
logger.error(
"❌ n8n document upload error",
extra={
"status_code": response.status_code,
"body": response_text[:500],
},
)
raise HTTPException(
status_code=response.status_code,
detail=f"Ошибка n8n: {response_text}",
)
except httpx.TimeoutException:
logger.error("⏱️ n8n document upload timeout")
raise HTTPException(status_code=504, detail="Таймаут загрузки документа")
except HTTPException:
raise
except Exception as e:
logger.exception("❌ Document upload error")
raise HTTPException(
status_code=500,
detail=f"Ошибка загрузки документа: {str(e)}",
)
@router.get("/status/{claim_id}")
async def get_documents_status(claim_id: str):
"""
Получить статус обработки документов для заявки.
Возвращает:
- Список загруженных документов и их OCR статус
- Общий прогресс обработки
"""
try:
# TODO: Запрос в PostgreSQL для получения статуса документов
# Пока возвращаем mock данные
return {
"success": True,
"claim_id": claim_id,
"documents": [],
"ocr_progress": {
"total": 0,
"completed": 0,
"processing": 0,
"failed": 0,
},
"wizard_ready": False,
"claim_ready": False,
}
except Exception as e:
logger.exception("❌ Error getting documents status")
raise HTTPException(
status_code=500,
detail=f"Ошибка получения статуса: {str(e)}",
)
@router.post("/generate-list")
async def generate_documents_list(request: Request):
"""
Запрос на генерацию списка документов для проблемы.
Принимает описание проблемы, отправляет в n8n для быстрого AI-анализа.
n8n публикует результат в Redis канал ocr_events:{session_id} с event_type=documents_list_ready.
"""
try:
body = await request.json()
session_id = body.get("session_id")
problem_description = body.get("problem_description")
if not session_id or not problem_description:
raise HTTPException(
status_code=400,
detail="session_id и problem_description обязательны",
)
logger.info(
"📝 Generate documents list request",
extra={
"session_id": session_id,
"description_length": len(problem_description),
},
)
# Публикуем событие в Redis для n8n
event_data = {
"type": "generate_documents_list",
"session_id": session_id,
"claim_id": body.get("claim_id"),
"unified_id": body.get("unified_id"),
"phone": body.get("phone"),
"problem_description": problem_description,
"timestamp": datetime.utcnow().isoformat(),
}
channel = f"{settings.redis_prefix}documents_list"
subscribers = await redis_service.publish(
channel,
json.dumps(event_data, ensure_ascii=False)
)
logger.info(
"✅ Documents list request published",
extra={
"channel": channel,
"subscribers": subscribers,
},
)
return {
"success": True,
"message": "Запрос на генерацию списка документов отправлен",
"channel": channel,
}
except HTTPException:
raise
except Exception as e:
logger.exception("❌ Error generating documents list")
raise HTTPException(
status_code=500,
detail=f"Ошибка генерации списка: {str(e)}",
)

View File

@@ -12,7 +12,7 @@ from .services.redis_service import redis_service
from .services.rabbitmq_service import rabbitmq_service
from .services.policy_service import policy_service
from .services.s3_service import s3_service
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents
# Настройка логирования
logging.basicConfig(
@@ -103,6 +103,7 @@ app.include_router(draft.router)
app.include_router(events.router)
app.include_router(n8n_proxy.router) # 🔒 Безопасный proxy к n8n webhooks
app.include_router(session.router) # 🔑 Session management через Redis
app.include_router(documents.router) # 📄 Documents upload and processing
@app.get("/")

View File

@@ -0,0 +1,383 @@
# 🚀 Новая архитектура: Быстрая загрузка документов
**Дата создания:** 2025-11-26
**Статус:** В разработке
---
## 📋 Проблема
Текущий флоу слишком медленный:
1. **2 минуты** — генерация визарда (RAG + AI анализ)
2. **Длинная анкета** — слишком много вопросов для пользователя
---
## ✅ Новое решение
### Концепция
1. После описания проблемы → сразу запрашиваем документы (без ожидания визарда)
2. Пока пользователь загружает документы → в бэке генерируется визард + OCR
3. После всех документов → показываем готовое заявление на апрув
### Преимущества
- **Быстрый старт** — пользователь не ждёт 2 минуты
- **Параллельная работа** — OCR и визард генерируются пока пользователь ищет документы
- **Меньше вопросов** — большая часть данных извлекается из документов
---
## 🔄 Новый флоу (шаги)
```
┌─────────────────┐
│ 1. Телефон │ (уже есть)
│ SMS верификация
└────────┬────────┘
┌─────────────────┐
│ 2. Черновики │ (уже есть, обновить UI)
│ - Новые статусы│
│ - Legacy→"Начать заново"
└────────┬────────┘
┌─────────────────┐
│ 3. Описание │ (уже есть)
│ Свободный текст│
└────────┬────────┘
▼ → n8n: быстрая генерация списка документов (5-10 сек)
│ → n8n: параллельно запускает генерацию визарда (в фоне)
┌─────────────────┐
│ 4. Документы │ 🆕 НОВЫЙ КОМПОНЕНТ
│ - Поэкранная загрузка
│ - Критичные помечены
│ - Можно пропустить
└────────┬────────┘
▼ → n8n: OCR каждого документа → заполнение визарда (в фоне)
┌─────────────────┐
│ 5. Ожидание │ 🆕 НОВЫЙ КОМПОНЕНТ
│ "Формируем заявление..."
│ Loader + прогресс
└────────┬────────┘
▼ ← n8n: claim_ready event (SSE)
┌─────────────────┐
│ 6. Заявление │ (уже есть StepClaimConfirmation)
│ Просмотр + редактирование
└────────┬────────┘
┌─────────────────┐
│ 7. SMS апрув │ (уже есть)
└─────────────────┘
```
---
## 📊 Статусы черновика (status_code)
| Статус | Описание | UI при открытии |
|--------|----------|-----------------|
| `draft_new` | Только описание | → Шаг документов |
| `draft_docs_progress` | Часть документов загружена | → Продолжить с текущего документа |
| `draft_docs_complete` | Все документы загружены | → Показать loader |
| `draft_claim_ready` | Заявление готово | → Показать заявление |
| `awaiting_sms` | Ждёт SMS | → Форма SMS |
| `approved` | Отправлено | Не показываем |
### Legacy черновики (старый формат)
- Нет `documents_required` → показываем с пометкой "устаревший"
- Кнопка "Начать заново" → копирует description, создаёт новый черновик
---
## 📦 Структура payload черновика
```json
{
// === Идентификаторы ===
"claim_id": "CLM-2025-11-26-X7Y8Z9",
"session_token": "sess_abc123...",
"unified_id": "user_456...",
"phone": "+79991234567",
"email": "user@example.com",
// === Описание проблемы ===
"problem_description": "Купил курсы за 50000р, компания не отвечает...",
// === Документы (новое!) ===
"documents_required": [
{
"type": "contract",
"name": "Договор или оферта",
"critical": true,
"hints": "Скриншот или PDF договора/оферты"
},
{
"type": "payment",
"name": "Подтверждение оплаты",
"critical": true,
"hints": "Чек, выписка из банка, скриншот платежа"
},
{
"type": "correspondence",
"name": "Переписка с продавцом",
"critical": false,
"hints": "Скриншоты переписки, email, чаты"
}
],
"documents_uploaded": [
{
"type": "contract",
"file_id": "s3://...",
"ocr_status": "completed",
"ocr_data": {...}
}
],
"documents_skipped": ["correspondence"],
"current_doc_index": 1,
// === Визард (генерируется в фоне) ===
"wizard_plan": {...}, // AI-generated questions
"wizard_answers": {...}, // Auto-filled from OCR
"wizard_ready": true, // Флаг готовности
// === Заявление ===
"claim_ready": false, // Флаг готовности заявления
"claim_data": { // Готовое заявление для апрува
"applicant": {...},
"case": {...},
"contract_or_service": {...},
"offenders": [...],
"claim": {...},
"attachments": [...]
},
// === Метаданные ===
"created_at": "2025-11-26T10:00:00Z",
"updated_at": "2025-11-26T10:05:00Z"
}
```
---
## 🔌 API Endpoints
### Существующие (без изменений)
- `POST /api/v1/claims/description` — публикация описания в Redis
- `GET /api/v1/claims/drafts/list` — список черновиков
- `GET /api/v1/claims/drafts/{claim_id}` — полные данные черновика
- `POST /api/v1/claims/approve` — финальный апрув (SMS)
### Новые/Изменённые
#### 1. SSE: Получение списка документов
```
GET /api/v1/events/{session_id}
Event: documents_list_ready
Data: {
"event_type": "documents_list_ready",
"documents_required": [...]
}
```
#### 2. Загрузка документа
```
POST /api/v1/documents/upload
Content-Type: multipart/form-data
Body:
- claim_id: string
- document_type: string (contract, payment, etc.)
- file: binary
Response:
{
"success": true,
"file_id": "s3://...",
"ocr_status": "processing"
}
```
#### 3. SSE: Статус OCR и формирования заявления
```
GET /api/v1/events/{session_id}
Event: document_ocr_completed
Data: {
"event_type": "document_ocr_completed",
"document_type": "contract",
"ocr_data": {...}
}
Event: claim_ready
Data: {
"event_type": "claim_ready",
"claim_data": {...}
}
```
#### 4. Получение статуса черновика
```
GET /api/v1/claims/drafts/{claim_id}/status
Response:
{
"status_code": "draft_docs_progress",
"documents_total": 3,
"documents_uploaded": 1,
"documents_skipped": 0,
"wizard_ready": false,
"claim_ready": false
}
```
---
## 🖥️ Frontend компоненты
### 1. StepDocumentsNew.tsx (НОВЫЙ)
```tsx
// Поэкранная загрузка документов
// Один документ на экран
// Критичные помечены алертом
// Кнопки: "Загрузить", "Пропустить", "Назад"
interface Props {
documents: DocumentConfig[];
currentIndex: number;
onUpload: (file: File) => void;
onSkip: () => void;
onNext: () => void;
onPrev: () => void;
}
```
### 2. StepWaitingClaim.tsx (НОВЫЙ)
```tsx
// Loader пока формируется заявление
// Прогресс: "OCR документов...", "Анализ данных...", "Формирование заявления..."
// SSE подписка на claim_ready
interface Props {
sessionId: string;
onClaimReady: (claimData: any) => void;
}
```
### 3. StepDraftSelection.tsx (ОБНОВИТЬ)
```tsx
// Новые статусы черновиков
// Разные действия для разных статусов
// Legacy черновики → "Начать заново"
```
### 4. ClaimForm.tsx (ОБНОВИТЬ)
```tsx
// Новая логика шагов
// Убрать StepWizardPlan из основного флоу
// Добавить StepDocumentsNew и StepWaitingClaim
```
---
## ⚙️ n8n Воркфлоу
### 1. Генерация списка документов (быстрая)
```
Redis Trigger (ticket_form:description)
AI: Быстрый анализ → список документов (5-10 сек)
Redis Publish (ocr_events:{session_id})
+ event_type: documents_list_ready
PostgreSQL: Сохранить documents_required в черновик
Параллельно: Запустить генерацию визарда (отдельный воркфлоу)
```
### 2. Генерация визарда (фоновая)
```
(Запускается из воркфлоу 1)
AI Agent: RAG + генерация вопросов (2 мин)
PostgreSQL: Сохранить wizard_plan в черновик
+ wizard_ready = true
```
### 3. OCR документа
```
Webhook (upload документа)
S3 Upload
AI Vision: OCR + извлечение данных
PostgreSQL: Сохранить в documents_uploaded
Redis Publish: document_ocr_completed
Если все документы загружены:
↓ (Запустить формирование заявления)
```
### 4. Формирование заявления
```
(После всех документов)
Собрать данные из:
- wizard_plan
- documents_uploaded (OCR данные)
- CRM контакт
AI: Сформировать заявление
PostgreSQL: Сохранить claim_data
+ claim_ready = true
Redis Publish: claim_ready
```
---
## 📝 План реализации
### Фаза 1: Frontend (без n8n)
1. ✅ Создать `StepDocumentsNew.tsx` — заглушка с mock данными
2. ✅ Создать `StepWaitingClaim.tsx` — loader
3. ✅ Обновить `ClaimForm.tsx` — новый флоу шагов
4. ✅ Обновить `StepDraftSelection.tsx` — новые статусы
### Фаза 2: Backend
1. ✅ Эндпоинт `POST /api/v1/documents/upload`
2. ✅ SSE events: `documents_list_ready`, `document_ocr_completed`, `claim_ready`
3. ✅ Эндпоинт `GET /api/v1/claims/drafts/{claim_id}/status`
### Фаза 3: n8n
1. ✅ Воркфлоу: Генерация списка документов
2. ✅ Воркфлоу: OCR документа
3. ✅ Воркфлоу: Формирование заявления
### Фаза 4: Интеграция и тестирование
1. ✅ Полный цикл с реальными данными
2. ✅ Обработка ошибок
3. ✅ Legacy черновики
---
## 🎯 Ожидаемый результат
| Метрика | Было | Стало |
|---------|------|-------|
| Время до первого действия | ~2 мин | ~10 сек |
| Количество вопросов | 10-15 | 0-3 (только уточняющие) |
| Конверсия | ? | ↑ (меньше отвала) |

View File

@@ -0,0 +1,362 @@
/**
* StepDocumentsNew.tsx
*
* Поэкранная загрузка документов.
* Один документ на экран с возможностью пропуска.
*
* @version 1.0
* @date 2025-11-26
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import {
Button,
Card,
Upload,
Progress,
Alert,
Typography,
Space,
Spin,
message,
Result
} from 'antd';
import {
UploadOutlined,
FileTextOutlined,
ExclamationCircleOutlined,
CheckCircleOutlined,
LoadingOutlined,
InboxOutlined
} from '@ant-design/icons';
import type { UploadFile, UploadProps } from 'antd/es/upload/interface';
const { Title, Text, Paragraph } = Typography;
const { Dragger } = Upload;
// === Типы ===
export interface DocumentConfig {
type: string; // Идентификатор: contract, payment, correspondence
name: string; // Название: "Договор или оферта"
critical: boolean; // Обязательный документ?
hints?: string; // Подсказка: "Скриншот или PDF договора"
accept?: string[]; // Допустимые форматы: ['pdf', 'jpg', 'png']
}
interface Props {
formData: any;
updateFormData: (data: any) => void;
documents: DocumentConfig[];
currentIndex: number;
onDocumentUploaded: (docType: string, fileData: any) => void;
onDocumentSkipped: (docType: string) => void;
onAllDocumentsComplete: () => void;
onPrev: () => void;
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
}
// === Компонент ===
export default function StepDocumentsNew({
formData,
updateFormData,
documents,
currentIndex,
onDocumentUploaded,
onDocumentSkipped,
onAllDocumentsComplete,
onPrev,
addDebugEvent,
}: Props) {
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
// Текущий документ
const currentDoc = documents[currentIndex];
const isLastDocument = currentIndex === documents.length - 1;
const totalDocs = documents.length;
// Сбрасываем файлы при смене документа
useEffect(() => {
setFileList([]);
setUploadProgress(0);
}, [currentIndex]);
// === Handlers ===
const handleUpload = useCallback(async () => {
if (fileList.length === 0) {
message.error('Выберите файл для загрузки');
return;
}
const file = fileList[0];
if (!file.originFileObj) {
message.error('Ошибка: файл не найден');
return;
}
setUploading(true);
setUploadProgress(0);
try {
addDebugEvent?.('documents', 'info', `📤 Загрузка документа: ${currentDoc.name}`, {
document_type: currentDoc.type,
file_name: file.name,
file_size: file.size,
});
const formDataToSend = new FormData();
formDataToSend.append('claim_id', formData.claim_id || '');
formDataToSend.append('session_id', formData.session_id || '');
formDataToSend.append('document_type', currentDoc.type);
formDataToSend.append('file', file.originFileObj, file.name);
// Симуляция прогресса (реальный прогресс будет через XHR)
const progressInterval = setInterval(() => {
setUploadProgress(prev => Math.min(prev + 10, 90));
}, 200);
const response = await fetch('/api/v1/documents/upload', {
method: 'POST',
body: formDataToSend,
});
clearInterval(progressInterval);
setUploadProgress(100);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Ошибка загрузки: ${response.status} ${errorText}`);
}
const result = await response.json();
addDebugEvent?.('documents', 'success', `✅ Документ загружен: ${currentDoc.name}`, {
document_type: currentDoc.type,
file_id: result.file_id,
});
message.success(`${currentDoc.name} загружен!`);
// Сохраняем в formData
const uploadedDocs = formData.documents_uploaded || [];
uploadedDocs.push({
type: currentDoc.type,
file_id: result.file_id,
file_name: file.name,
ocr_status: 'processing',
});
updateFormData({
documents_uploaded: uploadedDocs,
current_doc_index: currentIndex + 1,
});
// Callback
onDocumentUploaded(currentDoc.type, result);
// Переходим к следующему или завершаем
if (isLastDocument) {
onAllDocumentsComplete();
}
} catch (error) {
console.error('❌ Upload error:', error);
message.error('Ошибка загрузки файла. Попробуйте ещё раз.');
addDebugEvent?.('documents', 'error', `❌ Ошибка загрузки: ${currentDoc.name}`, {
error: String(error),
});
} finally {
setUploading(false);
}
}, [fileList, currentDoc, formData, updateFormData, currentIndex, isLastDocument, onDocumentUploaded, onAllDocumentsComplete, addDebugEvent]);
const handleSkip = useCallback(() => {
if (currentDoc.critical) {
// Показываем предупреждение, но всё равно разрешаем пропустить
message.warning(`⚠️ Документ "${currentDoc.name}" важен для рассмотрения заявки`);
}
addDebugEvent?.('documents', 'info', `⏭️ Документ пропущен: ${currentDoc.name}`, {
document_type: currentDoc.type,
was_critical: currentDoc.critical,
});
// Сохраняем в список пропущенных
const skippedDocs = formData.documents_skipped || [];
if (!skippedDocs.includes(currentDoc.type)) {
skippedDocs.push(currentDoc.type);
}
updateFormData({
documents_skipped: skippedDocs,
current_doc_index: currentIndex + 1,
});
// Callback
onDocumentSkipped(currentDoc.type);
// Переходим к следующему или завершаем
if (isLastDocument) {
onAllDocumentsComplete();
}
}, [currentDoc, formData, updateFormData, currentIndex, isLastDocument, onDocumentSkipped, onAllDocumentsComplete, addDebugEvent]);
// === Upload Props ===
const uploadProps: UploadProps = {
fileList,
onChange: ({ fileList: newFileList }) => setFileList(newFileList.slice(-1)), // Только один файл
beforeUpload: () => false, // Не загружаем автоматически
maxCount: 1,
accept: currentDoc?.accept
? currentDoc.accept.map(ext => `.${ext}`).join(',')
: '.pdf,.jpg,.jpeg,.png,.heic,.doc,.docx',
disabled: uploading,
};
// === Render ===
if (!currentDoc) {
return (
<Result
status="success"
title="Все документы обработаны"
subTitle="Переходим к формированию заявления..."
extra={<Spin size="large" />}
/>
);
}
return (
<div style={{ maxWidth: 700, margin: '0 auto' }}>
<Card>
{/* === Прогресс === */}
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<Text type="secondary">
Документ {currentIndex + 1} из {totalDocs}
</Text>
<Text type="secondary">
{Math.round((currentIndex / totalDocs) * 100)}% завершено
</Text>
</div>
<Progress
percent={Math.round((currentIndex / totalDocs) * 100)}
showInfo={false}
strokeColor="#595959"
/>
</div>
{/* === Заголовок === */}
<div style={{ marginBottom: 24 }}>
<Title level={3} style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
<FileTextOutlined style={{ color: '#595959' }} />
{currentDoc.name}
{currentDoc.critical && (
<ExclamationCircleOutlined
style={{ color: '#fa8c16', fontSize: 20 }}
title="Важный документ"
/>
)}
</Title>
{currentDoc.hints && (
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
{currentDoc.hints}
</Paragraph>
)}
</div>
{/* === Алерт для критичных документов === */}
{currentDoc.critical && (
<Alert
message="Важный документ"
description="Этот документ значительно повысит шансы на успешное рассмотрение заявки. Если документа нет — можно пропустить, но мы рекомендуем загрузить."
type="warning"
showIcon
icon={<ExclamationCircleOutlined />}
style={{ marginBottom: 24 }}
/>
)}
{/* === Загрузка файла === */}
<Dragger {...uploadProps} style={{ marginBottom: 24 }}>
<p className="ant-upload-drag-icon">
{uploading ? (
<LoadingOutlined style={{ fontSize: 48, color: '#595959' }} spin />
) : (
<InboxOutlined style={{ fontSize: 48, color: '#595959' }} />
)}
</p>
<p className="ant-upload-text">
{uploading
? 'Загружаем документ...'
: 'Перетащите файл сюда или нажмите для выбора'
}
</p>
<p className="ant-upload-hint">
Поддерживаются: PDF, JPG, PNG, HEIC, DOC (до 20 МБ)
</p>
</Dragger>
{/* === Прогресс загрузки === */}
{uploading && (
<Progress
percent={uploadProgress}
status="active"
style={{ marginBottom: 24 }}
/>
)}
{/* === Кнопки === */}
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Button
onClick={onPrev}
disabled={uploading}
size="large"
>
Назад
</Button>
<Space>
<Button
onClick={handleSkip}
disabled={uploading}
size="large"
>
Пропустить
</Button>
<Button
type="primary"
onClick={handleUpload}
loading={uploading}
disabled={fileList.length === 0}
size="large"
icon={<UploadOutlined />}
>
{isLastDocument ? 'Загрузить и продолжить' : 'Загрузить'}
</Button>
</Space>
</Space>
{/* === Уже загруженные документы === */}
{formData.documents_uploaded && formData.documents_uploaded.length > 0 && (
<div style={{ marginTop: 24, padding: 16, background: '#f5f5f5', borderRadius: 8 }}>
<Text strong>Загруженные документы:</Text>
<ul style={{ margin: '8px 0 0 0', paddingLeft: 20 }}>
{formData.documents_uploaded.map((doc: any, idx: number) => (
<li key={idx}>
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: 8 }} />
{documents.find(d => d.type === doc.type)?.name || doc.type}
</li>
))}
</ul>
</div>
)}
</Card>
</div>
);
}

View File

@@ -1,7 +1,37 @@
/**
* StepDraftSelection.tsx
*
* Выбор черновика с поддержкой разных статусов:
* - draft_new: только описание
* - draft_docs_progress: часть документов загружена
* - draft_docs_complete: все документы, ждём заявление
* - draft_claim_ready: заявление готово
* - awaiting_sms: ждёт SMS подтверждения
* - legacy: старый формат (без documents_required)
*
* @version 2.0
* @date 2025-11-26
*/
import { useEffect, useState } from 'react';
import { Button, Card, List, Typography, Space, Empty, Popconfirm, message, Spin, Tag } from 'antd';
import { FileTextOutlined, DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
// Форматирование даты без date-fns (если библиотека не установлена)
import { Button, Card, List, Typography, Space, Empty, Popconfirm, message, Spin, Tag, Alert, Progress, Tooltip } from 'antd';
import {
FileTextOutlined,
DeleteOutlined,
PlusOutlined,
ReloadOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
LoadingOutlined,
UploadOutlined,
FileSearchOutlined,
MobileOutlined,
ExclamationCircleOutlined
} from '@ant-design/icons';
const { Title, Text, Paragraph } = Typography;
// Форматирование даты
const formatDate = (dateStr: string) => {
try {
const date = new Date(dateStr);
@@ -16,35 +46,129 @@ const formatDate = (dateStr: string) => {
}
};
const { Title, Text, Paragraph } = Typography;
// Относительное время
const getRelativeTime = (dateStr: string) => {
try {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'только что';
if (diffMins < 60) return `${diffMins} мин. назад`;
if (diffHours < 24) return `${diffHours} ч. назад`;
if (diffDays < 7) return `${diffDays} дн. назад`;
return formatDate(dateStr);
} catch {
return dateStr;
}
};
interface Draft {
id: string;
claim_id: string;
session_token: string;
status_code: string;
channel: string;
created_at: string;
updated_at: string;
problem_description?: string;
wizard_plan: boolean;
wizard_answers: boolean;
has_documents: boolean;
// Новые поля для нового флоу
documents_total?: number;
documents_uploaded?: number;
documents_skipped?: number;
wizard_ready?: boolean;
claim_ready?: boolean;
is_legacy?: boolean; // Старый формат без documents_required
}
interface Props {
phone?: string;
session_id?: string;
unified_id?: string; // ✅ Добавляем unified_id
unified_id?: string;
onSelectDraft: (claimId: string) => void;
onNewClaim: () => void;
onRestartDraft?: (claimId: string, description: string) => void; // Для legacy черновиков
}
// === Конфиг статусов ===
const STATUS_CONFIG: Record<string, {
color: string;
icon: React.ReactNode;
label: string;
description: string;
action: string;
}> = {
draft: {
color: 'default',
icon: <FileTextOutlined />,
label: 'Черновик',
description: 'Начато заполнение',
action: 'Продолжить',
},
draft_new: {
color: 'blue',
icon: <FileTextOutlined />,
label: 'Новый',
description: 'Только описание проблемы',
action: 'Загрузить документы',
},
draft_docs_progress: {
color: 'processing',
icon: <UploadOutlined />,
label: 'Загрузка документов',
description: 'Часть документов загружена',
action: 'Продолжить загрузку',
},
draft_docs_complete: {
color: 'orange',
icon: <LoadingOutlined />,
label: 'Обработка',
description: 'Формируется заявление...',
action: 'Ожидайте',
},
draft_claim_ready: {
color: 'green',
icon: <CheckCircleOutlined />,
label: 'Готово к отправке',
description: 'Заявление готово',
action: 'Просмотреть и отправить',
},
awaiting_sms: {
color: 'volcano',
icon: <MobileOutlined />,
label: 'Ожидает подтверждения',
description: 'Введите SMS код',
action: 'Подтвердить',
},
in_work: {
color: 'cyan',
icon: <FileSearchOutlined />,
label: 'В работе',
description: 'Заявка на рассмотрении',
action: 'Просмотреть',
},
legacy: {
color: 'warning',
icon: <ExclamationCircleOutlined />,
label: 'Устаревший формат',
description: 'Требуется обновление',
action: 'Начать заново',
},
};
export default function StepDraftSelection({
phone,
session_id,
unified_id, // ✅ Добавляем unified_id
unified_id,
onSelectDraft,
onNewClaim,
onRestartDraft,
}: Props) {
const [drafts, setDrafts] = useState<Draft[]>([]);
const [loading, setLoading] = useState(true);
@@ -54,7 +178,7 @@ export default function StepDraftSelection({
try {
setLoading(true);
const params = new URLSearchParams();
// ✅ Приоритет: unified_id > phone > session_id
if (unified_id) {
params.append('unified_id', unified_id);
console.log('🔍 StepDraftSelection: загружаем черновики по unified_id:', unified_id);
@@ -76,8 +200,18 @@ export default function StepDraftSelection({
const data = await response.json();
console.log('🔍 StepDraftSelection: ответ API:', data);
console.log('🔍 StepDraftSelection: количество черновиков:', data.count);
setDrafts(data.drafts || []);
// Определяем legacy черновики (без documents_required в payload)
const processedDrafts = (data.drafts || []).map((draft: Draft) => {
// Legacy если нет новых полей и есть старый wizard формат
const isLegacy = draft.wizard_plan && !draft.documents_total && draft.status_code === 'draft';
return {
...draft,
is_legacy: isLegacy,
};
});
setDrafts(processedDrafts);
} catch (error) {
console.error('Ошибка загрузки черновиков:', error);
message.error('Не удалось загрузить список черновиков');
@@ -88,7 +222,7 @@ export default function StepDraftSelection({
useEffect(() => {
loadDrafts();
}, [phone, session_id, unified_id]); // ✅ Добавляем unified_id в зависимости
}, [phone, session_id, unified_id]);
const handleDelete = async (claimId: string) => {
try {
@@ -111,14 +245,56 @@ export default function StepDraftSelection({
}
};
// Получение конфига статуса
const getStatusConfig = (draft: Draft) => {
if (draft.is_legacy) {
return STATUS_CONFIG.legacy;
}
return STATUS_CONFIG[draft.status_code] || STATUS_CONFIG.draft;
};
const getProgressInfo = (draft: Draft) => {
const parts: string[] = [];
if (draft.problem_description) parts.push('Описание');
if (draft.wizard_plan) parts.push('План вопросов');
if (draft.wizard_answers) parts.push('Ответы');
if (draft.has_documents) parts.push('Документы');
return parts.length > 0 ? parts.join(', ') : 'Начато';
// Прогресс документов
const getDocsProgress = (draft: Draft) => {
if (!draft.documents_total) return null;
const uploaded = draft.documents_uploaded || 0;
const skipped = draft.documents_skipped || 0;
const total = draft.documents_total;
const percent = Math.round(((uploaded + skipped) / total) * 100);
return { uploaded, skipped, total, percent };
};
// Обработка клика на черновик
const handleDraftAction = (draft: Draft) => {
const draftId = draft.claim_id || draft.id;
if (draft.is_legacy && onRestartDraft) {
// Legacy черновик - предлагаем начать заново с тем же описанием
onRestartDraft(draftId, draft.problem_description || '');
} else if (draft.status_code === 'draft_docs_complete') {
// Всё ещё обрабатывается - показываем сообщение
message.info('Заявление формируется. Пожалуйста, подождите.');
} else {
// Обычный переход
onSelectDraft(draftId);
}
};
// Кнопка действия
const getActionButton = (draft: Draft) => {
const config = getStatusConfig(draft);
const isProcessing = draft.status_code === 'draft_docs_complete';
return (
<Button
type={isProcessing ? 'default' : 'primary'}
onClick={() => handleDraftAction(draft)}
icon={config.icon}
disabled={isProcessing}
loading={isProcessing}
>
{config.action}
</Button>
);
};
return (
@@ -133,10 +309,10 @@ export default function StepDraftSelection({
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Title level={2} style={{ marginBottom: 8, color: '#1890ff' }}>
📋 Ваши черновики заявок
📋 Ваши заявки
</Title>
<Paragraph type="secondary" style={{ fontSize: 14, marginBottom: 16 }}>
Выберите черновик, чтобы продолжить заполнение, или создайте новую заявку.
Выберите заявку для продолжения или создайте новую.
</Paragraph>
</div>
@@ -146,7 +322,7 @@ export default function StepDraftSelection({
</div>
) : drafts.length === 0 ? (
<Empty
description="У вас нет незавершенных черновиков"
description="У вас нет незавершенных заявок"
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<Button type="primary" icon={<PlusOutlined />} onClick={onNewClaim} size="large">
@@ -157,89 +333,130 @@ export default function StepDraftSelection({
<>
<List
dataSource={drafts}
renderItem={(draft) => (
<List.Item
style={{
padding: '16px',
border: '1px solid #d9d9d9',
borderRadius: 8,
marginBottom: 12,
background: '#fff',
}}
actions={[
<Button
key="continue"
type="primary"
onClick={() => {
console.log('🔍 Выбран черновик:', draft.claim_id, 'id:', draft.id);
// Используем id (UUID) если claim_id отсутствует
const draftId = draft.claim_id || draft.id;
console.log('🔍 Загружаем черновик с ID:', draftId);
onSelectDraft(draftId);
}}
icon={<FileTextOutlined />}
>
Продолжить
</Button>,
<Popconfirm
key="delete"
title="Удалить черновик?"
description="Это действие нельзя отменить"
onConfirm={() => handleDelete(draft.claim_id!)}
okText="Да, удалить"
cancelText="Отмена"
>
<Button
danger
icon={<DeleteOutlined />}
loading={deletingId === draft.claim_id}
disabled={deletingId === draft.claim_id}
renderItem={(draft) => {
const config = getStatusConfig(draft);
const docsProgress = getDocsProgress(draft);
return (
<List.Item
style={{
padding: '16px',
border: `1px solid ${draft.is_legacy ? '#faad14' : '#d9d9d9'}`,
borderRadius: 8,
marginBottom: 12,
background: draft.is_legacy ? '#fffbe6' : '#fff',
}}
actions={[
getActionButton(draft),
<Popconfirm
key="delete"
title="Удалить заявку?"
description="Это действие нельзя отменить"
onConfirm={() => handleDelete(draft.claim_id || draft.id)}
okText="Да, удалить"
cancelText="Отмена"
>
Удалить
</Button>
</Popconfirm>,
]}
>
<List.Item.Meta
avatar={<FileTextOutlined style={{ fontSize: 24, color: '#595959' }} />}
title={
<Space>
<Text strong>Черновик</Text>
<Tag color="default">Черновик</Tag>
</Space>
}
description={
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Обновлен: {formatDate(draft.updated_at)}
</Text>
{draft.problem_description && (
<Text
ellipsis={{ tooltip: draft.problem_description }}
style={{ fontSize: 13 }}
>
{draft.problem_description}
<Button
danger
icon={<DeleteOutlined />}
loading={deletingId === (draft.claim_id || draft.id)}
disabled={deletingId === (draft.claim_id || draft.id)}
>
Удалить
</Button>
</Popconfirm>,
]}
>
<List.Item.Meta
avatar={
<div style={{
width: 40,
height: 40,
borderRadius: '50%',
background: draft.is_legacy ? '#fff7e6' : '#f0f0f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 20,
color: draft.is_legacy ? '#faad14' : '#595959',
}}>
{config.icon}
</div>
}
title={
<Space>
<Text strong style={{ fontSize: 16 }}>
{draft.problem_description
? draft.problem_description.substring(0, 50) + (draft.problem_description.length > 50 ? '...' : '')
: 'Заявка'
}
</Text>
)}
<Space size="small">
<Tag color={draft.wizard_plan ? 'green' : 'default'}>
{draft.wizard_plan ? '✓ План' : 'План'}
</Tag>
<Tag color={draft.wizard_answers ? 'green' : 'default'}>
{draft.wizard_answers ? '✓ Ответы' : 'Ответы'}
</Tag>
<Tag color={draft.has_documents ? 'green' : 'default'}>
{draft.has_documents ? '✓ Документы' : 'Документы'}
</Tag>
<Tag color={config.color}>{config.label}</Tag>
</Space>
<Text type="secondary" style={{ fontSize: 12 }}>
Прогресс: {getProgressInfo(draft)}
</Text>
</Space>
}
/>
</List.Item>
)}
}
description={
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{/* Время обновления */}
<Space size="small">
<ClockCircleOutlined style={{ color: '#8c8c8c' }} />
<Tooltip title={formatDate(draft.updated_at)}>
<Text type="secondary" style={{ fontSize: 12 }}>
{getRelativeTime(draft.updated_at)}
</Text>
</Tooltip>
</Space>
{/* Legacy предупреждение */}
{draft.is_legacy && (
<Alert
message="Этот черновик создан в старой версии формы. Нажмите 'Начать заново' для продолжения."
type="warning"
showIcon
style={{ fontSize: 12, padding: '4px 8px' }}
/>
)}
{/* Прогресс документов */}
{docsProgress && (
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
📎 Документы: {docsProgress.uploaded} из {docsProgress.total} загружено
{docsProgress.skipped > 0 && ` (${docsProgress.skipped} пропущено)`}
</Text>
<Progress
percent={docsProgress.percent}
size="small"
showInfo={false}
strokeColor="#52c41a"
/>
</div>
)}
{/* Старые теги прогресса (для обратной совместимости) */}
{!docsProgress && !draft.is_legacy && (
<Space size="small" wrap>
<Tag color={draft.problem_description ? 'green' : 'default'}>
{draft.problem_description ? '✓ Описание' : 'Описание'}
</Tag>
<Tag color={draft.wizard_plan ? 'green' : 'default'}>
{draft.wizard_plan ? '✓ План' : 'План'}
</Tag>
<Tag color={draft.has_documents ? 'green' : 'default'}>
{draft.has_documents ? '✓ Документы' : 'Документы'}
</Tag>
</Space>
)}
{/* Описание статуса */}
<Text type="secondary" style={{ fontSize: 12 }}>
{config.description}
</Text>
</Space>
}
/>
</List.Item>
);
}}
/>
<div style={{ textAlign: 'center', marginTop: 24 }}>
@@ -271,4 +488,3 @@ export default function StepDraftSelection({
</div>
);
}

View File

@@ -0,0 +1,339 @@
/**
* StepWaitingClaim.tsx
*
* Экран ожидания формирования заявления.
* Показывает прогресс: OCR → Анализ → Формирование заявления.
* Подписывается на SSE для получения claim_ready.
*
* @version 1.0
* @date 2025-11-26
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { Card, Typography, Progress, Space, Button, Spin, Result, Steps } from 'antd';
import {
LoadingOutlined,
CheckCircleOutlined,
FileSearchOutlined,
RobotOutlined,
FileTextOutlined,
ClockCircleOutlined
} from '@ant-design/icons';
import AiWorkingIllustration from '../../assets/ai-working.svg';
const { Title, Paragraph, Text } = Typography;
const { Step } = Steps;
interface Props {
sessionId: string;
claimId?: string;
documentsCount: number;
onClaimReady: (claimData: any) => void;
onTimeout: () => void;
onError: (error: string) => void;
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
}
type ProcessingStep = 'ocr' | 'analysis' | 'generation' | 'ready';
interface ProcessingState {
currentStep: ProcessingStep;
ocrCompleted: number;
ocrTotal: number;
message: string;
}
export default function StepWaitingClaim({
sessionId,
claimId,
documentsCount,
onClaimReady,
onTimeout,
onError,
addDebugEvent,
}: Props) {
const eventSourceRef = useRef<EventSource | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const [state, setState] = useState<ProcessingState>({
currentStep: 'ocr',
ocrCompleted: 0,
ocrTotal: documentsCount,
message: 'Распознаём документы...',
});
const [elapsedTime, setElapsedTime] = useState(0);
const [error, setError] = useState<string | null>(null);
// Таймер для отображения времени
useEffect(() => {
const interval = setInterval(() => {
setElapsedTime(prev => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
// SSE подписка
useEffect(() => {
if (!sessionId) {
setError('Отсутствует session_id');
return;
}
console.log('🔌 StepWaitingClaim: подписываемся на SSE', { sessionId, claimId });
const eventSource = new EventSource(`/api/v1/events/${sessionId}`);
eventSourceRef.current = eventSource;
addDebugEvent?.('waiting', 'info', '🔌 Подписка на SSE для ожидания заявления', {
session_id: sessionId,
claim_id: claimId,
});
// Таймаут 5 минут
timeoutRef.current = setTimeout(() => {
console.warn('⏰ Timeout ожидания заявления');
setError('Превышено время ожидания. Попробуйте обновить страницу.');
addDebugEvent?.('waiting', 'warning', '⏰ Таймаут ожидания заявления');
eventSource.close();
onTimeout();
}, 300000); // 5 минут
eventSource.onopen = () => {
console.log('✅ SSE соединение открыто (waiting)');
addDebugEvent?.('waiting', 'info', '✅ SSE соединение открыто');
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('📥 SSE event (waiting):', data);
const eventType = data.event_type || data.type;
// OCR документа завершён
if (eventType === 'document_ocr_completed') {
setState(prev => ({
...prev,
ocrCompleted: prev.ocrCompleted + 1,
message: `Распознано ${prev.ocrCompleted + 1} из ${prev.ocrTotal} документов`,
}));
addDebugEvent?.('waiting', 'info', `📄 OCR завершён: ${data.document_type}`);
}
// Все документы распознаны, начинаем анализ
if (eventType === 'ocr_all_completed' || eventType === 'analysis_started') {
setState(prev => ({
...prev,
currentStep: 'analysis',
message: 'Анализируем данные...',
}));
addDebugEvent?.('waiting', 'info', '🔍 Начат анализ данных');
}
// Генерация заявления
if (eventType === 'claim_generation_started') {
setState(prev => ({
...prev,
currentStep: 'generation',
message: 'Формируем заявление...',
}));
addDebugEvent?.('waiting', 'info', '📝 Начато формирование заявления');
}
// Заявление готово!
if (eventType === 'claim_ready' || eventType === 'claim_plan_ready') {
console.log('🎉 Заявление готово!', data);
// Очищаем таймаут
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
setState(prev => ({
...prev,
currentStep: 'ready',
message: 'Заявление готово!',
}));
addDebugEvent?.('waiting', 'success', '✅ Заявление готово');
// Закрываем SSE
eventSource.close();
eventSourceRef.current = null;
// Callback с данными
setTimeout(() => {
onClaimReady(data.data || data.claim_data || data);
}, 500);
}
// Ошибка
if (eventType === 'claim_error' || data.status === 'error') {
setError(data.message || 'Произошла ошибка при формировании заявления');
addDebugEvent?.('waiting', 'error', `❌ Ошибка: ${data.message}`);
eventSource.close();
onError(data.message);
}
} catch (err) {
console.error('❌ Ошибка парсинга SSE:', err);
}
};
eventSource.onerror = (err) => {
console.error('❌ SSE error (waiting):', err);
// Не показываем ошибку сразу — SSE может переподключиться
};
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
};
}, [sessionId, claimId, onClaimReady, onTimeout, onError, addDebugEvent]);
// Форматирование времени
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// Вычисляем процент прогресса
const getProgress = (): number => {
switch (state.currentStep) {
case 'ocr':
// OCR: 0-50%
return state.ocrTotal > 0
? Math.round((state.ocrCompleted / state.ocrTotal) * 50)
: 25;
case 'analysis':
return 60;
case 'generation':
return 85;
case 'ready':
return 100;
default:
return 0;
}
};
// Индекс текущего шага для Steps
const getStepIndex = (): number => {
switch (state.currentStep) {
case 'ocr': return 0;
case 'analysis': return 1;
case 'generation': return 2;
case 'ready': return 3;
default: return 0;
}
};
// === Render ===
if (error) {
return (
<Result
status="error"
title="Ошибка"
subTitle={error}
extra={
<Button type="primary" onClick={() => window.location.reload()}>
Обновить страницу
</Button>
}
/>
);
}
if (state.currentStep === 'ready') {
return (
<Result
status="success"
title="Заявление готово!"
subTitle="Переходим к просмотру..."
icon={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
extra={<Spin size="large" />}
/>
);
}
return (
<div style={{ maxWidth: 600, margin: '0 auto' }}>
<Card style={{ textAlign: 'center' }}>
{/* === Иллюстрация === */}
<img
src={AiWorkingIllustration}
alt="AI работает"
style={{ maxWidth: 280, width: '100%', marginBottom: 24 }}
/>
{/* === Заголовок === */}
<Title level={3}>{state.message}</Title>
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
Наш AI-ассистент обрабатывает ваши документы и формирует заявление.
Это займёт 1-2 минуты.
</Paragraph>
{/* === Прогресс === */}
<Progress
percent={getProgress()}
status="active"
strokeColor={{
'0%': '#108ee9',
'100%': '#87d068',
}}
style={{ marginBottom: 24 }}
/>
{/* === Шаги обработки === */}
<Steps
current={getStepIndex()}
size="small"
style={{ marginBottom: 24 }}
>
<Step
title="OCR"
description={state.ocrTotal > 0 ? `${state.ocrCompleted}/${state.ocrTotal}` : ''}
icon={state.currentStep === 'ocr' ? <LoadingOutlined /> : <FileSearchOutlined />}
/>
<Step
title="Анализ"
icon={state.currentStep === 'analysis' ? <LoadingOutlined /> : <RobotOutlined />}
/>
<Step
title="Заявление"
icon={state.currentStep === 'generation' ? <LoadingOutlined /> : <FileTextOutlined />}
/>
<Step
title="Готово"
icon={<CheckCircleOutlined />}
/>
</Steps>
{/* === Таймер === */}
<Space>
<ClockCircleOutlined style={{ color: '#8c8c8c' }} />
<Text type="secondary">
Время ожидания: {formatTime(elapsedTime)}
</Text>
</Space>
{/* === Подсказка === */}
<Paragraph type="secondary" style={{ marginTop: 16, fontSize: 12 }}>
Не закрывайте эту страницу. Обработка происходит на сервере.
</Paragraph>
</Card>
</div>
);
}