Compare commits

..

13 Commits

Author SHA1 Message Date
Fedor
73524465fd feat: Обновления после последнего коммита
Изменения в backend:
- Обновления в n8n_proxy.py
- Изменения в SMS API
- Обновления конфигурации
- Улучшения SMS сервиса

Изменения в frontend:
- Обновления Step1Phone компонента
- Изменения в Step3Payment
- Улучшения generateConfirmationFormHTML
- Обновления ClaimForm страницы
- Изменения в vite.config.ts

Статистика: +242 строки, -81 строка
2026-01-02 17:37:37 +03:00
Fedor
f7d27388a0 feat: Add SMS debug code modal for dev environment 2025-12-29 10:59:21 +03:00
Fedor
56516fdd7d Add docker-compose.dev.yml for dev environment (ports 5177, 8201) 2025-12-29 10:55:48 +03:00
AI Assistant
1a653f2154 docs: Move session log to root 2025-12-29 01:28:24 +03:00
AI Assistant
df8c93f46b Add session log 2025-12-29 2025-12-29 01:23:01 +03:00
AI Assistant
30774db18c Production fixes: n8n workflow auto-restart, user-friendly messages, fixed navigation buttons 2025-12-29 01:19:19 +03:00
AI Assistant
080e7ec105 feat: Получение cf_2624 из MySQL и блокировка полей при подтверждении данных
- Добавлен сервис CrmMySQLService для прямого подключения к MySQL CRM
- Обновлён метод get_draft() для получения cf_2624 напрямую из БД
- Реализована блокировка полей (readonly) при contact_data_confirmed = true
- Добавлен выбор банка для СБП выплат с динамической загрузкой из API
- Обновлена документация по работе с cf_2624 и MySQL
- Добавлен network_mode: host в docker-compose для доступа к MySQL
- Обновлены компоненты формы для поддержки блокировки полей
2025-12-04 12:22:23 +03:00
AI Assistant
64385c430d fix: Исправление логики загрузки документов и расчёта прогресса
- Исправлена ошибка порядка объявления allDocsProcessed (Cannot access before initialization)
- Исправлена логика поиска незагруженного документа: поиск с начала, если сохранённый индекс уже обработан
- Исправлен расчёт прогресса: теперь используется количество обработанных документов (uploadedDocs + skippedDocs), а не currentDocIndex
- Убрана синхронизация currentDocIndex из formData, которая перезаписывала правильный индекс
- Добавлена логика автоматического пропуска уже загруженных документов при открытии формы
- Добавлено подробное логирование для отладки состояния документов
- Исправлена логика определения завершённости: проверяется каждый документ из documentsRequired

Результат:
- Форма корректно показывает следующий незагруженный документ
- Прогресс правильно отображает процент обработанных документов (75% при 3 из 4)
- Система не требует повторной загрузки уже загруженных документов
2025-11-27 14:36:42 +03:00
AI Assistant
02689e65db 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
2025-11-26 19:54:51 +03:00
AI Assistant
1d6c9d1f52 feat: Add detailed logging for description endpoint and n8n workflow docs
Added:
- Detailed logging for /api/v1/claims/description endpoint
- Full event data logging for debugging
- Documentation for n8n workflow setup (N8N_DESCRIPTION_WORKFLOW.md)

The issue: Form hangs on recommendations step because n8n doesn't process description events.

Flow:
1. Frontend sends description to /api/v1/claims/description
2. Backend publishes to Redis channel ticket_form:description
3. Frontend subscribes to SSE /api/v1/events/{session_id} (listens to ocr_events:{session_id})
4. n8n must:
   - Subscribe to ticket_form:description channel
   - Process description and generate wizard_plan
   - Publish wizard_plan back to ocr_events:{session_id} channel

Files:
- backend/app/api/claims.py (enhanced logging)
- docs/N8N_DESCRIPTION_WORKFLOW.md (new documentation)
2025-11-25 17:42:31 +03:00
AI Assistant
521831be5e docs: Add n8n workflow documentation for form approval
Added documentation for n8n workflow setup:
- Redis channel subscription (clientright:webform:approve)
- Data structure description
- SQL script usage for marking forms as approved
- Verification steps

Files:
- docs/N8N_FORM_APPROVAL_WORKFLOW.md
2025-11-25 16:42:28 +03:00
AI Assistant
2fb0921e4c feat: Exclude approved forms from drafts list
Added filtering to exclude approved/confirmed forms from drafts list:
- Updated /drafts/list endpoint to filter out forms with status_code='approved' or is_confirmed=true
- Created SQL script for n8n to mark forms as approved after processing Redis channel data
- Forms marked as approved will no longer appear in drafts list

SQL script: SQL_MARK_FORM_APPROVED.sql
- Updates status_code to 'approved'
- Sets is_confirmed = true
- Uses claim_lookup CTE to find claim by id or payload->>'claim_id'

Files:
- backend/app/api/claims.py (updated drafts list queries)
- docs/SQL_MARK_FORM_APPROVED.sql (new SQL script for n8n)
2025-11-25 16:42:09 +03:00
AI Assistant
3d3f5995af fix: SMS code now properly included in Redis channel
SMS code is now successfully included in the Redis event data:
- Frontend sends SMS code in payload to backend
- Backend extracts SMS code from request body
- Backend includes SMS code in event_data before publishing to Redis
- Added comprehensive logging for debugging

The issue was that backend Docker image needed to be rebuilt after code changes.

Files:
- backend/app/api/claims.py (added detailed logging)
- frontend/src/components/form/StepClaimConfirmation.tsx (SMS code validation)
2025-11-25 15:55:06 +03:00
114 changed files with 25765 additions and 856 deletions

99
GIT_STATUS.md Normal file
View File

@@ -0,0 +1,99 @@
# 📊 Статус Git репозитория DEV
**Дата проверки:** 2 января 2025
---
## 📅 Последний коммит
**Дата:** 29 декабря 2025, 10:59:21
**Автор:** Fedor (fedor@clientright.ru)
**Сообщение:** `feat: Add SMS debug code modal for dev environment`
**Хеш:** `f7d27388a0b62380e4f1bdeba3c997f50ff10587`
---
## ⚠️ Незакоммиченные изменения
**Всего изменено файлов:** 9
### Backend (4 файла):
- `backend/app/api/n8n_proxy.py` - изменён
- `backend/app/api/sms.py` - изменён
- `backend/app/config.py` - изменён
- `backend/app/services/sms_service.py` - изменён
### Frontend (5 файлов):
- `frontend/src/components/form/Step1Phone.tsx` - изменён
- `frontend/src/components/form/Step3Payment.tsx` - изменён
- `frontend/src/components/form/generateConfirmationFormHTML.ts` - изменён
- `frontend/src/pages/ClaimForm.tsx` - изменён
- `frontend/vite.config.ts` - изменён
**Статистика изменений:**
- Добавлено: ~242 строки
- Удалено: ~81 строка
- Чистое изменение: +161 строка
---
## 📤 Статус с remote
**Ветка:** `main`
**Remote:** `origin/main`
**Статус:** Есть локальные изменения, которые не запушены в remote
**Не запушенные изменения:**
- `backend/app/api/n8n_proxy.py`
- `backend/app/api/sms.py`
- `backend/app/config.py`
- `backend/app/services/sms_service.py`
---
## 🔄 История коммитов (последние 5)
1. **2025-12-29** - `feat: Add SMS debug code modal for dev environment`
2. **2025-12-29** - `Add docker-compose.dev.yml for dev environment (ports 5177, 8201)`
3. **2025-12-29** - `docs: Move session log to root`
4. **2025-12-29** - `Add session log 2025-12-29`
5. **2025-12-29** - `Production fixes: n8n workflow auto-restart, user-friendly messages, fixed navigation buttons`
---
## 💡 Рекомендации
### 1. Закоммитить изменения
```bash
cd /var/www/fastuser/data/www/crm.clientright.ru/aiform_dev
# Посмотреть что изменилось
git diff
# Добавить все изменения
git add .
# Закоммитить
git commit -m "feat: Описание изменений"
```
### 2. Запушить в remote
```bash
# Отправить в dev репозиторий
git push origin main
# Или если remote называется aiform_dev
git push aiform_dev main
```
### 3. Перенести в PROD (если нужно)
После коммита и пуша, можно перенести изменения в PROD папку.
---
**Автор:** AI Assistant + Фёдор
**Дата:** 2 января 2025

View 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
View 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
✅ Тестирование успешно

View File

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

View File

@@ -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` — запрос на генерацию списка документов

View 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`

198
SESSION_LOG_2025-12-03.md Normal file
View File

@@ -0,0 +1,198 @@
# Лог сессии 2025-12-03
## Задача 1: Получение cf_2624 из MySQL при загрузке черновика
### Проблема
Пользователь заметил, что для `claim_id: "226564ce-d7cf-48ee-a820-690e8f5ec8e5"` доступно редактирование, хотя в CRM стоит галка "Данные подтверждены" (`cf_2624 = "1"`).
### Решение
Вместо передачи `cf_2624` через события Redis, реализован прямой SQL запрос к MySQL БД vtiger CRM при загрузке черновика.
## Изменения
### 1. Добавлены credentials для MySQL CRM в `config.py`
```python
# MySQL CRM (vtiger CRM)
mysql_crm_host: str = "localhost"
mysql_crm_port: int = 3306
mysql_crm_db: str = "ci20465_72new"
mysql_crm_user: str = "ci20465_72new"
mysql_crm_password: str = "EcY979Rn"
```
### 2. Создан сервис `CrmMySQLService`
**Файл:** `ticket_form/backend/app/services/crm_mysql_service.py`
- Подключение к MySQL БД vtiger CRM
- Методы: `fetch_one()`, `fetch_all()`, `execute()`
- Использует `aiomysql` для асинхронных запросов
### 3. Обновлён `main.py`
- Добавлено подключение к MySQL CRM при старте
- Добавлено закрытие соединения при остановке
### 4. Обновлён `claims.py` - метод `get_draft()`
**Эндпоинт:** `GET /api/v1/claims/drafts/{claim_id}`
**Изменения:**
- Убран webservice API (getchallenge → login → retrieve)
- Добавлен прямой SQL запрос к MySQL для получения `cf_2624`
- Получаем все данные контакта, включая `cf_2624`
- Добавлено логирование для отладки
**SQL запрос:**
```sql
SELECT
cd.contactid,
cd.firstname,
cd.lastname,
cd.email,
cd.mobile,
ccf.cf_2624 AS cf_2624
FROM vtiger_contactdetails cd
LEFT JOIN vtiger_contactscf ccf ON ccf.contactid = cd.contactid
LEFT JOIN vtiger_crmentity ce ON ce.crmid = cd.contactid
WHERE cd.contactid = %s
AND ce.deleted = 0
LIMIT 1
```
**Логика:**
- Если `cf_2624 = "1"``contact_data_confirmed = True`, `contact_data_can_edit = False`
- Если `cf_2624 = "0"` или `NULL``contact_data_confirmed = False`, `contact_data_can_edit = True`
### 5. Обновлены SQL файлы и документация
- `N8N_POSTGRESQL_GET_CONTACT_DATA.sql``N8N_MYSQL_GET_CONTACT_DATA.sql`
- Изменён синтаксис: `$1``?` (для n8n MySQL ноды)
- Обновлена документация `BACKEND_GET_CONTACT_CF_2624_FROM_POSTGRESQL.md`
- Создан `N8N_MYSQL_GET_CONTACT_DATA.md`
## Преимущества нового подхода
1.**Проще** - один SQL запрос вместо цепочки HTTP запросов
2.**Быстрее** - прямой запрос к БД
3.**Надёжнее** - не зависит от webservice API
4.**Актуальнее** - всегда получаем свежие данные из БД
## Проблемы и решения
### Проблема 1: Файл crm_mysql_service.py отсутствовал в контейнере
**Решение:** Пересобран контейнер через `docker-compose build ticket_form_backend`
### Проблема 2: MySQL не подключался из Docker контейнера
**Ошибка:** `Can't connect to MySQL server on 'localhost'`
**Решение:**
- Изменён `docker-compose.yml`: добавлен `network_mode: host`
- Изменён `config.py`: `mysql_crm_host = "localhost"` (в режиме host работает)
**Результат:** `✅ MySQL CRM DB connected: localhost:3306/ci20465_72new`
### Проблема 3: contact_data_confirmed возвращал None
**Причина:** Флаг не передавался в компонент `StepClaimConfirmation`
**Решение:**
- Добавлен prop `contact_data_confirmed` в `StepClaimConfirmation`
- Передача флага из `formData.contact_data_confirmed` в компонент
- Исправлена логика получения флага (приоритет: props > claimPlanData > false)
## Проверка
**MySQL запрос:**
```bash
mysql -h localhost -u ci20465_72new -p'EcY979Rn' ci20465_72new \
-e "SELECT contactid, cf_2624 FROM vtiger_contactscf WHERE contactid = '399542' LIMIT 1;"
```
**Результат:**
```
contactid cf_2624
399542 1
```
В MySQL `cf_2624 = "1"` для `contact_id = "399542"` - данные подтверждены.
**API тест:**
```bash
curl "http://localhost:8200/api/v1/claims/drafts/226564ce-d7cf-48ee-a820-690e8f5ec8e5"
```
**Результат:**
```json
{
"contact_data_confirmed": true,
"contact_data_can_edit": false,
"contact_data_from_crm": {
"contactid": "399542",
"cf_2624": "1",
...
}
}
```
## Текущий статус
- ✅ Код обновлён
- ✅ Бэкенд пересобран и перезапущен
- ✅ MySQL CRM подключён
- ✅ API возвращает правильные данные
- ✅ Фронтенд получает `contact_data_confirmed` и блокирует поля
- ✅ Поля формы блокируются (readonly) при `contact_data_confirmed = true`
## Блокировка полей
При `contact_data_confirmed = true` блокируются следующие поля:
- `firstname` (Имя)
- `lastname` (Фамилия)
- `secondname` / `middle_name` (Отчество)
- `inn` (ИНН)
- `birthday` (Дата рождения)
- `birthplace` / `birth_place` (Место рождения)
- `address` / `mailingstreet` (Адрес)
- `email` (E-mail)
Поля становятся `readonly` и отображаются с серым фоном.
---
## Задача 2: Выбор банка для СБП выплат
### Реализация
- Динамическая загрузка списка банков из API `http://212.193.27.93/api/payouts/dictionaries/nspk-banks`
- Добавлено в форму создания заявки (`Step3Payment.tsx`)
- Добавлено в форму редактирования (`generateConfirmationFormHTML.ts`)
- Используется `input` + `datalist` для автоподстановки
---
## Файлы изменены
### Backend:
- `ticket_form/backend/app/config.py` - добавлены credentials для MySQL CRM
- `ticket_form/backend/app/services/crm_mysql_service.py` - новый сервис
- `ticket_form/backend/app/main.py` - подключение к MySQL CRM
- `ticket_form/backend/app/api/claims.py` - прямой SQL запрос к MySQL
- `ticket_form/docker-compose.yml` - добавлен `network_mode: host`
### Frontend:
- `ticket_form/frontend/src/components/form/StepClaimConfirmation.tsx` - передача `contact_data_confirmed`
- `ticket_form/frontend/src/pages/ClaimForm.tsx` - передача флага в компонент
- `ticket_form/frontend/src/components/form/generateConfirmationFormHTML.ts` - блокировка полей
### Документация:
- `ticket_form/docs/N8N_MYSQL_GET_CONTACT_DATA.sql` - SQL запрос для n8n
- `ticket_form/docs/N8N_MYSQL_GET_CONTACT_DATA.md` - документация
- `ticket_form/docs/BACKEND_GET_CONTACT_CF_2624_FROM_POSTGRESQL.md` - обновлена документация
---
## Коммиты
1. `e1142315` - feat: Получение cf_2624 из MySQL при загрузке черновика
2. `a86120dd` - fix: передача contact_data_confirmed в StepClaimConfirmation для блокировки полей
---
**Время работы:** 2025-12-03 16:00-17:00
**Статус:** ✅ Завершено успешно

105
SESSION_LOG_2025-12-29.md Normal file
View File

@@ -0,0 +1,105 @@
# Лог сессии 29 декабря 2025
## Основные задачи
### 1. Оптимизация мониторинга n8n workflow ✅
**Проблема:** Постоянный мониторинг workflow засорял логи n8n экзекушенами.
**Решение:**
- Отключён постоянный мониторинг (`auto_restart_n8n_workflow.py`)
- Реализована проверка workflow "по требованию" — при отправке формы пользователем
- Если n8n не слушает Redis канал → сообщение буферизуется в Redis
- В фоне запускается перезапуск workflow через n8n API
- После перезапуска буферизованные сообщения отправляются повторно
**Изменённые файлы:**
- `backend/app/services/n8n_service.py` (новый) — работа с n8n API
- `backend/app/services/redis_service.py` — добавлены методы буферизации
- `backend/app/api/claims.py` — интеграция проверки/перезапуска workflow
- `backend/app/config.py` — добавлены настройки n8n_url, n8n_api_key
- `backend/.env` — добавлен N8N_API_KEY
### 2. Синхронизация dev и prod ✅
**Проблема:** Dev и prod сильно разошлись, в проде появлялись DEV-секции.
**Решение:**
- Скопированы файлы из работающего prod контейнера
- Удалены все "DEV MODE" секции из frontend компонентов
- Добавлен `terserOptions` в vite.config.ts для удаления console.log в проде
- Создан `frontend/Dockerfile.prod` для правильной сборки
**Изменённые frontend файлы:**
- `Step1Phone.tsx` — убраны DEV кнопки
- `Step3Payment.tsx` — убран DEBUG код SMS
- `StepDescription.tsx` — useMockWizard=false в проде
- `StepDocumentUpload.tsx` — убраны DEV секции
- `ClaimForm.tsx` — убран DebugPanel, исправлена навигация
- `vite.config.ts` — drop_console в production
### 3. Обработка out_of_scope событий ✅
**Проблема:** Когда n8n возвращает `out_of_scope`, фронтенд не обрабатывал это.
**Решение:**
- Добавлена обработка `event_type: "out_of_scope"` в `StepWizardPlan.tsx`
- Показывается карточка с сообщением и suggested_actions
- Кнопка "Связаться с поддержкой" отправляет webhook на n8n
- После отправки — редирект на главную страницу
**Webhook:** `https://n8n.clientright.pro/webhook/3ef6ff67-f3f2-418e-a300-86cb4659dbde`
### 4. Исправление навигации ✅
**Проблема:** Обе кнопки "← Изменить описание" и "Новое обращение" вели на начальный экран.
**Решение:**
- "← Изменить описание" → `onPrev()` → шаг описания проблемы
- "Новое обращение" → `window.location.reload()` → начальный экран
**Изменённый файл:** `ClaimForm.tsx` — исправлен `onPrev` для `StepWizardPlan`
### 5. User-friendly сообщения ✅
**Проблема:** Технические ошибки показывались пользователям.
**Решение:**
- Сообщение "План вопросов не получен..." → "Обработка занимает больше времени, чем обычно. Попробуйте ещё раз."
---
## Техническая информация
### N8N API
- **URL:** https://n8n.clientright.pro
- **Workflow ID:** b4K4u851b4JFivyD
- **Header:** `X-N8N-API-KEY` (не Bearer!)
### Redis буферизация
- **Ключ буфера:** `ticket_form:buffer:description`
- **TTL:** 24 часа
- **Методы:** `buffer_push()`, `buffer_get_all()`, `buffer_size()`
### Docker prod
- **Frontend:** `ticket_form_frontend_prod` на порту 5176
- **Backend:** `ticket_form_backend_prod` на порту 8200
- **Dockerfile:** `frontend/Dockerfile.prod` (multi-stage build)
---
## Git
Все изменения запушены в:
- **origin** (erv-platform): http://147.45.146.17:3002/negodiy/erv-platform.git
- **aiform_prod** (новый): http://147.45.146.17:3002/negodiy/aiform_prod.git
**Commit:** `Production fixes: n8n workflow auto-restart, user-friendly messages, fixed navigation buttons`
---
## TODO на потом
- [ ] Протестировать полный флоу с падением n8n workflow
- [ ] Добавить алерты если workflow не поднимается после нескольких попыток
- [ ] Логирование буферизованных сообщений для мониторинга

View File

