feat(documents): дедупликация documents_meta и исправление field_label

- Исправлен N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js: использовать uploads_field_labels[0] вместо [grp]
- Создан SQL_CLAIMSAVE_FIXED_NEW_FLOW_DEDUP.sql с дедупликацией documents_meta
- Создан SQL_CLEANUP_DOCUMENTS_META_DUPLICATES.sql для очистки существующих дубликатов
- Создан полный уникальный индекс idx_document_texts_hash_unique на document_texts(file_hash)
- Добавлен SESSION_LOG_2025-11-28_documents_dedup.md с описанием всех изменений

Fixes:
- field_label теперь корректно отображает 'Переписка' вместо 'group-2'
- documents_meta не накапливает дубликаты при повторных сохранениях
- ON CONFLICT (file_hash) теперь работает для document_texts
This commit is contained in:
Fedor
2025-11-28 18:16:53 +03:00
parent 6c770f0a87
commit 840acca51a
118 changed files with 13379 additions and 218 deletions

View File

@@ -0,0 +1,176 @@
# Лог сессии: Исправление загрузки документов и SQL запросов
**Дата:** 2025-11-26
**Тема:** Исправление потери документов, дубликатов и правильного определения field_name
---
## Проблемы, которые были решены
### 1. Потеря документов при обновлении черновика
**Проблема:** При обработке нового документа через SQL `claimsave_final` существующие документы терялись.
**Причина:**
- SQL перезаписывал `documents_meta` вместо объединения
- `documents_uploaded` мог быть перезаписан пустым массивом, если `jsonb_agg` возвращал NULL
**Решение:**
- Исправлен SQL `SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql`:
- `documents_meta` теперь объединяется с существующими
- `documents_uploaded` всегда начинается с существующих документов
- Добавлена проверка на пустой массив перед перезаписью
### 2. Дубликаты документов в documents_meta
**Проблема:** В `documents_meta` были дубликаты (один и тот же `file_id` встречался несколько раз).
**Решение:**
- Создан скрипт `fix_documents_meta_duplicates.py` для удаления дубликатов
- Исправлена логика объединения в SQL
### 3. Неправильное определение типа документа
**Проблема:** Чек определялся как `contract` вместо `payment`.
**Причина:**
- SQL проверял `field_name` раньше, чем `field_label`
- `field_name` был `uploads[0][0]` для всех документов
**Решение:**
- Изменён порядок проверки в SQL: сначала `field_label`, потом `field_name`
- Исправлен файл `SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql`
### 4. Все документы имели одинаковый field_name
**Проблема:** В таблице `clpr_claim_documents` все документы имели `field_name: uploads[0][0]`, из-за чего второй документ перезаписывал первый.
**Причина:**
- `group_index` (индекс документа в `documents_required`) не передавался с фронтенда
- Код n8n использовал `group_index_num` из OCR, который всегда был `0`
**Решение:**
- Фронтенд (`StepWizardPlan.tsx`): добавлена передача `group_index` в запрос
- Бэкенд (`documents.py`): добавлено получение `group_index` из Form и передача в n8n
- Код n8n (`N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js`): приоритет `group_index` из body над `group_index_num` из OCR
- Создан скрипт `fix_claim_documents_field_names.py` для исправления существующих документов
### 5. SQL для claimsave перезаписывал documents_meta
**Проблема:** SQL `claimsave` перезаписывал `documents_meta` вместо объединения.
**Решение:**
- Исправлен файл `SQL_CLAIMSAVE_FIXED_NEW_FLOW.sql`:
- `documents_meta` объединяется с существующими
- Критичные поля удаляются из нового payload перед объединением
- Затем устанавливаются отдельно через `jsonb_set`
### 6. Дубликаты в списке загруженных документов на фронтенде
**Проблема:** React ошибка "Encountered two children with the same key, `contract`".
**Решение:**
- Исправлен `StepWizardPlan.tsx`:
- Убраны дубликаты при инициализации `uploadedDocs`
- Проверка на дубликаты при добавлении нового документа
- Использование `Array.from(new Set())` при рендеринге
---
## Созданные файлы
### SQL запросы
- `docs/SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql` - SQL для сохранения документов с автоматическим созданием `documents_uploaded`
- `docs/SQL_CLAIMSAVE_FIXED_NEW_FLOW.sql` - Исправленный SQL для `claimsave` с объединением `documents_meta`
- `docs/SQL_FIX_DRAFT_BDDB6815.sql` - SQL для исправления конкретного черновика
- `docs/SQL_FIX_CLAIM_DOCUMENTS_FIELD_NAMES.sql` - SQL для исправления `field_name` в таблице
### Код n8n
- `docs/N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js` - Исправленный код для обработки загруженных файлов с поддержкой `group_index`
### Скрипты для исправления данных
- `fix_draft_bddb6815_with_contract.py` - Скрипт для исправления черновика с учётом загруженных документов
- `fix_documents_meta_duplicates.py` - Скрипт для удаления дубликатов из `documents_meta`
- `fix_claim_documents_field_names.py` - Скрипт для исправления `field_name` в таблице `clpr_claim_documents`
- `check_documents_detailed.py` - Скрипт для детальной проверки документов
- `check_documents_mismatch.py` - Скрипт для проверки несоответствий между `documents_uploaded` и таблицей
---
## Изменённые файлы
### Backend
- `backend/app/api/documents.py` - Добавлена передача `group_index` в n8n
- `backend/app/api/claims.py` - Обновлена логика загрузки черновиков, добавлена поддержка `documents_required`
- `backend/app/api/events.py` - Исправлены синтаксические ошибки (удалены дубликаты кода)
- `backend/app/api/models.py` - Добавлены поля `unified_id` и `contact_id`
### Frontend
- `frontend/src/pages/ClaimForm.tsx` - Обновлена логика загрузки черновиков, добавлена поддержка нового флоу
- `frontend/src/components/form/StepWizardPlan.tsx` - Добавлена передача `group_index`, исправлены дубликаты в списке документов
- `frontend/src/components/form/StepDraftSelection.tsx` - Обновлена логика определения legacy черновиков
- `frontend/src/components/form/StepDescription.tsx` - Добавлена передача `unified_id` и `contact_id`
---
## Результаты
### Исправлено для черновика `bddb6815-8e17-4d54-a721-5e94382942c7`:
- ✅ Удалены дубликаты из `documents_meta` (было 4, стало 3)
- ✅ Исправлены типы документов в `documents_uploaded` (чек теперь `payment`, а не `contract`)
- ✅ Исправлены `field_name` в таблице `clpr_claim_documents`:
- `uploads[0][0]` - contract (договор)
- `uploads[1][0]` - payment (чек)
- `uploads[3][0]` - evidence_photo (фото доказательства)
### Текущее состояние:
- `documents_required`: 4 документа
- `documents_uploaded`: 2 документа (contract, payment)
- `documents_meta`: 3 документа (без дубликатов)
- `current_doc_index`: 2 (следующий документ - correspondence)
- `status_code`: `draft_docs_progress`
---
## Что нужно сделать дальше
1. **Обновить код n8n:**
- Заменить код в узле "Process Uploaded Files" на версию из `N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js`
- Убедиться, что `group_index` передаётся из body
2. **Обновить SQL в n8n:**
- Заменить SQL в узле "claimsave" на версию из `SQL_CLAIMSAVE_FIXED_NEW_FLOW.sql`
- Заменить SQL в узле "claimsave_final" на версию из `SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql`
3. **Проверить работу:**
- Загрузить новый документ через интерфейс
- Убедиться, что он получает правильный `field_name` (например, `uploads[2][0]` для третьего документа)
- Проверить, что документы не теряются при обновлении черновика
---
## Важные моменты
1. **Приоритет определения типа документа:**
- Сначала проверяется `field_label` (более точный)
- Потом проверяется `field_name` (fallback)
2. **Объединение документов:**
- `documents_meta` всегда объединяется с существующими
- `documents_uploaded` всегда начинается с существующих документов
- Новые документы добавляются только если их нет в существующих
3. **field_name:**
- Формат: `uploads[{group_index}][0]`
- `group_index` = индекс документа в `documents_required` (0-based)
- Передаётся с фронтенда через параметр `group_index`
---
## Команды для проверки
```bash
# Проверить документы в черновике
docker exec ticket_form_backend python3 /app/check_documents_detailed.py
# Проверить документы в таблице
docker exec ticket_form_backend python3 /app/check_claim_documents_table.py
# Исправить field_name для существующих документов
docker exec ticket_form_backend python3 /app/fix_claim_documents_field_names.py
```

View File

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

View File

