Compare commits
13 Commits
6f31ad0dda
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73524465fd | ||
|
|
f7d27388a0 | ||
|
|
56516fdd7d | ||
|
|
1a653f2154 | ||
|
|
df8c93f46b | ||
|
|
30774db18c | ||
|
|
080e7ec105 | ||
|
|
64385c430d | ||
|
|
02689e65db | ||
|
|
1d6c9d1f52 | ||
|
|
521831be5e | ||
|
|
2fb0921e4c | ||
|
|
3d3f5995af |
99
GIT_STATUS.md
Normal file
99
GIT_STATUS.md
Normal 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
|
||||
|
||||
192
SESSION_LOG_2025-11-22_DIALOG.md
Normal file
192
SESSION_LOG_2025-11-22_DIALOG.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# Лог диалога - 22 ноября 2025
|
||||
|
||||
## Хронология диалога
|
||||
|
||||
### Начало работы
|
||||
Пользователь начал работу с исправлениями в `ticket_form`, связанными с обработкой черновиков и прикреплением документов к проектам.
|
||||
|
||||
### 1. Проблема с извлечением данных из payload
|
||||
|
||||
**Проблема:** В `payload` данные вложены в `body` (`payload.body.wizard_plan`, `payload.body.answers`), а не в `payload` напрямую.
|
||||
|
||||
**Решение:**
|
||||
- Исправлено извлечение данных из `payload.body` для telegram-черновиков
|
||||
- Добавлен парсинг JSON-строк в `wizard_plan` и `answers`
|
||||
- Использование `claim.id` (UUID) как `claim_id`, если `claim_id` null
|
||||
- Логика перехода: если есть `wizard_plan` → переходим к StepWizardPlan (шаг 2)
|
||||
|
||||
**Файлы изменены:**
|
||||
- `ticket_form/frontend/src/pages/ClaimForm.tsx`
|
||||
|
||||
### 2. Ошибка при загрузке черновика
|
||||
|
||||
**Ошибка:** `ReferenceError: Cannot access 'claimId2' before initialization` в `ClaimForm.tsx:160:50`
|
||||
|
||||
**Причина:** Конфликт имён переменных - локальная переменная `claimId` конфликтовала с параметром функции.
|
||||
|
||||
**Решение:** Переименована локальная переменная `claimId` в `finalClaimId` внутри функции `loadDraft`.
|
||||
|
||||
**Файлы изменены:**
|
||||
- `ticket_form/frontend/src/pages/ClaimForm.tsx`
|
||||
|
||||
### 3. Работа с n8n workflow `b4K4u851b4JFivyD` (ticket_form:description)
|
||||
|
||||
**Задача:** Настроить ноду `claimsave` для сохранения первичного черновика жалобы после построения wizard plan.
|
||||
|
||||
**Требования:**
|
||||
1. Сохранить черновик сразу после первичного построения wizard plan
|
||||
2. Включить данные из агентов (агент1 и агент13)
|
||||
3. Учесть `session_token` и `unified_id`
|
||||
4. Сохранить: `wizard_plan`, `problem_description`, `answers_prefill`, `coverage_report`, AI agent outputs
|
||||
|
||||
**Документация создана:**
|
||||
- `ticket_form/docs/CLAIMSAVE_PRIMARY_DRAFT_FIX.md`
|
||||
- `ticket_form/docs/SQL_CLAIMSAVE_PRIMARY_DRAFT.sql`
|
||||
|
||||
### 4. Ошибка в n8n Code node (Code4)
|
||||
|
||||
**Ошибка:** `ReferenceError: session is not defined [line 34]`
|
||||
|
||||
**Проблема:** В коде использовалась переменная `session`, которая не была определена.
|
||||
|
||||
**Решение:** Исправлен код в `CODE4_FIXED.js`:
|
||||
- Заменено `const sessionToken = $('Redis Trigger').first().json.message.claim_id` на более надёжную логику
|
||||
- `sessionToken` теперь берётся из `Edit Fields11` или `Redis Trigger`, с fallback на временный ключ
|
||||
- `redisKey` теперь использует `sessionToken` вместо `claim_id`
|
||||
|
||||
**Файлы:**
|
||||
- `ticket_form/docs/CODE4_FIXED.js`
|
||||
|
||||
### 5. Исправление CreateWebContact ноды
|
||||
|
||||
**Задача:** Убрать генерацию `claim_id`, добавить `unified_id` из ноды `user_get`, убрать `voucher` и `event_type` из `redis_value`.
|
||||
|
||||
**Решение:** Обновлён код `CODE_CREATE_WEB_CONTACT_FINAL.js`:
|
||||
- Убрана генерация `claim_id`
|
||||
- Добавлен `unified_id` из ноды `user_get`
|
||||
- Убраны `voucher` и `event_type` из `sessionData`
|
||||
- `redis_key` использует `session_id`
|
||||
|
||||
**Файлы:**
|
||||
- `ticket_form/docs/CODE_CREATE_WEB_CONTACT_FIXED.js`
|
||||
|
||||
### 6. Ошибка "Не удалось определить номер обращения"
|
||||
|
||||
**Проблема:** При создании нового обращения появлялась ошибка "Не удалось определить номер обращения. Вернитесь на шаг с телефоном."
|
||||
|
||||
**Решение:** Принято решение использовать только `session_id` на ранних этапах, убрать зависимость от `claim_id`.
|
||||
|
||||
**Изменения:**
|
||||
- `ticket_form/frontend/src/components/form/StepDescription.tsx` - убрана проверка `claim_id`
|
||||
- `ticket_form/frontend/src/components/form/Step1Phone.tsx` - убран `claim_id` из сохраняемых данных
|
||||
- `ticket_form/frontend/src/components/form/StepWizardPlan.tsx` - изменён EventSource на использование `session_id`
|
||||
- `ticket_form/backend/app/api/claims.py` - обновлено логирование для опционального `claim_id`
|
||||
|
||||
### 7. Модификация api_attach_documents.php
|
||||
|
||||
**Задача:** Вернуть `project_name` в дополнение к `project_id`.
|
||||
|
||||
**Решение:** Обновлён `include/Webservices/CreateClientProject.php`:
|
||||
- Функция теперь возвращает `project_name` вместе с `project_id` и `is_new`
|
||||
- Добавлен SQL запрос для получения `project_name`, если проект найден (не новый)
|
||||
|
||||
**Файлы:**
|
||||
- `include/Webservices/CreateClientProject.php`
|
||||
|
||||
### 8. Обновление S3 пути для файлов
|
||||
|
||||
**Задача:** Изменить формат пути S3 на `/f9825c87-.../crm2/CRM_Active_Files/Documents/Project/{project_name}_{project_id}/{doc_id}__{slug}.{ext}`
|
||||
|
||||
**Решение:** Обновлён `CODE_FILES_RENAME_FIXED.js`:
|
||||
- Добавлено получение `project_id` и `project_name` из нескольких источников
|
||||
- Реализована санитизация `projectFolder` для удаления недопустимых символов
|
||||
- Обновлена генерация `slug` с приоритетом: `field_label` > `field_name` > `description`
|
||||
- Добавлен `field_label` в `renames` и `finalDocumentsMeta`
|
||||
|
||||
**Файлы:**
|
||||
- `ticket_form/docs/CODE_FILES_RENAME_FIXED.js`
|
||||
|
||||
### 9. Исправление slug для названий документов
|
||||
|
||||
**Задача:** Использовать название поля из формы визарда вместо generic "upload-contr".
|
||||
|
||||
**Решение:**
|
||||
- В `StepWizardPlan.tsx` добавлена отправка `uploads_field_labels[i]` (содержит `block.docLabel`)
|
||||
- В `CODE_FILES_RENAME_FIXED.js` обновлена генерация `slug` с использованием `field_label`
|
||||
|
||||
**Файлы:**
|
||||
- `ticket_form/frontend/src/components/form/StepWizardPlan.tsx`
|
||||
- `ticket_form/docs/CODE_FILES_RENAME_FIXED.js`
|
||||
|
||||
### 10. Ошибка "Multiple matching items" в Edit Fields13
|
||||
|
||||
**Ошибка:** `Multiple matching items for item [0] [item 0]` в ноде "Edit Fields13".
|
||||
|
||||
**Решение:** Обновлены выражения в "Edit Fields13":
|
||||
- Добавлен `.first()` для нод, возвращающих один item (`Edit Fields6`, `Code5`)
|
||||
- Исправлено обращение к `Split Out2` (используется `$json.to` вместо `$('Split Out2').item.json.to`)
|
||||
|
||||
### 11. Исправление CODE_MERGE_PROJECT_TO_SESSION
|
||||
|
||||
**Ошибка:** `TypeError: Cannot assign to read only property 'name' of object 'Error: Referenced node doesn't exist'`
|
||||
|
||||
**Решение:** Заменён оператор `||` для доступа к ноде на `try-catch` блоки для безопасной проверки существования ноды.
|
||||
|
||||
**Файлы:**
|
||||
- `ticket_form/docs/CODE_MERGE_PROJECT_TO_SESSION.js`
|
||||
|
||||
### 12. Финальные исправления и коммит
|
||||
|
||||
**Выполнено:**
|
||||
- Исправлена загрузка черновиков (упрощена логика перехода)
|
||||
- Убрано отображение `claim_id` в заголовке черновика
|
||||
- Обновлён формат пути S3 с `project_name`
|
||||
- Добавлен `field_label` в результат переименования файлов
|
||||
|
||||
**Git коммиты:**
|
||||
- `486f3619`: "Добавлен field_label в результат переименования файлов, исправлена загрузка черновиков, обновлен формат пути S3 с project_name"
|
||||
- `a20a4d0e`: "Добавлен лог сессии 2025-11-22"
|
||||
|
||||
## Итоговые изменения
|
||||
|
||||
### Frontend
|
||||
1. `ClaimForm.tsx` - исправлена загрузка черновиков, убрана зависимость от `claim_id`
|
||||
2. `StepDescription.tsx` - убрана проверка `claim_id`
|
||||
3. `Step1Phone.tsx` - убран `claim_id` из сохраняемых данных
|
||||
4. `StepWizardPlan.tsx` - добавлена отправка `uploads_field_labels`, изменён EventSource на `session_id`
|
||||
5. `StepDraftSelection.tsx` - убран `claim_id` из заголовка черновика
|
||||
|
||||
### Backend
|
||||
1. `claims.py` - обновлено логирование для опционального `claim_id`
|
||||
2. `CreateClientProject.php` - добавлен возврат `project_name`
|
||||
|
||||
### n8n Workflows
|
||||
1. Code4 - исправлена ошибка с `session is not defined`
|
||||
2. CreateWebContact - убрана генерация `claim_id`, добавлен `unified_id`
|
||||
3. CODE_FILES_RENAME_FIXED - обновлён формат пути S3, добавлен `field_label`
|
||||
4. CODE_MERGE_PROJECT_TO_SESSION - безопасная проверка существования ноды
|
||||
5. Edit Fields13 - исправлена ошибка "Multiple matching items"
|
||||
|
||||
### Документация
|
||||
1. `CLAIMSAVE_PRIMARY_DRAFT_FIX.md` - описание сохранения первичного черновика
|
||||
2. `SQL_CLAIMSAVE_PRIMARY_DRAFT.sql` - SQL запрос для сохранения черновика
|
||||
3. `CODE4_FIXED.js` - исправленный код для Code4
|
||||
4. `CODE_CREATE_WEB_CONTACT_FIXED.js` - исправленный код для CreateWebContact
|
||||
5. `CODE_FILES_RENAME_FIXED.js` - обновлённый код для переименования файлов
|
||||
6. `CODE_MERGE_PROJECT_TO_SESSION.js` - код для мержа данных проекта
|
||||
|
||||
## Статистика
|
||||
|
||||
- **Изменено файлов:** 212
|
||||
- **Добавлено строк:** +6706
|
||||
- **Удалено строк:** -125
|
||||
- **Git коммитов:** 2
|
||||
|
||||
## Важные замечания
|
||||
|
||||
1. На ранних этапах используется только `session_id`, `claim_id` генерируется позже в workflow
|
||||
2. `project_name` теперь используется в пути S3 для лучшей организации файлов
|
||||
3. `field_label` из формы визарда используется для генерации slug файлов
|
||||
4. Все ноды n8n должны безопасно обрабатывать отсутствие данных
|
||||
|
||||
|
||||
135
SESSION_LOG_2025-11-25.md
Normal file
135
SESSION_LOG_2025-11-25.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Лог сессии 25.11.2025
|
||||
|
||||
## Основные задачи
|
||||
|
||||
### 1. Передача unified_id и contact_id в описание проблемы
|
||||
|
||||
**Файлы:**
|
||||
- `backend/app/api/models.py` — добавлены поля `unified_id` и `contact_id` в `TicketFormDescriptionRequest`
|
||||
- `backend/app/api/claims.py` — добавлена передача `unified_id` и `contact_id` в Redis событие
|
||||
- `frontend/src/components/form/StepDescription.tsx` — добавлена передача `unified_id` и `contact_id` при отправке описания
|
||||
|
||||
**Результат:** При отправке описания проблемы теперь передаются `unified_id` и `contact_id` пользователя.
|
||||
|
||||
---
|
||||
|
||||
### 2. Структура таблиц CRM MySQL для контактов
|
||||
|
||||
**Основные таблицы:**
|
||||
- `vtiger_contactdetails` — основные данные (firstname, lastname, email, mobile, phone)
|
||||
- `vtiger_contactscf` — кастомные поля:
|
||||
- `cf_1157` — Отчество (middle_name)
|
||||
- `cf_1263` — Место рождения (birthplace)
|
||||
- `cf_1257` — ИНН (inn)
|
||||
- `cf_1849` — Реквизиты (requisites)
|
||||
- `cf_1580` — Код (code)
|
||||
- `vtiger_contactsubdetails` — дополнительные данные (birthday, homephone)
|
||||
- `vtiger_contactaddress` — адреса (mailingstreet, mailingcity, и т.д.)
|
||||
|
||||
**Создан файл:** `docs/SQL_SELECT_CONTACT_WITH_CUSTOM_FIELDS.sql` — правильный SQL запрос для получения всех данных контакта
|
||||
|
||||
---
|
||||
|
||||
### 3. Исправление Code Node: Мерж данных проекта в сессию
|
||||
|
||||
**Проблема:** Данные из `body.other` (sessionData) не сохранялись в Redis — терялись все данные пользователя.
|
||||
|
||||
**Причина:** К моменту выполнения Code Node структура данных менялась (`body_keys: ["success", "result"]`), и `body.other` был недоступен.
|
||||
|
||||
**Решение:** Добавлен fallback на получение `other` напрямую из Webhook:
|
||||
```javascript
|
||||
// ✅ Пробуем также достать other из Webhook напрямую
|
||||
if (!rawOther) {
|
||||
try {
|
||||
const webhookJson = $('Webhook').first()?.json;
|
||||
if (webhookJson?.body?.other) {
|
||||
rawOther = webhookJson.body.other;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
```
|
||||
|
||||
**Файл:** `docs/CODE_MERGE_PROJECT_TO_SESSION.js`
|
||||
|
||||
**Результат:** Теперь в Redis сохраняются ВСЕ данные:
|
||||
- session_id, phone, unified_id, contact_id
|
||||
- lastname, firstname, middle_name
|
||||
- birthday, birthplace, inn
|
||||
- mailingzip, mailingstreet, email, tg_id
|
||||
- description
|
||||
- claim_id, project_id, project_name
|
||||
- is_new_project, current_step
|
||||
|
||||
---
|
||||
|
||||
### 4. Генерация новой сессии для новой жалобы
|
||||
|
||||
**Проблема:** При создании новой жалобы использовалась та же сессия, что и для предыдущей.
|
||||
|
||||
**Решение:**
|
||||
- Добавлена функция `generateUUIDv4()` в `ClaimForm.tsx`
|
||||
- При создании новой жалобы генерируется новый `session_id`
|
||||
- `session_token` в localStorage (авторизация) остаётся прежним
|
||||
- `unified_id`, `phone`, `contact_id` сохраняются
|
||||
|
||||
**Файл:** `frontend/src/pages/ClaimForm.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Созданные/обновлённые файлы
|
||||
|
||||
### Новые файлы:
|
||||
- `docs/SQL_SELECT_CONTACT_WITH_CUSTOM_FIELDS.sql` — SQL запрос для контактов с кастомными полями
|
||||
|
||||
### Обновлённые файлы:
|
||||
- `backend/app/api/models.py` — добавлены unified_id, contact_id
|
||||
- `backend/app/api/claims.py` — передача unified_id, contact_id в Redis
|
||||
- `frontend/src/components/form/StepDescription.tsx` — передача unified_id, contact_id
|
||||
- `frontend/src/pages/ClaimForm.tsx` — генерация новой сессии для новой жалобы
|
||||
- `docs/CODE_MERGE_PROJECT_TO_SESSION.js` — исправлен мерж данных в сессию
|
||||
|
||||
---
|
||||
|
||||
## Технические детали
|
||||
|
||||
### Redis канал для описания проблемы
|
||||
- Канал: `ticket_form:description`
|
||||
- Передаваемые данные: session_id, phone, email, unified_id, contact_id, problem_description
|
||||
|
||||
### Redis канал для подтверждения формы
|
||||
- Канал: `clientright:webform:approve`
|
||||
- Включает SMS код для верификации
|
||||
|
||||
### Структура сессии в Redis
|
||||
```json
|
||||
{
|
||||
"session_id": "sess_...",
|
||||
"phone": "79262306381",
|
||||
"unified_id": "usr_...",
|
||||
"contact_id": "320096",
|
||||
"lastname": "Коробков",
|
||||
"firstname": "Федор",
|
||||
"middle_name": "Владимирович",
|
||||
"birthday": "1981-09-18",
|
||||
"birthplace": "Москва",
|
||||
"inn": "123456789012",
|
||||
"mailingstreet": "...",
|
||||
"email": "help@clientright.ru",
|
||||
"tg_id": "295410106",
|
||||
"description": "...",
|
||||
"claim_id": "...",
|
||||
"project_id": "399171",
|
||||
"project_name": "Коробков_КлиентПрав",
|
||||
"is_new_project": false,
|
||||
"current_step": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Статус
|
||||
✅ Все задачи выполнены
|
||||
✅ Backend пересобран и перезапущен
|
||||
✅ Frontend обновлён через HMR
|
||||
✅ Тестирование успешно
|
||||
|
||||
176
SESSION_LOG_2025-11-26_DOCUMENTS_FIX.md
Normal file
176
SESSION_LOG_2025-11-26_DOCUMENTS_FIX.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Лог сессии: Исправление загрузки документов и SQL запросов
|
||||
|
||||
**Дата:** 2025-11-26
|
||||
**Тема:** Исправление потери документов, дубликатов и правильного определения field_name
|
||||
|
||||
---
|
||||
|
||||
## Проблемы, которые были решены
|
||||
|
||||
### 1. Потеря документов при обновлении черновика
|
||||
**Проблема:** При обработке нового документа через SQL `claimsave_final` существующие документы терялись.
|
||||
|
||||
**Причина:**
|
||||
- SQL перезаписывал `documents_meta` вместо объединения
|
||||
- `documents_uploaded` мог быть перезаписан пустым массивом, если `jsonb_agg` возвращал NULL
|
||||
|
||||
**Решение:**
|
||||
- Исправлен SQL `SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql`:
|
||||
- `documents_meta` теперь объединяется с существующими
|
||||
- `documents_uploaded` всегда начинается с существующих документов
|
||||
- Добавлена проверка на пустой массив перед перезаписью
|
||||
|
||||
### 2. Дубликаты документов в documents_meta
|
||||
**Проблема:** В `documents_meta` были дубликаты (один и тот же `file_id` встречался несколько раз).
|
||||
|
||||
**Решение:**
|
||||
- Создан скрипт `fix_documents_meta_duplicates.py` для удаления дубликатов
|
||||
- Исправлена логика объединения в SQL
|
||||
|
||||
### 3. Неправильное определение типа документа
|
||||
**Проблема:** Чек определялся как `contract` вместо `payment`.
|
||||
|
||||
**Причина:**
|
||||
- SQL проверял `field_name` раньше, чем `field_label`
|
||||
- `field_name` был `uploads[0][0]` для всех документов
|
||||
|
||||
**Решение:**
|
||||
- Изменён порядок проверки в SQL: сначала `field_label`, потом `field_name`
|
||||
- Исправлен файл `SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql`
|
||||
|
||||
### 4. Все документы имели одинаковый field_name
|
||||
**Проблема:** В таблице `clpr_claim_documents` все документы имели `field_name: uploads[0][0]`, из-за чего второй документ перезаписывал первый.
|
||||
|
||||
**Причина:**
|
||||
- `group_index` (индекс документа в `documents_required`) не передавался с фронтенда
|
||||
- Код n8n использовал `group_index_num` из OCR, который всегда был `0`
|
||||
|
||||
**Решение:**
|
||||
- Фронтенд (`StepWizardPlan.tsx`): добавлена передача `group_index` в запрос
|
||||
- Бэкенд (`documents.py`): добавлено получение `group_index` из Form и передача в n8n
|
||||
- Код n8n (`N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js`): приоритет `group_index` из body над `group_index_num` из OCR
|
||||
- Создан скрипт `fix_claim_documents_field_names.py` для исправления существующих документов
|
||||
|
||||
### 5. SQL для claimsave перезаписывал documents_meta
|
||||
**Проблема:** SQL `claimsave` перезаписывал `documents_meta` вместо объединения.
|
||||
|
||||
**Решение:**
|
||||
- Исправлен файл `SQL_CLAIMSAVE_FIXED_NEW_FLOW.sql`:
|
||||
- `documents_meta` объединяется с существующими
|
||||
- Критичные поля удаляются из нового payload перед объединением
|
||||
- Затем устанавливаются отдельно через `jsonb_set`
|
||||
|
||||
### 6. Дубликаты в списке загруженных документов на фронтенде
|
||||
**Проблема:** React ошибка "Encountered two children with the same key, `contract`".
|
||||
|
||||
**Решение:**
|
||||
- Исправлен `StepWizardPlan.tsx`:
|
||||
- Убраны дубликаты при инициализации `uploadedDocs`
|
||||
- Проверка на дубликаты при добавлении нового документа
|
||||
- Использование `Array.from(new Set())` при рендеринге
|
||||
|
||||
---
|
||||
|
||||
## Созданные файлы
|
||||
|
||||
### SQL запросы
|
||||
- `docs/SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql` - SQL для сохранения документов с автоматическим созданием `documents_uploaded`
|
||||
- `docs/SQL_CLAIMSAVE_FIXED_NEW_FLOW.sql` - Исправленный SQL для `claimsave` с объединением `documents_meta`
|
||||
- `docs/SQL_FIX_DRAFT_BDDB6815.sql` - SQL для исправления конкретного черновика
|
||||
- `docs/SQL_FIX_CLAIM_DOCUMENTS_FIELD_NAMES.sql` - SQL для исправления `field_name` в таблице
|
||||
|
||||
### Код n8n
|
||||
- `docs/N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js` - Исправленный код для обработки загруженных файлов с поддержкой `group_index`
|
||||
|
||||
### Скрипты для исправления данных
|
||||
- `fix_draft_bddb6815_with_contract.py` - Скрипт для исправления черновика с учётом загруженных документов
|
||||
- `fix_documents_meta_duplicates.py` - Скрипт для удаления дубликатов из `documents_meta`
|
||||
- `fix_claim_documents_field_names.py` - Скрипт для исправления `field_name` в таблице `clpr_claim_documents`
|
||||
- `check_documents_detailed.py` - Скрипт для детальной проверки документов
|
||||
- `check_documents_mismatch.py` - Скрипт для проверки несоответствий между `documents_uploaded` и таблицей
|
||||
|
||||
---
|
||||
|
||||
## Изменённые файлы
|
||||
|
||||
### Backend
|
||||
- `backend/app/api/documents.py` - Добавлена передача `group_index` в n8n
|
||||
- `backend/app/api/claims.py` - Обновлена логика загрузки черновиков, добавлена поддержка `documents_required`
|
||||
- `backend/app/api/events.py` - Исправлены синтаксические ошибки (удалены дубликаты кода)
|
||||
- `backend/app/api/models.py` - Добавлены поля `unified_id` и `contact_id`
|
||||
|
||||
### Frontend
|
||||
- `frontend/src/pages/ClaimForm.tsx` - Обновлена логика загрузки черновиков, добавлена поддержка нового флоу
|
||||
- `frontend/src/components/form/StepWizardPlan.tsx` - Добавлена передача `group_index`, исправлены дубликаты в списке документов
|
||||
- `frontend/src/components/form/StepDraftSelection.tsx` - Обновлена логика определения legacy черновиков
|
||||
- `frontend/src/components/form/StepDescription.tsx` - Добавлена передача `unified_id` и `contact_id`
|
||||
|
||||
---
|
||||
|
||||
## Результаты
|
||||
|
||||
### Исправлено для черновика `bddb6815-8e17-4d54-a721-5e94382942c7`:
|
||||
- ✅ Удалены дубликаты из `documents_meta` (было 4, стало 3)
|
||||
- ✅ Исправлены типы документов в `documents_uploaded` (чек теперь `payment`, а не `contract`)
|
||||
- ✅ Исправлены `field_name` в таблице `clpr_claim_documents`:
|
||||
- `uploads[0][0]` - contract (договор)
|
||||
- `uploads[1][0]` - payment (чек)
|
||||
- `uploads[3][0]` - evidence_photo (фото доказательства)
|
||||
|
||||
### Текущее состояние:
|
||||
- `documents_required`: 4 документа
|
||||
- `documents_uploaded`: 2 документа (contract, payment)
|
||||
- `documents_meta`: 3 документа (без дубликатов)
|
||||
- `current_doc_index`: 2 (следующий документ - correspondence)
|
||||
- `status_code`: `draft_docs_progress`
|
||||
|
||||
---
|
||||
|
||||
## Что нужно сделать дальше
|
||||
|
||||
1. **Обновить код n8n:**
|
||||
- Заменить код в узле "Process Uploaded Files" на версию из `N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js`
|
||||
- Убедиться, что `group_index` передаётся из body
|
||||
|
||||
2. **Обновить SQL в n8n:**
|
||||
- Заменить SQL в узле "claimsave" на версию из `SQL_CLAIMSAVE_FIXED_NEW_FLOW.sql`
|
||||
- Заменить SQL в узле "claimsave_final" на версию из `SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql`
|
||||
|
||||
3. **Проверить работу:**
|
||||
- Загрузить новый документ через интерфейс
|
||||
- Убедиться, что он получает правильный `field_name` (например, `uploads[2][0]` для третьего документа)
|
||||
- Проверить, что документы не теряются при обновлении черновика
|
||||
|
||||
---
|
||||
|
||||
## Важные моменты
|
||||
|
||||
1. **Приоритет определения типа документа:**
|
||||
- Сначала проверяется `field_label` (более точный)
|
||||
- Потом проверяется `field_name` (fallback)
|
||||
|
||||
2. **Объединение документов:**
|
||||
- `documents_meta` всегда объединяется с существующими
|
||||
- `documents_uploaded` всегда начинается с существующих документов
|
||||
- Новые документы добавляются только если их нет в существующих
|
||||
|
||||
3. **field_name:**
|
||||
- Формат: `uploads[{group_index}][0]`
|
||||
- `group_index` = индекс документа в `documents_required` (0-based)
|
||||
- Передаётся с фронтенда через параметр `group_index`
|
||||
|
||||
---
|
||||
|
||||
## Команды для проверки
|
||||
|
||||
```bash
|
||||
# Проверить документы в черновике
|
||||
docker exec ticket_form_backend python3 /app/check_documents_detailed.py
|
||||
|
||||
# Проверить документы в таблице
|
||||
docker exec ticket_form_backend python3 /app/check_claim_documents_table.py
|
||||
|
||||
# Исправить field_name для существующих документов
|
||||
docker exec ticket_form_backend python3 /app/fix_claim_documents_field_names.py
|
||||
```
|
||||
|
||||
287
SESSION_LOG_2025-11-26_NEW_FLOW.md
Normal file
287
SESSION_LOG_2025-11-26_NEW_FLOW.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# 📝 Лог сессии: Новая архитектура загрузки документов
|
||||
|
||||
**Дата:** 2025-11-26
|
||||
**Время:** ~13:00 MSK
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Цель сессии
|
||||
|
||||
Концептуальная переработка флоу подачи заявки:
|
||||
- **Проблема:** Визард генерируется слишком долго (2 минуты), анкета слишком длинная
|
||||
- **Решение:** Сразу запрашиваем документы, параллельно генерируем визард в бэке
|
||||
|
||||
---
|
||||
|
||||
## ✅ Что сделано
|
||||
|
||||
### 1. Документация архитектуры
|
||||
- **Файл:** `docs/NEW_FLOW_ARCHITECTURE.md`
|
||||
- Описан новый флоу: Description → Documents → Waiting → Claim Review → SMS
|
||||
- Определены статусы черновиков: `draft_new`, `draft_docs_progress`, `draft_docs_complete`, `draft_claim_ready`, `awaiting_sms`
|
||||
- Структура payload черновика с новыми полями
|
||||
|
||||
### 2. Frontend компоненты
|
||||
|
||||
#### StepDocumentsNew.tsx (НОВЫЙ)
|
||||
- Поэкранная загрузка документов (один документ на экран)
|
||||
- Критичные документы помечены предупреждением
|
||||
- Возможность пропустить любой документ
|
||||
- Прогресс-бар загрузки
|
||||
- Отображение уже загруженных документов
|
||||
|
||||
#### StepWaitingClaim.tsx (НОВЫЙ)
|
||||
- Экран ожидания формирования заявления
|
||||
- SSE подписка на события: `document_ocr_completed`, `claim_ready`
|
||||
- Шаги обработки: OCR → Анализ → Формирование → Готово
|
||||
- Таймер ожидания
|
||||
- Таймаут 5 минут с обработкой ошибок
|
||||
|
||||
#### StepDraftSelection.tsx (ОБНОВЛЁН)
|
||||
- Поддержка новых статусов черновиков
|
||||
- Визуальное отображение разных статусов (цвета, иконки, описания)
|
||||
- Прогресс документов (X из Y загружено)
|
||||
- Legacy черновики помечаются как "устаревший формат"
|
||||
- Разные действия для разных статусов
|
||||
|
||||
### 3. Backend API
|
||||
|
||||
#### documents.py (НОВЫЙ)
|
||||
- `POST /api/v1/documents/upload` — загрузка одного документа
|
||||
- `GET /api/v1/documents/status/{claim_id}` — статус обработки документов
|
||||
- `POST /api/v1/documents/generate-list` — запрос на генерацию списка документов
|
||||
- Интеграция с n8n webhook
|
||||
- Публикация событий в Redis
|
||||
|
||||
#### main.py (ОБНОВЛЁН)
|
||||
- Добавлен роутер `documents`
|
||||
|
||||
---
|
||||
|
||||
## 📁 Изменённые файлы
|
||||
|
||||
```
|
||||
ticket_form/
|
||||
├── docs/
|
||||
│ └── NEW_FLOW_ARCHITECTURE.md # НОВЫЙ
|
||||
├── frontend/src/components/form/
|
||||
│ ├── StepDocumentsNew.tsx # НОВЫЙ
|
||||
│ ├── StepWaitingClaim.tsx # НОВЫЙ
|
||||
│ └── StepDraftSelection.tsx # ОБНОВЛЁН
|
||||
├── backend/app/
|
||||
│ ├── api/
|
||||
│ │ └── documents.py # НОВЫЙ
|
||||
│ └── main.py # ОБНОВЛЁН
|
||||
└── SESSION_LOG_2025-11-26_NEW_FLOW.md # НОВЫЙ
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⏳ Что осталось сделать
|
||||
|
||||
### Frontend
|
||||
- [ ] Обновить `ClaimForm.tsx` — интегрировать новые компоненты в флоу
|
||||
- [ ] Обновить `StepDescription.tsx` — после описания переходить к документам (не к визарду)
|
||||
|
||||
### Backend
|
||||
- [ ] Эндпоинт получения списка документов из черновика
|
||||
- [ ] SSE события для прогресса OCR
|
||||
|
||||
### n8n
|
||||
- [ ] Воркфлоу: генерация списка документов (быстрый AI запрос)
|
||||
- [ ] Воркфлоу: OCR документа → заполнение полей визарда
|
||||
- [ ] Воркфлоу: формирование заявления после всех документов
|
||||
- [ ] Webhook: `/webhook/document-upload`
|
||||
|
||||
### Тестирование
|
||||
- [ ] Полный цикл с реальными данными
|
||||
- [ ] Обработка ошибок
|
||||
- [ ] Legacy черновики
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Технические детали
|
||||
|
||||
### Новые SSE события
|
||||
```javascript
|
||||
// Список документов готов
|
||||
{ event_type: "documents_list_ready", documents_required: [...] }
|
||||
|
||||
// Документ загружен (начало OCR)
|
||||
{ event_type: "document_uploaded", document_type: "contract", status: "processing" }
|
||||
|
||||
// OCR завершён
|
||||
{ event_type: "document_ocr_completed", document_type: "contract", ocr_data: {...} }
|
||||
|
||||
// Заявление готово
|
||||
{ event_type: "claim_ready", claim_data: {...} }
|
||||
```
|
||||
|
||||
### Статусы черновиков
|
||||
| Статус | Описание |
|
||||
|--------|----------|
|
||||
| `draft_new` | Только описание проблемы |
|
||||
| `draft_docs_progress` | Часть документов загружена |
|
||||
| `draft_docs_complete` | Все документы, ждём заявление |
|
||||
| `draft_claim_ready` | Заявление готово |
|
||||
| `awaiting_sms` | Ждёт SMS подтверждения |
|
||||
|
||||
### Legacy черновики
|
||||
- Определяются по отсутствию `documents_required` в payload
|
||||
- Показываются с пометкой "устаревший формат"
|
||||
- Кнопка "Начать заново" копирует description в новый черновик
|
||||
|
||||
---
|
||||
|
||||
## 📌 Примечания
|
||||
|
||||
1. **Ветка backup:** `backup-wizard-ui-2025-11-26` содержит состояние до изменений
|
||||
2. **n8n:** Webhook `/webhook/document-upload` нужно создать
|
||||
3. **Redis каналы:**
|
||||
- `ocr_events:{session_id}` — события для конкретного пользователя
|
||||
- `ticket_form:documents_list` — запрос на генерацию списка документов
|
||||
|
||||
|
||||
|
||||
**Дата:** 2025-11-26
|
||||
**Время:** ~13:00 MSK
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Цель сессии
|
||||
|
||||
Концептуальная переработка флоу подачи заявки:
|
||||
- **Проблема:** Визард генерируется слишком долго (2 минуты), анкета слишком длинная
|
||||
- **Решение:** Сразу запрашиваем документы, параллельно генерируем визард в бэке
|
||||
|
||||
---
|
||||
|
||||
## ✅ Что сделано
|
||||
|
||||
### 1. Документация архитектуры
|
||||
- **Файл:** `docs/NEW_FLOW_ARCHITECTURE.md`
|
||||
- Описан новый флоу: Description → Documents → Waiting → Claim Review → SMS
|
||||
- Определены статусы черновиков: `draft_new`, `draft_docs_progress`, `draft_docs_complete`, `draft_claim_ready`, `awaiting_sms`
|
||||
- Структура payload черновика с новыми полями
|
||||
|
||||
### 2. Frontend компоненты
|
||||
|
||||
#### StepDocumentsNew.tsx (НОВЫЙ)
|
||||
- Поэкранная загрузка документов (один документ на экран)
|
||||
- Критичные документы помечены предупреждением
|
||||
- Возможность пропустить любой документ
|
||||
- Прогресс-бар загрузки
|
||||
- Отображение уже загруженных документов
|
||||
|
||||
#### StepWaitingClaim.tsx (НОВЫЙ)
|
||||
- Экран ожидания формирования заявления
|
||||
- SSE подписка на события: `document_ocr_completed`, `claim_ready`
|
||||
- Шаги обработки: OCR → Анализ → Формирование → Готово
|
||||
- Таймер ожидания
|
||||
- Таймаут 5 минут с обработкой ошибок
|
||||
|
||||
#### StepDraftSelection.tsx (ОБНОВЛЁН)
|
||||
- Поддержка новых статусов черновиков
|
||||
- Визуальное отображение разных статусов (цвета, иконки, описания)
|
||||
- Прогресс документов (X из Y загружено)
|
||||
- Legacy черновики помечаются как "устаревший формат"
|
||||
- Разные действия для разных статусов
|
||||
|
||||
### 3. Backend API
|
||||
|
||||
#### documents.py (НОВЫЙ)
|
||||
- `POST /api/v1/documents/upload` — загрузка одного документа
|
||||
- `GET /api/v1/documents/status/{claim_id}` — статус обработки документов
|
||||
- `POST /api/v1/documents/generate-list` — запрос на генерацию списка документов
|
||||
- Интеграция с n8n webhook
|
||||
- Публикация событий в Redis
|
||||
|
||||
#### main.py (ОБНОВЛЁН)
|
||||
- Добавлен роутер `documents`
|
||||
|
||||
---
|
||||
|
||||
## 📁 Изменённые файлы
|
||||
|
||||
```
|
||||
ticket_form/
|
||||
├── docs/
|
||||
│ └── NEW_FLOW_ARCHITECTURE.md # НОВЫЙ
|
||||
├── frontend/src/components/form/
|
||||
│ ├── StepDocumentsNew.tsx # НОВЫЙ
|
||||
│ ├── StepWaitingClaim.tsx # НОВЫЙ
|
||||
│ └── StepDraftSelection.tsx # ОБНОВЛЁН
|
||||
├── backend/app/
|
||||
│ ├── api/
|
||||
│ │ └── documents.py # НОВЫЙ
|
||||
│ └── main.py # ОБНОВЛЁН
|
||||
└── SESSION_LOG_2025-11-26_NEW_FLOW.md # НОВЫЙ
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⏳ Что осталось сделать
|
||||
|
||||
### Frontend
|
||||
- [ ] Обновить `ClaimForm.tsx` — интегрировать новые компоненты в флоу
|
||||
- [ ] Обновить `StepDescription.tsx` — после описания переходить к документам (не к визарду)
|
||||
|
||||
### Backend
|
||||
- [ ] Эндпоинт получения списка документов из черновика
|
||||
- [ ] SSE события для прогресса OCR
|
||||
|
||||
### n8n
|
||||
- [ ] Воркфлоу: генерация списка документов (быстрый AI запрос)
|
||||
- [ ] Воркфлоу: OCR документа → заполнение полей визарда
|
||||
- [ ] Воркфлоу: формирование заявления после всех документов
|
||||
- [ ] Webhook: `/webhook/document-upload`
|
||||
|
||||
### Тестирование
|
||||
- [ ] Полный цикл с реальными данными
|
||||
- [ ] Обработка ошибок
|
||||
- [ ] Legacy черновики
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Технические детали
|
||||
|
||||
### Новые SSE события
|
||||
```javascript
|
||||
// Список документов готов
|
||||
{ event_type: "documents_list_ready", documents_required: [...] }
|
||||
|
||||
// Документ загружен (начало OCR)
|
||||
{ event_type: "document_uploaded", document_type: "contract", status: "processing" }
|
||||
|
||||
// OCR завершён
|
||||
{ event_type: "document_ocr_completed", document_type: "contract", ocr_data: {...} }
|
||||
|
||||
// Заявление готово
|
||||
{ event_type: "claim_ready", claim_data: {...} }
|
||||
```
|
||||
|
||||
### Статусы черновиков
|
||||
| Статус | Описание |
|
||||
|--------|----------|
|
||||
| `draft_new` | Только описание проблемы |
|
||||
| `draft_docs_progress` | Часть документов загружена |
|
||||
| `draft_docs_complete` | Все документы, ждём заявление |
|
||||
| `draft_claim_ready` | Заявление готово |
|
||||
| `awaiting_sms` | Ждёт SMS подтверждения |
|
||||
|
||||
### Legacy черновики
|
||||
- Определяются по отсутствию `documents_required` в payload
|
||||
- Показываются с пометкой "устаревший формат"
|
||||
- Кнопка "Начать заново" копирует description в новый черновик
|
||||
|
||||
---
|
||||
|
||||
## 📌 Примечания
|
||||
|
||||
1. **Ветка backup:** `backup-wizard-ui-2025-11-26` содержит состояние до изменений
|
||||
2. **n8n:** Webhook `/webhook/document-upload` нужно создать
|
||||
3. **Redis каналы:**
|
||||
- `ocr_events:{session_id}` — события для конкретного пользователя
|
||||
- `ticket_form:documents_list` — запрос на генерацию списка документов
|
||||
|
||||
|
||||
55
SESSION_LOG_2025-11-26_WIZARD_UI.md
Normal file
55
SESSION_LOG_2025-11-26_WIZARD_UI.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Сессия 26 ноября 2025 - Исправления UI Wizard
|
||||
|
||||
## Основные изменения
|
||||
|
||||
### 1. Исправлена ошибка Authentication failed в upload_documents_to_crm.php
|
||||
- **Проблема:** Race condition при параллельных запросах к webservice CRM
|
||||
- **Решение:** Добавлена функция `getWebserviceSession()` с retry механизмом (до 3 попыток) и случайной задержкой между попытками
|
||||
|
||||
### 2. Исправлен Wizard Plan - чекбоксы заменены на блоки загрузки
|
||||
- **Проблема:** Вопрос `docs_exist` показывал чекбоксы вместо полей загрузки файлов
|
||||
- **Решение:**
|
||||
- Скрыт вопрос `docs_exist` когда есть документы в плане
|
||||
- Добавлены блоки загрузки файлов под карточкой "Документы, которые понадобятся"
|
||||
|
||||
### 3. Чекбокс "У меня нет документа" перенесён под загрузку
|
||||
- **Было:** Чекбокс показывался отдельно сверху
|
||||
- **Стало:** Чекбокс внутри карточки, под Dragger (только для обязательных документов)
|
||||
|
||||
### 4. Блоки загрузки сразу развёрнуты
|
||||
- Добавлен useEffect с ref для автоматического создания блоков при загрузке плана
|
||||
- Используется `createdDocBlocksRef` чтобы избежать дублирования
|
||||
|
||||
### 5. Убраны лишние поля для предустановленных документов
|
||||
- Для документов из плана (contract, payment, correspondence и т.д.):
|
||||
- Нет поля "Уточните тип" (тип уже известен)
|
||||
- Нет кнопки "Удалить" для первого блока
|
||||
- Для дополнительных блоков - поля отображаются
|
||||
|
||||
### 6. Исправлено дублирование блоков
|
||||
- Убран дублирующий useEffect (для documentGroups)
|
||||
- Добавлен ref `createdDocBlocksRef` для отслеживания созданных блоков
|
||||
- Исправлена опечатка `React.useRef` → `useRef`
|
||||
|
||||
## Файлы изменены
|
||||
|
||||
1. `upload_documents_to_crm.php` - retry механизм для аутентификации
|
||||
2. `ticket_form/frontend/src/components/form/StepWizardPlan.tsx`:
|
||||
- Скрытие вопроса docs_exist
|
||||
- Блоки загрузки под информационной карточкой
|
||||
- Чекбокс под Dragger
|
||||
- Автосоздание блоков при загрузке
|
||||
- Улучшенная логика isPredefinedDoc
|
||||
|
||||
## Коммиты
|
||||
|
||||
1. `Добавлен retry механизм для webservice аутентификации (race condition fix)`
|
||||
2. `Заменены чекбоксы docs_exist на блоки загрузки файлов`
|
||||
3. `Исправлен JSX Fragment для блоков загрузки документов`
|
||||
4. `Чекбокс 'нет документа' перенесён под блок загрузки`
|
||||
5. `Блоки загрузки документов сразу развёрнуты при загрузке плана`
|
||||
6. `Убраны лишние поля для предустановленных документов`
|
||||
7. `Убран дублирующий useEffect для создания блоков документов`
|
||||
8. `Исправлено дублирование блоков документов (ref для отслеживания созданных)`
|
||||
9. `Исправлен React.useRef → useRef`
|
||||
|
||||
198
SESSION_LOG_2025-12-03.md
Normal file
198
SESSION_LOG_2025-12-03.md
Normal 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
105
SESSION_LOG_2025-12-29.md
Normal 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 не поднимается после нескольких попыток
|
||||
- [ ] Логирование буферизованных сообщений для мониторинга
|
||||
|
||||
@@ -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(
|
||||
|
||||
809
backend/app/api/documents.py
Normal file
809
backend/app/api/documents.py
Normal 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"])
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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 канала (опционально)")
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
118
backend/app/services/crm_mysql_service.py
Normal file
118
backend/app/services/crm_mysql_service.py
Normal 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()
|
||||
|
||||
|
||||
179
backend/app/services/n8n_service.py
Normal file
179
backend/app/services/n8n_service.py
Normal 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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# Глобальный экземпляр
|
||||
|
||||
@@ -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:
|
||||
# Получаем актуальный токен
|
||||
|
||||
68
check_claim_documents_table.py
Normal file
68
check_claim_documents_table.py
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Проверка документов в таблице clpr_claim_documents
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
|
||||
POSTGRES_HOST = "147.45.189.234"
|
||||
POSTGRES_PORT = 5432
|
||||
POSTGRES_DB = "default_db"
|
||||
POSTGRES_USER = "gen_user"
|
||||
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
|
||||
|
||||
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
|
||||
|
||||
async def check_documents_table():
|
||||
conn = await asyncpg.connect(
|
||||
host=POSTGRES_HOST,
|
||||
port=POSTGRES_PORT,
|
||||
database=POSTGRES_DB,
|
||||
user=POSTGRES_USER,
|
||||
password=POSTGRES_PASSWORD
|
||||
)
|
||||
|
||||
try:
|
||||
# Сначала находим UUID claim
|
||||
claim_row = await conn.fetchrow("""
|
||||
SELECT id FROM clpr_claims
|
||||
WHERE id::text = $1 OR payload->>'claim_id' = $1
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""", CLAIM_ID)
|
||||
|
||||
if not claim_row:
|
||||
print(f"❌ Черновик {CLAIM_ID} не найден!")
|
||||
return
|
||||
|
||||
claim_uuid = claim_row['id']
|
||||
|
||||
# Ищем документы по UUID (claim_id в таблице - text)
|
||||
rows = await conn.fetch("""
|
||||
SELECT
|
||||
ccd.id,
|
||||
ccd.claim_id,
|
||||
ccd.field_name,
|
||||
ccd.file_id,
|
||||
ccd.file_name,
|
||||
ccd.original_file_name,
|
||||
ccd.uploaded_at
|
||||
FROM clpr_claim_documents ccd
|
||||
WHERE ccd.claim_id = $1
|
||||
ORDER BY ccd.uploaded_at DESC
|
||||
""", str(claim_uuid))
|
||||
|
||||
print(f"📋 Найдено {len(rows)} документов в таблице clpr_claim_documents:")
|
||||
for i, row in enumerate(rows):
|
||||
print(f"\n {i+1}. field_name: {row['field_name']}")
|
||||
print(f" file_id: {row['file_id']}")
|
||||
print(f" file_name: {row['file_name']}")
|
||||
print(f" original_file_name: {row['original_file_name']}")
|
||||
print(f" uploaded_at: {row['uploaded_at']}")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(check_documents_table())
|
||||
|
||||
86
check_documents_detailed.py
Normal file
86
check_documents_detailed.py
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Детальная проверка документов в черновике
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import json
|
||||
|
||||
POSTGRES_HOST = "147.45.189.234"
|
||||
POSTGRES_PORT = 5432
|
||||
POSTGRES_DB = "default_db"
|
||||
POSTGRES_USER = "gen_user"
|
||||
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
|
||||
|
||||
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
|
||||
|
||||
async def check_documents_detailed():
|
||||
conn = await asyncpg.connect(
|
||||
host=POSTGRES_HOST,
|
||||
port=POSTGRES_PORT,
|
||||
database=POSTGRES_DB,
|
||||
user=POSTGRES_USER,
|
||||
password=POSTGRES_PASSWORD
|
||||
)
|
||||
|
||||
try:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT id, status_code, payload, updated_at
|
||||
FROM clpr_claims
|
||||
WHERE id::text = $1 OR payload->>'claim_id' = $1
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""", CLAIM_ID)
|
||||
|
||||
if not row:
|
||||
print(f"❌ Черновик {CLAIM_ID} не найден!")
|
||||
return
|
||||
|
||||
payload = row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload'])
|
||||
|
||||
print(f"📋 Статус: {row['status_code']}")
|
||||
print(f"📋 Обновлён: {row['updated_at']}")
|
||||
print(f"\n📋 documents_meta ({len(payload.get('documents_meta', []))} шт.):")
|
||||
for i, doc in enumerate(payload.get('documents_meta', [])):
|
||||
print(f" {i+1}. {doc.get('field_label', 'N/A')}")
|
||||
print(f" file_id: {doc.get('file_id', 'N/A')[:80]}...")
|
||||
print(f" field_name: {doc.get('field_name', 'N/A')}")
|
||||
|
||||
print(f"\n📋 documents_uploaded ({len(payload.get('documents_uploaded', []))} шт.):")
|
||||
for i, doc in enumerate(payload.get('documents_uploaded', [])):
|
||||
print(f" {i+1}. Тип: {doc.get('type', 'N/A')} / {doc.get('id', 'N/A')}")
|
||||
print(f" file_id: {doc.get('file_id', 'N/A')[:80]}...")
|
||||
print(f" original_file_name: {doc.get('original_file_name', 'N/A')}")
|
||||
|
||||
print(f"\n📋 documents_required ({len(payload.get('documents_required', []))} шт.):")
|
||||
for i, doc in enumerate(payload.get('documents_required', [])):
|
||||
print(f" {i+1}. {doc.get('name', 'N/A')} (id: {doc.get('id', 'N/A')})")
|
||||
|
||||
print(f"\n📋 current_doc_index: {payload.get('current_doc_index', 'N/A')}")
|
||||
|
||||
# Проверяем уникальность file_id
|
||||
print(f"\n🔍 Проверка уникальности file_id:")
|
||||
documents_meta = payload.get('documents_meta', [])
|
||||
file_ids_meta = [doc.get('file_id') for doc in documents_meta if doc.get('file_id')]
|
||||
unique_file_ids_meta = list(set(file_ids_meta))
|
||||
print(f" documents_meta: всего {len(file_ids_meta)}, уникальных {len(unique_file_ids_meta)}")
|
||||
if len(file_ids_meta) != len(unique_file_ids_meta):
|
||||
print(f" ⚠️ ЕСТЬ ДУБЛИКАТЫ!")
|
||||
from collections import Counter
|
||||
duplicates = [fid for fid, count in Counter(file_ids_meta).items() if count > 1]
|
||||
for dup in duplicates:
|
||||
print(f" - {dup[:80]}... (встречается {Counter(file_ids_meta)[dup]} раз)")
|
||||
|
||||
documents_uploaded = payload.get('documents_uploaded', [])
|
||||
file_ids_uploaded = [doc.get('file_id') for doc in documents_uploaded if doc.get('file_id')]
|
||||
unique_file_ids_uploaded = list(set(file_ids_uploaded))
|
||||
print(f" documents_uploaded: всего {len(file_ids_uploaded)}, уникальных {len(unique_file_ids_uploaded)}")
|
||||
if len(file_ids_uploaded) != len(unique_file_ids_uploaded):
|
||||
print(f" ⚠️ ЕСТЬ ДУБЛИКАТЫ!")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(check_documents_detailed())
|
||||
|
||||
118
check_documents_mismatch.py
Normal file
118
check_documents_mismatch.py
Normal file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Проверка несоответствия между documents_uploaded и clpr_claim_documents
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import json
|
||||
|
||||
POSTGRES_HOST = "147.45.189.234"
|
||||
POSTGRES_PORT = 5432
|
||||
POSTGRES_DB = "default_db"
|
||||
POSTGRES_USER = "gen_user"
|
||||
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
|
||||
|
||||
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
|
||||
|
||||
async def check_mismatch():
|
||||
conn = await asyncpg.connect(
|
||||
host=POSTGRES_HOST,
|
||||
port=POSTGRES_PORT,
|
||||
database=POSTGRES_DB,
|
||||
user=POSTGRES_USER,
|
||||
password=POSTGRES_PASSWORD
|
||||
)
|
||||
|
||||
try:
|
||||
# Находим UUID claim
|
||||
claim_row = await conn.fetchrow("""
|
||||
SELECT id FROM clpr_claims
|
||||
WHERE id::text = $1 OR payload->>'claim_id' = $1
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""", CLAIM_ID)
|
||||
|
||||
if not claim_row:
|
||||
print(f"❌ Черновик {CLAIM_ID} не найден!")
|
||||
return
|
||||
|
||||
claim_uuid = claim_row['id']
|
||||
|
||||
# Получаем payload
|
||||
payload_row = await conn.fetchrow("""
|
||||
SELECT payload FROM clpr_claims WHERE id = $1
|
||||
""", claim_uuid)
|
||||
|
||||
payload = payload_row['payload'] if isinstance(payload_row['payload'], dict) else json.loads(payload_row['payload'])
|
||||
|
||||
# Получаем документы из таблицы
|
||||
table_docs = await conn.fetch("""
|
||||
SELECT
|
||||
ccd.id,
|
||||
ccd.claim_id,
|
||||
ccd.field_name,
|
||||
ccd.file_id,
|
||||
ccd.file_name,
|
||||
ccd.original_file_name,
|
||||
ccd.uploaded_at
|
||||
FROM clpr_claim_documents ccd
|
||||
WHERE ccd.claim_id = $1
|
||||
ORDER BY ccd.uploaded_at DESC
|
||||
""", str(claim_uuid))
|
||||
|
||||
print(f"📋 Документы в таблице clpr_claim_documents ({len(table_docs)} шт.):")
|
||||
for i, doc in enumerate(table_docs):
|
||||
print(f" {i+1}. field_name: {doc['field_name']}")
|
||||
print(f" file_id: {doc['file_id']}")
|
||||
print(f" file_name: {doc['file_name']}")
|
||||
print(f" original_file_name: {doc['original_file_name']}")
|
||||
print(f" uploaded_at: {doc['uploaded_at']}")
|
||||
|
||||
print(f"\n📋 Документы в documents_uploaded ({len(payload.get('documents_uploaded', []))} шт.):")
|
||||
for i, doc in enumerate(payload.get('documents_uploaded', [])):
|
||||
print(f" {i+1}. Тип: {doc.get('type', 'N/A')} / {doc.get('id', 'N/A')}")
|
||||
print(f" file_id: {doc.get('file_id', 'N/A')}")
|
||||
print(f" original_file_name: {doc.get('original_file_name', 'N/A')}")
|
||||
|
||||
print(f"\n📋 Документы в documents_meta ({len(payload.get('documents_meta', []))} шт.):")
|
||||
for i, doc in enumerate(payload.get('documents_meta', [])):
|
||||
print(f" {i+1}. field_label: {doc.get('field_label', 'N/A')}")
|
||||
print(f" field_name: {doc.get('field_name', 'N/A')}")
|
||||
print(f" file_id: {doc.get('file_id', 'N/A')}")
|
||||
|
||||
# Проверяем, какие документы из documents_uploaded отсутствуют в таблице
|
||||
print(f"\n🔍 Проверка отсутствующих документов:")
|
||||
table_file_ids = {doc['file_id'] for doc in table_docs}
|
||||
uploaded_file_ids = {doc.get('file_id') for doc in payload.get('documents_uploaded', []) if doc.get('file_id')}
|
||||
|
||||
missing_in_table = uploaded_file_ids - table_file_ids
|
||||
if missing_in_table:
|
||||
print(f" ⚠️ В documents_uploaded есть, но нет в таблице ({len(missing_in_table)} шт.):")
|
||||
for file_id in missing_in_table:
|
||||
doc = next((d for d in payload.get('documents_uploaded', []) if d.get('file_id') == file_id), None)
|
||||
if doc:
|
||||
print(f" - {doc.get('type', 'N/A')}: {file_id[:80]}...")
|
||||
print(f" original_file_name: {doc.get('original_file_name', 'N/A')}")
|
||||
else:
|
||||
print(f" ✅ Все документы из documents_uploaded есть в таблице")
|
||||
|
||||
# Проверяем field_name
|
||||
print(f"\n🔍 Проверка field_name:")
|
||||
table_field_names = {doc['field_name'] for doc in table_docs}
|
||||
meta_field_names = {doc.get('field_name') for doc in payload.get('documents_meta', []) if doc.get('field_name')}
|
||||
|
||||
print(f" В таблице: {sorted(table_field_names)}")
|
||||
print(f" В documents_meta: {sorted(meta_field_names)}")
|
||||
|
||||
# Проверяем, есть ли конфликты по field_name
|
||||
if len(table_docs) < len(payload.get('documents_uploaded', [])):
|
||||
print(f"\n ⚠️ Возможная причина: несколько документов с одинаковым field_name")
|
||||
print(f" В таблице используется UNIQUE constraint на (claim_id, field_name)")
|
||||
print(f" Если два документа имеют одинаковый field_name, второй перезапишет первый")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(check_mismatch())
|
||||
|
||||
62
check_draft_documents.py
Normal file
62
check_draft_documents.py
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Проверка документов в черновике
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import json
|
||||
|
||||
POSTGRES_HOST = "147.45.189.234"
|
||||
POSTGRES_PORT = 5432
|
||||
POSTGRES_DB = "default_db"
|
||||
POSTGRES_USER = "gen_user"
|
||||
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
|
||||
|
||||
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
|
||||
|
||||
async def check_documents():
|
||||
conn = await asyncpg.connect(
|
||||
host=POSTGRES_HOST,
|
||||
port=POSTGRES_PORT,
|
||||
database=POSTGRES_DB,
|
||||
user=POSTGRES_USER,
|
||||
password=POSTGRES_PASSWORD
|
||||
)
|
||||
|
||||
try:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT id, status_code, payload
|
||||
FROM clpr_claims
|
||||
WHERE id::text = $1 OR payload->>'claim_id' = $1
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""", CLAIM_ID)
|
||||
|
||||
if not row:
|
||||
print(f"❌ Черновик {CLAIM_ID} не найден!")
|
||||
return
|
||||
|
||||
payload = row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload'])
|
||||
|
||||
print("📋 documents_meta:")
|
||||
for i, doc in enumerate(payload.get('documents_meta', [])):
|
||||
print(f" {i+1}. {doc.get('field_label', 'N/A')} - {doc.get('file_id', 'N/A')}")
|
||||
|
||||
print("\n📋 documents_uploaded:")
|
||||
for i, doc in enumerate(payload.get('documents_uploaded', [])):
|
||||
print(f" {i+1}. {doc.get('type', 'N/A')} / {doc.get('id', 'N/A')} - {doc.get('file_id', 'N/A')}")
|
||||
|
||||
print("\n📋 Все file_id в payload:")
|
||||
# Ищем все file_id в payload
|
||||
payload_str = json.dumps(payload, ensure_ascii=False)
|
||||
import re
|
||||
file_ids = re.findall(r'file_id["\']?\s*:\s*["\']([^"\']+)', payload_str)
|
||||
for file_id in set(file_ids):
|
||||
print(f" - {file_id}")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(check_documents())
|
||||
|
||||
39
docker-compose.dev.yml
Normal file
39
docker-compose.dev.yml
Normal 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
97
docs/BACKEND_GET_CONTACT_CF_2624_FROM_POSTGRESQL.md
Normal file
97
docs/BACKEND_GET_CONTACT_CF_2624_FROM_POSTGRESQL.md
Normal 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: тот же
|
||||
|
||||
136
docs/CF_2624_IMPLEMENTATION_SUMMARY.md
Normal file
136
docs/CF_2624_IMPLEMENTATION_SUMMARY.md
Normal 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 (обновлён)
|
||||
|
||||
|
||||
114
docs/CF_2624_IN_OCR_STATUS_EVENT.md
Normal file
114
docs/CF_2624_IN_OCR_STATUS_EVENT.md
Normal 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`
|
||||
- Использоваться для блокировки полей на фронтенде
|
||||
|
||||
|
||||
94
docs/CLAIM_226564ce_STATUS.md
Normal file
94
docs/CLAIM_226564ce_STATUS.md
Normal 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 фильтрации.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
56
docs/CREATE_WEB_CONTACT_RESPONSE_FORMAT.md
Normal file
56
docs/CREATE_WEB_CONTACT_RESPONSE_FORMAT.md
Normal 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"`
|
||||
|
||||
149
docs/CRM_CONTACT_DATA_CONFIRMED_FIELD.md
Normal file
149
docs/CRM_CONTACT_DATA_CONFIRMED_FIELD.md
Normal 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. ⏳ Протестировать:
|
||||
- Создать контакт → поле должно быть "Нет"
|
||||
- Подтвердить форму → поле должно стать "Да"
|
||||
- Загрузить черновик → поля должны быть заблокированы
|
||||
|
||||
217
docs/FRONTEND_UPDATE_CONTACT_DATA_CONFIRMED.md
Normal file
217
docs/FRONTEND_UPDATE_CONTACT_DATA_CONFIRMED.md
Normal 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 загружаются и отображаются
|
||||
- ✅ Кнопка "Изменить данные" работает (если реализована)
|
||||
|
||||
210
docs/N8N_ADD_CF_2624_TO_OCR_STATUS_EVENT.md
Normal file
210
docs/N8N_ADD_CF_2624_TO_OCR_STATUS_EVENT.md
Normal 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`
|
||||
|
||||
|
||||
44
docs/N8N_CODE_CHECK_CONTACT_DATA_CONFIRMED.js
Normal file
44
docs/N8N_CODE_CHECK_CONTACT_DATA_CONFIRMED.js
Normal 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
|
||||
};
|
||||
|
||||
264
docs/N8N_CODE_IN_JAVASCRIPT_КЛИЕНТПРАВ_FULL.js
Normal file
264
docs/N8N_CODE_IN_JAVASCRIPT_КЛИЕНТПРАВ_FULL.js
Normal 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];
|
||||
|
||||
113
docs/N8N_CODE_PREPARE_DOCUMENT_SKIP_SQL.js
Normal file
113
docs/N8N_CODE_PREPARE_DOCUMENT_SKIP_SQL.js
Normal 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
|
||||
}
|
||||
}];
|
||||
|
||||
160
docs/N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js
Normal file
160
docs/N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js
Normal 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
|
||||
}
|
||||
}];
|
||||
115
docs/N8N_CODE_PUSH_DOCUMENTS_LIST.js
Normal file
115
docs/N8N_CODE_PUSH_DOCUMENTS_LIST.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: Пуш списка документов в Redis
|
||||
// ============================================================================
|
||||
// Расположение в workflow:
|
||||
// Redis Trigger (ticket_form:description)
|
||||
// → AI Agent (анализ проблемы)
|
||||
// → PostgreSQL (SQL_SAVE_DRAFT_NEW_FLOW.sql)
|
||||
// → [ЭТОТ CODE NODE]
|
||||
// → Redis Publish
|
||||
// ============================================================================
|
||||
|
||||
// Получаем результат из PostgreSQL
|
||||
const sqlResult = $input.first().json;
|
||||
|
||||
// claim содержит результат SQL запроса
|
||||
const claim = sqlResult.claim || sqlResult;
|
||||
|
||||
// Валидация
|
||||
if (!claim.session_token) {
|
||||
throw new Error('Нет session_token в результате SQL');
|
||||
}
|
||||
|
||||
if (!claim.documents_required || claim.documents_required.length === 0) {
|
||||
console.log('⚠️ Список документов пуст, но продолжаем');
|
||||
}
|
||||
|
||||
// Формируем событие для Redis
|
||||
const event = {
|
||||
event_type: 'documents_list_ready',
|
||||
status: 'ready',
|
||||
|
||||
// Идентификаторы
|
||||
claim_id: claim.claim_id,
|
||||
session_id: claim.session_token,
|
||||
|
||||
// ✅ Список документов для фронтенда
|
||||
documents_required: claim.documents_required || [],
|
||||
documents_count: claim.documents_count || 0,
|
||||
|
||||
// Метаданные
|
||||
timestamp: new Date().toISOString(),
|
||||
message: 'Список необходимых документов готов'
|
||||
};
|
||||
|
||||
// Логируем для отладки
|
||||
console.log('📤 Публикуем событие documents_list_ready:', {
|
||||
channel: `ocr_events:${claim.session_token}`,
|
||||
documents_count: event.documents_count,
|
||||
claim_id: event.claim_id
|
||||
});
|
||||
|
||||
// Возвращаем для Redis Publish node
|
||||
return {
|
||||
json: {
|
||||
// Канал Redis (ocr_events:{session_id})
|
||||
channel: `ocr_events:${claim.session_token}`,
|
||||
|
||||
// Данные события (будут JSON.stringify в Redis node)
|
||||
message: JSON.stringify(event),
|
||||
|
||||
// Дополнительно передаём для следующих нод
|
||||
claim_id: claim.claim_id,
|
||||
session_token: claim.session_token,
|
||||
documents_required: claim.documents_required
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Пример структуры documents_required:
|
||||
// ============================================================================
|
||||
// [
|
||||
// {
|
||||
// "id": "contract",
|
||||
// "name": "Договор или заказ",
|
||||
// "required": false,
|
||||
// "priority": 1,
|
||||
// "accept": ["pdf", "jpg", "png"],
|
||||
// "hints": "Поскольку договор не выслан, можно приложить публичную оферту"
|
||||
// },
|
||||
// {
|
||||
// "id": "payment",
|
||||
// "name": "Чек или подтверждение оплаты",
|
||||
// "required": false,
|
||||
// "priority": 1,
|
||||
// "accept": ["pdf", "jpg", "png"],
|
||||
// "hints": "Копия квитанции, чека или банковской выписки"
|
||||
// },
|
||||
// {
|
||||
// "id": "correspondence",
|
||||
// "name": "Переписка",
|
||||
// "required": true, // ⚠️ КРИТИЧНЫЙ документ
|
||||
// "priority": 2,
|
||||
// "accept": ["pdf", "jpg", "png"],
|
||||
// "hints": "Скриншоты переписки с организацией, претензии"
|
||||
// }
|
||||
// ]
|
||||
// ============================================================================
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Настройка Redis Publish node (следующая нода):
|
||||
// ============================================================================
|
||||
//
|
||||
// Operation: Publish
|
||||
// Channel: {{ $json.channel }}
|
||||
// Message: {{ $json.message }}
|
||||
//
|
||||
// Или через Execute Command:
|
||||
// Command: PUBLISH
|
||||
// Arguments:
|
||||
// - {{ $json.channel }}
|
||||
// - {{ $json.message }}
|
||||
// ============================================================================
|
||||
|
||||
51
docs/N8N_CODE_SET_CONTACT_DATA_CONFIRMED.js
Normal file
51
docs/N8N_CODE_SET_CONTACT_DATA_CONFIRMED.js
Normal 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 }}
|
||||
|
||||
150
docs/N8N_DESCRIPTION_WORKFLOW.md
Normal file
150
docs/N8N_DESCRIPTION_WORKFLOW.md
Normal 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})
|
||||
```
|
||||
|
||||
120
docs/N8N_FORM_APPROVAL_WORKFLOW.md
Normal file
120
docs/N8N_FORM_APPROVAL_WORKFLOW.md
Normal 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
225
docs/N8N_MEMORY_ISSUES.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# 🐛 Проблемы с памятью в n8n
|
||||
|
||||
## 🔍 Симптомы
|
||||
|
||||
- UI n8n не отвечает (нельзя сохранить workflow, включить/выключить)
|
||||
- Workflow не обрабатывает события
|
||||
- Страница зависает при попытке редактирования
|
||||
- Требуется перезагрузка сервера для восстановления
|
||||
|
||||
## 💾 Возможные причины
|
||||
|
||||
### 1. **Переполнение памяти (OOM)**
|
||||
- n8n процесс исчерпал доступную память
|
||||
- Система убивает процесс (OOM Killer)
|
||||
- Или процесс зависает в ожидании освобождения памяти
|
||||
|
||||
**Диагностика:**
|
||||
```bash
|
||||
# Проверка использования памяти n8n
|
||||
docker stats n8n_container --no-stream
|
||||
|
||||
# Проверка логов OOM Killer
|
||||
dmesg | grep -i "out of memory"
|
||||
dmesg | grep -i "killed process"
|
||||
|
||||
# Проверка использования памяти системой
|
||||
free -h
|
||||
```
|
||||
|
||||
### 2. **Утечки памяти в workflow**
|
||||
- Workflow накапливает данные в памяти
|
||||
- Большие массивы данных не освобождаются
|
||||
- Долгие операции держат данные в памяти
|
||||
|
||||
**Диагностика:**
|
||||
- Проверить Execution History - сколько данных хранится
|
||||
- Проверить размер данных в workflow (большие JSON объекты)
|
||||
- Проверить количество активных executions
|
||||
|
||||
### 3. **Слишком много активных workflows**
|
||||
- Много workflows работают одновременно
|
||||
- Каждый workflow держит соединения и данные в памяти
|
||||
- Redis Trigger для каждого workflow = отдельное соединение
|
||||
|
||||
**Диагностика:**
|
||||
```bash
|
||||
# Количество активных workflows (через n8n API или БД)
|
||||
# Проверить количество Redis подписок
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" CLIENT LIST | grep -c "SUBSCRIBE"
|
||||
```
|
||||
|
||||
### 4. **Большие данные в workflow**
|
||||
- Workflow обрабатывает большие файлы/JSON
|
||||
- Данные хранятся в памяти между нодами
|
||||
- Нет очистки промежуточных данных
|
||||
|
||||
**Диагностика:**
|
||||
- Проверить размер данных в Execution History
|
||||
- Проверить размер JSON payload между нодами
|
||||
- Проверить использование диска для execution data
|
||||
|
||||
### 5. **Проблемы с базой данных n8n**
|
||||
- База данных n8n переполнена старыми executions
|
||||
- Медленные запросы блокируют работу
|
||||
- Блокировки таблиц
|
||||
|
||||
**Диагностика:**
|
||||
```bash
|
||||
# Размер базы данных n8n
|
||||
# Проверить количество executions
|
||||
# Проверить медленные запросы
|
||||
```
|
||||
|
||||
## 🛠️ Решения
|
||||
|
||||
### 1. **Ограничить использование памяти**
|
||||
|
||||
В `docker-compose.yml` для n8n:
|
||||
```yaml
|
||||
services:
|
||||
n8n:
|
||||
mem_limit: 2g # Ограничить память до 2GB
|
||||
mem_reservation: 1g # Резервировать минимум 1GB
|
||||
oom_kill_disable: false # Разрешить OOM Killer убивать процесс
|
||||
```
|
||||
|
||||
Или через переменные окружения:
|
||||
```bash
|
||||
NODE_OPTIONS="--max-old-space-size=1536" # Ограничить heap до 1.5GB
|
||||
```
|
||||
|
||||
### 2. **Очистить старые executions**
|
||||
|
||||
Настроить автоматическую очистку в n8n:
|
||||
- Settings → Workflows → Execution Data Retention
|
||||
- Установить срок хранения (например, 7 дней)
|
||||
- Включить автоматическую очистку
|
||||
|
||||
Или через SQL (если используете PostgreSQL):
|
||||
```sql
|
||||
-- Удалить executions старше 7 дней
|
||||
DELETE FROM execution_entity
|
||||
WHERE "stoppedAt" < NOW() - INTERVAL '7 days';
|
||||
|
||||
-- Удалить execution_data для удалённых executions
|
||||
DELETE FROM execution_data
|
||||
WHERE "executionId" NOT IN (SELECT id FROM execution_entity);
|
||||
```
|
||||
|
||||
### 3. **Оптимизировать workflow**
|
||||
|
||||
- **Не хранить большие данные между нодами**
|
||||
- Использовать `Set` node для очистки ненужных полей
|
||||
- Не передавать большие файлы через workflow data
|
||||
|
||||
- **Использовать streaming для больших данных**
|
||||
- Обрабатывать данные порциями
|
||||
- Не загружать всё в память сразу
|
||||
|
||||
- **Ограничить размер данных в Redis Trigger**
|
||||
- Проверять размер сообщения перед обработкой
|
||||
- Отклонять слишком большие сообщения
|
||||
|
||||
### 4. **Мониторинг памяти**
|
||||
|
||||
Создать скрипт для мониторинга:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# monitor_n8n_memory.sh
|
||||
|
||||
CONTAINER="n8n_container"
|
||||
THRESHOLD=80 # Процент использования памяти
|
||||
|
||||
MEMORY_USAGE=$(docker stats $CONTAINER --no-stream --format "{{.MemPerc}}" | sed 's/%//')
|
||||
|
||||
if (( $(echo "$MEMORY_USAGE > $THRESHOLD" | bc -l) )); then
|
||||
echo "⚠️ ВНИМАНИЕ: n8n использует ${MEMORY_USAGE}% памяти!"
|
||||
# Можно добавить отправку алерта
|
||||
fi
|
||||
```
|
||||
|
||||
### 5. **Настроить swap**
|
||||
|
||||
Если сервер имеет swap, убедиться что он настроен:
|
||||
```bash
|
||||
# Проверить swap
|
||||
swapon --show
|
||||
|
||||
# Если нет swap, создать (осторожно - может замедлить работу)
|
||||
sudo fallocate -l 2G /swapfile
|
||||
sudo chmod 600 /swapfile
|
||||
sudo mkswap /swapfile
|
||||
sudo swapon /swapfile
|
||||
```
|
||||
|
||||
### 6. **Ограничить количество активных workflows**
|
||||
|
||||
- Отключить неиспользуемые workflows
|
||||
- Использовать один workflow вместо нескольких для похожих задач
|
||||
- Разделить сложные workflows на несколько простых
|
||||
|
||||
### 7. **Оптимизировать Redis Trigger**
|
||||
|
||||
- Использовать один Redis Trigger для нескольких каналов (если возможно)
|
||||
- Ограничить количество одновременных подписок
|
||||
- Использовать Redis Streams вместо Pub/Sub для больших объёмов данных
|
||||
|
||||
## 📊 Диагностика после перезагрузки
|
||||
|
||||
После перезагрузки сервера проверить:
|
||||
|
||||
```bash
|
||||
# 1. Использование памяти n8n
|
||||
docker stats n8n_container --no-stream
|
||||
|
||||
# 2. Логи n8n на ошибки памяти
|
||||
docker logs n8n_container 2>&1 | grep -i "memory\|oom\|heap"
|
||||
|
||||
# 3. Системные логи OOM Killer
|
||||
dmesg | grep -i "out of memory" | tail -20
|
||||
|
||||
# 4. Использование памяти системой
|
||||
free -h
|
||||
|
||||
# 5. Топ процессов по использованию памяти
|
||||
ps aux --sort=-%mem | head -10
|
||||
```
|
||||
|
||||
## 🔄 Профилактика
|
||||
|
||||
1. **Регулярная очистка executions**
|
||||
- Настроить автоматическую очистку старых данных
|
||||
- Ограничить срок хранения execution data
|
||||
|
||||
2. **Мониторинг ресурсов**
|
||||
- Настроить алерты при высоком использовании памяти
|
||||
- Регулярно проверять использование ресурсов
|
||||
|
||||
3. **Оптимизация workflows**
|
||||
- Избегать хранения больших данных в памяти
|
||||
- Использовать streaming для больших файлов
|
||||
- Очищать промежуточные данные
|
||||
|
||||
4. **Ограничения ресурсов**
|
||||
- Установить лимиты памяти для n8n контейнера
|
||||
- Настроить OOM Killer для корректной обработки
|
||||
|
||||
5. **Резервирование**
|
||||
- Рассмотреть использование нескольких инстансов n8n
|
||||
- Использовать load balancer для распределения нагрузки
|
||||
|
||||
## 📝 Рекомендации для продакшена
|
||||
|
||||
1. **Мониторинг**: Настроить Prometheus/Grafana для мониторинга памяти
|
||||
2. **Алерты**: Настроить уведомления при превышении порога памяти
|
||||
3. **Автоматическая очистка**: Настроить cron для очистки старых executions
|
||||
4. **Лимиты**: Установить жёсткие лимиты памяти для n8n
|
||||
5. **Логирование**: Включить детальное логирование использования памяти
|
||||
|
||||
## 🔗 Полезные ссылки
|
||||
|
||||
- [n8n Memory Management](https://docs.n8n.io/hosting/configuration/environment-variables/#memory-management)
|
||||
- [Docker Memory Limits](https://docs.docker.com/config/containers/resource_constraints/#memory)
|
||||
- [Node.js Memory Management](https://nodejs.org/api/cli.html#--max-old-space-sizesize-in-megabytes)
|
||||
|
||||
74
docs/N8N_MYSQL_GET_CONTACT_DATA.md
Normal file
74
docs/N8N_MYSQL_GET_CONTACT_DATA.md
Normal 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.
|
||||
|
||||
|
||||
35
docs/N8N_MYSQL_GET_CONTACT_DATA.sql
Normal file
35
docs/N8N_MYSQL_GET_CONTACT_DATA.sql
Normal 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;
|
||||
|
||||
|
||||
167
docs/N8N_REDIS_TRIGGER_TROUBLESHOOTING.md
Normal file
167
docs/N8N_REDIS_TRIGGER_TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# 🔧 Troubleshooting: Redis Trigger в n8n зависает
|
||||
|
||||
## 🐛 Проблема
|
||||
|
||||
Redis Trigger в n8n перестаёт слушать канал `ticket_form:description`, хотя workflow активен.
|
||||
|
||||
## 🔍 Возможные причины
|
||||
|
||||
### 1. **Потеря соединения с Redis**
|
||||
- Соединение оборвалось из-за сетевых проблем
|
||||
- Redis перезапустился, но n8n не переподключился
|
||||
- Таймаут соединения
|
||||
|
||||
**Решение:**
|
||||
- Проверить логи n8n на ошибки подключения
|
||||
- Убедиться, что Redis доступен: `redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PING`
|
||||
- Перезапустить workflow в n8n (отключить → включить)
|
||||
|
||||
### 2. **Проблемы с памятью/ресурсами**
|
||||
- n8n исчерпал память
|
||||
- Слишком много активных workflows
|
||||
|
||||
**Решение:**
|
||||
- Проверить использование памяти: `docker stats n8n_container`
|
||||
- Увеличить лимиты памяти для n8n
|
||||
- Перезапустить n8n контейнер
|
||||
|
||||
### 3. **Долгие операции в workflow**
|
||||
- Workflow обрабатывает сообщение слишком долго
|
||||
- Блокирует обработку новых сообщений
|
||||
|
||||
**Решение:**
|
||||
- Оптимизировать workflow (убрать долгие операции)
|
||||
- Использовать асинхронную обработку
|
||||
- Разбить workflow на несколько этапов
|
||||
|
||||
### 4. **Проблемы с сетью**
|
||||
- Временные сбои сети между n8n и Redis
|
||||
- Firewall блокирует соединение
|
||||
|
||||
**Решение:**
|
||||
- Проверить сетевую связность: `ping crm.clientright.ru`
|
||||
- Проверить firewall правила
|
||||
- Использовать retry-логику в workflow
|
||||
|
||||
## 🛠️ Решения для предотвращения
|
||||
|
||||
### 1. **Мониторинг подписчиков**
|
||||
|
||||
Запустить скрипт мониторинга:
|
||||
```bash
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
python3 monitor_n8n_redis_trigger.py
|
||||
```
|
||||
|
||||
Или добавить в cron для автоматической проверки:
|
||||
```bash
|
||||
# Проверка каждые 5 минут
|
||||
*/5 * * * * cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form && python3 monitor_n8n_redis_trigger.py >> logs/n8n_monitor_cron.log 2>&1
|
||||
```
|
||||
|
||||
### 2. **Health Check для Redis Trigger**
|
||||
|
||||
Добавить в workflow n8n:
|
||||
- **Schedule Trigger** (каждые 5 минут)
|
||||
- **Redis Publish** (отправить тестовое сообщение)
|
||||
- **If Node** (проверить, обработалось ли сообщение)
|
||||
- **Send Alert** (если нет - отправить уведомление)
|
||||
|
||||
### 3. **Автоматический перезапуск workflow**
|
||||
|
||||
Создать скрипт для автоматического перезапуска:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Проверка и перезапуск workflow если нет подписчиков
|
||||
|
||||
SUBS=$(redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PUBSUB NUMSUB "ticket_form:description" | tail -1)
|
||||
|
||||
if [ "$SUBS" -eq "0" ]; then
|
||||
echo "⚠️ Нет подписчиков! Требуется перезапуск workflow"
|
||||
# Здесь можно добавить API вызов для перезапуска workflow через n8n API
|
||||
fi
|
||||
```
|
||||
|
||||
### 4. **Настройка Redis для стабильности**
|
||||
|
||||
В `redis.conf`:
|
||||
```conf
|
||||
# Таймаут для неактивных соединений (0 = отключить)
|
||||
timeout 0
|
||||
|
||||
# Keepalive для TCP соединений
|
||||
tcp-keepalive 60
|
||||
|
||||
# Максимальное количество клиентов
|
||||
maxclients 10000
|
||||
```
|
||||
|
||||
### 5. **Логирование в n8n**
|
||||
|
||||
Включить детальное логирование для Redis Trigger:
|
||||
- Settings → Logging → Level: `debug`
|
||||
- Проверить логи на ошибки подключения
|
||||
|
||||
## 📊 Диагностика
|
||||
|
||||
### Проверка подписчиков
|
||||
```bash
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PUBSUB NUMSUB "ticket_form:description"
|
||||
```
|
||||
|
||||
### Проверка подключения n8n к Redis
|
||||
```bash
|
||||
# Из контейнера n8n
|
||||
docker exec -it n8n_container redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PING
|
||||
```
|
||||
|
||||
### Тестовая публикация
|
||||
```bash
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" \
|
||||
PUBLISH "ticket_form:description" '{"type":"test","session_id":"test123"}'
|
||||
```
|
||||
|
||||
### Проверка логов n8n
|
||||
```bash
|
||||
docker logs n8n_container | grep -i redis
|
||||
docker logs n8n_container | grep -i "ticket_form:description"
|
||||
```
|
||||
|
||||
## ✅ Быстрое решение
|
||||
|
||||
Если workflow завис:
|
||||
|
||||
1. **Отключить workflow** в n8n (кнопка "Active")
|
||||
2. **Сохранить** изменения
|
||||
3. **Включить обратно** (кнопка "Active")
|
||||
4. **Проверить подписчиков**: `PUBSUB NUMSUB "ticket_form:description"`
|
||||
|
||||
Если не помогло:
|
||||
|
||||
1. **Перезапустить n8n контейнер**:
|
||||
```bash
|
||||
docker restart n8n_container
|
||||
```
|
||||
|
||||
2. **Проверить Redis**:
|
||||
```bash
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PING
|
||||
```
|
||||
|
||||
3. **Проверить сеть** между n8n и Redis
|
||||
|
||||
## 🔄 Рекомендации для продакшена
|
||||
|
||||
1. **Мониторинг**: Настроить автоматический мониторинг подписчиков
|
||||
2. **Алерты**: Настроить уведомления при отсутствии подписчиков
|
||||
3. **Health Checks**: Регулярные проверки работоспособности
|
||||
4. **Логирование**: Детальное логирование всех операций с Redis
|
||||
5. **Резервирование**: Рассмотреть использование Redis Sentinel для высокой доступности
|
||||
|
||||
## 📝 Логи для анализа
|
||||
|
||||
Проверить логи:
|
||||
- `/var/www/fastuser/data/www/crm.clientright.ru/ticket_form/logs/n8n_redis_monitor.log` - мониторинг
|
||||
- `docker logs n8n_container` - логи n8n
|
||||
- `/var/www/fastuser/data/www/crm.clientright.ru/ticket_form/backend/logs/` - логи backend
|
||||
|
||||
62
docs/N8N_SET_CF_2624_CONTACT_CONFIRMED.md
Normal file
62
docs/N8N_SET_CF_2624_CONTACT_CONFIRMED.md
Normal 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. При следующей загрузке черновика поля должны быть заблокированы
|
||||
|
||||
112
docs/N8N_SQL_PARAMETERS_DOCUMENT_SKIP.md
Normal file
112
docs/N8N_SQL_PARAMETERS_DOCUMENT_SKIP.md
Normal 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 должен содержать данные во всех этих местах для надёжности.
|
||||
|
||||
147
docs/N8N_UPDATE_CF_2624_IN_RESPONSE.md
Normal file
147
docs/N8N_UPDATE_CF_2624_IN_RESPONSE.md
Normal 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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
135
docs/N8N_WORKFLOW_6mxRJ2LLHmQXyaDz_CHANGES.md
Normal file
135
docs/N8N_WORKFLOW_6mxRJ2LLHmQXyaDz_CHANGES.md
Normal 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)
|
||||
|
||||
100
docs/N8N_WORKFLOW_ADD_POSTGRESQL_CONTACT.md
Normal file
100
docs/N8N_WORKFLOW_ADD_POSTGRESQL_CONTACT.md
Normal 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 напрямую для скорости.
|
||||
|
||||
|
||||
87
docs/N8N_WORKFLOW_UPDATE_CONTACT_DATA_CONFIRMED.md
Normal file
87
docs/N8N_WORKFLOW_UPDATE_CONTACT_DATA_CONFIRMED.md
Normal 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`
|
||||
|
||||
767
docs/NEW_FLOW_ARCHITECTURE.md
Normal file
767
docs/NEW_FLOW_ARCHITECTURE.md
Normal file
@@ -0,0 +1,767 @@
|
||||
# 🚀 Новая архитектура: Быстрая загрузка документов
|
||||
|
||||
**Дата создания:** 2025-11-26
|
||||
**Статус:** В разработке
|
||||
|
||||
---
|
||||
|
||||
## 📋 Проблема
|
||||
|
||||
Текущий флоу слишком медленный:
|
||||
1. **2 минуты** — генерация визарда (RAG + AI анализ)
|
||||
2. **Длинная анкета** — слишком много вопросов для пользователя
|
||||
|
||||
---
|
||||
|
||||
## ✅ Новое решение
|
||||
|
||||
### Концепция
|
||||
1. После описания проблемы → сразу запрашиваем документы (без ожидания визарда)
|
||||
2. Пока пользователь загружает документы → в бэке генерируется визард + OCR
|
||||
3. После всех документов → показываем готовое заявление на апрув
|
||||
|
||||
### Преимущества
|
||||
- **Быстрый старт** — пользователь не ждёт 2 минуты
|
||||
- **Параллельная работа** — OCR и визард генерируются пока пользователь ищет документы
|
||||
- **Меньше вопросов** — большая часть данных извлекается из документов
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Новый флоу (шаги)
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ 1. Телефон │ (уже есть)
|
||||
│ SMS верификация
|
||||
└────────┬────────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 2. Черновики │ (уже есть, обновить UI)
|
||||
│ - Новые статусы│
|
||||
│ - Legacy→"Начать заново"
|
||||
└────────┬────────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 3. Описание │ (уже есть)
|
||||
│ Свободный текст│
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼ → n8n: быстрая генерация списка документов (5-10 сек)
|
||||
│ → n8n: параллельно запускает генерацию визарда (в фоне)
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 4. Документы │ 🆕 НОВЫЙ КОМПОНЕНТ
|
||||
│ - Поэкранная загрузка
|
||||
│ - Критичные помечены
|
||||
│ - Можно пропустить
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼ → n8n: OCR каждого документа → заполнение визарда (в фоне)
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 5. Ожидание │ 🆕 НОВЫЙ КОМПОНЕНТ
|
||||
│ "Формируем заявление..."
|
||||
│ Loader + прогресс
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼ ← n8n: claim_ready event (SSE)
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 6. Заявление │ (уже есть StepClaimConfirmation)
|
||||
│ Просмотр + редактирование
|
||||
└────────┬────────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 7. SMS апрув │ (уже есть)
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Статусы черновика (status_code)
|
||||
|
||||
| Статус | Описание | UI при открытии |
|
||||
|--------|----------|-----------------|
|
||||
| `draft_new` | Только описание | → Шаг документов |
|
||||
| `draft_docs_progress` | Часть документов загружена | → Продолжить с текущего документа |
|
||||
| `draft_docs_complete` | Все документы загружены | → Показать loader |
|
||||
| `draft_claim_ready` | Заявление готово | → Показать заявление |
|
||||
| `awaiting_sms` | Ждёт SMS | → Форма SMS |
|
||||
| `approved` | Отправлено | Не показываем |
|
||||
|
||||
### Legacy черновики (старый формат)
|
||||
- Нет `documents_required` → показываем с пометкой "устаревший"
|
||||
- Кнопка "Начать заново" → копирует description, создаёт новый черновик
|
||||
|
||||
---
|
||||
|
||||
## 📦 Структура payload черновика
|
||||
|
||||
```json
|
||||
{
|
||||
// === Идентификаторы ===
|
||||
"claim_id": "CLM-2025-11-26-X7Y8Z9",
|
||||
"session_token": "sess_abc123...",
|
||||
"unified_id": "user_456...",
|
||||
"phone": "+79991234567",
|
||||
"email": "user@example.com",
|
||||
|
||||
// === Описание проблемы ===
|
||||
"problem_description": "Купил курсы за 50000р, компания не отвечает...",
|
||||
|
||||
// === Документы (новое!) ===
|
||||
"documents_required": [
|
||||
{
|
||||
"type": "contract",
|
||||
"name": "Договор или оферта",
|
||||
"critical": true,
|
||||
"hints": "Скриншот или PDF договора/оферты"
|
||||
},
|
||||
{
|
||||
"type": "payment",
|
||||
"name": "Подтверждение оплаты",
|
||||
"critical": true,
|
||||
"hints": "Чек, выписка из банка, скриншот платежа"
|
||||
},
|
||||
{
|
||||
"type": "correspondence",
|
||||
"name": "Переписка с продавцом",
|
||||
"critical": false,
|
||||
"hints": "Скриншоты переписки, email, чаты"
|
||||
}
|
||||
],
|
||||
"documents_uploaded": [
|
||||
{
|
||||
"type": "contract",
|
||||
"file_id": "s3://...",
|
||||
"ocr_status": "completed",
|
||||
"ocr_data": {...}
|
||||
}
|
||||
],
|
||||
"documents_skipped": ["correspondence"],
|
||||
"current_doc_index": 1,
|
||||
|
||||
// === Визард (генерируется в фоне) ===
|
||||
"wizard_plan": {...}, // AI-generated questions
|
||||
"wizard_answers": {...}, // Auto-filled from OCR
|
||||
"wizard_ready": true, // Флаг готовности
|
||||
|
||||
// === Заявление ===
|
||||
"claim_ready": false, // Флаг готовности заявления
|
||||
"claim_data": { // Готовое заявление для апрува
|
||||
"applicant": {...},
|
||||
"case": {...},
|
||||
"contract_or_service": {...},
|
||||
"offenders": [...],
|
||||
"claim": {...},
|
||||
"attachments": [...]
|
||||
},
|
||||
|
||||
// === Метаданные ===
|
||||
"created_at": "2025-11-26T10:00:00Z",
|
||||
"updated_at": "2025-11-26T10:05:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API Endpoints
|
||||
|
||||
### Существующие (без изменений)
|
||||
- `POST /api/v1/claims/description` — публикация описания в Redis
|
||||
- `GET /api/v1/claims/drafts/list` — список черновиков
|
||||
- `GET /api/v1/claims/drafts/{claim_id}` — полные данные черновика
|
||||
- `POST /api/v1/claims/approve` — финальный апрув (SMS)
|
||||
|
||||
### Новые/Изменённые
|
||||
|
||||
#### 1. SSE: Получение списка документов
|
||||
```
|
||||
GET /api/v1/events/{session_id}
|
||||
|
||||
Event: documents_list_ready
|
||||
Data: {
|
||||
"event_type": "documents_list_ready",
|
||||
"documents_required": [...]
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Загрузка документа
|
||||
```
|
||||
POST /api/v1/documents/upload
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
Body:
|
||||
- claim_id: string
|
||||
- document_type: string (contract, payment, etc.)
|
||||
- file: binary
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"file_id": "s3://...",
|
||||
"ocr_status": "processing"
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. SSE: Статус OCR и формирования заявления
|
||||
```
|
||||
GET /api/v1/events/{session_id}
|
||||
|
||||
Event: document_ocr_completed
|
||||
Data: {
|
||||
"event_type": "document_ocr_completed",
|
||||
"document_type": "contract",
|
||||
"ocr_data": {...}
|
||||
}
|
||||
|
||||
Event: claim_ready
|
||||
Data: {
|
||||
"event_type": "claim_ready",
|
||||
"claim_data": {...}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Получение статуса черновика
|
||||
```
|
||||
GET /api/v1/claims/drafts/{claim_id}/status
|
||||
|
||||
Response:
|
||||
{
|
||||
"status_code": "draft_docs_progress",
|
||||
"documents_total": 3,
|
||||
"documents_uploaded": 1,
|
||||
"documents_skipped": 0,
|
||||
"wizard_ready": false,
|
||||
"claim_ready": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Frontend компоненты
|
||||
|
||||
### 1. StepDocumentsNew.tsx (НОВЫЙ)
|
||||
```tsx
|
||||
// Поэкранная загрузка документов
|
||||
// Один документ на экран
|
||||
// Критичные помечены алертом
|
||||
// Кнопки: "Загрузить", "Пропустить", "Назад"
|
||||
|
||||
interface Props {
|
||||
documents: DocumentConfig[];
|
||||
currentIndex: number;
|
||||
onUpload: (file: File) => void;
|
||||
onSkip: () => void;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. StepWaitingClaim.tsx (НОВЫЙ)
|
||||
```tsx
|
||||
// Loader пока формируется заявление
|
||||
// Прогресс: "OCR документов...", "Анализ данных...", "Формирование заявления..."
|
||||
// SSE подписка на claim_ready
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
onClaimReady: (claimData: any) => void;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. StepDraftSelection.tsx (ОБНОВИТЬ)
|
||||
```tsx
|
||||
// Новые статусы черновиков
|
||||
// Разные действия для разных статусов
|
||||
// Legacy черновики → "Начать заново"
|
||||
```
|
||||
|
||||
### 4. ClaimForm.tsx (ОБНОВИТЬ)
|
||||
```tsx
|
||||
// Новая логика шагов
|
||||
// Убрать StepWizardPlan из основного флоу
|
||||
// Добавить StepDocumentsNew и StepWaitingClaim
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ n8n Воркфлоу
|
||||
|
||||
### 1. Генерация списка документов (быстрая)
|
||||
```
|
||||
Redis Trigger (ticket_form:description)
|
||||
↓
|
||||
AI: Быстрый анализ → список документов (5-10 сек)
|
||||
↓
|
||||
Redis Publish (ocr_events:{session_id})
|
||||
+ event_type: documents_list_ready
|
||||
↓
|
||||
PostgreSQL: Сохранить documents_required в черновик
|
||||
↓
|
||||
Параллельно: Запустить генерацию визарда (отдельный воркфлоу)
|
||||
```
|
||||
|
||||
### 2. Генерация визарда (фоновая)
|
||||
```
|
||||
(Запускается из воркфлоу 1)
|
||||
↓
|
||||
AI Agent: RAG + генерация вопросов (2 мин)
|
||||
↓
|
||||
PostgreSQL: Сохранить wizard_plan в черновик
|
||||
+ wizard_ready = true
|
||||
```
|
||||
|
||||
### 3. OCR документа
|
||||
```
|
||||
Webhook (upload документа)
|
||||
↓
|
||||
S3 Upload
|
||||
↓
|
||||
AI Vision: OCR + извлечение данных
|
||||
↓
|
||||
PostgreSQL: Сохранить в documents_uploaded
|
||||
↓
|
||||
Redis Publish: document_ocr_completed
|
||||
↓
|
||||
Если все документы загружены:
|
||||
↓ (Запустить формирование заявления)
|
||||
```
|
||||
|
||||
### 4. Формирование заявления
|
||||
```
|
||||
(После всех документов)
|
||||
↓
|
||||
Собрать данные из:
|
||||
- wizard_plan
|
||||
- documents_uploaded (OCR данные)
|
||||
- CRM контакт
|
||||
↓
|
||||
AI: Сформировать заявление
|
||||
↓
|
||||
PostgreSQL: Сохранить claim_data
|
||||
+ claim_ready = true
|
||||
↓
|
||||
Redis Publish: claim_ready
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 План реализации
|
||||
|
||||
### Фаза 1: Frontend (без n8n)
|
||||
1. ✅ Создать `StepDocumentsNew.tsx` — заглушка с mock данными
|
||||
2. ✅ Создать `StepWaitingClaim.tsx` — loader
|
||||
3. ✅ Обновить `ClaimForm.tsx` — новый флоу шагов
|
||||
4. ✅ Обновить `StepDraftSelection.tsx` — новые статусы
|
||||
|
||||
### Фаза 2: Backend
|
||||
1. ✅ Эндпоинт `POST /api/v1/documents/upload`
|
||||
2. ✅ SSE events: `documents_list_ready`, `document_ocr_completed`, `claim_ready`
|
||||
3. ✅ Эндпоинт `GET /api/v1/claims/drafts/{claim_id}/status`
|
||||
|
||||
### Фаза 3: n8n
|
||||
1. ✅ Воркфлоу: Генерация списка документов
|
||||
2. ✅ Воркфлоу: OCR документа
|
||||
3. ✅ Воркфлоу: Формирование заявления
|
||||
|
||||
### Фаза 4: Интеграция и тестирование
|
||||
1. ✅ Полный цикл с реальными данными
|
||||
2. ✅ Обработка ошибок
|
||||
3. ✅ Legacy черновики
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Ожидаемый результат
|
||||
|
||||
| Метрика | Было | Стало |
|
||||
|---------|------|-------|
|
||||
| Время до первого действия | ~2 мин | ~10 сек |
|
||||
| Количество вопросов | 10-15 | 0-3 (только уточняющие) |
|
||||
| Конверсия | ? | ↑ (меньше отвала) |
|
||||
|
||||
|
||||
|
||||
**Дата создания:** 2025-11-26
|
||||
**Статус:** В разработке
|
||||
|
||||
---
|
||||
|
||||
## 📋 Проблема
|
||||
|
||||
Текущий флоу слишком медленный:
|
||||
1. **2 минуты** — генерация визарда (RAG + AI анализ)
|
||||
2. **Длинная анкета** — слишком много вопросов для пользователя
|
||||
|
||||
---
|
||||
|
||||
## ✅ Новое решение
|
||||
|
||||
### Концепция
|
||||
1. После описания проблемы → сразу запрашиваем документы (без ожидания визарда)
|
||||
2. Пока пользователь загружает документы → в бэке генерируется визард + OCR
|
||||
3. После всех документов → показываем готовое заявление на апрув
|
||||
|
||||
### Преимущества
|
||||
- **Быстрый старт** — пользователь не ждёт 2 минуты
|
||||
- **Параллельная работа** — OCR и визард генерируются пока пользователь ищет документы
|
||||
- **Меньше вопросов** — большая часть данных извлекается из документов
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Новый флоу (шаги)
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ 1. Телефон │ (уже есть)
|
||||
│ SMS верификация
|
||||
└────────┬────────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 2. Черновики │ (уже есть, обновить UI)
|
||||
│ - Новые статусы│
|
||||
│ - Legacy→"Начать заново"
|
||||
└────────┬────────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 3. Описание │ (уже есть)
|
||||
│ Свободный текст│
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼ → n8n: быстрая генерация списка документов (5-10 сек)
|
||||
│ → n8n: параллельно запускает генерацию визарда (в фоне)
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 4. Документы │ 🆕 НОВЫЙ КОМПОНЕНТ
|
||||
│ - Поэкранная загрузка
|
||||
│ - Критичные помечены
|
||||
│ - Можно пропустить
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼ → n8n: OCR каждого документа → заполнение визарда (в фоне)
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 5. Ожидание │ 🆕 НОВЫЙ КОМПОНЕНТ
|
||||
│ "Формируем заявление..."
|
||||
│ Loader + прогресс
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼ ← n8n: claim_ready event (SSE)
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 6. Заявление │ (уже есть StepClaimConfirmation)
|
||||
│ Просмотр + редактирование
|
||||
└────────┬────────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 7. SMS апрув │ (уже есть)
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Статусы черновика (status_code)
|
||||
|
||||
| Статус | Описание | UI при открытии |
|
||||
|--------|----------|-----------------|
|
||||
| `draft_new` | Только описание | → Шаг документов |
|
||||
| `draft_docs_progress` | Часть документов загружена | → Продолжить с текущего документа |
|
||||
| `draft_docs_complete` | Все документы загружены | → Показать loader |
|
||||
| `draft_claim_ready` | Заявление готово | → Показать заявление |
|
||||
| `awaiting_sms` | Ждёт SMS | → Форма SMS |
|
||||
| `approved` | Отправлено | Не показываем |
|
||||
|
||||
### Legacy черновики (старый формат)
|
||||
- Нет `documents_required` → показываем с пометкой "устаревший"
|
||||
- Кнопка "Начать заново" → копирует description, создаёт новый черновик
|
||||
|
||||
---
|
||||
|
||||
## 📦 Структура payload черновика
|
||||
|
||||
```json
|
||||
{
|
||||
// === Идентификаторы ===
|
||||
"claim_id": "CLM-2025-11-26-X7Y8Z9",
|
||||
"session_token": "sess_abc123...",
|
||||
"unified_id": "user_456...",
|
||||
"phone": "+79991234567",
|
||||
"email": "user@example.com",
|
||||
|
||||
// === Описание проблемы ===
|
||||
"problem_description": "Купил курсы за 50000р, компания не отвечает...",
|
||||
|
||||
// === Документы (новое!) ===
|
||||
"documents_required": [
|
||||
{
|
||||
"type": "contract",
|
||||
"name": "Договор или оферта",
|
||||
"critical": true,
|
||||
"hints": "Скриншот или PDF договора/оферты"
|
||||
},
|
||||
{
|
||||
"type": "payment",
|
||||
"name": "Подтверждение оплаты",
|
||||
"critical": true,
|
||||
"hints": "Чек, выписка из банка, скриншот платежа"
|
||||
},
|
||||
{
|
||||
"type": "correspondence",
|
||||
"name": "Переписка с продавцом",
|
||||
"critical": false,
|
||||
"hints": "Скриншоты переписки, email, чаты"
|
||||
}
|
||||
],
|
||||
"documents_uploaded": [
|
||||
{
|
||||
"type": "contract",
|
||||
"file_id": "s3://...",
|
||||
"ocr_status": "completed",
|
||||
"ocr_data": {...}
|
||||
}
|
||||
],
|
||||
"documents_skipped": ["correspondence"],
|
||||
"current_doc_index": 1,
|
||||
|
||||
// === Визард (генерируется в фоне) ===
|
||||
"wizard_plan": {...}, // AI-generated questions
|
||||
"wizard_answers": {...}, // Auto-filled from OCR
|
||||
"wizard_ready": true, // Флаг готовности
|
||||
|
||||
// === Заявление ===
|
||||
"claim_ready": false, // Флаг готовности заявления
|
||||
"claim_data": { // Готовое заявление для апрува
|
||||
"applicant": {...},
|
||||
"case": {...},
|
||||
"contract_or_service": {...},
|
||||
"offenders": [...],
|
||||
"claim": {...},
|
||||
"attachments": [...]
|
||||
},
|
||||
|
||||
// === Метаданные ===
|
||||
"created_at": "2025-11-26T10:00:00Z",
|
||||
"updated_at": "2025-11-26T10:05:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API Endpoints
|
||||
|
||||
### Существующие (без изменений)
|
||||
- `POST /api/v1/claims/description` — публикация описания в Redis
|
||||
- `GET /api/v1/claims/drafts/list` — список черновиков
|
||||
- `GET /api/v1/claims/drafts/{claim_id}` — полные данные черновика
|
||||
- `POST /api/v1/claims/approve` — финальный апрув (SMS)
|
||||
|
||||
### Новые/Изменённые
|
||||
|
||||
#### 1. SSE: Получение списка документов
|
||||
```
|
||||
GET /api/v1/events/{session_id}
|
||||
|
||||
Event: documents_list_ready
|
||||
Data: {
|
||||
"event_type": "documents_list_ready",
|
||||
"documents_required": [...]
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Загрузка документа
|
||||
```
|
||||
POST /api/v1/documents/upload
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
Body:
|
||||
- claim_id: string
|
||||
- document_type: string (contract, payment, etc.)
|
||||
- file: binary
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"file_id": "s3://...",
|
||||
"ocr_status": "processing"
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. SSE: Статус OCR и формирования заявления
|
||||
```
|
||||
GET /api/v1/events/{session_id}
|
||||
|
||||
Event: document_ocr_completed
|
||||
Data: {
|
||||
"event_type": "document_ocr_completed",
|
||||
"document_type": "contract",
|
||||
"ocr_data": {...}
|
||||
}
|
||||
|
||||
Event: claim_ready
|
||||
Data: {
|
||||
"event_type": "claim_ready",
|
||||
"claim_data": {...}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. Получение статуса черновика
|
||||
```
|
||||
GET /api/v1/claims/drafts/{claim_id}/status
|
||||
|
||||
Response:
|
||||
{
|
||||
"status_code": "draft_docs_progress",
|
||||
"documents_total": 3,
|
||||
"documents_uploaded": 1,
|
||||
"documents_skipped": 0,
|
||||
"wizard_ready": false,
|
||||
"claim_ready": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Frontend компоненты
|
||||
|
||||
### 1. StepDocumentsNew.tsx (НОВЫЙ)
|
||||
```tsx
|
||||
// Поэкранная загрузка документов
|
||||
// Один документ на экран
|
||||
// Критичные помечены алертом
|
||||
// Кнопки: "Загрузить", "Пропустить", "Назад"
|
||||
|
||||
interface Props {
|
||||
documents: DocumentConfig[];
|
||||
currentIndex: number;
|
||||
onUpload: (file: File) => void;
|
||||
onSkip: () => void;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. StepWaitingClaim.tsx (НОВЫЙ)
|
||||
```tsx
|
||||
// Loader пока формируется заявление
|
||||
// Прогресс: "OCR документов...", "Анализ данных...", "Формирование заявления..."
|
||||
// SSE подписка на claim_ready
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
onClaimReady: (claimData: any) => void;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. StepDraftSelection.tsx (ОБНОВИТЬ)
|
||||
```tsx
|
||||
// Новые статусы черновиков
|
||||
// Разные действия для разных статусов
|
||||
// Legacy черновики → "Начать заново"
|
||||
```
|
||||
|
||||
### 4. ClaimForm.tsx (ОБНОВИТЬ)
|
||||
```tsx
|
||||
// Новая логика шагов
|
||||
// Убрать StepWizardPlan из основного флоу
|
||||
// Добавить StepDocumentsNew и StepWaitingClaim
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ n8n Воркфлоу
|
||||
|
||||
### 1. Генерация списка документов (быстрая)
|
||||
```
|
||||
Redis Trigger (ticket_form:description)
|
||||
↓
|
||||
AI: Быстрый анализ → список документов (5-10 сек)
|
||||
↓
|
||||
Redis Publish (ocr_events:{session_id})
|
||||
+ event_type: documents_list_ready
|
||||
↓
|
||||
PostgreSQL: Сохранить documents_required в черновик
|
||||
↓
|
||||
Параллельно: Запустить генерацию визарда (отдельный воркфлоу)
|
||||
```
|
||||
|
||||
### 2. Генерация визарда (фоновая)
|
||||
```
|
||||
(Запускается из воркфлоу 1)
|
||||
↓
|
||||
AI Agent: RAG + генерация вопросов (2 мин)
|
||||
↓
|
||||
PostgreSQL: Сохранить wizard_plan в черновик
|
||||
+ wizard_ready = true
|
||||
```
|
||||
|
||||
### 3. OCR документа
|
||||
```
|
||||
Webhook (upload документа)
|
||||
↓
|
||||
S3 Upload
|
||||
↓
|
||||
AI Vision: OCR + извлечение данных
|
||||
↓
|
||||
PostgreSQL: Сохранить в documents_uploaded
|
||||
↓
|
||||
Redis Publish: document_ocr_completed
|
||||
↓
|
||||
Если все документы загружены:
|
||||
↓ (Запустить формирование заявления)
|
||||
```
|
||||
|
||||
### 4. Формирование заявления
|
||||
```
|
||||
(После всех документов)
|
||||
↓
|
||||
Собрать данные из:
|
||||
- wizard_plan
|
||||
- documents_uploaded (OCR данные)
|
||||
- CRM контакт
|
||||
↓
|
||||
AI: Сформировать заявление
|
||||
↓
|
||||
PostgreSQL: Сохранить claim_data
|
||||
+ claim_ready = true
|
||||
↓
|
||||
Redis Publish: claim_ready
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 План реализации
|
||||
|
||||
### Фаза 1: Frontend (без n8n)
|
||||
1. ✅ Создать `StepDocumentsNew.tsx` — заглушка с mock данными
|
||||
2. ✅ Создать `StepWaitingClaim.tsx` — loader
|
||||
3. ✅ Обновить `ClaimForm.tsx` — новый флоу шагов
|
||||
4. ✅ Обновить `StepDraftSelection.tsx` — новые статусы
|
||||
|
||||
### Фаза 2: Backend
|
||||
1. ✅ Эндпоинт `POST /api/v1/documents/upload`
|
||||
2. ✅ SSE events: `documents_list_ready`, `document_ocr_completed`, `claim_ready`
|
||||
3. ✅ Эндпоинт `GET /api/v1/claims/drafts/{claim_id}/status`
|
||||
|
||||
### Фаза 3: n8n
|
||||
1. ✅ Воркфлоу: Генерация списка документов
|
||||
2. ✅ Воркфлоу: OCR документа
|
||||
3. ✅ Воркфлоу: Формирование заявления
|
||||
|
||||
### Фаза 4: Интеграция и тестирование
|
||||
1. ✅ Полный цикл с реальными данными
|
||||
2. ✅ Обработка ошибок
|
||||
3. ✅ Legacy черновики
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Ожидаемый результат
|
||||
|
||||
| Метрика | Было | Стало |
|
||||
|---------|------|-------|
|
||||
| Время до первого действия | ~2 мин | ~10 сек |
|
||||
| Количество вопросов | 10-15 | 0-3 (только уточняющие) |
|
||||
| Конверсия | ? | ↑ (меньше отвала) |
|
||||
|
||||
|
||||
96
docs/SESSION_LOG_2025-11-28_documents_dedup.md
Normal file
96
docs/SESSION_LOG_2025-11-28_documents_dedup.md
Normal 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` для информирования о дубликатах
|
||||
|
||||
438
docs/SESSION_LOG_2025-11-29_RAG_WORKFLOW.md
Normal file
438
docs/SESSION_LOG_2025-11-29_RAG_WORKFLOW.md
Normal 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` — шаблон формы заявления
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
281
docs/SESSION_LOG_2025-12-01.md
Normal file
281
docs/SESSION_LOG_2025-12-01.md
Normal 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 интеграция, исправление дубликатов
|
||||
|
||||
141
docs/SQL_ADD_CONTACT_DATA_CONFIRMED.sql
Normal file
141
docs/SQL_ADD_CONTACT_DATA_CONFIRMED.sql
Normal 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);
|
||||
|
||||
379
docs/SQL_CLAIMSAVE_DOCUMENT_SKIP.sql
Normal file
379
docs/SQL_CLAIMSAVE_DOCUMENT_SKIP.sql
Normal 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;
|
||||
|
||||
130
docs/SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW.sql
Normal file
130
docs/SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW.sql
Normal file
@@ -0,0 +1,130 @@
|
||||
-- ============================================================================
|
||||
-- Исправленный SQL для сохранения документов (claimsave_final) - ПОДДЕРЖКА НОВОГО ФЛОУ
|
||||
-- ============================================================================
|
||||
-- Проблема: SQL не сохранял documents_required и мог перезаписать статус
|
||||
-- Решение: Сохраняем documents_required и не перезаписываем новые статусы
|
||||
-- ============================================================================
|
||||
|
||||
WITH partial AS (
|
||||
SELECT $1::jsonb AS p, $2::text AS claim_id_str
|
||||
),
|
||||
|
||||
claim_lookup AS (
|
||||
SELECT
|
||||
c.id,
|
||||
c.payload,
|
||||
c.status_code
|
||||
FROM clpr_claims c, partial
|
||||
WHERE c.id::text = partial.claim_id_str
|
||||
OR c.payload->>'claim_id' = partial.claim_id_str
|
||||
ORDER BY
|
||||
CASE WHEN c.id::text = partial.claim_id_str THEN 1 ELSE 2 END,
|
||||
c.updated_at DESC
|
||||
LIMIT 1
|
||||
),
|
||||
|
||||
docs AS (
|
||||
SELECT
|
||||
claim_lookup.id::text AS claim_id,
|
||||
doc.field_name::text AS field_name,
|
||||
doc.file_id::text AS file_id,
|
||||
doc.file_name::text AS file_name,
|
||||
doc.original_file_name::text AS original_file_name,
|
||||
(doc.uploaded_at)::timestamptz AS uploaded_at,
|
||||
doc.file_url::text AS file_url
|
||||
FROM partial, claim_lookup
|
||||
CROSS JOIN LATERAL jsonb_to_recordset(
|
||||
COALESCE(partial.p->'documents_meta','[]'::jsonb)
|
||||
) AS doc(
|
||||
field_name text, file_id text, file_name text,
|
||||
original_file_name text, uploaded_at text, file_url text
|
||||
)
|
||||
),
|
||||
|
||||
upsert_docs AS (
|
||||
INSERT INTO clpr_claim_documents
|
||||
(claim_id, field_name, file_id, uploaded_at, file_name, original_file_name)
|
||||
SELECT claim_id, field_name, file_id, uploaded_at, file_name, original_file_name
|
||||
FROM docs
|
||||
ON CONFLICT (claim_id, field_name) DO UPDATE
|
||||
SET file_id = EXCLUDED.file_id,
|
||||
uploaded_at = EXCLUDED.uploaded_at,
|
||||
file_name = EXCLUDED.file_name,
|
||||
original_file_name = EXCLUDED.original_file_name
|
||||
RETURNING id, claim_id, field_name, file_id
|
||||
),
|
||||
|
||||
-- ✅ ИСПРАВЛЕНО: Сохраняем documents_required и обновляем статус правильно
|
||||
upd_claim AS (
|
||||
UPDATE clpr_claims c
|
||||
SET
|
||||
-- ✅ Объединяем payload: сохраняем documents_required и documents_meta
|
||||
payload = jsonb_set(
|
||||
jsonb_set(
|
||||
COALESCE(c.payload, '{}'::jsonb),
|
||||
'{documents_meta}',
|
||||
COALESCE((SELECT p->'documents_meta' FROM partial), '[]'::jsonb),
|
||||
true
|
||||
),
|
||||
'{documents_required}',
|
||||
COALESCE(
|
||||
(SELECT p->'documents_required' FROM partial WHERE partial.p->'documents_required' IS NOT NULL),
|
||||
c.payload->'documents_required', -- Сохраняем существующий, если новый не пришёл
|
||||
'[]'::jsonb
|
||||
),
|
||||
true
|
||||
),
|
||||
-- ✅ Обновляем статус только если нужно (не перезаписываем новые статусы)
|
||||
status_code = CASE
|
||||
-- Если статус уже новый - сохраняем его
|
||||
WHEN c.status_code IN ('draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready')
|
||||
THEN c.status_code
|
||||
-- Если есть documents_required и документы загружены - обновляем статус
|
||||
WHEN c.payload->'documents_required' IS NOT NULL
|
||||
AND jsonb_array_length(COALESCE(c.payload->'documents_required', '[]'::jsonb)) > 0
|
||||
AND (SELECT COUNT(*) FROM docs) > 0
|
||||
THEN CASE
|
||||
WHEN (SELECT COUNT(*) FROM docs) >= jsonb_array_length(COALESCE(c.payload->'documents_required', '[]'::jsonb))
|
||||
THEN 'draft_docs_complete'
|
||||
ELSE 'draft_docs_progress'
|
||||
END
|
||||
-- Иначе сохраняем существующий
|
||||
ELSE c.status_code
|
||||
END,
|
||||
updated_at = now(),
|
||||
expires_at = now() + interval '14 days'
|
||||
FROM partial, claim_lookup
|
||||
WHERE c.id = claim_lookup.id
|
||||
RETURNING c.id, c.payload, c.status_code
|
||||
)
|
||||
|
||||
SELECT
|
||||
(SELECT jsonb_build_object(
|
||||
'claim_id', u.id::text,
|
||||
'status_code', u.status_code,
|
||||
'payload', u.payload
|
||||
) FROM upd_claim u) AS claim,
|
||||
|
||||
(
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', u.id,
|
||||
'field_name', u.field_name,
|
||||
'file_id', u.file_id,
|
||||
'file_url', d.file_url,
|
||||
'file_name', d.file_name,
|
||||
'original_file_name', d.original_file_name,
|
||||
'uploaded_at', d.uploaded_at,
|
||||
'filename_for_upload',
|
||||
COALESCE(
|
||||
NULLIF(d.original_file_name, ''),
|
||||
NULLIF(d.file_name, ''),
|
||||
regexp_replace(d.file_id, '^.*/', '')
|
||||
)
|
||||
)
|
||||
)
|
||||
FROM upsert_docs u
|
||||
JOIN docs d ON d.claim_id = u.claim_id AND d.field_name = u.field_name
|
||||
WHERE d.file_url IS NOT NULL AND d.file_url <> ''
|
||||
) AS documents;
|
||||
|
||||
324
docs/SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql
Normal file
324
docs/SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql
Normal 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;
|
||||
|
||||
345
docs/SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED_FIXED.sql
Normal file
345
docs/SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED_FIXED.sql
Normal 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;
|
||||
|
||||
362
docs/SQL_CLAIMSAVE_FIXED_NEW_FLOW.sql
Normal file
362
docs/SQL_CLAIMSAVE_FIXED_NEW_FLOW.sql
Normal file
@@ -0,0 +1,362 @@
|
||||
-- ============================================================================
|
||||
-- Исправленный SQL для сохранения claim (claimsave) - ПОДДЕРЖКА НОВОГО ФЛОУ
|
||||
-- ============================================================================
|
||||
-- Проблема: SQL не сохранял documents_required и перезаписывал status_code на 'draft'
|
||||
-- Решение: Сохраняем documents_required и не перезаписываем новые статусы
|
||||
-- ============================================================================
|
||||
|
||||
WITH partial AS (
|
||||
SELECT
|
||||
$1::jsonb AS p,
|
||||
$2::text AS claim_id_str
|
||||
),
|
||||
|
||||
existing_claim AS (
|
||||
SELECT
|
||||
id,
|
||||
payload,
|
||||
status_code,
|
||||
created_at
|
||||
FROM clpr_claims
|
||||
WHERE id = (SELECT claim_id_str::uuid FROM partial)
|
||||
OR payload->>'claim_id' = (SELECT claim_id_str FROM partial)
|
||||
ORDER BY
|
||||
CASE WHEN id = (SELECT claim_id_str::uuid FROM partial) THEN 1 ELSE 2 END,
|
||||
updated_at DESC
|
||||
LIMIT 1
|
||||
),
|
||||
|
||||
-- Парсим documents_required (или берём из БД)
|
||||
documents_required_parsed AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN partial.p->'documents_required' IS NOT NULL
|
||||
AND jsonb_typeof(partial.p->'documents_required') = 'array'
|
||||
THEN partial.p->'documents_required'
|
||||
WHEN partial.p->'edit_fields_parsed'->'documents_required' IS NOT NULL
|
||||
AND jsonb_typeof(partial.p->'edit_fields_parsed'->'documents_required') = 'array'
|
||||
THEN partial.p->'edit_fields_parsed'->'documents_required'
|
||||
WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->'documents_required' IS NOT NULL)
|
||||
THEN (SELECT payload->'documents_required' FROM existing_claim)
|
||||
ELSE '[]'::jsonb
|
||||
END AS documents_required
|
||||
FROM partial
|
||||
),
|
||||
|
||||
-- Парсим documents_uploaded (или берём из БД)
|
||||
documents_uploaded_parsed AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN partial.p->'documents_uploaded' IS NOT NULL
|
||||
AND jsonb_typeof(partial.p->'documents_uploaded') = 'array'
|
||||
THEN partial.p->'documents_uploaded'
|
||||
WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->'documents_uploaded' IS NOT NULL)
|
||||
THEN (SELECT payload->'documents_uploaded' FROM existing_claim)
|
||||
ELSE '[]'::jsonb
|
||||
END AS documents_uploaded
|
||||
FROM partial
|
||||
),
|
||||
|
||||
-- Парсим documents_skipped (или берём из БД)
|
||||
documents_skipped_parsed AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN partial.p->'documents_skipped' IS NOT NULL
|
||||
AND jsonb_typeof(partial.p->'documents_skipped') = 'array'
|
||||
THEN partial.p->'documents_skipped'
|
||||
WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->'documents_skipped' IS NOT NULL)
|
||||
THEN (SELECT payload->'documents_skipped' FROM existing_claim)
|
||||
ELSE '[]'::jsonb
|
||||
END AS documents_skipped
|
||||
FROM partial
|
||||
),
|
||||
|
||||
-- Парсим current_doc_index (или берём из БД)
|
||||
current_doc_index_parsed AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN partial.p->'current_doc_index' IS NOT NULL
|
||||
THEN (partial.p->'current_doc_index')::int
|
||||
WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->'current_doc_index' IS NOT NULL)
|
||||
THEN (SELECT (payload->'current_doc_index')::int FROM existing_claim)
|
||||
ELSE 0
|
||||
END AS current_doc_index
|
||||
FROM partial
|
||||
),
|
||||
|
||||
-- Парсим wizard_answers
|
||||
wizard_answers_parsed AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN partial.p->'edit_fields_raw'->'body'->>'wizard_answers' IS NOT NULL
|
||||
THEN (partial.p->'edit_fields_raw'->'body'->>'wizard_answers')::jsonb
|
||||
WHEN partial.p->'edit_fields_parsed'->'body'->>'wizard_answers' IS NOT NULL
|
||||
THEN (partial.p->'edit_fields_parsed'->'body'->>'wizard_answers')::jsonb
|
||||
WHEN partial.p->>'wizard_answers' IS NOT NULL
|
||||
THEN (partial.p->>'wizard_answers')::jsonb
|
||||
WHEN partial.p->'wizard_answers' IS NOT NULL
|
||||
AND jsonb_typeof(partial.p->'wizard_answers') = 'object'
|
||||
THEN partial.p->'wizard_answers'
|
||||
ELSE '{}'::jsonb
|
||||
END AS answers
|
||||
FROM partial
|
||||
),
|
||||
|
||||
-- Парсим wizard_plan (или берём из существующей записи)
|
||||
wizard_plan_parsed AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN partial.p->'edit_fields_parsed'->'wizard_plan_parsed' IS NOT NULL
|
||||
AND jsonb_typeof(partial.p->'edit_fields_parsed'->'wizard_plan_parsed') = 'object'
|
||||
THEN partial.p->'edit_fields_parsed'->'wizard_plan_parsed'
|
||||
WHEN partial.p->>'wizard_plan' IS NOT NULL
|
||||
THEN (partial.p->>'wizard_plan')::jsonb
|
||||
WHEN partial.p->'wizard_plan' IS NOT NULL
|
||||
AND jsonb_typeof(partial.p->'wizard_plan') = 'object'
|
||||
THEN partial.p->'wizard_plan'
|
||||
WHEN partial.p->'edit_fields_raw'->'body'->>'wizard_plan' IS NOT NULL
|
||||
THEN (partial.p->'edit_fields_raw'->'body'->>'wizard_plan')::jsonb
|
||||
WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->'wizard_plan' IS NOT NULL)
|
||||
THEN (SELECT payload->'wizard_plan' FROM existing_claim)
|
||||
ELSE NULL
|
||||
END AS wizard_plan
|
||||
FROM partial
|
||||
),
|
||||
|
||||
-- Парсим problem_description (или берём из БД)
|
||||
problem_description_parsed AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN partial.p->>'problem_description' IS NOT NULL
|
||||
THEN partial.p->>'problem_description'
|
||||
WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->>'problem_description' IS NOT NULL)
|
||||
THEN (SELECT payload->>'problem_description' FROM existing_claim)
|
||||
ELSE NULL
|
||||
END AS problem_description
|
||||
FROM partial
|
||||
),
|
||||
|
||||
-- Определяем правильный статус
|
||||
status_code_resolved AS (
|
||||
SELECT
|
||||
CASE
|
||||
-- Если есть documents_required и документы загружаются - новый флоу
|
||||
WHEN (SELECT jsonb_array_length(documents_required) FROM documents_required_parsed) > 0
|
||||
THEN CASE
|
||||
-- Все документы загружены или пропущены
|
||||
WHEN (SELECT jsonb_array_length(documents_uploaded) FROM documents_uploaded_parsed) +
|
||||
(SELECT jsonb_array_length(documents_skipped) FROM documents_skipped_parsed) >=
|
||||
(SELECT jsonb_array_length(documents_required) FROM documents_required_parsed)
|
||||
THEN 'draft_docs_complete'
|
||||
-- Документы загружаются
|
||||
WHEN (SELECT jsonb_array_length(documents_uploaded) FROM documents_uploaded_parsed) > 0
|
||||
THEN 'draft_docs_progress'
|
||||
-- Только описание
|
||||
ELSE 'draft_new'
|
||||
END
|
||||
-- Старый флоу: проверяем wizard_answers
|
||||
WHEN (SELECT answers->>'docs_exist' FROM wizard_answers_parsed) = 'true'
|
||||
THEN 'in_work'
|
||||
-- Сохраняем существующий статус, если он новый
|
||||
WHEN EXISTS (SELECT 1 FROM existing_claim
|
||||
WHERE status_code IN ('draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready'))
|
||||
THEN (SELECT status_code FROM existing_claim)
|
||||
-- По умолчанию
|
||||
ELSE 'draft'
|
||||
END AS status_code
|
||||
FROM partial
|
||||
),
|
||||
|
||||
-- UPSERT claim
|
||||
claim_upsert AS (
|
||||
INSERT INTO clpr_claims (
|
||||
id,
|
||||
session_token,
|
||||
unified_id,
|
||||
contact_id,
|
||||
phone,
|
||||
channel,
|
||||
type_code,
|
||||
status_code,
|
||||
payload,
|
||||
created_at,
|
||||
updated_at,
|
||||
expires_at
|
||||
)
|
||||
SELECT
|
||||
COALESCE((SELECT id FROM existing_claim), partial.claim_id_str::uuid),
|
||||
COALESCE(
|
||||
partial.p->>'session_id',
|
||||
partial.p->'edit_fields_parsed'->'body'->>'session_id',
|
||||
partial.p->'edit_fields_raw'->'body'->>'session_id',
|
||||
'sess-unknown'
|
||||
),
|
||||
COALESCE(
|
||||
partial.p->>'unified_id',
|
||||
partial.p->'edit_fields_parsed'->'body'->>'unified_id',
|
||||
partial.p->'edit_fields_raw'->'body'->>'unified_id'
|
||||
),
|
||||
COALESCE(
|
||||
partial.p->>'contact_id',
|
||||
partial.p->'edit_fields_parsed'->'body'->>'contact_id',
|
||||
partial.p->'edit_fields_raw'->'body'->>'contact_id'
|
||||
),
|
||||
COALESCE(
|
||||
partial.p->>'phone',
|
||||
partial.p->'edit_fields_parsed'->'body'->>'phone',
|
||||
partial.p->'edit_fields_raw'->'body'->>'phone'
|
||||
),
|
||||
'web_form',
|
||||
COALESCE(partial.p->>'type_code', 'consumer'),
|
||||
(SELECT status_code FROM status_code_resolved),
|
||||
jsonb_build_object(
|
||||
'claim_id', partial.claim_id_str,
|
||||
'problem_description', (SELECT problem_description FROM problem_description_parsed),
|
||||
'answers', (SELECT answers FROM wizard_answers_parsed),
|
||||
-- ✅ ОБЪЕДИНЯЕМ documents_meta с существующими (не перезаписываем!)
|
||||
'documents_meta', COALESCE(
|
||||
(SELECT p->'documents_meta' FROM partial WHERE partial.p->'documents_meta' IS NOT NULL),
|
||||
'[]'::jsonb
|
||||
) || COALESCE(
|
||||
(SELECT payload->'documents_meta' FROM existing_claim),
|
||||
'[]'::jsonb
|
||||
),
|
||||
-- ✅ НОВЫЙ ФЛОУ: Сохраняем documents_required и связанные поля
|
||||
'documents_required', (SELECT documents_required FROM documents_required_parsed),
|
||||
'documents_uploaded', (SELECT documents_uploaded FROM documents_uploaded_parsed),
|
||||
'documents_skipped', (SELECT documents_skipped FROM documents_skipped_parsed),
|
||||
'current_doc_index', (SELECT current_doc_index FROM current_doc_index_parsed),
|
||||
'wizard_plan', (SELECT wizard_plan FROM wizard_plan_parsed),
|
||||
'phone', COALESCE(partial.p->>'phone', (SELECT payload->>'phone' FROM existing_claim)),
|
||||
'email', COALESCE(partial.p->>'email', (SELECT payload->>'email' FROM existing_claim))
|
||||
),
|
||||
COALESCE((SELECT created_at FROM existing_claim), now()),
|
||||
now(),
|
||||
now() + interval '14 days'
|
||||
FROM partial
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
session_token = EXCLUDED.session_token,
|
||||
unified_id = COALESCE(EXCLUDED.unified_id, clpr_claims.unified_id),
|
||||
contact_id = COALESCE(EXCLUDED.contact_id, clpr_claims.contact_id),
|
||||
phone = COALESCE(EXCLUDED.phone, clpr_claims.phone),
|
||||
-- ✅ НЕ перезаписываем статус, если он новый (сохраняем существующий)
|
||||
status_code = CASE
|
||||
WHEN clpr_claims.status_code IN ('draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready')
|
||||
THEN clpr_claims.status_code -- Сохраняем существующий новый статус
|
||||
ELSE EXCLUDED.status_code -- Используем новый статус
|
||||
END,
|
||||
-- ✅ Объединяем payload правильно: аккуратно объединяем критичные поля
|
||||
payload = jsonb_set(
|
||||
jsonb_set(
|
||||
jsonb_set(
|
||||
jsonb_set(
|
||||
jsonb_set(
|
||||
-- Сначала берём существующий payload и объединяем с новым (без критичных полей)
|
||||
COALESCE(clpr_claims.payload, '{}'::jsonb) ||
|
||||
(EXCLUDED.payload - 'documents_meta' - 'documents_required' - 'documents_uploaded' - 'documents_skipped' - 'current_doc_index'),
|
||||
'{documents_meta}',
|
||||
-- ✅ ОБЪЕДИНЯЕМ documents_meta (не перезаписываем!)
|
||||
COALESCE(
|
||||
EXCLUDED.payload->'documents_meta',
|
||||
'[]'::jsonb
|
||||
) || COALESCE(
|
||||
clpr_claims.payload->'documents_meta',
|
||||
'[]'::jsonb
|
||||
),
|
||||
true
|
||||
),
|
||||
'{documents_required}',
|
||||
COALESCE(
|
||||
EXCLUDED.payload->'documents_required',
|
||||
clpr_claims.payload->'documents_required',
|
||||
'[]'::jsonb
|
||||
),
|
||||
true
|
||||
),
|
||||
'{documents_uploaded}',
|
||||
COALESCE(
|
||||
EXCLUDED.payload->'documents_uploaded',
|
||||
clpr_claims.payload->'documents_uploaded',
|
||||
'[]'::jsonb
|
||||
),
|
||||
true
|
||||
),
|
||||
'{documents_skipped}',
|
||||
COALESCE(
|
||||
EXCLUDED.payload->'documents_skipped',
|
||||
clpr_claims.payload->'documents_skipped',
|
||||
'[]'::jsonb
|
||||
),
|
||||
true
|
||||
),
|
||||
'{current_doc_index}',
|
||||
COALESCE(
|
||||
EXCLUDED.payload->'current_doc_index',
|
||||
clpr_claims.payload->'current_doc_index',
|
||||
to_jsonb(0)
|
||||
),
|
||||
true
|
||||
),
|
||||
updated_at = now(),
|
||||
expires_at = now() + interval '14 days'
|
||||
RETURNING id, status_code, payload, unified_id, contact_id, phone, session_token
|
||||
),
|
||||
|
||||
-- UPSERT documents (если есть)
|
||||
docs_upsert AS (
|
||||
INSERT INTO clpr_claim_documents (
|
||||
claim_id,
|
||||
field_name,
|
||||
file_id,
|
||||
uploaded_at,
|
||||
file_name,
|
||||
original_file_name
|
||||
)
|
||||
SELECT
|
||||
partial.claim_id_str AS claim_id,
|
||||
doc.field_name,
|
||||
doc.file_id,
|
||||
COALESCE((doc.uploaded_at)::timestamptz, now()),
|
||||
doc.file_name,
|
||||
doc.original_file_name
|
||||
FROM partial
|
||||
CROSS JOIN LATERAL jsonb_to_recordset(
|
||||
COALESCE(partial.p->'documents_meta', '[]'::jsonb)
|
||||
) AS doc(
|
||||
field_name text,
|
||||
file_id text,
|
||||
file_name text,
|
||||
original_file_name text,
|
||||
uploaded_at text
|
||||
)
|
||||
WHERE partial.p->'documents_meta' IS NOT NULL
|
||||
AND jsonb_array_length(partial.p->'documents_meta') > 0
|
||||
ON CONFLICT (claim_id, field_name) DO UPDATE SET
|
||||
file_id = EXCLUDED.file_id,
|
||||
uploaded_at = EXCLUDED.uploaded_at,
|
||||
file_name = EXCLUDED.file_name,
|
||||
original_file_name = EXCLUDED.original_file_name
|
||||
RETURNING id, claim_id, field_name, file_id, file_name, original_file_name
|
||||
)
|
||||
|
||||
-- Возвращаем результат
|
||||
SELECT
|
||||
(SELECT jsonb_build_object(
|
||||
'claim_id', cu.id::text,
|
||||
'claim_id_str', (cu.payload->>'claim_id'),
|
||||
'status_code', cu.status_code,
|
||||
'unified_id', cu.unified_id,
|
||||
'contact_id', cu.contact_id,
|
||||
'phone', cu.phone,
|
||||
'session_token', cu.session_token,
|
||||
'payload', cu.payload
|
||||
) FROM claim_upsert cu) AS claim,
|
||||
|
||||
(SELECT jsonb_agg(jsonb_build_object(
|
||||
'id', id,
|
||||
'field_name', field_name,
|
||||
'file_id', file_id,
|
||||
'file_name', file_name,
|
||||
'original_file_name', original_file_name
|
||||
)) FROM docs_upsert) AS documents;
|
||||
|
||||
391
docs/SQL_CLAIMSAVE_FIXED_NEW_FLOW_DEDUP.sql
Normal file
391
docs/SQL_CLAIMSAVE_FIXED_NEW_FLOW_DEDUP.sql
Normal 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;
|
||||
|
||||
36
docs/SQL_CLEANUP_DOCUMENTS_META_DUPLICATES.sql
Normal file
36
docs/SQL_CLEANUP_DOCUMENTS_META_DUPLICATES.sql
Normal 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;
|
||||
|
||||
81
docs/SQL_DOCUMENTS_META_STRUCTURE.md
Normal file
81
docs/SQL_DOCUMENTS_META_STRUCTURE.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Структура documents_meta в SQL запросах
|
||||
|
||||
## Текущая структура после OCR объединения
|
||||
|
||||
После обработки файлов OCR возвращает объединённые документы со следующей структурой:
|
||||
|
||||
```json
|
||||
{
|
||||
"documents_meta": [
|
||||
{
|
||||
"field_name": "uploads[0][0]",
|
||||
"field_label": "Договор или заказ",
|
||||
"file_id": "clientright/0/1764167196926.pdf",
|
||||
"file_name": "1764167196926.pdf",
|
||||
"original_file_name": "1764167196926.pdf",
|
||||
"uploaded_at": "2025-11-26T14:44:51.430Z",
|
||||
"files_count": 2, // ✅ Новое поле: сколько файлов было объединено
|
||||
"pages": 4 // ✅ Новое поле: сколько страниц в объединённом PDF
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Как SQL обрабатывает эту структуру
|
||||
|
||||
### 1. Сохранение в `clpr_claim_documents`
|
||||
|
||||
SQL использует `jsonb_to_recordset` для извлечения только нужных полей:
|
||||
|
||||
```sql
|
||||
CROSS JOIN LATERAL jsonb_to_recordset(
|
||||
COALESCE(partial.p->'documents_meta', '[]'::jsonb)
|
||||
) AS doc(
|
||||
field_name text,
|
||||
file_id text,
|
||||
file_name text,
|
||||
original_file_name text,
|
||||
uploaded_at text
|
||||
)
|
||||
```
|
||||
|
||||
**Важно:** `field_label`, `files_count`, `pages` не извлекаются, но это нормально - они не нужны в таблице `clpr_claim_documents`.
|
||||
|
||||
### 2. Сохранение в `payload->'documents_meta'`
|
||||
|
||||
Полный JSON сохраняется в `payload` через `jsonb_build_object`:
|
||||
|
||||
```sql
|
||||
jsonb_build_object(
|
||||
'claim_id', partial.claim_id_str,
|
||||
'documents_meta', COALESCE(partial.p->'documents_meta', '[]'::jsonb),
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
**Результат:** Все поля (`field_label`, `files_count`, `pages`) сохраняются в `payload->'documents_meta'` в полном объёме.
|
||||
|
||||
## Проверка сохранения
|
||||
|
||||
После выполнения SQL запроса можно проверить:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
payload->'documents_meta'->0->>'field_label' AS field_label,
|
||||
payload->'documents_meta'->0->>'files_count' AS files_count,
|
||||
payload->'documents_meta'->0->>'pages' AS pages
|
||||
FROM clpr_claims
|
||||
WHERE payload->>'claim_id' = 'bddb6815-8e17-4d54-a721-5e94382942c7';
|
||||
```
|
||||
|
||||
Должны вернуться:
|
||||
- `field_label`: "Договор или заказ"
|
||||
- `files_count`: "2"
|
||||
- `pages`: "4"
|
||||
|
||||
## Вывод
|
||||
|
||||
✅ **SQL запрос работает правильно** - дополнительные поля сохраняются в `payload->'documents_meta'` и доступны для использования в дальнейших операциях.
|
||||
|
||||
❌ **Не нужно менять SQL** - текущая структура достаточна для работы.
|
||||
|
||||
104
docs/SQL_DOCUMENT_ID_RETURN.md
Normal file
104
docs/SQL_DOCUMENT_ID_RETURN.md
Normal 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` в каждом элементе
|
||||
|
||||
98
docs/SQL_FIX_CLAIM_DOCUMENTS_FIELD_NAMES.sql
Normal file
98
docs/SQL_FIX_CLAIM_DOCUMENTS_FIELD_NAMES.sql
Normal file
@@ -0,0 +1,98 @@
|
||||
-- ============================================================================
|
||||
-- SQL для исправления field_name в таблице clpr_claim_documents
|
||||
-- ============================================================================
|
||||
-- Проблема: Все документы имеют одинаковый field_name (uploads[0][0])
|
||||
-- Решение: Пересоздаём записи с правильными field_name на основе documents_uploaded
|
||||
-- ============================================================================
|
||||
|
||||
-- Для конкретного claim_id
|
||||
WITH claim_data AS (
|
||||
SELECT
|
||||
id,
|
||||
payload
|
||||
FROM clpr_claims
|
||||
WHERE id::text = 'bddb6815-8e17-4d54-a721-5e94382942c7'
|
||||
OR payload->>'claim_id' = 'bddb6815-8e17-4d54-a721-5e94382942c7'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
),
|
||||
|
||||
-- Извлекаем documents_required для определения индексов
|
||||
documents_required_array AS (
|
||||
SELECT
|
||||
jsonb_array_elements(payload->'documents_required') WITH ORDINALITY AS doc_req(doc, idx)
|
||||
FROM claim_data
|
||||
),
|
||||
|
||||
-- Извлекаем documents_uploaded с правильными индексами
|
||||
documents_uploaded_mapped AS (
|
||||
SELECT
|
||||
doc_up.*,
|
||||
(doc_req.idx - 1)::int AS group_index -- Индекс документа (0-based)
|
||||
FROM claim_data,
|
||||
jsonb_array_elements(payload->'documents_uploaded') AS doc_up,
|
||||
documents_required_array doc_req
|
||||
WHERE (doc_up->>'id' = doc_req.doc->>'id' OR doc_up->>'type' = doc_req.doc->>'id')
|
||||
),
|
||||
|
||||
-- Удаляем старые записи
|
||||
deleted_old AS (
|
||||
DELETE FROM clpr_claim_documents
|
||||
WHERE claim_id = (SELECT id::text FROM claim_data)
|
||||
RETURNING claim_id, field_name, file_id
|
||||
),
|
||||
|
||||
-- Вставляем новые записи с правильными field_name
|
||||
inserted_new AS (
|
||||
INSERT INTO clpr_claim_documents (
|
||||
claim_id,
|
||||
field_name,
|
||||
file_id,
|
||||
file_name,
|
||||
original_file_name,
|
||||
uploaded_at
|
||||
)
|
||||
SELECT
|
||||
(SELECT id::text FROM claim_data) AS claim_id,
|
||||
'uploads[' || group_index || '][0]' AS field_name,
|
||||
doc_up->>'file_id' AS file_id,
|
||||
doc_up->>'file_name' AS file_name,
|
||||
doc_up->>'original_file_name' AS original_file_name,
|
||||
COALESCE(
|
||||
(doc_up->>'uploaded_at')::timestamptz,
|
||||
now()
|
||||
) AS uploaded_at
|
||||
FROM documents_uploaded_mapped doc_up
|
||||
WHERE doc_up->>'file_id' IS NOT NULL
|
||||
AND doc_up->>'file_id' <> ''
|
||||
ON CONFLICT (claim_id, field_name) DO UPDATE SET
|
||||
file_id = EXCLUDED.file_id,
|
||||
file_name = EXCLUDED.file_name,
|
||||
original_file_name = EXCLUDED.original_file_name,
|
||||
uploaded_at = EXCLUDED.uploaded_at
|
||||
RETURNING claim_id, field_name, file_id, file_name
|
||||
)
|
||||
|
||||
-- Возвращаем результат
|
||||
SELECT
|
||||
'Удалено старых записей' AS action,
|
||||
COUNT(*) AS count
|
||||
FROM deleted_old
|
||||
UNION ALL
|
||||
SELECT
|
||||
'Вставлено новых записей' AS action,
|
||||
COUNT(*) AS count
|
||||
FROM inserted_new;
|
||||
|
||||
-- Проверка результата
|
||||
SELECT
|
||||
ccd.claim_id,
|
||||
ccd.field_name,
|
||||
ccd.file_id,
|
||||
ccd.file_name,
|
||||
ccd.original_file_name,
|
||||
ccd.uploaded_at
|
||||
FROM clpr_claim_documents ccd
|
||||
WHERE ccd.claim_id = 'bddb6815-8e17-4d54-a721-5e94382942c7'
|
||||
ORDER BY ccd.field_name;
|
||||
|
||||
41
docs/SQL_FIX_DOCUMENT_ID_RETURN.sql
Normal file
41
docs/SQL_FIX_DOCUMENT_ID_RETURN.sql
Normal 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
|
||||
|
||||
79
docs/SQL_FIX_DRAFT_BDDB6815.sql
Normal file
79
docs/SQL_FIX_DRAFT_BDDB6815.sql
Normal file
@@ -0,0 +1,79 @@
|
||||
-- ============================================================================
|
||||
-- SQL для исправления черновика bddb6815-8e17-4d54-a721-5e94382942c7
|
||||
-- ============================================================================
|
||||
-- Проблема: У черновика нет documents_required и неправильный статус
|
||||
-- Решение: Добавляем documents_required и устанавливаем правильный статус
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE clpr_claims
|
||||
SET
|
||||
status_code = CASE
|
||||
-- Если документы уже загружены - ставим draft_docs_progress или draft_docs_complete
|
||||
WHEN jsonb_array_length(COALESCE(payload->'documents_uploaded', '[]'::jsonb)) > 0
|
||||
THEN CASE
|
||||
WHEN jsonb_array_length(COALESCE(payload->'documents_uploaded', '[]'::jsonb)) >= 4
|
||||
THEN 'draft_docs_complete'
|
||||
ELSE 'draft_docs_progress'
|
||||
END
|
||||
-- Если документов нет - ставим draft_new
|
||||
ELSE 'draft_new'
|
||||
END,
|
||||
|
||||
-- Добавляем documents_required в payload
|
||||
payload = jsonb_set(
|
||||
COALESCE(payload, '{}'::jsonb),
|
||||
'{documents_required}',
|
||||
'[
|
||||
{
|
||||
"id": "contract",
|
||||
"name": "Договор или заказ",
|
||||
"hints": "Фото или скан подписанного договора или квитанции",
|
||||
"accept": ["pdf", "jpg", "png"],
|
||||
"priority": 1,
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "payment",
|
||||
"name": "Чек или подтверждение оплаты",
|
||||
"hints": "Копия кассового чека, онлайн-платежа или квитанции",
|
||||
"accept": ["pdf", "jpg", "png"],
|
||||
"priority": 1,
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"id": "correspondence",
|
||||
"name": "Переписка",
|
||||
"hints": "Скриншоты сообщений, писем, жалоб",
|
||||
"accept": ["pdf", "jpg", "png"],
|
||||
"priority": 2,
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"id": "evidence_photo",
|
||||
"name": "Фото доказательства",
|
||||
"hints": "Фото дефектов товара, видео процесса ремонта или передачи",
|
||||
"accept": ["jpg", "png", "pdf"],
|
||||
"priority": 2,
|
||||
"required": false
|
||||
}
|
||||
]'::jsonb,
|
||||
true
|
||||
),
|
||||
|
||||
updated_at = now()
|
||||
|
||||
WHERE id::text = 'bddb6815-8e17-4d54-a721-5e94382942c7'
|
||||
OR payload->>'claim_id' = 'bddb6815-8e17-4d54-a721-5e94382942c7';
|
||||
|
||||
-- Проверяем результат
|
||||
SELECT
|
||||
id::text,
|
||||
status_code,
|
||||
payload->>'claim_id' as claim_id,
|
||||
jsonb_array_length(COALESCE(payload->'documents_required', '[]'::jsonb)) as docs_required_count,
|
||||
jsonb_array_length(COALESCE(payload->'documents_uploaded', '[]'::jsonb)) as docs_uploaded_count,
|
||||
payload->'documents_required'->0->>'name' as first_doc_name
|
||||
FROM clpr_claims
|
||||
WHERE id::text = 'bddb6815-8e17-4d54-a721-5e94382942c7'
|
||||
OR payload->>'claim_id' = 'bddb6815-8e17-4d54-a721-5e94382942c7';
|
||||
|
||||
146
docs/SQL_GET_CONTACT_DATA_FROM_CRM.sql
Normal file
146
docs/SQL_GET_CONTACT_DATA_FROM_CRM.sql
Normal 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;
|
||||
|
||||
127
docs/SQL_GET_DOCUMENT_BY_ID.sql
Normal file
127
docs/SQL_GET_DOCUMENT_BY_ID.sql
Normal 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; -- ✅ Возвращаем только один документ
|
||||
|
||||
56
docs/SQL_MARK_FORM_APPROVED.sql
Normal file
56
docs/SQL_MARK_FORM_APPROVED.sql
Normal 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;
|
||||
345
docs/SQL_SAVE_DRAFT_NEW_FLOW.sql
Normal file
345
docs/SQL_SAVE_DRAFT_NEW_FLOW.sql
Normal file
@@ -0,0 +1,345 @@
|
||||
-- ============================================================================
|
||||
-- SQL запрос для n8n: Сохранение черновика (НОВЫЙ ФЛОУ с документами)
|
||||
-- ============================================================================
|
||||
-- Назначение: Сохранить черновик сразу после анализа описания проблемы
|
||||
-- AI Agent возвращает facts + docs (список документов)
|
||||
--
|
||||
-- Вход от AI Agent:
|
||||
-- output: { facts_short, facts_full, problem, recommendation, docs: [...] }
|
||||
-- propertyName: { session_id, phone, unified_id, contact_id, ФИО и т.д. }
|
||||
--
|
||||
-- Параметры:
|
||||
-- $1 = payload_json (jsonb) - полный payload с output и propertyName
|
||||
-- $2 = session_token (text) - сессия пользователя (из propertyName.session_id)
|
||||
-- $3 = unified_id (text) - unified_id пользователя
|
||||
-- $4 = problem_description (text) - исходное описание проблемы от пользователя
|
||||
--
|
||||
-- Возвращает:
|
||||
-- claim - объект с claim_id, session_token, status_code, documents_required
|
||||
-- ============================================================================
|
||||
|
||||
WITH input_data AS (
|
||||
SELECT
|
||||
$1::jsonb AS payload,
|
||||
$2::text AS session_token_str,
|
||||
NULLIF($3::text, '') AS unified_id_str,
|
||||
NULLIF($4::text, '') AS problem_desc
|
||||
),
|
||||
|
||||
-- Извлекаем данные из payload
|
||||
parsed_data AS (
|
||||
SELECT
|
||||
input_data.*,
|
||||
input_data.payload->'output' AS ai_output,
|
||||
input_data.payload->'propertyName' AS user_data,
|
||||
input_data.payload->'output'->'docs' AS documents_required
|
||||
FROM input_data
|
||||
),
|
||||
|
||||
-- Проверяем существующий черновик по session_token
|
||||
existing_claim AS (
|
||||
SELECT id, payload
|
||||
FROM clpr_claims
|
||||
WHERE session_token = (SELECT session_token_str FROM input_data)
|
||||
LIMIT 1
|
||||
),
|
||||
|
||||
-- Генерируем или используем существующий UUID
|
||||
claim_id_resolved AS (
|
||||
SELECT
|
||||
COALESCE(
|
||||
(SELECT id FROM existing_claim),
|
||||
gen_random_uuid()
|
||||
) AS claim_uuid
|
||||
),
|
||||
|
||||
-- INSERT или UPDATE черновика
|
||||
upserted_claim AS (
|
||||
INSERT INTO clpr_claims (
|
||||
id,
|
||||
session_token,
|
||||
unified_id,
|
||||
channel,
|
||||
type_code,
|
||||
status_code,
|
||||
payload,
|
||||
created_at,
|
||||
updated_at,
|
||||
expires_at
|
||||
)
|
||||
SELECT
|
||||
claim_id_resolved.claim_uuid,
|
||||
parsed_data.session_token_str,
|
||||
COALESCE(parsed_data.unified_id_str, parsed_data.user_data->>'unified_id'),
|
||||
'web_form',
|
||||
'consumer',
|
||||
'draft_new', -- ✅ Новый статус: только описание + документы
|
||||
jsonb_build_object(
|
||||
'claim_id', claim_id_resolved.claim_uuid::text,
|
||||
'problem_description', COALESCE(parsed_data.problem_desc, parsed_data.user_data->>'problem_description'),
|
||||
|
||||
-- AI анализ
|
||||
'ai_analysis', jsonb_build_object(
|
||||
'facts_short', parsed_data.ai_output->>'facts_short',
|
||||
'facts_full', parsed_data.ai_output->>'facts_full',
|
||||
'problem', parsed_data.ai_output->>'problem',
|
||||
'recommendation', parsed_data.ai_output->>'recommendation'
|
||||
),
|
||||
|
||||
-- ✅ Список необходимых документов (новое!)
|
||||
'documents_required', COALESCE(parsed_data.documents_required, '[]'::jsonb),
|
||||
'documents_uploaded', '[]'::jsonb,
|
||||
'documents_skipped', '[]'::jsonb,
|
||||
'current_doc_index', 0,
|
||||
|
||||
-- Данные пользователя
|
||||
'phone', COALESCE(parsed_data.user_data->>'phone', ''),
|
||||
'email', COALESCE(parsed_data.user_data->>'email', ''),
|
||||
'contact_id', parsed_data.user_data->>'contact_id',
|
||||
|
||||
-- ФИО и паспортные данные (для заявления)
|
||||
'applicant', jsonb_build_object(
|
||||
'lastname', parsed_data.user_data->>'lastname',
|
||||
'firstname', parsed_data.user_data->>'firstname',
|
||||
'middle_name', parsed_data.user_data->>'middle_name',
|
||||
'birthday', parsed_data.user_data->>'birthday',
|
||||
'birthplace', parsed_data.user_data->>'birthplace',
|
||||
'inn', parsed_data.user_data->>'inn',
|
||||
'address', parsed_data.user_data->>'mailingstreet',
|
||||
'zip', parsed_data.user_data->>'mailingzip'
|
||||
),
|
||||
|
||||
-- Telegram ID если есть
|
||||
'tg_id', parsed_data.user_data->>'tg_id',
|
||||
|
||||
-- Флаги готовности
|
||||
'wizard_ready', false,
|
||||
'claim_ready', false
|
||||
),
|
||||
now(),
|
||||
now(),
|
||||
now() + interval '14 days'
|
||||
FROM parsed_data, claim_id_resolved
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
unified_id = COALESCE(EXCLUDED.unified_id, clpr_claims.unified_id),
|
||||
status_code = 'draft_new',
|
||||
payload = clpr_claims.payload || EXCLUDED.payload,
|
||||
updated_at = now(),
|
||||
expires_at = now() + interval '14 days'
|
||||
RETURNING id, session_token, status_code, payload
|
||||
)
|
||||
|
||||
-- Возвращаем результат для n8n
|
||||
SELECT
|
||||
jsonb_build_object(
|
||||
'claim_id', upserted_claim.id::text,
|
||||
'session_token', upserted_claim.session_token,
|
||||
'status_code', upserted_claim.status_code,
|
||||
'documents_required', upserted_claim.payload->'documents_required',
|
||||
'documents_count', jsonb_array_length(COALESCE(upserted_claim.payload->'documents_required', '[]'::jsonb))
|
||||
) AS claim
|
||||
FROM upserted_claim;
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- Пример вызова в n8n (PostgreSQL Node):
|
||||
-- ============================================================================
|
||||
--
|
||||
-- Параметры:
|
||||
-- $1 = {{ JSON.stringify($json) }} -- Весь payload от AI Agent
|
||||
-- $2 = {{ $json.propertyName.session_id }} -- session_token
|
||||
-- $3 = {{ $json.propertyName.unified_id }} -- unified_id
|
||||
-- $4 = {{ $node["Redis Trigger"].json.description }} -- Исходное описание проблемы
|
||||
--
|
||||
-- После выполнения SQL, в Code Node пушим в Redis:
|
||||
--
|
||||
-- const result = $input.first().json.claim;
|
||||
--
|
||||
-- return {
|
||||
-- json: {
|
||||
-- channel: `ocr_events:${result.session_token}`,
|
||||
-- event: {
|
||||
-- event_type: 'documents_list_ready',
|
||||
-- claim_id: result.claim_id,
|
||||
-- session_id: result.session_token,
|
||||
-- documents_required: result.documents_required,
|
||||
-- documents_count: result.documents_count,
|
||||
-- timestamp: new Date().toISOString()
|
||||
-- }
|
||||
-- }
|
||||
-- };
|
||||
-- ============================================================================
|
||||
|
||||
|
||||
-- SQL запрос для n8n: Сохранение черновика (НОВЫЙ ФЛОУ с документами)
|
||||
-- ============================================================================
|
||||
-- Назначение: Сохранить черновик сразу после анализа описания проблемы
|
||||
-- AI Agent возвращает facts + docs (список документов)
|
||||
--
|
||||
-- Вход от AI Agent:
|
||||
-- output: { facts_short, facts_full, problem, recommendation, docs: [...] }
|
||||
-- propertyName: { session_id, phone, unified_id, contact_id, ФИО и т.д. }
|
||||
--
|
||||
-- Параметры:
|
||||
-- $1 = payload_json (jsonb) - полный payload с output и propertyName
|
||||
-- $2 = session_token (text) - сессия пользователя (из propertyName.session_id)
|
||||
-- $3 = unified_id (text) - unified_id пользователя
|
||||
-- $4 = problem_description (text) - исходное описание проблемы от пользователя
|
||||
--
|
||||
-- Возвращает:
|
||||
-- claim - объект с claim_id, session_token, status_code, documents_required
|
||||
-- ============================================================================
|
||||
|
||||
WITH input_data AS (
|
||||
SELECT
|
||||
$1::jsonb AS payload,
|
||||
$2::text AS session_token_str,
|
||||
NULLIF($3::text, '') AS unified_id_str,
|
||||
NULLIF($4::text, '') AS problem_desc
|
||||
),
|
||||
|
||||
-- Извлекаем данные из payload
|
||||
parsed_data AS (
|
||||
SELECT
|
||||
input_data.*,
|
||||
input_data.payload->'output' AS ai_output,
|
||||
input_data.payload->'propertyName' AS user_data,
|
||||
input_data.payload->'output'->'docs' AS documents_required
|
||||
FROM input_data
|
||||
),
|
||||
|
||||
-- Проверяем существующий черновик по session_token
|
||||
existing_claim AS (
|
||||
SELECT id, payload
|
||||
FROM clpr_claims
|
||||
WHERE session_token = (SELECT session_token_str FROM input_data)
|
||||
LIMIT 1
|
||||
),
|
||||
|
||||
-- Генерируем или используем существующий UUID
|
||||
claim_id_resolved AS (
|
||||
SELECT
|
||||
COALESCE(
|
||||
(SELECT id FROM existing_claim),
|
||||
gen_random_uuid()
|
||||
) AS claim_uuid
|
||||
),
|
||||
|
||||
-- INSERT или UPDATE черновика
|
||||
upserted_claim AS (
|
||||
INSERT INTO clpr_claims (
|
||||
id,
|
||||
session_token,
|
||||
unified_id,
|
||||
channel,
|
||||
type_code,
|
||||
status_code,
|
||||
payload,
|
||||
created_at,
|
||||
updated_at,
|
||||
expires_at
|
||||
)
|
||||
SELECT
|
||||
claim_id_resolved.claim_uuid,
|
||||
parsed_data.session_token_str,
|
||||
COALESCE(parsed_data.unified_id_str, parsed_data.user_data->>'unified_id'),
|
||||
'web_form',
|
||||
'consumer',
|
||||
'draft_new', -- ✅ Новый статус: только описание + документы
|
||||
jsonb_build_object(
|
||||
'claim_id', claim_id_resolved.claim_uuid::text,
|
||||
'problem_description', COALESCE(parsed_data.problem_desc, parsed_data.user_data->>'problem_description'),
|
||||
|
||||
-- AI анализ
|
||||
'ai_analysis', jsonb_build_object(
|
||||
'facts_short', parsed_data.ai_output->>'facts_short',
|
||||
'facts_full', parsed_data.ai_output->>'facts_full',
|
||||
'problem', parsed_data.ai_output->>'problem',
|
||||
'recommendation', parsed_data.ai_output->>'recommendation'
|
||||
),
|
||||
|
||||
-- ✅ Список необходимых документов (новое!)
|
||||
'documents_required', COALESCE(parsed_data.documents_required, '[]'::jsonb),
|
||||
'documents_uploaded', '[]'::jsonb,
|
||||
'documents_skipped', '[]'::jsonb,
|
||||
'current_doc_index', 0,
|
||||
|
||||
-- Данные пользователя
|
||||
'phone', COALESCE(parsed_data.user_data->>'phone', ''),
|
||||
'email', COALESCE(parsed_data.user_data->>'email', ''),
|
||||
'contact_id', parsed_data.user_data->>'contact_id',
|
||||
|
||||
-- ФИО и паспортные данные (для заявления)
|
||||
'applicant', jsonb_build_object(
|
||||
'lastname', parsed_data.user_data->>'lastname',
|
||||
'firstname', parsed_data.user_data->>'firstname',
|
||||
'middle_name', parsed_data.user_data->>'middle_name',
|
||||
'birthday', parsed_data.user_data->>'birthday',
|
||||
'birthplace', parsed_data.user_data->>'birthplace',
|
||||
'inn', parsed_data.user_data->>'inn',
|
||||
'address', parsed_data.user_data->>'mailingstreet',
|
||||
'zip', parsed_data.user_data->>'mailingzip'
|
||||
),
|
||||
|
||||
-- Telegram ID если есть
|
||||
'tg_id', parsed_data.user_data->>'tg_id',
|
||||
|
||||
-- Флаги готовности
|
||||
'wizard_ready', false,
|
||||
'claim_ready', false
|
||||
),
|
||||
now(),
|
||||
now(),
|
||||
now() + interval '14 days'
|
||||
FROM parsed_data, claim_id_resolved
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
unified_id = COALESCE(EXCLUDED.unified_id, clpr_claims.unified_id),
|
||||
status_code = 'draft_new',
|
||||
payload = clpr_claims.payload || EXCLUDED.payload,
|
||||
updated_at = now(),
|
||||
expires_at = now() + interval '14 days'
|
||||
RETURNING id, session_token, status_code, payload
|
||||
)
|
||||
|
||||
-- Возвращаем результат для n8n
|
||||
SELECT
|
||||
jsonb_build_object(
|
||||
'claim_id', upserted_claim.id::text,
|
||||
'session_token', upserted_claim.session_token,
|
||||
'status_code', upserted_claim.status_code,
|
||||
'documents_required', upserted_claim.payload->'documents_required',
|
||||
'documents_count', jsonb_array_length(COALESCE(upserted_claim.payload->'documents_required', '[]'::jsonb))
|
||||
) AS claim
|
||||
FROM upserted_claim;
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- Пример вызова в n8n (PostgreSQL Node):
|
||||
-- ============================================================================
|
||||
--
|
||||
-- Параметры:
|
||||
-- $1 = {{ JSON.stringify($json) }} -- Весь payload от AI Agent
|
||||
-- $2 = {{ $json.propertyName.session_id }} -- session_token
|
||||
-- $3 = {{ $json.propertyName.unified_id }} -- unified_id
|
||||
-- $4 = {{ $node["Redis Trigger"].json.description }} -- Исходное описание проблемы
|
||||
--
|
||||
-- После выполнения SQL, в Code Node пушим в Redis:
|
||||
--
|
||||
-- const result = $input.first().json.claim;
|
||||
--
|
||||
-- return {
|
||||
-- json: {
|
||||
-- channel: `ocr_events:${result.session_token}`,
|
||||
-- event: {
|
||||
-- event_type: 'documents_list_ready',
|
||||
-- claim_id: result.claim_id,
|
||||
-- session_id: result.session_token,
|
||||
-- documents_required: result.documents_required,
|
||||
-- documents_count: result.documents_count,
|
||||
-- timestamp: new Date().toISOString()
|
||||
-- }
|
||||
-- }
|
||||
-- };
|
||||
-- ============================================================================
|
||||
|
||||
|
||||
31
docs/SQL_SELECT_CONTACT_WITH_CUSTOM_FIELDS.sql
Normal file
31
docs/SQL_SELECT_CONTACT_WITH_CUSTOM_FIELDS.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- Правильный SQL запрос для получения всех данных контакта с кастомными полями
|
||||
-- Исправлено: birthday в vtiger_contactsubdetails, mailingstreet в vtiger_contactaddress
|
||||
|
||||
SELECT
|
||||
cd.contactid,
|
||||
cd.firstname,
|
||||
cd.lastname,
|
||||
cd.email,
|
||||
cd.mobile,
|
||||
cd.phone,
|
||||
cs.birthday, -- ✅ Из vtiger_contactsubdetails
|
||||
ca.mailingstreet, -- ✅ Из vtiger_contactaddress
|
||||
ca.mailingcity,
|
||||
ca.mailingstate,
|
||||
ca.mailingzip,
|
||||
ca.mailingcountry,
|
||||
-- Кастомные поля из vtiger_contactscf:
|
||||
ccf.cf_1157 AS middle_name, -- Отчество
|
||||
ccf.cf_1263 AS birthplace, -- Место рождения
|
||||
ccf.cf_1257 AS inn, -- ИНН
|
||||
ccf.cf_1849 AS requisites, -- Реквизиты
|
||||
ccf.cf_1580 AS code, -- Код
|
||||
ccf.cf_1706 AS sms -- SMS
|
||||
FROM vtiger_contactdetails cd
|
||||
LEFT JOIN vtiger_contactscf ccf ON ccf.contactid = cd.contactid
|
||||
LEFT JOIN vtiger_contactsubdetails cs ON cs.contactsubscriptionid = cd.contactid
|
||||
LEFT JOIN vtiger_contactaddress ca ON ca.contactaddressid = cd.contactid
|
||||
LEFT JOIN vtiger_crmentity ce ON ce.crmid = cd.contactid
|
||||
WHERE cd.contactid = {{ $json.contact_id }}
|
||||
AND ce.deleted = 0
|
||||
|
||||
91
docs/SQL_UPDATE_DOCUMENT_DESCRIPTION.sql
Normal file
91
docs/SQL_UPDATE_DOCUMENT_DESCRIPTION.sql
Normal 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;
|
||||
|
||||
86
docs/SQL_UPDATE_DOCUMENT_DESCRIPTION_SIMPLE.sql
Normal file
86
docs/SQL_UPDATE_DOCUMENT_DESCRIPTION_SIMPLE.sql
Normal 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;
|
||||
|
||||
21
docs/SQL_UPDATE_DOCUMENT_HASH.sql
Normal file
21
docs/SQL_UPDATE_DOCUMENT_HASH.sql
Normal 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;
|
||||
|
||||
|
||||
64
docs/migrations/001_add_ocr_status.sql
Normal file
64
docs/migrations/001_add_ocr_status.sql
Normal 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 $$;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
72
docs/migrations/002_add_document_match.sql
Normal file
72
docs/migrations/002_add_document_match.sql
Normal 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 $$;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
27
docs/n8n_code_error_response.js
Normal file
27
docs/n8n_code_error_response.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// Code23 — помещаем в n8n-nodes-base.code (JS), Mode = Run Once for All Items
|
||||
|
||||
// Берём все входные элементы
|
||||
const items = $input.all();
|
||||
|
||||
// Предполагаем, что нас интересует первый элемент массива
|
||||
const data = items[0].json;
|
||||
|
||||
// Всегда возвращаем сообщение об ошибке
|
||||
const answerText = 'Извините, произошла ошибка, мы уже работаем над ее устранением, попробуйте задать ваш вопрос еще раз через некоторое время';
|
||||
|
||||
// Собираем единый объект для следующего узла
|
||||
return [
|
||||
{
|
||||
json: {
|
||||
...data,
|
||||
respound: {
|
||||
type: 'text',
|
||||
text: answerText,
|
||||
replyMarkup: {
|
||||
remove_keyboard: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
104
docs/n8n_nodes/README_SETUP.md
Normal file
104
docs/n8n_nodes/README_SETUP.md
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
37
docs/n8n_nodes/check_all_ready.json
Normal file
37
docs/n8n_nodes/check_all_ready.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
25
docs/n8n_nodes/publish_docs_ready.json
Normal file
25
docs/n8n_nodes/publish_docs_ready.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
24
docs/n8n_nodes/redis_incr_ready.json
Normal file
24
docs/n8n_nodes/redis_incr_ready.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
26
docs/n8n_nodes/update_ocr_error.json
Normal file
26
docs/n8n_nodes/update_ocr_error.json
Normal 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"
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
25
docs/n8n_nodes/update_ocr_status.json
Normal file
25
docs/n8n_nodes/update_ocr_status.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
154
fix_claim_documents_field_names.py
Normal file
154
fix_claim_documents_field_names.py
Normal file
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Исправление field_name в таблице clpr_claim_documents
|
||||
Пересоздаёт записи с правильными field_name на основе documents_uploaded и documents_required
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
POSTGRES_HOST = "147.45.189.234"
|
||||
POSTGRES_PORT = 5432
|
||||
POSTGRES_DB = "default_db"
|
||||
POSTGRES_USER = "gen_user"
|
||||
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
|
||||
|
||||
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
|
||||
|
||||
async def fix_field_names():
|
||||
conn = await asyncpg.connect(
|
||||
host=POSTGRES_HOST,
|
||||
port=POSTGRES_PORT,
|
||||
database=POSTGRES_DB,
|
||||
user=POSTGRES_USER,
|
||||
password=POSTGRES_PASSWORD
|
||||
)
|
||||
|
||||
try:
|
||||
# Получаем данные черновика
|
||||
row = await conn.fetchrow("""
|
||||
SELECT id, payload
|
||||
FROM clpr_claims
|
||||
WHERE id::text = $1 OR payload->>'claim_id' = $1
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""", CLAIM_ID)
|
||||
|
||||
if not row:
|
||||
print(f"❌ Черновик {CLAIM_ID} не найден!")
|
||||
return
|
||||
|
||||
claim_uuid = row['id']
|
||||
payload = row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload'])
|
||||
|
||||
documents_required = payload.get('documents_required', [])
|
||||
documents_uploaded = payload.get('documents_uploaded', [])
|
||||
|
||||
print(f"📋 documents_required: {len(documents_required)} документов")
|
||||
print(f"📋 documents_uploaded: {len(documents_uploaded)} документов")
|
||||
|
||||
# Создаём мапу: doc_id -> group_index
|
||||
doc_id_to_index = {}
|
||||
for idx, doc_req in enumerate(documents_required):
|
||||
doc_id = doc_req.get('id')
|
||||
if doc_id:
|
||||
doc_id_to_index[doc_id] = idx
|
||||
|
||||
print(f"\n📋 Маппинг документов:")
|
||||
for doc_id, idx in doc_id_to_index.items():
|
||||
print(f" {doc_id} -> group_index {idx}")
|
||||
|
||||
# Удаляем старые записи
|
||||
deleted_count = await conn.execute("""
|
||||
DELETE FROM clpr_claim_documents
|
||||
WHERE claim_id = $1
|
||||
""", str(claim_uuid))
|
||||
|
||||
print(f"\n🗑️ Удалено старых записей: {deleted_count.split()[-1]}")
|
||||
|
||||
# Вставляем новые записи с правильными field_name
|
||||
inserted_count = 0
|
||||
for doc_up in documents_uploaded:
|
||||
doc_type = doc_up.get('type') or doc_up.get('id')
|
||||
file_id = doc_up.get('file_id')
|
||||
|
||||
if not doc_type or not file_id:
|
||||
print(f" ⚠️ Пропущен документ без type/id или file_id: {doc_up}")
|
||||
continue
|
||||
|
||||
group_index = doc_id_to_index.get(doc_type)
|
||||
if group_index is None:
|
||||
print(f" ⚠️ Не найден group_index для типа {doc_type}")
|
||||
continue
|
||||
|
||||
field_name = f"uploads[{group_index}][0]"
|
||||
|
||||
# Парсим uploaded_at
|
||||
uploaded_at_str = doc_up.get('uploaded_at')
|
||||
uploaded_at = None
|
||||
if uploaded_at_str:
|
||||
try:
|
||||
# Пробуем разные форматы даты
|
||||
if isinstance(uploaded_at_str, str):
|
||||
if 'T' in uploaded_at_str:
|
||||
uploaded_at = datetime.fromisoformat(uploaded_at_str.replace('Z', '+00:00'))
|
||||
else:
|
||||
uploaded_at = datetime.fromisoformat(uploaded_at_str)
|
||||
elif isinstance(uploaded_at_str, datetime):
|
||||
uploaded_at = uploaded_at_str
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Ошибка парсинга даты {uploaded_at_str}: {e}")
|
||||
uploaded_at = None
|
||||
|
||||
await conn.execute("""
|
||||
INSERT INTO clpr_claim_documents (
|
||||
claim_id,
|
||||
field_name,
|
||||
file_id,
|
||||
file_name,
|
||||
original_file_name,
|
||||
uploaded_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (claim_id, field_name) DO UPDATE SET
|
||||
file_id = EXCLUDED.file_id,
|
||||
file_name = EXCLUDED.file_name,
|
||||
original_file_name = EXCLUDED.original_file_name,
|
||||
uploaded_at = EXCLUDED.uploaded_at
|
||||
""",
|
||||
str(claim_uuid),
|
||||
field_name,
|
||||
file_id,
|
||||
doc_up.get('file_name', ''),
|
||||
doc_up.get('original_file_name', ''),
|
||||
uploaded_at
|
||||
)
|
||||
|
||||
inserted_count += 1
|
||||
print(f" ✅ Вставлен: {field_name} -> {doc_type} ({file_id[:50]}...)")
|
||||
|
||||
print(f"\n✅ Вставлено новых записей: {inserted_count}")
|
||||
|
||||
# Проверяем результат
|
||||
result_rows = await conn.fetch("""
|
||||
SELECT
|
||||
field_name,
|
||||
file_id,
|
||||
file_name,
|
||||
original_file_name
|
||||
FROM clpr_claim_documents
|
||||
WHERE claim_id = $1
|
||||
ORDER BY field_name
|
||||
""", str(claim_uuid))
|
||||
|
||||
print(f"\n📊 Результат в таблице ({len(result_rows)} записей):")
|
||||
for row in result_rows:
|
||||
print(f" {row['field_name']}: {row['file_name']} ({row['file_id'][:50]}...)")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(fix_field_names())
|
||||
|
||||
87
fix_documents_meta_duplicates.py
Normal file
87
fix_documents_meta_duplicates.py
Normal file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Очистка дубликатов в documents_meta
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import json
|
||||
|
||||
POSTGRES_HOST = "147.45.189.234"
|
||||
POSTGRES_PORT = 5432
|
||||
POSTGRES_DB = "default_db"
|
||||
POSTGRES_USER = "gen_user"
|
||||
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
|
||||
|
||||
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
|
||||
|
||||
async def fix_duplicates():
|
||||
conn = await asyncpg.connect(
|
||||
host=POSTGRES_HOST,
|
||||
port=POSTGRES_PORT,
|
||||
database=POSTGRES_DB,
|
||||
user=POSTGRES_USER,
|
||||
password=POSTGRES_PASSWORD
|
||||
)
|
||||
|
||||
try:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT id, payload
|
||||
FROM clpr_claims
|
||||
WHERE id::text = $1 OR payload->>'claim_id' = $1
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""", CLAIM_ID)
|
||||
|
||||
if not row:
|
||||
print(f"❌ Черновик {CLAIM_ID} не найден!")
|
||||
return
|
||||
|
||||
payload = row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload'])
|
||||
documents_meta = payload.get('documents_meta', [])
|
||||
|
||||
print(f"📋 Было документов в documents_meta: {len(documents_meta)}")
|
||||
|
||||
# Убираем дубликаты по file_id (оставляем первый)
|
||||
seen_file_ids = set()
|
||||
unique_documents_meta = []
|
||||
|
||||
for doc in documents_meta:
|
||||
file_id = doc.get('file_id')
|
||||
if file_id and file_id not in seen_file_ids:
|
||||
seen_file_ids.add(file_id)
|
||||
unique_documents_meta.append(doc)
|
||||
elif file_id:
|
||||
print(f" ⚠️ Пропущен дубликат: {file_id[:80]}...")
|
||||
|
||||
print(f"📋 Стало документов в documents_meta: {len(unique_documents_meta)}")
|
||||
|
||||
# Обновляем payload
|
||||
payload['documents_meta'] = unique_documents_meta
|
||||
|
||||
await conn.execute("""
|
||||
UPDATE clpr_claims
|
||||
SET
|
||||
payload = $1::jsonb,
|
||||
updated_at = now()
|
||||
WHERE id::text = $2 OR payload->>'claim_id' = $2
|
||||
""", json.dumps(payload, ensure_ascii=False), CLAIM_ID)
|
||||
|
||||
print(f"\n✅ Дубликаты удалены!")
|
||||
|
||||
# Проверяем результат
|
||||
row_after = await conn.fetchrow("""
|
||||
SELECT jsonb_array_length(payload->'documents_meta') as docs_count
|
||||
FROM clpr_claims
|
||||
WHERE id::text = $1 OR payload->>'claim_id' = $1
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""", CLAIM_ID)
|
||||
|
||||
print(f"📊 Результат: {row_after['docs_count']} документов в documents_meta")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(fix_duplicates())
|
||||
|
||||
136
fix_draft_bddb6815.py
Normal file
136
fix_draft_bddb6815.py
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Скрипт для исправления черновика bddb6815-8e17-4d54-a721-5e94382942c7
|
||||
Добавляет documents_required и исправляет статус
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Параметры подключения к БД (из config.py)
|
||||
POSTGRES_HOST = "147.45.189.234"
|
||||
POSTGRES_PORT = 5432
|
||||
POSTGRES_DB = "default_db"
|
||||
POSTGRES_USER = "gen_user"
|
||||
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
|
||||
|
||||
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
|
||||
|
||||
DOCUMENTS_REQUIRED = [
|
||||
{
|
||||
"id": "contract",
|
||||
"name": "Договор или заказ",
|
||||
"hints": "Фото или скан подписанного договора или квитанции",
|
||||
"accept": ["pdf", "jpg", "png"],
|
||||
"priority": 1,
|
||||
"required": True
|
||||
},
|
||||
{
|
||||
"id": "payment",
|
||||
"name": "Чек или подтверждение оплаты",
|
||||
"hints": "Копия кассового чека, онлайн-платежа или квитанции",
|
||||
"accept": ["pdf", "jpg", "png"],
|
||||
"priority": 1,
|
||||
"required": True
|
||||
},
|
||||
{
|
||||
"id": "correspondence",
|
||||
"name": "Переписка",
|
||||
"hints": "Скриншоты сообщений, писем, жалоб",
|
||||
"accept": ["pdf", "jpg", "png"],
|
||||
"priority": 2,
|
||||
"required": False
|
||||
},
|
||||
{
|
||||
"id": "evidence_photo",
|
||||
"name": "Фото доказательства",
|
||||
"hints": "Фото дефектов товара, видео процесса ремонта или передачи",
|
||||
"accept": ["jpg", "png", "pdf"],
|
||||
"priority": 2,
|
||||
"required": False
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def fix_draft():
|
||||
"""Исправляет черновик: добавляет documents_required и обновляет статус"""
|
||||
conn = await asyncpg.connect(
|
||||
host=POSTGRES_HOST,
|
||||
port=POSTGRES_PORT,
|
||||
database=POSTGRES_DB,
|
||||
user=POSTGRES_USER,
|
||||
password=POSTGRES_PASSWORD
|
||||
)
|
||||
|
||||
try:
|
||||
# Получаем текущее состояние черновика
|
||||
row = await conn.fetchrow("""
|
||||
SELECT id, status_code, payload
|
||||
FROM clpr_claims
|
||||
WHERE id::text = $1 OR payload->>'claim_id' = $1
|
||||
LIMIT 1
|
||||
""", CLAIM_ID)
|
||||
|
||||
if not row:
|
||||
print(f"❌ Черновик {CLAIM_ID} не найден!")
|
||||
return
|
||||
|
||||
payload = row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload'])
|
||||
current_status = row['status_code']
|
||||
documents_uploaded = payload.get('documents_uploaded', [])
|
||||
uploaded_count = len(documents_uploaded) if isinstance(documents_uploaded, list) else 0
|
||||
|
||||
print(f"📋 Текущее состояние черновика:")
|
||||
print(f" - status_code: {current_status}")
|
||||
print(f" - documents_required: {len(payload.get('documents_required', []))} шт.")
|
||||
print(f" - documents_uploaded: {uploaded_count} шт.")
|
||||
|
||||
# Определяем новый статус
|
||||
if uploaded_count > 0:
|
||||
if uploaded_count >= len(DOCUMENTS_REQUIRED):
|
||||
new_status = 'draft_docs_complete'
|
||||
else:
|
||||
new_status = 'draft_docs_progress'
|
||||
else:
|
||||
new_status = 'draft_new'
|
||||
|
||||
# Обновляем payload
|
||||
payload['documents_required'] = DOCUMENTS_REQUIRED
|
||||
|
||||
# Обновляем черновик
|
||||
await conn.execute("""
|
||||
UPDATE clpr_claims
|
||||
SET
|
||||
status_code = $1,
|
||||
payload = $2::jsonb,
|
||||
updated_at = now()
|
||||
WHERE id::text = $3 OR payload->>'claim_id' = $3
|
||||
""", new_status, json.dumps(payload, ensure_ascii=False), CLAIM_ID)
|
||||
|
||||
print(f"\n✅ Черновик исправлен!")
|
||||
print(f" - Новый status_code: {new_status}")
|
||||
print(f" - documents_required: {len(DOCUMENTS_REQUIRED)} документов добавлено")
|
||||
|
||||
# Проверяем результат
|
||||
row_after = await conn.fetchrow("""
|
||||
SELECT
|
||||
id::text,
|
||||
status_code,
|
||||
jsonb_array_length(COALESCE(payload->'documents_required', '[]'::jsonb)) as docs_count
|
||||
FROM clpr_claims
|
||||
WHERE id::text = $1 OR payload->>'claim_id' = $1
|
||||
LIMIT 1
|
||||
""", CLAIM_ID)
|
||||
|
||||
print(f"\n📊 Результат:")
|
||||
print(f" - status_code: {row_after['status_code']}")
|
||||
print(f" - documents_required count: {row_after['docs_count']}")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(fix_draft())
|
||||
|
||||
261
fix_draft_bddb6815_with_contract.py
Normal file
261
fix_draft_bddb6815_with_contract.py
Normal file
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Скрипт для исправления черновика bddb6815-8e17-4d54-a721-5e94382942c7
|
||||
Добавляет documents_required и обновляет статус с учётом уже загруженного договора
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
# Параметры подключения к БД
|
||||
POSTGRES_HOST = "147.45.189.234"
|
||||
POSTGRES_PORT = 5432
|
||||
POSTGRES_DB = "default_db"
|
||||
POSTGRES_USER = "gen_user"
|
||||
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
|
||||
|
||||
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
|
||||
|
||||
DOCUMENTS_REQUIRED = [
|
||||
{
|
||||
"id": "contract",
|
||||
"name": "Договор или заказ",
|
||||
"hints": "Фото или скан подписанного договора или квитанции",
|
||||
"accept": ["pdf", "jpg", "png"],
|
||||
"priority": 1,
|
||||
"required": True
|
||||
},
|
||||
{
|
||||
"id": "payment",
|
||||
"name": "Чек или подтверждение оплаты",
|
||||
"hints": "Копия кассового чека, онлайн-платежа или квитанции",
|
||||
"accept": ["pdf", "jpg", "png"],
|
||||
"priority": 1,
|
||||
"required": True
|
||||
},
|
||||
{
|
||||
"id": "correspondence",
|
||||
"name": "Переписка",
|
||||
"hints": "Скриншоты сообщений, писем, жалоб",
|
||||
"accept": ["pdf", "jpg", "png"],
|
||||
"priority": 2,
|
||||
"required": False
|
||||
},
|
||||
{
|
||||
"id": "evidence_photo",
|
||||
"name": "Фото доказательства",
|
||||
"hints": "Фото дефектов товара, видео процесса ремонта или передачи",
|
||||
"accept": ["jpg", "png", "pdf"],
|
||||
"priority": 2,
|
||||
"required": False
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def fix_draft():
|
||||
"""Исправляет черновик: добавляет documents_required и обновляет статус"""
|
||||
conn = await asyncpg.connect(
|
||||
host=POSTGRES_HOST,
|
||||
port=POSTGRES_PORT,
|
||||
database=POSTGRES_DB,
|
||||
user=POSTGRES_USER,
|
||||
password=POSTGRES_PASSWORD
|
||||
)
|
||||
|
||||
try:
|
||||
# Получаем текущее состояние черновика
|
||||
row = await conn.fetchrow("""
|
||||
SELECT id, status_code, payload
|
||||
FROM clpr_claims
|
||||
WHERE id::text = $1 OR payload->>'claim_id' = $1
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""", CLAIM_ID)
|
||||
|
||||
if not row:
|
||||
print(f"❌ Черновик {CLAIM_ID} не найден!")
|
||||
return
|
||||
|
||||
payload = row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload'])
|
||||
current_status = row['status_code']
|
||||
|
||||
print(f"📋 Текущее состояние черновика:")
|
||||
print(f" - status_code: {current_status}")
|
||||
print(f" - documents_required: {len(payload.get('documents_required', []))} шт.")
|
||||
print(f" - documents_uploaded: {len(payload.get('documents_uploaded', []))} шт.")
|
||||
print(f" - documents_meta: {len(payload.get('documents_meta', []))} шт.")
|
||||
|
||||
# Проверяем documents_meta на наличие загруженных документов
|
||||
documents_meta = payload.get('documents_meta', [])
|
||||
existing_documents_uploaded = payload.get('documents_uploaded', [])
|
||||
|
||||
# Функция для определения типа документа (сначала по field_label, потом по field_name)
|
||||
def get_document_type(field_label: str, field_name: str) -> str:
|
||||
field_label_lower = field_label.lower()
|
||||
# ✅ СНАЧАЛА проверяем field_label (более точный способ)
|
||||
if 'договор' in field_label_lower or 'заказ' in field_label_lower:
|
||||
return 'contract'
|
||||
elif 'чек' in field_label_lower or 'оплат' in field_label_lower:
|
||||
return 'payment'
|
||||
elif 'переписк' in field_label_lower:
|
||||
return 'correspondence'
|
||||
elif 'доказательств' in field_label_lower or 'фото' in field_label_lower:
|
||||
return 'evidence_photo'
|
||||
# ✅ ПОТОМ проверяем field_name (fallback)
|
||||
elif 'uploads[0]' in field_name:
|
||||
return 'contract'
|
||||
elif 'uploads[1]' in field_name:
|
||||
return 'payment'
|
||||
elif 'uploads[2]' in field_name:
|
||||
return 'correspondence'
|
||||
elif 'uploads[3]' in field_name:
|
||||
return 'evidence_photo'
|
||||
else:
|
||||
return 'unknown'
|
||||
|
||||
# ✅ Объединяем существующие documents_uploaded с documents_meta
|
||||
# Создаём мапу file_id -> doc_meta для быстрого поиска
|
||||
meta_by_file_id = {}
|
||||
if documents_meta:
|
||||
print(f"\n🔍 Найдено {len(documents_meta)} документов в documents_meta")
|
||||
for doc_meta in documents_meta:
|
||||
file_id = doc_meta.get('file_id', '')
|
||||
if file_id:
|
||||
meta_by_file_id[file_id] = doc_meta
|
||||
|
||||
# ✅ Пересоздаём documents_uploaded: объединяем существующие с данными из documents_meta
|
||||
documents_uploaded = []
|
||||
seen_file_ids = set()
|
||||
|
||||
# Сначала обрабатываем documents_meta (приоритет)
|
||||
for doc_meta in documents_meta:
|
||||
file_id = doc_meta.get('file_id', '')
|
||||
if not file_id or file_id in seen_file_ids:
|
||||
continue
|
||||
|
||||
field_label = doc_meta.get('field_label', '')
|
||||
field_name = doc_meta.get('field_name', '')
|
||||
doc_type = get_document_type(field_label, field_name)
|
||||
|
||||
if doc_type != 'unknown':
|
||||
seen_file_ids.add(file_id)
|
||||
documents_uploaded.append({
|
||||
"id": doc_type,
|
||||
"type": doc_type,
|
||||
"file_id": file_id,
|
||||
"file_name": doc_meta.get('file_name', ''),
|
||||
"original_file_name": doc_meta.get('original_file_name', ''),
|
||||
"uploaded_at": doc_meta.get('uploaded_at', datetime.utcnow().isoformat()),
|
||||
"ocr_status": "completed",
|
||||
"files_count": doc_meta.get('files_count', 1),
|
||||
"pages": doc_meta.get('pages', None)
|
||||
})
|
||||
print(f" ✅ Из documents_meta: {doc_type} ({field_label}) - {doc_meta.get('original_file_name', 'N/A')}")
|
||||
|
||||
# Затем добавляем существующие documents_uploaded, которых нет в documents_meta
|
||||
for existing_doc in existing_documents_uploaded:
|
||||
file_id = existing_doc.get('file_id', '')
|
||||
if not file_id or file_id in seen_file_ids:
|
||||
continue
|
||||
|
||||
# Если есть в documents_meta - пропускаем (уже обработали)
|
||||
if file_id in meta_by_file_id:
|
||||
continue
|
||||
|
||||
# Используем существующий тип или пытаемся определить по file_name
|
||||
doc_type = existing_doc.get('type') or existing_doc.get('id') or 'unknown'
|
||||
|
||||
# Если тип неправильный (например, contract вместо payment), пытаемся определить по file_name
|
||||
if doc_type == 'contract' and 'chek' in file_id.lower():
|
||||
doc_type = 'payment'
|
||||
elif doc_type == 'contract' and 'dogovor' in file_id.lower():
|
||||
doc_type = 'contract'
|
||||
|
||||
seen_file_ids.add(file_id)
|
||||
documents_uploaded.append({
|
||||
"id": doc_type,
|
||||
"type": doc_type,
|
||||
"file_id": file_id,
|
||||
"file_name": existing_doc.get('file_name', ''),
|
||||
"original_file_name": existing_doc.get('original_file_name', ''),
|
||||
"uploaded_at": existing_doc.get('uploaded_at', datetime.utcnow().isoformat()),
|
||||
"ocr_status": existing_doc.get('ocr_status', 'completed'),
|
||||
"files_count": existing_doc.get('files_count', 1),
|
||||
"pages": existing_doc.get('pages', None)
|
||||
})
|
||||
print(f" ✅ Из существующих: {doc_type} - {existing_doc.get('original_file_name', 'N/A')}")
|
||||
|
||||
# Определяем current_doc_index (индекс следующего документа для загрузки)
|
||||
# Убираем дубликаты по типу документа
|
||||
uploaded_types = list(set([doc.get('id') or doc.get('type') for doc in documents_uploaded]))
|
||||
current_doc_index = 0
|
||||
|
||||
# Находим первый незагруженный документ
|
||||
for idx, doc_req in enumerate(DOCUMENTS_REQUIRED):
|
||||
if doc_req['id'] not in uploaded_types:
|
||||
current_doc_index = idx
|
||||
break
|
||||
else:
|
||||
# Все документы загружены
|
||||
current_doc_index = len(DOCUMENTS_REQUIRED)
|
||||
|
||||
# Определяем новый статус (учитываем уникальные типы документов)
|
||||
uploaded_unique_types = len(uploaded_types)
|
||||
if uploaded_unique_types >= len(DOCUMENTS_REQUIRED):
|
||||
new_status = 'draft_docs_complete'
|
||||
elif uploaded_unique_types > 0:
|
||||
new_status = 'draft_docs_progress'
|
||||
else:
|
||||
new_status = 'draft_new'
|
||||
|
||||
# Обновляем payload
|
||||
payload['documents_required'] = DOCUMENTS_REQUIRED
|
||||
payload['documents_uploaded'] = documents_uploaded
|
||||
payload['current_doc_index'] = current_doc_index
|
||||
|
||||
print(f"\n📝 Обновление черновика:")
|
||||
print(f" - documents_required: {len(DOCUMENTS_REQUIRED)} документов")
|
||||
print(f" - documents_uploaded: {len(documents_uploaded)} документов")
|
||||
print(f" - current_doc_index: {current_doc_index} (следующий документ: {DOCUMENTS_REQUIRED[current_doc_index]['name'] if current_doc_index < len(DOCUMENTS_REQUIRED) else 'все загружены'})")
|
||||
print(f" - status_code: {current_status} → {new_status}")
|
||||
|
||||
# Обновляем черновик
|
||||
await conn.execute("""
|
||||
UPDATE clpr_claims
|
||||
SET
|
||||
status_code = $1,
|
||||
payload = $2::jsonb,
|
||||
updated_at = now()
|
||||
WHERE id::text = $3 OR payload->>'claim_id' = $3
|
||||
""", new_status, json.dumps(payload, ensure_ascii=False), CLAIM_ID)
|
||||
|
||||
print(f"\n✅ Черновик исправлен!")
|
||||
|
||||
# Проверяем результат
|
||||
row_after = await conn.fetchrow("""
|
||||
SELECT
|
||||
id::text,
|
||||
status_code,
|
||||
jsonb_array_length(COALESCE(payload->'documents_required', '[]'::jsonb)) as docs_required_count,
|
||||
jsonb_array_length(COALESCE(payload->'documents_uploaded', '[]'::jsonb)) as docs_uploaded_count,
|
||||
(payload->>'current_doc_index')::int as current_doc_index
|
||||
FROM clpr_claims
|
||||
WHERE id::text = $1 OR payload->>'claim_id' = $1
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""", CLAIM_ID)
|
||||
|
||||
print(f"\n📊 Результат:")
|
||||
print(f" - status_code: {row_after['status_code']}")
|
||||
print(f" - documents_required count: {row_after['docs_required_count']}")
|
||||
print(f" - documents_uploaded count: {row_after['docs_uploaded_count']}")
|
||||
print(f" - current_doc_index: {row_after['current_doc_index']}")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(fix_draft())
|
||||
|
||||
36
frontend/Dockerfile.prod
Normal file
36
frontend/Dockerfile.prod
Normal 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"]
|
||||
|
||||
@@ -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
8927
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user