@@ -1,7 +1,7 @@
"""
Claims API Routes - Обработка заявок
"""
from fastapi import APIRouter, HTTPException, Request, Query
from fastapi import APIRouter, HTTPException, Request, Query, BackgroundTasks
from typing import Optional, List
import httpx
from .models import (
@@ -15,6 +15,8 @@ import json
import logging
from ..services.redis_service import redis_service
from ..services.database import db
from ..services.crm_mysql_service import crm_mysql_service
from ..services.n8n_service import check_workflow_status, restart_workflow, MIN_RESTART_INTERVAL
from ..config import settings
router = APIRouter(prefix="/api/v1/claims", tags=["Claims"])
@@ -201,15 +203,19 @@ async def list_drafts(
c.updated_at
FROM clpr_claims c
WHERE c.unified_id = $1
-- ВРЕМЕННО: убираем все фильтры для диагностики
-- TODO: вернуть фильтры после выяснения проблемы
-- AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
ORDER BY c.updated_at DESC
LIMIT 20
"""
params = [unified_id]
logger.info(f"🔍 Searching by unified_id: {unified_id}")
elif phone:
# Fallback: ищем через clpr_user_accounts и clpr_users
# Fallback: ищем через clpr_user_accounts и clpr_users, ИЛИ напрямую по телефону в payload
# Поддерживаем разные форматы телефона: 71234543212, +71234543212, 81234543212
query = """
SELECT
SELECT DISTINCT
c.id,
c.payload->>'claim_id' as claim_id,
c.session_token,
@@ -219,19 +225,35 @@ async def list_drafts(
c.created_at,
c.updated_at
FROM clpr_claims c
WHERE c.unified_id = (
SELECT u.unified_id
FROM clpr_user_accounts ua
JOIN clpr_users u ON u.id = ua.user_id
WHERE ua.channel = 'web_form'
AND ua.channel_user_id = $1
LIMIT 1
)
WHERE c.channel = 'web_form'
AND (
-- Вариант 1: Поиск через unified_id (если есть запись в clpr_user_accounts)
c.unified_id = (
SELECT u.unified_id
FROM clpr_user_accounts ua
JOIN clpr_users u ON u.id = ua.user_id
WHERE ua.channel = 'web_form'
AND (ua.channel_user_id = $1 OR ua.channel_user_id = $2 OR ua.channel_user_id = $3)
LIMIT 1
)
-- Вариант 2: Прямой поиск по телефону в payload (в разных форматах)
OR c.payload->>'phone' = $1
OR c.payload->>'phone' = $2
OR c.payload->>'phone' = $3
)
AND (c.status_code != 'approved' OR c.status_code IS NULL)
AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
ORDER BY c.updated_at DESC
LIMIT 20
"""
params = [phone]
logger.info(f"🔍 Searching by phone (fallback): {phone}")
# Подготавливаем варианты телефона для поиска
phone_variants = [
phone, # Оригинальный формат
f"+{phone}", # С плюсом
phone.replace('7', '8', 1) if phone.startswith('7') else phone # С 8 вместо 7
]
params = phone_variants
logger.info(f"🔍 Searching by phone (fallback): {phone}, variants: {phone_variants}")
elif session_id:
# Fallback: поиск по session_token
query = """
@@ -246,6 +268,8 @@ async def list_drafts(
c.updated_at
FROM clpr_claims c
WHERE c.session_token = $1
AND (c.status_code != 'approved' OR c.status_code IS NULL)
AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
ORDER BY c.updated_at DESC
LIMIT 20
"""
@@ -258,9 +282,22 @@ async def list_drafts(
# Простой тест: проверяем, что unified_id вообще есть в базе
test_count = 0
test_count_null = 0
test_count_approved = 0
test_count_confirmed = 0
if unified_id:
try:
# Все заявления с этим unified_id
test_count = await db.fetch_val("SELECT COUNT(*) FROM clpr_claims WHERE unified_id = $1", unified_id)
# Заявления со статусом approved
test_count_approved = await db.fetch_val("""
SELECT COUNT(*) FROM clpr_claims
WHERE unified_id = $1 AND status_code = 'approved'
""", unified_id)
# Заявления с is_confirmed = true
test_count_confirmed = await db.fetch_val("""
SELECT COUNT(*) FROM clpr_claims
WHERE unified_id = $1 AND is_confirmed = true
""", unified_id)
# Также проверяем, сколько записей с NULL unified_id для этого пользователя (через phone)
if phone:
test_count_null = await db.fetch_val("""
@@ -269,7 +306,7 @@ async def list_drafts(
AND c.channel = 'web_form'
AND c.payload->>'phone' = $1
""", phone)
logger.info(f"🔍 Test COUNT: unified_id={unified_id}{test_count} records")
logger.info(f"🔍 Test COUNT: unified_id={unified_id}{test_count} total, {test_count_approved} approved, {test_count_confirmed} confirmed")
if test_count_null > 0:
logger.warning(f"⚠️ Found {test_count_null} records with NULL unified_id for phone={phone}")
except Exception as e:
@@ -284,10 +321,25 @@ async def list_drafts(
logger.info(f"🔍 Test COUNT result: {test_count}")
logger.info(f"🔍 Rows found: {len(rows)}")
# Если заявления есть, но не возвращаются - проверяем статусы
if len(rows) == 0 and test_count > 0 and unified_id:
logger.warning(f"⚠️ Заявления есть (test_count={test_count}), но запрос вернул 0 строк!")
try:
all_statuses = await db.fetch_all("""
SELECT status_code, is_confirmed, channel, id
FROM clpr_claims
WHERE unified_id = $1
""", unified_id)
logger.warning(f"⚠️ Все заявления для unified_id: {[dict(r) for r in all_statuses]}")
except Exception as e:
logger.error(f"❌ Ошибка при проверке статусов: {e}")
# ВРЕМЕННО: возвращаем тестовые данные для отладки
debug_info = {
"unified_id": unified_id,
"test_count": test_count,
"test_count_approved": test_count_approved or 0,
"test_count_confirmed": test_count_confirmed or 0,
"test_count_null": test_count_null,
"rows_found": len(rows),
"query": query[:200] if len(query) > 200 else query,
@@ -310,18 +362,68 @@ async def list_drafts(
else:
payload = {}
# Извлекаем данные из ai_analysis или wizard_plan
ai_analysis = payload.get('ai_analysis') or {}
wizard_plan = payload.get('wizard_plan') or {}
# Краткое описание проблемы (заголовок)
problem_title = ai_analysis.get('problem') or payload.get('problem') or None
# Категория проблемы
category = ai_analysis.get('category') or wizard_plan.get('category') or None
# Подробное описание (для превью)
problem_text = payload.get('problem_description', '')
# Считаем документы
documents_meta = payload.get('documents_meta') or []
documents_required = payload.get('documents_required') or []
# Считаем загруженные (уникальные по field_label)
uploaded_labels = set()
for doc in documents_meta:
label = doc.get('field_label') or doc.get('field_name')
if label:
uploaded_labels.add(label)
documents_uploaded = len(uploaded_labels)
documents_total = len(documents_required) if documents_required else 0
# Формируем список документов со статусами
documents_list = []
for doc_req in documents_required:
doc_name = doc_req.get('name', 'Документ')
doc_id = doc_req.get('id', '')
is_required = doc_req.get('required', False)
# Проверяем загружен ли (по name или id)
is_uploaded = doc_name in uploaded_labels or doc_id in uploaded_labels
documents_list.append({
"name": doc_name,
"required": is_required,
"uploaded": is_uploaded,
})
drafts.append({
"id": str(row['id']),
"claim_id": row.get('claim_id'),
"session_token": row.get('session_token'),
"status_code": row.get('status_code'),
"channel": row.get('channel'), # Добавляем канал в ответ
"channel": row.get('channel'),
"created_at": row['created_at'].isoformat() if row.get('created_at') else None,
"updated_at": row['updated_at'].isoformat() if row.get('updated_at') else None,
"problem_description": payload.get('problem_description', '')[:100] if payload.get('problem_description') else None,
# Заголовок - краткое описание проблемы из AI
"problem_title": problem_title[:150] if problem_title else None,
# Полное описание
"problem_description": problem_text[:500] if problem_text else None,
"category": category,
"wizard_plan": payload.get('wizard_plan') is not None,
"wizard_answers": payload.get('answers') is not None,
"has_documents": len(payload.get('documents_meta', [])) > 0 if payload.get('documents_meta') else False,
"has_documents": documents_uploaded > 0,
# Прогресс документов
"documents_total": documents_total,
"documents_uploaded": documents_uploaded,
"documents_skipped": 0, # TODO: считать пропущенные
"documents_list": documents_list, # Список со статусами
})
return {
@@ -394,18 +496,120 @@ 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 для примера
# ✅ Проверяем флаг подтверждения данных контакта из CRM (поле cf_2624)
# Простой способ: делаем прямой SQL запрос к БД (таблицы vtiger_*)
# ПРИМЕЧАНИЕ: Если таблицы vtiger_* находятся в MySQL (а не PostgreSQL),
# нужно использовать отдельный connection через policy_service или создать новый MySQL connection
unified_id = row.get('unified_id')
contact_data_confirmed = False
contact_data_can_edit = True
contact_data_from_crm = None
# Получаем contact_id из payload
contact_id = payload.get('contact_id') if isinstance(payload, dict) else None
# Преобразуем contact_id в строку, если он есть
if contact_id:
contact_id = str(contact_id).strip()
logger.info(f"🔍 Получен contact_id из черновика: {contact_id} (type: {type(contact_id)})")
if contact_id:
try:
# ✅ Прямой SQL запрос к MySQL для получения cf_2624
# Таблицы vtiger_* находятся в MySQL БД
contact_query = """
SELECT
cd.contactid,
cd.firstname,
cd.lastname,
cd.email,
cd.mobile,
cd.phone,
cs.birthday,
ca.mailingstreet,
ca.mailingcity,
ca.mailingstate,
ca.mailingzip,
ca.mailingcountry,
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,
ccf.cf_2624 AS cf_2624
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 = %s
AND ce.deleted = 0
LIMIT 1
"""
contact_row = await crm_mysql_service.fetch_one(contact_query, contact_id)
if contact_row:
# Формируем объект с данными контакта
contact_data_from_crm = {
"contactid": contact_row.get("contactid"),
"firstname": contact_row.get("firstname"),
"lastname": contact_row.get("lastname"),
"email": contact_row.get("email"),
"mobile": contact_row.get("mobile"),
"phone": contact_row.get("phone"),
"birthday": contact_row.get("birthday"),
"mailingstreet": contact_row.get("mailingstreet"),
"mailingcity": contact_row.get("mailingcity"),
"mailingstate": contact_row.get("mailingstate"),
"mailingzip": contact_row.get("mailingzip"),
"mailingcountry": contact_row.get("mailingcountry"),
"cf_1157": contact_row.get("middle_name"), # Отчество
"cf_1263": contact_row.get("birthplace"), # Место рождения
"cf_1257": contact_row.get("inn"), # ИНН
"cf_1849": contact_row.get("requisites"), # Реквизиты
"cf_1580": contact_row.get("code"), # Код
"cf_1706": contact_row.get("sms"), # SMS
"cf_2624": contact_row.get("cf_2624") or "0" # ✅ Данные подтверждены
}
# ✅ Проверяем кастомное поле "Данные подтверждены" (cf_2624)
confirmed_field = contact_data_from_crm.get("cf_2624", "0")
contact_data_confirmed = confirmed_field == "1" or confirmed_field == "true" or confirmed_field is True
contact_data_can_edit = not contact_data_confirmed
logger.info(
f"🔒 Статус данных контакта из MySQL CRM: confirmed={contact_data_confirmed}, "
f"field_value={confirmed_field}, contact_id={contact_id}"
)
else:
logger.warning(f"⚠️ Контакт не найден в MySQL CRM: contact_id={contact_id}")
except Exception as e:
logger.warning(f"⚠️ Не удалось загрузить данные контакта из MySQL CRM: {str(e)}")
return {
"success": True,
"claim": {
"id": str(row['id']),
"claim_id": final_claim_id, # ✅ Используем claim_id из payload, если его нет в row
"claim_id": final_claim_id,
"session_token": row.get('session_token'),
"status_code": row.get('status_code'),
"channel": row.get('channel'), # ✅ Добавляем channel для отладки
"channel": row.get('channel'),
"created_at": row['created_at'].isoformat() if row.get('created_at') else None,
"updated_at": row['updated_at'].isoformat() if row.get('updated_at') else None,
"payload": payload
}
},
# ✅ Флаги подтверждения данных контакта (из CRM поля cf_2624)
"contact_data_confirmed": contact_data_confirmed,
"contact_data_can_edit": contact_data_can_edit,
"contact_data_from_crm": contact_data_from_crm # Данные из CRM (всегда загружаем, если есть contact_id)
}
except HTTPException:
@@ -420,14 +624,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
"""
@@ -472,6 +675,10 @@ async def publish_form_approval(request: Request):
"body_type": type(body).__name__,
"sms_code_in_body": "sms_code" in body if isinstance(body, dict) else False,
"sms_code_value": body.get("sms_code", "NOT_FOUND") if isinstance(body, dict) else "NOT_DICT",
"contact_data_confirmed_in_body": "contact_data_confirmed" in body if isinstance(body, dict) else False,
"cf_2624_in_body": "cf_2624" in body if isinstance(body, dict) else False,
"bank_id_in_body": "bank_id" in body if isinstance(body, dict) else False,
"bank_name_in_body": "bank_name" in body if isinstance(body, dict) else False,
},
)
@@ -497,6 +704,27 @@ async def publish_form_approval(request: Request):
import time
idempotency_key = f"{claim_id}_{int(time.time() * 1000)}_{body.get('user_id', 'unknown')}"
# ✅ Получаем флаг подтверждения данных контакта и данные банка
contact_data_confirmed = body.get("contact_data_confirmed", False)
cf_2624 = body.get("cf_2624", "0")
bank_id = body.get("bank_id", "")
bank_name = body.get("bank_name", "")
# Логируем полученные значения для отладки
logger.info(
f"📥 Извлеченные дополнительные поля",
extra={
"contact_data_confirmed": contact_data_confirmed,
"cf_2624": cf_2624,
"bank_id": bank_id,
"bank_name": bank_name,
"has_contact_data_confirmed": "contact_data_confirmed" in body,
"has_cf_2624": "cf_2624" in body,
"has_bank_id": "bank_id" in body,
"has_bank_name": "bank_name" in body,
},
)
# Формируем событие для Redis
event_data = {
"event_type": "form_approve",
@@ -511,6 +739,14 @@ async def publish_form_approval(request: Request):
"idempotency_key": idempotency_key, # Для защиты от дублей в RabbitMQ
"timestamp": datetime.utcnow().isoformat(),
# ✅ Флаг редактирования перс данных (cf_2624)
"contact_data_confirmed": contact_data_confirmed,
"cf_2624": cf_2624, # Значение для CRM (1 = подтверждено, 0 = не подтверждено)
# ✅ Данные банка для СБП выплаты
"bank_id": bank_id,
"bank_name": bank_name,
# Данные формы подтверждения
"form_data": body.get("form_data", {}),
"user": body.get("user", {}),
@@ -524,17 +760,33 @@ async def publish_form_approval(request: Request):
# Публикуем в Redis канал clientright:webform:approve
channel = "clientright:webform:approve"
event_json = json.dumps(event_data, ensure_ascii=False)
# Логируем перед публикацией
# Логируем event_data перед сериализацией
logger.info(
f"📢 Публикуем событие в Redis канал {channel}",
f"📢 Формируем событие для Redis канала {channel}",
extra={
"claim_id": claim_id,
"idempotency_key": idempotency_key,
"sms_code": sms_code if sms_code else "(пусто)",
"has_sms_code": bool(sms_code),
"sms_code_in_event_data": "sms_code" in event_data,
"event_data_sms_code_value": event_data.get("sms_code", "NOT_FOUND"),
"event_data_keys": list(event_data.keys()),
"contact_data_confirmed_in_event": "contact_data_confirmed" in event_data,
"cf_2624_in_event": "cf_2624" in event_data,
"bank_id_in_event": "bank_id" in event_data,
"bank_name_in_event": "bank_name" in event_data,
},
)
event_json = json.dumps(event_data, ensure_ascii=False)
# Логируем после сериализации
logger.info(
f"📢 JSON для публикации готов",
extra={
"json_length": len(event_json),
"sms_code_in_json": '"sms_code"' in event_json,
},
)
@@ -656,8 +908,55 @@ async def load_wizard_data(claim_id: str):
raise HTTPException(status_code=500, detail=f"Ошибка при загрузке данных визарда: {str(e)}")
async def _check_and_restart_workflow_if_needed(channel: str):
"""
Проверяет и перезапускает workflow если нужно (в фоне)
Защита от частых перезапусков через Redis lock
"""
try:
# Проверяем lock - если недавно перезапускали, пропускаем
lock_key = f"workflow_restart_lock:{channel}"
lock_value = await redis_service.get(lock_key)
if lock_value:
logger.info(f"⏸️ Workflow недавно перезапускался, пропускаем (lock active)")
return
# Проверяем статус workflow
workflow_data = await check_workflow_status()
if workflow_data:
is_active = workflow_data.get("active", False)
if not is_active:
logger.warning(f"⚠️ Workflow НЕ активен! Активирую и перезапускаю...")
# Workflow выключен — нужно его ВКЛЮЧИТЬ
else:
logger.info(
f"⚠️ Workflow активен, но нет подписчиков. Перезапускаю workflow..."
)
# Устанавливаем lock на MIN_RESTART_INTERVAL секунд
await redis_service.set(lock_key, "1", expire=MIN_RESTART_INTERVAL)
# Перезапускаем
success = await restart_workflow()
if success:
logger.info("✅ Workflow успешно перезапущен")
else:
logger.error("Не удалось перезапустить workflow")
else:
logger.warning("⚠️ Не удалось проверить статус workflow, пропускаем перезапуск")
except Exception as e:
logger.exception(f"❌ Ошибка при проверке/перезапуске workflow: {e}")
@router.post("/description")
async def publish_ticket_form_description(payload: TicketFormDescriptionRequest):
async def publish_ticket_form_description(
payload: TicketFormDescriptionRequest,
background_tasks: BackgroundTasks
):
"""
Публикует свободное описание проблемы в Redis канал ticket_form:description
(слушается воркфлоу в n8n)
@@ -670,24 +969,99 @@ 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(),
}
event_json = json.dumps(event, ensure_ascii=False)
logger.info(
"📝 TicketForm description received",
extra={"session_id": payload.session_id, "claim_id": payload.claim_id or "not_set"},
extra={
"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,
},
)
await redis_service.publish(channel, json.dumps(event, ensure_ascii=False))
logger.info(
"📡 TicketForm description published",
extra={"channel": channel, "session_id": payload.session_id},
"📡 Publishing to Redis channel",
extra={
"channel": channel,
"event_type": event["type"],
"event_keys": list(event.keys()),
"json_length": len(event_json),
},
)
return {
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_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"Saving message to buffer and restarting workflow..."
)
# Сохраняем сообщение в буфер для последующей отправки
buffer_message = {
"session_id": payload.session_id,
"claim_id": payload.claim_id,
"event": event,
"timestamp": datetime.utcnow().isoformat(),
}
await redis_service.buffer_push("description", buffer_message)
logger.info(f"💾 Сообщение сохранено в буфер: session_id={payload.session_id}")
# Запускаем проверку и перезапуск workflow в фоне
background_tasks.add_task(_check_and_restart_workflow_if_needed, channel)
# Дополнительная проверка: логируем полный event для отладки
logger.debug(
"🔍 Full event data published",
extra={
"channel": channel,
"event": event,
},
)
# Формируем ответ с информацией о подписчиках
response_data = {
"success": True,
"channel": channel,
"subscribers_count": subscribers_count,
"event": event,
}
# Если подписчиков нет - сообщаем что обработка займёт больше времени
if subscribers_count == 0:
buffer_size = await redis_service.buffer_size("description")
response_data["warning"] = (
"Обработка вашего обращения займёт немного больше времени. "
"Идёт автоматическое восстановление системы. "
"Ваше сообщение сохранено и будет обработано в ближайшее время."
)
response_data["workflow_recovering"] = True
response_data["message_buffered"] = True
response_data["buffer_size"] = buffer_size
return response_data
except Exception as e:
logger.exception("❌ Failed to publish ticket form description")
raise HTTPException(

View File

@@ -0,0 +1,809 @@
"""
Documents API Routes - Загрузка и обработка документов
Новый флоу: поэкранная загрузка документов
"""
from fastapi import APIRouter, HTTPException, UploadFile, File, Form, Request
from typing import Optional, List
import httpx
import json
import uuid
import hashlib
from datetime import datetime
import logging
from ..services.redis_service import redis_service
from ..services.database import db
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"
def get_client_ip(request: Request) -> str:
"""Получить реальный IP клиента (с учётом proxy заголовков)"""
# Сначала проверяем заголовки от reverse proxy
forwarded_for = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
real_ip = request.headers.get("x-real-ip", "").strip()
# X-Forwarded-For имеет приоритет
if forwarded_for and forwarded_for not in ("127.0.0.1", "192.168.0.1", "::1"):
return forwarded_for
if real_ip and real_ip not in ("127.0.0.1", "192.168.0.1", "::1"):
return real_ip
# Fallback на request.client
return request.client.host if request.client else "unknown"
@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),
group_index: 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 = get_client_ip(request)
# Формируем данные в формате совместимом с существующим 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_* для совместимости
# ✅ Используем group_index для правильной индексации (по умолчанию 0)
"uploads_field_names[{idx}]".format(idx=group_index or "0"): document_type,
"uploads_field_labels[{idx}]".format(idx=group_index or "0"): document_name or document_type,
"uploads_descriptions[{idx}]".format(idx=group_index or "0"): document_description or "",
}
# ✅ Добавляем group_index в данные формы
if group_index:
form_data["group_index"] = group_index
logger.info(f"📋 group_index передан в n8n: {group_index}")
# Файл для multipart (ключ uploads[group_index] для совместимости)
idx = group_index or "0"
files = {
f"uploads[{idx}]": (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 = get_client_ip(request)
# Генерируем 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)}",
)
async def skip_document(
request: Request,
claim_id: str = Form(...),
session_id: str = Form(...),
document_type: str = Form(...),
document_name: Optional[str] = Form(None),
group_index: Optional[str] = Form(None),
unified_id: Optional[str] = Form(None),
contact_id: Optional[str] = Form(None),
phone: Optional[str] = Form(None),
):
"""
Пропуск документа (пользователь указал, что документа нет).
Отправляет событие в n8n на тот же webhook, что и загрузка файлов,
но с флагом skipped=true для обработки пропуска.
"""
try:
logger.info(
"⏭️ Document skip received",
extra={
"claim_id": claim_id,
"session_id": session_id,
"document_type": document_type,
"group_index": group_index,
},
)
# Получаем IP клиента
client_ip = get_client_ip(request)
# Формируем данные в формате совместимом с существующим n8n воркфлоу
form_data = {
# Основные идентификаторы
"form_id": "ticket_form",
"stage": "document_skip",
"session_id": session_id,
"claim_id": claim_id,
"client_ip": client_ip,
# Идентификаторы пользователя
"unified_id": unified_id or "",
"contact_id": contact_id or "",
"phone": phone or "",
# Информация о документе
"document_type": document_type,
"document_name": document_name or document_type,
"skipped": "true", # ✅ Флаг пропуска документа
"action": "skip", # ✅ Действие: пропуск
"skip_timestamp": datetime.utcnow().isoformat(),
# Формат uploads_* для совместимости (без файлов)
# ✅ Используем group_index для правильной индексации (по умолчанию 0)
"uploads_field_names[{idx}]".format(idx=group_index or "0"): document_type,
"uploads_field_labels[{idx}]".format(idx=group_index or "0"): document_name or document_type,
"uploads_descriptions[{idx}]".format(idx=group_index or "0"): "",
"files_count": "0", # ✅ Нет файлов
}
# ✅ Добавляем group_index в данные формы
if group_index:
form_data["group_index"] = group_index
logger.info(f"📋 group_index передан в n8n: {group_index}")
# Отправляем в n8n на тот же webhook (без файлов)
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
N8N_DOCUMENT_UPLOAD_WEBHOOK,
data=form_data,
)
response_text = response.text or ""
if response.status_code == 200:
logger.info(
"✅ Document skip sent to n8n",
extra={
"claim_id": claim_id,
"document_type": document_type,
"response_preview": response_text[:200],
},
)
# Парсим ответ от n8n
try:
n8n_response = json.loads(response_text)
except json.JSONDecodeError:
n8n_response = {"raw": response_text}
# Публикуем событие в Redis для фронтенда
event_data = {
"event_type": "document_skipped",
"status": "skipped",
"claim_id": claim_id,
"session_id": session_id,
"document_type": document_type,
"document_name": document_name or document_type,
"timestamp": datetime.utcnow().isoformat(),
}
await redis_service.publish(
f"ocr_events:{session_id}",
json.dumps(event_data, ensure_ascii=False)
)
return {
"success": True,
"document_type": document_type,
"status": "skipped",
"message": "Документ пропущен и сохранён",
"n8n_response": n8n_response,
}
else:
logger.error(
"❌ n8n document skip error",
extra={
"status_code": response.status_code,
"body": response_text[:500],
},
)
raise HTTPException(
status_code=response.status_code,
detail=f"Ошибка n8n: {response_text}",
)
except httpx.TimeoutException:
logger.error("⏱️ n8n document skip timeout")
raise HTTPException(status_code=504, detail="Таймаут отправки пропуска документа")
except HTTPException:
raise
except Exception as e:
logger.exception("❌ Document skip error")
raise HTTPException(status_code=500, detail=f"Ошибка пропуска документа: {str(e)}")
@router.post("/generate-list")
async def generate_documents_list(request: Request):
"""
Запрос на генерацию списка документов для проблемы.
Принимает описание проблемы, отправляет в 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)}",
)
def compute_documents_hash(doc_ids: List[str]) -> str:
"""
Вычисляет hash от списка document_id для проверки актуальности черновика.
Должен совпадать с JS алгоритмом в n8n build_form_draft.
"""
import ctypes
sorted_ids = sorted([d for d in doc_ids if d])
hash_input = ','.join(sorted_ids)
# djb2 hash — эмуляция JS поведения
# В JS: (hash << 5) возвращает 32-битный signed int
hash_val = 5381
for char in hash_input:
# ctypes.c_int32 эмулирует JS 32-битный signed int при сдвиге
shifted = ctypes.c_int32(hash_val << 5).value
hash_val = shifted + hash_val + ord(char)
# В JS: Math.abs(hash).toString(16).padStart(8, '0')
return format(abs(hash_val), 'x').zfill(8)
@router.post("/check-ocr-status")
async def check_ocr_status(request: Request):
"""
Проверка статуса OCR обработки документов.
Вызывается при нажатии "Продолжить" после загрузки документов.
Логика:
1. Проверяем наличие form_draft в payload
2. Если черновик есть и documents_hash совпадает — возвращаем его
3. Если черновика нет или он устарел — запускаем RAG workflow
"""
try:
body = await request.json()
claim_id = body.get("claim_id")
session_id = body.get("session_id")
force_refresh = body.get("force_refresh", False) # Принудительное обновление
if not claim_id or not session_id:
raise HTTPException(
status_code=400,
detail="claim_id и session_id обязательны",
)
logger.info(
"🔍 Check OCR status request",
extra={
"claim_id": claim_id,
"session_id": session_id,
"force_refresh": force_refresh,
},
)
# =====================================================
# ШАГ 1: Проверяем наличие черновика в БД
# =====================================================
if not force_refresh:
try:
# Получаем form_draft и список документов
claim_data = await db.fetch_one("""
SELECT
c.payload->'form_draft' AS form_draft,
(
SELECT array_agg(cd.id::text ORDER BY cd.id)
FROM clpr_claim_documents cd
WHERE cd.claim_id::uuid = c.id
) AS document_ids
FROM clpr_claims c
WHERE c.id = $1::uuid
""", claim_id)
if claim_data and claim_data.get('form_draft'):
form_draft = claim_data['form_draft']
# Если form_draft — строка, парсим JSON
if isinstance(form_draft, str):
form_draft = json.loads(form_draft)
saved_hash = form_draft.get('documents_hash', '')
document_ids = claim_data.get('document_ids') or []
current_hash = compute_documents_hash(document_ids)
logger.info(
"📋 Draft check",
extra={
"saved_hash": saved_hash,
"current_hash": current_hash,
"docs_count": len(document_ids),
},
)
# ✅ Черновик актуален — возвращаем его!
if saved_hash == current_hash:
logger.info(
"✅ Using cached form_draft",
extra={
"claim_id": claim_id,
"hash": saved_hash,
},
)
# Публикуем событие что данные готовы
event_data = {
"event_type": "form_draft_ready",
"status": "ready",
"message": "Черновик формы готов",
"claim_id": claim_id,
"session_id": session_id,
"form_draft": form_draft,
"from_cache": True,
"timestamp": datetime.utcnow().isoformat(),
}
await redis_service.publish(
f"ocr_events:{session_id}",
json.dumps(event_data, ensure_ascii=False)
)
return {
"success": True,
"status": "ready",
"message": "Черновик формы готов (из кэша)",
"from_cache": True,
"form_draft": form_draft,
"listen_channel": f"ocr_events:{session_id}",
}
else:
logger.info(
"🔄 Draft outdated, running RAG",
extra={
"reason": "documents_hash mismatch",
"saved_hash": saved_hash,
"current_hash": current_hash,
},
)
except Exception as e:
logger.warning(f"⚠️ Draft check failed: {e}, proceeding with RAG")
# =====================================================
# ШАГ 2: Черновика нет или устарел — запускаем RAG
# =====================================================
event_data = {
"claim_id": claim_id,
"session_token": session_id,
"timestamp": datetime.utcnow().isoformat(),
}
channel = "clpr:check:ocr_status"
subscribers = await redis_service.publish(
channel,
json.dumps(event_data, ensure_ascii=False)
)
logger.info(
"✅ OCR status check published (running RAG)",
extra={
"channel": channel,
"subscribers": subscribers,
"claim_id": claim_id,
},
)
return {
"success": True,
"status": "processing",
"message": "Запрос на обработку документов отправлен",
"from_cache": False,
"channel": channel,
"listen_channel": f"ocr_events:{session_id}",
}
except HTTPException:
raise
except Exception as e:
logger.exception("❌ Error checking OCR status")
raise HTTPException(
status_code=500,
detail=f"Ошибка проверки статуса: {str(e)}",
)
router.add_api_route("/skip", skip_document, methods=["POST"], tags=["Documents"])

View File

@@ -13,7 +13,7 @@ import logging
logger = logging.getLogger(__name__)
router = APIRouter()
router = APIRouter(prefix="/api/v1", tags=["Events"])
class EventPublish(BaseModel):
@@ -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 = {
@@ -207,15 +215,114 @@ async def stream_events(task_id: str):
except Exception as e:
logger.error(f"❌ Error loading wizard data from PostgreSQL: {e}")
# ✅ Обработка ocr_status ready: загружаем form_draft из PostgreSQL
if actual_event.get('event_type') == 'ocr_status' and actual_event.get('status') == 'ready':
claim_id = actual_event.get('claim_id') or actual_event.get('data', {}).get('claim_id')
# ✅ Получаем cf_2624 из события (Данные подтверждены)
cf_2624 = actual_event.get('cf_2624')
if claim_id:
logger.info(f"🔍 OCR ready event received, loading form_draft for claim_id={claim_id}, cf_2624={cf_2624}")
try:
# ✅ Если есть cf_2624 в событии - сохраняем в черновик
if cf_2624 is not None:
try:
update_query = """
UPDATE clpr_claims
SET payload = jsonb_set(
COALESCE(payload, '{}'::jsonb),
'{cf_2624}',
$1::jsonb
)
WHERE id::text = $2 OR payload->>'claim_id' = $2
RETURNING id;
"""
await db.execute(update_query, json.dumps(cf_2624), claim_id)
logger.info(f"✅ Сохранён cf_2624={cf_2624} в черновик claim_id={claim_id}")
except Exception as e:
logger.warning(f"⚠️ Не удалось сохранить cf_2624 в черновик: {e}")
# Загружаем form_draft и documents из PostgreSQL
query = """
SELECT
c.id,
c.payload->'form_draft' as form_draft,
c.payload->'documents_required' as documents_required,
c.payload->'documents_meta' as documents_meta,
c.payload->>'cf_2624' as cf_2624
FROM clpr_claims c
WHERE c.id::text = $1 OR c.payload->>'claim_id' = $1
LIMIT 1
"""
row = await db.fetch_one(query, claim_id)
if row:
# Парсим JSONB поля (могут быть строками)
form_draft_raw = row.get('form_draft')
documents_required_raw = row.get('documents_required')
documents_meta_raw = row.get('documents_meta')
cf_2624_from_db = row.get('cf_2624') # ✅ Получаем cf_2624 из БД
# Парсим если строка
def parse_json_field(val):
if val is None:
return None
if isinstance(val, str):
try:
return json.loads(val)
except:
return val
return val
form_draft = parse_json_field(form_draft_raw)
documents_required = parse_json_field(documents_required_raw)
documents_meta = parse_json_field(documents_meta_raw)
# Обогащаем событие данными из БД
actual_event['data'] = {
'claim_id': claim_id,
'all_ready': True,
'form_draft': form_draft,
'documents_required': documents_required,
'documents_meta': documents_meta,
}
# ✅ Добавляем cf_2624 в событие (из БД или из события)
actual_event['cf_2624'] = cf_2624_from_db or cf_2624 or "0"
logger.info(f"✅ Form draft loaded from PostgreSQL for claim_id={claim_id}, has_form_draft={form_draft is not None}, cf_2624={actual_event.get('cf_2624')}")
else:
logger.warning(f"⚠️ Claim not found in PostgreSQL: claim_id={claim_id}")
except Exception as e:
logger.error(f"❌ Error loading form_draft from PostgreSQL: {e}")
# Отправляем событие клиенту (плоский формат)
event_json = json.dumps(actual_event, ensure_ascii=False)
logger.info(f"📤 Sending event to client: {actual_event.get('status', 'unknown')}")
event_json = json.dumps(actual_event, ensure_ascii=False, default=str)
event_type_sent = actual_event.get('event_type', 'unknown')
event_status = actual_event.get('status', 'unknown')
# Логируем размер и наличие данных
data_info = actual_event.get('data', {})
has_form_draft = 'form_draft' in data_info if isinstance(data_info, dict) else False
logger.info(f"📤 Sending event to client: type={event_type_sent}, status={event_status}, json_len={len(event_json)}, has_form_draft={has_form_draft}")
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
# Закрываем для ocr_status ready (форма заявления готова)
if event_type_sent == 'ocr_status' and event_status == 'ready':
logger.info(f"✅ OCR ready event sent, closing SSE")
break
else:
logger.info(f"⏰ Timeout waiting for message on {channel}")

View File

@@ -44,7 +44,8 @@ class ClaimCreateRequest(BaseModel):
# Шаг 3: Данные для выплаты
payment_method: str = "sbp" # "sbp", "card", "bank_transfer"
bank_name: Optional[str] = None
bank_id: Optional[str] = None # ID банка из NSPK API (bankid)
bank_name: Optional[str] = None # Название банка для отображения
card_number: Optional[str] = None
account_number: Optional[str] = None
@@ -69,6 +70,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 канала (опционально)")

View File

@@ -15,11 +15,11 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/n8n", tags=["n8n-proxy"])
# URL webhooks из .env (будут добавлены)
N8N_POLICY_CHECK_WEBHOOK = getattr(settings, 'n8n_policy_check_webhook', None)
N8N_FILE_UPLOAD_WEBHOOK = getattr(settings, 'n8n_file_upload_webhook', None)
N8N_CREATE_CONTACT_WEBHOOK = getattr(settings, 'n8n_create_contact_webhook', 'https://n8n.clientright.pro/webhook/511fde97-88bb-4fb4-bea5-cafdc364be27')
N8N_CREATE_CLAIM_WEBHOOK = getattr(settings, 'n8n_create_claim_webhook', 'https://n8n.clientright.pro/webhook/d5bf4ca6-9e44-44b9-9714-3186ea703e7d')
# URL webhooks - берём из settings (defaults в config.py)
N8N_POLICY_CHECK_WEBHOOK = settings.n8n_policy_check_webhook or None
N8N_FILE_UPLOAD_WEBHOOK = settings.n8n_file_upload_webhook or None
N8N_CREATE_CONTACT_WEBHOOK = settings.n8n_create_contact_webhook
N8N_CREATE_CLAIM_WEBHOOK = settings.n8n_create_claim_webhook
@router.post("/policy/check")
@@ -124,7 +124,9 @@ async def proxy_create_contact(request: Request):
logger.error("⏱️ N8N webhook timeout")
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n")
except Exception as e:
import traceback
logger.error(f"❌ Error proxying to n8n: {e}")
logger.error(f"❌ Traceback: {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"Ошибка создания контакта: {str(e)}")

View File

@@ -21,7 +21,7 @@ async def send_sms_code(request: SMSSendRequest):
return {
"success": True,
"message": "Код отправлен на указанный номер",
"debug_code": code if sms_service.enabled else None # Показываем код только в dev
"debug_code": code # Всегда возвращаем код для dev модалки
}
else:
raise HTTPException(

View File

@@ -42,6 +42,15 @@ class Settings(BaseSettings):
mysql_user: str = "root"
mysql_password: str = ""
# ============================================
# MYSQL CRM (vtiger CRM)
# ============================================
mysql_crm_host: str = "localhost" # В режиме network_mode: host используем localhost # Доступ к хосту из Docker контейнера
mysql_crm_port: int = 3306
mysql_crm_db: str = "ci20465_72new"
mysql_crm_user: str = "ci20465_72new"
mysql_crm_password: str = "EcY979Rn"
@property
def database_url(self) -> str:
"""Формирует URL для подключения к PostgreSQL"""
@@ -162,12 +171,14 @@ class Settings(BaseSettings):
return self.cors_origins
# ============================================
# N8N WEBHOOKS (скрыты от фронтенда)
# N8N API & WEBHOOKS
# ============================================
n8n_url: str = "https://n8n.clientright.pro"
n8n_api_key: str = "" # Нужно задать в .env
n8n_policy_check_webhook: str = ""
n8n_file_upload_webhook: str = ""
n8n_create_contact_webhook: str = ""
n8n_create_claim_webhook: str = ""
n8n_create_contact_webhook: str = "https://n8n.clientright.pro/webhook/511fde97-88bb-4fb4-bea5-cafdc364be27"
n8n_create_claim_webhook: str = "https://n8n.clientright.pro/webhook/d5bf4ca6-9e44-44b9-9714-3186ea703e7d"
# ============================================
# LOGGING

View File

@@ -11,8 +11,9 @@ from .services.database import db
from .services.redis_service import redis_service
from .services.rabbitmq_service import rabbitmq_service
from .services.policy_service import policy_service
from .services.crm_mysql_service import crm_mysql_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(
@@ -56,6 +57,12 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.warning(f"⚠️ MySQL Policy DB not available: {e}")
try:
# Подключаем MySQL CRM (vtiger)
await crm_mysql_service.connect()
except Exception as e:
logger.warning(f"⚠️ MySQL CRM DB not available: {e}")
try:
# Подключаем S3 (для загрузки файлов)
s3_service.connect()
@@ -73,6 +80,7 @@ async def lifespan(app: FastAPI):
await redis_service.disconnect()
await rabbitmq_service.disconnect()
await policy_service.close()
await crm_mysql_service.close()
logger.info("👋 Ticket Form Intake Platform stopped")
@@ -103,6 +111,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 +237,4 @@ async def info():
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8200)

View File

@@ -0,0 +1,118 @@
"""
CRM MySQL Service - Подключение к MySQL БД vtiger CRM
"""
import aiomysql
from typing import Optional, Dict, Any, List
from ..config import settings
import logging
logger = logging.getLogger(__name__)
class CrmMySQLService:
"""Сервис для работы с MySQL БД vtiger CRM"""
def __init__(self):
self.pool: Optional[aiomysql.Pool] = None
async def connect(self):
"""Подключение к MySQL БД vtiger CRM"""
try:
self.pool = await aiomysql.create_pool(
host=settings.mysql_crm_host,
port=settings.mysql_crm_port,
user=settings.mysql_crm_user,
password=settings.mysql_crm_password,
db=settings.mysql_crm_db,
autocommit=True,
minsize=1,
maxsize=5
)
logger.info(f"✅ MySQL CRM DB connected: {settings.mysql_crm_host}:{settings.mysql_crm_port}/{settings.mysql_crm_db}")
except Exception as e:
logger.error(f"❌ MySQL CRM DB connection error: {e}")
raise
async def fetch_one(self, query: str, *args) -> Optional[Dict[str, Any]]:
"""
Выполнить SQL запрос и вернуть одну запись
Args:
query: SQL запрос с плейсхолдерами %s
*args: Параметры для запроса
Returns:
Dict с данными или None если не найдено
"""
if not self.pool:
await self.connect()
try:
async with self.pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(query, args)
result = await cursor.fetchone()
return dict(result) if result else None
except Exception as e:
logger.error(f"❌ Error executing query: {e}")
raise
async def fetch_all(self, query: str, *args) -> List[Dict[str, Any]]:
"""
Выполнить SQL запрос и вернуть все записи
Args:
query: SQL запрос с плейсхолдерами %s
*args: Параметры для запроса
Returns:
List[Dict] с данными
"""
if not self.pool:
await self.connect()
try:
async with self.pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(query, args)
results = await cursor.fetchall()
return [dict(row) for row in results] if results else []
except Exception as e:
logger.error(f"❌ Error executing query: {e}")
raise
async def execute(self, query: str, *args) -> int:
"""
Выполнить SQL запрос (INSERT, UPDATE, DELETE)
Args:
query: SQL запрос с плейсхолдерами %s
*args: Параметры для запроса
Returns:
Количество затронутых строк
"""
if not self.pool:
await self.connect()
try:
async with self.pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(query, args)
return cursor.rowcount
except Exception as e:
logger.error(f"❌ Error executing query: {e}")
raise
async def close(self):
"""Закрыть пул подключений"""
if self.pool:
self.pool.close()
await self.pool.wait_closed()
logger.info("MySQL CRM DB pool closed")
# Глобальный экземпляр
crm_mysql_service = CrmMySQLService()

View File

@@ -0,0 +1,179 @@
"""
Сервис для работы с n8n API
"""
import httpx
import logging
from typing import Optional
from ..config import settings
from ..services.redis_service import redis_service
logger = logging.getLogger(__name__)
# Workflow ID для ticket_form:description
WORKFLOW_ID = "b4K4u851b4JFivyD"
N8N_URL = "https://n8n.clientright.pro"
MIN_RESTART_INTERVAL = 300 # Минимум 5 минут между перезапусками
async def check_workflow_status() -> Optional[dict]:
"""
Проверка статуса workflow через n8n API
Returns:
dict с данными workflow или None при ошибке
"""
if not settings.n8n_api_key:
logger.warning("⚠️ N8N_API_KEY не настроен")
return None
headers = _get_headers()
if not headers:
return None
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}",
headers=headers
)
if response.status_code == 200:
return response.json()
else:
logger.warning(f"⚠️ n8n API вернул статус {response.status_code}")
return None
except Exception as e:
logger.error(f"❌ Ошибка при проверке статуса workflow: {e}")
return None
async def restart_workflow() -> bool:
"""
Перезапуск workflow через n8n API
Returns:
True если успешно, False при ошибке
"""
if not settings.n8n_api_key:
logger.error("❌ N8N_API_KEY не настроен! Не могу перезапустить workflow")
return False
headers = _get_headers()
if not headers:
return False
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# Шаг 1: Деактивировать workflow
logger.info(f"🔄 Деактивирую workflow {WORKFLOW_ID}...")
deactivate_response = await client.post(
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/deactivate",
headers=headers
)
if deactivate_response.status_code not in [200, 404]:
logger.warning(
f"⚠️ Неожиданный статус при деактивации: "
f"{deactivate_response.status_code}"
)
else:
logger.info("✅ Workflow деактивирован")
# Задержка перед активацией
import asyncio
await asyncio.sleep(2)
# Шаг 2: Активировать workflow
logger.info(f"🔄 Активирую workflow {WORKFLOW_ID}...")
activate_response = await client.post(
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/activate",
headers=headers
)
if activate_response.status_code == 200:
logger.info("✅ Workflow активирован")
# После успешного перезапуска отправляем сообщения из буфера
await _send_buffered_messages()
return True
else:
logger.error(
f"❌ Ошибка активации workflow: "
f"{activate_response.status_code} - {activate_response.text[:200]}"
)
return False
except Exception as e:
logger.error(f"❌ Неожиданная ошибка при перезапуске workflow: {e}")
return False
async def _send_buffered_messages():
"""
Отправить все сообщения из буфера после восстановления workflow
"""
try:
buffer_key = "description" # Буфер для ticket_form:description
messages = await redis_service.buffer_get_all(buffer_key)
if not messages:
logger.info("📭 Буфер пуст, нечего отправлять")
return
logger.info(f"📤 Отправляю {len(messages)} сообщений из буфера...")
import json
channel = f"{settings.redis_prefix}description"
sent_count = 0
failed_count = 0
for message in messages:
try:
event_json = json.dumps(message.get("event", message), ensure_ascii=False)
subscribers = await redis_service.publish(channel, event_json)
if subscribers > 0:
sent_count += 1
logger.info(
f"✅ Буферированное сообщение отправлено: "
f"session_id={message.get('session_id', 'unknown')}, "
f"subscribers={subscribers}"
)
else:
failed_count += 1
logger.warning(
f"⚠️ Буферированное сообщение не доставлено "
f"(подписчиков нет): session_id={message.get('session_id', 'unknown')}"
)
# Возвращаем обратно в буфер если не доставлено
await redis_service.buffer_push(buffer_key, message)
except Exception as e:
failed_count += 1
logger.error(f"❌ Ошибка отправки буферизованного сообщения: {e}")
# Возвращаем обратно в буфер
await redis_service.buffer_push(buffer_key, message)
logger.info(
f"📊 Результат отправки буфера: {sent_count} отправлено, {failed_count} не доставлено"
)
except Exception as e:
logger.exception(f"❌ Ошибка при отправке буферизованных сообщений: {e}")
def _get_headers() -> Optional[dict]:
"""Получить заголовки для n8n API"""
if not settings.n8n_api_key:
return None
api_key = settings.n8n_api_key
# Убираем "Bearer " если есть - n8n API использует X-N8N-API-KEY
clean_key = api_key.replace("Bearer ", "").strip()
# n8n API принимает ключ в заголовке X-N8N-API-KEY
return {"X-N8N-API-KEY": clean_key}

View File

@@ -2,7 +2,7 @@
Redis Service для кеширования, rate limiting, сессий
"""
import redis.asyncio as redis
from typing import Optional, Any
from typing import Optional, Any, List
import json
from ..config import settings
import logging
@@ -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:
"""Удалить ключ"""
@@ -146,6 +155,58 @@ class RedisService:
async def cache_delete(self, cache_key: str):
"""Удалить из кеша"""
await self.delete(f"cache:{cache_key}")
# ============================================
# MESSAGE BUFFER (для буферизации сообщений при недоступности workflow)
# ============================================
async def buffer_push(self, buffer_key: str, message: dict):
"""
Добавить сообщение в буфер (очередь)
Args:
buffer_key: Имя буфера (например, "description")
message: Сообщение для буферизации
"""
full_key = f"{settings.redis_prefix}buffer:{buffer_key}"
await self.client.lpush(full_key, json.dumps(message))
# Устанавливаем TTL на буфер (24 часа)
await self.client.expire(full_key, 86400)
async def buffer_get_all(self, buffer_key: str) -> List[dict]:
"""
Получить все сообщения из буфера (и очистить буфер)
Args:
buffer_key: Имя буфера
Returns:
Список сообщений
"""
full_key = f"{settings.redis_prefix}buffer:{buffer_key}"
# Используем транзакцию для атомарности
pipe = self.client.pipeline()
pipe.lrange(full_key, 0, -1) # Получить все
pipe.delete(full_key) # Удалить буфер
results = await pipe.execute()
messages_data = results[0] if results else []
messages = []
for msg_str in messages_data:
try:
messages.append(json.loads(msg_str))
except json.JSONDecodeError:
logger.warning(f"⚠️ Не удалось распарсить сообщение из буфера: {msg_str}")
# Возвращаем в правильном порядке (FIFO - сначала старые)
return list(reversed(messages))
async def buffer_size(self, buffer_key: str) -> int:
"""Получить размер буфера"""
full_key = f"{settings.redis_prefix}buffer:{buffer_key}"
return await self.client.llen(full_key)
# Глобальный экземпляр

View File

@@ -65,11 +65,17 @@ class SMSService:
logger.warning("SMS отправка отключена в конфигурации")
return False
# 🔧 DEV: ПРИНУДИТЕЛЬНО ОТКЛЮЧЕНА ОТПРАВКА SMS
# Раскомментировать для продакшена!
logger.info(f"🔧 DEV MODE: SMS to {phone} ЗАБЛОКИРОВАНА (экономим бюджет!)")
logger.info(f"📱 Message: {message}")
return True
# DEBUG MODE: Не отправляем реальные SMS, экономим бюджет
if settings.debug or settings.app_env == "development":
logger.info(f"🔧 DEBUG MODE: SMS to {phone} not sent (saving money!)")
logger.info(f"📱 Message would be: {message}")
return True
# if settings.debug or settings.app_env == "development":
# logger.info(f"🔧 DEBUG MODE: SMS to {phone} not sent (saving money!)")
# logger.info(f"📱 Message would be: {message}")
# return True
try:
# Получаем актуальный токен

View File

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

View File

@@ -0,0 +1,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
View File

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

62
check_draft_documents.py Normal file
View File

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

39
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,39 @@
version: '3.8'
services:
aiform_frontend_dev:
container_name: aiform_frontend_dev
build: ./frontend
ports:
- "5177:3000"
environment:
- VITE_API_URL=http://localhost:8201
- NODE_ENV=development
volumes:
- ./frontend/src:/app/src:ro
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- aiform-dev-network
restart: unless-stopped
aiform_backend_dev:
container_name: aiform_backend_dev
build: ./backend
ports:
- "8201:8200"
env_file:
- ./backend/.env
environment:
- APP_ENV=development
- DEBUG=true
networks:
- aiform-dev-network
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
networks:
aiform-dev-network:
driver: bridge

View File

@@ -19,12 +19,9 @@ services:
ticket_form_backend:
container_name: ticket_form_backend
build: ./backend
ports:
- "${TICKET_FORM_BACKEND_PORT:-8200}:8200"
network_mode: host
env_file:
- .env
networks:
- ticket-form-network
restart: unless-stopped
redis:

View File

@@ -0,0 +1,97 @@
# Получение cf_2624 из MySQL при загрузке черновика
## ✅ Упрощённый подход
Вместо передачи `cf_2624` через события Redis, просто делаем прямой SQL запрос к MySQL при загрузке черновика.
## Где это происходит
**Файл:** `ticket_form/backend/app/api/claims.py`
**Эндпоинт:** `GET /api/v1/claims/drafts/{claim_id}`
**Функция:** `get_draft()`
## Как работает
1. **Получаем `contact_id` из черновика:**
```python
contact_id = payload.get('contact_id')
```
2. **Делаем SQL запрос к MySQL:**
```sql
SELECT
cd.contactid,
cd.firstname,
cd.lastname,
cd.email,
cd.mobile,
ccf.cf_2624 AS cf_2624
FROM vtiger_contactdetails cd
LEFT JOIN vtiger_contactscf ccf ON ccf.contactid = cd.contactid
LEFT JOIN vtiger_crmentity ce ON ce.crmid = cd.contactid
WHERE cd.contactid = %s
AND ce.deleted = 0
LIMIT 1
```
3. **Используем `cf_2624` для блокировки полей:**
```python
contact_data_confirmed = (cf_2624 == "1")
contact_data_can_edit = not contact_data_confirmed
```
## Преимущества
1. ✅ **Проще** - один SQL запрос вместо цепочки событий
2. ✅ **Быстрее** - прямой запрос к БД
3. ✅ **Надёжнее** - не зависит от событий Redis
4. ✅ **Актуальнее** - всегда получаем свежие данные из БД
## Что не нужно делать
- ❌ Передавать `cf_2624` через события Redis
- ❌ Сохранять `cf_2624` в черновик при обработке событий
- ❌ Использовать webservice API для получения `cf_2624`
## Проверка
1. ✅ При загрузке черновика делается SQL запрос к PostgreSQL
2. ✅ Получаем `cf_2624` из таблицы `vtiger_contactscf`
3. ✅ Используем для блокировки полей на фронтенде
---
## Реализация
### MySQL Connection для CRM
Создан отдельный сервис `CrmMySQLService` для подключения к MySQL БД vtiger CRM:
**Файл:** `ticket_form/backend/app/services/crm_mysql_service.py`
**Credentials (из config.php):**
- Host: `localhost`
- Port: `3306`
- Database: `ci20465_72new`
- User: `ci20465_72new`
- Password: `EcY979Rn`
### Использование в коде
```python
from ..services.crm_mysql_service import crm_mysql_service
# SQL запрос с MySQL синтаксисом (%s вместо $1)
contact_query = """
SELECT ... FROM vtiger_contactdetails cd
WHERE cd.contactid = %s
"""
contact_row = await crm_mysql_service.fetch_one(contact_query, contact_id)
```
### Отличия от PostgreSQL
- Параметры: `%s` вместо `$1`
- Синтаксис JOIN: тот же
- LIMIT: тот же

View File

@@ -0,0 +1,136 @@
# Реализация проверки cf_2624 при формировании заявления
## ✅ Что сделано
### 1. Backend API (`/drafts/{claim_id}`)
- ✅ Получает `cf_2624` из CRM через webservice `retrieve`
- ✅ Преобразует в `contact_data_confirmed` (boolean)
- ✅ Возвращает в ответе API вместе с `contact_data_from_crm`
**Файл:** `ticket_form/backend/app/api/claims.py` (строки 459-539)
### 2. Frontend - Загрузка черновика
- ✅ Получает `contact_data_confirmed` из ответа API
- ✅ Сохраняет в `formData`
- ✅ Передаёт в `claimPlanData` для `StepClaimConfirmation`
**Файл:** `ticket_form/frontend/src/pages/ClaimForm.tsx` (строки 564-848)
### 3. Frontend - Форма подтверждения
-`StepClaimConfirmation` получает `contact_data_confirmed` из `claimPlanData`
- ✅ Передаёт в `generateConfirmationFormHTML`
- ✅ Форма блокирует персональные данные если `contact_data_confirmed = true`
**Файлы:**
- `ticket_form/frontend/src/components/form/StepClaimConfirmation.tsx` (строки 89-96)
- `ticket_form/frontend/src/components/form/generateConfirmationFormHTML.ts` (строки 4, 293, 724-740, 840, 907-915)
### 4. CreateWebContact
- ✅ Возвращает `cf_2624` в JSON ответе
- ✅ Для новых контактов: `cf_2624 = "0"`
- ✅ Для существующих: берёт значение из CRM
**Файл:** `include/Webservices/CreateWebContact.php`
---
## ⏳ Что нужно сделать
### 1. Обновить n8n workflow `6mxRJ2LLHmQXyaDz`
**После ноды `CreateWebContacКлиентправ`:**
Добавить ноду `Code: Extract Contact Data Confirmed`:
```javascript
// Парсим результат CreateWebContact
const rawResult = $node["CreateWebContacКлиентправ"].json.result;
const contactData = JSON.parse(rawResult);
// Извлекаем cf_2624 (Данные подтверждены)
const cf_2624 = contactData.cf_2624 || "0";
const contact_data_confirmed = cf_2624 === "1";
return {
contact_id: contactData.contact_id,
is_new_contact: contactData.is_new,
cf_2624: cf_2624,
contact_data_confirmed: contact_data_confirmed,
contact_data_can_edit: !contact_data_confirmed
};
```
**В ноде `Code in JavaScriptКлиентправ` (формирование ответа):**
Добавить в return:
```javascript
const contactStatus = $('Code: Extract Contact Data Confirmed').first().json;
return {
// ... существующие поля ...
contact_data_confirmed: contactStatus.contact_data_confirmed || false,
contact_data_can_edit: contactStatus.contact_data_can_edit !== false,
cf_2624: contactStatus.cf_2624 || "0",
// ... остальные поля ...
};
```
**См. подробности:** `ticket_form/docs/N8N_UPDATE_CF_2624_IN_RESPONSE.md`
---
## 🔄 Логика работы
### Сценарий 1: Загрузка черновика
1. Пользователь выбирает черновик
2. Frontend вызывает `/api/v1/claims/drafts/{claim_id}`
3. Backend получает `cf_2624` из CRM
4. Backend возвращает `contact_data_confirmed = (cf_2624 === "1")`
5. Frontend передаёт флаг в форму подтверждения
6. Форма блокирует поля если `contact_data_confirmed = true`
### Сценарий 2: Новое заявление (через n8n)
1. Пользователь вводит телефон
2. n8n вызывает `CreateWebContact`
3. `CreateWebContact` возвращает `cf_2624` в ответе
4. n8n извлекает `cf_2624` и передаёт в ответе для фронтенда
5. Frontend получает `contact_data_confirmed` из ответа n8n
6. Форма блокирует поля если `contact_data_confirmed = true`
---
## 📋 Какие поля блокируются
Если `contact_data_confirmed = true`, блокируются следующие поля:
- ✅ Фамилия (`lastname`)
- ✅ Имя (`firstname`)
- ✅ Отчество (`secondname`, `middle_name`)
- ✅ ИНН (`inn`)
- ✅ Дата рождения (`birthday`)
- ✅ Место рождения (`birthplace`, `birth_place`)
- ✅ Адрес (`mailingstreet`, `address`)
- ✅ Email (`email`)
**Телефон (`mobile`) всегда только для чтения** (не зависит от флага)
---
## 🧪 Проверка
1. ✅ Создать контакт в CRM → `cf_2624` должен быть "0"
2. ✅ Загрузить черновик → поля должны быть редактируемыми
3. ⏳ Установить `cf_2624 = "1"` в CRM
4. ⏳ Загрузить черновик → поля должны быть заблокированы
5. ⏳ Проверить предупреждение "⚠️ Данные подтверждены" в форме
---
## 📝 Документация
- `ticket_form/docs/CRM_CONTACT_DATA_CONFIRMED_FIELD.md` - Описание поля cf_2624
- `ticket_form/docs/CREATE_WEB_CONTACT_RESPONSE_FORMAT.md` - Формат ответа CreateWebContact
- `ticket_form/docs/N8N_UPDATE_CF_2624_IN_RESPONSE.md` - Обновление n8n workflow
- `ticket_form/docs/CODE_CREATE_WEB_CONTACT_FINAL.js` - Код для n8n (обновлён)

View File

@@ -0,0 +1,114 @@
# Добавление cf_2624 в событие ocr_status ready
## ✅ Да, правильно!
Событие `ocr_status` с `status: "ready"` должно содержать поле `cf_2624` и сохраняться в черновик.
## Формат события в Redis
**Канал:** `ocr_events:sess_5fc7cdd1-a848-4e92-aed4-3ee4bfb19b4c`
**Событие:**
```json
{
"event_type": "ocr_status",
"status": "ready",
"claim_id": "ef853bac-f54b-46aa-adf8-f0c9c0cd76bc",
"message": "Заявление сформировано",
"timestamp": "2025-12-03T12:44:12.347Z",
"cf_2624": "0"
}
```
## Что происходит
### 1. n8n workflow публикует событие
После сохранения черновика (после `claimsave`) n8n публикует событие в Redis канал `ocr_events:{session_id}` с полем `cf_2624`.
**Где добавить:** После ноды `claimsave`, перед публикацией в Redis.
**См. подробности:** `ticket_form/docs/N8N_ADD_CF_2624_TO_OCR_STATUS_EVENT.md`
---
### 2. Backend обрабатывает событие
Backend получает событие из Redis и:
- ✅ Загружает `form_draft` из PostgreSQL
-**Сохраняет `cf_2624` в черновик**`payload.cf_2624`)
- ✅ Отправляет событие на фронтенд через SSE
**Файл:** `ticket_form/backend/app/api/events.py` (строки 218-273)
---
### 3. Сохранение в черновик
`cf_2624` сохраняется в таблицу `clpr_claims` в поле `payload.cf_2624`:
```sql
UPDATE clpr_claims
SET payload = jsonb_set(
COALESCE(payload, '{}'::jsonb),
'{cf_2624}',
'"0"'::jsonb -- или '"1"'
)
WHERE id::text = $1 OR payload->>'claim_id' = $1;
```
---
## Порядок работы
1. **n8n workflow:**
- `CreateWebContacКлиентправ` → получает `cf_2624` из CRM
- `claimsave` → сохраняет черновик
- `Code: Prepare OCR Status Event` → формирует событие с `cf_2624`
- `HTTP Request` или `Redis Publish` → публикует в `ocr_events:{session_id}`
2. **Backend:**
- Получает событие из Redis
- Сохраняет `cf_2624` в черновик
- Загружает `form_draft` из PostgreSQL
- Отправляет на фронтенд через SSE
3. **Фронтенд:**
- Получает событие через SSE
- Использует `cf_2624` для блокировки полей
---
## Проверка
1. ✅ Событие публикуется в `ocr_events:{session_id}` с `cf_2624`
2. ✅ Backend сохраняет `cf_2624` в черновик (`payload.cf_2624`)
3. ✅ При загрузке черновика `cf_2624` доступен в `payload.cf_2624`
---
## SQL для проверки
```sql
-- Проверить, что cf_2624 сохранён в черновик
SELECT
id,
payload->>'claim_id' as claim_id,
payload->>'cf_2624' as cf_2624,
updated_at
FROM clpr_claims
WHERE payload->>'claim_id' = 'ef853bac-f54b-46aa-adf8-f0c9c0cd76bc'
ORDER BY updated_at DESC
LIMIT 1;
```
---
## Итого
**Да, правильно!** Событие `ocr_status` с `status: "ready"` должно содержать `cf_2624`, и это значение будет:
- Публиковаться в Redis канал `ocr_events:{session_id}`
- Сохраняться в черновик в `payload.cf_2624`
- Использоваться для блокировки полей на фронтенде

View File

@@ -0,0 +1,94 @@
# Статус заявки 226564ce-d7cf-48ee-a820-690e8f5ec8e5
## ✅ Общая информация
- **ID**: `226564ce-d7cf-48ee-a820-690e8f5ec8e5`
- **Status**: `draft_docs_complete`
- **Unified ID**: `usr_b1fbffa0-477b-4abb-95d6-8d6f849ddc71`
- **Session Token**: `sess_c278abf8-1603-484d-af98-8b93843e5253`
- **Phone**: `71234543212`
- **Channel**: `web_form`
- **Is Confirmed**: `false` (должна отображаться в списке)
- **Created**: `2025-12-01 14:38:11`
- **Updated**: `2025-12-01 20:06:18`
- **Expires**: `2025-12-15 19:35:30`
## ✅ Документы
### documents_meta (2 записи)
1. **uploads[1][0]**
- `field_label`: "Чек или подтверждение оплаты" ✅ (правильно, не "group-2")
- `file_id`: `/f9825c87-4e3558f6-f9b6-405c-ad3d-d1535c49b61c/crm2/CRM_Active_Files/Documents/Project/ERV_3212_КлиентПрав_399543/e34f2f9e-e48d-47f4-9c2d-6957012c0800__chek-ili-podtverzhdenie-oplaty.pdf`
- `file_name`: `e34f2f9e-e48d-47f4-9c2d-6957012c0800__chek-ili-podtverzhdenie-oplaty.pdf`
- `uploaded_at`: `2025-12-01T14:15:54.122Z`
2. **uploads[0][0]**
- `field_label`: "Договор или заказ" ✅ (правильно)
- `file_id`: `/f9825c87-4e3558f6-f9b6-405c-ad3d-d1535c49b61c/crm2/CRM_Active_Files/Documents/Project/ERV_3212_КлиентПрав_399543/344deab2-1a3a-46ce-931b-5a29bb2c40a3__dogovor-ili-zakaz.pdf`
- `file_name`: `344deab2-1a3a-46ce-931b-5a29bb2c40a3__dogovor-ili-zakaz.pdf`
- `uploaded_at`: `2025-12-01T13:47:15.772Z`
### clpr_claim_documents (2 записи)
1. **uploads[1][0]**
- `id`: `e34f2f9e-e48d-47f4-9c2d-6957012c0800`
- `file_hash`: `3e1f1332a76b7f26df1628c49579f30a873de9170f3b8007b0bac5e4a439ca67`
2. **uploads[0][0]**
- `id`: `344deab2-1a3a-46ce-931b-5a29bb2c40a3`
- `file_hash`: `83822e59662aa2037977dc5a8661d8a057ae6572e6f99936a31c6cdd7d66f1d9`
## ✅ Проверки
-**Дубликатов нет** — все `field_name` уникальны
-**field_label правильные** — не "group-2", а реальные названия
-**Синхронизация**`documents_meta` и `clpr_claim_documents` совпадают
-**file_hash заполнен**оба документа имеют хеш
-**Заявка должна отображаться**`is_confirmed = false`, `status_code != 'approved'`
## 📋 Payload структура
Заявка содержит следующие ключи в `payload`:
- `body`
- `email`
- `phone`
- `tg_id`
- `answers`
- `claim_id`
- `applicant`
- `contact_id`
- `form_draft`
- `ai_analysis`
- `claim_ready`
- `wizard_plan`
- `wizard_ready`
- `ai_agent13_rag`
- `documents_meta`
- `ai_agent1_facts`
- `answers_prefill`
- `current_doc_index`
- `documents_skipped`
- `documents_required`
- `documents_uploaded`
- `problem_description`
## 🔍 Возможные проблемы с отображением
Если заявка не отображается или отображается неправильно, проверьте:
1. **API endpoint `/drafts/list`** — должен находить заявку по `unified_id`, `phone` или `session_token`
2. **Фронтенд фильтрация** — возможно, фильтруется по `status_code`
3. **Отображение `field_label`** — должно использовать `documents_meta[].field_label`, а не вычислять из `field_name`
## ✅ Вывод
**Заявка в порядке!** Все данные корректны:
- ✅ Нет дубликатов в `documents_meta`
-`field_label` правильные
- ✅ Документы синхронизированы
-`file_hash` заполнен
- ✅ Заявка должна отображаться в списке
Если есть проблемы с отображением, они скорее всего на стороне фронтенда или API фильтрации.

View File

@@ -1,7 +1,12 @@
// Парсим результат CreateWebContact
const rawResult = $node["CreateWebContact"].json.result;
const contactData = JSON.parse(rawResult); // {"contact_id": "396625", "is_new": false}
const contactData = JSON.parse(rawResult); // {"contact_id": "396625", "is_new": false, "cf_2624": "1"}
// ✅ Извлекаем cf_2624 (Данные подтверждены)
// "1" = данные подтверждены, "0" = не подтверждены
const cf_2624 = contactData.cf_2624 || "0";
const contact_data_confirmed = cf_2624 === "1";
const phone = $('Edit Fields').first().json.phone;
@@ -18,6 +23,8 @@ const sessionData = {
contact_id: contactData.contact_id, // ← распарсенный ID из CreateWebContact
phone: phone,
is_new_contact: contactData.is_new, // ← флаг нового контакта
cf_2624: cf_2624, // ✅ Сохраняем cf_2624 в сессию
contact_data_confirmed: contact_data_confirmed, // ✅ Сохраняем флаг подтверждения
status: "draft",
current_step: 1,
created_at: new Date().toISOString(),
@@ -34,6 +41,10 @@ return {
contact_id: contactData.contact_id,
is_new_contact: contactData.is_new,
phone: phone,
// ✅ Флаги подтверждения данных контакта (из cf_2624)
cf_2624: cf_2624,
contact_data_confirmed: contact_data_confirmed,
contact_data_can_edit: !contact_data_confirmed,
redis_key: `session:${session_id}`, // ✅ Используем session_id для ключа Redis
redis_value: JSON.stringify(sessionData),
ttl: 604800

View File

@@ -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 [
{

View File

@@ -0,0 +1,56 @@
# Формат ответа CreateWebContact
## Обновление: добавлено поле cf_2624
### Старый формат:
```json
{
"contact_id": "396625",
"is_new": false
}
```
### Новый формат (с cf_2624):
```json
{
"contact_id": "396625",
"is_new": false,
"cf_2624": "1"
}
```
## Описание полей:
- **contact_id** (string) - ID контакта в CRM
- **is_new** (boolean) - `true` если контакт только что создан, `false` если найден существующий
- **cf_2624** (string) - "Данные подтверждены":
- `"1"` = "Да" (данные подтверждены)
- `"0"` = "Нет" (данные не подтверждены)
## Использование в n8n:
```javascript
// Парсим результат CreateWebContact
const rawResult = $node["CreateWebContact"].json.result;
const contactData = JSON.parse(rawResult);
// Получаем данные
const contact_id = contactData.contact_id;
const is_new = contactData.is_new;
const data_confirmed = contactData.cf_2624 === "1"; // true/false
// Используем в дальнейшей логике
if (data_confirmed) {
// Данные подтверждены - блокируем редактирование
}
```
## Логика работы:
1. **Новый контакт** (`is_new: true`):
- `cf_2624` всегда `"0"` (данные не подтверждены)
2. **Существующий контакт** (`is_new: false`):
- `cf_2624` берётся из базы данных CRM
- Если поле пустое → возвращается `"0"`

View File

@@ -0,0 +1,149 @@
# Добавление поля "Данные подтверждены" в CRM
## Шаг 1: Создание кастомного поля в CRM
1. Зайти в CRM → Настройки → Кастомные поля → Модуль "Контакты"
2. Создать новое поле:
- **Название:** "Данные подтверждены"
- **Тип:** "Да/Нет" (Checkbox) или "Список" (Picklist) со значениями "Да"/"Нет"
- **Код поля:** `cf_2624` ✅ (уже создано)
- **По умолчанию:** "Нет" (false)
3. **ВАЖНО:** Записать номер поля (например, `cf_2624`)
---
## Шаг 2: Обновление backend для проверки поля в CRM
### Файл: `ticket_form/backend/app/api/claims.py`
В функции `get_draft()` вместо проверки PostgreSQL, проверяем поле в CRM:
```python
# ✅ Проверяем флаг подтверждения данных контакта из CRM
unified_id = row.get('unified_id')
contact_data_confirmed = False
contact_data_can_edit = True
contact_data_confirmed_at = None
contact_data_from_crm = None
if unified_id:
# Получаем contact_id из payload
contact_id = payload.get('contact_id') if isinstance(payload, dict) else None
if contact_id:
try:
# Получаем данные контакта из CRM
async with httpx.AsyncClient(timeout=30.0) as client:
# 1. Get Challenge
challenge_response = await client.get(
f"{settings.crm_webservice_url}",
params={"operation": "getchallenge", "username": "api"}
)
challenge_data = challenge_response.json()
token = challenge_data.get("result", {}).get("token", "")
# 2. Login
import hashlib
salt = "4r9ANex8PT2IuRV"
access_key = hashlib.md5((token + salt).encode()).hexdigest()
login_response = await client.post(
f"{settings.crm_webservice_url}",
data={
"operation": "login",
"username": "api",
"accessKey": access_key
}
)
login_data = login_response.json()
session_name = login_data.get("result", {}).get("sessionName", "")
# 3. Retrieve Contact
retrieve_response = await client.post(
f"{settings.crm_webservice_url}",
data={
"operation": "retrieve",
"sessionName": session_name,
"id": f"12x{contact_id}"
}
)
retrieve_data = retrieve_response.json()
if retrieve_data.get("success") and retrieve_data.get("result"):
contact_data_from_crm = retrieve_data["result"]
# ✅ Проверяем кастомное поле "Данные подтверждены"
confirmed_field = contact_data_from_crm.get("cf_2624", "0") # "1" = да, "0" = нет
contact_data_confirmed = confirmed_field == "1" or confirmed_field == "true"
contact_data_can_edit = not contact_data_confirmed
logger.info(
f"🔒 Статус данных контакта из CRM: confirmed={contact_data_confirmed}, "
f"field_value={confirmed_field}"
)
except Exception as e:
logger.warning(f"⚠️ Не удалось загрузить данные из CRM: {str(e)}")
```
---
## Шаг 3: Обновление n8n workflow для установки поля
### В workflow `6mxRJ2LLHmQXyaDz`
После подтверждения формы (после SMS-верификации) добавить ноду:
**Название:** `HTTP Request: Set Contact Data Confirmed`
**Метод:** POST
**URL:** `{{ $env.CRM_WEBSERVICE_URL }}`
**Body (form-data):**
```
operation: revise
sessionName: {{ $('Login to CRM').json.sessionName }}
id: 12x{{ JSON.parse($node['CreateWebContacКлиентправ'].json.result).contact_id }}
cf_2624: 1
```
**Где:**
- `cf_2624` - поле "Данные подтверждены"
- `1` = "Да" (данные подтверждены)
---
## Шаг 4: Обновление UpsertContact (если используется)
Если используется `UpsertContact.php`, добавить поддержку нового поля:
```php
// В функции vtws_upsertcontact()
if (!empty($data_confirmed)) {
$params['cf_2624'] = $data_confirmed; // "1" или "0"
}
```
---
## Преимущества подхода:
1.**CRM - источник истины** - все данные в одном месте
2.**Нет синхронизации** - не нужно синхронизировать флаги между PostgreSQL и CRM
3.**Простота** - один флаг в CRM, проверяем его напрямую
4.**Видимость** - менеджеры видят статус в карточке контакта
5.**Гибкость** - можно менять статус вручную в CRM
---
## Проверка:
1. ✅ Поле создано в CRM: `cf_2624`
2. ⏳ Обновить код backend (использовать `cf_2624`)
3. ⏳ Обновить n8n workflow (использовать `cf_2624`)
4. ⏳ Протестировать:
- Создать контакт → поле должно быть "Нет"
- Подтвердить форму → поле должно стать "Да"
- Загрузить черновик → поля должны быть заблокированы

View File

@@ -0,0 +1,217 @@
# Обновление фронтенда: Блокировка редактирования подтверждённых данных
## Изменения
### 1. Step1Phone.tsx - Получение флага из n8n
**После получения ответа от n8n (после строки ~150):**
```typescript
// ✅ Извлекаем флаг подтверждения данных
const contact_data_confirmed = result.contact_data_confirmed || false;
const contact_data_can_edit = result.contact_data_can_edit !== false; // По умолчанию true
const contact_data_confirmed_at = result.contact_data_confirmed_at || null;
// Сохраняем в formData
updateFormData({
// ... существующие поля ...
contact_data_confirmed: contact_data_confirmed,
contact_data_can_edit: contact_data_can_edit,
contact_data_confirmed_at: contact_data_confirmed_at,
});
```
---
### 2. generateConfirmationFormHTML.ts - Блокировка полей
**Добавить параметр `contact_data_confirmed` в функцию:**
```typescript
export function generateConfirmationFormHTML(
data: any,
contact_data_confirmed: boolean = false
): string {
// ... существующий код ...
// В функции createInputField добавить проверку:
function createInputField(root: string, key: string, value: any, label: string, type: string = 'text') {
const isReadOnly = contact_data_confirmed && (
key === 'firstname' ||
key === 'lastname' ||
key === 'middle_name' ||
key === 'inn' ||
key === 'birthday' ||
key === 'birthplace' ||
key === 'mailingstreet' ||
key === 'email'
);
const readonlyAttr = isReadOnly ? 'readonly' : '';
const readonlyClass = isReadOnly ? 'readonly-field' : '';
// ... остальной код с добавлением readonlyAttr и readonlyClass ...
}
}
```
**Добавить CSS для readonly полей:**
```css
.readonly-field {
background-color: #f5f5f5 !important;
cursor: not-allowed !important;
opacity: 0.7;
}
```
---
### 3. StepClaimConfirmation.tsx - Передача флага в форму
**В useEffect (после строки ~90):**
```typescript
// Получаем флаг подтверждения из claimPlanData или formData
const contact_data_confirmed =
claimPlanData?.contact_data_confirmed ||
claimPlanData?.propertyName?.meta?.contact_data_confirmed ||
formData?.contact_data_confirmed ||
false;
// Передаём в generateConfirmationFormHTML
const html = generateConfirmationFormHTML(formData, contact_data_confirmed);
```
---
### 4. Добавить кнопку "Изменить данные" (опционально)
**В generateConfirmationFormHTML.ts:**
```typescript
// После заголовка формы, если contact_data_confirmed = true
if (contact_data_confirmed) {
html += `
<div style="margin-bottom: 16px; padding: 12px; background: #fff7e6; border: 1px solid #ffd591; border-radius: 4px;">
<p style="margin: 0 0 8px 0; color: #ad6800;">
<strong>⚠️ Данные подтверждены</strong>
</p>
<p style="margin: 0; font-size: 14px; color: #ad6800;">
Для изменения данных требуется подтверждение через SMS.
</p>
<button
type="button"
id="btn-edit-data"
style="margin-top: 8px; padding: 6px 16px; background: #fa8c16; color: white; border: none; border-radius: 4px; cursor: pointer;"
>
Изменить данные
</button>
</div>
`;
}
```
**В JavaScript внутри формы:**
```javascript
// Обработчик кнопки "Изменить данные"
const editBtn = document.getElementById('btn-edit-data');
if (editBtn) {
editBtn.addEventListener('click', function() {
// Отправляем сообщение родительскому окну
window.parent.postMessage({
type: 'request_edit_contact_data',
eventData: {
phone: state.user?.mobile || '',
unified_id: state.meta?.unified_id || ''
}
}, '*');
});
}
```
---
### 5. Обработка запроса на изменение данных
**В StepClaimConfirmation.tsx:**
```typescript
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// ... существующие обработчики ...
if (event.data.type === 'request_edit_contact_data') {
const { phone, unified_id } = event.data.eventData;
// Показываем модалку SMS для подтверждения
setSmsModalVisible(true);
setSmsCodeSent(false);
sendSMSCode(phone);
// Сохраняем флаг, что это запрос на изменение данных
setPendingFormData({
...pendingFormData,
is_edit_request: true,
unified_id: unified_id
});
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);
```
---
### 6. После SMS подтверждения - сброс флага
**В verifySMSCode (после успешной верификации):**
```typescript
// Если это запрос на изменение данных
if (pendingFormData?.is_edit_request) {
// Отправляем запрос в n8n для сброса флага
await fetch('/api/v1/claims/contact-data/reset-confirmed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
unified_id: pendingFormData.unified_id,
sms_code: code
})
});
// Обновляем флаг в formData
updateFormData({
contact_data_confirmed: false,
contact_data_can_edit: true
});
// Перезагружаем форму с разблокированными полями
// (можно просто обновить страницу или пересоздать форму)
window.location.reload();
}
```
---
## Порядок реализации
1. ✅ Обновить Step1Phone для получения флага
2. ✅ Обновить generateConfirmationFormHTML для блокировки полей
3. ✅ Обновить StepClaimConfirmation для передачи флага
4. ⏳ Добавить кнопку "Изменить данные" (опционально)
5. ⏳ Реализовать механизм переподтверждения через SMS
---
## Тестирование
После обновления проверить:
- ✅ Флаг получается из n8n
- ✅ Поля блокируются при `contact_data_confirmed = true`
- ✅ Данные из CRM загружаются и отображаются
- ✅ Кнопка "Изменить данные" работает (если реализована)

View File

@@ -0,0 +1,210 @@
# Добавление cf_2624 в событие ocr_status ready
## Задача
После сохранения черновика (после `claimsave`) публиковать событие `ocr_status` с `status: "ready"` в Redis канал `ocr_events:{session_id}` с полем `cf_2624`.
## Формат события
```json
{
"event_type": "ocr_status",
"status": "ready",
"claim_id": "ef853bac-f54b-46aa-adf8-f0c9c0cd76bc",
"message": "Заявление сформировано",
"timestamp": "2025-12-03T12:44:12.347Z",
"cf_2624": "0"
}
```
## Где добавить в n8n workflow
### Вариант 1: После ноды `claimsave` (PostgreSQL)
**Название ноды:** `Code: Prepare OCR Status Event`
**Расположение:** После ноды `claimsave` (PostgreSQL), перед нодой публикации в Redis
**Код:**
```javascript
// Получаем результат из claimsave
const claimResult = $input.first().json;
const claim = claimResult.claim || claimResult;
// Получаем contact_id из claim
const contact_id = claim.contact_id;
// ✅ Получаем cf_2624 из PostgreSQL (если есть нода Get Contact Data)
let cf_2624 = "0"; // По умолчанию "0" (не подтверждено)
try {
// Пытаемся получить из предыдущей ноды PostgreSQL: Get Contact Data
const contactData = $('PostgreSQL: Get Contact Data')?.first()?.json;
if (contactData && contactData.cf_2624) {
cf_2624 = contactData.cf_2624;
} else {
// Альтернатива: получаем из CreateWebContact
const createWebContactResult = $node["CreateWebContacКлиентправ"]?.json?.result || "";
if (createWebContactResult) {
const contactData = typeof createWebContactResult === 'string'
? JSON.parse(createWebContactResult)
: createWebContactResult;
if (contactData.cf_2624) {
cf_2624 = contactData.cf_2624;
}
}
}
} catch (e) {
console.warn('⚠️ Не удалось получить cf_2624, используем значение по умолчанию "0"');
}
// Формируем событие для Redis
const event = {
event_type: 'ocr_status',
status: 'ready',
claim_id: claim.claim_id || claim.id,
message: 'Заявление сформировано',
timestamp: new Date().toISOString(),
cf_2624: cf_2624 // ✅ Добавляем cf_2624
};
console.log('📤 Подготовлено событие ocr_status ready:', {
claim_id: event.claim_id,
cf_2624: event.cf_2624,
contact_id: contact_id
});
return {
json: {
// Данные для публикации в Redis
channel: `ocr_events:${claim.session_token || claim.session_id}`,
message: JSON.stringify(event),
// Передаём дальше для следующих нод
claim_id: event.claim_id,
session_token: claim.session_token || claim.session_id,
cf_2624: cf_2624
}
};
```
---
### Вариант 2: Прямо в ноде публикации (HTTP Request или Redis Publish)
**Если используется HTTP Request:**
**URL:** `{{ $env.BACKEND_URL }}/api/v1/events/{{ $json.session_token }}`
**Body (JSON):**
```json
{
"event_type": "ocr_status",
"status": "ready",
"message": "Заявление сформировано",
"data": {
"claim_id": "{{ $json.claim_id }}",
"cf_2624": "{{ $json.cf_2624 || '0' }}"
},
"timestamp": "{{ $now.toISO() }}"
}
```
**Если используется Redis Publish:**
**Channel:** `ocr_events:{{ $json.session_token }}`
**Message:**
```javascript
={{ JSON.stringify({
event_type: 'ocr_status',
status: 'ready',
claim_id: $json.claim_id,
message: 'Заявление сформировано',
timestamp: new Date().toISOString(),
cf_2624: $json.cf_2624 || '0'
}) }}
```
---
## Порядок нод в workflow
1. **CreateWebContacКлиентправ** → получаем `contact_id` и `cf_2624`
2. **PostgreSQL: Get Contact Data** (опционально) → получаем полные данные контакта включая `cf_2624`
3. **claimsave** (PostgreSQL) → сохраняем черновик
4. **Code: Prepare OCR Status Event** → формируем событие с `cf_2624`
5. **HTTP Request** или **Redis Publish** → публикуем событие в `ocr_events:{session_id}`
---
## Сохранение в черновик
Событие с `cf_2624` будет:
1. ✅ Публиковаться в Redis канал `ocr_events:{session_id}`
2. ✅ Обрабатываться backend'ом (загружает `form_draft` из PostgreSQL)
3.**Нужно добавить:** Сохранение `cf_2624` в черновик при обработке события
### Обновление backend для сохранения cf_2624
В файле `ticket_form/backend/app/api/events.py` (строка 218-267):
После загрузки `form_draft` из PostgreSQL, если в событии есть `cf_2624`, нужно сохранить его в черновик:
```python
# ✅ Обработка ocr_status ready: загружаем form_draft из PostgreSQL
if actual_event.get('event_type') == 'ocr_status' and actual_event.get('status') == 'ready':
claim_id = actual_event.get('claim_id') or actual_event.get('data', {}).get('claim_id')
cf_2624 = actual_event.get('cf_2624') # ✅ Получаем cf_2624 из события
if claim_id:
# ... существующий код загрузки form_draft ...
# ✅ Если есть cf_2624 в событии - сохраняем в черновик
if cf_2624:
try:
update_query = """
UPDATE clpr_claims
SET payload = jsonb_set(
payload,
'{cf_2624}',
$1::jsonb
)
WHERE id::text = $2
RETURNING id;
"""
await db.execute(update_query, json.dumps(cf_2624), claim_id)
logger.info(f"✅ Сохранён cf_2624={cf_2624} в черновик claim_id={claim_id}")
except Exception as e:
logger.warning(f"⚠️ Не удалось сохранить cf_2624: {e}")
```
---
## Проверка
1. ✅ Событие публикуется в `ocr_events:{session_id}` с `cf_2624`
2. ⏳ Backend обрабатывает событие и сохраняет `cf_2624` в черновик
3. ⏳ При загрузке черновика `cf_2624` доступен в `payload.cf_2624`
---
## Пример полного события
```json
{
"event_type": "ocr_status",
"status": "ready",
"claim_id": "ef853bac-f54b-46aa-adf8-f0c9c0cd76bc",
"message": "Заявление сформировано",
"timestamp": "2025-12-03T12:44:12.347Z",
"cf_2624": "0"
}
```
Это событие будет:
- ✅ Публиковаться в Redis канал `ocr_events:sess_5fc7cdd1-a848-4e92-aed4-3ee4bfb19b4c`
- ✅ Обрабатываться backend'ом
- ✅ Сохраняться в черновик в поле `payload.cf_2624`

View File

@@ -0,0 +1,44 @@
// ============================================================================
// Code Node для n8n: Проверка подтверждения данных контакта
// ============================================================================
// Назначение: Проверить, подтверждены ли данные контакта пользователя
// и нужно ли блокировать редактирование
//
// Использование: После получения unified_id, перед загрузкой данных формы
// ============================================================================
// Получаем unified_id из предыдущих шагов
const unified_id = $('user_get').first().json.unified_id ||
$('Edit Fields').first().json.unified_id ||
$json.unified_id;
if (!unified_id) {
throw new Error('unified_id не найден');
}
// Выполняем SQL запрос для проверки статуса
// (это должно быть в PostgreSQL ноде, но для примера показываю логику)
// SQL запрос:
// SELECT * FROM clpr_get_contact_data_status($1);
// Параметр: unified_id
// Ожидаемый результат:
// {
// is_confirmed: true/false,
// confirmed_at: "2025-12-02T14:30:00Z" или null,
// can_edit: true/false
// }
// Для Code Node (если нужно обработать результат):
const status = $('PostgreSQL Check Status').first().json; // Предполагаем, что есть такая нода
return {
unified_id: unified_id,
is_confirmed: status.is_confirmed || false,
confirmed_at: status.confirmed_at || null,
can_edit: status.can_edit !== false, // По умолчанию можно редактировать
// Флаг для фронтенда
lock_editing: status.is_confirmed || false
};

View File

@@ -0,0 +1,264 @@
// ========================================
// Code Node: Code in JavaScriptКлиентправ
// Формирование Response для фронтенда с поддержкой cf_2624
// ========================================
// --- 1. Генерация UUIDv4 ---
function generateUUIDv4() {
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);
});
}
// --- 2. Парсим контакт из CreateWebContacКлиентправ ---
const createWebContactNode = $node["CreateWebContacКлиентправ"] || $node["CreateWebContact"];
const rawResult = createWebContactNode?.json?.result || "";
let contactData = {};
try {
contactData = typeof rawResult === 'string'
? JSON.parse(rawResult)
: rawResult;
} catch (e) {
console.error('❌ Ошибка парсинга CreateWebContact:', e);
contactData = {};
}
// ✅ Извлекаем cf_2624 (Данные подтверждены) из CreateWebContact
// "1" = данные подтверждены, "0" = не подтверждены
const cf_2624 = contactData.cf_2624 || "0";
const contact_data_confirmed = cf_2624 === "1" || cf_2624 === "true" || cf_2624 === true;
const contact_data_can_edit = !contact_data_confirmed;
console.log('🔒 Статус данных контакта из CreateWebContact:', {
contact_id: contactData.contact_id,
is_new: contactData.is_new,
cf_2624: cf_2624,
contact_data_confirmed: contact_data_confirmed,
contact_data_can_edit: contact_data_can_edit
});
// --- 2.1. Получаем полные данные контакта из PostgreSQL (если есть) ---
let contactFromDB = null;
try {
// Пытаемся найти ноду PostgreSQL, которая получила данные контакта
const possiblePostgresNodes = [
'PostgreSQL: Get Contact Data',
'Get Contact from DB',
'PostgreSQL',
'Get Contact Details'
];
for (const nodeName of possiblePostgresNodes) {
try {
const node = $(nodeName)?.first();
if (node && node.json) {
// Проверяем, что это данные контакта (есть contactid)
if (node.json.contactid || node.json.contact_id) {
contactFromDB = node.json;
console.log('✅ Получены данные контакта из PostgreSQL:', {
contactid: contactFromDB.contactid || contactFromDB.contact_id,
firstname: contactFromDB.firstname,
lastname: contactFromDB.lastname
});
break;
}
}
} catch (e) {
continue;
}
}
// Альтернативный способ: ищем по структуре данных
if (!contactFromDB) {
// Может быть в предыдущей ноде с результатом запроса
const inputData = $input.all();
for (const item of inputData) {
if (item.json && (item.json.contactid || item.json.contact_id)) {
contactFromDB = item.json;
break;
}
}
}
} catch (e) {
console.warn('⚠️ Не удалось получить данные контакта из PostgreSQL:', e.message);
}
// Если данные из БД получены - используем их для дополнения информации
if (contactFromDB) {
console.log('📋 Данные контакта из БД:', {
contactid: contactFromDB.contactid,
firstname: contactFromDB.firstname,
lastname: contactFromDB.lastname,
email: contactFromDB.email,
mobile: contactFromDB.mobile,
birthday: contactFromDB.birthday,
mailingstreet: contactFromDB.mailingstreet,
middle_name: contactFromDB.middle_name,
birthplace: contactFromDB.birthplace,
inn: contactFromDB.inn
});
}
// --- 3. Телефон из Edit Fields ---
let phone = null;
try {
const editFields = $('Edit Fields')?.first();
if (editFields && editFields.json) {
phone = editFields.json.phone;
}
} catch (e) {
console.warn('⚠️ Не удалось получить phone из Edit Fields:', e.message);
}
// --- 4. unified_id из user_get ---
let unified_id = null;
try {
const possibleUserNodes = ['user_get', 'Find or Create User', 'PostgreSQL: Find User'];
for (const nodeName of possibleUserNodes) {
try {
const node = $node[nodeName];
if (node && node.json && node.json.unified_id) {
unified_id = node.json.unified_id;
break;
}
} catch (e) {
// Нода не существует или не выполнена - продолжаем поиск
continue;
}
}
if (!unified_id) {
console.warn('⚠️ unified_id не получен из ноды user_get. Проверьте, что нода выполнена.');
}
} catch (e) {
console.warn('⚠️ Не удалось получить unified_id:', e.message);
}
// --- 5. Генерируем session_id (если не получен из предыдущих нод) ---
let session_id = null;
// Пытаемся получить session_id из предыдущих нод
try {
const possibleSessionNodes = [
'Code in JavaScript1',
'Code in JavaScript',
'Set Session Data',
'Create Session'
];
for (const nodeName of possibleSessionNodes) {
try {
const node = $(nodeName)?.first();
if (node && node.json) {
if (node.json.session_id) {
session_id = node.json.session_id;
break;
} else if (node.json.redis_value) {
const parsed = JSON.parse(node.json.redis_value);
if (parsed.session_id) {
session_id = parsed.session_id;
break;
}
}
}
} catch (e) {
continue;
}
}
// Пытаемся получить из Edit Fields
if (!session_id) {
try {
const editFields = $('Edit Fields')?.first();
if (editFields && editFields.json && editFields.json.session_id) {
session_id = editFields.json.session_id;
}
} catch (e) {
// Игнорируем
}
}
} catch (e) {
console.warn('⚠️ Не удалось получить session_id из предыдущих нод:', e.message);
}
// Если session_id не найден - генерируем новый
if (!session_id) {
session_id = 'sess_' + generateUUIDv4();
console.log('✅ Сгенерирован новый session_id:', session_id);
}
// --- 6. Формируем sessionData для Redis ---
const sessionData = {
session_id, // ← теперь сохраняем внутрь
unified_id,
contact_id: contactData.contact_id,
phone,
is_new_contact: contactData.is_new || contactData.is_new_contact || false,
// ✅ Флаги подтверждения данных контакта (из cf_2624)
cf_2624: cf_2624,
contact_data_confirmed: contact_data_confirmed,
contact_data_can_edit: contact_data_can_edit,
// ✅ Данные контакта из PostgreSQL (если получены)
contact_from_db: contactFromDB ? {
contactid: contactFromDB.contactid || contactFromDB.contact_id,
firstname: contactFromDB.firstname,
lastname: contactFromDB.lastname,
email: contactFromDB.email,
mobile: contactFromDB.mobile,
phone: contactFromDB.phone,
birthday: contactFromDB.birthday,
mailingstreet: contactFromDB.mailingstreet,
mailingcity: contactFromDB.mailingcity,
mailingstate: contactFromDB.mailingstate,
mailingzip: contactFromDB.mailingzip,
mailingcountry: contactFromDB.mailingcountry,
middle_name: contactFromDB.middle_name,
birthplace: contactFromDB.birthplace,
inn: contactFromDB.inn,
requisites: contactFromDB.requisites,
code: contactFromDB.code,
sms: contactFromDB.sms
} : null,
status: "draft",
current_step: 1,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
documents: {},
email: contactFromDB?.email || null,
bank_name: null
};
// --- 7. Возвращаем результат в формате items ---
const result = {
json: {
session: session_id,
session_id,
unified_id,
contact_id: contactData.contact_id,
is_new_contact: contactData.is_new || contactData.is_new_contact || false,
phone,
// ✅ Флаги подтверждения данных контакта (из cf_2624)
cf_2624: cf_2624,
contact_data_confirmed: contact_data_confirmed,
contact_data_can_edit: contact_data_can_edit,
redis_key: `session:${session_id}`,
redis_value: JSON.stringify(sessionData),
ttl: 604800
}
};
// Логируем финальный ответ для отладки
console.log('✅ Сформирован ответ для фронтенда:', {
session_id: result.json.session_id,
has_unified_id: !!result.json.unified_id,
has_contact_id: !!result.json.contact_id,
contact_data_confirmed: result.json.contact_data_confirmed,
cf_2624: result.json.cf_2624,
is_new_contact: result.json.is_new_contact
});
return [result];

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
// ============================================================================
// Code Node для n8n: Установка флага подтверждения данных
// ============================================================================
// Назначение: Установить флаг contact_data_confirmed_at после подтверждения формы
//
// Использование: После успешного сохранения данных в CRM через claim_confirmed
// ============================================================================
// Получаем unified_id
const unified_id = $('user_get').first().json.unified_id ||
$json.unified_id;
if (!unified_id) {
throw new Error('unified_id не найден для установки флага подтверждения');
}
// Получаем contact_id из CRM (если есть)
const contact_id = $node['CreateWebContacКлиентправ']?.json?.result?.contact_id ||
$json.contact_id ||
null;
// Проверяем, есть ли данные в CRM (для автоматического подтверждения)
// Если contact_id > 0, значит данные уже есть в CRM - подтверждаем автоматически
const has_crm_data = contact_id && parseInt(contact_id) > 0;
// Формируем данные для PostgreSQL
return {
unified_id: unified_id,
contact_id: contact_id,
has_crm_data: has_crm_data,
// Флаг для SQL функции
should_confirm: true, // Всегда подтверждаем после сохранения формы
confirmed_at: new Date().toISOString()
};
// ============================================================================
// SQL запрос для PostgreSQL ноды (после этого Code Node):
// ============================================================================
// SELECT clpr_set_contact_data_confirmed($1, $2::timestamptz);
//
// Параметры:
// $1 = {{ $json.unified_id }}
// $2 = {{ $json.confirmed_at }}
//
// ИЛИ для автоматического подтверждения существующих данных:
// SELECT clpr_auto_confirm_if_crm_has_data($1, $2::integer);
//
// Параметры:
// $1 = {{ $json.unified_id }}
// $2 = {{ $json.contact_id }}

View File

@@ -0,0 +1,150 @@
# Настройка n8n Workflow для обработки описания проблемы
## Проблема
После отправки описания проблемы форма "тупит" на шаге рекомендаций. Это происходит потому, что n8n не обрабатывает событие из Redis канала.
## Текущий поток данных
1. **Frontend** отправляет описание на `/api/v1/claims/description`
2. **Backend** публикует событие в Redis канал `ticket_form:description`
3. **Frontend** подписывается на SSE `/api/v1/events/{session_id}` (слушает канал `ocr_events:{session_id}`)
4. **n8n** должен:
- Подписаться на канал `ticket_form:description` (или получить событие из него)
- Обработать описание и сгенерировать `wizard_plan`
- Опубликовать `wizard_plan` в канал `ocr_events:{session_id}` через POST `/api/v1/events/{session_id}`
## Структура события в Redis канале `ticket_form:description`
```json
{
"type": "ticket_form_description",
"session_id": "sess_xxx",
"claim_id": "claim_id_xxx" или null,
"phone": "79262306381",
"email": "user@example.com",
"description": "Описание проблемы...",
"source": "ticket_form",
"timestamp": "2025-11-25T12:30:36.262855"
}
```
## Настройка n8n Workflow
### Шаг 1: Redis Subscribe Node
1. Добавьте **Redis Subscribe** node
2. Настройте подключение к Redis:
- Host: `crm.clientright.ru` (или IP вашего Redis)
- Port: `6379`
- Password: `CRM_Redis_Pass_2025_Secure!`
3. Channel: `ticket_form:description`
4. Output: `JSON`
### Шаг 2: Обработка описания
После получения события из Redis:
1. Извлеките `session_id` из события: `{{ $json.session_id }}`
2. Извлеките `description` из события: `{{ $json.description }}`
3. Обработайте описание (AI, RAG и т.д.)
4. Сгенерируйте `wizard_plan`
### Шаг 3: Сохранение wizard_plan в PostgreSQL
Сохраните `wizard_plan` в таблицу `clpr_claims` используя SQL скрипт (например, `SQL_CLAIMSAVE_UPSERT_SIMPLE.sql`).
### Шаг 4: Публикация wizard_plan обратно в Redis
**ВАЖНО:** После генерации `wizard_plan` нужно опубликовать событие обратно в Redis канал `ocr_events:{session_id}`.
Используйте **HTTP Request** node:
- **Method:** POST
- **URL:** `http://147.45.146.17:8200/api/v1/events/{{ $json.session_id }}`
- **Headers:**
```json
{
"Content-Type": "application/json"
}
```
- **Body (JSON):**
```json
{
"event_type": "wizard_ready",
"status": "ready",
"message": "Wizard plan готов",
"data": {
"claim_id": "{{ $json.claim_id }}",
"wizard_plan": {{ $json.wizard_plan }},
"answers_prefill": {{ $json.answers_prefill }},
"coverage_report": {{ $json.coverage_report }}
},
"timestamp": "{{ $now.toISO() }}"
}
```
**Альтернатива:** Используйте **Redis Publish** node напрямую:
- Channel: `ocr_events:{{ $json.session_id }}`
- Message (JSON):
```json
{
"event_type": "wizard_ready",
"status": "ready",
"message": "Wizard plan готов",
"data": {
"claim_id": "{{ $json.claim_id }}",
"wizard_plan": {{ $json.wizard_plan }},
"answers_prefill": {{ $json.answers_prefill }},
"coverage_report": {{ $json.coverage_report }}
},
"timestamp": "{{ $now.toISO() }}"
}
```
## Проверка работы
1. Откройте консоль браузера (F12)
2. Отправьте описание проблемы
3. Проверьте логи backend:
```bash
docker-compose logs -f ticket_form_backend | grep -E "📝|📡|description"
```
4. Проверьте, что событие опубликовано в Redis:
```bash
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PUBSUB CHANNELS "ticket_form:*"
```
5. Проверьте, что n8n получил событие (в логах n8n workflow)
6. Проверьте, что n8n опубликовал `wizard_plan` обратно в канал `ocr_events:{session_id}`
## Типичные проблемы
### Проблема 1: n8n не получает события из Redis
**Решение:** Проверьте, что Redis Subscribe node правильно настроен и подключен к правильному каналу `ticket_form:description`.
### Проблема 2: Frontend не получает wizard_plan
**Решение:** Проверьте, что n8n публикует событие в правильный канал `ocr_events:{session_id}` (не `ocr_events:session_id`, а `ocr_events:{session_id}` где `{session_id}` - это значение из события).
### Проблема 3: Неправильный формат события
**Решение:** Убедитесь, что событие содержит поле `event_type: "wizard_ready"` и `status: "ready"`. Backend ожидает этот формат.
## Пример полного workflow в n8n
```
Redis Subscribe (ticket_form:description)
Code Node (обработка описания)
AI/RAG Node (генерация wizard_plan)
PostgreSQL Node (сохранение wizard_plan)
HTTP Request Node (POST /api/v1/events/{session_id})
или
Redis Publish Node (ocr_events:{session_id})
```

View File

@@ -0,0 +1,120 @@
# Настройка n8n Workflow для обработки подтвержденных форм
## Описание
После того, как пользователь подтвердил форму и прошел SMS-верификацию, данные публикуются в Redis канал `clientright:webform:approve`. n8n workflow должен:
1. Подписаться на Redis канал `clientright:webform:approve`
2. Обработать данные формы
3. Отметить форму как подтвержденную в PostgreSQL (чтобы она больше не показывалась в черновиках)
## Структура данных в Redis канале
```json
{
"event_type": "form_approve",
"status": "approved",
"message": "Форма подтверждена после SMS-верификации",
"claim_id": "0eb051ec-23a6-4e06-8b98-f02d20d35f68",
"session_token": "sess_xxx",
"unified_id": "usr_xxx",
"phone": "79262306381",
"sms_code": "123456",
"sms_verified": true,
"idempotency_key": "claim_id_timestamp_user_id",
"timestamp": "2025-11-25T12:30:36.262855",
"form_data": { /* данные формы */ },
"user": { /* данные пользователя */ },
"project": { /* данные проекта */ },
"offenders": [ /* нарушители */ ],
"meta": { /* метаданные */ }
}
```
## Настройка n8n Workflow
### Шаг 1: Redis Subscribe Node
1. Добавьте **Redis Subscribe** node
2. Настройте подключение к Redis:
- Host: `crm.clientright.ru` (или IP вашего Redis)
- Port: `6379`
- Password: `CRM_Redis_Pass_2025_Secure!`
3. Channel: `clientright:webform:approve`
4. Output: `JSON`
### Шаг 2: Обработка данных
После получения данных из Redis канала:
1. **Parse JSON** (если нужно)
2. **Обработайте данные формы** (сохранение в CRM, отправка уведомлений и т.д.)
3. **Отметьте форму как подтвержденную** (см. Шаг 3)
### Шаг 3: Отметка формы как подтвержденной
Используйте **PostgreSQL** node с SQL скриптом из `SQL_MARK_FORM_APPROVED.sql`:
```sql
-- Используйте claim_id из данных Redis события
WITH claim_lookup AS (
SELECT
c.id,
c.payload,
c.status_code,
c.is_confirmed
FROM clpr_claims c
WHERE c.id::text = '{{ $json.claim_id }}'::text
OR c.payload->>'claim_id' = '{{ $json.claim_id }}'::text
ORDER BY
CASE WHEN c.id::text = '{{ $json.claim_id }}'::text THEN 1 ELSE 2 END,
c.updated_at DESC
LIMIT 1
)
UPDATE clpr_claims c
SET
status_code = 'approved',
is_confirmed = true,
updated_at = now()
FROM claim_lookup cl
WHERE c.id = cl.id
RETURNING
c.id,
c.payload->>'claim_id' AS claim_id,
c.status_code,
c.is_confirmed,
c.updated_at;
```
**Параметры:**
- `{{ $json.claim_id }}` - claim_id из данных Redis события
**Результат:**
- Форма помечается как `status_code = 'approved'`
- Устанавливается `is_confirmed = true`
- Форма больше не будет показываться в списке черновиков (`/api/v1/claims/drafts/list`)
## Проверка работы
После обработки события в n8n:
1. Проверьте, что запись в `clpr_claims` обновлена:
```sql
SELECT id, status_code, is_confirmed, updated_at
FROM clpr_claims
WHERE payload->>'claim_id' = 'YOUR_CLAIM_ID';
```
2. Проверьте, что форма не показывается в черновиках:
```bash
curl "http://localhost:8200/api/v1/claims/drafts/list?unified_id=YOUR_UNIFIED_ID"
```
## Важные поля из Redis события
- `claim_id` - ID заявки (используется для обновления статуса)
- `sms_code` - SMS код, использованный для верификации (для аудита)
- `form_data` - данные формы подтверждения
- `user`, `project`, `offenders` - структурированные данные формы
- `idempotency_key` - ключ для защиты от дублей (для будущей интеграции с RabbitMQ)

225
docs/N8N_MEMORY_ISSUES.md Normal file
View 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)

View File

@@ -0,0 +1,74 @@
# Получение данных контакта из MySQL в n8n
## Задача
В n8n workflow нужно получить полные данные контакта из MySQL БД vtiger CRM перед формированием финального ответа.
## SQL запрос
**Файл:** `ticket_form/docs/N8N_POSTGRESQL_GET_CONTACT_DATA.sql` (название файла устарело, но запрос для MySQL)
```sql
SELECT
cd.contactid,
cd.firstname,
cd.lastname,
cd.email,
cd.mobile,
cd.phone,
cs.birthday,
ca.mailingstreet,
ca.mailingcity,
ca.mailingstate,
ca.mailingzip,
ca.mailingcountry,
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,
ccf.cf_2624 AS cf_2624
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 = ?
AND ce.deleted = 0
LIMIT 1;
```
## Настройка ноды MySQL в n8n
1. **Тип ноды:** MySQL
2. **Operation:** Execute Query
3. **Query:** (см. выше)
4. **Parameters:**
- `?` = `{{ JSON.parse($node["CreateWebContacКлиентправ"].json.result).contact_id }}`
## Credentials для MySQL
- **Host:** `localhost`
- **Port:** `3306`
- **Database:** `ci20465_72new`
- **User:** `ci20465_72new`
- **Password:** `EcY979Rn`
## Использование в Code node
После выполнения MySQL запроса, данные доступны в Code node:
```javascript
const pgContactNode = $('MySQL: Get Contact Data')?.first();
if (pgContactNode && pgContactNode.json && pgContactNode.json.length > 0) {
const contactFromDb = pgContactNode.json[0];
// Используем contactFromDb.cf_2624, contactFromDb.firstname, и т.д.
}
```
---
**Примечание:** Название файла `N8N_POSTGRESQL_GET_CONTACT_DATA.sql` устарело, но запрос работает для MySQL.

View File

@@ -0,0 +1,35 @@
-- SQL запрос для получения полных данных контакта из CRM
-- Используется в ноде MySQL перед Code in JavaScriptКлиентправ
-- ПРИМЕЧАНИЕ: Таблицы vtiger_* находятся в MySQL БД
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
ccf.cf_2624 AS cf_2624 -- ✅ Данные подтверждены
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 = ?
AND ce.deleted = 0
LIMIT 1;

View 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

View File

@@ -0,0 +1,62 @@
# Установка поля cf_2624 "Данные подтверждены" в n8n workflow
## Обновление workflow 6mxRJ2LLHmQXyaDz
### После подтверждения формы (после SMS-верификации)
**Добавить ноду:** `HTTP Request: Set Contact Data Confirmed`
**Параметры:**
- **Method:** POST
- **URL:** `{{ $env.CRM_WEBSERVICE_URL }}` (или полный URL CRM webservice)
- **Body Type:** form-data
**Body (form-data):**
```
operation: revise
sessionName: {{ $('Login to CRM').json.sessionName }}
id: 12x{{ JSON.parse($node['CreateWebContacКлиентправ'].json.result).contact_id }}
cf_2624: 1
```
**Где:**
- `cf_2624` - поле "Данные подтверждены" в CRM
- `1` = "Да" (данные подтверждены)
- `0` = "Нет" (данные не подтверждены)
---
## Альтернативный вариант: через Code Node
Если нужно более гибкое управление, можно использовать Code Node:
**Название:** `Code: Set Contact Data Confirmed`
**Код:**
```javascript
// Получаем contact_id из CreateWebContact
const contactResult = JSON.parse($node['CreateWebContacКлиентправ'].json.result);
const contact_id = contactResult.contact_id;
// Получаем sessionName из Login to CRM
const sessionName = $('Login to CRM').json.sessionName;
// Формируем данные для обновления
return {
operation: 'revise',
sessionName: sessionName,
id: `12x${contact_id}`,
cf_2624: '1' // Устанавливаем "Да" (данные подтверждены)
};
```
Затем подключить к **HTTP Request** ноде, которая отправит эти данные в CRM.
---
## Проверка работы:
1. После SMS-верификации и подтверждения формы
2. Проверить в CRM, что у контакта поле `cf_2624` = "Да"
3. При следующей загрузке черновика поля должны быть заблокированы

View File

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

View File

@@ -0,0 +1,147 @@
# Обновление n8n workflow: Использование cf_2624 из CreateWebContact
## Задача
При формировании заявления проверять значение `cf_2624` из ответа `CreateWebContact`:
- Если `cf_2624 = "0"` → данные можно редактировать
- Если `cf_2624 = "1"` → данные только для просмотра (readonly)
## Изменения в workflow 6mxRJ2LLHmQXyaDz
### 1. После ноды `CreateWebContacКлиентправ`
**Название ноды:** `Code: Extract Contact Data Confirmed`
**Код:**
```javascript
// Парсим результат CreateWebContact
const rawResult = $node["CreateWebContacКлиентправ"].json.result;
const contactData = JSON.parse(rawResult);
// Извлекаем cf_2624 (Данные подтверждены)
// "1" = данные подтверждены, "0" = не подтверждены
const cf_2624 = contactData.cf_2624 || "0";
const contact_data_confirmed = cf_2624 === "1";
console.log('🔒 Статус данных контакта:', {
contact_id: contactData.contact_id,
is_new: contactData.is_new,
cf_2624: cf_2624,
contact_data_confirmed: contact_data_confirmed
});
return {
contact_id: contactData.contact_id,
is_new_contact: contactData.is_new,
cf_2624: cf_2624,
contact_data_confirmed: contact_data_confirmed,
contact_data_can_edit: !contact_data_confirmed
};
```
---
### 2. В ноде `Code in JavaScriptКлиентправ` (формирование ответа для фронтенда)
**Добавить в return:**
```javascript
// Получаем данные о подтверждении из предыдущей ноды
const contactStatus = $('Code: Extract Contact Data Confirmed').first().json;
return {
// ... существующие поля ...
session: session_id,
session_id: session_id,
unified_id: unified_id,
contact_id: contactStatus.contact_id,
is_new_contact: contactStatus.is_new_contact,
// ✅ Флаги подтверждения данных контакта (из cf_2624)
contact_data_confirmed: contactStatus.contact_data_confirmed || false,
contact_data_can_edit: contactStatus.contact_data_can_edit !== false,
cf_2624: contactStatus.cf_2624 || "0",
// ... остальные поля ...
};
```
---
### 3. При загрузке черновика (если используется отдельный workflow)
**Если есть нода для загрузки черновика:**
```javascript
// Получаем contact_id из черновика
const contact_id = $json.contact_id || $json.payload?.contact_id;
if (contact_id) {
// Вызываем CreateWebContact для получения cf_2624
// (или используем retrieve из CRM)
// Для простоты можно использовать retrieve:
const retrieveResult = await $http.post('{{ $env.CRM_WEBSERVICE_URL }}', {
operation: 'retrieve',
sessionName: $('Login to CRM').json.sessionName,
id: `12x${contact_id}`
});
const cf_2624 = retrieveResult.result?.cf_2624 || "0";
const contact_data_confirmed = cf_2624 === "1";
return {
// ... данные черновика ...
contact_data_confirmed: contact_data_confirmed,
contact_data_can_edit: !contact_data_confirmed,
cf_2624: cf_2624
};
}
```
---
## Логика работы
1. **При создании/поиске контакта:**
- `CreateWebContact` возвращает `cf_2624` в ответе
- Извлекаем значение и передаём в ответе для фронтенда
2. **При загрузке черновика:**
- Backend API `/drafts/{claim_id}` уже получает `cf_2624` из CRM
- Фронтенд получает `contact_data_confirmed` из ответа API
- Передаёт в `StepClaimConfirmation``generateConfirmationFormHTML`
3. **При формировании заявления:**
- Если `cf_2624 = "1"` → поля персональных данных блокируются (readonly)
- Если `cf_2624 = "0"` → поля можно редактировать
---
## Проверка
1.`CreateWebContact` возвращает `cf_2624` в ответе
2. ⏳ n8n workflow извлекает `cf_2624` и передаёт в ответе
3. ⏳ Фронтенд получает `contact_data_confirmed` и блокирует поля
4. ⏳ Backend API `/drafts/{claim_id}` получает `cf_2624` из CRM
---
## Пример ответа от n8n:
```json
{
"success": true,
"result": {
"session": "sess_...",
"contact_id": "399542",
"unified_id": "usr_...",
"contact_data_confirmed": true,
"contact_data_can_edit": false,
"cf_2624": "1",
"is_new_contact": false
}
}
```

View File

@@ -0,0 +1,135 @@
# Конкретные изменения в workflow 6mxRJ2LLHmQXyaDz
## Что менять:
### 1. После ноды `user_get` → добавить PostgreSQL ноду (ПЕРВАЯ)
**Название ноды:** `PostgreSQL: Auto Confirm Contact Data`
**Параметры:**
- **Operation:** Execute Query
- **Query:**
```sql
SELECT clpr_auto_confirm_if_crm_has_data($1, $2::integer);
```
- **Parameters:**
- `$1` = `{{ $json.unified_id }}` ← используем данные из предыдущей ноды (user_get)
- `$2` = `{{ JSON.parse($node['CreateWebContacКлиентправ'].json.result).contact_id }}`
**Подключение:**
- `user_get``PostgreSQL: Auto Confirm Contact Data``Execute a SQL query2`
---
### 2. После ноды `PostgreSQL: Auto Confirm Contact Data` → добавить PostgreSQL ноду (ВТОРАЯ)
**Название ноды:** `PostgreSQL: Check Contact Data Status`
**Параметры:**
- **Operation:** Execute Query
- **Query:**
```sql
SELECT * FROM clpr_get_contact_data_status($1);
```
- **Parameters:**
- `$1` = `{{ $json.unified_id }}` ← unified_id передаётся дальше по цепочке
**Подключение:**
- `PostgreSQL: Auto Confirm Contact Data``PostgreSQL: Check Contact Data Status``Execute a SQL query2`
---
### 3. В ноде `Code in JavaScript` (та что перед `Respond to Webhook1`) → добавить флаг в ответ
**Найти эту строку:**
```javascript
// Unified ID из PostgreSQL (обязательно!)
unified_id: userData.unified_id, // из ноды user_get (PostgreSQL: Find or Create User)
```
**Добавить ПОСЛЕ неё:**
```javascript
// Флаг подтверждения данных контакта
contact_data_confirmed: $('PostgreSQL: Check Contact Data Status').first().json.is_confirmed || false,
contact_data_can_edit: $('PostgreSQL: Check Contact Data Status').first().json.can_edit !== false,
contact_data_confirmed_at: $('PostgreSQL: Check Contact Data Status').first().json.confirmed_at || null,
```
**Полный return должен быть:**
```javascript
return {
success: true,
result: {
session: $('Code in JavaScript3').first().json.session_id,
contact_id: sessionData.contact_id || claimResult.contact_id,
project_id: sessionData.project_id,
// Unified ID из PostgreSQL (обязательно!)
unified_id: userData.unified_id,
// Флаг подтверждения данных контакта
contact_data_confirmed: $('PostgreSQL: Check Contact Data Status').first().json.is_confirmed || false,
contact_data_can_edit: $('PostgreSQL: Check Contact Data Status').first().json.can_edit !== false,
contact_data_confirmed_at: $('PostgreSQL: Check Contact Data Status').first().json.confirmed_at || null,
// Данные заявки
ticket_id: claimResult.ticket_id,
ticket_number: claimResult.ticket_number,
title: claimResult.title,
category: claimResult.category,
status: claimResult.status,
// Метаданные
event_type: sessionData.event_type,
current_step: sessionData.current_step || 1,
updated_at: sessionData.updated_at || new Date().toISOString(),
// Дополнительно
is_new_contact: claimResult.is_new_contact || false
}
};
```
---
## Итого: 3 изменения
1. ✅ Добавить ноду `PostgreSQL: Auto Confirm Contact Data` после `CreateWebContacКлиентправ`
2. ✅ Добавить ноду `PostgreSQL: Check Contact Data Status` после `user_get`
3. ✅ Добавить 3 строки в `Code in JavaScript` перед `Respond to Webhook1`
---
## Порядок выполнения в workflow:
```
contact → Edit Fields → Get Challenge → ... → Login to CRM → form_id
CreateWebContacКлиентправ
[НОВАЯ] PostgreSQL: Auto Confirm Contact Data
Code in JavaScriptКлиентправ
user_get
[НОВАЯ] PostgreSQL: Check Contact Data Status
Execute a SQL query2
...
Code in JavaScript (← ДОБАВИТЬ ФЛАГИ)
Respond to Webhook1
```
---
## Проверка:
После изменений в ответе n8n должны быть поля:
- `contact_data_confirmed` (true/false)
- `contact_data_can_edit` (true/false)
- `contact_data_confirmed_at` (дата или null)

View File

@@ -0,0 +1,100 @@
# Добавление ноды PostgreSQL для получения данных контакта
## Задача
Добавить ноду PostgreSQL перед "Code in JavaScriptКлиентправ" для получения полных данных контакта из CRM.
## Шаги
### 1. Добавить ноду PostgreSQL
**Название ноды:** `PostgreSQL: Get Contact Data`
**Параметры:**
- **Operation:** Execute Query
- **Query:** (см. файл `N8N_POSTGRESQL_GET_CONTACT_DATA.sql`)
**SQL запрос:**
```sql
SELECT
cd.contactid,
cd.firstname,
cd.lastname,
cd.email,
cd.mobile,
cd.phone,
cs.birthday,
ca.mailingstreet,
ca.mailingcity,
ca.mailingstate,
ca.mailingzip,
ca.mailingcountry,
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,
ccf.cf_2624 AS cf_2624
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 = $1
AND ce.deleted = 0
LIMIT 1;
```
**Параметры запроса:**
- `$1` = `{{ JSON.parse($node["CreateWebContacКлиентправ"].json.result).contact_id }}`
---
### 2. Порядок нод в workflow
1. **CreateWebContacКлиентправ** → создаёт/находит контакт
2. **PostgreSQL: Get Contact Data** → получает полные данные контакта
3. **Code in JavaScriptКлиентправ** → использует данные из обеих нод
---
### 3. Что получает Code node
После добавления ноды PostgreSQL, Code node получит доступ к:
- `$('PostgreSQL: Get Contact Data').first().json` - полные данные контакта
**Доступные поля:**
- `contactid` - ID контакта
- `firstname`, `lastname` - ФИО
- `email`, `mobile`, `phone` - Контакты
- `birthday` - Дата рождения
- `mailingstreet`, `mailingcity`, etc. - Адрес
- `middle_name` (cf_1157) - Отчество
- `birthplace` (cf_1263) - Место рождения
- `inn` (cf_1257) - ИНН
- `requisites` (cf_1849) - Реквизиты
- `code` (cf_1580) - Код
- `sms` (cf_1706) - SMS
- `cf_2624` - Данные подтверждены
---
### 4. Использование в Code node
Код в "Code in JavaScriptКлиентправ" автоматически найдёт данные из PostgreSQL ноды и добавит их в `sessionData.contact_from_db`.
---
## Альтернатива: если нет доступа к PostgreSQL
Если нет прямого доступа к PostgreSQL, можно использовать HTTP Request к backend API:
**Название ноды:** `HTTP Request: Get Contact Data`
**Метод:** GET
**URL:** `{{ $env.BACKEND_URL }}/api/v1/contacts/{{ JSON.parse($node["CreateWebContacКлиентправ"].json.result).contact_id }}`
Но лучше использовать PostgreSQL напрямую для скорости.

View File

@@ -0,0 +1,87 @@
# Обновление workflow 6mxRJ2LLHmQXyaDz: Подтверждение данных контакта
## Изменения в workflow
### 1. После ноды `CreateWebContacКлиентправ`
**Добавить ноду:** `PostgreSQL: Auto Confirm if CRM has data`
**SQL запрос:**
```sql
SELECT clpr_auto_confirm_if_crm_has_data($1, $2::integer);
```
**Параметры:**
- `$1` = `{{ $('user_get').first().json.unified_id }}`
- `$2` = `{{ JSON.parse($node['CreateWebContacКлиентправ'].json.result).contact_id }}`
**Назначение:** Если данные уже есть в CRM (contact_id > 0), автоматически ставим флаг подтверждения.
---
### 2. После ноды `Code in JavaScriptКлиентправ`
**Добавить ноду:** `PostgreSQL: Check Contact Data Status`
**SQL запрос:**
```sql
SELECT * FROM clpr_get_contact_data_status($1);
```
**Параметры:**
- `$1` = `{{ $('user_get').first().json.unified_id }}`
**Назначение:** Проверяем, подтверждены ли данные. Результат передаём дальше.
---
### 3. В ответе для фронтенда (нода `Code in JavaScript`)
**Добавить в return:**
```javascript
const contactStatus = $('PostgreSQL: Check Contact Data Status').first().json;
return {
// ... существующие поля ...
contact_data_confirmed: contactStatus.is_confirmed || false,
contact_data_can_edit: contactStatus.can_edit !== false,
contact_data_confirmed_at: contactStatus.confirmed_at || null
};
```
---
### 4. После подтверждения формы (workflow для `claim_confirmed`)
**Добавить ноду:** `PostgreSQL: Set Contact Data Confirmed`
**SQL запрос:**
```sql
SELECT clpr_set_contact_data_confirmed($1, NOW());
```
**Параметры:**
- `$1` = `{{ $json.unified_id }}`
**Назначение:** Устанавливаем флаг подтверждения после успешного сохранения данных.
---
## Порядок выполнения
1. **Создание контакта**`CreateWebContacКлиентправ`
2. **Автоподтверждение** → Если данные есть в CRM → `clpr_auto_confirm_if_crm_has_data`
3. **Проверка статуса**`clpr_get_contact_data_status` → передаём фронтенду
4. **Фронтенд** → Если `contact_data_confirmed = true` → блокируем редактирование
5. **После подтверждения**`clpr_set_contact_data_confirmed` → устанавливаем флаг
---
## Проверка в n8n
После обновления workflow проверить:
- ✅ Флаг устанавливается при наличии данных в CRM
- ✅ Флаг устанавливается после подтверждения формы
- ✅ Статус передаётся фронтенду
- ✅ Фронтенд блокирует редактирование при `contact_data_confirmed = true`

View 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 (только уточняющие) |
| Конверсия | ? | ↑ (меньше отвала) |

View File

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

View File

@@ -0,0 +1,438 @@
# Лог сессии: Настройка RAG workflow для извлечения данных
**Дата:** 2025-11-29
**Workflow ID:** `itX62h38faB51y9J` ("6 ocr_check:attempt")
---
## 🎯 Цель сессии
Настроить workflow для автоматического извлечения данных из документов с использованием RAG (Retrieval-Augmented Generation) для заполнения формы заявления.
---
## 📊 Структура workflow
```
ocr_check:attempt (Redis Trigger)
clime_id (Set) → извлекает claim_id, session_id
analiz (Set) → добавляет prefix, session_token
give_data1 (PostgreSQL) → большой SQL, собирает все данные
Code1 (Code) → нормализует данные
prepare_rag_items (Code) → создаёт 3 items: user, project, offenders
Loop Over Items → итерация по типам
Code6 (Code) → генерация промптов для AI
AI Agent2 (LLM + RAG) → извлечение данных из документов
Code5 (Code) → парсинг JSON из LLM
Edit Fields4 → извлекает output
Aggregate → собирает все результаты
dataset (Set) → финальная сборка
```
---
## 📝 Обновлённые ноды
### 1. Code1 — нормализация данных из give_data1
```javascript
// Code1 — нормализация данных из give_data1
// ИСПРАВЛЕНО: извлекаем payload.applicant, ai_analysis, wizard_plan, полные documents
function toNullish(v) {
if (v === undefined || v === null) return null;
if (typeof v === 'string' && v.trim() === '') return null;
return v;
}
function pick(...vals) {
return vals.find(v => v !== undefined && v !== null && v !== '') ?? null;
}
function mapDocuments(docs = []) {
if (!docs || !Array.isArray(docs)) return [];
return docs.map(d => ({
id: toNullish(d.id),
claim_document_id: toNullish(d.id),
file_id: toNullish(d.file_id),
file_url: toNullish(d.file_url),
file_name: toNullish(d.file_name),
original_file_name: toNullish(d.original_file_name),
field_name: toNullish(d.field_name),
uploaded_at: toNullish(d.uploaded_at),
filename_for_upload: toNullish(d.filename_for_upload),
// AI данные
document_type: toNullish(d.document_type),
document_label: toNullish(d.document_label),
document_summary: toNullish(d.document_summary),
ocr_status: toNullish(d.ocr_status),
match_score: toNullish(d.match_score),
match_status: toNullish(d.match_status),
match_reason: toNullish(d.match_reason),
}));
}
function mapCombinedDocs(cds = []) {
if (!cds || !Array.isArray(cds)) return [];
return cds.map(c => ({
claim_document_id: toNullish(c.claim_document_id),
combined_document_id: toNullish(c.combined_document_id),
pages: toNullish(c.pages),
combined_text: toNullish(c.combined_text),
}));
}
function normalizeOne(src) {
const claim = src.claim ?? {};
const payload = claim.payload ?? {};
const userInfo = src.user_info ?? {};
// Извлекаем applicant из payload
const applicant = payload.applicant ?? {};
const aiAnalysis = payload.ai_analysis ?? {};
const answersPrefill = payload.answers_prefill ?? [];
const wizardPlan = payload.wizard_plan ?? null;
// USER: приоритет payload.applicant
const user = {
firstname: pick(applicant.firstname, applicant.first_name),
secondname: pick(applicant.middle_name, applicant.secondname),
lastname: pick(applicant.lastname, applicant.last_name),
mobile: pick(payload.phone),
email: pick(payload.email),
tgid: pick(claim.telegram_id, payload.tg_id),
birthday: pick(applicant.birthday, applicant.birth_date),
birthplace: pick(applicant.birthplace, applicant.birth_place),
mailingstreet: pick(applicant.address),
inn: pick(applicant.inn),
zip: pick(applicant.zip),
channel: pick(userInfo.channel, claim.channel),
unified_id: pick(claim.unified_id),
session_token: pick(claim.session_token),
};
// CASE
const caseData = {
id: toNullish(claim.id),
prefix: toNullish(claim.prefix),
channel: toNullish(claim.channel),
type_code: toNullish(claim.type_code),
status_code: toNullish(claim.status_code),
created_at: toNullish(claim.created_at),
updated_at: toNullish(claim.updated_at),
telegram_id: toNullish(claim.telegram_id),
session_token: toNullish(claim.session_token),
unified_id: toNullish(claim.unified_id),
case_type: pick(wizardPlan?.case_type, claim.type_code),
};
// ANSWERS
const answers = {};
if (Array.isArray(answersPrefill)) {
answersPrefill.forEach(a => {
if (a?.name && a?.value !== undefined) {
answers[a.name] = a.value;
}
});
}
// AI_ANALYSIS
const ai = {
problem: toNullish(aiAnalysis.problem),
facts_short: toNullish(aiAnalysis.facts_short),
facts_full: toNullish(aiAnalysis.facts_full),
recommendation: toNullish(aiAnalysis.recommendation),
};
const problemDescription = toNullish(payload.problem_description);
return {
case: caseData,
user,
answers: Object.keys(answers).length ? answers : null,
answers_prefill: answersPrefill.length ? answersPrefill : null,
ai_analysis: ai,
problem_description: problemDescription,
documents: mapDocuments(src.documents),
combined_docs: mapCombinedDocs(src.combined_docs),
wizard_plan: wizardPlan,
meta: {
claim_id: caseData.id,
session_token: caseData.session_token,
unified_id: caseData.unified_id,
}
};
}
const raw = items[0]?.json ?? {};
const arr = Array.isArray(raw) ? raw : [raw];
const results = arr.map(normalizeOne).map(obj => ({ json: obj }));
return results.length ? results : [{ json: null }];
```
---
### 2. Code6 — генерация промптов для RAG
```javascript
// n8n Code node: Генерация prompt'а под конкретный тип
// ВХОД: { type: 'user'|'project'|'offenders', data: {...} }
const type = $json.type;
const data = $json.data;
const code1Data = (() => {
try {
return $('Code1').first().json || {};
} catch(_) {
return {};
}
})();
const aiAnalysis = code1Data.ai_analysis || {};
const problemDescription = code1Data.problem_description || '';
const wizardPlan = code1Data.wizard_plan || {};
const caseType = wizardPlan.case_type || code1Data.case?.type_code || 'consumer';
let schema = '';
let searchHints = '';
let contextInfo = '';
contextInfo = `
КОНТЕКСТ ДЕЛА:
- Тип: ${caseType}
- Проблема: ${aiAnalysis.problem || 'не указана'}
- Краткие факты: ${aiAnalysis.facts_short || 'не указаны'}
`;
if (type === 'user') {
schema = `{
"user": {
"firstname": string|null,
"secondname": string|null,
"lastname": string|null,
"mobile": string|null,
"email": string|null,
"tgid": number|null,
"birthday": "YYYY-MM-DD"|null,
"birthplace": string|null,
"mailingstreet": string|null,
"inn": string|null (12 цифр для физлица)
}
}`;
searchHints = `Ищи данные ПОКУПАТЕЛЯ/ЗАКАЗЧИКА:
- ФИО: после "Покупатель:", "Заказчик:", "Потребитель:"
- Адрес: "адрес регистрации", "адрес проживания", "место жительства"
- ИНН физлица = 12 цифр
- Телефон: в реквизитах, после "тел:", "моб:"
- Email: в реквизитах`;
} else if (type === 'project') {
schema = `{
"project": {
"category": string|null (тема обращения),
"direction": string|null,
"agrprice": number|null (сумма в рублях, только цифры!),
"subject": string|null (предмет договора - что купили/заказали),
"agrdate": "YYYY-MM-DD"|null (дата заключения договора),
"startdate": "YYYY-MM-DD"|null (дата начала услуги/поездки),
"finishdate": "YYYY-MM-DD"|null (дата окончания),
"country": string|null (страна для турпутёвок),
"hotel": string|null (название отеля),
"transport": "да"|"нет"|null (включён ли трансфер),
"insurance": "да"|"нет"|null (включена ли страховка),
"description": string|null (краткое описание сделки)
}
}`;
searchHints = `Ищи данные ДОГОВОРА/СДЕЛКИ:
- Сумма: "Цена договора", "Стоимость", "Итого к оплате", "Сумма заказа"
- Дата: "Дата заключения", "Договор № ... от ...", "Заказ от ..."
- Предмет: что именно купили или заказали (товар, услуга, тур)
- Для туров: страна, отель, даты заезда/выезда`;
} else if (type === 'offenders') {
schema = `{
"offenders": [
{
"role": "seller"|"service_provider"|"tour_agent"|"tour_operator"|"delivery"|"installer"|"intermediary"|null,
"accountname": string|null (название организации или ФИО ИП),
"address": string|null (юридический адрес),
"email": string|null,
"website": string|null,
"phone": string|null,
"inn": string|null (10 цифр для юрлица, 12 для ИП),
"ogrn": string|null (13 цифр ОГРН или 15 цифр ОГРНИП)
}
]
}`;
searchHints = `Ищи данные ВСЕХ КОНТРАГЕНТОВ (может быть несколько!):
ГДЕ ИСКАТЬ:
- "Продавец:", "Исполнитель:", "Поставщик:"
- После ООО, ИП, ЗАО, ОАО, ПАО, АО
- В реквизитах договора, в шапке чека
РЕКВИЗИТЫ:
- ИНН юрлица = 10 цифр, ИП = 12 цифр
- ОГРН = 13 цифр, ОГРНИП = 15 цифр
РОЛИ (определи по контексту):
- seller — продавец товара (магазин, салон)
- service_provider — исполнитель услуги
- tour_agent — турагент (кто продал путёвку)
- tour_operator — туроператор (кто организует тур, указан в договоре отдельно)
- delivery — служба доставки
- installer — сборщик/установщик
- intermediary — посредник, маркетплейс
ВАЖНО: Если в документах несколько организаций — добавь всех!`;
}
const filledCount = Object.values(data || {}).filter(v => v !== null && v !== undefined && v !== '').length;
const totalCount = Object.keys(data || {}).length;
return [{
json: {
systemMessage: `Ты — юридический помощник-экстрактор. У тебя есть инструмент vectorStore для поиска по документам.
${contextInfo}
${searchHints}
ПРАВИЛА:
1. Ищи только поля из схемы ниже
2. Возвращай строго JSON в указанном формате
3. Если данные не найдены — ставь null
4. НЕ ПРИДУМЫВАЙ данные!
5. Дозаполняй только пустые/null поля`,
userMessage: `Текущие данные (заполнено ${filledCount} из ${totalCount}, дозаполни остальное):
${JSON.stringify(data, null, 2)}
Схема для ответа:
${schema}`,
_meta: {
type,
filledCount,
totalCount,
caseType
}
}
}];
```
---
### 3. prepare_rag_items — создание 3 items для RAG (НУЖНО ДОБАВИТЬ!)
```javascript
// Code node: prepare_rag_items
// Создаёт 3 items для RAG: user, project, offenders
// Вставить между Code1 и Loop Over Items
const src = $('Code1').first().json;
// USER — из уже собранных данных Code1
const userData = src.user || {};
// PROJECT — пока пустой, RAG заполнит
const projectData = {
category: null,
direction: null,
agrprice: null,
subject: null,
agrdate: null,
startdate: null,
finishdate: null,
country: null,
hotel: null,
transport: null,
insurance: null,
description: src.problem_description || null,
};
// OFFENDERS — пока пустой массив, RAG найдёт
const offendersData = [];
// Выдаём 3 items для Loop Over Items
return [
{ json: { type: 'user', data: userData } },
{ json: { type: 'project', data: projectData } },
{ json: { type: 'offenders', data: offendersData } }
];
```
---
## ✅ Результат работы workflow
Тестовый запуск на claim `509872e2-9666-4c5e-8ab7-2304dd6a5d18`:
### USER — полностью заполнен
- firstname: Федор
- secondname: Владимирович
- lastname: Коробков
- mobile: 79262306381
- email: help@clientright.ru
- tgid: 295410106
- birthday: 1981-09-18
- birthplace: Москва
- mailingstreet: МО, г. Балашиха, мкр. Железнодорожный, ул. Советская, д.20, кв. 52
### PROJECT — основное заполнено
- category: задержка ремонта/недоставка комплектующих и отказ в оказании услуги сборки
- agrprice: **89620** (сумма договора)
- subject: кровать-подиум Hemwood Base 180х200 и тумбы к ней
- agrdate: **2025-08-09** (дата договора)
- startdate: **2025-08-16** (дата доставки)
- description: полное описание проблемы
### OFFENDERS — найдено 2 контрагента!
**1. Продавец (seller):**
- accountname: ИП Хациев Зелимхан Зелимханович
- inn: 201471261963 (12 цифр — ИП ✅)
- ogrn: 315774600000123 (15 цифр — ОГРНИП ✅)
- website: raiton.ru
**2. Исполнитель услуг (service_provider):**
- accountname: АО «ОРМАТЕК»
- inn: 7724890784 (10 цифр — юрлицо ✅)
- email: kassa@ormatek.com
---
## 📋 TODO (следующие шаги)
1. [ ] Добавить ноду `prepare_rag_items` между Code1 и Loop Over Items
2. [ ] Добавить постобработку данных (валидация, исправление ошибок AI)
3. [ ] Сохранение результата в Redis для формы
4. [ ] Подключить к генерации формы заявления
---
## 📁 Связанные файлы
- `ticket_form/docs/SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED_FIXED.sql` — SQL для сохранения документов
- `ticket_form/frontend/src/components/form/generateConfirmationFormHTML.ts` — шаблон формы заявления

View File

@@ -0,0 +1,281 @@
# Session Log 2025-12-01
## Сессия: UI/UX улучшения + CRM интеграция + Исправление дубликатов
### Участники
- Пользователь: Фёдор
- AI: Claude (Cursor)
---
## 1. UI/UX Улучшения формы заявки
### 1.1 Изменение заголовков
- **Главный заголовок формы**: "Подать заявку на выплату" → "Подать обращение о защите прав потребителя"
- **Заголовок вкладки браузера**: "ERV Insurance Platform" → "Clientright — защита прав потребителей"
**Файлы:**
- `ticket_form/frontend/src/pages/ClaimForm.tsx`
- `ticket_form/frontend/index.html`
- `ticket_form/frontend/public/index.html`
### 1.2 Улучшение отображения черновиков
**Файлы:**
- `ticket_form/frontend/src/components/form/StepDraftSelection.tsx`
- `ticket_form/backend/app/api/claims.py`
**Изменения:**
- Описание проблемы: увеличено до 250 символов, многострочное отображение
- Добавлен заголовок проблемы (`problem_title` из `ai_analysis.problem`)
- Добавлена категория (`category`) как фиолетовый тег
- Прогресс-бар документов: `X / Y` с иконками статуса (✓ зелёный / ○ красный/серый)
- Удалены избыточные теги "✓ Описание", "✓ План", "✓ Документы"
- Убран дублирующий "++" из кнопки "Создать новую заявку"
### 1.3 Исправление навигации
- Кнопка "Назад" теперь всегда возвращает к списку черновиков (Step 0)
- Пропуск шагов "Проверка полиса" и "Тип события" для нового флоу (`documents_required` present)
**Файлы:**
- `ticket_form/frontend/src/pages/ClaimForm.tsx`
- `ticket_form/frontend/src/components/form/StepWizardPlan.tsx`
### 1.4 Переименование шагов
```
'Телефон' → 'Вход'
'Описание' → 'Обращение'
'Рекомендации' → 'Документы'
'Оплата' → 'Заявление'
```
---
## 2. Backend улучшения
### 2.1 Исправление IP клиента
**Проблема:** Отображался Docker IP `192.168.0.1`
**Решение:** Добавлена функция `_get_client_ip()` с проверкой заголовков:
```python
def _get_client_ip(request: Request) -> str:
# 1. X-Forwarded-For
# 2. X-Real-IP
# 3. request.client.host (fallback)
```
**Файл:** `ticket_form/backend/app/api/documents.py`
### 2.2 Расширение API списка черновиков
**Добавлены поля:**
- `problem_title` - заголовок проблемы
- `category` - категория
- `documents_total` - всего документов
- `documents_uploaded` - загружено (уникальных типов)
- `documents_skipped` - пропущено
- `documents_required_list` - детальный список с статусами
**Файл:** `ticket_form/backend/app/api/claims.py`
### 2.3 SSE для OCR статуса
Добавлено подключение SSE для получения статуса OCR обработки после загрузки документов.
**Файл:** `ticket_form/frontend/src/components/form/StepWizardPlan.tsx`
---
## 3. CRM Webservices (PHP)
### 3.1 UpsertContact.php
**Назначение:** Создание/обновление контакта с поддержкой `tgid`
**Приоритет поиска:**
1. `contact_id` - если передан
2. `mobile` - поиск по телефону
3. `tgid` - поиск по Telegram ID
**Параметры:** `contact_json` (JSON строка)
**Регистрация в БД:**
```sql
INSERT INTO vtiger_ws_operation (operationid, name, handler_path, handler_method, type, prelogin)
VALUES (57, 'UpsertContact', 'include/Webservices/UpsertContact.php', 'vtws_upsertcontact', 'POST', 0);
INSERT INTO vtiger_ws_operation_parameters (operationid, name, type, sequence)
VALUES (57, 'contact_json', 'string', 1);
```
### 3.2 UpsertAccounts.php
**Назначение:** Пакетное создание/поиск контрагентов (offenders) по ИНН
**Логика:**
- Поиск по ИНН
- Если найден - возвращает ID без обновления
- Если не найден - создаёт новый
**Параметры:** `offenders_json` (JSON массив)
**Регистрация в БД:**
```sql
INSERT INTO vtiger_ws_operation (operationid, name, handler_path, handler_method, type, prelogin)
VALUES (58, 'UpsertAccounts', 'include/Webservices/UpsertAccounts.php', 'vtws_upsertaccounts', 'POST', 0);
INSERT INTO vtiger_ws_operation_parameters (operationid, name, type, sequence)
VALUES (58, 'offenders_json', 'string', 1);
```
### 3.3 UpsertProject.php
**Назначение:** Создание/обновление проекта с маппингом ответчиков
**Параметры:** `project_json` (JSON объект содержит):
- `project_id` - ID проекта для обновления
- `claim_id` - ID заявки
- `contact_id` - ID контакта
- `result` - JSON строка с `offender_ids`
- `projectdata` - данные проекта
**Маппинг ответчиков:**
- Первый ответчик → `cf_2274` (основной)
- Второй ответчик → `cf_2276` (агент)
**Регистрация в БД:**
```sql
INSERT INTO vtiger_ws_operation (operationid, name, handler_path, handler_method, type, prelogin)
VALUES (59, 'UpsertProject', 'include/Webservices/UpsertProject.php', 'vtws_upsertproject', 'POST', 0);
INSERT INTO vtiger_ws_operation_parameters (operationid, name, type, sequence)
VALUES (59, 'project_json', 'string', 1);
```
---
## 4. Исправление дубликатов documents_meta
### 4.1 Проблема
В `documents_meta` накапливались дубликаты при каждой загрузке документа.
**Причина:** SQL-запрос использовал простую конкатенацию `||` без дедупликации:
```sql
'{documents_meta}',
COALESCE(...новые...) || COALESCE(...старые...)
```
### 4.2 Найденные дубликаты
| claim_id | Было записей | Уникальных |
|----------|--------------|------------|
| `bddb6815-8e17-4d54-a721-5e94382942c7` | 11 | 5 |
| `226564ce-d7cf-48ee-a820-690e8f5ec8e5` | 3 | 2 |
| `509872e2-9666-4c5e-8ab7-2304dd6a5d18` | 4 | 3 |
| `ef853bac-f54b-46aa-adf8-f0c9c0cd76bc` | 4 | 3 |
### 4.3 SQL для исправления
```sql
-- Дедупликация по field_name (оставляем последний файл)
UPDATE clpr_claims
SET payload = jsonb_set(
payload,
'{documents_meta}',
(
SELECT COALESCE(
jsonb_agg(doc ORDER BY (doc->>'uploaded_at') DESC NULLS LAST),
'[]'::jsonb
)
FROM (
SELECT DISTINCT ON (doc->>'field_name') doc
FROM jsonb_array_elements(payload->'documents_meta') doc
ORDER BY doc->>'field_name', (doc->>'uploaded_at') DESC NULLS LAST
) unique_docs
),
true
),
updated_at = now()
WHERE id IN (...);
```
### 4.4 Исправленный SQL для загрузки документов
Добавлен CTE `documents_meta_dedup`:
```sql
documents_meta_dedup AS (
SELECT COALESCE(
(
SELECT jsonb_agg(doc ORDER BY (doc->>'uploaded_at') DESC NULLS LAST)
FROM (
SELECT DISTINCT ON (doc->>'field_name', doc->>'file_id') doc
FROM (
-- Новые записи (приоритет 1)
SELECT jsonb_array_elements(...) AS doc, 1 AS priority
UNION ALL
-- Существующие записи (приоритет 2)
SELECT jsonb_array_elements(...) AS doc, 2 AS priority
) all_docs
ORDER BY doc->>'field_name', doc->>'file_id', priority
) unique_docs
),
'[]'::jsonb
) AS documents_meta
)
```
---
## 5. n8n Workflows
### 5.1 Проблема с Redis каналами
**Бэкенд публикует:** `clpr:check:ocr_status`
**n8n слушает:**
- `fnSo3FTTbQcMjwt3``clpr:ocr:clime_file`
- `1IKe2PccqXLkD2KR``clpr:ocr:jobs`
**Нужно:** Либо создать новый workflow для `clpr:check:ocr_status`, либо изменить канал в бэкенде.
---
## 6. Файлы изменены
### Frontend
- `ticket_form/frontend/index.html`
- `ticket_form/frontend/public/index.html`
- `ticket_form/frontend/src/pages/ClaimForm.tsx`
- `ticket_form/frontend/src/components/form/StepDraftSelection.tsx`
- `ticket_form/frontend/src/components/form/StepWizardPlan.tsx`
- `ticket_form/frontend/src/components/form/generateConfirmationFormHTML.ts`
### Backend
- `ticket_form/backend/app/api/claims.py`
- `ticket_form/backend/app/api/documents.py`
- `ticket_form/backend/app/api/events.py`
### CRM
- `include/Webservices/UpsertContact.php` (NEW)
- `include/Webservices/UpsertAccounts.php` (NEW)
- `include/Webservices/UpsertProject.php` (NEW)
---
## 7. Коммит
```
git commit -m "feat: UI/UX improvements + CRM integration methods + documents_meta deduplication"
Commit: da82100b
12 files changed, 1531 insertions(+), 145 deletions(-)
```
---
## 8. Нерешённые задачи
1. **n8n workflow для `clpr:check:ocr_status`** - нужно либо создать новый, либо изменить канал
2. **Обновить SQL в бэкенде** - заменить SQL загрузки документов на версию с дедупликацией
3. **Обновить n8n ноды** для использования новых CRM методов:
- `Create Contact``UpsertContact`
- `Create Account``UpsertAccounts`
- `Create Project``UpsertProject`
---
## Метаданные сессии
- **Дата:** 2025-12-01
- **Продолжительность:** ~2 часа
- **Основной фокус:** UI/UX, CRM интеграция, исправление дубликатов

View File

@@ -0,0 +1,141 @@
-- ============================================================================
-- SQL миграция: Добавление флага подтверждения данных контакта
-- ============================================================================
-- Назначение: Предотвратить изменение данных контакта после первого подтверждения
--
-- Логика:
-- 1. При первом подтверждении формы ставим contact_data_confirmed_at = NOW()
-- 2. Если данные уже есть в CRM (созданы менеджером) - считаем подтверждёнными
-- 3. При следующих обращениях проверяем флаг и блокируем редактирование
-- 4. При изменении данных обновляем timestamp
-- ============================================================================
-- 1. Добавляем поле contact_data_confirmed_at в clpr_users
ALTER TABLE clpr_users
ADD COLUMN IF NOT EXISTS contact_data_confirmed_at TIMESTAMPTZ;
-- 2. Создаём индекс для быстрого поиска
CREATE INDEX IF NOT EXISTS idx_clpr_users_contact_data_confirmed
ON clpr_users(contact_data_confirmed_at)
WHERE contact_data_confirmed_at IS NOT NULL;
-- 3. Комментарий к полю
COMMENT ON COLUMN clpr_users.contact_data_confirmed_at IS
'Дата и время подтверждения данных контакта пользователем. Если NULL - данные можно редактировать. Если NOT NULL - данные только для чтения.';
-- ============================================================================
-- Функция: Проверка, подтверждены ли данные контакта
-- ============================================================================
CREATE OR REPLACE FUNCTION clpr_is_contact_data_confirmed(p_unified_id VARCHAR)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1
FROM clpr_users
WHERE unified_id = p_unified_id
AND contact_data_confirmed_at IS NOT NULL
);
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- Функция: Установить флаг подтверждения данных
-- ============================================================================
CREATE OR REPLACE FUNCTION clpr_set_contact_data_confirmed(
p_unified_id VARCHAR,
p_confirmed_at TIMESTAMPTZ DEFAULT NOW()
)
RETURNS VOID AS $$
BEGIN
UPDATE clpr_users
SET contact_data_confirmed_at = p_confirmed_at,
updated_at = NOW()
WHERE unified_id = p_unified_id;
-- Если пользователь не найден - создаём запись (на всякий случай)
IF NOT FOUND THEN
INSERT INTO clpr_users (unified_id, contact_data_confirmed_at, created_at, updated_at)
VALUES (p_unified_id, p_confirmed_at, NOW(), NOW())
ON CONFLICT (unified_id) DO UPDATE
SET contact_data_confirmed_at = p_confirmed_at,
updated_at = NOW();
END IF;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- Функция: Проверка и автоматическая установка флага для существующих контактов
-- ============================================================================
-- Если в CRM уже есть данные контакта (firstname, lastname, inn и т.д. заполнены),
-- считаем их подтверждёнными автоматически
--
-- ВАЖНО: Эта функция должна вызываться после синхронизации данных из CRM
-- ============================================================================
CREATE OR REPLACE FUNCTION clpr_auto_confirm_if_crm_has_data(
p_unified_id VARCHAR,
p_contact_id INTEGER
)
RETURNS VOID AS $$
DECLARE
v_has_data BOOLEAN;
BEGIN
-- Проверяем, есть ли уже подтверждённые данные
IF EXISTS (
SELECT 1 FROM clpr_users
WHERE unified_id = p_unified_id
AND contact_data_confirmed_at IS NOT NULL
) THEN
RETURN; -- Уже подтверждено
END IF;
-- Проверяем наличие данных в CRM через webservice
-- Если contact_id передан и > 0, считаем что данные есть в CRM
-- (это упрощённая проверка, можно расширить через API)
IF p_contact_id IS NOT NULL AND p_contact_id > 0 THEN
-- Устанавливаем флаг подтверждения
PERFORM clpr_set_contact_data_confirmed(p_unified_id);
END IF;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- Функция: Получить статус подтверждения данных
-- ============================================================================
CREATE OR REPLACE FUNCTION clpr_get_contact_data_status(p_unified_id VARCHAR)
RETURNS TABLE(
is_confirmed BOOLEAN,
confirmed_at TIMESTAMPTZ,
can_edit BOOLEAN
) AS $$
BEGIN
RETURN QUERY
SELECT
COALESCE(u.contact_data_confirmed_at IS NOT NULL, false) AS is_confirmed,
u.contact_data_confirmed_at AS confirmed_at,
COALESCE(u.contact_data_confirmed_at IS NULL, true) AS can_edit
FROM clpr_users u
WHERE u.unified_id = p_unified_id;
-- Если пользователь не найден - возвращаем false (можно редактировать)
IF NOT FOUND THEN
RETURN QUERY SELECT false, NULL::TIMESTAMPTZ, true;
END IF;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- Примеры использования:
-- ============================================================================
-- 1. Проверить, подтверждены ли данные
-- SELECT clpr_is_contact_data_confirmed('usr_abc123...');
-- 2. Установить флаг подтверждения
-- SELECT clpr_set_contact_data_confirmed('usr_abc123...');
-- 3. Получить статус
-- SELECT * FROM clpr_get_contact_data_status('usr_abc123...');
-- 4. Автоматически подтвердить, если данные есть в CRM
-- SELECT clpr_auto_confirm_if_crm_has_data('usr_abc123...', 396625);

View File

@@ -0,0 +1,379 @@
-- ============================================================================
-- SQL для сохранения claim при пропуске документа (document skip) - НОВЫЙ ФЛОУ
-- ============================================================================
-- Проблема: При пропуске документа нужно обновить documents_skipped и current_doc_index
-- Решение: Добавляем документ в documents_skipped, обновляем current_doc_index и status_code
-- ============================================================================
WITH partial AS (
SELECT
$1::jsonb AS p,
$2::text AS claim_id_str
),
existing_claim AS (
SELECT
id,
session_token,
unified_id,
contact_id,
phone,
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
),
-- Парсим информацию о пропущенном документе
skipped_document_info AS (
SELECT
COALESCE(
partial.p->>'document_type',
partial.p->'body'->>'document_type',
partial.p->'edit_fields_raw'->'body'->>'document_type',
partial.p->'edit_fields_parsed'->'body'->>'document_type'
) AS document_type,
COALESCE(
partial.p->>'document_name',
partial.p->'body'->>'document_name',
partial.p->'edit_fields_raw'->'body'->>'document_name',
partial.p->'edit_fields_parsed'->'body'->>'document_name'
) AS document_name,
COALESCE(
(partial.p->>'group_index')::int,
(partial.p->'body'->>'group_index')::int,
(partial.p->'edit_fields_raw'->'body'->>'group_index')::int,
(partial.p->'edit_fields_parsed'->'body'->>'group_index')::int,
NULL
) AS group_index
FROM partial
),
-- Парсим 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 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
-- Если документ уже есть в списке пропущенных - ОБНОВЛЯЕМ его (добавляем group_index, если его нет)
WHEN EXISTS (
SELECT 1
FROM existing_claim
WHERE payload->'documents_skipped' @> jsonb_build_array(
jsonb_build_object(
'id', (SELECT document_type FROM skipped_document_info)
)
)
)
THEN (
-- Удаляем старую запись и добавляем новую с group_index
SELECT jsonb_agg(
CASE
WHEN doc->>'id' = (SELECT document_type FROM skipped_document_info)
THEN jsonb_build_object(
'id', (SELECT document_type FROM skipped_document_info),
'name', COALESCE((SELECT document_name FROM skipped_document_info), (SELECT document_type FROM skipped_document_info)),
'group_index', (SELECT group_index FROM skipped_document_info),
'skipped_at', COALESCE(doc->>'skipped_at', now()::text)
)
ELSE doc
END
)
FROM existing_claim,
jsonb_array_elements(payload->'documents_skipped') AS doc
)
-- Добавляем новый пропущенный документ
WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->'documents_skipped' IS NOT NULL)
THEN (
SELECT payload->'documents_skipped' || jsonb_build_array(
jsonb_build_object(
'id', (SELECT document_type FROM skipped_document_info),
'name', COALESCE((SELECT document_name FROM skipped_document_info), (SELECT document_type FROM skipped_document_info)),
'group_index', (SELECT group_index FROM skipped_document_info),
'skipped_at', now()::text
)
)
FROM existing_claim
)
-- Создаём новый массив с пропущенным документом
ELSE jsonb_build_array(
jsonb_build_object(
'id', (SELECT document_type FROM skipped_document_info),
'name', COALESCE((SELECT document_name FROM skipped_document_info), (SELECT document_type FROM skipped_document_info)),
'group_index', (SELECT group_index FROM skipped_document_info),
'skipped_at', now()::text
)
)
END AS documents_skipped
FROM partial
),
-- Парсим current_doc_index и ОБНОВЛЯЕМ его (увеличиваем на 1 или находим следующий непропущенный)
current_doc_index_parsed AS (
SELECT
CASE
-- Если передан group_index - используем его + 1
WHEN (SELECT group_index FROM skipped_document_info) IS NOT NULL
THEN (SELECT group_index FROM skipped_document_info) + 1
-- Иначе берём из payload и увеличиваем на 1
WHEN partial.p->'current_doc_index' IS NOT NULL
THEN (partial.p->'current_doc_index')::int + 1
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) + 1
ELSE 1
END AS current_doc_index
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
OR (SELECT jsonb_array_length(documents_skipped) FROM documents_skipped_parsed) > 0
THEN 'draft_docs_progress'
-- Только описание
ELSE 'draft_new'
END
-- Сохраняем существующий статус, если он новый
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->'body'->>'session_id',
partial.p->'edit_fields_parsed'->'body'->>'session_id',
partial.p->'edit_fields_raw'->'body'->>'session_id',
(SELECT payload->>'session_id' FROM existing_claim),
'sess-unknown'
),
COALESCE(
partial.p->>'unified_id',
partial.p->'body'->>'unified_id',
partial.p->'edit_fields_parsed'->'body'->>'unified_id',
partial.p->'edit_fields_raw'->'body'->>'unified_id',
(SELECT unified_id FROM existing_claim)
),
COALESCE(
partial.p->>'contact_id',
partial.p->'body'->>'contact_id',
partial.p->'edit_fields_parsed'->'body'->>'contact_id',
partial.p->'edit_fields_raw'->'body'->>'contact_id',
(SELECT contact_id FROM existing_claim)
),
COALESCE(
partial.p->>'phone',
partial.p->'body'->>'phone',
partial.p->'edit_fields_parsed'->'body'->>'phone',
partial.p->'edit_fields_raw'->'body'->>'phone',
(SELECT phone FROM existing_claim)
),
'web_form',
COALESCE(partial.p->>'type_code', 'consumer'),
(SELECT status_code FROM status_code_resolved),
jsonb_build_object(
'claim_id', partial.claim_id_str,
-- Сохраняем существующие поля из payload
'problem_description', COALESCE(
partial.p->>'problem_description',
(SELECT payload->>'problem_description' FROM existing_claim)
),
'answers', COALESCE(
partial.p->'answers',
(SELECT payload->'answers' FROM existing_claim),
'{}'::jsonb
),
'wizard_plan', COALESCE(
partial.p->'wizard_plan',
(SELECT payload->'wizard_plan' FROM existing_claim)
),
-- ✅ Сохраняем documents_meta (если есть)
'documents_meta', COALESCE(
partial.p->'documents_meta',
(SELECT payload->'documents_meta' FROM existing_claim),
'[]'::jsonb
),
-- ✅ НОВЫЙ ФЛОУ: Обновляем documents_required, documents_uploaded, documents_skipped, current_doc_index
'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),
'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 = COALESCE(EXCLUDED.session_token, clpr_claims.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 на основе прогресса документов
status_code = (SELECT status_code FROM status_code_resolved),
-- ✅ Объединяем payload правильно: аккуратно обновляем критичные поля
payload = jsonb_set(
jsonb_set(
jsonb_set(
jsonb_set(
-- Сначала берём существующий payload и объединяем с новым (без критичных полей)
COALESCE(clpr_claims.payload, '{}'::jsonb) ||
(EXCLUDED.payload - 'documents_required' - 'documents_uploaded' - 'documents_skipped' - 'current_doc_index'),
'{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}',
-- ✅ ОБЪЕДИНЯЕМ documents_skipped (добавляем новый пропущенный документ или ОБНОВЛЯЕМ существующий)
CASE
-- Если новый документ уже есть в существующем списке - ОБНОВЛЯЕМ его (добавляем group_index)
WHEN clpr_claims.payload->'documents_skipped' @> jsonb_build_array(
jsonb_build_object(
'id', (SELECT document_type FROM skipped_document_info)
)
)
THEN (
-- Обновляем существующую запись, добавляя group_index
SELECT jsonb_agg(
CASE
WHEN doc->>'id' = (SELECT document_type FROM skipped_document_info)
THEN jsonb_build_object(
'id', (SELECT document_type FROM skipped_document_info),
'name', COALESCE((SELECT document_name FROM skipped_document_info), (SELECT document_type FROM skipped_document_info)),
'group_index', (SELECT group_index FROM skipped_document_info),
'skipped_at', COALESCE(doc->>'skipped_at', now()::text)
)
ELSE doc
END
)
FROM jsonb_array_elements(clpr_claims.payload->'documents_skipped') AS doc
)
-- Добавляем новый пропущенный документ
ELSE COALESCE(clpr_claims.payload->'documents_skipped', '[]'::jsonb) || jsonb_build_array(
jsonb_build_object(
'id', (SELECT document_type FROM skipped_document_info),
'name', COALESCE((SELECT document_name FROM skipped_document_info), (SELECT document_type FROM skipped_document_info)),
'group_index', (SELECT group_index FROM skipped_document_info),
'skipped_at', now()::text
)
)
END,
true
),
'{current_doc_index}',
COALESCE(
EXCLUDED.payload->'current_doc_index',
-- Если не передан, увеличиваем существующий на 1
CASE
WHEN clpr_claims.payload->'current_doc_index' IS NOT NULL
THEN to_jsonb((clpr_claims.payload->'current_doc_index')::int + 1)
ELSE to_jsonb(1)
END
),
true
),
updated_at = now(),
expires_at = now() + interval '14 days'
RETURNING id, status_code, payload, unified_id, contact_id, phone, session_token
)
-- Возвращаем результат
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,
'documents_skipped', cu.payload->'documents_skipped',
'current_doc_index', cu.payload->'current_doc_index'
) AS claim
FROM claim_upsert cu;

View 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;

View File

@@ -0,0 +1,324 @@
-- ============================================================================
-- Исправленный 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 DISTINCT ON (claim_lookup.id::text, doc.field_name, doc.file_id)
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
)
-- ✅ Приоритет: записи с валидным file_url идут первыми
ORDER BY claim_lookup.id::text, doc.field_name, doc.file_id,
CASE WHEN doc.file_url IS NOT NULL AND doc.file_url <> '' AND doc.file_url ~* '^https?://' THEN 0 ELSE 1 END
),
-- ✅ НОВОЕ: Создаём 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, uploaded_at, file_name, original_file_name -- ✅ Возвращаем все поля для использования в финальном SELECT
),
-- ✅ ИСПРАВЛЕНО: Сохраняем 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,
-- ✅ ИСПРАВЛЕНО: Возвращаем ВСЕ документы из upsert_docs с правильным claim_document_id
(
SELECT jsonb_agg(
jsonb_build_object(
'id', u.id::text, -- ✅ Это claim_document_id из таблицы clpr_claim_documents
'claim_document_id', u.id::text, -- ✅ Явно указываем для ясности
'field_name', u.field_name,
'file_id', u.file_id,
-- ✅ Получаем file_url из docs (если есть) или из documents_meta в payload
'file_url', COALESCE(
d.file_url,
(
SELECT meta->>'file_url'
FROM upd_claim uc, jsonb_array_elements(uc.payload->'documents_meta') AS meta
WHERE meta->>'field_name' = u.field_name
AND meta->>'file_id' = u.file_id
AND meta->>'file_url' IS NOT NULL
AND meta->>'file_url' <> ''
LIMIT 1
)
),
'file_name', COALESCE(d.file_name, u.file_name),
'original_file_name', COALESCE(d.original_file_name, u.original_file_name),
'uploaded_at', COALESCE(
d.uploaded_at::text,
u.uploaded_at::text
),
'filename_for_upload',
COALESCE(
NULLIF(COALESCE(d.original_file_name, u.original_file_name), ''),
NULLIF(COALESCE(d.file_name, u.file_name), ''),
regexp_replace(u.file_id, '^.*/', '')
)
)
ORDER BY u.field_name -- ✅ Сортируем для предсказуемости
)
FROM upsert_docs u
-- ✅ LEFT JOIN: возвращаем ВСЕ документы из таблицы, даже если нет file_url в docs
LEFT JOIN docs d ON d.claim_id = u.claim_id
AND d.field_name = u.field_name
AND d.file_id = u.file_id
AND d.file_url IS NOT NULL
AND d.file_url <> ''
) AS documents;

View File

@@ -0,0 +1,345 @@
-- ============================================================================
-- Исправленный SQL для сохранения документов (claimsave_final) - ПОДДЕРЖКА НОВОГО ФЛОУ
-- ============================================================================
-- ИСПРАВЛЕНИЕ: Возврат правильного claim_document_id из таблицы clpr_claim_documents
-- Проблема: Возвращался только один документ и неправильный id
-- Решение: Используем u.id из upsert_docs (таблица clpr_claim_documents) и фильтруем правильно
-- ============================================================================
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
)
-- ✅ ФИЛЬТРУЕМ: берём только документы с валидным file_url И уникальным field_name+file_id
WHERE doc.file_url IS NOT NULL
AND doc.file_url <> ''
AND doc.file_url ~* '^https?://'
-- ✅ Убираем дубликаты: берём только первую запись для каждого field_name с валидным file_url
AND NOT EXISTS (
SELECT 1 FROM jsonb_to_recordset(COALESCE(partial.p->'documents_meta','[]'::jsonb)) AS doc2(
field_name text,
file_id text,
file_url text
)
WHERE doc2.field_name = doc.field_name
AND doc2.file_id = doc.file_id
AND doc2.file_url ~* '^https?://'
AND doc2.file_url <> ''
-- Сравниваем по позиции в массиве (берем первый)
AND (SELECT ordinality FROM jsonb_array_elements(COALESCE(partial.p->'documents_meta','[]'::jsonb)) WITH ORDINALITY AS d3 WHERE d3.value->>'field_name' = doc.field_name AND d3.value->>'file_id' = doc.file_id ORDER BY d3.ordinality LIMIT 1) <
(SELECT ordinality FROM jsonb_array_elements(COALESCE(partial.p->'documents_meta','[]'::jsonb)) WITH ORDINALITY AS d4 WHERE d4.value->>'field_name' = doc.field_name AND d4.value->>'file_id' = doc.file_id ORDER BY d4.ordinality LIMIT 1)
)
),
-- ✅ НОВОЕ: Создаём 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
),
-- ✅ ИСПРАВЛЕНО: Сохраняем документы в таблицу clpr_claim_documents
-- ✅ ДОБАВЛЕНО: document_type и document_label для AI matching
upsert_docs AS (
INSERT INTO clpr_claim_documents
(claim_id, field_name, file_id, uploaded_at, file_name, original_file_name,
document_type, document_label)
SELECT
claim_id,
field_name,
file_id,
uploaded_at,
file_name,
original_file_name,
-- document_type: вычисляем из field_label или field_name
CASE
WHEN field_label ILIKE '%договор%' OR field_label ILIKE '%заказ%'
THEN 'contract'
WHEN field_label ILIKE '%чек%' OR field_label ILIKE '%оплат%'
THEN 'payment'
WHEN field_label ILIKE '%переписк%'
THEN 'correspondence'
WHEN field_label ILIKE '%доказательств%' OR field_label ILIKE '%фото%'
THEN 'evidence_photo'
WHEN field_name LIKE 'uploads[0]%'
THEN 'contract'
WHEN field_name LIKE 'uploads[1]%'
THEN 'payment'
WHEN field_name LIKE 'uploads[2]%'
THEN 'correspondence'
WHEN field_name LIKE 'uploads[3]%'
THEN 'evidence_photo'
ELSE 'unknown'
END,
-- document_label: сохраняем как есть из формы
field_label
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,
document_type = EXCLUDED.document_type,
document_label = EXCLUDED.document_label
RETURNING id, claim_id, field_name, file_id, document_type, document_label
),
-- ✅ ИСПРАВЛЕНО: Сохраняем 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
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,
-- ✅ ИСПРАВЛЕНО: Возвращаем ВСЕ документы из upsert_docs с правильным claim_document_id
-- ✅ ДОБАВЛЕНО: document_type и document_label для передачи в OCR workflow
(
SELECT jsonb_agg(
jsonb_build_object(
'id', u.id::text, -- ✅ Это claim_document_id из таблицы clpr_claim_documents
'claim_document_id', u.id::text, -- ✅ Явно указываем для ясности
'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::text,
'document_type', u.document_type, -- ✅ Тип документа (contract, payment, etc.)
'document_label', u.document_label, -- ✅ Название поля формы
'filename_for_upload',
COALESCE(
NULLIF(d.original_file_name, ''),
NULLIF(d.file_name, ''),
regexp_replace(d.file_id, '^.*/', '')
)
)
ORDER BY u.field_name -- ✅ Сортируем для предсказуемости
)
FROM upsert_docs u
-- ✅ JOIN с docs для получения file_url и других метаданных
LEFT JOIN docs d ON d.claim_id = u.claim_id AND d.field_name = u.field_name AND d.file_id = u.file_id
-- ✅ Возвращаем ВСЕ документы из таблицы, даже если нет file_url в docs
-- (file_url может быть в documents_meta в payload)
) AS documents;

View 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;

View File

@@ -0,0 +1,391 @@
-- ============================================================================
-- Исправленный SQL для сохранения claim (claimsave) - С ДЕДУПЛИКАЦИЕЙ
-- ============================================================================
-- Проблема: documents_meta накапливает дубликаты при каждом сохранении
-- Решение: При добавлении новых записей удаляем старые с тем же field_name
-- ============================================================================
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_meta
-- Приоритет: новые записи перезаписывают старые с тем же field_name
documents_meta_dedup AS (
SELECT COALESCE(
(
SELECT jsonb_agg(doc ORDER BY (doc->>'uploaded_at') DESC NULLS LAST)
FROM (
-- Уникальные записи: приоритет новым по field_name
SELECT DISTINCT ON (doc->>'field_name') doc
FROM (
-- 1. Сначала новые записи (приоритет)
SELECT jsonb_array_elements(
COALESCE((SELECT p->'documents_meta' FROM partial WHERE p->'documents_meta' IS NOT NULL), '[]'::jsonb)
) AS doc, 1 AS priority
UNION ALL
-- 2. Потом существующие записи
SELECT jsonb_array_elements(
COALESCE((SELECT payload->'documents_meta' FROM existing_claim), '[]'::jsonb)
) AS doc, 2 AS priority
) all_docs
-- Сортируем: сначала новые (priority=1), потом по дате
ORDER BY doc->>'field_name', priority, (doc->>'uploaded_at') DESC NULLS LAST
) unique_docs
),
'[]'::jsonb
) AS documents_meta
),
-- Парсим 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', (SELECT documents_meta FROM documents_meta_dedup),
-- ✅ НОВЫЙ ФЛОУ: Сохраняем 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,
-- ✅ ИСПРАВЛЕНО: Дедуплицированное объединение documents_meta
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}',
-- ✅ ДЕДУПЛИКАЦИЯ: новые записи перезаписывают старые с тем же field_name
(
SELECT COALESCE(jsonb_agg(doc), '[]'::jsonb)
FROM (
SELECT DISTINCT ON (doc->>'field_name') doc
FROM (
-- Новые записи (приоритет)
SELECT jsonb_array_elements(COALESCE(EXCLUDED.payload->'documents_meta', '[]'::jsonb)) AS doc, 1 AS priority
UNION ALL
-- Существующие записи
SELECT jsonb_array_elements(COALESCE(clpr_claims.payload->'documents_meta', '[]'::jsonb)) AS doc, 2 AS priority
) all_docs
ORDER BY doc->>'field_name', priority, (doc->>'uploaded_at') DESC NULLS LAST
) unique_docs
),
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;

View File

@@ -0,0 +1,36 @@
-- ============================================================================
-- SQL для очистки дубликатов в documents_meta
-- ============================================================================
-- Удаляет дубликаты, оставляя только самую новую запись для каждого field_name
-- ============================================================================
-- $1 = claim_id (UUID)
UPDATE clpr_claims
SET payload = jsonb_set(
payload,
'{documents_meta}',
(
SELECT COALESCE(jsonb_agg(doc ORDER BY (doc->>'uploaded_at') DESC NULLS LAST), '[]'::jsonb)
FROM (
SELECT DISTINCT ON (doc->>'field_name') doc
FROM jsonb_array_elements(COALESCE(payload->'documents_meta', '[]'::jsonb)) AS doc
ORDER BY
doc->>'field_name',
-- Приоритет: записи с file_url важнее, потом по дате
CASE WHEN doc->>'file_url' IS NOT NULL AND doc->>'file_url' <> '' THEN 0 ELSE 1 END,
(doc->>'uploaded_at') DESC NULLS LAST
) unique_docs
),
true
),
updated_at = now()
WHERE id = $1
RETURNING
id,
jsonb_array_length(payload->'documents_meta') AS documents_meta_count,
(
SELECT jsonb_agg(doc->>'field_name')
FROM jsonb_array_elements(payload->'documents_meta') AS doc
) AS field_names;

View File

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

View File

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

View File

@@ -0,0 +1,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;

View File

@@ -0,0 +1,41 @@
-- Исправление: возврат правильного claim_document_id из таблицы clpr_claim_documents
-- Проблема: возвращается не id из таблицы, а что-то другое (возможно из documents_meta)
-- В SQL запросе должно быть:
-- 1. В CTE upsert_docs: RETURNING id (это claim_document_id из таблицы)
-- 2. В финальном SELECT: использовать u.id из upsert_docs, а НЕ из documents_meta
-- ПРАВИЛЬНЫЙ ВАРИАНТ:
SELECT
(SELECT jsonb_build_object(...) FROM upd_claim u) AS claim,
(
SELECT jsonb_agg(
jsonb_build_object(
'id', u.id, -- ✅ Это claim_document_id из таблицы clpr_claim_documents
'claim_document_id', u.id, -- ✅ Явно указываем, что это claim_document_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 -- ✅ Используем u.id из upsert_docs (таблица clpr_claim_documents)
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;
-- ❌ НЕПРАВИЛЬНО (если используется id из documents_meta):
-- 'id', d.id -- Это НЕ claim_document_id из таблицы!
-- ✅ ПРАВИЛЬНО:
-- 'id', u.id -- Это claim_document_id из таблицы clpr_claim_documents

View 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';

View File

@@ -0,0 +1,146 @@
-- ============================================================================
-- SQL запрос для получения данных контакта из CRM (через n8n)
-- ============================================================================
-- Назначение: Получить актуальные данные контакта из CRM для отображения
-- в форме подтверждения (если данные уже подтверждены)
--
-- Использование: В n8n workflow после проверки флага contact_data_confirmed_at
-- ============================================================================
-- ВАЖНО: Этот запрос выполняется через n8n HTTP Request к CRM webservice,
-- а не напрямую в PostgreSQL, т.к. CRM в MySQL
-- Пример запроса к CRM через n8n:
-- POST https://crm.clientright.ru/webservice.php
-- Body: operation=retrieve&sessionName={{sessionName}}&id=12x{{contact_id}}
-- ============================================================================
-- Альтернатива: Хранить кэш данных контакта в PostgreSQL
-- ============================================================================
-- Создаём таблицу для кэширования данных контакта из CRM
CREATE TABLE IF NOT EXISTS clpr_contact_data_cache (
unified_id VARCHAR NOT NULL PRIMARY KEY,
contact_id INTEGER,
firstname VARCHAR,
lastname VARCHAR,
middle_name VARCHAR,
inn VARCHAR,
birthday DATE,
birthplace VARCHAR,
mailingstreet VARCHAR,
email VARCHAR,
mobile VARCHAR,
-- Дополнительные поля из CRM
data_json JSONB, -- Полные данные из CRM для расширяемости
synced_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT fk_unified_id FOREIGN KEY (unified_id)
REFERENCES clpr_users(unified_id) ON DELETE CASCADE
);
-- Индекс для быстрого поиска
CREATE INDEX IF NOT EXISTS idx_clpr_contact_data_cache_contact_id
ON clpr_contact_data_cache(contact_id);
-- Комментарий
COMMENT ON TABLE clpr_contact_data_cache IS
'Кэш данных контакта из CRM. Обновляется при синхронизации через n8n.';
-- ============================================================================
-- Функция: Получить данные контакта (из кэша или NULL)
-- ============================================================================
CREATE OR REPLACE FUNCTION clpr_get_contact_data(p_unified_id VARCHAR)
RETURNS TABLE(
contact_id INTEGER,
firstname VARCHAR,
lastname VARCHAR,
middle_name VARCHAR,
inn VARCHAR,
birthday DATE,
birthplace VARCHAR,
mailingstreet VARCHAR,
email VARCHAR,
mobile VARCHAR,
data_json JSONB
) AS $$
BEGIN
RETURN QUERY
SELECT
c.contact_id,
c.firstname,
c.lastname,
c.middle_name,
c.inn,
c.birthday,
c.birthplace,
c.mailingstreet,
c.email,
c.mobile,
c.data_json
FROM clpr_contact_data_cache c
WHERE c.unified_id = p_unified_id;
END;
$$ LANGUAGE plpgsql;
-- ============================================================================
-- Функция: Обновить кэш данных контакта
-- ============================================================================
CREATE OR REPLACE FUNCTION clpr_update_contact_data_cache(
p_unified_id VARCHAR,
p_contact_id INTEGER,
p_data JSONB
)
RETURNS VOID AS $$
BEGIN
INSERT INTO clpr_contact_data_cache (
unified_id,
contact_id,
firstname,
lastname,
middle_name,
inn,
birthday,
birthplace,
mailingstreet,
email,
mobile,
data_json,
synced_at,
updated_at
) VALUES (
p_unified_id,
p_contact_id,
p_data->>'firstname',
p_data->>'lastname',
p_data->>'cf_1157', -- middle_name
p_data->>'cf_1257', -- inn
(p_data->>'birthday')::DATE,
p_data->>'cf_1263', -- birthplace
p_data->>'mailingstreet',
p_data->>'email',
p_data->>'mobile',
p_data,
NOW(),
NOW()
)
ON CONFLICT (unified_id) DO UPDATE
SET
contact_id = EXCLUDED.contact_id,
firstname = EXCLUDED.firstname,
lastname = EXCLUDED.lastname,
middle_name = EXCLUDED.middle_name,
inn = EXCLUDED.inn,
birthday = EXCLUDED.birthday,
birthplace = EXCLUDED.birthplace,
mailingstreet = EXCLUDED.mailingstreet,
email = EXCLUDED.email,
mobile = EXCLUDED.mobile,
data_json = EXCLUDED.data_json,
synced_at = NOW(),
updated_at = NOW();
END;
$$ LANGUAGE plpgsql;

View File

@@ -0,0 +1,127 @@
-- ============================================================================
-- SQL запрос для получения документа по claim_document_id
-- ============================================================================
-- Параметры:
-- $1 :: uuid -- claim_id (ID жалобы)
-- $2 :: uuid -- claim_document_id (ID документа из таблицы clpr_claim_documents)
-- ============================================================================
WITH c AS (
SELECT id, id::text AS claim_id_text, payload
FROM clpr_claims
WHERE id = $1
)
SELECT
cd.id AS claim_document_id,
cd.claim_id::text AS claim_id,
cd.field_name,
cd.file_id,
cd.uploaded_at,
cd.file_name,
cd.original_file_name,
cd.file_hash, -- ✅ SHA-256 хеш файла (для дедупликации)
m.file_url,
m.file_name AS meta_file_name,
m.original_file_name AS meta_original_file_name,
-- ✅ Название документа: сначала из field_label в documents_meta, потом из documents_uploaded, потом из documents_required
COALESCE(
NULLIF(m.field_label, ''),
NULLIF(du_name.document_name_from_uploaded, ''),
NULLIF(dr_name.document_name_from_required, ''),
cd.field_name,
'Документ'
) AS document_name,
COALESCE(
NULLIF(m.original_file_name, ''),
NULLIF(m.file_name, ''),
NULLIF(cd.original_file_name, ''),
NULLIF(cd.file_name, ''),
NULLIF(regexp_replace(cd.file_id, '^.*/', ''), ''),
'document.pdf'
) AS filename_for_upload,
/* описание: сначала из массива edit_fields_parsed.uploads_descriptions[i],
потом — из payload.body['uploads_descriptions[i]'],
потом — из payload['uploads_descriptions[i]'] */
NULLIF(
COALESCE(ud_arr.upload_description, ud_body.upload_description, ud_root.upload_description),
''
) AS upload_description
FROM clpr_claim_documents cd
JOIN c ON c.claim_id_text = cd.claim_id::text
-- достаём i из uploads[i][j]
JOIN LATERAL (
SELECT NULLIF((regexp_match(cd.field_name, 'uploads\[(\d+)\]'))[1], '')::int AS i1
) idx ON TRUE
-- мета по файлу (валидный URL) + название документа (field_label)
LEFT JOIN LATERAL (
SELECT
x.file_url::text,
x.file_name::text,
x.original_file_name::text,
x.field_label::text
FROM jsonb_to_recordset(COALESCE(c.payload->'documents_meta','[]'::jsonb))
AS x(field_name text, file_id text, file_url text, file_name text, original_file_name text, field_label text)
WHERE x.field_name = cd.field_name
AND x.file_id = cd.file_id
AND x.file_url ~* '^https?://'
AND x.file_url <> ''
LIMIT 1
) m ON TRUE
-- название документа из documents_uploaded (fallback, если нет field_label в documents_meta)
LEFT JOIN LATERAL (
SELECT du.name::text AS document_name_from_uploaded
FROM jsonb_to_recordset(COALESCE(c.payload->'documents_uploaded','[]'::jsonb))
AS du(id text, name text, field_name text, file_id text, type text)
WHERE du.field_name = cd.field_name
AND (du.file_id = cd.file_id OR du.file_id IS NULL)
LIMIT 1
) du_name ON TRUE
-- название документа из documents_required (fallback через тип документа из documents_uploaded)
LEFT JOIN LATERAL (
SELECT dr.name::text AS document_name_from_required
FROM jsonb_to_recordset(COALESCE(c.payload->'documents_required','[]'::jsonb))
AS dr(id text, name text, required boolean, priority int)
-- Находим тип документа через documents_uploaded по field_name
WHERE EXISTS (
SELECT 1
FROM jsonb_to_recordset(COALESCE(c.payload->'documents_uploaded','[]'::jsonb))
AS du(id text, field_name text)
WHERE du.field_name = cd.field_name
AND du.id = dr.id
LIMIT 1
)
LIMIT 1
) dr_name ON TRUE
-- 1) массив: payload.edit_fields_parsed.uploads_descriptions[i]
LEFT JOIN LATERAL (
SELECT (c.payload->'edit_fields_parsed'->'uploads_descriptions')->>idx.i1 AS upload_description
) ud_arr ON TRUE
-- 2) плоские ключи в payload.body: 'uploads_descriptions[i]'
LEFT JOIN LATERAL (
SELECT b.v AS upload_description
FROM jsonb_each_text(COALESCE(c.payload->'body','{}'::jsonb)) AS b(k, v)
WHERE b.k = 'uploads_descriptions[' || idx.i1::text || ']'
LIMIT 1
) ud_body ON TRUE
-- 3) плоские ключи на корне payload: 'uploads_descriptions[i]'
LEFT JOIN LATERAL (
SELECT r.v AS upload_description
FROM jsonb_each_text(COALESCE(c.payload,'{}'::jsonb)) AS r(k, v)
WHERE r.k = 'uploads_descriptions[' || idx.i1::text || ']'
LIMIT 1
) ud_root ON TRUE
-- ✅ ФИЛЬТР: ищем конкретный документ по claim_document_id (после всех JOIN)
WHERE cd.id = $2
LIMIT 1; -- ✅ Возвращаем только один документ

View File

@@ -0,0 +1,56 @@
-- SQL скрипт для n8n: Отметка формы как подтвержденной после получения данных из Redis канала
-- Используется после обработки события из канала clientright:webform:approve
--
-- Параметры:
-- $1 = claim_id (UUID или текст)
-- $2 = approved_form (JSONB - полные данные формы после апрува)
-- $3 = sms_code (текст - код подтверждения)
-- $4 = phone (текст - телефон)
--
-- Обновляет:
-- - status_code = 'approved' (отмечает форму как подтвержденную)
-- - is_confirmed = true (дополнительный флаг подтверждения)
-- - payload.approved_form = полные данные формы
-- - payload.sms_verified = true
-- - payload.sms_code = код подтверждения
-- - payload.approved_at = время подтверждения
-- - updated_at = now() (время обновления)
--
-- После этого запись больше не будет показываться в списке черновиков
WITH claim_lookup AS (
SELECT
c.id,
c.payload,
c.status_code,
c.is_confirmed
FROM clpr_claims c
WHERE c.id::text = $1::text
OR c.payload->>'claim_id' = $1::text
ORDER BY
CASE WHEN c.id::text = $1::text THEN 1 ELSE 2 END,
c.updated_at DESC
LIMIT 1
)
UPDATE clpr_claims c
SET
status_code = 'approved',
is_confirmed = true,
payload = c.payload
|| jsonb_build_object(
'approved_form', $2::jsonb,
'sms_verified', true,
'sms_code', $3::text,
'approved_phone', $4::text,
'approved_at', now()::text
),
updated_at = now()
FROM claim_lookup cl
WHERE c.id = cl.id
RETURNING
c.id,
c.payload->>'claim_id' AS claim_id,
c.status_code,
c.is_confirmed,
c.updated_at,
c.payload->'approved_form' IS NOT NULL AS has_approved_form;

View 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()
-- }
-- }
-- };
-- ============================================================================

View 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

View File

@@ -0,0 +1,91 @@
-- ============================================================================
-- SQL запрос для обновления upload_description документа по claim_document_id
-- ============================================================================
-- Параметры:
-- $1 :: uuid -- claim_document_id (ID документа из таблицы clpr_claim_documents)
-- $2 :: text -- upload_description (новое описание документа)
-- ============================================================================
WITH
-- Находим документ и извлекаем нужные данные
doc_info AS (
SELECT
cd.id AS claim_document_id,
cd.claim_id::text AS claim_id_text,
cd.field_name,
-- Извлекаем индекс i из field_name (например, uploads[0][0] -> 0)
NULLIF((regexp_match(cd.field_name, 'uploads\[(\d+)\]'))[1], '')::int AS upload_index,
c.id AS claim_uuid,
c.payload
FROM clpr_claim_documents cd
JOIN clpr_claims c ON c.id::text = cd.claim_id::text
WHERE cd.id = $1
LIMIT 1
),
-- Обновляем payload: приоритет обновления в следующем порядке:
-- 1. payload.body['uploads_descriptions[i]'] (самый приоритетный)
-- 2. payload['uploads_descriptions[i]'] (если нет body)
-- 3. payload.edit_fields_parsed.uploads_descriptions[i] (массив, если нет плоских ключей)
updated_claim AS (
UPDATE clpr_claims c
SET
payload = CASE
-- Если есть payload.body, обновляем там
WHEN c.payload->'body' IS NOT NULL THEN
jsonb_set(
c.payload,
ARRAY['body', 'uploads_descriptions[' || di.upload_index::text || ']'],
to_jsonb($2::text),
true
)
-- Если нет body, но есть корневой ключ, обновляем там
WHEN c.payload ? ('uploads_descriptions[' || di.upload_index::text || ']') THEN
jsonb_set(
c.payload,
ARRAY['uploads_descriptions[' || di.upload_index::text || ']'],
to_jsonb($2::text),
true
)
-- Если есть edit_fields_parsed.uploads_descriptions (массив), обновляем там
WHEN c.payload->'edit_fields_parsed'->'uploads_descriptions' IS NOT NULL
AND jsonb_typeof(c.payload->'edit_fields_parsed'->'uploads_descriptions') = 'array' THEN
-- Для массива используем jsonb_set с числовым индексом
jsonb_set(
c.payload,
ARRAY['edit_fields_parsed', 'uploads_descriptions', di.upload_index::text],
to_jsonb($2::text),
true
)
-- Если ничего нет, создаём в body
ELSE
jsonb_set(
COALESCE(c.payload, '{}'::jsonb),
ARRAY['body', 'uploads_descriptions[' || di.upload_index::text || ']'],
to_jsonb($2::text),
true
)
END,
updated_at = now()
FROM doc_info di
WHERE c.id = di.claim_uuid
RETURNING c.id, c.payload
)
-- Возвращаем обновлённые данные
SELECT
di.claim_document_id,
di.claim_id_text AS claim_id,
di.field_name,
di.upload_index,
-- Проверяем, где сохранилось описание
COALESCE(
uc.payload->'body'->>('uploads_descriptions[' || di.upload_index::text || ']'),
uc.payload->>('uploads_descriptions[' || di.upload_index::text || ']'),
uc.payload->'edit_fields_parsed'->'uploads_descriptions'->>di.upload_index::text,
NULL
) AS upload_description,
uc.payload
FROM doc_info di
JOIN updated_claim uc ON uc.id = di.claim_uuid;

View File

@@ -0,0 +1,86 @@
-- ============================================================================
-- Упрощённый SQL запрос для обновления upload_description
-- ============================================================================
-- Параметры:
-- $1 :: uuid -- claim_document_id (ID документа из таблицы clpr_claim_documents)
-- $2 :: text -- upload_description (новое описание документа)
-- ============================================================================
-- Этот запрос обновляет описание в payload.body['uploads_descriptions[i]']
-- Это самый приоритетный способ хранения, который проверяется первым при чтении
-- ============================================================================
WITH
-- Находим документ и извлекаем нужные данные
doc_info AS (
SELECT
cd.id AS claim_document_id,
cd.claim_id::text AS claim_id_text,
cd.field_name,
-- Извлекаем индекс i из field_name (например, uploads[0][0] -> 0)
NULLIF((regexp_match(cd.field_name, 'uploads\[(\d+)\]'))[1], '')::int AS upload_index,
c.id AS claim_uuid,
c.payload
FROM clpr_claim_documents cd
JOIN clpr_claims c ON c.id::text = cd.claim_id::text
WHERE cd.id = $1
LIMIT 1
),
-- Обновляем payload: обновляем описание в body (самый приоритетный и надёжный способ)
updated_claim AS (
UPDATE clpr_claims c
SET
payload = (
-- Сохраняем весь payload кроме body
COALESCE(c.payload, '{}'::jsonb) - 'body'
) || jsonb_build_object(
-- Обновляем body: берём существующий body (или пустой объект) и добавляем/обновляем ключ
'body',
COALESCE(c.payload->'body', '{}'::jsonb) ||
jsonb_build_object('uploads_descriptions[' || di.upload_index::text || ']', $2::text)
),
updated_at = now()
FROM doc_info di
WHERE c.id = di.claim_uuid
RETURNING c.id, c.payload
)
-- Возвращаем обновлённые данные
SELECT
di.claim_document_id,
di.claim_id_text AS claim_id,
di.field_name,
di.upload_index,
-- Проверяем, где сохранилось описание (приоритет: body > корень > массив)
COALESCE(
uc.payload->'body'->>('uploads_descriptions[' || di.upload_index::text || ']'),
uc.payload->>('uploads_descriptions[' || di.upload_index::text || ']'),
uc.payload->'edit_fields_parsed'->'uploads_descriptions'->>di.upload_index::text,
NULL
) AS upload_description,
-- ✅ Диагностика: длина сохранённого значения
length(
COALESCE(
uc.payload->'body'->>('uploads_descriptions[' || di.upload_index::text || ']'),
uc.payload->>('uploads_descriptions[' || di.upload_index::text || ']'),
uc.payload->'edit_fields_parsed'->'uploads_descriptions'->>di.upload_index::text,
''
)
) AS description_length,
-- ✅ Диагностика: длина переданного значения
length($2::text) AS input_length,
-- ✅ Диагностика: проверяем, что значение сохранилось полностью
CASE
WHEN length($2::text) = length(
COALESCE(
uc.payload->'body'->>('uploads_descriptions[' || di.upload_index::text || ']'),
uc.payload->>('uploads_descriptions[' || di.upload_index::text || ']'),
uc.payload->'edit_fields_parsed'->'uploads_descriptions'->>di.upload_index::text,
''
)
) THEN 'OK'
ELSE 'TRUNCATED'
END AS status
FROM doc_info di
JOIN updated_claim uc ON uc.id = di.claim_uuid;

View File

@@ -0,0 +1,21 @@
-- ============================================================================
-- SQL запрос для записи file_hash в clpr_claim_documents
-- ============================================================================
-- Параметры:
-- $1 :: uuid -- claim_document_id (ID документа)
-- $2 :: varchar -- file_hash (SHA-256 хеш, 64 символа hex)
-- ============================================================================
UPDATE clpr_claim_documents
SET file_hash = $2
WHERE id = $1
RETURNING
id AS claim_document_id,
claim_id,
field_name,
file_id,
file_hash,
file_name,
original_file_name;

View File

@@ -0,0 +1,64 @@
-- ============================================================
-- Миграция: Добавление статуса OCR обработки для документов
-- Дата: 2025-11-28
-- Описание: Добавляет колонки для отслеживания статуса OCR
-- обработки документов в заявках
-- ============================================================
-- 1. Добавляем колонки в clpr_claim_documents
ALTER TABLE clpr_claim_documents
ADD COLUMN IF NOT EXISTS ocr_status VARCHAR(20) DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS ocr_processed_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS ocr_error TEXT;
-- 2. Комментарии к колонкам
COMMENT ON COLUMN clpr_claim_documents.ocr_status IS 'Статус OCR обработки: pending, processing, ready, error';
COMMENT ON COLUMN clpr_claim_documents.ocr_processed_at IS 'Время завершения OCR обработки';
COMMENT ON COLUMN clpr_claim_documents.ocr_error IS 'Текст ошибки при неудачной OCR обработке';
-- 3. Индекс для быстрого поиска по статусу
CREATE INDEX IF NOT EXISTS idx_claim_docs_ocr_status
ON clpr_claim_documents(claim_id, ocr_status);
-- 4. Индекс для поиска необработанных документов
CREATE INDEX IF NOT EXISTS idx_claim_docs_pending
ON clpr_claim_documents(ocr_status)
WHERE ocr_status = 'pending';
-- 5. Проставляем 'ready' для уже обработанных документов
-- (те, что уже есть в document_texts по file_hash)
UPDATE clpr_claim_documents cd
SET
ocr_status = 'ready',
ocr_processed_at = NOW()
WHERE cd.file_hash IS NOT NULL
AND EXISTS (
SELECT 1 FROM document_texts dt
WHERE dt.file_hash = cd.file_hash
)
AND (cd.ocr_status IS NULL OR cd.ocr_status = 'pending');
-- 6. Статистика после миграции
DO $$
DECLARE
total_docs INT;
ready_docs INT;
pending_docs INT;
BEGIN
SELECT COUNT(*) INTO total_docs FROM clpr_claim_documents;
SELECT COUNT(*) INTO ready_docs FROM clpr_claim_documents WHERE ocr_status = 'ready';
SELECT COUNT(*) INTO pending_docs FROM clpr_claim_documents WHERE ocr_status = 'pending';
RAISE NOTICE '=== OCR Status Migration Complete ===';
RAISE NOTICE 'Total documents: %', total_docs;
RAISE NOTICE 'Ready (already processed): %', ready_docs;
RAISE NOTICE 'Pending (need OCR): %', pending_docs;
END $$;

View File

@@ -0,0 +1,72 @@
-- ============================================================================
-- Миграция 002: Добавление проверки соответствия документов
-- ============================================================================
-- Цель: Хранить результат проверки AI — соответствует ли загруженный документ
-- запрошенному типу (договор, чек, переписка и т.д.)
-- ============================================================================
-- Добавляем колонки в clpr_claim_documents
ALTER TABLE clpr_claim_documents
ADD COLUMN IF NOT EXISTS document_type VARCHAR(50), -- ожидаемый тип: contract, payment, correspondence, evidence_photo
ADD COLUMN IF NOT EXISTS document_label VARCHAR(255), -- читаемое название: "Договор или заказ"
ADD COLUMN IF NOT EXISTS match_score INT, -- процент соответствия 0-100
ADD COLUMN IF NOT EXISTS match_status VARCHAR(20) DEFAULT 'pending', -- pending/passed/failed/skipped
ADD COLUMN IF NOT EXISTS match_reason TEXT, -- пояснение от AI почему такой score
ADD COLUMN IF NOT EXISTS match_checked_at TIMESTAMP; -- когда проверено
-- Комментарии к колонкам
COMMENT ON COLUMN clpr_claim_documents.document_type IS 'Ожидаемый тип документа: contract, payment, correspondence, evidence_photo, other';
COMMENT ON COLUMN clpr_claim_documents.document_label IS 'Читаемое название типа документа: "Договор или заказ", "Чек", "Переписка"';
COMMENT ON COLUMN clpr_claim_documents.match_score IS 'Процент соответствия документа ожидаемому типу (0-100). NULL = не проверено';
COMMENT ON COLUMN clpr_claim_documents.match_status IS 'Статус проверки: pending (ждёт), passed (ок), failed (не соответствует), skipped (пропущено)';
COMMENT ON COLUMN clpr_claim_documents.match_reason IS 'Пояснение от AI: почему документ соответствует/не соответствует';
COMMENT ON COLUMN clpr_claim_documents.match_checked_at IS 'Когда была выполнена проверка соответствия';
-- Индекс для быстрого поиска непроверенных и проблемных документов
CREATE INDEX IF NOT EXISTS idx_claim_docs_match_status
ON clpr_claim_documents(claim_id, match_status);
-- Заполняем document_type и document_label из payload для существующих документов
UPDATE clpr_claim_documents cd
SET
document_type = du.doc_type,
document_label = dm.field_label
FROM clpr_claims c,
LATERAL (
SELECT x->>'field_label' AS field_label
FROM jsonb_array_elements(COALESCE(c.payload->'documents_meta', '[]'::jsonb)) x
WHERE x->>'field_name' = cd.field_name
LIMIT 1
) dm,
LATERAL (
SELECT x->>'type' AS doc_type
FROM jsonb_array_elements(COALESCE(c.payload->'documents_uploaded', '[]'::jsonb)) x
WHERE x->>'file_name' = cd.file_name OR x->>'file_id' LIKE '%' || cd.file_name
LIMIT 1
) du
WHERE cd.claim_id::text = c.id::text
AND cd.document_type IS NULL;
-- Статистика после миграции
DO $$
DECLARE
total_docs INT;
with_type INT;
with_label INT;
BEGIN
SELECT COUNT(*) INTO total_docs FROM clpr_claim_documents;
SELECT COUNT(*) INTO with_type FROM clpr_claim_documents WHERE document_type IS NOT NULL;
SELECT COUNT(*) INTO with_label FROM clpr_claim_documents WHERE document_label IS NOT NULL;
RAISE NOTICE '=== Document Match Migration Complete ===';
RAISE NOTICE 'Total documents: %', total_docs;
RAISE NOTICE 'With document_type: %', with_type;
RAISE NOTICE 'With document_label: %', with_label;
END $$;

View 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
}
}
}
}
];

View File

@@ -0,0 +1,104 @@
# Настройка OCR Status Tracking в n8n
## Шаги для добавления нод в workflow `fnSo3FTTbQcMjwt3`
### 1. Открой workflow в n8n
https://n8n.clientright.pro/workflow/fnSo3FTTbQcMjwt3
### 2. Добавь ноды в следующем порядке:
#### Нода 1: `update_ocr_status` (PostgreSQL)
**Расположение:** После `Postgres PGVector Store1`
**Позиция:** [3850, 1664]
**SQL запрос:**
```sql
UPDATE clpr_claim_documents
SET
ocr_status = 'ready',
ocr_processed_at = NOW()
WHERE id = '{{ $('files').item.json.claim_document_id }}'::uuid
RETURNING
id AS doc_id,
claim_id,
ocr_status,
(SELECT COUNT(*) FROM clpr_claim_documents WHERE claim_id = clpr_claim_documents.claim_id) AS total_docs,
(SELECT COUNT(*) FROM clpr_claim_documents WHERE claim_id = clpr_claim_documents.claim_id AND ocr_status = 'ready') AS ready_docs;
```
**Credentials:** `timeweb_bd` (Postgres account 2)
---
#### Нода 2: `redis_incr_ready` (Redis)
**Расположение:** После `update_ocr_status`
**Позиция:** [4100, 1664]
**Параметры:**
- Operation: `incr`
- Key: `claim:ocr:ready:{{ $json.claim_id }}`
**Credentials:** `Redis account`
---
#### Нода 3: `check_all_ready` (IF)
**Расположение:** После `redis_incr_ready`
**Позиция:** [4350, 1664]
**Условие:**
```
{{ $('update_ocr_status').item.json.total_docs }} == {{ $('update_ocr_status').item.json.ready_docs }}
```
---
#### Нода 4: `publish_docs_ready` (Redis)
**Расположение:** TRUE выход из `check_all_ready`
**Позиция:** [4600, 1550]
**Параметры:**
- Operation: `publish`
- Channel: `clpr:claim:docs_ready`
- Message:
```javascript
{{ JSON.stringify({
claim_id: $('update_ocr_status').item.json.claim_id,
total_docs: $('update_ocr_status').item.json.total_docs,
ready_docs: $('update_ocr_status').item.json.ready_docs,
timestamp: new Date().toISOString()
}) }}
```
**Credentials:** `Redis account`
---
### 3. Соединения (Connections)
```
Postgres PGVector Store1 → update_ocr_status
update_ocr_status → redis_incr_ready
redis_incr_ready → check_all_ready
check_all_ready (TRUE) → publish_docs_ready
check_all_ready (FALSE) → (конец)
```
### 4. Сохрани и активируй workflow
---
## Проверка
После добавления нод, при обработке документа:
1. Статус в `clpr_claim_documents.ocr_status` будет меняться на `'ready'`
2. Счётчик в Redis `claim:ocr:ready:{claim_id}` будет инкрементиться
3. Когда все документы готовы, событие `clpr:claim:docs_ready` будет опубликовано в Redis

View File

@@ -0,0 +1,37 @@
{
"name": "check_all_ready",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [4350, 1664],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "ocr-ready-check-001",
"leftValue": "={{ $('update_ocr_status').item.json.total_docs }}",
"rightValue": "={{ $('update_ocr_status').item.json.ready_docs }}",
"operator": {
"type": "number",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
}
}

View File

@@ -0,0 +1,25 @@
{
"name": "publish_docs_ready",
"type": "n8n-nodes-base.redis",
"typeVersion": 1,
"position": [4600, 1550],
"parameters": {
"operation": "publish",
"channel": "clpr:claim:docs_ready",
"messageData": "={{ JSON.stringify({ claim_id: $('update_ocr_status').item.json.claim_id, total_docs: $('update_ocr_status').item.json.total_docs, ready_docs: $('update_ocr_status').item.json.ready_docs, timestamp: new Date().toISOString() }) }}"
},
"credentials": {
"redis": {
"id": "F2IkIEYT9O7UTtz9",
"name": "Redis account"
}
}
}

View File

@@ -0,0 +1,24 @@
{
"name": "redis_incr_ready",
"type": "n8n-nodes-base.redis",
"typeVersion": 1,
"position": [4100, 1664],
"parameters": {
"operation": "incr",
"key": "=claim:ocr:ready:{{ $json.claim_id }}"
},
"credentials": {
"redis": {
"id": "F2IkIEYT9O7UTtz9",
"name": "Redis account"
}
}
}

View File

@@ -0,0 +1,26 @@
{
"name": "update_ocr_error",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [3850, 1850],
"parameters": {
"operation": "executeQuery",
"query": "-- Обновляем статус OCR при ошибке\nUPDATE clpr_claim_documents\nSET \n ocr_status = 'error',\n ocr_error = '{{ $json.error || \"OCR processing failed\" }}',\n ocr_processed_at = NOW()\nWHERE id = '{{ $('files').item.json.claim_document_id }}'::uuid\nRETURNING id, claim_id, ocr_status, ocr_error;",
"options": {}
},
"credentials": {
"postgres": {
"id": "sGJ0fJhU8rz88w3k",
"name": "timeweb_bd"
}
},
"onError": "continueRegularOutput"
}

View File

@@ -0,0 +1,25 @@
{
"name": "update_ocr_status",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [3850, 1664],
"parameters": {
"operation": "executeQuery",
"query": "-- Обновляем статус OCR для документа и возвращаем счётчики\nUPDATE clpr_claim_documents\nSET \n ocr_status = 'ready',\n ocr_processed_at = NOW()\nWHERE id = '{{ $('files').item.json.claim_document_id }}'::uuid\nRETURNING \n id AS doc_id,\n claim_id,\n ocr_status,\n (SELECT COUNT(*) FROM clpr_claim_documents WHERE claim_id = clpr_claim_documents.claim_id) AS total_docs,\n (SELECT COUNT(*) FROM clpr_claim_documents WHERE claim_id = clpr_claim_documents.claim_id AND ocr_status = 'ready') AS ready_docs;",
"options": {}
},
"credentials": {
"postgres": {
"id": "sGJ0fJhU8rz88w3k",
"name": "timeweb_bd"
}
}
}

View File

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

View File

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

136
fix_draft_bddb6815.py Normal file
View File

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

View File

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

36
frontend/Dockerfile.prod Normal file
View File

@@ -0,0 +1,36 @@
# React Frontend Dockerfile (PRODUCTION BUILD)
FROM node:18-alpine AS builder
# Устанавливаем рабочую директорию
WORKDIR /app
# Копируем package.json
COPY package*.json ./
# Устанавливаем зависимости
RUN npm ci
# Копируем исходный код
COPY . .
# Собираем production build
RUN npm run build
# Production stage
FROM node:18-alpine
# Устанавливаем serve глобально
RUN npm install -g serve
# Копируем собранное приложение из builder stage
COPY --from=builder /app/dist /app/dist
# Устанавливаем рабочую директорию
WORKDIR /app
# Открываем порт
EXPOSE 3000
# Запускаем serve для раздачи статических файлов
CMD ["serve", "-s", "dist", "-l", "3000"]

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ERV Insurance Platform</title>
<title>Clientright — защита прав потребителей</title>
</head>
<body>
<div id="root"></div>

8927
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"type-check": "tsc --noEmit",

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ERV Insurance Platform</title>
<title>Clientright — защита прав потребителей</title>
</head>
<body>
<div id="root"></div>

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Form, Input, Button, message, Space } from 'antd';
import { PhoneOutlined, SafetyOutlined } from '@ant-design/icons';
import { Form, Input, Button, message, Space, Modal } from 'antd';
import { PhoneOutlined, SafetyOutlined, CopyOutlined } from '@ant-design/icons';
interface Props {
formData: any;
@@ -23,6 +23,8 @@ export default function Step1Phone({
const [codeSent, setCodeSent] = useState(false);
const [loading, setLoading] = useState(false);
const [verifyLoading, setVerifyLoading] = useState(false);
const [debugCode, setDebugCode] = useState<string | null>(null);
const [showDebugModal, setShowDebugModal] = useState(false);
const sendCode = async () => {
try {
@@ -49,8 +51,11 @@ export default function Step1Phone({
message.success('Код отправлен на ваш телефон');
setCodeSent(true);
updateFormData({ phone });
// 🔧 DEV MODE: показываем debug код в модалке
if (result.debug_code) {
message.info(`DEBUG: Код ${result.debug_code}`);
setDebugCode(result.debug_code);
setShowDebugModal(true);
}
} else {
addDebugEvent?.('sms', 'error', `❌ Ошибка SMS: ${result.detail}`, { error: result.detail });
@@ -278,6 +283,37 @@ export default function Step1Phone({
maxLength={10}
size="large"
style={{ flex: 1 }}
onPaste={(e) => {
// Обработка вставки: очищаем от +7, пробелов и других символов
e.preventDefault();
const pastedText = (e.clipboardData || (window as any).clipboardData).getData('text');
// Убираем все нецифровые символы
let cleanText = pastedText.replace(/\D/g, '');
// Если начинается с 7 или 8, убираем первую цифру (код страны)
if (cleanText.length === 11 && (cleanText.startsWith('7') || cleanText.startsWith('8'))) {
cleanText = cleanText.substring(1);
}
// Оставляем только первые 10 цифр
cleanText = cleanText.substring(0, 10);
// ✅ Устанавливаем значение напрямую в input, затем синхронизируем с формой
const target = e.target as HTMLInputElement;
if (target) {
target.value = cleanText;
// Триггерим событие input для синхронизации с формой
const inputEvent = new Event('input', { bubbles: true });
target.dispatchEvent(inputEvent);
}
// ✅ Синхронизируем с формой через requestAnimationFrame для избежания циклических ссылок
requestAnimationFrame(() => {
form.setFieldValue('phone', cleanText);
// Показываем предупреждение, если номер был обрезан
if (pastedText.replace(/\D/g, '').length > 10) {
message.warning('Номер автоматически обрезан до 10 цифр');
}
});
}}
/>
</Space.Compact>
</Form.Item>
@@ -305,38 +341,58 @@ export default function Step1Phone({
)}
</Form.Item>
{/* 🔧 Технические кнопки для разработки */}
<div style={{
marginTop: 24,
padding: 16,
background: '#f0f0f0',
borderRadius: 8,
border: '2px dashed #999'
}}>
<div style={{ marginBottom: 8, fontSize: 12, color: '#666', fontWeight: 'bold' }}>
🔧 DEV MODE - Быстрая навигация (без валидации)
</div>
<div style={{ display: 'flex', gap: 8 }}>
{/* 🔧 DEV MODE: Модалка с SMS кодом */}
<Modal
title="🔧 DEV MODE - SMS Код"
open={showDebugModal}
onCancel={() => setShowDebugModal(false)}
footer={[
<Button
type="dashed"
key="copy"
icon={<CopyOutlined />}
onClick={() => {
// Автозаполняем телефон и email
const devData = {
phone: '79001234567', // БЕЗ +
email: 'test@test.ru',
};
updateFormData(devData);
setIsPhoneVerified(true);
message.success('DEV: Телефон автоматически подтверждён');
onNext();
if (debugCode) {
// Fallback для HTTP (clipboard API требует HTTPS)
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(debugCode);
} else {
// Fallback: копируем через textarea
const textArea = document.createElement('textarea');
textArea.value = debugCode;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
message.success('Код скопирован!');
}
}}
size="small"
style={{ flex: 1 }}
>
Далее (Step 2) [пропустить]
Скопировать
</Button>,
<Button key="close" type="primary" onClick={() => setShowDebugModal(false)}>
Закрыть
</Button>
]}
>
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<p style={{ marginBottom: 16, color: '#666' }}>
Это DEV режим. SMS не отправляется реально.
</p>
<div style={{
fontSize: 32,
fontWeight: 'bold',
fontFamily: 'monospace',
background: '#f5f5f5',
padding: '16px 32px',
borderRadius: 8,
display: 'inline-block',
letterSpacing: 8
}}>
{debugCode}
</div>
</div>
</div>
</Modal>
</Form>
);
}

View File

@@ -656,36 +656,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
) : null}
</Modal>
{/* 🔧 Технические кнопки для разработки */}
<div style={{
marginTop: 24,
padding: 16,
background: '#f0f0f0',
borderRadius: 8,
border: '2px dashed #999'
}}>
<div style={{ marginBottom: 8, fontSize: 12, color: '#666', fontWeight: 'bold' }}>
🔧 DEV MODE - Быстрая навигация (без валидации)
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Button
type="dashed"
onClick={() => {
// Пропускаем валидацию, заполняем минимальные данные
const devData = {
voucher: 'E1000-123456789',
claim_id: `CLM-DEV-${Math.random().toString(36).substr(2, 6).toUpperCase()}`,
};
updateFormData(devData);
onNext();
}}
size="small"
style={{ flex: 1 }}
>
Далее (Step 2) [пропустить]
</Button>
</div>
</div>
{/* DEV MODE секция удалена для продакшена */}
</Form>
);
}

View File

@@ -593,45 +593,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
</div>
</Form.Item>
{/* 🔧 Технические кнопки для разработки */}
<div style={{
marginTop: 24,
padding: 16,
background: '#f0f0f0',
borderRadius: 8,
border: '2px dashed #999'
}}>
<div style={{ marginBottom: 8, fontSize: 12, color: '#666', fontWeight: 'bold' }}>
🔧 DEV MODE - Быстрая навигация (без валидации)
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Button
onClick={onPrev}
size="small"
disabled={uploading}
>
Назад (Step 1)
</Button>
<Button
type="dashed"
onClick={() => {
const devData = {
eventType: 'delay_flight',
processedDocuments: {
boarding_or_ticket: { flight_number: 'DEV123', date: '2025-10-28' },
delay_confirmation: { delay_duration: '4h' }
}
};
updateFormData(devData);
onNext();
}}
size="small"
style={{ flex: 1 }}
>
Далее (Step 3) [пропустить]
</Button>
</div>
</div>
{/* DEV MODE секция удалена для продакшена */}
</Form>
);
}

Some files were not shown because too many files have changed in this diff Show More