fix: Исправление загрузки документов и SQL запросов
- Исправлена потеря документов при обновлении черновика (SQL объединяет вместо перезаписи) - Исправлено определение типа документа (приоритет field_label над field_name) - Исправлены дубликаты в documents_meta и documents_uploaded - Добавлена передача group_index с фронтенда для правильного field_name - Исправлены все документы в таблице clpr_claim_documents с правильными field_name - Обновлены SQL запросы: claimsave и claimsave_final для нового флоу - Добавлена поддержка multi-file upload для одного документа - Исправлены дубликаты в списке загруженных документов на фронтенде Файлы: - SQL: SQL_CLAIMSAVE_FIXED_NEW_FLOW.sql, SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql - n8n: N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js (поддержка group_index) - Backend: documents.py (передача group_index в n8n) - Frontend: StepWizardPlan.tsx (передача group_index, исправление дубликатов) - Скрипты: fix_claim_documents_field_names.py, fix_documents_meta_duplicates.py Результат: документы больше не теряются, имеют правильные типы и field_name
This commit is contained in:
192
SESSION_LOG_2025-11-22_DIALOG.md
Normal file
192
SESSION_LOG_2025-11-22_DIALOG.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# Лог диалога - 22 ноября 2025
|
||||
|
||||
## Хронология диалога
|
||||
|
||||
### Начало работы
|
||||
Пользователь начал работу с исправлениями в `ticket_form`, связанными с обработкой черновиков и прикреплением документов к проектам.
|
||||
|
||||
### 1. Проблема с извлечением данных из payload
|
||||
|
||||
**Проблема:** В `payload` данные вложены в `body` (`payload.body.wizard_plan`, `payload.body.answers`), а не в `payload` напрямую.
|
||||
|
||||
**Решение:**
|
||||
- Исправлено извлечение данных из `payload.body` для telegram-черновиков
|
||||
- Добавлен парсинг JSON-строк в `wizard_plan` и `answers`
|
||||
- Использование `claim.id` (UUID) как `claim_id`, если `claim_id` null
|
||||
- Логика перехода: если есть `wizard_plan` → переходим к StepWizardPlan (шаг 2)
|
||||
|
||||
**Файлы изменены:**
|
||||
- `ticket_form/frontend/src/pages/ClaimForm.tsx`
|
||||
|
||||
### 2. Ошибка при загрузке черновика
|
||||
|
||||
**Ошибка:** `ReferenceError: Cannot access 'claimId2' before initialization` в `ClaimForm.tsx:160:50`
|
||||
|
||||
**Причина:** Конфликт имён переменных - локальная переменная `claimId` конфликтовала с параметром функции.
|
||||
|
||||
**Решение:** Переименована локальная переменная `claimId` в `finalClaimId` внутри функции `loadDraft`.
|
||||
|
||||
**Файлы изменены:**
|
||||
- `ticket_form/frontend/src/pages/ClaimForm.tsx`
|
||||
|
||||
### 3. Работа с n8n workflow `b4K4u851b4JFivyD` (ticket_form:description)
|
||||
|
||||
**Задача:** Настроить ноду `claimsave` для сохранения первичного черновика жалобы после построения wizard plan.
|
||||
|
||||
**Требования:**
|
||||
1. Сохранить черновик сразу после первичного построения wizard plan
|
||||
2. Включить данные из агентов (агент1 и агент13)
|
||||
3. Учесть `session_token` и `unified_id`
|
||||
4. Сохранить: `wizard_plan`, `problem_description`, `answers_prefill`, `coverage_report`, AI agent outputs
|
||||
|
||||
**Документация создана:**
|
||||
- `ticket_form/docs/CLAIMSAVE_PRIMARY_DRAFT_FIX.md`
|
||||
- `ticket_form/docs/SQL_CLAIMSAVE_PRIMARY_DRAFT.sql`
|
||||
|
||||
### 4. Ошибка в n8n Code node (Code4)
|
||||
|
||||
**Ошибка:** `ReferenceError: session is not defined [line 34]`
|
||||
|
||||
**Проблема:** В коде использовалась переменная `session`, которая не была определена.
|
||||
|
||||
**Решение:** Исправлен код в `CODE4_FIXED.js`:
|
||||
- Заменено `const sessionToken = $('Redis Trigger').first().json.message.claim_id` на более надёжную логику
|
||||
- `sessionToken` теперь берётся из `Edit Fields11` или `Redis Trigger`, с fallback на временный ключ
|
||||
- `redisKey` теперь использует `sessionToken` вместо `claim_id`
|
||||
|
||||
**Файлы:**
|
||||
- `ticket_form/docs/CODE4_FIXED.js`
|
||||
|
||||
### 5. Исправление CreateWebContact ноды
|
||||
|
||||
**Задача:** Убрать генерацию `claim_id`, добавить `unified_id` из ноды `user_get`, убрать `voucher` и `event_type` из `redis_value`.
|
||||
|
||||
**Решение:** Обновлён код `CODE_CREATE_WEB_CONTACT_FINAL.js`:
|
||||
- Убрана генерация `claim_id`
|
||||
- Добавлен `unified_id` из ноды `user_get`
|
||||
- Убраны `voucher` и `event_type` из `sessionData`
|
||||
- `redis_key` использует `session_id`
|
||||
|
||||
**Файлы:**
|
||||
- `ticket_form/docs/CODE_CREATE_WEB_CONTACT_FIXED.js`
|
||||
|
||||
### 6. Ошибка "Не удалось определить номер обращения"
|
||||
|
||||
**Проблема:** При создании нового обращения появлялась ошибка "Не удалось определить номер обращения. Вернитесь на шаг с телефоном."
|
||||
|
||||
**Решение:** Принято решение использовать только `session_id` на ранних этапах, убрать зависимость от `claim_id`.
|
||||
|
||||
**Изменения:**
|
||||
- `ticket_form/frontend/src/components/form/StepDescription.tsx` - убрана проверка `claim_id`
|
||||
- `ticket_form/frontend/src/components/form/Step1Phone.tsx` - убран `claim_id` из сохраняемых данных
|
||||
- `ticket_form/frontend/src/components/form/StepWizardPlan.tsx` - изменён EventSource на использование `session_id`
|
||||
- `ticket_form/backend/app/api/claims.py` - обновлено логирование для опционального `claim_id`
|
||||
|
||||
### 7. Модификация api_attach_documents.php
|
||||
|
||||
**Задача:** Вернуть `project_name` в дополнение к `project_id`.
|
||||
|
||||
**Решение:** Обновлён `include/Webservices/CreateClientProject.php`:
|
||||
- Функция теперь возвращает `project_name` вместе с `project_id` и `is_new`
|
||||
- Добавлен SQL запрос для получения `project_name`, если проект найден (не новый)
|
||||
|
||||
**Файлы:**
|
||||
- `include/Webservices/CreateClientProject.php`
|
||||
|
||||
### 8. Обновление S3 пути для файлов
|
||||
|
||||
**Задача:** Изменить формат пути S3 на `/f9825c87-.../crm2/CRM_Active_Files/Documents/Project/{project_name}_{project_id}/{doc_id}__{slug}.{ext}`
|
||||
|
||||
**Решение:** Обновлён `CODE_FILES_RENAME_FIXED.js`:
|
||||
- Добавлено получение `project_id` и `project_name` из нескольких источников
|
||||
- Реализована санитизация `projectFolder` для удаления недопустимых символов
|
||||
- Обновлена генерация `slug` с приоритетом: `field_label` > `field_name` > `description`
|
||||
- Добавлен `field_label` в `renames` и `finalDocumentsMeta`
|
||||
|
||||
**Файлы:**
|
||||
- `ticket_form/docs/CODE_FILES_RENAME_FIXED.js`
|
||||
|
||||
### 9. Исправление slug для названий документов
|
||||
|
||||
**Задача:** Использовать название поля из формы визарда вместо generic "upload-contr".
|
||||
|
||||
**Решение:**
|
||||
- В `StepWizardPlan.tsx` добавлена отправка `uploads_field_labels[i]` (содержит `block.docLabel`)
|
||||
- В `CODE_FILES_RENAME_FIXED.js` обновлена генерация `slug` с использованием `field_label`
|
||||
|
||||
**Файлы:**
|
||||
- `ticket_form/frontend/src/components/form/StepWizardPlan.tsx`
|
||||
- `ticket_form/docs/CODE_FILES_RENAME_FIXED.js`
|
||||
|
||||
### 10. Ошибка "Multiple matching items" в Edit Fields13
|
||||
|
||||
**Ошибка:** `Multiple matching items for item [0] [item 0]` в ноде "Edit Fields13".
|
||||
|
||||
**Решение:** Обновлены выражения в "Edit Fields13":
|
||||
- Добавлен `.first()` для нод, возвращающих один item (`Edit Fields6`, `Code5`)
|
||||
- Исправлено обращение к `Split Out2` (используется `$json.to` вместо `$('Split Out2').item.json.to`)
|
||||
|
||||
### 11. Исправление CODE_MERGE_PROJECT_TO_SESSION
|
||||
|
||||
**Ошибка:** `TypeError: Cannot assign to read only property 'name' of object 'Error: Referenced node doesn't exist'`
|
||||
|
||||
**Решение:** Заменён оператор `||` для доступа к ноде на `try-catch` блоки для безопасной проверки существования ноды.
|
||||
|
||||
**Файлы:**
|
||||
- `ticket_form/docs/CODE_MERGE_PROJECT_TO_SESSION.js`
|
||||
|
||||
### 12. Финальные исправления и коммит
|
||||
|
||||
**Выполнено:**
|
||||
- Исправлена загрузка черновиков (упрощена логика перехода)
|
||||
- Убрано отображение `claim_id` в заголовке черновика
|
||||
- Обновлён формат пути S3 с `project_name`
|
||||
- Добавлен `field_label` в результат переименования файлов
|
||||
|
||||
**Git коммиты:**
|
||||
- `486f3619`: "Добавлен field_label в результат переименования файлов, исправлена загрузка черновиков, обновлен формат пути S3 с project_name"
|
||||
- `a20a4d0e`: "Добавлен лог сессии 2025-11-22"
|
||||
|
||||
## Итоговые изменения
|
||||
|
||||
### Frontend
|
||||
1. `ClaimForm.tsx` - исправлена загрузка черновиков, убрана зависимость от `claim_id`
|
||||
2. `StepDescription.tsx` - убрана проверка `claim_id`
|
||||
3. `Step1Phone.tsx` - убран `claim_id` из сохраняемых данных
|
||||
4. `StepWizardPlan.tsx` - добавлена отправка `uploads_field_labels`, изменён EventSource на `session_id`
|
||||
5. `StepDraftSelection.tsx` - убран `claim_id` из заголовка черновика
|
||||
|
||||
### Backend
|
||||
1. `claims.py` - обновлено логирование для опционального `claim_id`
|
||||
2. `CreateClientProject.php` - добавлен возврат `project_name`
|
||||
|
||||
### n8n Workflows
|
||||
1. Code4 - исправлена ошибка с `session is not defined`
|
||||
2. CreateWebContact - убрана генерация `claim_id`, добавлен `unified_id`
|
||||
3. CODE_FILES_RENAME_FIXED - обновлён формат пути S3, добавлен `field_label`
|
||||
4. CODE_MERGE_PROJECT_TO_SESSION - безопасная проверка существования ноды
|
||||
5. Edit Fields13 - исправлена ошибка "Multiple matching items"
|
||||
|
||||
### Документация
|
||||
1. `CLAIMSAVE_PRIMARY_DRAFT_FIX.md` - описание сохранения первичного черновика
|
||||
2. `SQL_CLAIMSAVE_PRIMARY_DRAFT.sql` - SQL запрос для сохранения черновика
|
||||
3. `CODE4_FIXED.js` - исправленный код для Code4
|
||||
4. `CODE_CREATE_WEB_CONTACT_FIXED.js` - исправленный код для CreateWebContact
|
||||
5. `CODE_FILES_RENAME_FIXED.js` - обновлённый код для переименования файлов
|
||||
6. `CODE_MERGE_PROJECT_TO_SESSION.js` - код для мержа данных проекта
|
||||
|
||||
## Статистика
|
||||
|
||||
- **Изменено файлов:** 212
|
||||
- **Добавлено строк:** +6706
|
||||
- **Удалено строк:** -125
|
||||
- **Git коммитов:** 2
|
||||
|
||||
## Важные замечания
|
||||
|
||||
1. На ранних этапах используется только `session_id`, `claim_id` генерируется позже в workflow
|
||||
2. `project_name` теперь используется в пути S3 для лучшей организации файлов
|
||||
3. `field_label` из формы визарда используется для генерации slug файлов
|
||||
4. Все ноды n8n должны безопасно обрабатывать отсутствие данных
|
||||
|
||||
|
||||
135
SESSION_LOG_2025-11-25.md
Normal file
135
SESSION_LOG_2025-11-25.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Лог сессии 25.11.2025
|
||||
|
||||
## Основные задачи
|
||||
|
||||
### 1. Передача unified_id и contact_id в описание проблемы
|
||||
|
||||
**Файлы:**
|
||||
- `backend/app/api/models.py` — добавлены поля `unified_id` и `contact_id` в `TicketFormDescriptionRequest`
|
||||
- `backend/app/api/claims.py` — добавлена передача `unified_id` и `contact_id` в Redis событие
|
||||
- `frontend/src/components/form/StepDescription.tsx` — добавлена передача `unified_id` и `contact_id` при отправке описания
|
||||
|
||||
**Результат:** При отправке описания проблемы теперь передаются `unified_id` и `contact_id` пользователя.
|
||||
|
||||
---
|
||||
|
||||
### 2. Структура таблиц CRM MySQL для контактов
|
||||
|
||||
**Основные таблицы:**
|
||||
- `vtiger_contactdetails` — основные данные (firstname, lastname, email, mobile, phone)
|
||||
- `vtiger_contactscf` — кастомные поля:
|
||||
- `cf_1157` — Отчество (middle_name)
|
||||
- `cf_1263` — Место рождения (birthplace)
|
||||
- `cf_1257` — ИНН (inn)
|
||||
- `cf_1849` — Реквизиты (requisites)
|
||||
- `cf_1580` — Код (code)
|
||||
- `vtiger_contactsubdetails` — дополнительные данные (birthday, homephone)
|
||||
- `vtiger_contactaddress` — адреса (mailingstreet, mailingcity, и т.д.)
|
||||
|
||||
**Создан файл:** `docs/SQL_SELECT_CONTACT_WITH_CUSTOM_FIELDS.sql` — правильный SQL запрос для получения всех данных контакта
|
||||
|
||||
---
|
||||
|
||||
### 3. Исправление Code Node: Мерж данных проекта в сессию
|
||||
|
||||
**Проблема:** Данные из `body.other` (sessionData) не сохранялись в Redis — терялись все данные пользователя.
|
||||
|
||||
**Причина:** К моменту выполнения Code Node структура данных менялась (`body_keys: ["success", "result"]`), и `body.other` был недоступен.
|
||||
|
||||
**Решение:** Добавлен fallback на получение `other` напрямую из Webhook:
|
||||
```javascript
|
||||
// ✅ Пробуем также достать other из Webhook напрямую
|
||||
if (!rawOther) {
|
||||
try {
|
||||
const webhookJson = $('Webhook').first()?.json;
|
||||
if (webhookJson?.body?.other) {
|
||||
rawOther = webhookJson.body.other;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
```
|
||||
|
||||
**Файл:** `docs/CODE_MERGE_PROJECT_TO_SESSION.js`
|
||||
|
||||
**Результат:** Теперь в Redis сохраняются ВСЕ данные:
|
||||
- session_id, phone, unified_id, contact_id
|
||||
- lastname, firstname, middle_name
|
||||
- birthday, birthplace, inn
|
||||
- mailingzip, mailingstreet, email, tg_id
|
||||
- description
|
||||
- claim_id, project_id, project_name
|
||||
- is_new_project, current_step
|
||||
|
||||
---
|
||||
|
||||
### 4. Генерация новой сессии для новой жалобы
|
||||
|
||||
**Проблема:** При создании новой жалобы использовалась та же сессия, что и для предыдущей.
|
||||
|
||||
**Решение:**
|
||||
- Добавлена функция `generateUUIDv4()` в `ClaimForm.tsx`
|
||||
- При создании новой жалобы генерируется новый `session_id`
|
||||
- `session_token` в localStorage (авторизация) остаётся прежним
|
||||
- `unified_id`, `phone`, `contact_id` сохраняются
|
||||
|
||||
**Файл:** `frontend/src/pages/ClaimForm.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Созданные/обновлённые файлы
|
||||
|
||||
### Новые файлы:
|
||||
- `docs/SQL_SELECT_CONTACT_WITH_CUSTOM_FIELDS.sql` — SQL запрос для контактов с кастомными полями
|
||||
|
||||
### Обновлённые файлы:
|
||||
- `backend/app/api/models.py` — добавлены unified_id, contact_id
|
||||
- `backend/app/api/claims.py` — передача unified_id, contact_id в Redis
|
||||
- `frontend/src/components/form/StepDescription.tsx` — передача unified_id, contact_id
|
||||
- `frontend/src/pages/ClaimForm.tsx` — генерация новой сессии для новой жалобы
|
||||
- `docs/CODE_MERGE_PROJECT_TO_SESSION.js` — исправлен мерж данных в сессию
|
||||
|
||||
---
|
||||
|
||||
## Технические детали
|
||||
|
||||
### Redis канал для описания проблемы
|
||||
- Канал: `ticket_form:description`
|
||||
- Передаваемые данные: session_id, phone, email, unified_id, contact_id, problem_description
|
||||
|
||||
### Redis канал для подтверждения формы
|
||||
- Канал: `clientright:webform:approve`
|
||||
- Включает SMS код для верификации
|
||||
|
||||
### Структура сессии в Redis
|
||||
```json
|
||||
{
|
||||
"session_id": "sess_...",
|
||||
"phone": "79262306381",
|
||||
"unified_id": "usr_...",
|
||||
"contact_id": "320096",
|
||||
"lastname": "Коробков",
|
||||
"firstname": "Федор",
|
||||
"middle_name": "Владимирович",
|
||||
"birthday": "1981-09-18",
|
||||
"birthplace": "Москва",
|
||||
"inn": "123456789012",
|
||||
"mailingstreet": "...",
|
||||
"email": "help@clientright.ru",
|
||||
"tg_id": "295410106",
|
||||
"description": "...",
|
||||
"claim_id": "...",
|
||||
"project_id": "399171",
|
||||
"project_name": "Коробков_КлиентПрав",
|
||||
"is_new_project": false,
|
||||
"current_step": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Статус
|
||||
✅ Все задачи выполнены
|
||||
✅ Backend пересобран и перезапущен
|
||||
✅ Frontend обновлён через HMR
|
||||
✅ Тестирование успешно
|
||||
|
||||
176
SESSION_LOG_2025-11-26_DOCUMENTS_FIX.md
Normal file
176
SESSION_LOG_2025-11-26_DOCUMENTS_FIX.md
Normal 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
|
||||
```
|
||||
|
||||
287
SESSION_LOG_2025-11-26_NEW_FLOW.md
Normal file
287
SESSION_LOG_2025-11-26_NEW_FLOW.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# 📝 Лог сессии: Новая архитектура загрузки документов
|
||||
|
||||
**Дата:** 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` — запрос на генерацию списка документов
|
||||
|
||||
|
||||
|
||||
**Дата:** 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` — запрос на генерацию списка документов
|
||||
|
||||
|
||||
55
SESSION_LOG_2025-11-26_WIZARD_UI.md
Normal file
55
SESSION_LOG_2025-11-26_WIZARD_UI.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Сессия 26 ноября 2025 - Исправления UI Wizard
|
||||
|
||||
## Основные изменения
|
||||
|
||||
### 1. Исправлена ошибка Authentication failed в upload_documents_to_crm.php
|
||||
- **Проблема:** Race condition при параллельных запросах к webservice CRM
|
||||
- **Решение:** Добавлена функция `getWebserviceSession()` с retry механизмом (до 3 попыток) и случайной задержкой между попытками
|
||||
|
||||
### 2. Исправлен Wizard Plan - чекбоксы заменены на блоки загрузки
|
||||
- **Проблема:** Вопрос `docs_exist` показывал чекбоксы вместо полей загрузки файлов
|
||||
- **Решение:**
|
||||
- Скрыт вопрос `docs_exist` когда есть документы в плане
|
||||
- Добавлены блоки загрузки файлов под карточкой "Документы, которые понадобятся"
|
||||
|
||||
### 3. Чекбокс "У меня нет документа" перенесён под загрузку
|
||||
- **Было:** Чекбокс показывался отдельно сверху
|
||||
- **Стало:** Чекбокс внутри карточки, под Dragger (только для обязательных документов)
|
||||
|
||||
### 4. Блоки загрузки сразу развёрнуты
|
||||
- Добавлен useEffect с ref для автоматического создания блоков при загрузке плана
|
||||
- Используется `createdDocBlocksRef` чтобы избежать дублирования
|
||||
|
||||
### 5. Убраны лишние поля для предустановленных документов
|
||||
- Для документов из плана (contract, payment, correspondence и т.д.):
|
||||
- Нет поля "Уточните тип" (тип уже известен)
|
||||
- Нет кнопки "Удалить" для первого блока
|
||||
- Для дополнительных блоков - поля отображаются
|
||||
|
||||
### 6. Исправлено дублирование блоков
|
||||
- Убран дублирующий useEffect (для documentGroups)
|
||||
- Добавлен ref `createdDocBlocksRef` для отслеживания созданных блоков
|
||||
- Исправлена опечатка `React.useRef` → `useRef`
|
||||
|
||||
## Файлы изменены
|
||||
|
||||
1. `upload_documents_to_crm.php` - retry механизм для аутентификации
|
||||
2. `ticket_form/frontend/src/components/form/StepWizardPlan.tsx`:
|
||||
- Скрытие вопроса docs_exist
|
||||
- Блоки загрузки под информационной карточкой
|
||||
- Чекбокс под Dragger
|
||||
- Автосоздание блоков при загрузке
|
||||
- Улучшенная логика isPredefinedDoc
|
||||
|
||||
## Коммиты
|
||||
|
||||
1. `Добавлен retry механизм для webservice аутентификации (race condition fix)`
|
||||
2. `Заменены чекбоксы docs_exist на блоки загрузки файлов`
|
||||
3. `Исправлен JSX Fragment для блоков загрузки документов`
|
||||
4. `Чекбокс 'нет документа' перенесён под блок загрузки`
|
||||
5. `Блоки загрузки документов сразу развёрнуты при загрузке плана`
|
||||
6. `Убраны лишние поля для предустановленных документов`
|
||||
7. `Убран дублирующий useEffect для создания блоков документов`
|
||||
8. `Исправлено дублирование блоков документов (ref для отслеживания созданных)`
|
||||
9. `Исправлен React.useRef → useRef`
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -688,6 +693,8 @@ async def publish_ticket_form_description(payload: TicketFormDescriptionRequest)
|
||||
"claim_id": payload.claim_id, # Опционально - может быть None
|
||||
"phone": payload.phone,
|
||||
"email": payload.email,
|
||||
"unified_id": payload.unified_id, # ✅ Unified ID пользователя
|
||||
"contact_id": payload.contact_id, # ✅ Contact ID пользователя
|
||||
"description": payload.problem_description.strip(),
|
||||
"source": payload.source,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
@@ -701,6 +708,8 @@ async def publish_ticket_form_description(payload: TicketFormDescriptionRequest)
|
||||
"session_id": payload.session_id,
|
||||
"claim_id": payload.claim_id or "not_set",
|
||||
"phone": payload.phone,
|
||||
"unified_id": payload.unified_id or "not_set",
|
||||
"contact_id": payload.contact_id or "not_set",
|
||||
"description_length": len(payload.problem_description),
|
||||
"channel": channel,
|
||||
},
|
||||
@@ -716,18 +725,25 @@ async def publish_ticket_form_description(payload: TicketFormDescriptionRequest)
|
||||
},
|
||||
)
|
||||
|
||||
await redis_service.publish(channel, event_json)
|
||||
subscribers_count = await redis_service.publish(channel, event_json)
|
||||
|
||||
logger.info(
|
||||
"✅ TicketForm description published to Redis",
|
||||
extra={
|
||||
"channel": channel,
|
||||
"session_id": payload.session_id,
|
||||
"subscribers_notified": True,
|
||||
"subscribers_count": subscribers_count,
|
||||
"event_json_preview": event_json[:500],
|
||||
},
|
||||
)
|
||||
|
||||
if subscribers_count == 0:
|
||||
logger.warning(
|
||||
f"⚠️ WARNING: No subscribers on channel {channel}! "
|
||||
f"n8n workflow is not listening to this channel. "
|
||||
f"Event was published but will be lost."
|
||||
)
|
||||
|
||||
# Дополнительная проверка: логируем полный event для отладки
|
||||
logger.debug(
|
||||
"🔍 Full event data published",
|
||||
|
||||
909
backend/app/api/documents.py
Normal file
909
backend/app/api/documents.py
Normal file
@@ -0,0 +1,909 @@
|
||||
"""
|
||||
Documents API Routes - Загрузка и обработка документов
|
||||
|
||||
Новый флоу: поэкранная загрузка документов
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Form, Request
|
||||
from typing import Optional, List
|
||||
import httpx
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from ..services.redis_service import redis_service
|
||||
from ..config import settings
|
||||
|
||||
router = APIRouter(prefix="/api/v1/documents", tags=["Documents"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# n8n webhook для загрузки документов
|
||||
N8N_DOCUMENT_UPLOAD_WEBHOOK = "https://n8n.clientright.pro/webhook/webform_document_upload"
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_document(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
claim_id: str = Form(...),
|
||||
session_id: str = Form(...),
|
||||
document_type: str = Form(...),
|
||||
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 для:
|
||||
1. Сохранения в S3
|
||||
2. OCR обработки
|
||||
3. Обновления черновика в PostgreSQL
|
||||
|
||||
После успешной обработки n8n публикует событие document_ocr_completed в Redis.
|
||||
"""
|
||||
try:
|
||||
# Генерируем уникальный ID файла
|
||||
file_id = f"doc_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
logger.info(
|
||||
"📤 Document upload received",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"session_id": session_id,
|
||||
"document_type": document_type,
|
||||
"file_name": file.filename,
|
||||
"file_size": file.size if hasattr(file, 'size') else 'unknown',
|
||||
"content_type": file.content_type,
|
||||
},
|
||||
)
|
||||
|
||||
# Читаем содержимое файла
|
||||
file_content = await file.read()
|
||||
file_size = len(file_content)
|
||||
|
||||
# Получаем 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_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_* для совместимости
|
||||
"uploads_field_names[0]": document_type,
|
||||
"uploads_field_labels[0]": document_name or document_type,
|
||||
"uploads_descriptions[0]": document_description or "",
|
||||
}
|
||||
|
||||
# Файл для multipart (ключ uploads[0] для совместимости)
|
||||
files = {
|
||||
"uploads[0]": (file.filename, file_content, file.content_type or "application/octet-stream")
|
||||
}
|
||||
|
||||
# Отправляем в n8n
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.post(
|
||||
N8N_DOCUMENT_UPLOAD_WEBHOOK,
|
||||
data=form_data,
|
||||
files=files,
|
||||
)
|
||||
|
||||
response_text = response.text or ""
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
"✅ Document uploaded to n8n",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"document_type": document_type,
|
||||
"file_id": file_id,
|
||||
"response_preview": response_text[:200],
|
||||
},
|
||||
)
|
||||
|
||||
# Парсим ответ от n8n
|
||||
try:
|
||||
n8n_response = json.loads(response_text)
|
||||
except json.JSONDecodeError:
|
||||
n8n_response = {"raw": response_text}
|
||||
|
||||
# Публикуем событие в Redis для фронтенда
|
||||
event_data = {
|
||||
"event_type": "document_uploaded",
|
||||
"status": "processing",
|
||||
"claim_id": claim_id,
|
||||
"session_id": session_id,
|
||||
"document_type": document_type,
|
||||
"file_id": file_id,
|
||||
"original_filename": file.filename,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
await redis_service.publish(
|
||||
f"ocr_events:{session_id}",
|
||||
json.dumps(event_data, ensure_ascii=False)
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"file_id": file_id,
|
||||
"document_type": document_type,
|
||||
"ocr_status": "processing",
|
||||
"message": "Документ загружен и отправлен на обработку",
|
||||
"n8n_response": n8n_response,
|
||||
}
|
||||
|
||||
else:
|
||||
logger.error(
|
||||
"❌ n8n document upload error",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"body": response_text[:500],
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=f"Ошибка n8n: {response_text}",
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("⏱️ n8n document upload timeout")
|
||||
raise HTTPException(status_code=504, detail="Таймаут загрузки документа")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("❌ Document upload error")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ошибка загрузки документа: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.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):
|
||||
"""
|
||||
Получить статус обработки документов для заявки.
|
||||
|
||||
Возвращает:
|
||||
- Список загруженных документов и их OCR статус
|
||||
- Общий прогресс обработки
|
||||
"""
|
||||
try:
|
||||
# TODO: Запрос в PostgreSQL для получения статуса документов
|
||||
# Пока возвращаем mock данные
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"claim_id": claim_id,
|
||||
"documents": [],
|
||||
"ocr_progress": {
|
||||
"total": 0,
|
||||
"completed": 0,
|
||||
"processing": 0,
|
||||
"failed": 0,
|
||||
},
|
||||
"wizard_ready": False,
|
||||
"claim_ready": False,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("❌ Error getting documents status")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ошибка получения статуса: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/generate-list")
|
||||
async def generate_documents_list(request: Request):
|
||||
"""
|
||||
Запрос на генерацию списка документов для проблемы.
|
||||
|
||||
Принимает описание проблемы, отправляет в n8n для быстрого AI-анализа.
|
||||
n8n публикует результат в Redis канал ocr_events:{session_id} с event_type=documents_list_ready.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
|
||||
session_id = body.get("session_id")
|
||||
problem_description = body.get("problem_description")
|
||||
|
||||
if not session_id or not problem_description:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="session_id и problem_description обязательны",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"📝 Generate documents list request",
|
||||
extra={
|
||||
"session_id": session_id,
|
||||
"description_length": len(problem_description),
|
||||
},
|
||||
)
|
||||
|
||||
# Публикуем событие в Redis для n8n
|
||||
event_data = {
|
||||
"type": "generate_documents_list",
|
||||
"session_id": session_id,
|
||||
"claim_id": body.get("claim_id"),
|
||||
"unified_id": body.get("unified_id"),
|
||||
"phone": body.get("phone"),
|
||||
"problem_description": problem_description,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
channel = f"{settings.redis_prefix}documents_list"
|
||||
|
||||
subscribers = await redis_service.publish(
|
||||
channel,
|
||||
json.dumps(event_data, ensure_ascii=False)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Documents list request published",
|
||||
extra={
|
||||
"channel": channel,
|
||||
"subscribers": subscribers,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Запрос на генерацию списка документов отправлен",
|
||||
"channel": channel,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("❌ Error generating documents list")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ошибка генерации списка: {str(e)}",
|
||||
)
|
||||
from typing import Optional, List
|
||||
import httpx
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from ..services.redis_service import redis_service
|
||||
from ..config import settings
|
||||
|
||||
router = APIRouter(prefix="/api/v1/documents", tags=["Documents"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# n8n webhook для загрузки документов
|
||||
N8N_DOCUMENT_UPLOAD_WEBHOOK = "https://n8n.clientright.pro/webhook/webform_document_upload"
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_document(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
claim_id: str = Form(...),
|
||||
session_id: str = Form(...),
|
||||
document_type: str = Form(...),
|
||||
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 для:
|
||||
1. Сохранения в S3
|
||||
2. OCR обработки
|
||||
3. Обновления черновика в PostgreSQL
|
||||
|
||||
После успешной обработки n8n публикует событие document_ocr_completed в Redis.
|
||||
"""
|
||||
try:
|
||||
# Генерируем уникальный ID файла
|
||||
file_id = f"doc_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
logger.info(
|
||||
"📤 Document upload received",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"session_id": session_id,
|
||||
"document_type": document_type,
|
||||
"file_name": file.filename,
|
||||
"file_size": file.size if hasattr(file, 'size') else 'unknown',
|
||||
"content_type": file.content_type,
|
||||
},
|
||||
)
|
||||
|
||||
# Читаем содержимое файла
|
||||
file_content = await file.read()
|
||||
file_size = len(file_content)
|
||||
|
||||
# Получаем 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_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_* для совместимости
|
||||
"uploads_field_names[0]": document_type,
|
||||
"uploads_field_labels[0]": document_name or document_type,
|
||||
"uploads_descriptions[0]": document_description or "",
|
||||
}
|
||||
|
||||
# Файл для multipart (ключ uploads[0] для совместимости)
|
||||
files = {
|
||||
"uploads[0]": (file.filename, file_content, file.content_type or "application/octet-stream")
|
||||
}
|
||||
|
||||
# Отправляем в n8n
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.post(
|
||||
N8N_DOCUMENT_UPLOAD_WEBHOOK,
|
||||
data=form_data,
|
||||
files=files,
|
||||
)
|
||||
|
||||
response_text = response.text or ""
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
"✅ Document uploaded to n8n",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"document_type": document_type,
|
||||
"file_id": file_id,
|
||||
"response_preview": response_text[:200],
|
||||
},
|
||||
)
|
||||
|
||||
# Парсим ответ от n8n
|
||||
try:
|
||||
n8n_response = json.loads(response_text)
|
||||
except json.JSONDecodeError:
|
||||
n8n_response = {"raw": response_text}
|
||||
|
||||
# Публикуем событие в Redis для фронтенда
|
||||
event_data = {
|
||||
"event_type": "document_uploaded",
|
||||
"status": "processing",
|
||||
"claim_id": claim_id,
|
||||
"session_id": session_id,
|
||||
"document_type": document_type,
|
||||
"file_id": file_id,
|
||||
"original_filename": file.filename,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
await redis_service.publish(
|
||||
f"ocr_events:{session_id}",
|
||||
json.dumps(event_data, ensure_ascii=False)
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"file_id": file_id,
|
||||
"document_type": document_type,
|
||||
"ocr_status": "processing",
|
||||
"message": "Документ загружен и отправлен на обработку",
|
||||
"n8n_response": n8n_response,
|
||||
}
|
||||
|
||||
else:
|
||||
logger.error(
|
||||
"❌ n8n document upload error",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"body": response_text[:500],
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=f"Ошибка n8n: {response_text}",
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("⏱️ n8n document upload timeout")
|
||||
raise HTTPException(status_code=504, detail="Таймаут загрузки документа")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("❌ Document upload error")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ошибка загрузки документа: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.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):
|
||||
"""
|
||||
Получить статус обработки документов для заявки.
|
||||
|
||||
Возвращает:
|
||||
- Список загруженных документов и их OCR статус
|
||||
- Общий прогресс обработки
|
||||
"""
|
||||
try:
|
||||
# TODO: Запрос в PostgreSQL для получения статуса документов
|
||||
# Пока возвращаем mock данные
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"claim_id": claim_id,
|
||||
"documents": [],
|
||||
"ocr_progress": {
|
||||
"total": 0,
|
||||
"completed": 0,
|
||||
"processing": 0,
|
||||
"failed": 0,
|
||||
},
|
||||
"wizard_ready": False,
|
||||
"claim_ready": False,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("❌ Error getting documents status")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ошибка получения статуса: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/generate-list")
|
||||
async def generate_documents_list(request: Request):
|
||||
"""
|
||||
Запрос на генерацию списка документов для проблемы.
|
||||
|
||||
Принимает описание проблемы, отправляет в n8n для быстрого AI-анализа.
|
||||
n8n публикует результат в Redis канал ocr_events:{session_id} с event_type=documents_list_ready.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
|
||||
session_id = body.get("session_id")
|
||||
problem_description = body.get("problem_description")
|
||||
|
||||
if not session_id or not problem_description:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="session_id и problem_description обязательны",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"📝 Generate documents list request",
|
||||
extra={
|
||||
"session_id": session_id,
|
||||
"description_length": len(problem_description),
|
||||
},
|
||||
)
|
||||
|
||||
# Публикуем событие в Redis для n8n
|
||||
event_data = {
|
||||
"type": "generate_documents_list",
|
||||
"session_id": session_id,
|
||||
"claim_id": body.get("claim_id"),
|
||||
"unified_id": body.get("unified_id"),
|
||||
"phone": body.get("phone"),
|
||||
"problem_description": problem_description,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
channel = f"{settings.redis_prefix}documents_list"
|
||||
|
||||
subscribers = await redis_service.publish(
|
||||
channel,
|
||||
json.dumps(event_data, ensure_ascii=False)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Documents list request published",
|
||||
extra={
|
||||
"channel": channel,
|
||||
"subscribers": subscribers,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Запрос на генерацию списка документов отправлен",
|
||||
"channel": channel,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("❌ Error generating documents list")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ошибка генерации списка: {str(e)}",
|
||||
)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -69,6 +69,8 @@ class TicketFormDescriptionRequest(BaseModel):
|
||||
claim_id: Optional[str] = Field(None, description="ID заявки (если уже создана)")
|
||||
phone: Optional[str] = Field(None, description="Номер телефона заявителя")
|
||||
email: Optional[str] = Field(None, description="Email заявителя")
|
||||
unified_id: Optional[str] = Field(None, description="Unified ID пользователя из PostgreSQL")
|
||||
contact_id: Optional[str] = Field(None, description="Contact ID пользователя в CRM")
|
||||
problem_description: str = Field(..., min_length=10, description="Свободное описание ситуации")
|
||||
source: str = Field("ticket_form", description="Источник события")
|
||||
channel: Optional[str] = Field(None, description="Переопределение Redis канала (опционально)")
|
||||
|
||||
@@ -12,7 +12,7 @@ from .services.redis_service import redis_service
|
||||
from .services.rabbitmq_service import rabbitmq_service
|
||||
from .services.policy_service import policy_service
|
||||
from .services.s3_service import s3_service
|
||||
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session
|
||||
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(
|
||||
@@ -103,6 +103,7 @@ app.include_router(draft.router)
|
||||
app.include_router(events.router)
|
||||
app.include_router(n8n_proxy.router) # 🔒 Безопасный proxy к n8n webhooks
|
||||
app.include_router(session.router) # 🔑 Session management через Redis
|
||||
app.include_router(documents.router) # 📄 Documents upload and processing
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@@ -228,3 +229,4 @@ async def info():
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8200)
|
||||
|
||||
|
||||
@@ -54,9 +54,18 @@ class RedisService:
|
||||
async def publish(self, channel: str, message: str):
|
||||
"""Публикация сообщения в канал Redis Pub/Sub"""
|
||||
try:
|
||||
await self.client.publish(channel, message)
|
||||
subscribers_count = await self.client.publish(channel, message)
|
||||
logger.info(
|
||||
f"📢 Redis publish: channel={channel}, message_length={len(message)}, subscribers={subscribers_count}"
|
||||
)
|
||||
if subscribers_count == 0:
|
||||
logger.warning(
|
||||
f"⚠️ No subscribers on channel {channel}. Message published but no one is listening!"
|
||||
)
|
||||
return subscribers_count
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Redis publish error: {e}")
|
||||
raise
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
"""Удалить ключ"""
|
||||
|
||||
68
check_claim_documents_table.py
Normal file
68
check_claim_documents_table.py
Normal 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())
|
||||
|
||||
86
check_documents_detailed.py
Normal file
86
check_documents_detailed.py
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/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(f"📋 Статус: {row['status_code']}")
|
||||
print(f"📋 Обновлён: {row['updated_at']}")
|
||||
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())
|
||||
|
||||
118
check_documents_mismatch.py
Normal file
118
check_documents_mismatch.py
Normal 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())
|
||||
|
||||
62
check_draft_documents.py
Normal file
62
check_draft_documents.py
Normal 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())
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// ========================================
|
||||
// Code Node: Мерж данных проекта в сессию
|
||||
// v2.0 - с расширенным логированием для отладки
|
||||
// ========================================
|
||||
|
||||
// 1. Берём первый item
|
||||
@@ -12,25 +13,62 @@ if (!inputItem || !inputItem.json) {
|
||||
// root — то, что реально пришло в эту ноду
|
||||
const root = inputItem.json;
|
||||
|
||||
// ✅ ОТЛАДКА: смотрим что пришло
|
||||
console.log('🔍 DEBUG: root keys:', Object.keys(root));
|
||||
console.log('🔍 DEBUG: root.body exists:', !!root.body);
|
||||
console.log('🔍 DEBUG: root.other exists:', !!root.other);
|
||||
|
||||
// 2. Универсально получаем body
|
||||
// - если нода стоит сразу после Webhook → данные лежат в root.body
|
||||
// - если кто-то выше уже отдал только body → root и есть body
|
||||
const body = root.body || root;
|
||||
|
||||
console.log('🔍 DEBUG: body keys:', Object.keys(body));
|
||||
console.log('🔍 DEBUG: body.other exists:', !!body.other);
|
||||
console.log('🔍 DEBUG: body.other type:', typeof body.other);
|
||||
|
||||
// 3. Парсим body.other (если есть) как сессию
|
||||
// ✅ ВАЖНО: Также проверяем root.other напрямую (если данные пришли не через body)
|
||||
let sessionData = {};
|
||||
const rawOther = body.other;
|
||||
let rawOther = body.other || root.other;
|
||||
|
||||
// ✅ Пробуем также достать other из Webhook напрямую
|
||||
if (!rawOther) {
|
||||
try {
|
||||
const webhookJson = $('Webhook').first()?.json;
|
||||
if (webhookJson?.body?.other) {
|
||||
rawOther = webhookJson.body.other;
|
||||
console.log('✅ Взяли other напрямую из Webhook');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('⚠️ Не удалось достать other из Webhook:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🔍 DEBUG: rawOther exists:', !!rawOther);
|
||||
console.log('🔍 DEBUG: rawOther type:', typeof rawOther);
|
||||
if (rawOther) {
|
||||
console.log('🔍 DEBUG: rawOther preview:', typeof rawOther === 'string' ? rawOther.substring(0, 200) : JSON.stringify(rawOther).substring(0, 200));
|
||||
}
|
||||
|
||||
if (rawOther) {
|
||||
if (typeof rawOther === 'string') {
|
||||
try {
|
||||
sessionData = JSON.parse(rawOther);
|
||||
console.log('✅ Распарсили other как JSON. Ключи:', Object.keys(sessionData));
|
||||
console.log('✅ sessionData.session_id:', sessionData.session_id);
|
||||
console.log('✅ sessionData.phone:', sessionData.phone);
|
||||
console.log('✅ sessionData.firstname:', sessionData.firstname);
|
||||
} catch (e) {
|
||||
throw new Error('Не смог распарсить body.other как JSON: ' + e.message + '. rawOther: ' + rawOther);
|
||||
throw new Error('Не смог распарсить other как JSON: ' + e.message + '. rawOther: ' + rawOther.substring(0, 500));
|
||||
}
|
||||
} else if (typeof rawOther === 'object') {
|
||||
sessionData = rawOther;
|
||||
console.log('✅ other уже объект. Ключи:', Object.keys(sessionData));
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ other отсутствует или пустой. Проверьте структуру данных!');
|
||||
console.log('⚠️ root:', JSON.stringify(root).substring(0, 500));
|
||||
}
|
||||
|
||||
// 4. Определяем claimId (основной путь)
|
||||
@@ -94,19 +132,75 @@ if (!projectResult || !projectResult.project_id) {
|
||||
}
|
||||
|
||||
// 8. Собираем обновлённую сессию
|
||||
// ✅ Используем spread оператор, но с фильтрацией undefined значений
|
||||
// Сначала создаём базовый объект из sessionData, фильтруя undefined
|
||||
const baseSession = Object.keys(sessionData).reduce((acc, key) => {
|
||||
if (sessionData[key] !== undefined && sessionData[key] !== null) {
|
||||
acc[key] = sessionData[key];
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
console.log('📦 baseSession после фильтрации:', Object.keys(baseSession));
|
||||
console.log('📦 baseSession sample:', {
|
||||
session_id: baseSession.session_id,
|
||||
phone: baseSession.phone,
|
||||
unified_id: baseSession.unified_id,
|
||||
contact_id: baseSession.contact_id,
|
||||
firstname: baseSession.firstname,
|
||||
lastname: baseSession.lastname,
|
||||
});
|
||||
|
||||
const updatedSession = {
|
||||
...sessionData, // всё, что было в other
|
||||
claim_id: claimId, // актуальный claim_id
|
||||
// ✅ Шаг 1: Все данные из sessionData (body.other) - базовая сессия
|
||||
...baseSession,
|
||||
|
||||
// ✅ Шаг 2: Дополняем данными из body (если их нет в sessionData)
|
||||
...(body.phone && !baseSession.phone ? { phone: body.phone } : {}),
|
||||
...(body.unified_id && !baseSession.unified_id ? { unified_id: body.unified_id } : {}),
|
||||
...(body.contact_id && !baseSession.contact_id ? { contact_id: body.contact_id } : {}),
|
||||
...(body.email && !baseSession.email ? { email: body.email } : {}),
|
||||
|
||||
// ✅ Шаг 3: Данные проекта (новые, всегда перезаписываем)
|
||||
claim_id: claimId, // актуальный claim_id (перезаписываем null из sessionData)
|
||||
project_id: projectResult.project_id, // id проекта из CRM
|
||||
project_name: projectResult.project_name || null, // название проекта из CRM (новое поле)
|
||||
project_name: projectResult.project_name || null, // название проекта из CRM
|
||||
is_new_project: projectResult.is_new, // флаг новый/старый
|
||||
current_step: 2, // двигаем визард на шаг 2
|
||||
|
||||
// ✅ Шаг 4: Данные анализа из body (приоритет body)
|
||||
problem: body.problem || baseSession.problem || null,
|
||||
last_analysis_output: body.output || baseSession.last_analysis_output || null,
|
||||
|
||||
// ✅ Шаг 5: Метаданные (всегда обновляем)
|
||||
updated_at: new Date().toISOString(),
|
||||
// опционально дотащим полезные поля из body:
|
||||
problem: body.problem ?? sessionData.problem,
|
||||
last_analysis_output: body.output ?? sessionData.last_analysis_output,
|
||||
};
|
||||
|
||||
// ✅ Логируем результат для отладки
|
||||
console.log('📦 sessionData keys:', Object.keys(sessionData));
|
||||
console.log('📦 sessionData sample:', {
|
||||
session_id: sessionData.session_id,
|
||||
phone: sessionData.phone,
|
||||
unified_id: sessionData.unified_id,
|
||||
contact_id: sessionData.contact_id,
|
||||
firstname: sessionData.firstname,
|
||||
lastname: sessionData.lastname,
|
||||
middle_name: sessionData.middle_name,
|
||||
});
|
||||
console.log('📦 updatedSession keys:', Object.keys(updatedSession));
|
||||
console.log('📦 updatedSession sample:', {
|
||||
session_id: updatedSession.session_id,
|
||||
phone: updatedSession.phone,
|
||||
unified_id: updatedSession.unified_id,
|
||||
contact_id: updatedSession.contact_id,
|
||||
firstname: updatedSession.firstname,
|
||||
lastname: updatedSession.lastname,
|
||||
middle_name: updatedSession.middle_name,
|
||||
claim_id: updatedSession.claim_id,
|
||||
project_id: updatedSession.project_id,
|
||||
});
|
||||
console.log('📦 updatedSession FULL:', JSON.stringify(updatedSession, null, 2));
|
||||
|
||||
// 9. Возвращаем один item для Redis SET
|
||||
return [
|
||||
{
|
||||
|
||||
157
docs/N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js
Normal file
157
docs/N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js
Normal file
@@ -0,0 +1,157 @@
|
||||
// ============================================================================
|
||||
// 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}]`;
|
||||
const field_label = uploads_field_labels[grp] || uploads_field_names[grp] || uploads_descriptions[grp] || `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[grp] || '');
|
||||
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
|
||||
}
|
||||
}];
|
||||
115
docs/N8N_CODE_PUSH_DOCUMENTS_LIST.js
Normal file
115
docs/N8N_CODE_PUSH_DOCUMENTS_LIST.js
Normal 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 }}
|
||||
// ============================================================================
|
||||
|
||||
225
docs/N8N_MEMORY_ISSUES.md
Normal file
225
docs/N8N_MEMORY_ISSUES.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# 🐛 Проблемы с памятью в n8n
|
||||
|
||||
## 🔍 Симптомы
|
||||
|
||||
- UI n8n не отвечает (нельзя сохранить workflow, включить/выключить)
|
||||
- Workflow не обрабатывает события
|
||||
- Страница зависает при попытке редактирования
|
||||
- Требуется перезагрузка сервера для восстановления
|
||||
|
||||
## 💾 Возможные причины
|
||||
|
||||
### 1. **Переполнение памяти (OOM)**
|
||||
- n8n процесс исчерпал доступную память
|
||||
- Система убивает процесс (OOM Killer)
|
||||
- Или процесс зависает в ожидании освобождения памяти
|
||||
|
||||
**Диагностика:**
|
||||
```bash
|
||||
# Проверка использования памяти n8n
|
||||
docker stats n8n_container --no-stream
|
||||
|
||||
# Проверка логов OOM Killer
|
||||
dmesg | grep -i "out of memory"
|
||||
dmesg | grep -i "killed process"
|
||||
|
||||
# Проверка использования памяти системой
|
||||
free -h
|
||||
```
|
||||
|
||||
### 2. **Утечки памяти в workflow**
|
||||
- Workflow накапливает данные в памяти
|
||||
- Большие массивы данных не освобождаются
|
||||
- Долгие операции держат данные в памяти
|
||||
|
||||
**Диагностика:**
|
||||
- Проверить Execution History - сколько данных хранится
|
||||
- Проверить размер данных в workflow (большие JSON объекты)
|
||||
- Проверить количество активных executions
|
||||
|
||||
### 3. **Слишком много активных workflows**
|
||||
- Много workflows работают одновременно
|
||||
- Каждый workflow держит соединения и данные в памяти
|
||||
- Redis Trigger для каждого workflow = отдельное соединение
|
||||
|
||||
**Диагностика:**
|
||||
```bash
|
||||
# Количество активных workflows (через n8n API или БД)
|
||||
# Проверить количество Redis подписок
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" CLIENT LIST | grep -c "SUBSCRIBE"
|
||||
```
|
||||
|
||||
### 4. **Большие данные в workflow**
|
||||
- Workflow обрабатывает большие файлы/JSON
|
||||
- Данные хранятся в памяти между нодами
|
||||
- Нет очистки промежуточных данных
|
||||
|
||||
**Диагностика:**
|
||||
- Проверить размер данных в Execution History
|
||||
- Проверить размер JSON payload между нодами
|
||||
- Проверить использование диска для execution data
|
||||
|
||||
### 5. **Проблемы с базой данных n8n**
|
||||
- База данных n8n переполнена старыми executions
|
||||
- Медленные запросы блокируют работу
|
||||
- Блокировки таблиц
|
||||
|
||||
**Диагностика:**
|
||||
```bash
|
||||
# Размер базы данных n8n
|
||||
# Проверить количество executions
|
||||
# Проверить медленные запросы
|
||||
```
|
||||
|
||||
## 🛠️ Решения
|
||||
|
||||
### 1. **Ограничить использование памяти**
|
||||
|
||||
В `docker-compose.yml` для n8n:
|
||||
```yaml
|
||||
services:
|
||||
n8n:
|
||||
mem_limit: 2g # Ограничить память до 2GB
|
||||
mem_reservation: 1g # Резервировать минимум 1GB
|
||||
oom_kill_disable: false # Разрешить OOM Killer убивать процесс
|
||||
```
|
||||
|
||||
Или через переменные окружения:
|
||||
```bash
|
||||
NODE_OPTIONS="--max-old-space-size=1536" # Ограничить heap до 1.5GB
|
||||
```
|
||||
|
||||
### 2. **Очистить старые executions**
|
||||
|
||||
Настроить автоматическую очистку в n8n:
|
||||
- Settings → Workflows → Execution Data Retention
|
||||
- Установить срок хранения (например, 7 дней)
|
||||
- Включить автоматическую очистку
|
||||
|
||||
Или через SQL (если используете PostgreSQL):
|
||||
```sql
|
||||
-- Удалить executions старше 7 дней
|
||||
DELETE FROM execution_entity
|
||||
WHERE "stoppedAt" < NOW() - INTERVAL '7 days';
|
||||
|
||||
-- Удалить execution_data для удалённых executions
|
||||
DELETE FROM execution_data
|
||||
WHERE "executionId" NOT IN (SELECT id FROM execution_entity);
|
||||
```
|
||||
|
||||
### 3. **Оптимизировать workflow**
|
||||
|
||||
- **Не хранить большие данные между нодами**
|
||||
- Использовать `Set` node для очистки ненужных полей
|
||||
- Не передавать большие файлы через workflow data
|
||||
|
||||
- **Использовать streaming для больших данных**
|
||||
- Обрабатывать данные порциями
|
||||
- Не загружать всё в память сразу
|
||||
|
||||
- **Ограничить размер данных в Redis Trigger**
|
||||
- Проверять размер сообщения перед обработкой
|
||||
- Отклонять слишком большие сообщения
|
||||
|
||||
### 4. **Мониторинг памяти**
|
||||
|
||||
Создать скрипт для мониторинга:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# monitor_n8n_memory.sh
|
||||
|
||||
CONTAINER="n8n_container"
|
||||
THRESHOLD=80 # Процент использования памяти
|
||||
|
||||
MEMORY_USAGE=$(docker stats $CONTAINER --no-stream --format "{{.MemPerc}}" | sed 's/%//')
|
||||
|
||||
if (( $(echo "$MEMORY_USAGE > $THRESHOLD" | bc -l) )); then
|
||||
echo "⚠️ ВНИМАНИЕ: n8n использует ${MEMORY_USAGE}% памяти!"
|
||||
# Можно добавить отправку алерта
|
||||
fi
|
||||
```
|
||||
|
||||
### 5. **Настроить swap**
|
||||
|
||||
Если сервер имеет swap, убедиться что он настроен:
|
||||
```bash
|
||||
# Проверить swap
|
||||
swapon --show
|
||||
|
||||
# Если нет swap, создать (осторожно - может замедлить работу)
|
||||
sudo fallocate -l 2G /swapfile
|
||||
sudo chmod 600 /swapfile
|
||||
sudo mkswap /swapfile
|
||||
sudo swapon /swapfile
|
||||
```
|
||||
|
||||
### 6. **Ограничить количество активных workflows**
|
||||
|
||||
- Отключить неиспользуемые workflows
|
||||
- Использовать один workflow вместо нескольких для похожих задач
|
||||
- Разделить сложные workflows на несколько простых
|
||||
|
||||
### 7. **Оптимизировать Redis Trigger**
|
||||
|
||||
- Использовать один Redis Trigger для нескольких каналов (если возможно)
|
||||
- Ограничить количество одновременных подписок
|
||||
- Использовать Redis Streams вместо Pub/Sub для больших объёмов данных
|
||||
|
||||
## 📊 Диагностика после перезагрузки
|
||||
|
||||
После перезагрузки сервера проверить:
|
||||
|
||||
```bash
|
||||
# 1. Использование памяти n8n
|
||||
docker stats n8n_container --no-stream
|
||||
|
||||
# 2. Логи n8n на ошибки памяти
|
||||
docker logs n8n_container 2>&1 | grep -i "memory\|oom\|heap"
|
||||
|
||||
# 3. Системные логи OOM Killer
|
||||
dmesg | grep -i "out of memory" | tail -20
|
||||
|
||||
# 4. Использование памяти системой
|
||||
free -h
|
||||
|
||||
# 5. Топ процессов по использованию памяти
|
||||
ps aux --sort=-%mem | head -10
|
||||
```
|
||||
|
||||
## 🔄 Профилактика
|
||||
|
||||
1. **Регулярная очистка executions**
|
||||
- Настроить автоматическую очистку старых данных
|
||||
- Ограничить срок хранения execution data
|
||||
|
||||
2. **Мониторинг ресурсов**
|
||||
- Настроить алерты при высоком использовании памяти
|
||||
- Регулярно проверять использование ресурсов
|
||||
|
||||
3. **Оптимизация workflows**
|
||||
- Избегать хранения больших данных в памяти
|
||||
- Использовать streaming для больших файлов
|
||||
- Очищать промежуточные данные
|
||||
|
||||
4. **Ограничения ресурсов**
|
||||
- Установить лимиты памяти для n8n контейнера
|
||||
- Настроить OOM Killer для корректной обработки
|
||||
|
||||
5. **Резервирование**
|
||||
- Рассмотреть использование нескольких инстансов n8n
|
||||
- Использовать load balancer для распределения нагрузки
|
||||
|
||||
## 📝 Рекомендации для продакшена
|
||||
|
||||
1. **Мониторинг**: Настроить Prometheus/Grafana для мониторинга памяти
|
||||
2. **Алерты**: Настроить уведомления при превышении порога памяти
|
||||
3. **Автоматическая очистка**: Настроить cron для очистки старых executions
|
||||
4. **Лимиты**: Установить жёсткие лимиты памяти для n8n
|
||||
5. **Логирование**: Включить детальное логирование использования памяти
|
||||
|
||||
## 🔗 Полезные ссылки
|
||||
|
||||
- [n8n Memory Management](https://docs.n8n.io/hosting/configuration/environment-variables/#memory-management)
|
||||
- [Docker Memory Limits](https://docs.docker.com/config/containers/resource_constraints/#memory)
|
||||
- [Node.js Memory Management](https://nodejs.org/api/cli.html#--max-old-space-sizesize-in-megabytes)
|
||||
|
||||
167
docs/N8N_REDIS_TRIGGER_TROUBLESHOOTING.md
Normal file
167
docs/N8N_REDIS_TRIGGER_TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# 🔧 Troubleshooting: Redis Trigger в n8n зависает
|
||||
|
||||
## 🐛 Проблема
|
||||
|
||||
Redis Trigger в n8n перестаёт слушать канал `ticket_form:description`, хотя workflow активен.
|
||||
|
||||
## 🔍 Возможные причины
|
||||
|
||||
### 1. **Потеря соединения с Redis**
|
||||
- Соединение оборвалось из-за сетевых проблем
|
||||
- Redis перезапустился, но n8n не переподключился
|
||||
- Таймаут соединения
|
||||
|
||||
**Решение:**
|
||||
- Проверить логи n8n на ошибки подключения
|
||||
- Убедиться, что Redis доступен: `redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PING`
|
||||
- Перезапустить workflow в n8n (отключить → включить)
|
||||
|
||||
### 2. **Проблемы с памятью/ресурсами**
|
||||
- n8n исчерпал память
|
||||
- Слишком много активных workflows
|
||||
|
||||
**Решение:**
|
||||
- Проверить использование памяти: `docker stats n8n_container`
|
||||
- Увеличить лимиты памяти для n8n
|
||||
- Перезапустить n8n контейнер
|
||||
|
||||
### 3. **Долгие операции в workflow**
|
||||
- Workflow обрабатывает сообщение слишком долго
|
||||
- Блокирует обработку новых сообщений
|
||||
|
||||
**Решение:**
|
||||
- Оптимизировать workflow (убрать долгие операции)
|
||||
- Использовать асинхронную обработку
|
||||
- Разбить workflow на несколько этапов
|
||||
|
||||
### 4. **Проблемы с сетью**
|
||||
- Временные сбои сети между n8n и Redis
|
||||
- Firewall блокирует соединение
|
||||
|
||||
**Решение:**
|
||||
- Проверить сетевую связность: `ping crm.clientright.ru`
|
||||
- Проверить firewall правила
|
||||
- Использовать retry-логику в workflow
|
||||
|
||||
## 🛠️ Решения для предотвращения
|
||||
|
||||
### 1. **Мониторинг подписчиков**
|
||||
|
||||
Запустить скрипт мониторинга:
|
||||
```bash
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
python3 monitor_n8n_redis_trigger.py
|
||||
```
|
||||
|
||||
Или добавить в cron для автоматической проверки:
|
||||
```bash
|
||||
# Проверка каждые 5 минут
|
||||
*/5 * * * * cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form && python3 monitor_n8n_redis_trigger.py >> logs/n8n_monitor_cron.log 2>&1
|
||||
```
|
||||
|
||||
### 2. **Health Check для Redis Trigger**
|
||||
|
||||
Добавить в workflow n8n:
|
||||
- **Schedule Trigger** (каждые 5 минут)
|
||||
- **Redis Publish** (отправить тестовое сообщение)
|
||||
- **If Node** (проверить, обработалось ли сообщение)
|
||||
- **Send Alert** (если нет - отправить уведомление)
|
||||
|
||||
### 3. **Автоматический перезапуск workflow**
|
||||
|
||||
Создать скрипт для автоматического перезапуска:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Проверка и перезапуск workflow если нет подписчиков
|
||||
|
||||
SUBS=$(redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PUBSUB NUMSUB "ticket_form:description" | tail -1)
|
||||
|
||||
if [ "$SUBS" -eq "0" ]; then
|
||||
echo "⚠️ Нет подписчиков! Требуется перезапуск workflow"
|
||||
# Здесь можно добавить API вызов для перезапуска workflow через n8n API
|
||||
fi
|
||||
```
|
||||
|
||||
### 4. **Настройка Redis для стабильности**
|
||||
|
||||
В `redis.conf`:
|
||||
```conf
|
||||
# Таймаут для неактивных соединений (0 = отключить)
|
||||
timeout 0
|
||||
|
||||
# Keepalive для TCP соединений
|
||||
tcp-keepalive 60
|
||||
|
||||
# Максимальное количество клиентов
|
||||
maxclients 10000
|
||||
```
|
||||
|
||||
### 5. **Логирование в n8n**
|
||||
|
||||
Включить детальное логирование для Redis Trigger:
|
||||
- Settings → Logging → Level: `debug`
|
||||
- Проверить логи на ошибки подключения
|
||||
|
||||
## 📊 Диагностика
|
||||
|
||||
### Проверка подписчиков
|
||||
```bash
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PUBSUB NUMSUB "ticket_form:description"
|
||||
```
|
||||
|
||||
### Проверка подключения n8n к Redis
|
||||
```bash
|
||||
# Из контейнера n8n
|
||||
docker exec -it n8n_container redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PING
|
||||
```
|
||||
|
||||
### Тестовая публикация
|
||||
```bash
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" \
|
||||
PUBLISH "ticket_form:description" '{"type":"test","session_id":"test123"}'
|
||||
```
|
||||
|
||||
### Проверка логов n8n
|
||||
```bash
|
||||
docker logs n8n_container | grep -i redis
|
||||
docker logs n8n_container | grep -i "ticket_form:description"
|
||||
```
|
||||
|
||||
## ✅ Быстрое решение
|
||||
|
||||
Если workflow завис:
|
||||
|
||||
1. **Отключить workflow** в n8n (кнопка "Active")
|
||||
2. **Сохранить** изменения
|
||||
3. **Включить обратно** (кнопка "Active")
|
||||
4. **Проверить подписчиков**: `PUBSUB NUMSUB "ticket_form:description"`
|
||||
|
||||
Если не помогло:
|
||||
|
||||
1. **Перезапустить n8n контейнер**:
|
||||
```bash
|
||||
docker restart n8n_container
|
||||
```
|
||||
|
||||
2. **Проверить Redis**:
|
||||
```bash
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PING
|
||||
```
|
||||
|
||||
3. **Проверить сеть** между n8n и Redis
|
||||
|
||||
## 🔄 Рекомендации для продакшена
|
||||
|
||||
1. **Мониторинг**: Настроить автоматический мониторинг подписчиков
|
||||
2. **Алерты**: Настроить уведомления при отсутствии подписчиков
|
||||
3. **Health Checks**: Регулярные проверки работоспособности
|
||||
4. **Логирование**: Детальное логирование всех операций с Redis
|
||||
5. **Резервирование**: Рассмотреть использование Redis Sentinel для высокой доступности
|
||||
|
||||
## 📝 Логи для анализа
|
||||
|
||||
Проверить логи:
|
||||
- `/var/www/fastuser/data/www/crm.clientright.ru/ticket_form/logs/n8n_redis_monitor.log` - мониторинг
|
||||
- `docker logs n8n_container` - логи n8n
|
||||
- `/var/www/fastuser/data/www/crm.clientright.ru/ticket_form/backend/logs/` - логи backend
|
||||
|
||||
767
docs/NEW_FLOW_ARCHITECTURE.md
Normal file
767
docs/NEW_FLOW_ARCHITECTURE.md
Normal file
@@ -0,0 +1,767 @@
|
||||
# 🚀 Новая архитектура: Быстрая загрузка документов
|
||||
|
||||
**Дата создания:** 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 (только уточняющие) |
|
||||
| Конверсия | ? | ↑ (меньше отвала) |
|
||||
|
||||
|
||||
|
||||
**Дата создания:** 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 (только уточняющие) |
|
||||
| Конверсия | ? | ↑ (меньше отвала) |
|
||||
|
||||
|
||||
130
docs/SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW.sql
Normal file
130
docs/SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW.sql
Normal file
@@ -0,0 +1,130 @@
|
||||
-- ============================================================================
|
||||
-- Исправленный SQL для сохранения документов (claimsave_final) - ПОДДЕРЖКА НОВОГО ФЛОУ
|
||||
-- ============================================================================
|
||||
-- Проблема: SQL не сохранял documents_required и мог перезаписать статус
|
||||
-- Решение: Сохраняем documents_required и не перезаписываем новые статусы
|
||||
-- ============================================================================
|
||||
|
||||
WITH partial AS (
|
||||
SELECT $1::jsonb AS p, $2::text AS claim_id_str
|
||||
),
|
||||
|
||||
claim_lookup AS (
|
||||
SELECT
|
||||
c.id,
|
||||
c.payload,
|
||||
c.status_code
|
||||
FROM clpr_claims c, partial
|
||||
WHERE c.id::text = partial.claim_id_str
|
||||
OR c.payload->>'claim_id' = partial.claim_id_str
|
||||
ORDER BY
|
||||
CASE WHEN c.id::text = partial.claim_id_str THEN 1 ELSE 2 END,
|
||||
c.updated_at DESC
|
||||
LIMIT 1
|
||||
),
|
||||
|
||||
docs AS (
|
||||
SELECT
|
||||
claim_lookup.id::text AS claim_id,
|
||||
doc.field_name::text AS field_name,
|
||||
doc.file_id::text AS file_id,
|
||||
doc.file_name::text AS file_name,
|
||||
doc.original_file_name::text AS original_file_name,
|
||||
(doc.uploaded_at)::timestamptz AS uploaded_at,
|
||||
doc.file_url::text AS file_url
|
||||
FROM partial, claim_lookup
|
||||
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, file_url text
|
||||
)
|
||||
),
|
||||
|
||||
upsert_docs AS (
|
||||
INSERT INTO clpr_claim_documents
|
||||
(claim_id, field_name, file_id, uploaded_at, file_name, original_file_name)
|
||||
SELECT claim_id, field_name, file_id, uploaded_at, file_name, original_file_name
|
||||
FROM docs
|
||||
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
|
||||
),
|
||||
|
||||
-- ✅ ИСПРАВЛЕНО: Сохраняем documents_required и обновляем статус правильно
|
||||
upd_claim AS (
|
||||
UPDATE clpr_claims c
|
||||
SET
|
||||
-- ✅ Объединяем payload: сохраняем documents_required и documents_meta
|
||||
payload = jsonb_set(
|
||||
jsonb_set(
|
||||
COALESCE(c.payload, '{}'::jsonb),
|
||||
'{documents_meta}',
|
||||
COALESCE((SELECT p->'documents_meta' FROM partial), '[]'::jsonb),
|
||||
true
|
||||
),
|
||||
'{documents_required}',
|
||||
COALESCE(
|
||||
(SELECT p->'documents_required' FROM partial WHERE partial.p->'documents_required' IS NOT NULL),
|
||||
c.payload->'documents_required', -- Сохраняем существующий, если новый не пришёл
|
||||
'[]'::jsonb
|
||||
),
|
||||
true
|
||||
),
|
||||
-- ✅ Обновляем статус только если нужно (не перезаписываем новые статусы)
|
||||
status_code = CASE
|
||||
-- Если статус уже новый - сохраняем его
|
||||
WHEN c.status_code IN ('draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready')
|
||||
THEN c.status_code
|
||||
-- Если есть documents_required и документы загружены - обновляем статус
|
||||
WHEN c.payload->'documents_required' IS NOT NULL
|
||||
AND jsonb_array_length(COALESCE(c.payload->'documents_required', '[]'::jsonb)) > 0
|
||||
AND (SELECT COUNT(*) FROM docs) > 0
|
||||
THEN CASE
|
||||
WHEN (SELECT COUNT(*) FROM docs) >= jsonb_array_length(COALESCE(c.payload->'documents_required', '[]'::jsonb))
|
||||
THEN 'draft_docs_complete'
|
||||
ELSE 'draft_docs_progress'
|
||||
END
|
||||
-- Иначе сохраняем существующий
|
||||
ELSE c.status_code
|
||||
END,
|
||||
updated_at = now(),
|
||||
expires_at = now() + interval '14 days'
|
||||
FROM partial, claim_lookup
|
||||
WHERE c.id = claim_lookup.id
|
||||
RETURNING c.id, c.payload, c.status_code
|
||||
)
|
||||
|
||||
SELECT
|
||||
(SELECT jsonb_build_object(
|
||||
'claim_id', u.id::text,
|
||||
'status_code', u.status_code,
|
||||
'payload', u.payload
|
||||
) FROM upd_claim u) AS claim,
|
||||
|
||||
(
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', u.id,
|
||||
'field_name', u.field_name,
|
||||
'file_id', u.file_id,
|
||||
'file_url', d.file_url,
|
||||
'file_name', d.file_name,
|
||||
'original_file_name', d.original_file_name,
|
||||
'uploaded_at', d.uploaded_at,
|
||||
'filename_for_upload',
|
||||
COALESCE(
|
||||
NULLIF(d.original_file_name, ''),
|
||||
NULLIF(d.file_name, ''),
|
||||
regexp_replace(d.file_id, '^.*/', '')
|
||||
)
|
||||
)
|
||||
)
|
||||
FROM upsert_docs u
|
||||
JOIN docs d ON d.claim_id = u.claim_id AND d.field_name = u.field_name
|
||||
WHERE d.file_url IS NOT NULL AND d.file_url <> ''
|
||||
) AS documents;
|
||||
|
||||
299
docs/SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql
Normal file
299
docs/SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql
Normal file
@@ -0,0 +1,299 @@
|
||||
-- ============================================================================
|
||||
-- Исправленный SQL для сохранения документов (claimsave_final) - ПОДДЕРЖКА НОВОГО ФЛОУ
|
||||
-- ============================================================================
|
||||
-- Проблема: SQL не создавал documents_uploaded на основе documents_meta
|
||||
-- Решение: Автоматически создаём documents_uploaded из documents_meta
|
||||
--
|
||||
-- ЧТО ДЕЛАЕТ ЭТОТ SQL:
|
||||
-- 1. Принимает documents_meta из n8n (после OCR обработки)
|
||||
-- 2. Автоматически создаёт documents_uploaded на основе documents_meta
|
||||
-- 3. Определяет тип документа (contract, payment, correspondence, evidence_photo)
|
||||
-- по field_label или field_name
|
||||
-- 4. Объединяет новые документы с существующими documents_uploaded (не перезаписывает)
|
||||
-- 5. Обновляет current_doc_index (индекс следующего незагруженного документа)
|
||||
-- 6. Обновляет status_code (draft_docs_progress или draft_docs_complete)
|
||||
--
|
||||
-- ГДЕ ИСПОЛЬЗОВАТЬ:
|
||||
-- В n8n в узле "PostgreSQL" после обработки документов OCR
|
||||
-- Параметры: $1 = payload (jsonb), $2 = claim_id (text)
|
||||
-- ============================================================================
|
||||
|
||||
WITH partial AS (
|
||||
SELECT $1::jsonb AS p, $2::text AS claim_id_str
|
||||
),
|
||||
|
||||
claim_lookup AS (
|
||||
SELECT
|
||||
c.id,
|
||||
c.payload,
|
||||
c.status_code
|
||||
FROM clpr_claims c, partial
|
||||
WHERE c.id::text = partial.claim_id_str
|
||||
OR c.payload->>'claim_id' = partial.claim_id_str
|
||||
ORDER BY
|
||||
CASE WHEN c.id::text = partial.claim_id_str THEN 1 ELSE 2 END,
|
||||
c.updated_at DESC
|
||||
LIMIT 1
|
||||
),
|
||||
|
||||
docs AS (
|
||||
SELECT
|
||||
claim_lookup.id::text AS claim_id,
|
||||
doc.field_name::text AS field_name,
|
||||
doc.field_label::text AS field_label,
|
||||
doc.file_id::text AS file_id,
|
||||
doc.file_name::text AS file_name,
|
||||
doc.original_file_name::text AS original_file_name,
|
||||
(doc.uploaded_at)::timestamptz AS uploaded_at,
|
||||
doc.file_url::text AS file_url,
|
||||
doc.files_count::int AS files_count,
|
||||
doc.pages::int AS pages
|
||||
FROM partial, claim_lookup
|
||||
CROSS JOIN LATERAL jsonb_to_recordset(
|
||||
COALESCE(partial.p->'documents_meta','[]'::jsonb)
|
||||
) AS doc(
|
||||
field_name text,
|
||||
field_label text,
|
||||
file_id text,
|
||||
file_name text,
|
||||
original_file_name text,
|
||||
uploaded_at text,
|
||||
file_url text,
|
||||
files_count int,
|
||||
pages int
|
||||
)
|
||||
),
|
||||
|
||||
-- ✅ НОВОЕ: Создаём documents_uploaded на основе documents_meta
|
||||
documents_uploaded_built AS (
|
||||
SELECT
|
||||
-- ✅ ВАЖНО: Всегда начинаем с существующих documents_uploaded
|
||||
COALESCE(
|
||||
(SELECT claim_lookup.payload->'documents_uploaded' FROM claim_lookup),
|
||||
'[]'::jsonb
|
||||
) ||
|
||||
-- ✅ Добавляем только НОВЫЕ документы из documents_meta (которых нет в существующих)
|
||||
COALESCE(
|
||||
(
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id',
|
||||
CASE
|
||||
-- ✅ СНАЧАЛА проверяем field_label (более точный способ определения типа)
|
||||
WHEN doc.field_label ILIKE '%договор%' OR doc.field_label ILIKE '%заказ%'
|
||||
THEN 'contract'
|
||||
WHEN doc.field_label ILIKE '%чек%' OR doc.field_label ILIKE '%оплат%'
|
||||
THEN 'payment'
|
||||
WHEN doc.field_label ILIKE '%переписк%'
|
||||
THEN 'correspondence'
|
||||
WHEN doc.field_label ILIKE '%доказательств%' OR doc.field_label ILIKE '%фото%'
|
||||
THEN 'evidence_photo'
|
||||
-- ✅ ПОТОМ проверяем field_name (fallback, если field_label не определён)
|
||||
WHEN doc.field_name LIKE 'uploads[0]%'
|
||||
THEN 'contract'
|
||||
WHEN doc.field_name LIKE 'uploads[1]%'
|
||||
THEN 'payment'
|
||||
WHEN doc.field_name LIKE 'uploads[2]%'
|
||||
THEN 'correspondence'
|
||||
WHEN doc.field_name LIKE 'uploads[3]%'
|
||||
THEN 'evidence_photo'
|
||||
ELSE 'unknown'
|
||||
END,
|
||||
'type',
|
||||
CASE
|
||||
-- ✅ СНАЧАЛА проверяем field_label (более точный способ определения типа)
|
||||
WHEN doc.field_label ILIKE '%договор%' OR doc.field_label ILIKE '%заказ%'
|
||||
THEN 'contract'
|
||||
WHEN doc.field_label ILIKE '%чек%' OR doc.field_label ILIKE '%оплат%'
|
||||
THEN 'payment'
|
||||
WHEN doc.field_label ILIKE '%переписк%'
|
||||
THEN 'correspondence'
|
||||
WHEN doc.field_label ILIKE '%доказательств%' OR doc.field_label ILIKE '%фото%'
|
||||
THEN 'evidence_photo'
|
||||
-- ✅ ПОТОМ проверяем field_name (fallback, если field_label не определён)
|
||||
WHEN doc.field_name LIKE 'uploads[0]%'
|
||||
THEN 'contract'
|
||||
WHEN doc.field_name LIKE 'uploads[1]%'
|
||||
THEN 'payment'
|
||||
WHEN doc.field_name LIKE 'uploads[2]%'
|
||||
THEN 'correspondence'
|
||||
WHEN doc.field_name LIKE 'uploads[3]%'
|
||||
THEN 'evidence_photo'
|
||||
ELSE 'unknown'
|
||||
END,
|
||||
'file_id', doc.file_id,
|
||||
'file_name', doc.file_name,
|
||||
'original_file_name', doc.original_file_name,
|
||||
'uploaded_at', doc.uploaded_at::text,
|
||||
'ocr_status', 'completed',
|
||||
'files_count', COALESCE(doc.files_count, 1),
|
||||
'pages', doc.pages
|
||||
)
|
||||
ORDER BY doc.field_name
|
||||
)
|
||||
FROM docs doc, claim_lookup
|
||||
-- ✅ Исключаем документы, которые уже есть в documents_uploaded (по file_id)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(COALESCE(claim_lookup.payload->'documents_uploaded', '[]'::jsonb)) AS existing
|
||||
WHERE existing->>'file_id' = doc.file_id
|
||||
)
|
||||
AND doc.file_id IS NOT NULL
|
||||
),
|
||||
'[]'::jsonb -- Если новых документов нет - возвращаем пустой массив для объединения
|
||||
) AS documents_uploaded_array
|
||||
FROM claim_lookup
|
||||
),
|
||||
|
||||
-- ✅ НОВОЕ: Определяем current_doc_index (следующий незагруженный документ)
|
||||
current_doc_index_calculated AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN claim_lookup.payload->'documents_required' IS NOT NULL THEN
|
||||
-- Находим первый незагруженный документ
|
||||
COALESCE(
|
||||
(
|
||||
SELECT idx
|
||||
FROM jsonb_array_elements(claim_lookup.payload->'documents_required') WITH ORDINALITY AS req(doc, idx)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM documents_uploaded_built, jsonb_array_elements(documents_uploaded_built.documents_uploaded_array) AS uploaded
|
||||
WHERE (uploaded->>'id') = (req.doc->>'id')
|
||||
)
|
||||
ORDER BY idx
|
||||
LIMIT 1
|
||||
),
|
||||
-- Если все документы загружены, возвращаем количество документов
|
||||
jsonb_array_length(claim_lookup.payload->'documents_required')
|
||||
)
|
||||
ELSE 0
|
||||
END AS current_doc_index
|
||||
FROM claim_lookup
|
||||
),
|
||||
|
||||
upsert_docs AS (
|
||||
INSERT INTO clpr_claim_documents
|
||||
(claim_id, field_name, file_id, uploaded_at, file_name, original_file_name)
|
||||
SELECT claim_id, field_name, file_id, uploaded_at, file_name, original_file_name
|
||||
FROM docs
|
||||
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
|
||||
),
|
||||
|
||||
-- ✅ ИСПРАВЛЕНО: Сохраняем documents_required, documents_uploaded и обновляем статус правильно
|
||||
upd_claim AS (
|
||||
UPDATE clpr_claims c
|
||||
SET
|
||||
-- ✅ Объединяем payload: сохраняем documents_required, documents_meta, documents_uploaded и current_doc_index
|
||||
payload = jsonb_set(
|
||||
jsonb_set(
|
||||
jsonb_set(
|
||||
jsonb_set(
|
||||
COALESCE(c.payload, '{}'::jsonb),
|
||||
'{documents_meta}',
|
||||
-- ✅ ОБЪЕДИНЯЕМ существующие documents_meta с новыми (не перезаписываем!)
|
||||
COALESCE(
|
||||
(SELECT p->'documents_meta' FROM partial WHERE partial.p->'documents_meta' IS NOT NULL),
|
||||
'[]'::jsonb
|
||||
) || COALESCE(
|
||||
c.payload->'documents_meta',
|
||||
'[]'::jsonb
|
||||
),
|
||||
true
|
||||
),
|
||||
'{documents_required}',
|
||||
COALESCE(
|
||||
(SELECT p->'documents_required' FROM partial WHERE partial.p->'documents_required' IS NOT NULL),
|
||||
c.payload->'documents_required', -- Сохраняем существующий, если новый не пришёл
|
||||
'[]'::jsonb
|
||||
),
|
||||
true
|
||||
),
|
||||
'{documents_uploaded}',
|
||||
-- ✅ ВАЖНО: Используем объединённый массив из documents_uploaded_built
|
||||
-- Он уже содержит существующие documents_uploaded + новые из documents_meta
|
||||
-- Если documents_uploaded_built пуст или NULL - сохраняем существующий
|
||||
CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM documents_uploaded_built
|
||||
WHERE documents_uploaded_array IS NOT NULL
|
||||
AND jsonb_array_length(documents_uploaded_array) > 0
|
||||
)
|
||||
THEN (SELECT documents_uploaded_array FROM documents_uploaded_built LIMIT 1)
|
||||
ELSE COALESCE(c.payload->'documents_uploaded', '[]'::jsonb)
|
||||
END,
|
||||
true
|
||||
),
|
||||
'{current_doc_index}',
|
||||
to_jsonb((SELECT current_doc_index FROM current_doc_index_calculated)),
|
||||
true
|
||||
),
|
||||
-- ✅ Обновляем статус только если нужно (не перезаписываем новые статусы)
|
||||
status_code = CASE
|
||||
-- Если статус уже новый - сохраняем его (кроме случаев, когда нужно обновить)
|
||||
WHEN c.status_code IN ('draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready')
|
||||
THEN CASE
|
||||
-- Если есть documents_required и документы загружены - обновляем статус
|
||||
WHEN c.payload->'documents_required' IS NOT NULL
|
||||
AND jsonb_array_length(COALESCE(c.payload->'documents_required', '[]'::jsonb)) > 0
|
||||
AND (SELECT COUNT(*) FROM docs) > 0
|
||||
THEN CASE
|
||||
WHEN (SELECT COUNT(*) FROM docs) >= jsonb_array_length(COALESCE(c.payload->'documents_required', '[]'::jsonb))
|
||||
THEN 'draft_docs_complete'
|
||||
ELSE 'draft_docs_progress'
|
||||
END
|
||||
ELSE c.status_code
|
||||
END
|
||||
-- Если есть documents_required и документы загружены - обновляем статус
|
||||
WHEN c.payload->'documents_required' IS NOT NULL
|
||||
AND jsonb_array_length(COALESCE(c.payload->'documents_required', '[]'::jsonb)) > 0
|
||||
AND (SELECT COUNT(*) FROM docs) > 0
|
||||
THEN CASE
|
||||
WHEN (SELECT COUNT(*) FROM docs) >= jsonb_array_length(COALESCE(c.payload->'documents_required', '[]'::jsonb))
|
||||
THEN 'draft_docs_complete'
|
||||
ELSE 'draft_docs_progress'
|
||||
END
|
||||
-- Иначе сохраняем существующий
|
||||
ELSE c.status_code
|
||||
END,
|
||||
updated_at = now(),
|
||||
expires_at = now() + interval '14 days'
|
||||
FROM partial, claim_lookup
|
||||
WHERE c.id = claim_lookup.id
|
||||
RETURNING c.id, c.payload, c.status_code
|
||||
)
|
||||
|
||||
SELECT
|
||||
(SELECT jsonb_build_object(
|
||||
'claim_id', u.id::text,
|
||||
'status_code', u.status_code,
|
||||
'payload', u.payload
|
||||
) FROM upd_claim u) AS claim,
|
||||
|
||||
(
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', u.id,
|
||||
'field_name', u.field_name,
|
||||
'file_id', u.file_id,
|
||||
'file_url', d.file_url,
|
||||
'file_name', d.file_name,
|
||||
'original_file_name', d.original_file_name,
|
||||
'uploaded_at', d.uploaded_at,
|
||||
'filename_for_upload',
|
||||
COALESCE(
|
||||
NULLIF(d.original_file_name, ''),
|
||||
NULLIF(d.file_name, ''),
|
||||
regexp_replace(d.file_id, '^.*/', '')
|
||||
)
|
||||
)
|
||||
)
|
||||
FROM upsert_docs u
|
||||
JOIN docs d ON d.claim_id = u.claim_id AND d.field_name = u.field_name
|
||||
WHERE d.file_url IS NOT NULL AND d.file_url <> ''
|
||||
) AS documents;
|
||||
|
||||
362
docs/SQL_CLAIMSAVE_FIXED_NEW_FLOW.sql
Normal file
362
docs/SQL_CLAIMSAVE_FIXED_NEW_FLOW.sql
Normal file
@@ -0,0 +1,362 @@
|
||||
-- ============================================================================
|
||||
-- Исправленный SQL для сохранения claim (claimsave) - ПОДДЕРЖКА НОВОГО ФЛОУ
|
||||
-- ============================================================================
|
||||
-- Проблема: SQL не сохранял documents_required и перезаписывал status_code на 'draft'
|
||||
-- Решение: Сохраняем documents_required и не перезаписываем новые статусы
|
||||
-- ============================================================================
|
||||
|
||||
WITH partial AS (
|
||||
SELECT
|
||||
$1::jsonb AS p,
|
||||
$2::text AS claim_id_str
|
||||
),
|
||||
|
||||
existing_claim AS (
|
||||
SELECT
|
||||
id,
|
||||
payload,
|
||||
status_code,
|
||||
created_at
|
||||
FROM clpr_claims
|
||||
WHERE id = (SELECT claim_id_str::uuid FROM partial)
|
||||
OR payload->>'claim_id' = (SELECT claim_id_str FROM partial)
|
||||
ORDER BY
|
||||
CASE WHEN id = (SELECT claim_id_str::uuid FROM partial) THEN 1 ELSE 2 END,
|
||||
updated_at DESC
|
||||
LIMIT 1
|
||||
),
|
||||
|
||||
-- Парсим documents_required (или берём из БД)
|
||||
documents_required_parsed AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN partial.p->'documents_required' IS NOT NULL
|
||||
AND jsonb_typeof(partial.p->'documents_required') = 'array'
|
||||
THEN partial.p->'documents_required'
|
||||
WHEN partial.p->'edit_fields_parsed'->'documents_required' IS NOT NULL
|
||||
AND jsonb_typeof(partial.p->'edit_fields_parsed'->'documents_required') = 'array'
|
||||
THEN partial.p->'edit_fields_parsed'->'documents_required'
|
||||
WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->'documents_required' IS NOT NULL)
|
||||
THEN (SELECT payload->'documents_required' FROM existing_claim)
|
||||
ELSE '[]'::jsonb
|
||||
END AS documents_required
|
||||
FROM partial
|
||||
),
|
||||
|
||||
-- Парсим documents_uploaded (или берём из БД)
|
||||
documents_uploaded_parsed AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN partial.p->'documents_uploaded' IS NOT NULL
|
||||
AND jsonb_typeof(partial.p->'documents_uploaded') = 'array'
|
||||
THEN partial.p->'documents_uploaded'
|
||||
WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->'documents_uploaded' IS NOT NULL)
|
||||
THEN (SELECT payload->'documents_uploaded' FROM existing_claim)
|
||||
ELSE '[]'::jsonb
|
||||
END AS documents_uploaded
|
||||
FROM partial
|
||||
),
|
||||
|
||||
-- Парсим documents_skipped (или берём из БД)
|
||||
documents_skipped_parsed AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN partial.p->'documents_skipped' IS NOT NULL
|
||||
AND jsonb_typeof(partial.p->'documents_skipped') = 'array'
|
||||
THEN partial.p->'documents_skipped'
|
||||
WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->'documents_skipped' IS NOT NULL)
|
||||
THEN (SELECT payload->'documents_skipped' FROM existing_claim)
|
||||
ELSE '[]'::jsonb
|
||||
END AS documents_skipped
|
||||
FROM partial
|
||||
),
|
||||
|
||||
-- Парсим current_doc_index (или берём из БД)
|
||||
current_doc_index_parsed AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN partial.p->'current_doc_index' IS NOT NULL
|
||||
THEN (partial.p->'current_doc_index')::int
|
||||
WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->'current_doc_index' IS NOT NULL)
|
||||
THEN (SELECT (payload->'current_doc_index')::int FROM existing_claim)
|
||||
ELSE 0
|
||||
END AS current_doc_index
|
||||
FROM partial
|
||||
),
|
||||
|
||||
-- Парсим wizard_answers
|
||||
wizard_answers_parsed AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN partial.p->'edit_fields_raw'->'body'->>'wizard_answers' IS NOT NULL
|
||||
THEN (partial.p->'edit_fields_raw'->'body'->>'wizard_answers')::jsonb
|
||||
WHEN partial.p->'edit_fields_parsed'->'body'->>'wizard_answers' IS NOT NULL
|
||||
THEN (partial.p->'edit_fields_parsed'->'body'->>'wizard_answers')::jsonb
|
||||
WHEN partial.p->>'wizard_answers' IS NOT NULL
|
||||
THEN (partial.p->>'wizard_answers')::jsonb
|
||||
WHEN partial.p->'wizard_answers' IS NOT NULL
|
||||
AND jsonb_typeof(partial.p->'wizard_answers') = 'object'
|
||||
THEN partial.p->'wizard_answers'
|
||||
ELSE '{}'::jsonb
|
||||
END AS answers
|
||||
FROM partial
|
||||
),
|
||||
|
||||
-- Парсим wizard_plan (или берём из существующей записи)
|
||||
wizard_plan_parsed AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN partial.p->'edit_fields_parsed'->'wizard_plan_parsed' IS NOT NULL
|
||||
AND jsonb_typeof(partial.p->'edit_fields_parsed'->'wizard_plan_parsed') = 'object'
|
||||
THEN partial.p->'edit_fields_parsed'->'wizard_plan_parsed'
|
||||
WHEN partial.p->>'wizard_plan' IS NOT NULL
|
||||
THEN (partial.p->>'wizard_plan')::jsonb
|
||||
WHEN partial.p->'wizard_plan' IS NOT NULL
|
||||
AND jsonb_typeof(partial.p->'wizard_plan') = 'object'
|
||||
THEN partial.p->'wizard_plan'
|
||||
WHEN partial.p->'edit_fields_raw'->'body'->>'wizard_plan' IS NOT NULL
|
||||
THEN (partial.p->'edit_fields_raw'->'body'->>'wizard_plan')::jsonb
|
||||
WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->'wizard_plan' IS NOT NULL)
|
||||
THEN (SELECT payload->'wizard_plan' FROM existing_claim)
|
||||
ELSE NULL
|
||||
END AS wizard_plan
|
||||
FROM partial
|
||||
),
|
||||
|
||||
-- Парсим problem_description (или берём из БД)
|
||||
problem_description_parsed AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN partial.p->>'problem_description' IS NOT NULL
|
||||
THEN partial.p->>'problem_description'
|
||||
WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->>'problem_description' IS NOT NULL)
|
||||
THEN (SELECT payload->>'problem_description' FROM existing_claim)
|
||||
ELSE NULL
|
||||
END AS problem_description
|
||||
FROM partial
|
||||
),
|
||||
|
||||
-- Определяем правильный статус
|
||||
status_code_resolved AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- Если есть documents_required и документы загружаются - новый флоу
|
||||
WHEN (SELECT jsonb_array_length(documents_required) FROM documents_required_parsed) > 0
|
||||
THEN CASE
|
||||
-- Все документы загружены или пропущены
|
||||
WHEN (SELECT jsonb_array_length(documents_uploaded) FROM documents_uploaded_parsed) +
|
||||
(SELECT jsonb_array_length(documents_skipped) FROM documents_skipped_parsed) >=
|
||||
(SELECT jsonb_array_length(documents_required) FROM documents_required_parsed)
|
||||
THEN 'draft_docs_complete'
|
||||
-- Документы загружаются
|
||||
WHEN (SELECT jsonb_array_length(documents_uploaded) FROM documents_uploaded_parsed) > 0
|
||||
THEN 'draft_docs_progress'
|
||||
-- Только описание
|
||||
ELSE 'draft_new'
|
||||
END
|
||||
-- Старый флоу: проверяем wizard_answers
|
||||
WHEN (SELECT answers->>'docs_exist' FROM wizard_answers_parsed) = 'true'
|
||||
THEN 'in_work'
|
||||
-- Сохраняем существующий статус, если он новый
|
||||
WHEN EXISTS (SELECT 1 FROM existing_claim
|
||||
WHERE status_code IN ('draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready'))
|
||||
THEN (SELECT status_code FROM existing_claim)
|
||||
-- По умолчанию
|
||||
ELSE 'draft'
|
||||
END AS status_code
|
||||
FROM partial
|
||||
),
|
||||
|
||||
-- UPSERT claim
|
||||
claim_upsert AS (
|
||||
INSERT INTO clpr_claims (
|
||||
id,
|
||||
session_token,
|
||||
unified_id,
|
||||
contact_id,
|
||||
phone,
|
||||
channel,
|
||||
type_code,
|
||||
status_code,
|
||||
payload,
|
||||
created_at,
|
||||
updated_at,
|
||||
expires_at
|
||||
)
|
||||
SELECT
|
||||
COALESCE((SELECT id FROM existing_claim), partial.claim_id_str::uuid),
|
||||
COALESCE(
|
||||
partial.p->>'session_id',
|
||||
partial.p->'edit_fields_parsed'->'body'->>'session_id',
|
||||
partial.p->'edit_fields_raw'->'body'->>'session_id',
|
||||
'sess-unknown'
|
||||
),
|
||||
COALESCE(
|
||||
partial.p->>'unified_id',
|
||||
partial.p->'edit_fields_parsed'->'body'->>'unified_id',
|
||||
partial.p->'edit_fields_raw'->'body'->>'unified_id'
|
||||
),
|
||||
COALESCE(
|
||||
partial.p->>'contact_id',
|
||||
partial.p->'edit_fields_parsed'->'body'->>'contact_id',
|
||||
partial.p->'edit_fields_raw'->'body'->>'contact_id'
|
||||
),
|
||||
COALESCE(
|
||||
partial.p->>'phone',
|
||||
partial.p->'edit_fields_parsed'->'body'->>'phone',
|
||||
partial.p->'edit_fields_raw'->'body'->>'phone'
|
||||
),
|
||||
'web_form',
|
||||
COALESCE(partial.p->>'type_code', 'consumer'),
|
||||
(SELECT status_code FROM status_code_resolved),
|
||||
jsonb_build_object(
|
||||
'claim_id', partial.claim_id_str,
|
||||
'problem_description', (SELECT problem_description FROM problem_description_parsed),
|
||||
'answers', (SELECT answers FROM wizard_answers_parsed),
|
||||
-- ✅ ОБЪЕДИНЯЕМ documents_meta с существующими (не перезаписываем!)
|
||||
'documents_meta', COALESCE(
|
||||
(SELECT p->'documents_meta' FROM partial WHERE partial.p->'documents_meta' IS NOT NULL),
|
||||
'[]'::jsonb
|
||||
) || COALESCE(
|
||||
(SELECT payload->'documents_meta' FROM existing_claim),
|
||||
'[]'::jsonb
|
||||
),
|
||||
-- ✅ НОВЫЙ ФЛОУ: Сохраняем documents_required и связанные поля
|
||||
'documents_required', (SELECT documents_required FROM documents_required_parsed),
|
||||
'documents_uploaded', (SELECT documents_uploaded FROM documents_uploaded_parsed),
|
||||
'documents_skipped', (SELECT documents_skipped FROM documents_skipped_parsed),
|
||||
'current_doc_index', (SELECT current_doc_index FROM current_doc_index_parsed),
|
||||
'wizard_plan', (SELECT wizard_plan FROM wizard_plan_parsed),
|
||||
'phone', COALESCE(partial.p->>'phone', (SELECT payload->>'phone' FROM existing_claim)),
|
||||
'email', COALESCE(partial.p->>'email', (SELECT payload->>'email' FROM existing_claim))
|
||||
),
|
||||
COALESCE((SELECT created_at FROM existing_claim), now()),
|
||||
now(),
|
||||
now() + interval '14 days'
|
||||
FROM partial
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
session_token = EXCLUDED.session_token,
|
||||
unified_id = COALESCE(EXCLUDED.unified_id, clpr_claims.unified_id),
|
||||
contact_id = COALESCE(EXCLUDED.contact_id, clpr_claims.contact_id),
|
||||
phone = COALESCE(EXCLUDED.phone, clpr_claims.phone),
|
||||
-- ✅ НЕ перезаписываем статус, если он новый (сохраняем существующий)
|
||||
status_code = CASE
|
||||
WHEN clpr_claims.status_code IN ('draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready')
|
||||
THEN clpr_claims.status_code -- Сохраняем существующий новый статус
|
||||
ELSE EXCLUDED.status_code -- Используем новый статус
|
||||
END,
|
||||
-- ✅ Объединяем payload правильно: аккуратно объединяем критичные поля
|
||||
payload = jsonb_set(
|
||||
jsonb_set(
|
||||
jsonb_set(
|
||||
jsonb_set(
|
||||
jsonb_set(
|
||||
-- Сначала берём существующий payload и объединяем с новым (без критичных полей)
|
||||
COALESCE(clpr_claims.payload, '{}'::jsonb) ||
|
||||
(EXCLUDED.payload - 'documents_meta' - 'documents_required' - 'documents_uploaded' - 'documents_skipped' - 'current_doc_index'),
|
||||
'{documents_meta}',
|
||||
-- ✅ ОБЪЕДИНЯЕМ documents_meta (не перезаписываем!)
|
||||
COALESCE(
|
||||
EXCLUDED.payload->'documents_meta',
|
||||
'[]'::jsonb
|
||||
) || COALESCE(
|
||||
clpr_claims.payload->'documents_meta',
|
||||
'[]'::jsonb
|
||||
),
|
||||
true
|
||||
),
|
||||
'{documents_required}',
|
||||
COALESCE(
|
||||
EXCLUDED.payload->'documents_required',
|
||||
clpr_claims.payload->'documents_required',
|
||||
'[]'::jsonb
|
||||
),
|
||||
true
|
||||
),
|
||||
'{documents_uploaded}',
|
||||
COALESCE(
|
||||
EXCLUDED.payload->'documents_uploaded',
|
||||
clpr_claims.payload->'documents_uploaded',
|
||||
'[]'::jsonb
|
||||
),
|
||||
true
|
||||
),
|
||||
'{documents_skipped}',
|
||||
COALESCE(
|
||||
EXCLUDED.payload->'documents_skipped',
|
||||
clpr_claims.payload->'documents_skipped',
|
||||
'[]'::jsonb
|
||||
),
|
||||
true
|
||||
),
|
||||
'{current_doc_index}',
|
||||
COALESCE(
|
||||
EXCLUDED.payload->'current_doc_index',
|
||||
clpr_claims.payload->'current_doc_index',
|
||||
to_jsonb(0)
|
||||
),
|
||||
true
|
||||
),
|
||||
updated_at = now(),
|
||||
expires_at = now() + interval '14 days'
|
||||
RETURNING id, status_code, payload, unified_id, contact_id, phone, session_token
|
||||
),
|
||||
|
||||
-- UPSERT documents (если есть)
|
||||
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(
|
||||
field_name text,
|
||||
file_id text,
|
||||
file_name text,
|
||||
original_file_name text,
|
||||
uploaded_at text
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
-- Возвращаем результат
|
||||
SELECT
|
||||
(SELECT jsonb_build_object(
|
||||
'claim_id', cu.id::text,
|
||||
'claim_id_str', (cu.payload->>'claim_id'),
|
||||
'status_code', cu.status_code,
|
||||
'unified_id', cu.unified_id,
|
||||
'contact_id', cu.contact_id,
|
||||
'phone', cu.phone,
|
||||
'session_token', cu.session_token,
|
||||
'payload', cu.payload
|
||||
) FROM claim_upsert cu) AS claim,
|
||||
|
||||
(SELECT jsonb_agg(jsonb_build_object(
|
||||
'id', id,
|
||||
'field_name', field_name,
|
||||
'file_id', file_id,
|
||||
'file_name', file_name,
|
||||
'original_file_name', original_file_name
|
||||
)) FROM docs_upsert) AS documents;
|
||||
|
||||
81
docs/SQL_DOCUMENTS_META_STRUCTURE.md
Normal file
81
docs/SQL_DOCUMENTS_META_STRUCTURE.md
Normal 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** - текущая структура достаточна для работы.
|
||||
|
||||
98
docs/SQL_FIX_CLAIM_DOCUMENTS_FIELD_NAMES.sql
Normal file
98
docs/SQL_FIX_CLAIM_DOCUMENTS_FIELD_NAMES.sql
Normal file
@@ -0,0 +1,98 @@
|
||||
-- ============================================================================
|
||||
-- SQL для исправления field_name в таблице clpr_claim_documents
|
||||
-- ============================================================================
|
||||
-- Проблема: Все документы имеют одинаковый field_name (uploads[0][0])
|
||||
-- Решение: Пересоздаём записи с правильными field_name на основе documents_uploaded
|
||||
-- ============================================================================
|
||||
|
||||
-- Для конкретного claim_id
|
||||
WITH claim_data AS (
|
||||
SELECT
|
||||
id,
|
||||
payload
|
||||
FROM clpr_claims
|
||||
WHERE id::text = 'bddb6815-8e17-4d54-a721-5e94382942c7'
|
||||
OR payload->>'claim_id' = 'bddb6815-8e17-4d54-a721-5e94382942c7'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
),
|
||||
|
||||
-- Извлекаем documents_required для определения индексов
|
||||
documents_required_array AS (
|
||||
SELECT
|
||||
jsonb_array_elements(payload->'documents_required') WITH ORDINALITY AS doc_req(doc, idx)
|
||||
FROM claim_data
|
||||
),
|
||||
|
||||
-- Извлекаем documents_uploaded с правильными индексами
|
||||
documents_uploaded_mapped AS (
|
||||
SELECT
|
||||
doc_up.*,
|
||||
(doc_req.idx - 1)::int AS group_index -- Индекс документа (0-based)
|
||||
FROM claim_data,
|
||||
jsonb_array_elements(payload->'documents_uploaded') AS doc_up,
|
||||
documents_required_array doc_req
|
||||
WHERE (doc_up->>'id' = doc_req.doc->>'id' OR doc_up->>'type' = doc_req.doc->>'id')
|
||||
),
|
||||
|
||||
-- Удаляем старые записи
|
||||
deleted_old AS (
|
||||
DELETE FROM clpr_claim_documents
|
||||
WHERE claim_id = (SELECT id::text FROM claim_data)
|
||||
RETURNING claim_id, field_name, file_id
|
||||
),
|
||||
|
||||
-- Вставляем новые записи с правильными field_name
|
||||
inserted_new AS (
|
||||
INSERT INTO clpr_claim_documents (
|
||||
claim_id,
|
||||
field_name,
|
||||
file_id,
|
||||
file_name,
|
||||
original_file_name,
|
||||
uploaded_at
|
||||
)
|
||||
SELECT
|
||||
(SELECT id::text FROM claim_data) AS claim_id,
|
||||
'uploads[' || group_index || '][0]' AS field_name,
|
||||
doc_up->>'file_id' AS file_id,
|
||||
doc_up->>'file_name' AS file_name,
|
||||
doc_up->>'original_file_name' AS original_file_name,
|
||||
COALESCE(
|
||||
(doc_up->>'uploaded_at')::timestamptz,
|
||||
now()
|
||||
) AS uploaded_at
|
||||
FROM documents_uploaded_mapped doc_up
|
||||
WHERE doc_up->>'file_id' IS NOT NULL
|
||||
AND doc_up->>'file_id' <> ''
|
||||
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
|
||||
RETURNING claim_id, field_name, file_id, file_name
|
||||
)
|
||||
|
||||
-- Возвращаем результат
|
||||
SELECT
|
||||
'Удалено старых записей' AS action,
|
||||
COUNT(*) AS count
|
||||
FROM deleted_old
|
||||
UNION ALL
|
||||
SELECT
|
||||
'Вставлено новых записей' AS action,
|
||||
COUNT(*) AS count
|
||||
FROM inserted_new;
|
||||
|
||||
-- Проверка результата
|
||||
SELECT
|
||||
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 = 'bddb6815-8e17-4d54-a721-5e94382942c7'
|
||||
ORDER BY ccd.field_name;
|
||||
|
||||
79
docs/SQL_FIX_DRAFT_BDDB6815.sql
Normal file
79
docs/SQL_FIX_DRAFT_BDDB6815.sql
Normal file
@@ -0,0 +1,79 @@
|
||||
-- ============================================================================
|
||||
-- SQL для исправления черновика bddb6815-8e17-4d54-a721-5e94382942c7
|
||||
-- ============================================================================
|
||||
-- Проблема: У черновика нет documents_required и неправильный статус
|
||||
-- Решение: Добавляем documents_required и устанавливаем правильный статус
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE clpr_claims
|
||||
SET
|
||||
status_code = CASE
|
||||
-- Если документы уже загружены - ставим draft_docs_progress или draft_docs_complete
|
||||
WHEN jsonb_array_length(COALESCE(payload->'documents_uploaded', '[]'::jsonb)) > 0
|
||||
THEN CASE
|
||||
WHEN jsonb_array_length(COALESCE(payload->'documents_uploaded', '[]'::jsonb)) >= 4
|
||||
THEN 'draft_docs_complete'
|
||||
ELSE 'draft_docs_progress'
|
||||
END
|
||||
-- Если документов нет - ставим draft_new
|
||||
ELSE 'draft_new'
|
||||
END,
|
||||
|
||||
-- Добавляем documents_required в payload
|
||||
payload = jsonb_set(
|
||||
COALESCE(payload, '{}'::jsonb),
|
||||
'{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
|
||||
}
|
||||
]'::jsonb,
|
||||
true
|
||||
),
|
||||
|
||||
updated_at = now()
|
||||
|
||||
WHERE id::text = 'bddb6815-8e17-4d54-a721-5e94382942c7'
|
||||
OR payload->>'claim_id' = 'bddb6815-8e17-4d54-a721-5e94382942c7';
|
||||
|
||||
-- Проверяем результат
|
||||
SELECT
|
||||
id::text,
|
||||
status_code,
|
||||
payload->>'claim_id' as claim_id,
|
||||
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->'documents_required'->0->>'name' as first_doc_name
|
||||
FROM clpr_claims
|
||||
WHERE id::text = 'bddb6815-8e17-4d54-a721-5e94382942c7'
|
||||
OR payload->>'claim_id' = 'bddb6815-8e17-4d54-a721-5e94382942c7';
|
||||
|
||||
345
docs/SQL_SAVE_DRAFT_NEW_FLOW.sql
Normal file
345
docs/SQL_SAVE_DRAFT_NEW_FLOW.sql
Normal file
@@ -0,0 +1,345 @@
|
||||
-- ============================================================================
|
||||
-- SQL запрос для n8n: Сохранение черновика (НОВЫЙ ФЛОУ с документами)
|
||||
-- ============================================================================
|
||||
-- Назначение: Сохранить черновик сразу после анализа описания проблемы
|
||||
-- AI Agent возвращает facts + docs (список документов)
|
||||
--
|
||||
-- Вход от AI Agent:
|
||||
-- output: { facts_short, facts_full, problem, recommendation, docs: [...] }
|
||||
-- propertyName: { session_id, phone, unified_id, contact_id, ФИО и т.д. }
|
||||
--
|
||||
-- Параметры:
|
||||
-- $1 = payload_json (jsonb) - полный payload с output и propertyName
|
||||
-- $2 = session_token (text) - сессия пользователя (из propertyName.session_id)
|
||||
-- $3 = unified_id (text) - unified_id пользователя
|
||||
-- $4 = problem_description (text) - исходное описание проблемы от пользователя
|
||||
--
|
||||
-- Возвращает:
|
||||
-- claim - объект с claim_id, session_token, status_code, documents_required
|
||||
-- ============================================================================
|
||||
|
||||
WITH input_data AS (
|
||||
SELECT
|
||||
$1::jsonb AS payload,
|
||||
$2::text AS session_token_str,
|
||||
NULLIF($3::text, '') AS unified_id_str,
|
||||
NULLIF($4::text, '') AS problem_desc
|
||||
),
|
||||
|
||||
-- Извлекаем данные из payload
|
||||
parsed_data AS (
|
||||
SELECT
|
||||
input_data.*,
|
||||
input_data.payload->'output' AS ai_output,
|
||||
input_data.payload->'propertyName' AS user_data,
|
||||
input_data.payload->'output'->'docs' AS documents_required
|
||||
FROM input_data
|
||||
),
|
||||
|
||||
-- Проверяем существующий черновик по session_token
|
||||
existing_claim AS (
|
||||
SELECT id, payload
|
||||
FROM clpr_claims
|
||||
WHERE session_token = (SELECT session_token_str FROM input_data)
|
||||
LIMIT 1
|
||||
),
|
||||
|
||||
-- Генерируем или используем существующий UUID
|
||||
claim_id_resolved AS (
|
||||
SELECT
|
||||
COALESCE(
|
||||
(SELECT id FROM existing_claim),
|
||||
gen_random_uuid()
|
||||
) AS claim_uuid
|
||||
),
|
||||
|
||||
-- INSERT или UPDATE черновика
|
||||
upserted_claim AS (
|
||||
INSERT INTO clpr_claims (
|
||||
id,
|
||||
session_token,
|
||||
unified_id,
|
||||
channel,
|
||||
type_code,
|
||||
status_code,
|
||||
payload,
|
||||
created_at,
|
||||
updated_at,
|
||||
expires_at
|
||||
)
|
||||
SELECT
|
||||
claim_id_resolved.claim_uuid,
|
||||
parsed_data.session_token_str,
|
||||
COALESCE(parsed_data.unified_id_str, parsed_data.user_data->>'unified_id'),
|
||||
'web_form',
|
||||
'consumer',
|
||||
'draft_new', -- ✅ Новый статус: только описание + документы
|
||||
jsonb_build_object(
|
||||
'claim_id', claim_id_resolved.claim_uuid::text,
|
||||
'problem_description', COALESCE(parsed_data.problem_desc, parsed_data.user_data->>'problem_description'),
|
||||
|
||||
-- AI анализ
|
||||
'ai_analysis', jsonb_build_object(
|
||||
'facts_short', parsed_data.ai_output->>'facts_short',
|
||||
'facts_full', parsed_data.ai_output->>'facts_full',
|
||||
'problem', parsed_data.ai_output->>'problem',
|
||||
'recommendation', parsed_data.ai_output->>'recommendation'
|
||||
),
|
||||
|
||||
-- ✅ Список необходимых документов (новое!)
|
||||
'documents_required', COALESCE(parsed_data.documents_required, '[]'::jsonb),
|
||||
'documents_uploaded', '[]'::jsonb,
|
||||
'documents_skipped', '[]'::jsonb,
|
||||
'current_doc_index', 0,
|
||||
|
||||
-- Данные пользователя
|
||||
'phone', COALESCE(parsed_data.user_data->>'phone', ''),
|
||||
'email', COALESCE(parsed_data.user_data->>'email', ''),
|
||||
'contact_id', parsed_data.user_data->>'contact_id',
|
||||
|
||||
-- ФИО и паспортные данные (для заявления)
|
||||
'applicant', jsonb_build_object(
|
||||
'lastname', parsed_data.user_data->>'lastname',
|
||||
'firstname', parsed_data.user_data->>'firstname',
|
||||
'middle_name', parsed_data.user_data->>'middle_name',
|
||||
'birthday', parsed_data.user_data->>'birthday',
|
||||
'birthplace', parsed_data.user_data->>'birthplace',
|
||||
'inn', parsed_data.user_data->>'inn',
|
||||
'address', parsed_data.user_data->>'mailingstreet',
|
||||
'zip', parsed_data.user_data->>'mailingzip'
|
||||
),
|
||||
|
||||
-- Telegram ID если есть
|
||||
'tg_id', parsed_data.user_data->>'tg_id',
|
||||
|
||||
-- Флаги готовности
|
||||
'wizard_ready', false,
|
||||
'claim_ready', false
|
||||
),
|
||||
now(),
|
||||
now(),
|
||||
now() + interval '14 days'
|
||||
FROM parsed_data, claim_id_resolved
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
unified_id = COALESCE(EXCLUDED.unified_id, clpr_claims.unified_id),
|
||||
status_code = 'draft_new',
|
||||
payload = clpr_claims.payload || EXCLUDED.payload,
|
||||
updated_at = now(),
|
||||
expires_at = now() + interval '14 days'
|
||||
RETURNING id, session_token, status_code, payload
|
||||
)
|
||||
|
||||
-- Возвращаем результат для n8n
|
||||
SELECT
|
||||
jsonb_build_object(
|
||||
'claim_id', upserted_claim.id::text,
|
||||
'session_token', upserted_claim.session_token,
|
||||
'status_code', upserted_claim.status_code,
|
||||
'documents_required', upserted_claim.payload->'documents_required',
|
||||
'documents_count', jsonb_array_length(COALESCE(upserted_claim.payload->'documents_required', '[]'::jsonb))
|
||||
) AS claim
|
||||
FROM upserted_claim;
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- Пример вызова в n8n (PostgreSQL Node):
|
||||
-- ============================================================================
|
||||
--
|
||||
-- Параметры:
|
||||
-- $1 = {{ JSON.stringify($json) }} -- Весь payload от AI Agent
|
||||
-- $2 = {{ $json.propertyName.session_id }} -- session_token
|
||||
-- $3 = {{ $json.propertyName.unified_id }} -- unified_id
|
||||
-- $4 = {{ $node["Redis Trigger"].json.description }} -- Исходное описание проблемы
|
||||
--
|
||||
-- После выполнения SQL, в Code Node пушим в Redis:
|
||||
--
|
||||
-- const result = $input.first().json.claim;
|
||||
--
|
||||
-- return {
|
||||
-- json: {
|
||||
-- channel: `ocr_events:${result.session_token}`,
|
||||
-- event: {
|
||||
-- event_type: 'documents_list_ready',
|
||||
-- claim_id: result.claim_id,
|
||||
-- session_id: result.session_token,
|
||||
-- documents_required: result.documents_required,
|
||||
-- documents_count: result.documents_count,
|
||||
-- timestamp: new Date().toISOString()
|
||||
-- }
|
||||
-- }
|
||||
-- };
|
||||
-- ============================================================================
|
||||
|
||||
|
||||
-- SQL запрос для n8n: Сохранение черновика (НОВЫЙ ФЛОУ с документами)
|
||||
-- ============================================================================
|
||||
-- Назначение: Сохранить черновик сразу после анализа описания проблемы
|
||||
-- AI Agent возвращает facts + docs (список документов)
|
||||
--
|
||||
-- Вход от AI Agent:
|
||||
-- output: { facts_short, facts_full, problem, recommendation, docs: [...] }
|
||||
-- propertyName: { session_id, phone, unified_id, contact_id, ФИО и т.д. }
|
||||
--
|
||||
-- Параметры:
|
||||
-- $1 = payload_json (jsonb) - полный payload с output и propertyName
|
||||
-- $2 = session_token (text) - сессия пользователя (из propertyName.session_id)
|
||||
-- $3 = unified_id (text) - unified_id пользователя
|
||||
-- $4 = problem_description (text) - исходное описание проблемы от пользователя
|
||||
--
|
||||
-- Возвращает:
|
||||
-- claim - объект с claim_id, session_token, status_code, documents_required
|
||||
-- ============================================================================
|
||||
|
||||
WITH input_data AS (
|
||||
SELECT
|
||||
$1::jsonb AS payload,
|
||||
$2::text AS session_token_str,
|
||||
NULLIF($3::text, '') AS unified_id_str,
|
||||
NULLIF($4::text, '') AS problem_desc
|
||||
),
|
||||
|
||||
-- Извлекаем данные из payload
|
||||
parsed_data AS (
|
||||
SELECT
|
||||
input_data.*,
|
||||
input_data.payload->'output' AS ai_output,
|
||||
input_data.payload->'propertyName' AS user_data,
|
||||
input_data.payload->'output'->'docs' AS documents_required
|
||||
FROM input_data
|
||||
),
|
||||
|
||||
-- Проверяем существующий черновик по session_token
|
||||
existing_claim AS (
|
||||
SELECT id, payload
|
||||
FROM clpr_claims
|
||||
WHERE session_token = (SELECT session_token_str FROM input_data)
|
||||
LIMIT 1
|
||||
),
|
||||
|
||||
-- Генерируем или используем существующий UUID
|
||||
claim_id_resolved AS (
|
||||
SELECT
|
||||
COALESCE(
|
||||
(SELECT id FROM existing_claim),
|
||||
gen_random_uuid()
|
||||
) AS claim_uuid
|
||||
),
|
||||
|
||||
-- INSERT или UPDATE черновика
|
||||
upserted_claim AS (
|
||||
INSERT INTO clpr_claims (
|
||||
id,
|
||||
session_token,
|
||||
unified_id,
|
||||
channel,
|
||||
type_code,
|
||||
status_code,
|
||||
payload,
|
||||
created_at,
|
||||
updated_at,
|
||||
expires_at
|
||||
)
|
||||
SELECT
|
||||
claim_id_resolved.claim_uuid,
|
||||
parsed_data.session_token_str,
|
||||
COALESCE(parsed_data.unified_id_str, parsed_data.user_data->>'unified_id'),
|
||||
'web_form',
|
||||
'consumer',
|
||||
'draft_new', -- ✅ Новый статус: только описание + документы
|
||||
jsonb_build_object(
|
||||
'claim_id', claim_id_resolved.claim_uuid::text,
|
||||
'problem_description', COALESCE(parsed_data.problem_desc, parsed_data.user_data->>'problem_description'),
|
||||
|
||||
-- AI анализ
|
||||
'ai_analysis', jsonb_build_object(
|
||||
'facts_short', parsed_data.ai_output->>'facts_short',
|
||||
'facts_full', parsed_data.ai_output->>'facts_full',
|
||||
'problem', parsed_data.ai_output->>'problem',
|
||||
'recommendation', parsed_data.ai_output->>'recommendation'
|
||||
),
|
||||
|
||||
-- ✅ Список необходимых документов (новое!)
|
||||
'documents_required', COALESCE(parsed_data.documents_required, '[]'::jsonb),
|
||||
'documents_uploaded', '[]'::jsonb,
|
||||
'documents_skipped', '[]'::jsonb,
|
||||
'current_doc_index', 0,
|
||||
|
||||
-- Данные пользователя
|
||||
'phone', COALESCE(parsed_data.user_data->>'phone', ''),
|
||||
'email', COALESCE(parsed_data.user_data->>'email', ''),
|
||||
'contact_id', parsed_data.user_data->>'contact_id',
|
||||
|
||||
-- ФИО и паспортные данные (для заявления)
|
||||
'applicant', jsonb_build_object(
|
||||
'lastname', parsed_data.user_data->>'lastname',
|
||||
'firstname', parsed_data.user_data->>'firstname',
|
||||
'middle_name', parsed_data.user_data->>'middle_name',
|
||||
'birthday', parsed_data.user_data->>'birthday',
|
||||
'birthplace', parsed_data.user_data->>'birthplace',
|
||||
'inn', parsed_data.user_data->>'inn',
|
||||
'address', parsed_data.user_data->>'mailingstreet',
|
||||
'zip', parsed_data.user_data->>'mailingzip'
|
||||
),
|
||||
|
||||
-- Telegram ID если есть
|
||||
'tg_id', parsed_data.user_data->>'tg_id',
|
||||
|
||||
-- Флаги готовности
|
||||
'wizard_ready', false,
|
||||
'claim_ready', false
|
||||
),
|
||||
now(),
|
||||
now(),
|
||||
now() + interval '14 days'
|
||||
FROM parsed_data, claim_id_resolved
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
unified_id = COALESCE(EXCLUDED.unified_id, clpr_claims.unified_id),
|
||||
status_code = 'draft_new',
|
||||
payload = clpr_claims.payload || EXCLUDED.payload,
|
||||
updated_at = now(),
|
||||
expires_at = now() + interval '14 days'
|
||||
RETURNING id, session_token, status_code, payload
|
||||
)
|
||||
|
||||
-- Возвращаем результат для n8n
|
||||
SELECT
|
||||
jsonb_build_object(
|
||||
'claim_id', upserted_claim.id::text,
|
||||
'session_token', upserted_claim.session_token,
|
||||
'status_code', upserted_claim.status_code,
|
||||
'documents_required', upserted_claim.payload->'documents_required',
|
||||
'documents_count', jsonb_array_length(COALESCE(upserted_claim.payload->'documents_required', '[]'::jsonb))
|
||||
) AS claim
|
||||
FROM upserted_claim;
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- Пример вызова в n8n (PostgreSQL Node):
|
||||
-- ============================================================================
|
||||
--
|
||||
-- Параметры:
|
||||
-- $1 = {{ JSON.stringify($json) }} -- Весь payload от AI Agent
|
||||
-- $2 = {{ $json.propertyName.session_id }} -- session_token
|
||||
-- $3 = {{ $json.propertyName.unified_id }} -- unified_id
|
||||
-- $4 = {{ $node["Redis Trigger"].json.description }} -- Исходное описание проблемы
|
||||
--
|
||||
-- После выполнения SQL, в Code Node пушим в Redis:
|
||||
--
|
||||
-- const result = $input.first().json.claim;
|
||||
--
|
||||
-- return {
|
||||
-- json: {
|
||||
-- channel: `ocr_events:${result.session_token}`,
|
||||
-- event: {
|
||||
-- event_type: 'documents_list_ready',
|
||||
-- claim_id: result.claim_id,
|
||||
-- session_id: result.session_token,
|
||||
-- documents_required: result.documents_required,
|
||||
-- documents_count: result.documents_count,
|
||||
-- timestamp: new Date().toISOString()
|
||||
-- }
|
||||
-- }
|
||||
-- };
|
||||
-- ============================================================================
|
||||
|
||||
|
||||
31
docs/SQL_SELECT_CONTACT_WITH_CUSTOM_FIELDS.sql
Normal file
31
docs/SQL_SELECT_CONTACT_WITH_CUSTOM_FIELDS.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- Правильный SQL запрос для получения всех данных контакта с кастомными полями
|
||||
-- Исправлено: birthday в vtiger_contactsubdetails, mailingstreet в vtiger_contactaddress
|
||||
|
||||
SELECT
|
||||
cd.contactid,
|
||||
cd.firstname,
|
||||
cd.lastname,
|
||||
cd.email,
|
||||
cd.mobile,
|
||||
cd.phone,
|
||||
cs.birthday, -- ✅ Из vtiger_contactsubdetails
|
||||
ca.mailingstreet, -- ✅ Из vtiger_contactaddress
|
||||
ca.mailingcity,
|
||||
ca.mailingstate,
|
||||
ca.mailingzip,
|
||||
ca.mailingcountry,
|
||||
-- Кастомные поля из vtiger_contactscf:
|
||||
ccf.cf_1157 AS middle_name, -- Отчество
|
||||
ccf.cf_1263 AS birthplace, -- Место рождения
|
||||
ccf.cf_1257 AS inn, -- ИНН
|
||||
ccf.cf_1849 AS requisites, -- Реквизиты
|
||||
ccf.cf_1580 AS code, -- Код
|
||||
ccf.cf_1706 AS sms -- SMS
|
||||
FROM vtiger_contactdetails cd
|
||||
LEFT JOIN vtiger_contactscf ccf ON ccf.contactid = cd.contactid
|
||||
LEFT JOIN vtiger_contactsubdetails cs ON cs.contactsubscriptionid = cd.contactid
|
||||
LEFT JOIN vtiger_contactaddress ca ON ca.contactaddressid = cd.contactid
|
||||
LEFT JOIN vtiger_crmentity ce ON ce.crmid = cd.contactid
|
||||
WHERE cd.contactid = {{ $json.contact_id }}
|
||||
AND ce.deleted = 0
|
||||
|
||||
27
docs/n8n_code_error_response.js
Normal file
27
docs/n8n_code_error_response.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// Code23 — помещаем в n8n-nodes-base.code (JS), Mode = Run Once for All Items
|
||||
|
||||
// Берём все входные элементы
|
||||
const items = $input.all();
|
||||
|
||||
// Предполагаем, что нас интересует первый элемент массива
|
||||
const data = items[0].json;
|
||||
|
||||
// Всегда возвращаем сообщение об ошибке
|
||||
const answerText = 'Извините, произошла ошибка, мы уже работаем над ее устранением, попробуйте задать ваш вопрос еще раз через некоторое время';
|
||||
|
||||
// Собираем единый объект для следующего узла
|
||||
return [
|
||||
{
|
||||
json: {
|
||||
...data,
|
||||
respound: {
|
||||
type: 'text',
|
||||
text: answerText,
|
||||
replyMarkup: {
|
||||
remove_keyboard: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
154
fix_claim_documents_field_names.py
Normal file
154
fix_claim_documents_field_names.py
Normal 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())
|
||||
|
||||
87
fix_documents_meta_duplicates.py
Normal file
87
fix_documents_meta_duplicates.py
Normal 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())
|
||||
|
||||
136
fix_draft_bddb6815.py
Normal file
136
fix_draft_bddb6815.py
Normal 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())
|
||||
|
||||
261
fix_draft_bddb6815_with_contract.py
Normal file
261
fix_draft_bddb6815_with_contract.py
Normal 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())
|
||||
|
||||
@@ -74,6 +74,16 @@ export default function StepDescription({
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📝 Отправка описания проблемы на сервер:', {
|
||||
session_id: formData.session_id,
|
||||
phone: formData.phone,
|
||||
email: formData.email,
|
||||
unified_id: formData.unified_id,
|
||||
contact_id: formData.contact_id,
|
||||
description_length: safeDescription.length,
|
||||
description_preview: safeDescription.substring(0, 100),
|
||||
});
|
||||
|
||||
const response = await fetch('/api/v1/claims/description', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -81,14 +91,29 @@ export default function StepDescription({
|
||||
session_id: formData.session_id,
|
||||
phone: formData.phone,
|
||||
email: formData.email,
|
||||
unified_id: formData.unified_id, // ✅ Unified ID пользователя
|
||||
contact_id: formData.contact_id, // ✅ Contact ID пользователя
|
||||
problem_description: safeDescription,
|
||||
}),
|
||||
});
|
||||
|
||||
console.log('📝 Ответ сервера:', {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('❌ Ошибка отправки описания:', {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
throw new Error(`Ошибка API: ${response.status}`);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
console.log('✅ Описание успешно отправлено:', responseData);
|
||||
|
||||
message.success('Описание отправлено, подбираем рекомендации...');
|
||||
updateFormData({
|
||||
problemDescription: safeDescription,
|
||||
|
||||
725
frontend/src/components/form/StepDocumentsNew.tsx
Normal file
725
frontend/src/components/form/StepDocumentsNew.tsx
Normal file
@@ -0,0 +1,725 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
* StepDocumentsNew.tsx
|
||||
*
|
||||
* Поэкранная загрузка документов.
|
||||
* Один документ на экран с возможностью пропуска.
|
||||
*
|
||||
* @version 1.0
|
||||
* @date 2025-11-26
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Upload,
|
||||
Progress,
|
||||
Alert,
|
||||
Typography,
|
||||
Space,
|
||||
Spin,
|
||||
message,
|
||||
Result
|
||||
} from 'antd';
|
||||
import {
|
||||
UploadOutlined,
|
||||
FileTextOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
LoadingOutlined,
|
||||
InboxOutlined
|
||||
} from '@ant-design/icons';
|
||||
import type { UploadFile, UploadProps } from 'antd/es/upload/interface';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { Dragger } = Upload;
|
||||
|
||||
// === Типы ===
|
||||
export interface DocumentConfig {
|
||||
type: string; // Идентификатор: contract, payment, correspondence
|
||||
name: string; // Название: "Договор или оферта"
|
||||
critical: boolean; // Обязательный документ?
|
||||
hints?: string; // Подсказка: "Скриншот или PDF договора"
|
||||
accept?: string[]; // Допустимые форматы: ['pdf', 'jpg', 'png']
|
||||
}
|
||||
|
||||
interface Props {
|
||||
formData: any;
|
||||
updateFormData: (data: any) => void;
|
||||
documents: DocumentConfig[];
|
||||
currentIndex: number;
|
||||
onDocumentUploaded: (docType: string, fileData: any) => void;
|
||||
onDocumentSkipped: (docType: string) => void;
|
||||
onAllDocumentsComplete: () => void;
|
||||
onPrev: () => void;
|
||||
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
|
||||
}
|
||||
|
||||
// === Компонент ===
|
||||
export default function StepDocumentsNew({
|
||||
formData,
|
||||
updateFormData,
|
||||
documents,
|
||||
currentIndex,
|
||||
onDocumentUploaded,
|
||||
onDocumentSkipped,
|
||||
onAllDocumentsComplete,
|
||||
onPrev,
|
||||
addDebugEvent,
|
||||
}: Props) {
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
// Текущий документ
|
||||
const currentDoc = documents[currentIndex];
|
||||
const isLastDocument = currentIndex === documents.length - 1;
|
||||
const totalDocs = documents.length;
|
||||
|
||||
// Сбрасываем файлы при смене документа
|
||||
useEffect(() => {
|
||||
setFileList([]);
|
||||
setUploadProgress(0);
|
||||
}, [currentIndex]);
|
||||
|
||||
// === Handlers ===
|
||||
|
||||
const handleUpload = useCallback(async () => {
|
||||
if (fileList.length === 0) {
|
||||
message.error('Выберите файл для загрузки');
|
||||
return;
|
||||
}
|
||||
|
||||
const file = fileList[0];
|
||||
if (!file.originFileObj) {
|
||||
message.error('Ошибка: файл не найден');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
addDebugEvent?.('documents', 'info', `📤 Загрузка документа: ${currentDoc.name}`, {
|
||||
document_type: currentDoc.type,
|
||||
file_name: file.name,
|
||||
file_size: file.size,
|
||||
});
|
||||
|
||||
const formDataToSend = new FormData();
|
||||
formDataToSend.append('claim_id', formData.claim_id || '');
|
||||
formDataToSend.append('session_id', formData.session_id || '');
|
||||
formDataToSend.append('document_type', currentDoc.type);
|
||||
formDataToSend.append('file', file.originFileObj, file.name);
|
||||
|
||||
// Симуляция прогресса (реальный прогресс будет через XHR)
|
||||
const progressInterval = setInterval(() => {
|
||||
setUploadProgress(prev => Math.min(prev + 10, 90));
|
||||
}, 200);
|
||||
|
||||
const response = await fetch('/api/v1/documents/upload', {
|
||||
method: 'POST',
|
||||
body: formDataToSend,
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setUploadProgress(100);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Ошибка загрузки: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
addDebugEvent?.('documents', 'success', `✅ Документ загружен: ${currentDoc.name}`, {
|
||||
document_type: currentDoc.type,
|
||||
file_id: result.file_id,
|
||||
});
|
||||
|
||||
message.success(`${currentDoc.name} загружен!`);
|
||||
|
||||
// Сохраняем в formData
|
||||
const uploadedDocs = formData.documents_uploaded || [];
|
||||
uploadedDocs.push({
|
||||
type: currentDoc.type,
|
||||
file_id: result.file_id,
|
||||
file_name: file.name,
|
||||
ocr_status: 'processing',
|
||||
});
|
||||
|
||||
updateFormData({
|
||||
documents_uploaded: uploadedDocs,
|
||||
current_doc_index: currentIndex + 1,
|
||||
});
|
||||
|
||||
// Callback
|
||||
onDocumentUploaded(currentDoc.type, result);
|
||||
|
||||
// Переходим к следующему или завершаем
|
||||
if (isLastDocument) {
|
||||
onAllDocumentsComplete();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Upload error:', error);
|
||||
message.error('Ошибка загрузки файла. Попробуйте ещё раз.');
|
||||
addDebugEvent?.('documents', 'error', `❌ Ошибка загрузки: ${currentDoc.name}`, {
|
||||
error: String(error),
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}, [fileList, currentDoc, formData, updateFormData, currentIndex, isLastDocument, onDocumentUploaded, onAllDocumentsComplete, addDebugEvent]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
if (currentDoc.critical) {
|
||||
// Показываем предупреждение, но всё равно разрешаем пропустить
|
||||
message.warning(`⚠️ Документ "${currentDoc.name}" важен для рассмотрения заявки`);
|
||||
}
|
||||
|
||||
addDebugEvent?.('documents', 'info', `⏭️ Документ пропущен: ${currentDoc.name}`, {
|
||||
document_type: currentDoc.type,
|
||||
was_critical: currentDoc.critical,
|
||||
});
|
||||
|
||||
// Сохраняем в список пропущенных
|
||||
const skippedDocs = formData.documents_skipped || [];
|
||||
if (!skippedDocs.includes(currentDoc.type)) {
|
||||
skippedDocs.push(currentDoc.type);
|
||||
}
|
||||
|
||||
updateFormData({
|
||||
documents_skipped: skippedDocs,
|
||||
current_doc_index: currentIndex + 1,
|
||||
});
|
||||
|
||||
// Callback
|
||||
onDocumentSkipped(currentDoc.type);
|
||||
|
||||
// Переходим к следующему или завершаем
|
||||
if (isLastDocument) {
|
||||
onAllDocumentsComplete();
|
||||
}
|
||||
}, [currentDoc, formData, updateFormData, currentIndex, isLastDocument, onDocumentSkipped, onAllDocumentsComplete, addDebugEvent]);
|
||||
|
||||
// === Upload Props ===
|
||||
const uploadProps: UploadProps = {
|
||||
fileList,
|
||||
onChange: ({ fileList: newFileList }) => setFileList(newFileList.slice(-1)), // Только один файл
|
||||
beforeUpload: () => false, // Не загружаем автоматически
|
||||
maxCount: 1,
|
||||
accept: currentDoc?.accept
|
||||
? currentDoc.accept.map(ext => `.${ext}`).join(',')
|
||||
: '.pdf,.jpg,.jpeg,.png,.heic,.doc,.docx',
|
||||
disabled: uploading,
|
||||
};
|
||||
|
||||
// === Render ===
|
||||
|
||||
if (!currentDoc) {
|
||||
return (
|
||||
<Result
|
||||
status="success"
|
||||
title="Все документы обработаны"
|
||||
subTitle="Переходим к формированию заявления..."
|
||||
extra={<Spin size="large" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 700, margin: '0 auto' }}>
|
||||
<Card>
|
||||
{/* === Прогресс === */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text type="secondary">
|
||||
Документ {currentIndex + 1} из {totalDocs}
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
{Math.round((currentIndex / totalDocs) * 100)}% завершено
|
||||
</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round((currentIndex / totalDocs) * 100)}
|
||||
showInfo={false}
|
||||
strokeColor="#595959"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* === Заголовок === */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={3} style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<FileTextOutlined style={{ color: '#595959' }} />
|
||||
{currentDoc.name}
|
||||
{currentDoc.critical && (
|
||||
<ExclamationCircleOutlined
|
||||
style={{ color: '#fa8c16', fontSize: 20 }}
|
||||
title="Важный документ"
|
||||
/>
|
||||
)}
|
||||
</Title>
|
||||
|
||||
{currentDoc.hints && (
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
{currentDoc.hints}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* === Алерт для критичных документов === */}
|
||||
{currentDoc.critical && (
|
||||
<Alert
|
||||
message="Важный документ"
|
||||
description="Этот документ значительно повысит шансы на успешное рассмотрение заявки. Если документа нет — можно пропустить, но мы рекомендуем загрузить."
|
||||
type="warning"
|
||||
showIcon
|
||||
icon={<ExclamationCircleOutlined />}
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* === Загрузка файла === */}
|
||||
<Dragger {...uploadProps} style={{ marginBottom: 24 }}>
|
||||
<p className="ant-upload-drag-icon">
|
||||
{uploading ? (
|
||||
<LoadingOutlined style={{ fontSize: 48, color: '#595959' }} spin />
|
||||
) : (
|
||||
<InboxOutlined style={{ fontSize: 48, color: '#595959' }} />
|
||||
)}
|
||||
</p>
|
||||
<p className="ant-upload-text">
|
||||
{uploading
|
||||
? 'Загружаем документ...'
|
||||
: 'Перетащите файл сюда или нажмите для выбора'
|
||||
}
|
||||
</p>
|
||||
<p className="ant-upload-hint">
|
||||
Поддерживаются: PDF, JPG, PNG, HEIC, DOC (до 20 МБ)
|
||||
</p>
|
||||
</Dragger>
|
||||
|
||||
{/* === Прогресс загрузки === */}
|
||||
{uploading && (
|
||||
<Progress
|
||||
percent={uploadProgress}
|
||||
status="active"
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* === Кнопки === */}
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
onClick={onPrev}
|
||||
disabled={uploading}
|
||||
size="large"
|
||||
>
|
||||
← Назад
|
||||
</Button>
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
onClick={handleSkip}
|
||||
disabled={uploading}
|
||||
size="large"
|
||||
>
|
||||
Пропустить
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleUpload}
|
||||
loading={uploading}
|
||||
disabled={fileList.length === 0}
|
||||
size="large"
|
||||
icon={<UploadOutlined />}
|
||||
>
|
||||
{isLastDocument ? 'Загрузить и продолжить' : 'Загрузить'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
{/* === Уже загруженные документы === */}
|
||||
{formData.documents_uploaded && formData.documents_uploaded.length > 0 && (
|
||||
<div style={{ marginTop: 24, padding: 16, background: '#f5f5f5', borderRadius: 8 }}>
|
||||
<Text strong>Загруженные документы:</Text>
|
||||
<ul style={{ margin: '8px 0 0 0', paddingLeft: 20 }}>
|
||||
{formData.documents_uploaded.map((doc: any, idx: number) => (
|
||||
<li key={idx}>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: 8 }} />
|
||||
{documents.find(d => d.type === doc.type)?.name || doc.type}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,37 @@
|
||||
/**
|
||||
* StepDraftSelection.tsx
|
||||
*
|
||||
* Выбор черновика с поддержкой разных статусов:
|
||||
* - draft_new: только описание
|
||||
* - draft_docs_progress: часть документов загружена
|
||||
* - draft_docs_complete: все документы, ждём заявление
|
||||
* - draft_claim_ready: заявление готово
|
||||
* - awaiting_sms: ждёт SMS подтверждения
|
||||
* - legacy: старый формат (без documents_required)
|
||||
*
|
||||
* @version 2.0
|
||||
* @date 2025-11-26
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, List, Typography, Space, Empty, Popconfirm, message, Spin, Tag } from 'antd';
|
||||
import { FileTextOutlined, DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
// Форматирование даты без date-fns (если библиотека не установлена)
|
||||
import { Button, Card, List, Typography, Space, Empty, Popconfirm, message, Spin, Tag, Alert, Progress, Tooltip } from 'antd';
|
||||
import {
|
||||
FileTextOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
ClockCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
LoadingOutlined,
|
||||
UploadOutlined,
|
||||
FileSearchOutlined,
|
||||
MobileOutlined,
|
||||
ExclamationCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
// Форматирование даты
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
@@ -16,35 +46,129 @@ const formatDate = (dateStr: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
// Относительное время
|
||||
const getRelativeTime = (dateStr: string) => {
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return 'только что';
|
||||
if (diffMins < 60) return `${diffMins} мин. назад`;
|
||||
if (diffHours < 24) return `${diffHours} ч. назад`;
|
||||
if (diffDays < 7) return `${diffDays} дн. назад`;
|
||||
return formatDate(dateStr);
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
interface Draft {
|
||||
id: string;
|
||||
claim_id: string;
|
||||
session_token: string;
|
||||
status_code: string;
|
||||
channel: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
problem_description?: string;
|
||||
wizard_plan: boolean;
|
||||
wizard_answers: boolean;
|
||||
has_documents: boolean;
|
||||
// Новые поля для нового флоу
|
||||
documents_total?: number;
|
||||
documents_uploaded?: number;
|
||||
documents_skipped?: number;
|
||||
wizard_ready?: boolean;
|
||||
claim_ready?: boolean;
|
||||
is_legacy?: boolean; // Старый формат без documents_required
|
||||
}
|
||||
|
||||
interface Props {
|
||||
phone?: string;
|
||||
session_id?: string;
|
||||
unified_id?: string; // ✅ Добавляем unified_id
|
||||
unified_id?: string;
|
||||
onSelectDraft: (claimId: string) => void;
|
||||
onNewClaim: () => void;
|
||||
onRestartDraft?: (claimId: string, description: string) => void; // Для legacy черновиков
|
||||
}
|
||||
|
||||
// === Конфиг статусов ===
|
||||
const STATUS_CONFIG: Record<string, {
|
||||
color: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
description: string;
|
||||
action: string;
|
||||
}> = {
|
||||
draft: {
|
||||
color: 'default',
|
||||
icon: <FileTextOutlined />,
|
||||
label: 'Черновик',
|
||||
description: 'Начато заполнение',
|
||||
action: 'Продолжить',
|
||||
},
|
||||
draft_new: {
|
||||
color: 'blue',
|
||||
icon: <FileTextOutlined />,
|
||||
label: 'Новый',
|
||||
description: 'Только описание проблемы',
|
||||
action: 'Загрузить документы',
|
||||
},
|
||||
draft_docs_progress: {
|
||||
color: 'processing',
|
||||
icon: <UploadOutlined />,
|
||||
label: 'Загрузка документов',
|
||||
description: 'Часть документов загружена',
|
||||
action: 'Продолжить загрузку',
|
||||
},
|
||||
draft_docs_complete: {
|
||||
color: 'orange',
|
||||
icon: <LoadingOutlined />,
|
||||
label: 'Обработка',
|
||||
description: 'Формируется заявление...',
|
||||
action: 'Ожидайте',
|
||||
},
|
||||
draft_claim_ready: {
|
||||
color: 'green',
|
||||
icon: <CheckCircleOutlined />,
|
||||
label: 'Готово к отправке',
|
||||
description: 'Заявление готово',
|
||||
action: 'Просмотреть и отправить',
|
||||
},
|
||||
awaiting_sms: {
|
||||
color: 'volcano',
|
||||
icon: <MobileOutlined />,
|
||||
label: 'Ожидает подтверждения',
|
||||
description: 'Введите SMS код',
|
||||
action: 'Подтвердить',
|
||||
},
|
||||
in_work: {
|
||||
color: 'cyan',
|
||||
icon: <FileSearchOutlined />,
|
||||
label: 'В работе',
|
||||
description: 'Заявка на рассмотрении',
|
||||
action: 'Просмотреть',
|
||||
},
|
||||
legacy: {
|
||||
color: 'warning',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
label: 'Устаревший формат',
|
||||
description: 'Требуется обновление',
|
||||
action: 'Начать заново',
|
||||
},
|
||||
};
|
||||
|
||||
export default function StepDraftSelection({
|
||||
phone,
|
||||
session_id,
|
||||
unified_id, // ✅ Добавляем unified_id
|
||||
unified_id,
|
||||
onSelectDraft,
|
||||
onNewClaim,
|
||||
onRestartDraft,
|
||||
}: Props) {
|
||||
const [drafts, setDrafts] = useState<Draft[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -54,7 +178,7 @@ export default function StepDraftSelection({
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
// ✅ Приоритет: unified_id > phone > session_id
|
||||
|
||||
if (unified_id) {
|
||||
params.append('unified_id', unified_id);
|
||||
console.log('🔍 StepDraftSelection: загружаем черновики по unified_id:', unified_id);
|
||||
@@ -76,8 +200,22 @@ export default function StepDraftSelection({
|
||||
|
||||
const data = await response.json();
|
||||
console.log('🔍 StepDraftSelection: ответ API:', data);
|
||||
console.log('🔍 StepDraftSelection: количество черновиков:', data.count);
|
||||
setDrafts(data.drafts || []);
|
||||
|
||||
// Определяем legacy черновики (без documents_required в payload)
|
||||
const processedDrafts = (data.drafts || []).map((draft: Draft) => {
|
||||
// Legacy только если:
|
||||
// 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,
|
||||
};
|
||||
});
|
||||
|
||||
setDrafts(processedDrafts);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки черновиков:', error);
|
||||
message.error('Не удалось загрузить список черновиков');
|
||||
@@ -88,7 +226,7 @@ export default function StepDraftSelection({
|
||||
|
||||
useEffect(() => {
|
||||
loadDrafts();
|
||||
}, [phone, session_id, unified_id]); // ✅ Добавляем unified_id в зависимости
|
||||
}, [phone, session_id, unified_id]);
|
||||
|
||||
const handleDelete = async (claimId: string) => {
|
||||
try {
|
||||
@@ -111,14 +249,56 @@ export default function StepDraftSelection({
|
||||
}
|
||||
};
|
||||
|
||||
// Получение конфига статуса
|
||||
const getStatusConfig = (draft: Draft) => {
|
||||
if (draft.is_legacy) {
|
||||
return STATUS_CONFIG.legacy;
|
||||
}
|
||||
return STATUS_CONFIG[draft.status_code] || STATUS_CONFIG.draft;
|
||||
};
|
||||
|
||||
const getProgressInfo = (draft: Draft) => {
|
||||
const parts: string[] = [];
|
||||
if (draft.problem_description) parts.push('Описание');
|
||||
if (draft.wizard_plan) parts.push('План вопросов');
|
||||
if (draft.wizard_answers) parts.push('Ответы');
|
||||
if (draft.has_documents) parts.push('Документы');
|
||||
return parts.length > 0 ? parts.join(', ') : 'Начато';
|
||||
// Прогресс документов
|
||||
const getDocsProgress = (draft: Draft) => {
|
||||
if (!draft.documents_total) return null;
|
||||
const uploaded = draft.documents_uploaded || 0;
|
||||
const skipped = draft.documents_skipped || 0;
|
||||
const total = draft.documents_total;
|
||||
const percent = Math.round(((uploaded + skipped) / total) * 100);
|
||||
return { uploaded, skipped, total, percent };
|
||||
};
|
||||
|
||||
// Обработка клика на черновик
|
||||
const handleDraftAction = (draft: Draft) => {
|
||||
const draftId = draft.claim_id || draft.id;
|
||||
|
||||
if (draft.is_legacy && onRestartDraft) {
|
||||
// Legacy черновик - предлагаем начать заново с тем же описанием
|
||||
onRestartDraft(draftId, draft.problem_description || '');
|
||||
} else if (draft.status_code === 'draft_docs_complete') {
|
||||
// Всё ещё обрабатывается - показываем сообщение
|
||||
message.info('Заявление формируется. Пожалуйста, подождите.');
|
||||
} else {
|
||||
// Обычный переход
|
||||
onSelectDraft(draftId);
|
||||
}
|
||||
};
|
||||
|
||||
// Кнопка действия
|
||||
const getActionButton = (draft: Draft) => {
|
||||
const config = getStatusConfig(draft);
|
||||
const isProcessing = draft.status_code === 'draft_docs_complete';
|
||||
|
||||
return (
|
||||
<Button
|
||||
type={isProcessing ? 'default' : 'primary'}
|
||||
onClick={() => handleDraftAction(draft)}
|
||||
icon={config.icon}
|
||||
disabled={isProcessing}
|
||||
loading={isProcessing}
|
||||
>
|
||||
{config.action}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -133,10 +313,10 @@ export default function StepDraftSelection({
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Title level={2} style={{ marginBottom: 8, color: '#1890ff' }}>
|
||||
📋 Ваши черновики заявок
|
||||
📋 Ваши заявки
|
||||
</Title>
|
||||
<Paragraph type="secondary" style={{ fontSize: 14, marginBottom: 16 }}>
|
||||
Выберите черновик, чтобы продолжить заполнение, или создайте новую заявку.
|
||||
Выберите заявку для продолжения или создайте новую.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
@@ -146,7 +326,7 @@ export default function StepDraftSelection({
|
||||
</div>
|
||||
) : drafts.length === 0 ? (
|
||||
<Empty
|
||||
description="У вас нет незавершенных черновиков"
|
||||
description="У вас нет незавершенных заявок"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onNewClaim} size="large">
|
||||
@@ -157,89 +337,146 @@ export default function StepDraftSelection({
|
||||
<>
|
||||
<List
|
||||
dataSource={drafts}
|
||||
renderItem={(draft) => (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '16px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
background: '#fff',
|
||||
}}
|
||||
actions={[
|
||||
<Button
|
||||
key="continue"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
console.log('🔍 Выбран черновик:', draft.claim_id, 'id:', draft.id);
|
||||
// Используем id (UUID) если claim_id отсутствует
|
||||
const draftId = draft.claim_id || draft.id;
|
||||
console.log('🔍 Загружаем черновик с ID:', draftId);
|
||||
onSelectDraft(draftId);
|
||||
}}
|
||||
icon={<FileTextOutlined />}
|
||||
>
|
||||
Продолжить
|
||||
</Button>,
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="Удалить черновик?"
|
||||
description="Это действие нельзя отменить"
|
||||
onConfirm={() => handleDelete(draft.claim_id!)}
|
||||
okText="Да, удалить"
|
||||
cancelText="Отмена"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deletingId === draft.claim_id}
|
||||
disabled={deletingId === draft.claim_id}
|
||||
renderItem={(draft) => {
|
||||
const config = getStatusConfig(draft);
|
||||
const docsProgress = getDocsProgress(draft);
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '16px',
|
||||
border: `1px solid ${draft.is_legacy ? '#faad14' : '#d9d9d9'}`,
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
background: draft.is_legacy ? '#fffbe6' : '#fff',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
actions={[
|
||||
getActionButton(draft),
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="Удалить заявку?"
|
||||
description="Это действие нельзя отменить"
|
||||
onConfirm={() => handleDelete(draft.claim_id || draft.id)}
|
||||
okText="Да, удалить"
|
||||
cancelText="Отмена"
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<FileTextOutlined style={{ fontSize: 24, color: '#595959' }} />}
|
||||
title={
|
||||
<Space>
|
||||
<Text strong>Черновик</Text>
|
||||
<Tag color="default">Черновик</Tag>
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Обновлен: {formatDate(draft.updated_at)}
|
||||
</Text>
|
||||
{draft.problem_description && (
|
||||
<Text
|
||||
ellipsis={{ tooltip: draft.problem_description }}
|
||||
style={{ fontSize: 13 }}
|
||||
>
|
||||
{draft.problem_description}
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deletingId === (draft.claim_id || draft.id)}
|
||||
disabled={deletingId === (draft.claim_id || draft.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<div style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: draft.is_legacy ? '#fff7e6' : '#f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 20,
|
||||
color: draft.is_legacy ? '#faad14' : '#595959',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{config.icon}
|
||||
</div>
|
||||
}
|
||||
title={
|
||||
<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' }} />
|
||||
<Tooltip title={formatDate(draft.updated_at)}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{getRelativeTime(draft.updated_at)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
|
||||
{/* Legacy предупреждение */}
|
||||
{draft.is_legacy && (
|
||||
<Alert
|
||||
message="Черновик в старом формате. Нажмите 'Начать заново'."
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ fontSize: 12, padding: '4px 8px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Прогресс документов */}
|
||||
{docsProgress && (
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
📎 Документы: {docsProgress.uploaded} из {docsProgress.total} загружено
|
||||
{docsProgress.skipped > 0 && ` (${docsProgress.skipped} пропущено)`}
|
||||
</Text>
|
||||
<Progress
|
||||
percent={docsProgress.percent}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
strokeColor="#52c41a"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Старые теги прогресса (для обратной совместимости) */}
|
||||
{!docsProgress && !draft.is_legacy && (
|
||||
<Space size="small" wrap>
|
||||
<Tag color={draft.problem_description ? 'green' : 'default'}>
|
||||
{draft.problem_description ? '✓ Описание' : 'Описание'}
|
||||
</Tag>
|
||||
<Tag color={draft.wizard_plan ? 'green' : 'default'}>
|
||||
{draft.wizard_plan ? '✓ План' : 'План'}
|
||||
</Tag>
|
||||
<Tag color={draft.has_documents ? 'green' : 'default'}>
|
||||
{draft.has_documents ? '✓ Документы' : 'Документы'}
|
||||
</Tag>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{/* Описание статуса */}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{config.description}
|
||||
</Text>
|
||||
)}
|
||||
<Space size="small">
|
||||
<Tag color={draft.wizard_plan ? 'green' : 'default'}>
|
||||
{draft.wizard_plan ? '✓ План' : 'План'}
|
||||
</Tag>
|
||||
<Tag color={draft.wizard_answers ? 'green' : 'default'}>
|
||||
{draft.wizard_answers ? '✓ Ответы' : 'Ответы'}
|
||||
</Tag>
|
||||
<Tag color={draft.has_documents ? 'green' : 'default'}>
|
||||
{draft.has_documents ? '✓ Документы' : 'Документы'}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Прогресс: {getProgressInfo(draft)}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
||||
@@ -271,4 +508,3 @@ export default function StepDraftSelection({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
679
frontend/src/components/form/StepWaitingClaim.tsx
Normal file
679
frontend/src/components/form/StepWaitingClaim.tsx
Normal file
@@ -0,0 +1,679 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Card, Checkbox, Form, Input, Radio, Result, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd';
|
||||
import { LoadingOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
import { LoadingOutlined, PlusOutlined, ThunderboltOutlined, InboxOutlined } from '@ant-design/icons';
|
||||
import AiWorkingIllustration from '../../assets/ai-working.svg';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
|
||||
@@ -239,17 +239,27 @@ export default function StepWizardPlan({
|
||||
? docList[0].id
|
||||
: docId;
|
||||
|
||||
handleDocumentBlocksChange(docId, (blocks) => [
|
||||
...blocks,
|
||||
{
|
||||
id: generateBlockId(docId),
|
||||
fieldName: docId,
|
||||
description: '',
|
||||
category: category,
|
||||
docLabel: docLabel,
|
||||
files: [],
|
||||
},
|
||||
]);
|
||||
handleDocumentBlocksChange(docId, (blocks) => {
|
||||
// ✅ Автогенерация уникального описания:
|
||||
// - Первый блок: пустое (будет использоваться docLabel)
|
||||
// - Второй и далее: "docLabel #N"
|
||||
const blockNumber = blocks.length + 1;
|
||||
const autoDescription = blockNumber > 1
|
||||
? `${docLabel || docId} #${blockNumber}`
|
||||
: '';
|
||||
|
||||
return [
|
||||
...blocks,
|
||||
{
|
||||
id: generateBlockId(docId),
|
||||
fieldName: docId,
|
||||
description: autoDescription,
|
||||
category: category,
|
||||
docLabel: docLabel,
|
||||
files: [],
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
const updateDocumentBlock = (
|
||||
@@ -328,53 +338,61 @@ export default function StepWizardPlan({
|
||||
setProgressState({ done, total });
|
||||
}, [formValues, questions]);
|
||||
|
||||
// Автоматически создаём блоки для обязательных документов при ответе "Да"
|
||||
// Автоматически создаём блоки для ВСЕХ документов из плана при загрузке
|
||||
// Используем ref чтобы отслеживать какие блоки уже созданы
|
||||
const createdDocBlocksRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!plan || !formValues) return;
|
||||
if (!plan || !documents || documents.length === 0) return;
|
||||
|
||||
questions.forEach((question) => {
|
||||
const visible = evaluateCondition(question.ask_if, formValues);
|
||||
if (!visible) return;
|
||||
documents.forEach((doc) => {
|
||||
const docKey = doc.id || doc.name || `doc_unknown`;
|
||||
|
||||
const questionValue = formValues?.[question.name];
|
||||
if (!isAffirmative(questionValue)) return;
|
||||
// Не создаём блок, если уже создавали
|
||||
if (createdDocBlocksRef.current.has(docKey)) return;
|
||||
|
||||
const questionDocs = documentGroups[question.name] || [];
|
||||
questionDocs.forEach((doc) => {
|
||||
if (!doc.required) return;
|
||||
// Не создаём блок, если документ пропущен
|
||||
if (skippedDocuments.has(docKey)) return;
|
||||
|
||||
const docKey = doc.id || doc.name || `doc_${question.name}`;
|
||||
// Помечаем как созданный
|
||||
createdDocBlocksRef.current.add(docKey);
|
||||
|
||||
// Не создаём блок, если документ пропущен
|
||||
if (skippedDocuments.has(docKey)) return;
|
||||
|
||||
const existingBlocks = questionFileBlocks[docKey] || [];
|
||||
|
||||
// Если блока ещё нет, создаём его автоматически
|
||||
if (existingBlocks.length === 0) {
|
||||
const category = doc.id && !doc.id.includes('_exist') ? doc.id : docKey;
|
||||
handleDocumentBlocksChange(docKey, (blocks) => [
|
||||
...blocks,
|
||||
{
|
||||
id: generateBlockId(docKey),
|
||||
fieldName: docKey,
|
||||
description: '',
|
||||
category: category,
|
||||
docLabel: doc.name,
|
||||
files: [],
|
||||
},
|
||||
]);
|
||||
}
|
||||
const category = doc.id && !doc.id.includes('_exist') ? doc.id : docKey;
|
||||
handleDocumentBlocksChange(docKey, (blocks) => {
|
||||
// Проверяем ещё раз внутри callback
|
||||
if (blocks.length > 0) return blocks;
|
||||
return [
|
||||
...blocks,
|
||||
{
|
||||
id: generateBlockId(docKey),
|
||||
fieldName: docKey,
|
||||
description: '',
|
||||
category: category,
|
||||
docLabel: doc.name,
|
||||
files: [],
|
||||
},
|
||||
];
|
||||
});
|
||||
});
|
||||
}, [formValues, plan, questions, documentGroups, questionFileBlocks, handleDocumentBlocksChange, skippedDocuments]);
|
||||
}, [plan, documents, handleDocumentBlocksChange, skippedDocuments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWaiting || !formData.session_id || plan) {
|
||||
console.log('⏭️ StepWizardPlan: пропускаем подписку SSE', {
|
||||
isWaiting,
|
||||
hasSessionId: !!formData.session_id,
|
||||
hasPlan: !!plan,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = formData.session_id;
|
||||
console.log('🔌 StepWizardPlan: подписываемся на SSE канал для получения wizard_plan', {
|
||||
session_id: sessionId,
|
||||
sse_url: `/events/${sessionId}`,
|
||||
redis_channel: `ocr_events:${sessionId}`,
|
||||
});
|
||||
|
||||
const source = new EventSource(`/events/${sessionId}`);
|
||||
eventSourceRef.current = source;
|
||||
debugLoggerRef.current?.('wizard', 'info', '🔌 Подключаемся к SSE для плана вопросов', { session_id: sessionId });
|
||||
@@ -441,6 +459,43 @@ export default function StepWizardPlan({
|
||||
payload_preview: JSON.stringify(payload).substring(0, 200),
|
||||
});
|
||||
|
||||
// ✅ НОВЫЙ ФЛОУ: Обработка списка документов
|
||||
if (eventType === 'documents_list_ready') {
|
||||
const documentsRequired = payload.documents_required || [];
|
||||
|
||||
debugLoggerRef.current?.('wizard', 'success', '📋 Получен список документов!', {
|
||||
session_id: sessionId,
|
||||
documents_count: documentsRequired.length,
|
||||
documents: documentsRequired.map((d: any) => d.name),
|
||||
});
|
||||
|
||||
console.log('📋 documents_list_ready:', {
|
||||
claim_id: payload.claim_id,
|
||||
documents_required: documentsRequired,
|
||||
});
|
||||
|
||||
// Сохраняем в formData для нового флоу
|
||||
updateFormData({
|
||||
documents_required: documentsRequired,
|
||||
claim_id: payload.claim_id,
|
||||
wizardPlanStatus: 'documents_ready', // Новый статус
|
||||
});
|
||||
|
||||
setIsWaiting(false);
|
||||
setConnectionError(null);
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Пока показываем alert для теста, потом переход к StepDocumentsNew
|
||||
message.success(`Получен список документов: ${documentsRequired.length} шт.`);
|
||||
|
||||
// TODO: onNext() для перехода к StepDocumentsNew
|
||||
return;
|
||||
}
|
||||
|
||||
const wizardPayload = extractWizardPayload(payload);
|
||||
const hasWizardPlan = Boolean(wizardPayload);
|
||||
|
||||
@@ -695,6 +750,17 @@ export default function StepWizardPlan({
|
||||
return `upload_${group.index}`;
|
||||
};
|
||||
|
||||
// ✅ Подсчитываем дубликаты labels для автоматической нумерации
|
||||
const labelCounts: Record<string, number> = {};
|
||||
const labelIndexes: Record<string, number> = {};
|
||||
|
||||
// Первый проход - считаем сколько раз встречается каждый label
|
||||
groups.forEach((group) => {
|
||||
const block = group.block;
|
||||
const baseLabel = (block.description?.trim()) || block.docLabel || block.fieldName || guessFieldName(group);
|
||||
labelCounts[baseLabel] = (labelCounts[baseLabel] || 0) + 1;
|
||||
});
|
||||
|
||||
groups.forEach((group) => {
|
||||
const i = group.index;
|
||||
const block = group.block;
|
||||
@@ -713,11 +779,30 @@ export default function StepWizardPlan({
|
||||
);
|
||||
|
||||
// ✅ Добавляем реальное название поля (label) для использования в n8n
|
||||
// Приоритет: description (если заполнено) > docLabel > fieldLabel
|
||||
const baseLabel = (block.description?.trim()) || block.docLabel || fieldLabel;
|
||||
|
||||
// ✅ Автоматическая нумерация для дубликатов
|
||||
let finalFieldLabel = baseLabel;
|
||||
if (labelCounts[baseLabel] > 1) {
|
||||
labelIndexes[baseLabel] = (labelIndexes[baseLabel] || 0) + 1;
|
||||
finalFieldLabel = `${baseLabel} #${labelIndexes[baseLabel]}`;
|
||||
}
|
||||
|
||||
formPayload.append(
|
||||
`uploads_field_labels[${i}]`,
|
||||
block.docLabel || block.description || fieldLabel
|
||||
finalFieldLabel
|
||||
);
|
||||
|
||||
// 🔍 Логируем отправляемые метаданные документов
|
||||
console.log(`📁 Группа ${i}:`, {
|
||||
field_name: fieldLabel,
|
||||
field_label: finalFieldLabel,
|
||||
description: block.description,
|
||||
docLabel: block.docLabel,
|
||||
filesCount: block.files.length,
|
||||
});
|
||||
|
||||
// Файлы: uploads[i][j]
|
||||
block.files.forEach((file, j) => {
|
||||
const origin: any = (file as any).originFileObj;
|
||||
@@ -919,23 +1004,19 @@ export default function StepWizardPlan({
|
||||
const accept = docList.flatMap((doc) => doc.accept || []);
|
||||
const uniqueAccept = Array.from(new Set(accept.length ? accept : ['pdf', 'jpg', 'png']));
|
||||
|
||||
// Если документ предопределён (конкретный тип, не общий), не показываем лишние поля
|
||||
// Предопределённые документы: contract, payment, payment_confirmation и их вариации
|
||||
// Документ предопределён если у него есть id и он НЕ общий (не содержит _exist)
|
||||
// Для предустановленных документов НЕ показываем поле описания и кнопку "Удалить"
|
||||
const doc = docList[0];
|
||||
const isPredefinedDoc = docList.length === 1 && doc && doc.id &&
|
||||
!doc.id.includes('_exist') &&
|
||||
(doc.id === 'contract' || doc.id === 'payment' || doc.id === 'payment_confirmation' ||
|
||||
doc.id.includes('contract') || doc.id.includes('payment') || doc.id.includes('receipt') ||
|
||||
doc.id.includes('cheque') || doc.id.includes('чек'));
|
||||
const singleDocName = isPredefinedDoc ? doc.name : null;
|
||||
const isPredefinedDoc = docList.length === 1 && doc && doc.id && !doc.id.includes('_exist');
|
||||
const singleDocName = doc?.name || docLabel;
|
||||
const isRequired = docList.some(doc => doc.required);
|
||||
const isSkipped = skippedDocuments.has(docId);
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{/* Чекбокс "Пропустить" для обязательных документов */}
|
||||
{isRequired && (
|
||||
<div style={{ marginBottom: 8, padding: 8, background: '#f8f9fa', borderRadius: 8 }}>
|
||||
{/* Если документ пропущен - показываем только сообщение */}
|
||||
{isSkipped && (
|
||||
<div style={{ padding: 12, background: '#fff7e6', borderRadius: 8, border: '1px solid #ffd591' }}>
|
||||
<Checkbox
|
||||
checked={isSkipped}
|
||||
onChange={(e) => {
|
||||
@@ -949,7 +1030,7 @@ export default function StepWizardPlan({
|
||||
updateFormData({ wizardSkippedDocuments: Array.from(newSkipped) });
|
||||
}}
|
||||
>
|
||||
У меня нет этого документа
|
||||
<Text type="warning">У меня нет документа: {docLabel}</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
@@ -965,7 +1046,9 @@ export default function StepWizardPlan({
|
||||
}}
|
||||
title={singleDocName || `${docLabel} — группа #${idx + 1}`}
|
||||
extra={
|
||||
currentBlocks.length > 1 && (
|
||||
// Кнопка "Удалить" только если это дополнительный блок (idx > 0)
|
||||
// Первый блок предустановленного документа удалять нельзя
|
||||
(currentBlocks.length > 1 && idx > 0) && (
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
@@ -978,11 +1061,11 @@ export default function StepWizardPlan({
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{/* Поле описания только для необязательных/кастомных документов */}
|
||||
{/* Для обязательных документов (contract, payment) описание не требуется */}
|
||||
{!isPredefinedDoc && !isRequired && (
|
||||
{/* Поле описания показываем только для дополнительных блоков (idx > 0)
|
||||
или для общих документов (docs_exist) */}
|
||||
{(idx > 0 || !isPredefinedDoc) && (
|
||||
<Input
|
||||
placeholder="Описание документов (например: договор от 12.05, платёжка №123)"
|
||||
placeholder="Уточните тип документа (например: Претензия от 12.05)"
|
||||
value={block.description}
|
||||
onChange={(e) =>
|
||||
updateDocumentBlock(docId, block.id, { description: e.target.value })
|
||||
@@ -1023,6 +1106,24 @@ export default function StepWizardPlan({
|
||||
Допустимые форматы: {uniqueAccept.join(', ')}. До 5 файлов, максимум 20 МБ каждый.
|
||||
</p>
|
||||
</Dragger>
|
||||
|
||||
{/* Чекбокс "Нет документа" под загрузкой - только для обязательных и только в первом блоке */}
|
||||
{isRequired && idx === 0 && block.files.length === 0 && (
|
||||
<Checkbox
|
||||
checked={false}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
const newSkipped = new Set(skippedDocuments);
|
||||
newSkipped.add(docId);
|
||||
setSkippedDocuments(newSkipped);
|
||||
updateFormData({ wizardSkippedDocuments: Array.from(newSkipped) });
|
||||
}
|
||||
}}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
<Text type="secondary">У меня нет этого документа</Text>
|
||||
</Checkbox>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
@@ -1170,6 +1271,17 @@ export default function StepWizardPlan({
|
||||
// Если в плане визарда есть документы, не показываем поля про загрузку (text/textarea/file)
|
||||
const questionLabelLower = (question.label || '').toLowerCase();
|
||||
const questionNameLower = (question.name || '').toLowerCase();
|
||||
|
||||
// Скрываем вопрос docs_exist (чекбоксы "какие документы есть") если есть документы
|
||||
// Загрузка документов реализована через отдельные блоки под информационной карточкой
|
||||
const isDocsExistQuestion = questionNameLower === 'docs_exist' ||
|
||||
questionNameLower === 'correspondence_exist' ||
|
||||
questionNameLower.includes('docs_exist');
|
||||
if (isDocsExistQuestion && documents.length > 0) {
|
||||
console.log(`🚫 Question ${question.name} hidden: docs_exist with documents`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDocumentUploadQuestion =
|
||||
(question.input_type === 'text' ||
|
||||
question.input_type === 'textarea' ||
|
||||
@@ -1256,11 +1368,164 @@ export default function StepWizardPlan({
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ НОВЫЙ ФЛОУ: Если есть documents_required, показываем загрузку документов
|
||||
const documentsRequired = formData.documents_required || [];
|
||||
const hasNewFlowDocs = documentsRequired.length > 0;
|
||||
|
||||
// 🔍 ОТЛАДКА: Логируем состояние для диагностики
|
||||
console.log('🔍 StepWizardPlan - определение флоу:', {
|
||||
documentsRequiredCount: documentsRequired.length,
|
||||
documentsRequired: documentsRequired,
|
||||
hasNewFlowDocs,
|
||||
hasPlan: !!plan,
|
||||
isWaiting,
|
||||
formDataKeys: Object.keys(formData),
|
||||
});
|
||||
|
||||
// Состояние для поэкранной загрузки документов (новый флоу)
|
||||
const [currentDocIndex, setCurrentDocIndex] = useState(formData.current_doc_index || 0);
|
||||
// Убираем дубликаты при инициализации
|
||||
const initialUploadedDocs = formData.documents_uploaded?.map((d: any) => d.type || d.id) || [];
|
||||
const [uploadedDocs, setUploadedDocs] = useState<string[]>(Array.from(new Set(initialUploadedDocs)));
|
||||
const [skippedDocs, setSkippedDocs] = useState<string[]>(formData.documents_skipped || []);
|
||||
const [docChoice, setDocChoice] = useState<'upload' | 'none'>('upload'); // Выбор: загрузить или нет документа (по умолчанию - загрузить)
|
||||
const [currentUploadedFiles, setCurrentUploadedFiles] = useState<any[]>([]); // Массив загруженных файлов
|
||||
|
||||
// Текущий документ для загрузки
|
||||
const currentDoc = documentsRequired[currentDocIndex];
|
||||
const isLastDoc = currentDocIndex >= documentsRequired.length - 1;
|
||||
const allDocsProcessed = currentDocIndex >= documentsRequired.length;
|
||||
|
||||
// Обработчик выбора файлов (НЕ отправляем сразу, только сохраняем)
|
||||
const handleFilesChange = (fileList: any[]) => {
|
||||
console.log('📁 handleFilesChange:', fileList.length, 'файлов', fileList.map(f => f.name));
|
||||
setCurrentUploadedFiles(fileList);
|
||||
if (fileList.length > 0) {
|
||||
setDocChoice('upload');
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик "Продолжить" — отправляем файл или пропускаем
|
||||
const handleDocContinue = async () => {
|
||||
if (!currentDoc) return;
|
||||
|
||||
// Если выбрано "Нет документа" — пропускаем
|
||||
if (docChoice === 'none') {
|
||||
if (currentDoc.required) {
|
||||
message.warning(`⚠️ Документ "${currentDoc.name}" важен для рассмотрения заявки. Постарайтесь найти его позже.`);
|
||||
}
|
||||
|
||||
const newSkipped = [...skippedDocs, currentDoc.id];
|
||||
setSkippedDocs(newSkipped);
|
||||
|
||||
updateFormData({
|
||||
documents_skipped: newSkipped,
|
||||
current_doc_index: currentDocIndex + 1,
|
||||
});
|
||||
|
||||
// Переход к следующему (сброс состояния в useEffect)
|
||||
setCurrentDocIndex(prev => prev + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Если выбрано "Загрузить" — отправляем все файлы ОДНИМ запросом
|
||||
if (docChoice === 'upload' && currentUploadedFiles.length > 0) {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
|
||||
console.log('📤 Загружаем все файлы одним запросом:', {
|
||||
totalFiles: currentUploadedFiles.length,
|
||||
files: currentUploadedFiles.map(f => ({ name: f.name, uid: f.uid, size: f.size }))
|
||||
});
|
||||
|
||||
const formDataToSend = new FormData();
|
||||
formDataToSend.append('claim_id', formData.claim_id || '');
|
||||
formDataToSend.append('session_id', formData.session_id || '');
|
||||
formDataToSend.append('unified_id', formData.unified_id || '');
|
||||
formDataToSend.append('contact_id', formData.contact_id || '');
|
||||
formDataToSend.append('phone', formData.phone || '');
|
||||
formDataToSend.append('document_type', currentDoc.id);
|
||||
formDataToSend.append('document_name', currentDoc.name || currentDoc.id);
|
||||
formDataToSend.append('document_description', currentDoc.hints || '');
|
||||
formDataToSend.append('group_index', String(currentDocIndex)); // ✅ Передаём индекс документа для правильного field_name
|
||||
|
||||
// Добавляем все файлы в один запрос
|
||||
currentUploadedFiles.forEach((file) => {
|
||||
formDataToSend.append('files', file.originFileObj, file.name);
|
||||
});
|
||||
|
||||
const response = await fetch('/api/v1/documents/upload-multiple', {
|
||||
method: 'POST',
|
||||
body: formDataToSend,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || 'Ошибка загрузки файлов');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('✅ Все файлы загружены:', result);
|
||||
|
||||
// Обновляем состояние
|
||||
const uploadedDocsData = [...(formData.documents_uploaded || [])];
|
||||
|
||||
// Добавляем информацию о каждом загруженном файле
|
||||
result.file_ids.forEach((fileId: string, i: number) => {
|
||||
uploadedDocsData.push({
|
||||
type: currentDoc.id,
|
||||
file_id: fileId,
|
||||
filename: currentUploadedFiles[i]?.name || `file_${i}`,
|
||||
ocr_status: 'processing',
|
||||
});
|
||||
});
|
||||
|
||||
message.success(`${currentDoc.name}: загружено ${result.files_count} файл(ов)!`);
|
||||
|
||||
// Убираем дубликаты при добавлении
|
||||
const newUploaded = uploadedDocs.includes(currentDoc.id)
|
||||
? uploadedDocs
|
||||
: [...uploadedDocs, currentDoc.id];
|
||||
setUploadedDocs(newUploaded);
|
||||
|
||||
updateFormData({
|
||||
documents_uploaded: uploadedDocsData,
|
||||
current_doc_index: currentDocIndex + 1,
|
||||
});
|
||||
|
||||
// Переход к следующему (сброс состояния в useEffect)
|
||||
setCurrentDocIndex(prev => prev + 1);
|
||||
|
||||
} catch (error: any) {
|
||||
message.error(`Ошибка загрузки: ${error.message}`);
|
||||
console.error('Upload error:', error);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Можно ли нажать "Продолжить"
|
||||
const canContinue = docChoice === 'none' || (docChoice === 'upload' && currentUploadedFiles.length > 0);
|
||||
|
||||
// Сброс состояния при переходе к следующему документу
|
||||
useEffect(() => {
|
||||
setDocChoice('upload');
|
||||
setCurrentUploadedFiles([]);
|
||||
}, [currentDocIndex]);
|
||||
|
||||
// Все документы загружены — переход к ожиданию заявления
|
||||
const handleAllDocsComplete = () => {
|
||||
message.loading('Формируем заявление...', 0);
|
||||
// TODO: Переход к StepWaitingClaim или показ loader
|
||||
onNext();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Button onClick={onPrev}>← Назад</Button>
|
||||
{plan && (
|
||||
{plan && !hasNewFlowDocs && (
|
||||
<Button type="link" onClick={handleRefreshPlan}>
|
||||
Обновить рекомендации
|
||||
</Button>
|
||||
@@ -1274,7 +1539,143 @@ export default function StepWizardPlan({
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
{isWaiting && (
|
||||
{/* ✅ НОВЫЙ ФЛОУ: Поэкранная загрузка документов */}
|
||||
{hasNewFlowDocs && !allDocsProcessed && currentDoc && (
|
||||
<div style={{ padding: '24px 0' }}>
|
||||
{/* Прогресс */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text type="secondary">Документ {currentDocIndex + 1} из {documentsRequired.length}</Text>
|
||||
<Text type="secondary">{Math.round((currentDocIndex / documentsRequired.length) * 100)}% завершено</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round((currentDocIndex / documentsRequired.length) * 100)}
|
||||
showInfo={false}
|
||||
strokeColor="#595959"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Заголовок документа */}
|
||||
<Title level={4} style={{ marginBottom: 8 }}>
|
||||
📄 {currentDoc.name}
|
||||
{currentDoc.required && <Tag color="volcano" style={{ marginLeft: 8 }}>Важный</Tag>}
|
||||
</Title>
|
||||
|
||||
{currentDoc.hints && (
|
||||
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||
{currentDoc.hints}
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
{/* Радио-кнопки выбора */}
|
||||
<Radio.Group
|
||||
value={docChoice}
|
||||
onChange={(e) => {
|
||||
setDocChoice(e.target.value);
|
||||
if (e.target.value === 'none') {
|
||||
setCurrentUploadedFiles([]);
|
||||
}
|
||||
}}
|
||||
style={{ marginBottom: 16, display: 'block' }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Radio value="upload" style={{ fontSize: 16 }}>
|
||||
📎 Загрузить документ
|
||||
</Radio>
|
||||
<Radio value="none" style={{ fontSize: 16 }}>
|
||||
❌ У меня нет этого документа
|
||||
</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
|
||||
{/* Загрузка файлов — показываем только если выбрано "Загрузить" */}
|
||||
{docChoice === 'upload' && (
|
||||
<Dragger
|
||||
multiple={true}
|
||||
beforeUpload={() => false}
|
||||
fileList={currentUploadedFiles}
|
||||
onChange={({ fileList }) => handleFilesChange(fileList)}
|
||||
onRemove={(file) => {
|
||||
setCurrentUploadedFiles(prev => prev.filter(f => f.uid !== file.uid));
|
||||
return true;
|
||||
}}
|
||||
accept={currentDoc.accept?.map((ext: string) => `.${ext}`).join(',') || '.pdf,.jpg,.jpeg,.png'}
|
||||
disabled={submitting}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined style={{ color: '#595959', fontSize: 32 }} />
|
||||
</p>
|
||||
<p className="ant-upload-text">
|
||||
Перетащите файлы или нажмите для выбора
|
||||
</p>
|
||||
<p className="ant-upload-hint">
|
||||
📌 Можно загрузить несколько файлов (все страницы документа)
|
||||
<br />
|
||||
Форматы: {currentDoc.accept?.join(', ') || 'PDF, JPG, PNG'} (до 20 МБ каждый)
|
||||
</p>
|
||||
</Dragger>
|
||||
)}
|
||||
|
||||
{/* Предупреждение если "нет документа" для важного */}
|
||||
{docChoice === 'none' && currentDoc.required && (
|
||||
<div style={{
|
||||
padding: 12,
|
||||
background: '#fff7e6',
|
||||
border: '1px solid #ffd591',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16
|
||||
}}>
|
||||
<Text type="warning">
|
||||
⚠️ Этот документ важен для рассмотрения заявки. Постарайтесь найти его позже.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопки */}
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button onClick={onPrev}>← Назад</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleDocContinue}
|
||||
disabled={!canContinue || submitting}
|
||||
loading={submitting}
|
||||
>
|
||||
{submitting ? 'Загружаем...' : 'Продолжить →'}
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{/* Уже загруженные */}
|
||||
{uploadedDocs.length > 0 && (
|
||||
<div style={{ marginTop: 24, padding: 12, background: '#f6ffed', borderRadius: 8 }}>
|
||||
<Text strong>✅ Загружено:</Text>
|
||||
<ul style={{ margin: '8px 0 0 20px', padding: 0 }}>
|
||||
{/* Убираем дубликаты и используем уникальные ключи */}
|
||||
{Array.from(new Set(uploadedDocs)).map((docId, idx) => {
|
||||
const doc = documentsRequired.find((d: any) => d.id === docId);
|
||||
return <li key={`${docId}_${idx}`}>{doc?.name || docId}</li>;
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ НОВЫЙ ФЛОУ: Все документы загружены */}
|
||||
{hasNewFlowDocs && allDocsProcessed && (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<Title level={4}>✅ Все документы загружены!</Title>
|
||||
<Paragraph type="secondary">
|
||||
Загружено: {uploadedDocs.length}, пропущено: {skippedDocs.length}
|
||||
</Paragraph>
|
||||
<Button type="primary" size="large" onClick={handleAllDocsComplete}>
|
||||
Продолжить →
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* СТАРЫЙ ФЛОУ: Ожидание визарда */}
|
||||
{!hasNewFlowDocs && isWaiting && (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<img
|
||||
src={AiWorkingIllustration}
|
||||
@@ -1306,7 +1707,8 @@ export default function StepWizardPlan({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isWaiting && plan && (
|
||||
{/* СТАРЫЙ ФЛОУ: Визард готов */}
|
||||
{!hasNewFlowDocs && !isWaiting && plan && (
|
||||
<div>
|
||||
<Title level={4} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<ThunderboltOutlined style={{ color: '#595959' }} /> План действий
|
||||
@@ -1316,41 +1718,60 @@ export default function StepWizardPlan({
|
||||
</Paragraph>
|
||||
|
||||
{documents.length > 0 && (
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: '#fff',
|
||||
border: '1px solid #d9d9d9',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
title="Документы, которые понадобятся"
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{documents.map((doc: any) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text strong>{doc.name}</Text>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
{doc.hints}
|
||||
</Paragraph>
|
||||
<>
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: '#fff',
|
||||
border: '1px solid #d9d9d9',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
title="Документы, которые понадобятся"
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{documents.map((doc: any) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text strong>{doc.name}</Text>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
{doc.hints}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Tag color={doc.required ? 'volcano' : 'geekblue'}>
|
||||
{doc.required ? 'Обязательно' : 'Опционально'}
|
||||
</Tag>
|
||||
</div>
|
||||
<Tag color={doc.required ? 'volcano' : 'geekblue'}>
|
||||
{doc.required ? 'Обязательно' : 'Опционально'}
|
||||
</Tag>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Блоки загрузки для каждого документа из плана */}
|
||||
<div style={{ marginTop: 16, marginBottom: 24 }}>
|
||||
<Text strong style={{ fontSize: 16, marginBottom: 16, display: 'block' }}>
|
||||
Загрузите документы
|
||||
</Text>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{documents.map((doc: any) => {
|
||||
const docKey = doc.id || doc.name || `doc_${Math.random()}`;
|
||||
return (
|
||||
<div key={docKey}>
|
||||
{renderDocumentBlocks(docKey, [doc])}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{renderQuestions()}
|
||||
@@ -1360,6 +1781,3 @@ export default function StepWizardPlan({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,18 @@ import './ClaimForm.css';
|
||||
|
||||
const { Step } = Steps;
|
||||
|
||||
/**
|
||||
* Генерация UUID v4
|
||||
* Формат: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
*/
|
||||
function generateUUIDv4(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
// Шаг 1: Phone
|
||||
phone?: string;
|
||||
@@ -633,12 +645,33 @@ export default function ClaimForm() {
|
||||
console.log('🔄 Загрузка черновика: session_id из черновика:', claim.session_token);
|
||||
console.log('🔄 Загрузка черновика: текущий sessionIdRef.current:', sessionIdRef.current);
|
||||
console.log('🔄 Загрузка черновика: текущий formData.session_id:', formData.session_id);
|
||||
const actualSessionId = sessionIdRef.current || formData.session_id;
|
||||
|
||||
// ✅ При загрузке черновика используем session_id из черновика (для продолжения работы с той же жалобой)
|
||||
// Если session_id из черновика есть - используем его, иначе текущий
|
||||
const actualSessionId = claim.session_token || sessionIdRef.current || formData.session_id;
|
||||
console.log('🔄 Загрузка черновика: ИСПОЛЬЗУЕМ session_id:', actualSessionId);
|
||||
|
||||
// ✅ Обновляем sessionIdRef на сессию из черновика (если есть)
|
||||
if (claim.session_token && claim.session_token !== sessionIdRef.current) {
|
||||
sessionIdRef.current = claim.session_token;
|
||||
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, а не старый из черновика
|
||||
session_id: actualSessionId, // ✅ Используем session_id из черновика (если есть) или текущий
|
||||
phone: body.phone || payload.phone || formData.phone,
|
||||
email: body.email || payload.email || formData.email,
|
||||
problemDescription: problemDescription || formData.problemDescription,
|
||||
@@ -661,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);
|
||||
@@ -703,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), чтобы получить план
|
||||
@@ -793,12 +836,27 @@ export default function ClaimForm() {
|
||||
console.log('🆕 Текущий currentStep:', currentStep);
|
||||
console.log('🆕 isPhoneVerified:', isPhoneVerified);
|
||||
|
||||
// ✅ Генерируем НОВУЮ сессию для новой жалобы
|
||||
const newSessionId = 'sess_' + generateUUIDv4();
|
||||
console.log('🆕 Генерируем новую сессию для жалобы:', newSessionId);
|
||||
console.log('🆕 Старая сессия:', sessionIdRef.current);
|
||||
|
||||
// ✅ Обновляем sessionIdRef на новую сессию
|
||||
sessionIdRef.current = newSessionId;
|
||||
|
||||
// ✅ session_token в localStorage остаётся ПРЕЖНИМ (авторизация сохраняется)
|
||||
const savedSessionToken = localStorage.getItem('session_token');
|
||||
console.log('🆕 session_token в localStorage (авторизация):', savedSessionToken || '(не сохранён)');
|
||||
console.log('🆕 Авторизация сохранена: unified_id=', formData.unified_id, 'phone=', formData.phone);
|
||||
|
||||
setShowDraftSelection(false);
|
||||
setSelectedDraftId(null);
|
||||
setHasDrafts(false); // ✅ Сбрасываем флаг наличия черновиков
|
||||
|
||||
// Очищаем данные формы, кроме телефона и session_id
|
||||
// ✅ Очищаем данные формы и устанавливаем НОВЫЙ session_id
|
||||
// unified_id, phone, contact_id остаются прежними - авторизация сохранена!
|
||||
updateFormData({
|
||||
session_id: newSessionId, // ✅ Новая сессия для новой жалобы
|
||||
claim_id: undefined,
|
||||
problemDescription: undefined,
|
||||
wizardPlan: undefined,
|
||||
@@ -809,6 +867,7 @@ export default function ClaimForm() {
|
||||
wizardUploads: undefined,
|
||||
wizardSkippedDocuments: undefined,
|
||||
eventType: undefined,
|
||||
// ✅ unified_id, phone, contact_id НЕ очищаем - авторизация сохраняется!
|
||||
});
|
||||
|
||||
console.log('🆕 Переходим к шагу описания проблемы (пропускаем Phone и DraftSelection)');
|
||||
@@ -819,7 +878,7 @@ export default function ClaimForm() {
|
||||
// Шаг 1 - Description (сюда переходим)
|
||||
// Шаг 2 - WizardPlan
|
||||
setCurrentStep(1); // ✅ Переходим к описанию (индекс 1)
|
||||
}, [updateFormData, currentStep, isPhoneVerified]);
|
||||
}, [updateFormData, currentStep, isPhoneVerified, formData.unified_id, formData.phone]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
try {
|
||||
|
||||
48
monitor_n8n_memory.sh
Executable file
48
monitor_n8n_memory.sh
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# Мониторинг использования памяти n8n
|
||||
# Проверяет использование памяти и отправляет алерт при превышении порога
|
||||
|
||||
N8N_CONTAINER="${N8N_CONTAINER:-n8n}" # Имя контейнера n8n
|
||||
THRESHOLD="${MEMORY_THRESHOLD:-80}" # Порог использования памяти (%)
|
||||
LOG_FILE="/var/www/fastuser/data/www/crm.clientright.ru/ticket_form/logs/n8n_memory_monitor.log"
|
||||
|
||||
# Создать директорию для логов если не существует
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Проверка существования контейнера
|
||||
if ! docker ps --format "{{.Names}}" | grep -q "^${N8N_CONTAINER}$"; then
|
||||
log "❌ Контейнер ${N8N_CONTAINER} не найден!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Получение использования памяти
|
||||
MEMORY_INFO=$(docker stats "$N8N_CONTAINER" --no-stream --format "{{.MemUsage}}|{{.MemPerc}}")
|
||||
MEMORY_USAGE=$(echo "$MEMORY_INFO" | cut -d'|' -f1)
|
||||
MEMORY_PERCENT=$(echo "$MEMORY_INFO" | cut -d'|' -f2 | sed 's/%//')
|
||||
|
||||
# Проверка порога
|
||||
if (( $(echo "$MEMORY_PERCENT > $THRESHOLD" | bc -l 2>/dev/null || echo "0") )); then
|
||||
log "⚠️ ВНИМАНИЕ: n8n использует ${MEMORY_PERCENT}% памяти (${MEMORY_USAGE})"
|
||||
log " Порог: ${THRESHOLD}%"
|
||||
|
||||
# Дополнительная информация
|
||||
log "📊 Дополнительная информация:"
|
||||
docker stats "$N8N_CONTAINER" --no-stream --format " CPU: {{.CPUPerc}} | Memory: {{.MemUsage}} | Network: {{.NetIO}}" | tee -a "$LOG_FILE"
|
||||
|
||||
# Проверка OOM Killer
|
||||
OOM_COUNT=$(dmesg | grep -i "out of memory" | grep -i "$N8N_CONTAINER" | wc -l)
|
||||
if [ "$OOM_COUNT" -gt 0 ]; then
|
||||
log "🚨 Обнаружены записи OOM Killer для n8n!"
|
||||
dmesg | grep -i "out of memory" | grep -i "$N8N_CONTAINER" | tail -5 | tee -a "$LOG_FILE"
|
||||
fi
|
||||
|
||||
exit 1
|
||||
else
|
||||
log "✅ Память в норме: ${MEMORY_PERCENT}% (${MEMORY_USAGE})"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
144
monitor_n8n_redis_trigger.py
Executable file
144
monitor_n8n_redis_trigger.py
Executable file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Мониторинг Redis Trigger в n8n
|
||||
Проверяет наличие подписчиков на канале ticket_form:description
|
||||
и отправляет алерт если подписчиков нет
|
||||
"""
|
||||
import redis
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import sys
|
||||
|
||||
# Настройки
|
||||
REDIS_HOST = "crm.clientright.ru"
|
||||
REDIS_PORT = 6379
|
||||
REDIS_PASSWORD = "CRM_Redis_Pass_2025_Secure!"
|
||||
CHANNEL = "ticket_form:description"
|
||||
CHECK_INTERVAL = 60 # Проверка каждую минуту
|
||||
ALERT_THRESHOLD = 0 # Если подписчиков меньше этого значения - алерт
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('/var/www/fastuser/data/www/crm.clientright.ru/ticket_form/logs/n8n_redis_monitor.log'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_subscribers():
|
||||
"""Проверка количества подписчиков на канале"""
|
||||
try:
|
||||
r = redis.Redis(
|
||||
host=REDIS_HOST,
|
||||
port=REDIS_PORT,
|
||||
password=REDIS_PASSWORD,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5
|
||||
)
|
||||
|
||||
# Проверка подключения
|
||||
r.ping()
|
||||
|
||||
# Проверка подписчиков
|
||||
numsub = r.pubsub_numsub(CHANNEL)
|
||||
subscribers = numsub[0][1] if numsub else 0
|
||||
|
||||
logger.info(f"📊 Канал {CHANNEL}: {subscribers} подписчиков")
|
||||
|
||||
if subscribers <= ALERT_THRESHOLD:
|
||||
logger.warning(
|
||||
f"⚠️ ВНИМАНИЕ: На канале {CHANNEL} нет подписчиков! "
|
||||
f"n8n workflow может быть неактивен или завис."
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except redis.ConnectionError as e:
|
||||
logger.error(f"❌ Ошибка подключения к Redis: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Неожиданная ошибка: {e}")
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
r.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def send_test_message():
|
||||
"""Отправка тестового сообщения для проверки"""
|
||||
try:
|
||||
r = redis.Redis(
|
||||
host=REDIS_HOST,
|
||||
port=REDIS_PORT,
|
||||
password=REDIS_PASSWORD,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5
|
||||
)
|
||||
|
||||
test_message = {
|
||||
"type": "test",
|
||||
"session_id": "monitor_test",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"message": "Health check from monitor script"
|
||||
}
|
||||
|
||||
import json
|
||||
subscribers = r.publish(CHANNEL, json.dumps(test_message))
|
||||
logger.info(f"📤 Тестовое сообщение отправлено. Получено подписчиками: {subscribers}")
|
||||
|
||||
r.close()
|
||||
return subscribers > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка отправки тестового сообщения: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Основной цикл мониторинга"""
|
||||
logger.info("🚀 Запуск мониторинга Redis Trigger для n8n")
|
||||
logger.info(f"📡 Канал: {CHANNEL}")
|
||||
logger.info(f"⏱️ Интервал проверки: {CHECK_INTERVAL} секунд")
|
||||
|
||||
consecutive_failures = 0
|
||||
max_failures = 3 # После 3 неудачных проверок подряд - критический алерт
|
||||
|
||||
while True:
|
||||
try:
|
||||
is_ok = check_subscribers()
|
||||
|
||||
if is_ok:
|
||||
consecutive_failures = 0
|
||||
else:
|
||||
consecutive_failures += 1
|
||||
|
||||
if consecutive_failures >= max_failures:
|
||||
logger.critical(
|
||||
f"🚨 КРИТИЧЕСКОЕ СОСТОЯНИЕ: "
|
||||
f"Канал {CHANNEL} не имеет подписчиков уже {consecutive_failures} проверок подряд! "
|
||||
f"Требуется перезапуск n8n workflow!"
|
||||
)
|
||||
# Можно добавить отправку уведомления (email, telegram, etc.)
|
||||
|
||||
time.sleep(CHECK_INTERVAL)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("⏹️ Остановка мониторинга по запросу пользователя")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка в цикле мониторинга: {e}")
|
||||
time.sleep(CHECK_INTERVAL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user