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:
@@ -189,3 +189,4 @@
|
|||||||
3. `field_label` из формы визарда используется для генерации slug файлов
|
3. `field_label` из формы визарда используется для генерации slug файлов
|
||||||
4. Все ноды n8n должны безопасно обрабатывать отсутствие данных
|
4. Все ноды n8n должны безопасно обрабатывать отсутствие данных
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
143
ticket_form/SESSION_LOG_2025-11-26_NEW_FLOW.md
Normal file
143
ticket_form/SESSION_LOG_2025-11-26_NEW_FLOW.md
Normal 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` — запрос на генерацию списка документов
|
||||||
|
|
||||||
270
ticket_form/backend/app/api/documents.py
Normal file
270
ticket_form/backend/app/api/documents.py
Normal 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)}",
|
||||||
|
)
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ from .services.redis_service import redis_service
|
|||||||
from .services.rabbitmq_service import rabbitmq_service
|
from .services.rabbitmq_service import rabbitmq_service
|
||||||
from .services.policy_service import policy_service
|
from .services.policy_service import policy_service
|
||||||
from .services.s3_service import s3_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(
|
logging.basicConfig(
|
||||||
@@ -103,6 +103,7 @@ app.include_router(draft.router)
|
|||||||
app.include_router(events.router)
|
app.include_router(events.router)
|
||||||
app.include_router(n8n_proxy.router) # 🔒 Безопасный proxy к n8n webhooks
|
app.include_router(n8n_proxy.router) # 🔒 Безопасный proxy к n8n webhooks
|
||||||
app.include_router(session.router) # 🔑 Session management через Redis
|
app.include_router(session.router) # 🔑 Session management через Redis
|
||||||
|
app.include_router(documents.router) # 📄 Documents upload and processing
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
383
ticket_form/docs/NEW_FLOW_ARCHITECTURE.md
Normal file
383
ticket_form/docs/NEW_FLOW_ARCHITECTURE.md
Normal 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 (только уточняющие) |
|
||||||
|
| Конверсия | ? | ↑ (меньше отвала) |
|
||||||
|
|
||||||
362
ticket_form/frontend/src/components/form/StepDocumentsNew.tsx
Normal file
362
ticket_form/frontend/src/components/form/StepDocumentsNew.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 { useEffect, useState } from 'react';
|
||||||
import { Button, Card, List, Typography, Space, Empty, Popconfirm, message, Spin, Tag } from 'antd';
|
import { Button, Card, List, Typography, Space, Empty, Popconfirm, message, Spin, Tag, Alert, Progress, Tooltip } from 'antd';
|
||||||
import { FileTextOutlined, DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
import {
|
||||||
// Форматирование даты без date-fns (если библиотека не установлена)
|
FileTextOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
FileSearchOutlined,
|
||||||
|
MobileOutlined,
|
||||||
|
ExclamationCircleOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
// Форматирование даты
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
try {
|
try {
|
||||||
const date = new Date(dateStr);
|
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 {
|
interface Draft {
|
||||||
id: string;
|
id: string;
|
||||||
claim_id: string;
|
claim_id: string;
|
||||||
session_token: string;
|
session_token: string;
|
||||||
status_code: string;
|
status_code: string;
|
||||||
|
channel: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
problem_description?: string;
|
problem_description?: string;
|
||||||
wizard_plan: boolean;
|
wizard_plan: boolean;
|
||||||
wizard_answers: boolean;
|
wizard_answers: boolean;
|
||||||
has_documents: 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 {
|
interface Props {
|
||||||
phone?: string;
|
phone?: string;
|
||||||
session_id?: string;
|
session_id?: string;
|
||||||
unified_id?: string; // ✅ Добавляем unified_id
|
unified_id?: string;
|
||||||
onSelectDraft: (claimId: string) => void;
|
onSelectDraft: (claimId: string) => void;
|
||||||
onNewClaim: () => 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({
|
export default function StepDraftSelection({
|
||||||
phone,
|
phone,
|
||||||
session_id,
|
session_id,
|
||||||
unified_id, // ✅ Добавляем unified_id
|
unified_id,
|
||||||
onSelectDraft,
|
onSelectDraft,
|
||||||
onNewClaim,
|
onNewClaim,
|
||||||
|
onRestartDraft,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [drafts, setDrafts] = useState<Draft[]>([]);
|
const [drafts, setDrafts] = useState<Draft[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -54,7 +178,7 @@ export default function StepDraftSelection({
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
// ✅ Приоритет: unified_id > phone > session_id
|
|
||||||
if (unified_id) {
|
if (unified_id) {
|
||||||
params.append('unified_id', unified_id);
|
params.append('unified_id', unified_id);
|
||||||
console.log('🔍 StepDraftSelection: загружаем черновики по unified_id:', unified_id);
|
console.log('🔍 StepDraftSelection: загружаем черновики по unified_id:', unified_id);
|
||||||
@@ -76,8 +200,18 @@ export default function StepDraftSelection({
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('🔍 StepDraftSelection: ответ API:', data);
|
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) {
|
} catch (error) {
|
||||||
console.error('Ошибка загрузки черновиков:', error);
|
console.error('Ошибка загрузки черновиков:', error);
|
||||||
message.error('Не удалось загрузить список черновиков');
|
message.error('Не удалось загрузить список черновиков');
|
||||||
@@ -88,7 +222,7 @@ export default function StepDraftSelection({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDrafts();
|
loadDrafts();
|
||||||
}, [phone, session_id, unified_id]); // ✅ Добавляем unified_id в зависимости
|
}, [phone, session_id, unified_id]);
|
||||||
|
|
||||||
const handleDelete = async (claimId: string) => {
|
const handleDelete = async (claimId: string) => {
|
||||||
try {
|
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[] = [];
|
const getDocsProgress = (draft: Draft) => {
|
||||||
if (draft.problem_description) parts.push('Описание');
|
if (!draft.documents_total) return null;
|
||||||
if (draft.wizard_plan) parts.push('План вопросов');
|
const uploaded = draft.documents_uploaded || 0;
|
||||||
if (draft.wizard_answers) parts.push('Ответы');
|
const skipped = draft.documents_skipped || 0;
|
||||||
if (draft.has_documents) parts.push('Документы');
|
const total = draft.documents_total;
|
||||||
return parts.length > 0 ? parts.join(', ') : 'Начато';
|
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 (
|
return (
|
||||||
@@ -133,10 +309,10 @@ export default function StepDraftSelection({
|
|||||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
<div>
|
<div>
|
||||||
<Title level={2} style={{ marginBottom: 8, color: '#1890ff' }}>
|
<Title level={2} style={{ marginBottom: 8, color: '#1890ff' }}>
|
||||||
📋 Ваши черновики заявок
|
📋 Ваши заявки
|
||||||
</Title>
|
</Title>
|
||||||
<Paragraph type="secondary" style={{ fontSize: 14, marginBottom: 16 }}>
|
<Paragraph type="secondary" style={{ fontSize: 14, marginBottom: 16 }}>
|
||||||
Выберите черновик, чтобы продолжить заполнение, или создайте новую заявку.
|
Выберите заявку для продолжения или создайте новую.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -146,7 +322,7 @@ export default function StepDraftSelection({
|
|||||||
</div>
|
</div>
|
||||||
) : drafts.length === 0 ? (
|
) : drafts.length === 0 ? (
|
||||||
<Empty
|
<Empty
|
||||||
description="У вас нет незавершенных черновиков"
|
description="У вас нет незавершенных заявок"
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
>
|
>
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={onNewClaim} size="large">
|
<Button type="primary" icon={<PlusOutlined />} onClick={onNewClaim} size="large">
|
||||||
@@ -157,89 +333,130 @@ export default function StepDraftSelection({
|
|||||||
<>
|
<>
|
||||||
<List
|
<List
|
||||||
dataSource={drafts}
|
dataSource={drafts}
|
||||||
renderItem={(draft) => (
|
renderItem={(draft) => {
|
||||||
<List.Item
|
const config = getStatusConfig(draft);
|
||||||
style={{
|
const docsProgress = getDocsProgress(draft);
|
||||||
padding: '16px',
|
|
||||||
border: '1px solid #d9d9d9',
|
return (
|
||||||
borderRadius: 8,
|
<List.Item
|
||||||
marginBottom: 12,
|
style={{
|
||||||
background: '#fff',
|
padding: '16px',
|
||||||
}}
|
border: `1px solid ${draft.is_legacy ? '#faad14' : '#d9d9d9'}`,
|
||||||
actions={[
|
borderRadius: 8,
|
||||||
<Button
|
marginBottom: 12,
|
||||||
key="continue"
|
background: draft.is_legacy ? '#fffbe6' : '#fff',
|
||||||
type="primary"
|
}}
|
||||||
onClick={() => {
|
actions={[
|
||||||
console.log('🔍 Выбран черновик:', draft.claim_id, 'id:', draft.id);
|
getActionButton(draft),
|
||||||
// Используем id (UUID) если claim_id отсутствует
|
<Popconfirm
|
||||||
const draftId = draft.claim_id || draft.id;
|
key="delete"
|
||||||
console.log('🔍 Загружаем черновик с ID:', draftId);
|
title="Удалить заявку?"
|
||||||
onSelectDraft(draftId);
|
description="Это действие нельзя отменить"
|
||||||
}}
|
onConfirm={() => handleDelete(draft.claim_id || draft.id)}
|
||||||
icon={<FileTextOutlined />}
|
okText="Да, удалить"
|
||||||
>
|
cancelText="Отмена"
|
||||||
Продолжить
|
|
||||||
</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}
|
|
||||||
>
|
>
|
||||||
Удалить
|
<Button
|
||||||
</Button>
|
danger
|
||||||
</Popconfirm>,
|
icon={<DeleteOutlined />}
|
||||||
]}
|
loading={deletingId === (draft.claim_id || draft.id)}
|
||||||
>
|
disabled={deletingId === (draft.claim_id || draft.id)}
|
||||||
<List.Item.Meta
|
>
|
||||||
avatar={<FileTextOutlined style={{ fontSize: 24, color: '#595959' }} />}
|
Удалить
|
||||||
title={
|
</Button>
|
||||||
<Space>
|
</Popconfirm>,
|
||||||
<Text strong>Черновик</Text>
|
]}
|
||||||
<Tag color="default">Черновик</Tag>
|
>
|
||||||
</Space>
|
<List.Item.Meta
|
||||||
}
|
avatar={
|
||||||
description={
|
<div style={{
|
||||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
width: 40,
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
height: 40,
|
||||||
Обновлен: {formatDate(draft.updated_at)}
|
borderRadius: '50%',
|
||||||
</Text>
|
background: draft.is_legacy ? '#fff7e6' : '#f0f0f0',
|
||||||
{draft.problem_description && (
|
display: 'flex',
|
||||||
<Text
|
alignItems: 'center',
|
||||||
ellipsis={{ tooltip: draft.problem_description }}
|
justifyContent: 'center',
|
||||||
style={{ fontSize: 13 }}
|
fontSize: 20,
|
||||||
>
|
color: draft.is_legacy ? '#faad14' : '#595959',
|
||||||
{draft.problem_description}
|
}}>
|
||||||
|
{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>
|
</Text>
|
||||||
)}
|
<Tag color={config.color}>{config.label}</Tag>
|
||||||
<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>
|
|
||||||
</Space>
|
</Space>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
}
|
||||||
Прогресс: {getProgressInfo(draft)}
|
description={
|
||||||
</Text>
|
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||||
</Space>
|
{/* Время обновления */}
|
||||||
}
|
<Space size="small">
|
||||||
/>
|
<ClockCircleOutlined style={{ color: '#8c8c8c' }} />
|
||||||
</List.Item>
|
<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 }}>
|
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
||||||
@@ -271,4 +488,3 @@ export default function StepDraftSelection({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
339
ticket_form/frontend/src/components/form/StepWaitingClaim.tsx
Normal file
339
ticket_form/frontend/src/components/form/StepWaitingClaim.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user