@@ -400,6 +400,12 @@ async def get_draft(claim_id: str):
logger.info(f"🔍 Загружен черновик: id={row.get('id')}, claim_id={final_claim_id}, channel={row.get('channel')}")
# 🔍 ОТЛАДКА: Логируем наличие documents_required
documents_required = payload.get('documents_required', []) if isinstance(payload, dict) else []
logger.info(f"🔍 Черновик {final_claim_id}: status_code={row.get('status_code')}, documents_required count={len(documents_required) if isinstance(documents_required, list) else 0}")
if documents_required:
logger.info(f"🔍 documents_required: {documents_required[:2]}...") # Первые 2 для примера
return {
"success": True,
"claim": {
@@ -426,14 +432,13 @@ async def delete_draft(claim_id: str):
"""
Удалить черновик по claim_id
Удаляет только черновики (status_code = 'draft')
Удаляет черновики с любым статусом (кроме submitted/completed)
"""
try:
query = """
DELETE FROM clpr_claims
WHERE payload->>'claim_id' = $1
AND status_code = 'draft'
AND channel = 'web_form'
WHERE (payload->>'claim_id' = $1 OR id::text = $1)
AND status_code NOT IN ('submitted', 'completed', 'rejected')
RETURNING id
"""

View File

@@ -4,7 +4,7 @@ Documents API Routes - Загрузка и обработка документо
Новый флоу: поэкранная загрузка документов
"""
from fastapi import APIRouter, HTTPException, UploadFile, File, Form, Request
from typing import Optional
from typing import Optional, List
import httpx
import json
import uuid
@@ -17,7 +17,7 @@ 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"
N8N_DOCUMENT_UPLOAD_WEBHOOK = "https://n8n.clientright.pro/webhook/webform_document_upload"
@router.post("/upload")
@@ -27,8 +27,12 @@ async def upload_document(
claim_id: str = Form(...),
session_id: str = Form(...),
document_type: str = Form(...),
document_name: Optional[str] = Form(None),
document_description: Optional[str] = Form(None),
group_index: Optional[str] = Form(None),
unified_id: Optional[str] = Form(None),
contact_id: Optional[str] = Form(None),
phone: Optional[str] = Form(None),
):
"""
Загрузка одного документа.
@@ -60,26 +64,50 @@ async def upload_document(
file_content = await file.read()
file_size = len(file_content)
# Формируем данные для отправки в n8n
# Получаем IP клиента
client_ip = request.client.host if request.client else "unknown"
forwarded_for = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if forwarded_for:
client_ip = forwarded_for
# Формируем данные в формате совместимом с существующим n8n воркфлоу
form_data = {
"claim_id": claim_id,
# Основные идентификаторы
"form_id": "ticket_form",
"stage": "document_upload",
"session_id": session_id,
"claim_id": claim_id,
"client_ip": client_ip,
# Идентификаторы пользователя
"unified_id": unified_id or "",
"contact_id": contact_id or "",
"phone": phone or "",
# Информация о документе
"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(),
# Формат uploads_* для совместимости
# ✅ Используем group_index для правильной индексации (по умолчанию 0)
"uploads_field_names[{idx}]".format(idx=group_index or "0"): document_type,
"uploads_field_labels[{idx}]".format(idx=group_index or "0"): document_name or document_type,
"uploads_descriptions[{idx}]".format(idx=group_index or "0"): document_description or "",
}
if unified_id:
form_data["unified_id"] = unified_id
if contact_id:
form_data["contact_id"] = contact_id
# ✅ Добавляем group_index в данные формы
if group_index:
form_data["group_index"] = group_index
logger.info(f"📋 group_index передан в n8n: {group_index}")
# Файл для multipart
# Файл для multipart (ключ uploads[group_index] для совместимости)
idx = group_index or "0"
files = {
"file": (file.filename, file_content, file.content_type or "application/octet-stream")
f"uploads[{idx}]": (file.filename, file_content, file.content_type or "application/octet-stream")
}
# Отправляем в n8n
@@ -163,6 +191,174 @@ async def upload_document(
)
@router.post("/upload-multiple")
async def upload_multiple_documents(
request: Request,
files: List[UploadFile] = File(...),
claim_id: str = Form(...),
session_id: str = Form(...),
document_type: str = Form(...),
document_name: Optional[str] = Form(None),
document_description: Optional[str] = Form(None),
unified_id: Optional[str] = Form(None),
contact_id: Optional[str] = Form(None),
phone: Optional[str] = Form(None),
):
"""
Загрузка нескольких файлов для одного документа (например, несколько страниц паспорта).
Все файлы отправляются одним запросом в n8n.
"""
try:
logger.info(
"📤 Multiple documents upload received",
extra={
"claim_id": claim_id,
"session_id": session_id,
"document_type": document_type,
"files_count": len(files),
"file_names": [f.filename for f in files],
},
)
# Получаем IP клиента
client_ip = request.client.host if request.client else "unknown"
forwarded_for = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if forwarded_for:
client_ip = forwarded_for
# Генерируем ID для каждого файла и читаем контент
file_ids = []
files_multipart = {}
for i, file in enumerate(files):
file_id = f"doc_{uuid.uuid4().hex[:12]}"
file_ids.append(file_id)
file_content = await file.read()
files_multipart[f"uploads[{i}]"] = (
file.filename,
file_content,
file.content_type or "application/octet-stream"
)
# Формируем данные формы
form_data = {
# Основные идентификаторы
"form_id": "ticket_form",
"stage": "document_upload",
"session_id": session_id,
"claim_id": claim_id,
"client_ip": client_ip,
# Идентификаторы пользователя
"unified_id": unified_id or "",
"contact_id": contact_id or "",
"phone": phone or "",
# Информация о документе
"document_type": document_type,
"files_count": str(len(files)),
"upload_timestamp": datetime.utcnow().isoformat(),
}
# ✅ Получаем group_index из Form (индекс документа в documents_required)
form_params = await request.form()
group_index = form_params.get("group_index")
if group_index:
form_data["group_index"] = group_index
logger.info(f"📋 group_index передан в n8n: {group_index}")
# Добавляем информацию о каждом файле
for i, (file, file_id) in enumerate(zip(files, file_ids)):
form_data[f"file_ids[{i}]"] = file_id
form_data[f"uploads_field_names[{i}]"] = document_type
form_data[f"uploads_field_labels[{i}]"] = document_name or document_type
form_data[f"uploads_descriptions[{i}]"] = document_description or ""
form_data[f"original_filenames[{i}]"] = file.filename
# Отправляем в n8n одним запросом
async with httpx.AsyncClient(timeout=180.0) as client:
response = await client.post(
N8N_DOCUMENT_UPLOAD_WEBHOOK,
data=form_data,
files=files_multipart,
)
response_text = response.text or ""
if response.status_code == 200:
logger.info(
"✅ Multiple documents uploaded to n8n",
extra={
"claim_id": claim_id,
"document_type": document_type,
"file_ids": file_ids,
"files_count": len(files),
},
)
# Парсим ответ от n8n
try:
n8n_response = json.loads(response_text)
except json.JSONDecodeError:
n8n_response = {"raw": response_text}
# Публикуем событие в Redis
event_data = {
"event_type": "documents_uploaded",
"status": "processing",
"claim_id": claim_id,
"session_id": session_id,
"document_type": document_type,
"file_ids": file_ids,
"files_count": len(files),
"original_filenames": [f.filename for f in files],
"timestamp": datetime.utcnow().isoformat(),
}
await redis_service.publish(
f"ocr_events:{session_id}",
json.dumps(event_data, ensure_ascii=False)
)
return {
"success": True,
"file_ids": file_ids,
"files_count": len(files),
"document_type": document_type,
"ocr_status": "processing",
"message": f"Загружено {len(files)} файл(ов)",
"n8n_response": n8n_response,
}
else:
logger.error(
"❌ n8n multiple 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 multiple upload timeout")
raise HTTPException(status_code=504, detail="Таймаут загрузки документов")
except HTTPException:
raise
except Exception as e:
logger.exception("❌ Multiple upload error")
raise HTTPException(
status_code=500,
detail=f"Ошибка загрузки документов: {str(e)}",
)
@router.get("/status/{claim_id}")
async def get_documents_status(claim_id: str):
"""
@@ -198,6 +394,150 @@ async def get_documents_status(claim_id: str):
)
async def skip_document(
request: Request,
claim_id: str = Form(...),
session_id: str = Form(...),
document_type: str = Form(...),
document_name: Optional[str] = Form(None),
group_index: Optional[str] = Form(None),
unified_id: Optional[str] = Form(None),
contact_id: Optional[str] = Form(None),
phone: Optional[str] = Form(None),
):
"""
Пропуск документа (пользователь указал, что документа нет).
Отправляет событие в n8n на тот же webhook, что и загрузка файлов,
но с флагом skipped=true для обработки пропуска.
"""
try:
logger.info(
"⏭️ Document skip received",
extra={
"claim_id": claim_id,
"session_id": session_id,
"document_type": document_type,
"group_index": group_index,
},
)
# Получаем IP клиента
client_ip = request.client.host if request.client else "unknown"
forwarded_for = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if forwarded_for:
client_ip = forwarded_for
# Формируем данные в формате совместимом с существующим n8n воркфлоу
form_data = {
# Основные идентификаторы
"form_id": "ticket_form",
"stage": "document_skip",
"session_id": session_id,
"claim_id": claim_id,
"client_ip": client_ip,
# Идентификаторы пользователя
"unified_id": unified_id or "",
"contact_id": contact_id or "",
"phone": phone or "",
# Информация о документе
"document_type": document_type,
"document_name": document_name or document_type,
"skipped": "true", # ✅ Флаг пропуска документа
"action": "skip", # ✅ Действие: пропуск
"skip_timestamp": datetime.utcnow().isoformat(),
# Формат uploads_* для совместимости (без файлов)
# ✅ Используем group_index для правильной индексации (по умолчанию 0)
"uploads_field_names[{idx}]".format(idx=group_index or "0"): document_type,
"uploads_field_labels[{idx}]".format(idx=group_index or "0"): document_name or document_type,
"uploads_descriptions[{idx}]".format(idx=group_index or "0"): "",
"files_count": "0", # ✅ Нет файлов
}
# ✅ Добавляем group_index в данные формы
if group_index:
form_data["group_index"] = group_index
logger.info(f"📋 group_index передан в n8n: {group_index}")
# Отправляем в n8n на тот же webhook (без файлов)
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
N8N_DOCUMENT_UPLOAD_WEBHOOK,
data=form_data,
)
response_text = response.text or ""
if response.status_code == 200:
logger.info(
"✅ Document skip sent to n8n",
extra={
"claim_id": claim_id,
"document_type": document_type,
"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_skipped",
"status": "skipped",
"claim_id": claim_id,
"session_id": session_id,
"document_type": document_type,
"document_name": document_name or document_type,
"timestamp": datetime.utcnow().isoformat(),
}
await redis_service.publish(
f"ocr_events:{session_id}",
json.dumps(event_data, ensure_ascii=False)
)
return {
"success": True,
"document_type": document_type,
"status": "skipped",
"message": "Документ пропущен и сохранён",
"n8n_response": n8n_response,
}
else:
logger.error(
"❌ n8n document skip 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 skip timeout")
raise HTTPException(status_code=504, detail="Таймаут отправки пропуска документа")
except HTTPException:
raise
except Exception as e:
logger.exception("❌ Document skip error")
raise HTTPException(status_code=500, detail=f"Ошибка пропуска документа: {str(e)}")
@router.post("/generate-list")
async def generate_documents_list(request: Request):
"""
@@ -268,3 +608,7 @@ async def generate_documents_list(request: Request):
detail=f"Ошибка генерации списка: {str(e)}",
)
router.add_api_route("/skip", skip_document, methods=["POST"], tags=["Documents"])

View File

@@ -123,10 +123,18 @@ async def stream_events(task_id: str):
# Формат уже плоский (от backend API или старых источников)
actual_event = event
# ✅ Логируем полученное событие
event_type = actual_event.get('event_type')
logger.info(f"🔍 Processing event: event_type={event_type}, has claim_id={bool(actual_event.get('claim_id'))}")
# ✅ Обработка нового формата: documents_list_ready
if event_type == 'documents_list_ready':
logger.info(f"📋 Documents list received: {len(actual_event.get('documents_required', []))} documents")
# Просто пропускаем дальше к yield
# ✅ Обработка формата от n8n: если пришёл объект с claim_id, но без event_type
# Это значит, что n8n пушит минимальный payload для wizard_ready
logger.info(f"🔍 Checking event: has event_type={bool(actual_event.get('event_type'))}, has claim_id={bool(actual_event.get('claim_id'))}")
if not actual_event.get('event_type') and actual_event.get('claim_id'):
elif not event_type and actual_event.get('claim_id'):
logger.info(f"📦 Detected minimal wizard payload (no event_type), wrapping for claim_id={actual_event.get('claim_id')}")
# Обёртываем в правильный формат
actual_event = {
@@ -209,13 +217,21 @@ async def stream_events(task_id: str):
# Отправляем событие клиенту (плоский формат)
event_json = json.dumps(actual_event, ensure_ascii=False)
logger.info(f"📤 Sending event to client: {actual_event.get('status', 'unknown')}")
event_type_sent = actual_event.get('event_type', 'unknown')
event_status = actual_event.get('status', 'unknown')
logger.info(f"📤 Sending event to client: type={event_type_sent}, status={event_status}")
yield f"data: {event_json}\n\n"
# Если обработка завершена - закрываем соединение
if actual_event.get('status') in ['completed', 'error', 'success']:
# НЕ закрываем для documents_list_ready и document_ocr_completed (ждём ещё события)
if event_status in ['completed', 'error'] and event_type_sent not in ['documents_list_ready', 'document_ocr_completed', 'document_uploaded']:
logger.info(f"✅ Task {task_id} finished, closing SSE")
break
# Закрываем для финальных событий
if event_type_sent in ['claim_ready', 'claim_plan_ready']:
logger.info(f"✅ Final event {event_type_sent} sent, closing SSE")
break
else:
logger.info(f"⏰ Timeout waiting for message on {channel}")

View File

@@ -229,3 +229,4 @@ async def info():
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8200)

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""
Проверка документов в таблице clpr_claim_documents
"""
import asyncio
import asyncpg
POSTGRES_HOST = "147.45.189.234"
POSTGRES_PORT = 5432
POSTGRES_DB = "default_db"
POSTGRES_USER = "gen_user"
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
async def check_documents_table():
conn = await asyncpg.connect(
host=POSTGRES_HOST,
port=POSTGRES_PORT,
database=POSTGRES_DB,
user=POSTGRES_USER,
password=POSTGRES_PASSWORD
)
try:
# Сначала находим UUID claim
claim_row = await conn.fetchrow("""
SELECT id FROM clpr_claims
WHERE id::text = $1 OR payload->>'claim_id' = $1
ORDER BY updated_at DESC
LIMIT 1
""", CLAIM_ID)
if not claim_row:
print(f"❌ Черновик {CLAIM_ID} не найден!")
return
claim_uuid = claim_row['id']
# Ищем документы по UUID (claim_id в таблице - text)
rows = await conn.fetch("""
SELECT
ccd.id,
ccd.claim_id,
ccd.field_name,
ccd.file_id,
ccd.file_name,
ccd.original_file_name,
ccd.uploaded_at
FROM clpr_claim_documents ccd
WHERE ccd.claim_id = $1
ORDER BY ccd.uploaded_at DESC
""", str(claim_uuid))
print(f"📋 Найдено {len(rows)} документов в таблице clpr_claim_documents:")
for i, row in enumerate(rows):
print(f"\n {i+1}. field_name: {row['field_name']}")
print(f" file_id: {row['file_id']}")
print(f" file_name: {row['file_name']}")
print(f" original_file_name: {row['original_file_name']}")
print(f" uploaded_at: {row['uploaded_at']}")
finally:
await conn.close()
if __name__ == "__main__":
asyncio.run(check_documents_table())

View File

@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""
Проверка статуса жалобы в базе данных
"""
import asyncio
import asyncpg
import json
# Параметры подключения к БД
POSTGRES_HOST = "147.45.189.234"
POSTGRES_PORT = 5432
POSTGRES_DB = "default_db"
POSTGRES_USER = "gen_user"
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
async def check_claim_status():
"""Проверяет статус жалобы"""
conn = None
try:
conn = await asyncpg.connect(
host=POSTGRES_HOST,
port=POSTGRES_PORT,
database=POSTGRES_DB,
user=POSTGRES_USER,
password=POSTGRES_PASSWORD
)
# Запрос статуса жалобы
query = """
SELECT
id,
status_code,
payload->>'claim_id' as claim_id,
payload->>'current_doc_index' as current_doc_index,
jsonb_array_length(COALESCE(payload->'documents_required', '[]'::jsonb)) as documents_required_count,
jsonb_array_length(COALESCE(payload->'documents_uploaded', '[]'::jsonb)) as documents_uploaded_count,
jsonb_array_length(COALESCE(payload->'documents_skipped', '[]'::jsonb)) as documents_skipped_count,
payload->'documents_required' as documents_required,
payload->'documents_uploaded' as documents_uploaded,
payload->'documents_skipped' as documents_skipped,
created_at,
updated_at
FROM clpr_claims
WHERE id::text = $1
OR payload->>'claim_id' = $1
ORDER BY updated_at DESC
LIMIT 1
"""
row = await conn.fetchrow(query, CLAIM_ID)
if not row:
print(f"❌ Жалоба с claim_id '{CLAIM_ID}' не найдена")
return
print("=" * 80)
print(f"📋 Статус жалобы: {CLAIM_ID}")
print("=" * 80)
print(f"ID в БД: {row['id']}")
print(f"Status Code: {row['status_code']}")
print(f"Claim ID: {row['claim_id']}")
print(f"Current Doc Index: {row['current_doc_index']}")
print(f"\n📊 Статистика документов:")
print(f" - Требуется документов: {row['documents_required_count']}")
print(f" - Загружено документов: {row['documents_uploaded_count']}")
print(f" - Пропущено документов: {row['documents_skipped_count']}")
print(f"\n📅 Даты:")
print(f" - Создано: {row['created_at']}")
print(f" - Обновлено: {row['updated_at']}")
documents_required = row['documents_required'] if isinstance(row['documents_required'], list) else (json.loads(row['documents_required']) if isinstance(row['documents_required'], str) else [])
documents_uploaded = row['documents_uploaded'] if isinstance(row['documents_uploaded'], list) else (json.loads(row['documents_uploaded']) if isinstance(row['documents_uploaded'], str) else [])
documents_skipped = row['documents_skipped'] if isinstance(row['documents_skipped'], list) else (json.loads(row['documents_skipped']) if isinstance(row['documents_skipped'], str) else [])
if documents_required:
print(f"\n📄 Требуемые документы:")
for idx, doc in enumerate(documents_required):
doc_obj = doc if isinstance(doc, dict) else json.loads(doc) if isinstance(doc, str) else {}
print(f" {idx}. {doc_obj.get('name', doc_obj.get('id', 'unknown'))} (id: {doc_obj.get('id', 'unknown')})")
if documents_uploaded:
print(f"\n✅ Загруженные документы:")
for doc in documents_uploaded:
doc_obj = doc if isinstance(doc, dict) else json.loads(doc) if isinstance(doc, str) else {}
print(f" - {doc_obj.get('id', 'unknown')}: {doc_obj.get('file_name', 'N/A')}")
if documents_skipped:
print(f"\n⏭️ Пропущенные документы:")
for doc in documents_skipped:
doc_obj = doc if isinstance(doc, dict) else json.loads(doc) if isinstance(doc, str) else {}
group_idx = doc_obj.get('group_index', 'N/A')
print(f" - {doc_obj.get('id', 'unknown')} (group_index: {group_idx}): {doc_obj.get('name', 'N/A')}")
print("\n" + "=" * 80)
# Определяем, что должно происходить дальше
status = row['status_code']
uploaded = row['documents_uploaded_count'] or 0
skipped = row['documents_skipped_count'] or 0
required = row['documents_required_count'] or 0
print(f"\n🔍 Анализ статуса:")
if status == 'draft_docs_complete':
print("Все документы обработаны (загружены или пропущены)")
print(" 📝 Должно происходить: формирование заявления (wizard generation)")
elif status == 'draft_docs_progress':
print(" ⏳ Документы загружаются")
remaining = required - uploaded - skipped
print(f" 📊 Осталось обработать: {remaining} документов")
elif status == 'draft_new':
print(" 🆕 Новая жалоба, только описание")
elif status == 'draft_claim_ready':
print(" ✅ Заявление готово к отправке")
else:
print(f" ⚠️ Неизвестный статус: {status}")
except Exception as e:
print(f"❌ Ошибка: {e}")
import traceback
traceback.print_exc()
finally:
if conn:
await conn.close()
if __name__ == "__main__":
asyncio.run(check_claim_status())

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""
Детальная проверка документов в черновике
"""
import asyncio
import asyncpg
import json
POSTGRES_HOST = "147.45.189.234"
POSTGRES_PORT = 5432
POSTGRES_DB = "default_db"
POSTGRES_USER = "gen_user"
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
async def check_documents_detailed():
conn = await asyncpg.connect(
host=POSTGRES_HOST,
port=POSTGRES_PORT,
database=POSTGRES_DB,
user=POSTGRES_USER,
password=POSTGRES_PASSWORD
)
try:
row = await conn.fetchrow("""
SELECT id, status_code, payload, updated_at
FROM clpr_claims
WHERE id::text = $1 OR payload->>'claim_id' = $1
ORDER BY updated_at DESC
LIMIT 1
""", CLAIM_ID)
if not row:
print(f"❌ Черновик {CLAIM_ID} не найден!")
return
payload = row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload'])
print("=" * 80)
print(f"📋 Статус жалобы: {CLAIM_ID}")
print("=" * 80)
print(f"Status Code: {row['status_code']}")
print(f"Обновлён: {row['updated_at']}")
# Статистика документов
documents_required = payload.get('documents_required', [])
documents_uploaded = payload.get('documents_uploaded', [])
documents_skipped = payload.get('documents_skipped', [])
current_doc_index = payload.get('current_doc_index', 0)
print(f"\n📊 Статистика документов:")
print(f" - Требуется документов: {len(documents_required)}")
print(f" - Загружено документов: {len(documents_uploaded)}")
print(f" - Пропущено документов: {len(documents_skipped)}")
print(f" - Current Doc Index: {current_doc_index}")
# Анализ статуса
print(f"\n🔍 Анализ статуса:")
status = row['status_code']
uploaded = len(documents_uploaded)
skipped = len(documents_skipped)
required = len(documents_required)
if status == 'draft_docs_complete':
print("Все документы обработаны (загружены или пропущены)")
print(" 📝 Должно происходить: формирование заявления (wizard generation)")
elif status == 'draft_docs_progress':
print(" ⏳ Документы загружаются")
remaining = required - uploaded - skipped
print(f" 📊 Осталось обработать: {remaining} документов")
elif status == 'draft_new':
print(" 🆕 Новая жалоба, только описание")
elif status == 'draft_claim_ready':
print(" ✅ Заявление готово к отправке")
else:
print(f" ⚠️ Статус: {status}")
print("=" * 80)
print(f"\n📋 documents_meta ({len(payload.get('documents_meta', []))} шт.):")
for i, doc in enumerate(payload.get('documents_meta', [])):
print(f" {i+1}. {doc.get('field_label', 'N/A')}")
print(f" file_id: {doc.get('file_id', 'N/A')[:80]}...")
print(f" field_name: {doc.get('field_name', 'N/A')}")
print(f"\n📋 documents_uploaded ({len(payload.get('documents_uploaded', []))} шт.):")
for i, doc in enumerate(payload.get('documents_uploaded', [])):
print(f" {i+1}. Тип: {doc.get('type', 'N/A')} / {doc.get('id', 'N/A')}")
print(f" file_id: {doc.get('file_id', 'N/A')[:80]}...")
print(f" original_file_name: {doc.get('original_file_name', 'N/A')}")
print(f"\n📋 documents_required ({len(payload.get('documents_required', []))} шт.):")
for i, doc in enumerate(payload.get('documents_required', [])):
print(f" {i+1}. {doc.get('name', 'N/A')} (id: {doc.get('id', 'N/A')})")
print(f"\n📋 current_doc_index: {payload.get('current_doc_index', 'N/A')}")
# Проверяем уникальность file_id
print(f"\n🔍 Проверка уникальности file_id:")
documents_meta = payload.get('documents_meta', [])
file_ids_meta = [doc.get('file_id') for doc in documents_meta if doc.get('file_id')]
unique_file_ids_meta = list(set(file_ids_meta))
print(f" documents_meta: всего {len(file_ids_meta)}, уникальных {len(unique_file_ids_meta)}")
if len(file_ids_meta) != len(unique_file_ids_meta):
print(f" ⚠️ ЕСТЬ ДУБЛИКАТЫ!")
from collections import Counter
duplicates = [fid for fid, count in Counter(file_ids_meta).items() if count > 1]
for dup in duplicates:
print(f" - {dup[:80]}... (встречается {Counter(file_ids_meta)[dup]} раз)")
documents_uploaded = payload.get('documents_uploaded', [])
file_ids_uploaded = [doc.get('file_id') for doc in documents_uploaded if doc.get('file_id')]
unique_file_ids_uploaded = list(set(file_ids_uploaded))
print(f" documents_uploaded: всего {len(file_ids_uploaded)}, уникальных {len(unique_file_ids_uploaded)}")
if len(file_ids_uploaded) != len(unique_file_ids_uploaded):
print(f" ⚠️ ЕСТЬ ДУБЛИКАТЫ!")
finally:
await conn.close()
if __name__ == "__main__":
asyncio.run(check_documents_detailed())

View File

@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""
Проверка несоответствия между documents_uploaded и clpr_claim_documents
"""
import asyncio
import asyncpg
import json
POSTGRES_HOST = "147.45.189.234"
POSTGRES_PORT = 5432
POSTGRES_DB = "default_db"
POSTGRES_USER = "gen_user"
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
async def check_mismatch():
conn = await asyncpg.connect(
host=POSTGRES_HOST,
port=POSTGRES_PORT,
database=POSTGRES_DB,
user=POSTGRES_USER,
password=POSTGRES_PASSWORD
)
try:
# Находим UUID claim
claim_row = await conn.fetchrow("""
SELECT id FROM clpr_claims
WHERE id::text = $1 OR payload->>'claim_id' = $1
ORDER BY updated_at DESC
LIMIT 1
""", CLAIM_ID)
if not claim_row:
print(f"❌ Черновик {CLAIM_ID} не найден!")
return
claim_uuid = claim_row['id']
# Получаем payload
payload_row = await conn.fetchrow("""
SELECT payload FROM clpr_claims WHERE id = $1
""", claim_uuid)
payload = payload_row['payload'] if isinstance(payload_row['payload'], dict) else json.loads(payload_row['payload'])
# Получаем документы из таблицы
table_docs = await conn.fetch("""
SELECT
ccd.id,
ccd.claim_id,
ccd.field_name,
ccd.file_id,
ccd.file_name,
ccd.original_file_name,
ccd.uploaded_at
FROM clpr_claim_documents ccd
WHERE ccd.claim_id = $1
ORDER BY ccd.uploaded_at DESC
""", str(claim_uuid))
print(f"📋 Документы в таблице clpr_claim_documents ({len(table_docs)} шт.):")
for i, doc in enumerate(table_docs):
print(f" {i+1}. field_name: {doc['field_name']}")
print(f" file_id: {doc['file_id']}")
print(f" file_name: {doc['file_name']}")
print(f" original_file_name: {doc['original_file_name']}")
print(f" uploaded_at: {doc['uploaded_at']}")
print(f"\n📋 Документы в documents_uploaded ({len(payload.get('documents_uploaded', []))} шт.):")
for i, doc in enumerate(payload.get('documents_uploaded', [])):
print(f" {i+1}. Тип: {doc.get('type', 'N/A')} / {doc.get('id', 'N/A')}")
print(f" file_id: {doc.get('file_id', 'N/A')}")
print(f" original_file_name: {doc.get('original_file_name', 'N/A')}")
print(f"\n📋 Документы в documents_meta ({len(payload.get('documents_meta', []))} шт.):")
for i, doc in enumerate(payload.get('documents_meta', [])):
print(f" {i+1}. field_label: {doc.get('field_label', 'N/A')}")
print(f" field_name: {doc.get('field_name', 'N/A')}")
print(f" file_id: {doc.get('file_id', 'N/A')}")
# Проверяем, какие документы из documents_uploaded отсутствуют в таблице
print(f"\n🔍 Проверка отсутствующих документов:")
table_file_ids = {doc['file_id'] for doc in table_docs}
uploaded_file_ids = {doc.get('file_id') for doc in payload.get('documents_uploaded', []) if doc.get('file_id')}
missing_in_table = uploaded_file_ids - table_file_ids
if missing_in_table:
print(f" ⚠️ В documents_uploaded есть, но нет в таблице ({len(missing_in_table)} шт.):")
for file_id in missing_in_table:
doc = next((d for d in payload.get('documents_uploaded', []) if d.get('file_id') == file_id), None)
if doc:
print(f" - {doc.get('type', 'N/A')}: {file_id[:80]}...")
print(f" original_file_name: {doc.get('original_file_name', 'N/A')}")
else:
print(f"Все документы из documents_uploaded есть в таблице")
# Проверяем field_name
print(f"\n🔍 Проверка field_name:")
table_field_names = {doc['field_name'] for doc in table_docs}
meta_field_names = {doc.get('field_name') for doc in payload.get('documents_meta', []) if doc.get('field_name')}
print(f" В таблице: {sorted(table_field_names)}")
print(f" В documents_meta: {sorted(meta_field_names)}")
# Проверяем, есть ли конфликты по field_name
if len(table_docs) < len(payload.get('documents_uploaded', [])):
print(f"\n ⚠️ Возможная причина: несколько документов с одинаковым field_name")
print(f" В таблице используется UNIQUE constraint на (claim_id, field_name)")
print(f" Если два документа имеют одинаковый field_name, второй перезапишет первый")
finally:
await conn.close()
if __name__ == "__main__":
asyncio.run(check_mismatch())

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""
Проверка документов в черновике
"""
import asyncio
import asyncpg
import json
POSTGRES_HOST = "147.45.189.234"
POSTGRES_PORT = 5432
POSTGRES_DB = "default_db"
POSTGRES_USER = "gen_user"
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
async def check_documents():
conn = await asyncpg.connect(
host=POSTGRES_HOST,
port=POSTGRES_PORT,
database=POSTGRES_DB,
user=POSTGRES_USER,
password=POSTGRES_PASSWORD
)
try:
row = await conn.fetchrow("""
SELECT id, status_code, payload
FROM clpr_claims
WHERE id::text = $1 OR payload->>'claim_id' = $1
ORDER BY updated_at DESC
LIMIT 1
""", CLAIM_ID)
if not row:
print(f"❌ Черновик {CLAIM_ID} не найден!")
return
payload = row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload'])
print("📋 documents_meta:")
for i, doc in enumerate(payload.get('documents_meta', [])):
print(f" {i+1}. {doc.get('field_label', 'N/A')} - {doc.get('file_id', 'N/A')}")
print("\n📋 documents_uploaded:")
for i, doc in enumerate(payload.get('documents_uploaded', [])):
print(f" {i+1}. {doc.get('type', 'N/A')} / {doc.get('id', 'N/A')} - {doc.get('file_id', 'N/A')}")
print("\n📋 Все file_id в payload:")
# Ищем все file_id в payload
payload_str = json.dumps(payload, ensure_ascii=False)
import re
file_ids = re.findall(r'file_id["\']?\s*:\s*["\']([^"\']+)', payload_str)
for file_id in set(file_ids):
print(f" - {file_id}")
finally:
await conn.close()
if __name__ == "__main__":
asyncio.run(check_documents())

View File

@@ -0,0 +1,113 @@
// ============================================================================
// n8n Code Node: Подготовка параметров для SQL при пропуске документа
// ============================================================================
// Входные данные: массив с объектом [{ propertyName: {...}, body: {...} }]
// Выходные данные: { $1: jsonb_payload, $2: claim_id_string }
// ============================================================================
// Получаем входные данные
const inputData = $input.all();
if (!inputData || inputData.length === 0) {
return [{
json: {
error: "Нет входных данных",
$1: null,
$2: null
}
}];
}
// Берём первый элемент
// Если это массив - берём первый элемент массива
// Если это объект - используем его напрямую
let firstItem = inputData[0].json;
if (Array.isArray(firstItem)) {
firstItem = firstItem[0];
}
// Извлекаем данные
const propertyName = firstItem.propertyName || {};
const body = firstItem.body || {};
// Извлекаем claim_id (приоритет: body -> propertyName)
const claim_id = body.claim_id || propertyName.claim_id || null;
if (!claim_id) {
return [{
json: {
error: "claim_id не найден",
$1: null,
$2: null,
debug: {
body_keys: Object.keys(body),
propertyName_keys: Object.keys(propertyName)
}
}
}];
}
// Формируем payload для $1 (jsonb)
// SQL ищет данные в разных местах: p->>'document_type', p->'body'->>'document_type', p->'edit_fields_raw'->'body'->>'document_type'
const payload = {
// ✅ Основные идентификаторы (в корне для быстрого доступа)
session_id: body.session_id || propertyName.session_id,
claim_id: claim_id,
unified_id: body.unified_id || propertyName.unified_id,
contact_id: body.contact_id || propertyName.contact_id,
phone: body.phone || propertyName.phone,
// ✅ Информация о пропущенном документе (в корне для быстрого доступа)
document_type: body.document_type,
document_name: body.document_name || body.document_type,
group_index: body.group_index ? parseInt(body.group_index) : (body.group_index || null),
// ✅ Метаданные пропуска
skipped: body.skipped,
action: body.action,
skip_timestamp: body.skip_timestamp || new Date().toISOString(),
// ✅ Данные из propertyName (для сохранения в payload)
problem_description: propertyName.description || propertyName.problem_description,
email: propertyName.email,
// ✅ Данные из body (для совместимости)
form_id: body.form_id,
stage: body.stage,
client_ip: body.client_ip,
// ✅ Поля для совместимости с существующим SQL (SQL ищет данные здесь)
body: {
document_type: body.document_type,
document_name: body.document_name || body.document_type,
group_index: body.group_index ? parseInt(body.group_index) : (body.group_index || null),
session_id: body.session_id,
claim_id: claim_id,
unified_id: body.unified_id,
contact_id: body.contact_id,
phone: body.phone
},
edit_fields_raw: {
propertyName: propertyName,
body: body
},
edit_fields_parsed: {
propertyName: propertyName,
body: body
}
};
// Возвращаем параметры для SQL
return [{
json: {
$1: payload, // JSONB payload для SQL (будет передан как $1::jsonb)
$2: claim_id, // TEXT claim_id для SQL (будет передан как $2::text)
// Дополнительные поля для отладки
claim_id: claim_id,
document_type: body.document_type,
document_name: body.document_name,
group_index: body.group_index
}
}];

View File

@@ -0,0 +1,160 @@
// ============================================================================
// n8n Code Node: Обработка загруженных файлов (ИСПРАВЛЕННАЯ ВЕРСИЯ)
// ============================================================================
// OCR возвращает объединённые документы: один файл на группу (group_index)
// Структура: { data: [{ group_index_num: 0, files_count: 2, newfile: "...", ... }] }
// Решение: обрабатываем каждый элемент из data как объединённый документ
// ============================================================================
// ==== INPUT SHAPE SUPPORT ====
// OCR возвращает: { data: [ ...объединённые документы... ] }
const raw = $json;
const items = Array.isArray(raw?.data) ? raw.data : (Array.isArray(raw) ? raw : []);
if (!items.length) {
return [{
json: {
claim_id: null,
payload_partial_json: { documents_meta: [], edit_fields_raw: null, edit_fields_parsed: null },
filesRows: []
}
}];
}
// ==== CLAIM_ID DISCOVERY ====
let claim_id = $json.claim_id
|| $items('Edit Fields6')?.[0]?.json?.propertyName?.case_id
|| $('Edit Fields6').first().json.body.claim_id
|| null;
// ==== UTILS ====
const safeStr = (v) => (v == null ? '' : String(v));
const nowIso = new Date().toISOString();
const tryParseJSON = (x) => {
if (x == null) return null;
if (typeof x === 'object') return x;
if (typeof x === 'string') { try { return JSON.parse(x); } catch { return null; } }
return null;
};
// ==== ПРЕДВАРИТЕЛЬНО СОБИРАЕМ uploads_field_labels ИЗ BODY ====
const editRaw = $items('Edit Fields6')?.[0]?.json || null;
const body = editRaw?.body || null;
let uploads_descriptions = [];
let uploads_field_names = [];
let uploads_field_labels = [];
if (body && typeof body === 'object') {
const d = [];
const f = [];
const l = [];
for (const k of Object.keys(body)) {
const mD = k.match(/^uploads_descriptions\[(\d+)\]$/);
const mF = k.match(/^uploads_field_names\[(\d+)\]$/);
const mL = k.match(/^uploads_field_labels\[(\d+)\]$/);
if (mD) d[Number(mD[1])] = safeStr(body[k]);
if (mF) f[Number(mF[1])] = safeStr(body[k]);
if (mL) l[Number(mL[1])] = safeStr(body[k]);
}
uploads_descriptions = d.filter(v => v !== undefined);
uploads_field_names = f.filter(v => v !== undefined);
uploads_field_labels = l.filter(v => v !== undefined);
}
// ==== BUILD documents_meta + filesRows ====
// OCR возвращает объединённые документы: один файл на group_index
// Каждый элемент из data - это уже объединённый PDF (может содержать несколько страниц)
const documents_meta = [];
const filesRows = [];
for (const it of items) {
// ✅ ПРИОРИТЕТ: Используем group_index из body (переданный с фронтенда)
// Если его нет - используем group_index_num из OCR
// Если и его нет - пытаемся определить по document_type из uploads_field_names
let grp = null;
if (body && body.group_index !== undefined && body.group_index !== null) {
grp = Number(body.group_index);
} else if (it.group_index_num !== undefined && it.group_index_num !== null) {
grp = Number(it.group_index_num);
} else {
// Fallback: пытаемся определить по document_type
const doc_type = uploads_field_names[0] || uploads_field_labels[0] || '';
// Ищем индекс в documents_required по типу документа
// Это не идеально, но лучше чем всегда 0
grp = 0; // По умолчанию 0, если не можем определить
}
grp = grp || 0;
const file_index = 0; // После объединения всегда один файл на группу
const field_name = `uploads[${grp}][${file_index}]`;
// ✅ ИСПРАВЛЕНО: uploads_field_labels содержит элементы с индексом 0 (текущий запрос),
// а grp - это позиция в documents_required. Используем индекс 0 для массивов текущего запроса.
const field_label = uploads_field_labels[0] || uploads_field_names[0] || uploads_descriptions[0] || `group-${grp}`;
// OCR уже объединил файлы, используем newfile (путь к объединённому файлу)
const draft_key = safeStr(it.newfile || (it.folder && it.file_name ? `${it.folder}/${it.file_name}` : ''));
const original_name = safeStr(it.file_name || `group_${grp}.pdf`);
const description = safeStr(it.description || uploads_descriptions[0] || '');
const prefix = safeStr(it.prefix || '');
// files_count показывает, сколько исходных файлов было объединено
const files_count = Number(it.files_count) || 1;
const pages = Number(it.pages) || null;
documents_meta.push({
field_name,
field_label,
file_id: draft_key,
file_name: original_name,
original_file_name: original_name,
uploaded_at: nowIso,
files_count, // Информация: сколько файлов было объединено
pages, // Информация: сколько страниц в объединённом PDF
});
filesRows.push({
claim_id,
group_index: grp,
file_index, // Всегда 0 для объединённого документа
original_name,
draft_key,
mime: 'application/pdf',
size_bytes: null,
description,
prefix,
field_name,
field_label,
files_count, // Информация для отладки
pages, // Информация для отладки
});
}
// ==== ПОДТЯГИВАЕМ ВСЁ ИЗ "Edit Fields" ====
const propertyName = editRaw?.propertyName || null;
const answers_parsed = body ? (tryParseJSON(body.answers) || null) : null;
const wizard_plan_parsed = body ? (tryParseJSON(body.wizard_plan) || null) : null;
// ==== OUTPUT ====
return [{
json: {
claim_id,
payload_partial_json: {
documents_meta,
edit_fields_raw: editRaw || null,
edit_fields_parsed: {
propertyName,
body,
uploads_descriptions,
uploads_field_names,
uploads_field_labels,
answers_parsed,
wizard_plan_parsed,
}
},
filesRows
}
}];

View File

@@ -0,0 +1,115 @@
// ============================================================================
// n8n Code Node: Пуш списка документов в Redis
// ============================================================================
// Расположение в workflow:
// Redis Trigger (ticket_form:description)
// → AI Agent (анализ проблемы)
// → PostgreSQL (SQL_SAVE_DRAFT_NEW_FLOW.sql)
// → [ЭТОТ CODE NODE]
// → Redis Publish
// ============================================================================
// Получаем результат из PostgreSQL
const sqlResult = $input.first().json;
// claim содержит результат SQL запроса
const claim = sqlResult.claim || sqlResult;
// Валидация
if (!claim.session_token) {
throw new Error('Нет session_token в результате SQL');
}
if (!claim.documents_required || claim.documents_required.length === 0) {
console.log('⚠️ Список документов пуст, но продолжаем');
}
// Формируем событие для Redis
const event = {
event_type: 'documents_list_ready',
status: 'ready',
// Идентификаторы
claim_id: claim.claim_id,
session_id: claim.session_token,
// ✅ Список документов для фронтенда
documents_required: claim.documents_required || [],
documents_count: claim.documents_count || 0,
// Метаданные
timestamp: new Date().toISOString(),
message: 'Список необходимых документов готов'
};
// Логируем для отладки
console.log('📤 Публикуем событие documents_list_ready:', {
channel: `ocr_events:${claim.session_token}`,
documents_count: event.documents_count,
claim_id: event.claim_id
});
// Возвращаем для Redis Publish node
return {
json: {
// Канал Redis (ocr_events:{session_id})
channel: `ocr_events:${claim.session_token}`,
// Данные события (будут JSON.stringify в Redis node)
message: JSON.stringify(event),
// Дополнительно передаём для следующих нод
claim_id: claim.claim_id,
session_token: claim.session_token,
documents_required: claim.documents_required
}
};
// ============================================================================
// Пример структуры documents_required:
// ============================================================================
// [
// {
// "id": "contract",
// "name": "Договор или заказ",
// "required": false,
// "priority": 1,
// "accept": ["pdf", "jpg", "png"],
// "hints": "Поскольку договор не выслан, можно приложить публичную оферту"
// },
// {
// "id": "payment",
// "name": "Чек или подтверждение оплаты",
// "required": false,
// "priority": 1,
// "accept": ["pdf", "jpg", "png"],
// "hints": "Копия квитанции, чека или банковской выписки"
// },
// {
// "id": "correspondence",
// "name": "Переписка",
// "required": true, // ⚠️ КРИТИЧНЫЙ документ
// "priority": 2,
// "accept": ["pdf", "jpg", "png"],
// "hints": "Скриншоты переписки с организацией, претензии"
// }
// ]
// ============================================================================
// ============================================================================
// Настройка Redis Publish node (следующая нода):
// ============================================================================
//
// Operation: Publish
// Channel: {{ $json.channel }}
// Message: {{ $json.message }}
//
// Или через Execute Command:
// Command: PUBLISH
// Arguments:
// - {{ $json.channel }}
// - {{ $json.message }}
// ============================================================================

View File

@@ -0,0 +1,112 @@
# Параметры для SQL при пропуске документа
## Входные данные n8n
Массив с объектом:
```json
[
{
"propertyName": {
"session_id": "sess_f47c9f47-a727-4176-bf3d-26a02bb2fe24",
"phone": "79262306381",
"unified_id": "usr_90599ff2-ac79-4236-b950-0df85395096c",
"contact_id": "320096",
"claim_id": "bddb6815-8e17-4d54-a721-5e94382942c7",
"description": "...",
"email": "help@clientright.ru",
...
},
"body": {
"form_id": "ticket_form",
"stage": "document_skip",
"session_id": "sess_f47c9f47-a727-4176-bf3d-26a02bb2fe24",
"claim_id": "bddb6815-8e17-4d54-a721-5e94382942c7",
"unified_id": "usr_90599ff2-ac79-4236-b950-0df85395096c",
"contact_id": "320096",
"phone": "79262306381",
"document_type": "correspondence",
"document_name": "Переписка",
"skipped": "true",
"action": "skip",
"skip_timestamp": "2025-11-27T12:35:46.915646",
"group_index": "2"
}
}
]
```
## Параметры для SQL
### $1 (JSONB payload)
Структура payload должна содержать данные в разных местах для совместимости с SQL:
```json
{
// В корне (для быстрого доступа)
"session_id": "sess_f47c9f47-a727-4176-bf3d-26a02bb2fe24",
"claim_id": "bddb6815-8e17-4d54-a721-5e94382942c7",
"unified_id": "usr_90599ff2-ac79-4236-b950-0df85395096c",
"contact_id": "320096",
"phone": "79262306381",
"document_type": "correspondence",
"document_name": "Переписка",
"group_index": 2,
// В body (SQL ищет здесь: p->'body'->>'document_type')
"body": {
"document_type": "correspondence",
"document_name": "Переписка",
"group_index": 2,
"session_id": "sess_f47c9f47-a727-4176-bf3d-26a02bb2fe24",
"claim_id": "bddb6815-8e17-4d54-a721-5e94382942c7",
"unified_id": "usr_90599ff2-ac79-4236-b950-0df85395096c",
"contact_id": "320096",
"phone": "79262306381"
},
// В edit_fields_raw (SQL ищет здесь: p->'edit_fields_raw'->'body'->>'document_type')
"edit_fields_raw": {
"propertyName": { ... },
"body": { ... }
},
// В edit_fields_parsed (SQL ищет здесь: p->'edit_fields_parsed'->'body'->>'document_type')
"edit_fields_parsed": {
"propertyName": { ... },
"body": { ... }
},
// Дополнительные поля
"problem_description": "...",
"email": "help@clientright.ru",
"skipped": "true",
"action": "skip",
"skip_timestamp": "2025-11-27T12:35:46.915646"
}
```
### $2 (TEXT claim_id)
Просто строка с claim_id:
```
"bddb6815-8e17-4d54-a721-5e94382942c7"
```
## Использование в n8n
1. **Code Node** (`N8N_CODE_PREPARE_DOCUMENT_SKIP_SQL.js`) - подготавливает параметры
2. **PostgreSQL Node** - выполняет SQL запрос `SQL_CLAIMSAVE_DOCUMENT_SKIP.sql` с параметрами:
- Parameter Name: `$1`, Value: `={{ $json.$1 }}` (JSON)
- Parameter Name: `$2`, Value: `={{ $json.$2 }}` (String)
## Важно
SQL запрос ищет данные в следующем порядке:
1. `partial.p->>'document_type'` - в корне payload
2. `partial.p->'body'->>'document_type'` - в body
3. `partial.p->'edit_fields_raw'->'body'->>'document_type'` - в edit_fields_raw.body
4. `partial.p->'edit_fields_parsed'->'body'->>'document_type'` - в edit_fields_parsed.body
Поэтому payload должен содержать данные во всех этих местах для надёжности.

View File

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

View File

@@ -0,0 +1,96 @@
# Лог сессии 28.11.2025 — Дедупликация документов и исправление field_label
## Проблемы, которые были решены
### 1. Неправильный `field_label` ("group-2" вместо "Переписка")
**Причина:** В коде `N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js` использовался индекс `grp` (позиция в `documents_required`) для доступа к массиву `uploads_field_labels`, но этот массив содержит элементы с индексами от 0 (текущий запрос).
**Исправление:** Изменён доступ к массивам на индекс `0`:
```javascript
// Было:
const field_label = uploads_field_labels[grp] || ...
// Стало:
const field_label = uploads_field_labels[0] || uploads_field_names[0] || uploads_descriptions[0] || `group-${grp}`;
```
**Файл:** `ticket_form/docs/N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js`
---
### 2. Дублирование записей в `documents_meta`
**Причина:** SQL использовал простую конкатенацию `||` для объединения новых и существующих `documents_meta`, что приводило к накоплению дубликатов (было 28 записей вместо 2).
**Исправление:** Создан новый SQL с дедупликацией — новые записи заменяют старые с тем же `field_name`:
```sql
SELECT DISTINCT ON (doc->>'field_name') doc
FROM (
SELECT ... AS doc, 1 AS priority -- новые (приоритет)
UNION ALL
SELECT ... AS doc, 2 AS priority -- существующие
) all_docs
ORDER BY doc->>'field_name', priority, (doc->>'uploaded_at') DESC NULLS LAST
```
**Файл:** `ticket_form/docs/SQL_CLAIMSAVE_FIXED_NEW_FLOW_DEDUP.sql`
---
### 3. Ошибка `ON CONFLICT` для `document_texts`
**Причина:** Уникальный индекс на `file_hash` был частичным (`WHERE file_hash IS NOT NULL`), что не позволяло использовать `ON CONFLICT (file_hash)`.
**Исправление:** Создан полный уникальный индекс:
```sql
DROP INDEX IF EXISTS idx_document_texts_hash_unique;
CREATE UNIQUE INDEX idx_document_texts_hash_unique ON document_texts(file_hash);
```
---
## Созданные/изменённые файлы
| Файл | Описание |
|------|----------|
| `SQL_CLAIMSAVE_FIXED_NEW_FLOW_DEDUP.sql` | SQL с дедупликацией `documents_meta` |
| `SQL_CLEANUP_DOCUMENTS_META_DUPLICATES.sql` | SQL для очистки существующих дубликатов |
| `N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js` | Исправлен доступ к `uploads_field_labels[0]` |
---
## SQL-запросы для n8n
### Проверка дубликата по хешу
```sql
SELECT
EXISTS (SELECT 1 FROM document_texts WHERE file_hash = '{{ $json.file_hash }}') AS found,
(SELECT id FROM document_texts WHERE file_hash = '{{ $json.file_hash }}' LIMIT 1) AS existing_id;
```
### Вставка с дедупликацией
```sql
INSERT INTO document_texts
(file_id, file_url, path, title, filename_for_upload, "text", description, file_hash)
VALUES (...)
ON CONFLICT (file_hash) DO NOTHING
RETURNING id, file_id, title, file_hash;
```
---
## Изменения в БД
1. Создан уникальный индекс `idx_document_texts_hash_unique` на `document_texts(file_hash)`
2. Очищены дубликаты в `documents_meta` для заявки `ef853bac-f54b-46aa-adf8-f0c9c0cd76bc` (было 28 → стало 2)
3. Исправлен `field_label` для `uploads[2][0]` на "Переписка"
---
## Рекомендации
1. **Обновить SQL в n8n** ноде `claimsave` на версию из `SQL_CLAIMSAVE_FIXED_NEW_FLOW_DEDUP.sql`
2. **Обновить код** в ноде `editfiletobd1` на версию из `N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js`
3. **Добавить проверку хеша** перед вставкой в `document_texts` для информирования о дубликатах

View File

@@ -0,0 +1,81 @@
# Структура documents_meta в SQL запросах
## Текущая структура после OCR объединения
После обработки файлов OCR возвращает объединённые документы со следующей структурой:
```json
{
"documents_meta": [
{
"field_name": "uploads[0][0]",
"field_label": "Договор или заказ",
"file_id": "clientright/0/1764167196926.pdf",
"file_name": "1764167196926.pdf",
"original_file_name": "1764167196926.pdf",
"uploaded_at": "2025-11-26T14:44:51.430Z",
"files_count": 2, // ✅ Новое поле: сколько файлов было объединено
"pages": 4 // ✅ Новое поле: сколько страниц в объединённом PDF
}
]
}
```
## Как SQL обрабатывает эту структуру
### 1. Сохранение в `clpr_claim_documents`
SQL использует `jsonb_to_recordset` для извлечения только нужных полей:
```sql
CROSS JOIN LATERAL jsonb_to_recordset(
COALESCE(partial.p->'documents_meta', '[]'::jsonb)
) AS doc(
field_name text,
file_id text,
file_name text,
original_file_name text,
uploaded_at text
)
```
**Важно:** `field_label`, `files_count`, `pages` не извлекаются, но это нормально - они не нужны в таблице `clpr_claim_documents`.
### 2. Сохранение в `payload->'documents_meta'`
Полный JSON сохраняется в `payload` через `jsonb_build_object`:
```sql
jsonb_build_object(
'claim_id', partial.claim_id_str,
'documents_meta', COALESCE(partial.p->'documents_meta', '[]'::jsonb),
...
)
```
**Результат:** Все поля (`field_label`, `files_count`, `pages`) сохраняются в `payload->'documents_meta'` в полном объёме.
## Проверка сохранения
После выполнения SQL запроса можно проверить:
```sql
SELECT
payload->'documents_meta'->0->>'field_label' AS field_label,
payload->'documents_meta'->0->>'files_count' AS files_count,
payload->'documents_meta'->0->>'pages' AS pages
FROM clpr_claims
WHERE payload->>'claim_id' = 'bddb6815-8e17-4d54-a721-5e94382942c7';
```
Должны вернуться:
- `field_label`: "Договор или заказ"
- `files_count`: "2"
- `pages`: "4"
## Вывод
**SQL запрос работает правильно** - дополнительные поля сохраняются в `payload->'documents_meta'` и доступны для использования в дальнейших операциях.
**Не нужно менять SQL** - текущая структура достаточна для работы.

View File

@@ -0,0 +1,104 @@
# Возврат claim_document_id при сохранении документов
## Когда возникает claim_document_id?
`claim_document_id` (поле `id` в таблице `clpr_claim_documents`) возникает в момент INSERT или UPDATE записи в таблицу `clpr_claim_documents`.
## Где это происходит?
### 1. При загрузке документа (SQL_CLAIMSAVE_FIXED_NEW_FLOW.sql)
В CTE `docs_upsert` происходит INSERT/UPDATE:
```sql
docs_upsert AS (
INSERT INTO clpr_claim_documents (
claim_id,
field_name,
file_id,
uploaded_at,
file_name,
original_file_name
)
SELECT
partial.claim_id_str AS claim_id,
doc.field_name,
doc.file_id,
COALESCE((doc.uploaded_at)::timestamptz, now()),
doc.file_name,
doc.original_file_name
FROM partial
CROSS JOIN LATERAL jsonb_to_recordset(
COALESCE(partial.p->'documents_meta', '[]'::jsonb)
) AS doc(...)
WHERE partial.p->'documents_meta' IS NOT NULL
AND jsonb_array_length(partial.p->'documents_meta') > 0
ON CONFLICT (claim_id, field_name) DO UPDATE SET
file_id = EXCLUDED.file_id,
uploaded_at = EXCLUDED.uploaded_at,
file_name = EXCLUDED.file_name,
original_file_name = EXCLUDED.original_file_name
RETURNING id, claim_id, field_name, file_id, file_name, original_file_name -- ✅ Возвращаем id
)
```
### 2. В финальном SELECT
```sql
SELECT
(SELECT jsonb_build_object(...) FROM claim_upsert cu) AS claim,
(SELECT jsonb_agg(jsonb_build_object(
'id', id, -- ✅ Это и есть claim_document_id
'field_name', field_name,
'file_id', file_id,
'file_name', file_name,
'original_file_name', original_file_name
)) FROM docs_upsert) AS documents;
```
## Структура ответа
После выполнения SQL запроса возвращается:
```json
{
"claim": {
"claim_id": "...",
"status_code": "...",
...
},
"documents": [
{
"id": "16fa625e-1da3-4097-895a-75a8904c702a", // ← Это claim_document_id
"field_name": "uploads[1][0]",
"file_id": "...",
"file_name": "...",
"original_file_name": "..."
},
...
]
}
```
## Когда это нужно?
`claim_document_id` нужен для:
1. **Связи с другими таблицами** - если нужно связать документ с другими сущностями
2. **Обновления документа** - для UPDATE конкретной записи по ID
3. **Удаления документа** - для DELETE конкретной записи по ID
4. **Отслеживания** - для логирования и аудита
## Важно
- `claim_document_id` генерируется автоматически PostgreSQL при INSERT (если `id` имеет тип UUID с DEFAULT)
- При UPDATE (ON CONFLICT) возвращается существующий `id`
- `RETURNING id` в SQL запросе обязательно должен быть, чтобы получить `id` обратно
## Проверка
Чтобы убедиться, что `claim_document_id` возвращается, проверьте:
1. SQL запрос содержит `RETURNING id` в INSERT/UPDATE для `clpr_claim_documents`
2. Финальный SELECT включает `'id', id` из CTE с документами
3. n8n получает массив `documents` с полем `id` в каждом элементе

View File

@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""
Исправление field_name в таблице clpr_claim_documents
Пересоздаёт записи с правильными field_name на основе documents_uploaded и documents_required
"""
import asyncio
import asyncpg
import json
from datetime import datetime
POSTGRES_HOST = "147.45.189.234"
POSTGRES_PORT = 5432
POSTGRES_DB = "default_db"
POSTGRES_USER = "gen_user"
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
async def fix_field_names():
conn = await asyncpg.connect(
host=POSTGRES_HOST,
port=POSTGRES_PORT,
database=POSTGRES_DB,
user=POSTGRES_USER,
password=POSTGRES_PASSWORD
)
try:
# Получаем данные черновика
row = await conn.fetchrow("""
SELECT id, payload
FROM clpr_claims
WHERE id::text = $1 OR payload->>'claim_id' = $1
ORDER BY updated_at DESC
LIMIT 1
""", CLAIM_ID)
if not row:
print(f"❌ Черновик {CLAIM_ID} не найден!")
return
claim_uuid = row['id']
payload = row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload'])
documents_required = payload.get('documents_required', [])
documents_uploaded = payload.get('documents_uploaded', [])
print(f"📋 documents_required: {len(documents_required)} документов")
print(f"📋 documents_uploaded: {len(documents_uploaded)} документов")
# Создаём мапу: doc_id -> group_index
doc_id_to_index = {}
for idx, doc_req in enumerate(documents_required):
doc_id = doc_req.get('id')
if doc_id:
doc_id_to_index[doc_id] = idx
print(f"\n📋 Маппинг документов:")
for doc_id, idx in doc_id_to_index.items():
print(f" {doc_id} -> group_index {idx}")
# Удаляем старые записи
deleted_count = await conn.execute("""
DELETE FROM clpr_claim_documents
WHERE claim_id = $1
""", str(claim_uuid))
print(f"\n🗑️ Удалено старых записей: {deleted_count.split()[-1]}")
# Вставляем новые записи с правильными field_name
inserted_count = 0
for doc_up in documents_uploaded:
doc_type = doc_up.get('type') or doc_up.get('id')
file_id = doc_up.get('file_id')
if not doc_type or not file_id:
print(f" ⚠️ Пропущен документ без type/id или file_id: {doc_up}")
continue
group_index = doc_id_to_index.get(doc_type)
if group_index is None:
print(f" ⚠️ Не найден group_index для типа {doc_type}")
continue
field_name = f"uploads[{group_index}][0]"
# Парсим uploaded_at
uploaded_at_str = doc_up.get('uploaded_at')
uploaded_at = None
if uploaded_at_str:
try:
# Пробуем разные форматы даты
if isinstance(uploaded_at_str, str):
if 'T' in uploaded_at_str:
uploaded_at = datetime.fromisoformat(uploaded_at_str.replace('Z', '+00:00'))
else:
uploaded_at = datetime.fromisoformat(uploaded_at_str)
elif isinstance(uploaded_at_str, datetime):
uploaded_at = uploaded_at_str
except Exception as e:
print(f" ⚠️ Ошибка парсинга даты {uploaded_at_str}: {e}")
uploaded_at = None
await conn.execute("""
INSERT INTO clpr_claim_documents (
claim_id,
field_name,
file_id,
file_name,
original_file_name,
uploaded_at
)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (claim_id, field_name) DO UPDATE SET
file_id = EXCLUDED.file_id,
file_name = EXCLUDED.file_name,
original_file_name = EXCLUDED.original_file_name,
uploaded_at = EXCLUDED.uploaded_at
""",
str(claim_uuid),
field_name,
file_id,
doc_up.get('file_name', ''),
doc_up.get('original_file_name', ''),
uploaded_at
)
inserted_count += 1
print(f" ✅ Вставлен: {field_name} -> {doc_type} ({file_id[:50]}...)")
print(f"\n✅ Вставлено новых записей: {inserted_count}")
# Проверяем результат
result_rows = await conn.fetch("""
SELECT
field_name,
file_id,
file_name,
original_file_name
FROM clpr_claim_documents
WHERE claim_id = $1
ORDER BY field_name
""", str(claim_uuid))
print(f"\n📊 Результат в таблице ({len(result_rows)} записей):")
for row in result_rows:
print(f" {row['field_name']}: {row['file_name']} ({row['file_id'][:50]}...)")
finally:
await conn.close()
if __name__ == "__main__":
asyncio.run(fix_field_names())

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""
Очистка дубликатов в documents_meta
"""
import asyncio
import asyncpg
import json
POSTGRES_HOST = "147.45.189.234"
POSTGRES_PORT = 5432
POSTGRES_DB = "default_db"
POSTGRES_USER = "gen_user"
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
async def fix_duplicates():
conn = await asyncpg.connect(
host=POSTGRES_HOST,
port=POSTGRES_PORT,
database=POSTGRES_DB,
user=POSTGRES_USER,
password=POSTGRES_PASSWORD
)
try:
row = await conn.fetchrow("""
SELECT id, payload
FROM clpr_claims
WHERE id::text = $1 OR payload->>'claim_id' = $1
ORDER BY updated_at DESC
LIMIT 1
""", CLAIM_ID)
if not row:
print(f"❌ Черновик {CLAIM_ID} не найден!")
return
payload = row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload'])
documents_meta = payload.get('documents_meta', [])
print(f"📋 Было документов в documents_meta: {len(documents_meta)}")
# Убираем дубликаты по file_id (оставляем первый)
seen_file_ids = set()
unique_documents_meta = []
for doc in documents_meta:
file_id = doc.get('file_id')
if file_id and file_id not in seen_file_ids:
seen_file_ids.add(file_id)
unique_documents_meta.append(doc)
elif file_id:
print(f" ⚠️ Пропущен дубликат: {file_id[:80]}...")
print(f"📋 Стало документов в documents_meta: {len(unique_documents_meta)}")
# Обновляем payload
payload['documents_meta'] = unique_documents_meta
await conn.execute("""
UPDATE clpr_claims
SET
payload = $1::jsonb,
updated_at = now()
WHERE id::text = $2 OR payload->>'claim_id' = $2
""", json.dumps(payload, ensure_ascii=False), CLAIM_ID)
print(f"\n✅ Дубликаты удалены!")
# Проверяем результат
row_after = await conn.fetchrow("""
SELECT jsonb_array_length(payload->'documents_meta') as docs_count
FROM clpr_claims
WHERE id::text = $1 OR payload->>'claim_id' = $1
ORDER BY updated_at DESC
LIMIT 1
""", CLAIM_ID)
print(f"📊 Результат: {row_after['docs_count']} документов в documents_meta")
finally:
await conn.close()
if __name__ == "__main__":
asyncio.run(fix_duplicates())

View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
Скрипт для исправления черновика bddb6815-8e17-4d54-a721-5e94382942c7
Добавляет documents_required и исправляет статус
"""
import asyncio
import asyncpg
import json
from pathlib import Path
# Параметры подключения к БД (из config.py)
POSTGRES_HOST = "147.45.189.234"
POSTGRES_PORT = 5432
POSTGRES_DB = "default_db"
POSTGRES_USER = "gen_user"
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
DOCUMENTS_REQUIRED = [
{
"id": "contract",
"name": "Договор или заказ",
"hints": "Фото или скан подписанного договора или квитанции",
"accept": ["pdf", "jpg", "png"],
"priority": 1,
"required": True
},
{
"id": "payment",
"name": "Чек или подтверждение оплаты",
"hints": "Копия кассового чека, онлайн-платежа или квитанции",
"accept": ["pdf", "jpg", "png"],
"priority": 1,
"required": True
},
{
"id": "correspondence",
"name": "Переписка",
"hints": "Скриншоты сообщений, писем, жалоб",
"accept": ["pdf", "jpg", "png"],
"priority": 2,
"required": False
},
{
"id": "evidence_photo",
"name": "Фото доказательства",
"hints": "Фото дефектов товара, видео процесса ремонта или передачи",
"accept": ["jpg", "png", "pdf"],
"priority": 2,
"required": False
}
]
async def fix_draft():
"""Исправляет черновик: добавляет documents_required и обновляет статус"""
conn = await asyncpg.connect(
host=POSTGRES_HOST,
port=POSTGRES_PORT,
database=POSTGRES_DB,
user=POSTGRES_USER,
password=POSTGRES_PASSWORD
)
try:
# Получаем текущее состояние черновика
row = await conn.fetchrow("""
SELECT id, status_code, payload
FROM clpr_claims
WHERE id::text = $1 OR payload->>'claim_id' = $1
LIMIT 1
""", CLAIM_ID)
if not row:
print(f"❌ Черновик {CLAIM_ID} не найден!")
return
payload = row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload'])
current_status = row['status_code']
documents_uploaded = payload.get('documents_uploaded', [])
uploaded_count = len(documents_uploaded) if isinstance(documents_uploaded, list) else 0
print(f"📋 Текущее состояние черновика:")
print(f" - status_code: {current_status}")
print(f" - documents_required: {len(payload.get('documents_required', []))} шт.")
print(f" - documents_uploaded: {uploaded_count} шт.")
# Определяем новый статус
if uploaded_count > 0:
if uploaded_count >= len(DOCUMENTS_REQUIRED):
new_status = 'draft_docs_complete'
else:
new_status = 'draft_docs_progress'
else:
new_status = 'draft_new'
# Обновляем payload
payload['documents_required'] = DOCUMENTS_REQUIRED
# Обновляем черновик
await conn.execute("""
UPDATE clpr_claims
SET
status_code = $1,
payload = $2::jsonb,
updated_at = now()
WHERE id::text = $3 OR payload->>'claim_id' = $3
""", new_status, json.dumps(payload, ensure_ascii=False), CLAIM_ID)
print(f"\n✅ Черновик исправлен!")
print(f" - Новый status_code: {new_status}")
print(f" - documents_required: {len(DOCUMENTS_REQUIRED)} документов добавлено")
# Проверяем результат
row_after = await conn.fetchrow("""
SELECT
id::text,
status_code,
jsonb_array_length(COALESCE(payload->'documents_required', '[]'::jsonb)) as docs_count
FROM clpr_claims
WHERE id::text = $1 OR payload->>'claim_id' = $1
LIMIT 1
""", CLAIM_ID)
print(f"\n📊 Результат:")
print(f" - status_code: {row_after['status_code']}")
print(f" - documents_required count: {row_after['docs_count']}")
finally:
await conn.close()
if __name__ == "__main__":
asyncio.run(fix_draft())

View File

@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""
Скрипт для исправления черновика bddb6815-8e17-4d54-a721-5e94382942c7
Добавляет documents_required и обновляет статус с учётом уже загруженного договора
"""
import asyncio
import asyncpg
import json
from datetime import datetime
# Параметры подключения к БД
POSTGRES_HOST = "147.45.189.234"
POSTGRES_PORT = 5432
POSTGRES_DB = "default_db"
POSTGRES_USER = "gen_user"
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
DOCUMENTS_REQUIRED = [
{
"id": "contract",
"name": "Договор или заказ",
"hints": "Фото или скан подписанного договора или квитанции",
"accept": ["pdf", "jpg", "png"],
"priority": 1,
"required": True
},
{
"id": "payment",
"name": "Чек или подтверждение оплаты",
"hints": "Копия кассового чека, онлайн-платежа или квитанции",
"accept": ["pdf", "jpg", "png"],
"priority": 1,
"required": True
},
{
"id": "correspondence",
"name": "Переписка",
"hints": "Скриншоты сообщений, писем, жалоб",
"accept": ["pdf", "jpg", "png"],
"priority": 2,
"required": False
},
{
"id": "evidence_photo",
"name": "Фото доказательства",
"hints": "Фото дефектов товара, видео процесса ремонта или передачи",
"accept": ["jpg", "png", "pdf"],
"priority": 2,
"required": False
}
]
async def fix_draft():
"""Исправляет черновик: добавляет documents_required и обновляет статус"""
conn = await asyncpg.connect(
host=POSTGRES_HOST,
port=POSTGRES_PORT,
database=POSTGRES_DB,
user=POSTGRES_USER,
password=POSTGRES_PASSWORD
)
try:
# Получаем текущее состояние черновика
row = await conn.fetchrow("""
SELECT id, status_code, payload
FROM clpr_claims
WHERE id::text = $1 OR payload->>'claim_id' = $1
ORDER BY updated_at DESC
LIMIT 1
""", CLAIM_ID)
if not row:
print(f"❌ Черновик {CLAIM_ID} не найден!")
return
payload = row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload'])
current_status = row['status_code']
print(f"📋 Текущее состояние черновика:")
print(f" - status_code: {current_status}")
print(f" - documents_required: {len(payload.get('documents_required', []))} шт.")
print(f" - documents_uploaded: {len(payload.get('documents_uploaded', []))} шт.")
print(f" - documents_meta: {len(payload.get('documents_meta', []))} шт.")
# Проверяем documents_meta на наличие загруженных документов
documents_meta = payload.get('documents_meta', [])
existing_documents_uploaded = payload.get('documents_uploaded', [])
# Функция для определения типа документа (сначала по field_label, потом по field_name)
def get_document_type(field_label: str, field_name: str) -> str:
field_label_lower = field_label.lower()
# ✅ СНАЧАЛА проверяем field_label (более точный способ)
if 'договор' in field_label_lower or 'заказ' in field_label_lower:
return 'contract'
elif 'чек' in field_label_lower or 'оплат' in field_label_lower:
return 'payment'
elif 'переписк' in field_label_lower:
return 'correspondence'
elif 'доказательств' in field_label_lower or 'фото' in field_label_lower:
return 'evidence_photo'
# ✅ ПОТОМ проверяем field_name (fallback)
elif 'uploads[0]' in field_name:
return 'contract'
elif 'uploads[1]' in field_name:
return 'payment'
elif 'uploads[2]' in field_name:
return 'correspondence'
elif 'uploads[3]' in field_name:
return 'evidence_photo'
else:
return 'unknown'
# ✅ Объединяем существующие documents_uploaded с documents_meta
# Создаём мапу file_id -> doc_meta для быстрого поиска
meta_by_file_id = {}
if documents_meta:
print(f"\n🔍 Найдено {len(documents_meta)} документов в documents_meta")
for doc_meta in documents_meta:
file_id = doc_meta.get('file_id', '')
if file_id:
meta_by_file_id[file_id] = doc_meta
# ✅ Пересоздаём documents_uploaded: объединяем существующие с данными из documents_meta
documents_uploaded = []
seen_file_ids = set()
# Сначала обрабатываем documents_meta (приоритет)
for doc_meta in documents_meta:
file_id = doc_meta.get('file_id', '')
if not file_id or file_id in seen_file_ids:
continue
field_label = doc_meta.get('field_label', '')
field_name = doc_meta.get('field_name', '')
doc_type = get_document_type(field_label, field_name)
if doc_type != 'unknown':
seen_file_ids.add(file_id)
documents_uploaded.append({
"id": doc_type,
"type": doc_type,
"file_id": file_id,
"file_name": doc_meta.get('file_name', ''),
"original_file_name": doc_meta.get('original_file_name', ''),
"uploaded_at": doc_meta.get('uploaded_at', datetime.utcnow().isoformat()),
"ocr_status": "completed",
"files_count": doc_meta.get('files_count', 1),
"pages": doc_meta.get('pages', None)
})
print(f" ✅ Из documents_meta: {doc_type} ({field_label}) - {doc_meta.get('original_file_name', 'N/A')}")
# Затем добавляем существующие documents_uploaded, которых нет в documents_meta
for existing_doc in existing_documents_uploaded:
file_id = existing_doc.get('file_id', '')
if not file_id or file_id in seen_file_ids:
continue
# Если есть в documents_meta - пропускаем (уже обработали)
if file_id in meta_by_file_id:
continue
# Используем существующий тип или пытаемся определить по file_name
doc_type = existing_doc.get('type') or existing_doc.get('id') or 'unknown'
# Если тип неправильный (например, contract вместо payment), пытаемся определить по file_name
if doc_type == 'contract' and 'chek' in file_id.lower():
doc_type = 'payment'
elif doc_type == 'contract' and 'dogovor' in file_id.lower():
doc_type = 'contract'
seen_file_ids.add(file_id)
documents_uploaded.append({
"id": doc_type,
"type": doc_type,
"file_id": file_id,
"file_name": existing_doc.get('file_name', ''),
"original_file_name": existing_doc.get('original_file_name', ''),
"uploaded_at": existing_doc.get('uploaded_at', datetime.utcnow().isoformat()),
"ocr_status": existing_doc.get('ocr_status', 'completed'),
"files_count": existing_doc.get('files_count', 1),
"pages": existing_doc.get('pages', None)
})
print(f" ✅ Из существующих: {doc_type} - {existing_doc.get('original_file_name', 'N/A')}")
# Определяем current_doc_index (индекс следующего документа для загрузки)
# Убираем дубликаты по типу документа
uploaded_types = list(set([doc.get('id') or doc.get('type') for doc in documents_uploaded]))
current_doc_index = 0
# Находим первый незагруженный документ
for idx, doc_req in enumerate(DOCUMENTS_REQUIRED):
if doc_req['id'] not in uploaded_types:
current_doc_index = idx
break
else:
# Все документы загружены
current_doc_index = len(DOCUMENTS_REQUIRED)
# Определяем новый статус (учитываем уникальные типы документов)
uploaded_unique_types = len(uploaded_types)
if uploaded_unique_types >= len(DOCUMENTS_REQUIRED):
new_status = 'draft_docs_complete'
elif uploaded_unique_types > 0:
new_status = 'draft_docs_progress'
else:
new_status = 'draft_new'
# Обновляем payload
payload['documents_required'] = DOCUMENTS_REQUIRED
payload['documents_uploaded'] = documents_uploaded
payload['current_doc_index'] = current_doc_index
print(f"\n📝 Обновление черновика:")
print(f" - documents_required: {len(DOCUMENTS_REQUIRED)} документов")
print(f" - documents_uploaded: {len(documents_uploaded)} документов")
print(f" - current_doc_index: {current_doc_index} (следующий документ: {DOCUMENTS_REQUIRED[current_doc_index]['name'] if current_doc_index < len(DOCUMENTS_REQUIRED) else 'все загружены'})")
print(f" - status_code: {current_status}{new_status}")
# Обновляем черновик
await conn.execute("""
UPDATE clpr_claims
SET
status_code = $1,
payload = $2::jsonb,
updated_at = now()
WHERE id::text = $3 OR payload->>'claim_id' = $3
""", new_status, json.dumps(payload, ensure_ascii=False), CLAIM_ID)
print(f"\n✅ Черновик исправлен!")
# Проверяем результат
row_after = await conn.fetchrow("""
SELECT
id::text,
status_code,
jsonb_array_length(COALESCE(payload->'documents_required', '[]'::jsonb)) as docs_required_count,
jsonb_array_length(COALESCE(payload->'documents_uploaded', '[]'::jsonb)) as docs_uploaded_count,
(payload->>'current_doc_index')::int as current_doc_index
FROM clpr_claims
WHERE id::text = $1 OR payload->>'claim_id' = $1
ORDER BY updated_at DESC
LIMIT 1
""", CLAIM_ID)
print(f"\n📊 Результат:")
print(f" - status_code: {row_after['status_code']}")
print(f" - documents_required count: {row_after['docs_required_count']}")
print(f" - documents_uploaded count: {row_after['docs_uploaded_count']}")
print(f" - current_doc_index: {row_after['current_doc_index']}")
finally:
await conn.close()
if __name__ == "__main__":
asyncio.run(fix_draft())

View File

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

View File

@@ -203,8 +203,12 @@ export default function StepDraftSelection({
// Определяем 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';
// Legacy только если:
// 1. Статус 'draft' (старый формат) ИЛИ
// 2. Нет новых статусов (draft_new, draft_docs_progress, draft_docs_complete, draft_claim_ready)
// И есть wizard_plan (старый формат)
const isNewFlowStatus = ['draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready'].includes(draft.status_code || '');
const isLegacy = !isNewFlowStatus && draft.wizard_plan && draft.status_code === 'draft';
return {
...draft,
is_legacy: isLegacy,
@@ -316,19 +320,26 @@ export default function StepDraftSelection({
</Paragraph>
</div>
{/* Кнопка создания новой заявки - всегда вверху */}
<Button
type="primary"
icon={<PlusOutlined />}
onClick={onNewClaim}
size="large"
style={{ width: '100%' }}
>
+ Создать новую заявку
</Button>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin size="large" />
</div>
) : drafts.length === 0 ? (
<Empty
description="У вас нет незавершенных заявок"
description="У вас пока нет незавершенных заявок"
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<Button type="primary" icon={<PlusOutlined />} onClick={onNewClaim} size="large">
Создать новую заявку
</Button>
</Empty>
/>
) : (
<>
<List
@@ -345,6 +356,7 @@ export default function StepDraftSelection({
borderRadius: 8,
marginBottom: 12,
background: draft.is_legacy ? '#fffbe6' : '#fff',
overflow: 'hidden',
}}
actions={[
getActionButton(draft),
@@ -379,23 +391,38 @@ export default function StepDraftSelection({
justifyContent: 'center',
fontSize: 20,
color: draft.is_legacy ? '#faad14' : '#595959',
flexShrink: 0,
}}>
{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>
<Tag color={config.color}>{config.label}</Tag>
</Space>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Tag color={config.color} style={{ margin: 0 }}>{config.label}</Tag>
</div>
}
description={
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{/* Описание проблемы */}
{draft.problem_description && (
<Text
style={{
fontSize: 14,
display: 'block',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
}}
title={draft.problem_description}
>
{draft.problem_description.length > 60
? draft.problem_description.substring(0, 60) + '...'
: draft.problem_description
}
</Text>
)}
{/* Время обновления */}
<Space size="small">
<ClockCircleOutlined style={{ color: '#8c8c8c' }} />
@@ -409,7 +436,7 @@ export default function StepDraftSelection({
{/* Legacy предупреждение */}
{draft.is_legacy && (
<Alert
message="Этот черновик создан в старой версии формы. Нажмите 'Начать заново' для продолжения."
message="Черновик в старом формате. Нажмите 'Начать заново'."
type="warning"
showIcon
style={{ fontSize: 12, padding: '4px 8px' }}
@@ -459,19 +486,7 @@ export default function StepDraftSelection({
}}
/>
<div style={{ textAlign: 'center', marginTop: 24 }}>
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={onNewClaim}
size="large"
style={{ width: '100%' }}
>
Создать новую заявку
</Button>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ textAlign: 'center', marginTop: 16 }}>
<Button
type="link"
icon={<ReloadOutlined />}

View File

@@ -337,3 +337,343 @@ export default function StepWaitingClaim({
);
}
* 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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -657,6 +657,18 @@ export default function ClaimForm() {
console.log('🔄 Обновляем sessionIdRef на сессию из черновика:', claim.session_token);
}
// ✅ НОВЫЙ ФЛОУ: Извлекаем documents_required из payload
const documentsRequired = body.documents_required || payload.documents_required || [];
const documentsUploaded = body.documents_uploaded || payload.documents_uploaded || [];
const documentsSkipped = body.documents_skipped || payload.documents_skipped || [];
const currentDocIndex = body.current_doc_index ?? payload.current_doc_index ?? 0;
console.log('📋 Загрузка черновика - documents_required:', documentsRequired.length, 'шт.');
console.log('📋 Загрузка черновика - body.documents_required:', body.documents_required);
console.log('📋 Загрузка черновика - payload.documents_required:', payload.documents_required);
console.log('📋 Загрузка черновика - status_code:', claim.status_code);
console.log('📋 Загрузка черновика - все ключи payload:', Object.keys(payload));
updateFormData({
claim_id: finalClaimId, // ✅ Используем извлечённый claim_id
session_id: actualSessionId, // ✅ Используем session_id из черновика (если есть) или текущий
@@ -682,6 +694,11 @@ export default function ClaimForm() {
contact_id: body.contact_id || payload.contact_id || formData.contact_id,
project_id: body.project_id || payload.project_id || formData.project_id,
unified_id: formData.unified_id, // ✅ Сохраняем unified_id
// ✅ НОВЫЙ ФЛОУ: Документы
documents_required: documentsRequired,
documents_uploaded: documentsUploaded,
documents_skipped: documentsSkipped,
current_doc_index: currentDocIndex,
});
setSelectedDraftId(finalClaimId);
@@ -724,11 +741,16 @@ export default function ClaimForm() {
let targetStep = 1; // По умолчанию - описание (шаг 1)
if (wizardPlan) {
// ✅ Если есть wizard_plan - переходим к визарду (шаг 2)
// ✅ НОВЫЙ ФЛОУ: Если есть documents_required, показываем загрузку документов
if (documentsRequired.length > 0) {
targetStep = 2;
console.log('✅ Переходим к StepWizardPlan (шаг 2) - НОВЫЙ ФЛОУ: есть documents_required, показываем загрузку документов');
console.log('✅ documents_required:', documentsRequired.length, 'документов');
} else if (wizardPlan) {
// ✅ СТАРЫЙ ФЛОУ: Если есть wizard_plan - переходим к визарду (шаг 2)
// Пользователь уже описывал проблему, и есть план вопросов
targetStep = 2;
console.log('✅ Переходим к StepWizardPlan (шаг 2) - есть wizard_plan');
console.log('✅ Переходим к StepWizardPlan (шаг 2) - СТАРЫЙ ФЛОУ: есть wizard_plan');
console.log('✅ answers в черновике:', answers ? 'есть (показываем заполненную форму)' : 'нет (показываем пустую форму)');
} else if (problemDescription) {
// Если есть описание, но нет плана - переходим к визарду (шаг 2), чтобы получить план

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""
Изменение статуса жалобы на draft
"""
import asyncio
import asyncpg
import json
POSTGRES_HOST = "147.45.189.234"
POSTGRES_PORT = 5432
POSTGRES_DB = "default_db"
POSTGRES_USER = "gen_user"
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
async def set_status_to_draft():
conn = await asyncpg.connect(
host=POSTGRES_HOST,
port=POSTGRES_PORT,
database=POSTGRES_DB,
user=POSTGRES_USER,
password=POSTGRES_PASSWORD
)
try:
# Сначала проверяем текущий статус
row = await conn.fetchrow("""
SELECT id, status_code, payload, updated_at
FROM clpr_claims
WHERE id::text = $1 OR payload->>'claim_id' = $1
ORDER BY updated_at DESC
LIMIT 1
""", CLAIM_ID)
if not row:
print(f"❌ Жалоба с claim_id '{CLAIM_ID}' не найдена")
return
print(f"📋 Текущий статус: {row['status_code']}")
print(f"📋 ID в БД: {row['id']}")
# Обновляем статус на draft
result = await conn.execute("""
UPDATE clpr_claims
SET status_code = 'draft',
updated_at = now()
WHERE id = $1
""", row['id'])
print(f"✅ Статус обновлён на 'draft'")
print(f"📋 Результат: {result}")
# Проверяем новый статус
new_row = await conn.fetchrow("""
SELECT status_code, updated_at
FROM clpr_claims
WHERE id = $1
""", row['id'])
print(f"\n📋 Новый статус: {new_row['status_code']}")
print(f"📋 Обновлено: {new_row['updated_at']}")
except Exception as e:
print(f"❌ Ошибка: {e}")
import traceback
traceback.print_exc()
finally:
await conn.close()
if __name__ == "__main__":
asyncio.run(set_status_to_draft())