From 6c770f0a872a4dee22477c5589fe33ebb319abd8 Mon Sep 17 00:00:00 2001 From: Fedor Date: Wed, 26 Nov 2025 12:52:54 +0300 Subject: [PATCH] =?UTF-8?q?feat(ticket=5Fform):=20=D0=9D=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=B0=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D1=80=D0=B0=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ticket_form/SESSION_LOG_2025-11-22_DIALOG.md | 1 + .../SESSION_LOG_2025-11-26_NEW_FLOW.md | 143 ++++++ ticket_form/backend/app/api/documents.py | 270 +++++++++++ ticket_form/backend/app/main.py | 3 +- ticket_form/docs/NEW_FLOW_ARCHITECTURE.md | 383 ++++++++++++++++ .../src/components/form/StepDocumentsNew.tsx | 362 +++++++++++++++ .../components/form/StepDraftSelection.tsx | 418 +++++++++++++----- .../src/components/form/StepWaitingClaim.tsx | 339 ++++++++++++++ 8 files changed, 1817 insertions(+), 102 deletions(-) create mode 100644 ticket_form/SESSION_LOG_2025-11-26_NEW_FLOW.md create mode 100644 ticket_form/backend/app/api/documents.py create mode 100644 ticket_form/docs/NEW_FLOW_ARCHITECTURE.md create mode 100644 ticket_form/frontend/src/components/form/StepDocumentsNew.tsx create mode 100644 ticket_form/frontend/src/components/form/StepWaitingClaim.tsx diff --git a/ticket_form/SESSION_LOG_2025-11-22_DIALOG.md b/ticket_form/SESSION_LOG_2025-11-22_DIALOG.md index 93214bec..b1c0bbf5 100644 --- a/ticket_form/SESSION_LOG_2025-11-22_DIALOG.md +++ b/ticket_form/SESSION_LOG_2025-11-22_DIALOG.md @@ -189,3 +189,4 @@ 3. `field_label` из формы визарда используется для генерации slug файлов 4. Все ноды n8n должны безопасно обрабатывать отсутствие данных + diff --git a/ticket_form/SESSION_LOG_2025-11-26_NEW_FLOW.md b/ticket_form/SESSION_LOG_2025-11-26_NEW_FLOW.md new file mode 100644 index 00000000..2bfde6d2 --- /dev/null +++ b/ticket_form/SESSION_LOG_2025-11-26_NEW_FLOW.md @@ -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` — запрос на генерацию списка документов + diff --git a/ticket_form/backend/app/api/documents.py b/ticket_form/backend/app/api/documents.py new file mode 100644 index 00000000..332bc49a --- /dev/null +++ b/ticket_form/backend/app/api/documents.py @@ -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)}", + ) + diff --git a/ticket_form/backend/app/main.py b/ticket_form/backend/app/main.py index 45c25244..44d4f7d9 100644 --- a/ticket_form/backend/app/main.py +++ b/ticket_form/backend/app/main.py @@ -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("/") diff --git a/ticket_form/docs/NEW_FLOW_ARCHITECTURE.md b/ticket_form/docs/NEW_FLOW_ARCHITECTURE.md new file mode 100644 index 00000000..3c1f423c --- /dev/null +++ b/ticket_form/docs/NEW_FLOW_ARCHITECTURE.md @@ -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 (только уточняющие) | +| Конверсия | ? | ↑ (меньше отвала) | + diff --git a/ticket_form/frontend/src/components/form/StepDocumentsNew.tsx b/ticket_form/frontend/src/components/form/StepDocumentsNew.tsx new file mode 100644 index 00000000..f17f94ba --- /dev/null +++ b/ticket_form/frontend/src/components/form/StepDocumentsNew.tsx @@ -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([]); + 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 ( + } + /> + ); + } + + return ( +
+ + {/* === Прогресс === */} +
+
+ + Документ {currentIndex + 1} из {totalDocs} + + + {Math.round((currentIndex / totalDocs) * 100)}% завершено + +
+ +
+ + {/* === Заголовок === */} +
+ + <FileTextOutlined style={{ color: '#595959' }} /> + {currentDoc.name} + {currentDoc.critical && ( + <ExclamationCircleOutlined + style={{ color: '#fa8c16', fontSize: 20 }} + title="Важный документ" + /> + )} + + + {currentDoc.hints && ( + + {currentDoc.hints} + + )} +
+ + {/* === Алерт для критичных документов === */} + {currentDoc.critical && ( + } + style={{ marginBottom: 24 }} + /> + )} + + {/* === Загрузка файла === */} + +

+ {uploading ? ( + + ) : ( + + )} +

+

+ {uploading + ? 'Загружаем документ...' + : 'Перетащите файл сюда или нажмите для выбора' + } +

+

+ Поддерживаются: PDF, JPG, PNG, HEIC, DOC (до 20 МБ) +

+
+ + {/* === Прогресс загрузки === */} + {uploading && ( + + )} + + {/* === Кнопки === */} + + + + + + + + + + + {/* === Уже загруженные документы === */} + {formData.documents_uploaded && formData.documents_uploaded.length > 0 && ( +
+ Загруженные документы: +
    + {formData.documents_uploaded.map((doc: any, idx: number) => ( +
  • + + {documents.find(d => d.type === doc.type)?.name || doc.type} +
  • + ))} +
+
+ )} +
+
+ ); +} + diff --git a/ticket_form/frontend/src/components/form/StepDraftSelection.tsx b/ticket_form/frontend/src/components/form/StepDraftSelection.tsx index 31264172..d8187d17 100644 --- a/ticket_form/frontend/src/components/form/StepDraftSelection.tsx +++ b/ticket_form/frontend/src/components/form/StepDraftSelection.tsx @@ -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 = { + draft: { + color: 'default', + icon: , + label: 'Черновик', + description: 'Начато заполнение', + action: 'Продолжить', + }, + draft_new: { + color: 'blue', + icon: , + label: 'Новый', + description: 'Только описание проблемы', + action: 'Загрузить документы', + }, + draft_docs_progress: { + color: 'processing', + icon: , + label: 'Загрузка документов', + description: 'Часть документов загружена', + action: 'Продолжить загрузку', + }, + draft_docs_complete: { + color: 'orange', + icon: , + label: 'Обработка', + description: 'Формируется заявление...', + action: 'Ожидайте', + }, + draft_claim_ready: { + color: 'green', + icon: , + label: 'Готово к отправке', + description: 'Заявление готово', + action: 'Просмотреть и отправить', + }, + awaiting_sms: { + color: 'volcano', + icon: , + label: 'Ожидает подтверждения', + description: 'Введите SMS код', + action: 'Подтвердить', + }, + in_work: { + color: 'cyan', + icon: , + label: 'В работе', + description: 'Заявка на рассмотрении', + action: 'Просмотреть', + }, + legacy: { + color: 'warning', + icon: , + label: 'Устаревший формат', + description: 'Требуется обновление', + action: 'Начать заново', + }, +}; + export default function StepDraftSelection({ phone, session_id, - unified_id, // ✅ Добавляем unified_id + unified_id, onSelectDraft, onNewClaim, + onRestartDraft, }: Props) { const [drafts, setDrafts] = useState([]); 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 ( + + ); }; return ( @@ -133,10 +309,10 @@ export default function StepDraftSelection({
- 📋 Ваши черновики заявок + 📋 Ваши заявки - Выберите черновик, чтобы продолжить заполнение, или создайте новую заявку. + Выберите заявку для продолжения или создайте новую.
@@ -146,7 +322,7 @@ export default function StepDraftSelection({ ) : drafts.length === 0 ? ( , - handleDelete(draft.claim_id!)} - okText="Да, удалить" - cancelText="Отмена" - > - - , - ]} - > - } - title={ - - Черновик - Черновик - - } - description={ - - - Обновлен: {formatDate(draft.updated_at)} - - {draft.problem_description && ( - - {draft.problem_description} + + , + ]} + > + + {config.icon} + + } + title={ + + + {draft.problem_description + ? draft.problem_description.substring(0, 50) + (draft.problem_description.length > 50 ? '...' : '') + : 'Заявка' + } - )} - - - {draft.wizard_plan ? '✓ План' : 'План'} - - - {draft.wizard_answers ? '✓ Ответы' : 'Ответы'} - - - {draft.has_documents ? '✓ Документы' : 'Документы'} - + {config.label} - - Прогресс: {getProgressInfo(draft)} - - - } - /> - - )} + } + description={ + + {/* Время обновления */} + + + + + {getRelativeTime(draft.updated_at)} + + + + + {/* Legacy предупреждение */} + {draft.is_legacy && ( + + )} + + {/* Прогресс документов */} + {docsProgress && ( +
+ + 📎 Документы: {docsProgress.uploaded} из {docsProgress.total} загружено + {docsProgress.skipped > 0 && ` (${docsProgress.skipped} пропущено)`} + + +
+ )} + + {/* Старые теги прогресса (для обратной совместимости) */} + {!docsProgress && !draft.is_legacy && ( + + + {draft.problem_description ? '✓ Описание' : 'Описание'} + + + {draft.wizard_plan ? '✓ План' : 'План'} + + + {draft.has_documents ? '✓ Документы' : 'Документы'} + + + )} + + {/* Описание статуса */} + + {config.description} + +
+ } + /> + + ); + }} />
@@ -271,4 +488,3 @@ export default function StepDraftSelection({
); } - diff --git a/ticket_form/frontend/src/components/form/StepWaitingClaim.tsx b/ticket_form/frontend/src/components/form/StepWaitingClaim.tsx new file mode 100644 index 00000000..2cf801ff --- /dev/null +++ b/ticket_form/frontend/src/components/form/StepWaitingClaim.tsx @@ -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(null); + const timeoutRef = useRef(null); + + const [state, setState] = useState({ + currentStep: 'ocr', + ocrCompleted: 0, + ocrTotal: documentsCount, + message: 'Распознаём документы...', + }); + + const [elapsedTime, setElapsedTime] = useState(0); + const [error, setError] = useState(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 ( + window.location.reload()}> + Обновить страницу + + } + /> + ); + } + + if (state.currentStep === 'ready') { + return ( + } + extra={} + /> + ); + } + + return ( +
+ + {/* === Иллюстрация === */} + AI работает + + {/* === Заголовок === */} + {state.message} + + + Наш AI-ассистент обрабатывает ваши документы и формирует заявление. + Это займёт 1-2 минуты. + + + {/* === Прогресс === */} + + + {/* === Шаги обработки === */} + + 0 ? `${state.ocrCompleted}/${state.ocrTotal}` : ''} + icon={state.currentStep === 'ocr' ? : } + /> + : } + /> + : } + /> + } + /> + + + {/* === Таймер === */} + + + + Время ожидания: {formatTime(elapsedTime)} + + + + {/* === Подсказка === */} + + Не закрывайте эту страницу. Обработка происходит на сервере. + + +
+ ); +} +