feat: Получение cf_2624 из MySQL и блокировка полей при подтверждении данных

- Добавлен сервис CrmMySQLService для прямого подключения к MySQL CRM
- Обновлён метод get_draft() для получения cf_2624 напрямую из БД
- Реализована блокировка полей (readonly) при contact_data_confirmed = true
- Добавлен выбор банка для СБП выплат с динамической загрузкой из API
- Обновлена документация по работе с cf_2624 и MySQL
- Добавлен network_mode: host в docker-compose для доступа к MySQL
- Обновлены компоненты формы для поддержки блокировки полей
This commit is contained in:
AI Assistant
2025-12-04 12:22:23 +03:00
parent 64385c430d
commit 080e7ec105
69 changed files with 17034 additions and 1439 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -90,12 +90,15 @@ for (const it of items) {
const file_index = 0; // После объединения всегда один файл на группу
const field_name = `uploads[${grp}][${file_index}]`;
const field_label = uploads_field_labels[grp] || uploads_field_names[grp] || uploads_descriptions[grp] || `group-${grp}`;
// ✅ ИСПРАВЛЕНО: 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[grp] || '');
const description = safeStr(it.description || uploads_descriptions[0] || '');
const prefix = safeStr(it.prefix || '');
// files_count показывает, сколько исходных файлов было объединено

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
-- SQL запрос для получения полных данных контакта из CRM
-- Используется в ноде MySQL перед Code in JavaScriptКлиентправ
-- ПРИМЕЧАНИЕ: Таблицы vtiger_* находятся в MySQL БД
SELECT
cd.contactid,
cd.firstname,
cd.lastname,
cd.email,
cd.mobile,
cd.phone,
cs.birthday, -- ✅ Из vtiger_contactsubdetails
ca.mailingstreet, -- ✅ Из vtiger_contactaddress
ca.mailingcity,
ca.mailingstate,
ca.mailingzip,
ca.mailingcountry,
-- Кастомные поля из vtiger_contactscf:
ccf.cf_1157 AS middle_name, -- Отчество
ccf.cf_1263 AS birthplace, -- Место рождения
ccf.cf_1257 AS inn, -- ИНН
ccf.cf_1849 AS requisites, -- Реквизиты
ccf.cf_1580 AS code, -- Код
ccf.cf_1706 AS sms, -- SMS
ccf.cf_2624 AS cf_2624 -- ✅ Данные подтверждены
FROM vtiger_contactdetails cd
LEFT JOIN vtiger_contactscf ccf ON ccf.contactid = cd.contactid
LEFT JOIN vtiger_contactsubdetails cs ON cs.contactsubscriptionid = cd.contactid
LEFT JOIN vtiger_contactaddress ca ON ca.contactaddressid = cd.contactid
LEFT JOIN vtiger_crmentity ce ON ce.crmid = cd.contactid
WHERE cd.contactid = ?
AND ce.deleted = 0
LIMIT 1;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,7 +37,7 @@ claim_lookup AS (
),
docs AS (
SELECT
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,
@@ -62,6 +62,9 @@ docs AS (
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
@@ -181,7 +184,7 @@ upsert_docs AS (
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
RETURNING id, claim_id, field_name, file_id, uploaded_at, file_name, original_file_name -- ✅ Возвращаем все поля для использования в финальном SELECT
),
-- ✅ ИСПРАВЛЕНО: Сохраняем documents_required, documents_uploaded и обновляем статус правильно
@@ -274,26 +277,48 @@ SELECT
'payload', u.payload
) FROM upd_claim u) AS claim,
-- ✅ ИСПРАВЛЕНО: Возвращаем ВСЕ документы из upsert_docs с правильным claim_document_id
(
SELECT jsonb_agg(
jsonb_build_object(
'id', u.id,
'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,
-- ✅ Получаем 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(d.original_file_name, ''),
NULLIF(d.file_name, ''),
regexp_replace(d.file_id, '^.*/', '')
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
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 <> ''
-- ✅ LEFT JOIN: возвращаем ВСЕ документы из таблицы, даже если нет file_url в docs
LEFT JOIN docs d ON d.claim_id = u.claim_id
AND d.field_name = u.field_name
AND d.file_id = u.file_id
AND d.file_url IS NOT NULL
AND d.file_url <> ''
) AS documents;

View File

@@ -0,0 +1,345 @@
-- ============================================================================
-- Исправленный SQL для сохранения документов (claimsave_final) - ПОДДЕРЖКА НОВОГО ФЛОУ
-- ============================================================================
-- ИСПРАВЛЕНИЕ: Возврат правильного claim_document_id из таблицы clpr_claim_documents
-- Проблема: Возвращался только один документ и неправильный id
-- Решение: Используем u.id из upsert_docs (таблица clpr_claim_documents) и фильтруем правильно
-- ============================================================================
WITH partial AS (
SELECT $1::jsonb AS p, $2::text AS claim_id_str
),
claim_lookup AS (
SELECT
c.id,
c.payload,
c.status_code
FROM clpr_claims c, partial
WHERE c.id::text = partial.claim_id_str
OR c.payload->>'claim_id' = partial.claim_id_str
ORDER BY
CASE WHEN c.id::text = partial.claim_id_str THEN 1 ELSE 2 END,
c.updated_at DESC
LIMIT 1
),
docs AS (
SELECT
claim_lookup.id::text AS claim_id,
doc.field_name::text AS field_name,
doc.field_label::text AS field_label,
doc.file_id::text AS file_id,
doc.file_name::text AS file_name,
doc.original_file_name::text AS original_file_name,
(doc.uploaded_at)::timestamptz AS uploaded_at,
doc.file_url::text AS file_url,
doc.files_count::int AS files_count,
doc.pages::int AS pages
FROM partial, claim_lookup
CROSS JOIN LATERAL jsonb_to_recordset(
COALESCE(partial.p->'documents_meta','[]'::jsonb)
) AS doc(
field_name text,
field_label text,
file_id text,
file_name text,
original_file_name text,
uploaded_at text,
file_url text,
files_count int,
pages int
)
-- ✅ ФИЛЬТРУЕМ: берём только документы с валидным file_url И уникальным field_name+file_id
WHERE doc.file_url IS NOT NULL
AND doc.file_url <> ''
AND doc.file_url ~* '^https?://'
-- ✅ Убираем дубликаты: берём только первую запись для каждого field_name с валидным file_url
AND NOT EXISTS (
SELECT 1 FROM jsonb_to_recordset(COALESCE(partial.p->'documents_meta','[]'::jsonb)) AS doc2(
field_name text,
file_id text,
file_url text
)
WHERE doc2.field_name = doc.field_name
AND doc2.file_id = doc.file_id
AND doc2.file_url ~* '^https?://'
AND doc2.file_url <> ''
-- Сравниваем по позиции в массиве (берем первый)
AND (SELECT ordinality FROM jsonb_array_elements(COALESCE(partial.p->'documents_meta','[]'::jsonb)) WITH ORDINALITY AS d3 WHERE d3.value->>'field_name' = doc.field_name AND d3.value->>'file_id' = doc.file_id ORDER BY d3.ordinality LIMIT 1) <
(SELECT ordinality FROM jsonb_array_elements(COALESCE(partial.p->'documents_meta','[]'::jsonb)) WITH ORDINALITY AS d4 WHERE d4.value->>'field_name' = doc.field_name AND d4.value->>'file_id' = doc.file_id ORDER BY d4.ordinality LIMIT 1)
)
),
-- ✅ НОВОЕ: Создаём documents_uploaded на основе documents_meta
documents_uploaded_built AS (
SELECT
-- ✅ ВАЖНО: Всегда начинаем с существующих documents_uploaded
COALESCE(
(SELECT claim_lookup.payload->'documents_uploaded' FROM claim_lookup),
'[]'::jsonb
) ||
-- ✅ Добавляем только НОВЫЕ документы из documents_meta (которых нет в существующих)
COALESCE(
(
SELECT jsonb_agg(
jsonb_build_object(
'id',
CASE
-- ✅ СНАЧАЛА проверяем field_label (более точный способ определения типа)
WHEN doc.field_label ILIKE '%договор%' OR doc.field_label ILIKE '%заказ%'
THEN 'contract'
WHEN doc.field_label ILIKE '%чек%' OR doc.field_label ILIKE '%оплат%'
THEN 'payment'
WHEN doc.field_label ILIKE '%переписк%'
THEN 'correspondence'
WHEN doc.field_label ILIKE '%доказательств%' OR doc.field_label ILIKE '%фото%'
THEN 'evidence_photo'
-- ✅ ПОТОМ проверяем field_name (fallback, если field_label не определён)
WHEN doc.field_name LIKE 'uploads[0]%'
THEN 'contract'
WHEN doc.field_name LIKE 'uploads[1]%'
THEN 'payment'
WHEN doc.field_name LIKE 'uploads[2]%'
THEN 'correspondence'
WHEN doc.field_name LIKE 'uploads[3]%'
THEN 'evidence_photo'
ELSE 'unknown'
END,
'type',
CASE
-- ✅ СНАЧАЛА проверяем field_label (более точный способ определения типа)
WHEN doc.field_label ILIKE '%договор%' OR doc.field_label ILIKE '%заказ%'
THEN 'contract'
WHEN doc.field_label ILIKE '%чек%' OR doc.field_label ILIKE '%оплат%'
THEN 'payment'
WHEN doc.field_label ILIKE '%переписк%'
THEN 'correspondence'
WHEN doc.field_label ILIKE '%доказательств%' OR doc.field_label ILIKE '%фото%'
THEN 'evidence_photo'
-- ✅ ПОТОМ проверяем field_name (fallback, если field_label не определён)
WHEN doc.field_name LIKE 'uploads[0]%'
THEN 'contract'
WHEN doc.field_name LIKE 'uploads[1]%'
THEN 'payment'
WHEN doc.field_name LIKE 'uploads[2]%'
THEN 'correspondence'
WHEN doc.field_name LIKE 'uploads[3]%'
THEN 'evidence_photo'
ELSE 'unknown'
END,
'file_id', doc.file_id,
'file_name', doc.file_name,
'original_file_name', doc.original_file_name,
'uploaded_at', doc.uploaded_at::text,
'ocr_status', 'completed',
'files_count', COALESCE(doc.files_count, 1),
'pages', doc.pages
)
ORDER BY doc.field_name
)
FROM docs doc, claim_lookup
-- ✅ Исключаем документы, которые уже есть в documents_uploaded (по file_id)
WHERE NOT EXISTS (
SELECT 1
FROM jsonb_array_elements(COALESCE(claim_lookup.payload->'documents_uploaded', '[]'::jsonb)) AS existing
WHERE existing->>'file_id' = doc.file_id
)
AND doc.file_id IS NOT NULL
),
'[]'::jsonb -- Если новых документов нет - возвращаем пустой массив для объединения
) AS documents_uploaded_array
FROM claim_lookup
),
-- ✅ НОВОЕ: Определяем current_doc_index (следующий незагруженный документ)
current_doc_index_calculated AS (
SELECT
CASE
WHEN claim_lookup.payload->'documents_required' IS NOT NULL THEN
-- Находим первый незагруженный документ
COALESCE(
(
SELECT idx
FROM jsonb_array_elements(claim_lookup.payload->'documents_required') WITH ORDINALITY AS req(doc, idx)
WHERE NOT EXISTS (
SELECT 1
FROM documents_uploaded_built, jsonb_array_elements(documents_uploaded_built.documents_uploaded_array) AS uploaded
WHERE (uploaded->>'id') = (req.doc->>'id')
)
ORDER BY idx
LIMIT 1
),
-- Если все документы загружены, возвращаем количество документов
jsonb_array_length(claim_lookup.payload->'documents_required')
)
ELSE 0
END AS current_doc_index
FROM claim_lookup
),
-- ✅ ИСПРАВЛЕНО: Сохраняем документы в таблицу clpr_claim_documents
-- ✅ ДОБАВЛЕНО: document_type и document_label для AI matching
upsert_docs AS (
INSERT INTO clpr_claim_documents
(claim_id, field_name, file_id, uploaded_at, file_name, original_file_name,
document_type, document_label)
SELECT
claim_id,
field_name,
file_id,
uploaded_at,
file_name,
original_file_name,
-- document_type: вычисляем из field_label или field_name
CASE
WHEN field_label ILIKE '%договор%' OR field_label ILIKE '%заказ%'
THEN 'contract'
WHEN field_label ILIKE '%чек%' OR field_label ILIKE '%оплат%'
THEN 'payment'
WHEN field_label ILIKE '%переписк%'
THEN 'correspondence'
WHEN field_label ILIKE '%доказательств%' OR field_label ILIKE '%фото%'
THEN 'evidence_photo'
WHEN field_name LIKE 'uploads[0]%'
THEN 'contract'
WHEN field_name LIKE 'uploads[1]%'
THEN 'payment'
WHEN field_name LIKE 'uploads[2]%'
THEN 'correspondence'
WHEN field_name LIKE 'uploads[3]%'
THEN 'evidence_photo'
ELSE 'unknown'
END,
-- document_label: сохраняем как есть из формы
field_label
FROM docs
ON CONFLICT (claim_id, field_name) DO UPDATE
SET file_id = EXCLUDED.file_id,
uploaded_at = EXCLUDED.uploaded_at,
file_name = EXCLUDED.file_name,
original_file_name = EXCLUDED.original_file_name,
document_type = EXCLUDED.document_type,
document_label = EXCLUDED.document_label
RETURNING id, claim_id, field_name, file_id, document_type, document_label
),
-- ✅ ИСПРАВЛЕНО: Сохраняем documents_required, documents_uploaded и обновляем статус правильно
upd_claim AS (
UPDATE clpr_claims c
SET
-- ✅ Объединяем payload: сохраняем documents_required, documents_meta, documents_uploaded и current_doc_index
payload = jsonb_set(
jsonb_set(
jsonb_set(
jsonb_set(
COALESCE(c.payload, '{}'::jsonb),
'{documents_meta}',
-- ✅ ОБЪЕДИНЯЕМ существующие documents_meta с новыми (не перезаписываем!)
COALESCE(
(SELECT p->'documents_meta' FROM partial WHERE partial.p->'documents_meta' IS NOT NULL),
'[]'::jsonb
) || COALESCE(
c.payload->'documents_meta',
'[]'::jsonb
),
true
),
'{documents_required}',
COALESCE(
(SELECT p->'documents_required' FROM partial WHERE partial.p->'documents_required' IS NOT NULL),
c.payload->'documents_required, -- Сохраняем существующий, если новый не пришёл
'[]'::jsonb
),
true
),
'{documents_uploaded}',
-- ✅ ВАЖНО: Используем объединённый массив из documents_uploaded_built
CASE
WHEN EXISTS (
SELECT 1 FROM documents_uploaded_built
WHERE documents_uploaded_array IS NOT NULL
AND jsonb_array_length(documents_uploaded_array) > 0
)
THEN (SELECT documents_uploaded_array FROM documents_uploaded_built LIMIT 1)
ELSE COALESCE(c.payload->'documents_uploaded', '[]'::jsonb)
END,
true
),
'{current_doc_index}',
to_jsonb((SELECT current_doc_index FROM current_doc_index_calculated)),
true
),
-- ✅ Обновляем статус только если нужно (не перезаписываем новые статусы)
status_code = CASE
-- Если статус уже новый - сохраняем его (кроме случаев, когда нужно обновить)
WHEN c.status_code IN ('draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready')
THEN CASE
-- Если есть documents_required и документы загружены - обновляем статус
WHEN c.payload->'documents_required' IS NOT NULL
AND jsonb_array_length(COALESCE(c.payload->'documents_required', '[]'::jsonb)) > 0
AND (SELECT COUNT(*) FROM docs) > 0
THEN CASE
WHEN (SELECT COUNT(*) FROM docs) >= jsonb_array_length(COALESCE(c.payload->'documents_required', '[]'::jsonb))
THEN 'draft_docs_complete'
ELSE 'draft_docs_progress'
END
ELSE c.status_code
END
-- Если есть documents_required и документы загружены - обновляем статус
WHEN c.payload->'documents_required' IS NOT NULL
AND jsonb_array_length(COALESCE(c.payload->'documents_required', '[]'::jsonb)) > 0
AND (SELECT COUNT(*) FROM docs) > 0
THEN CASE
WHEN (SELECT COUNT(*) FROM docs) >= jsonb_array_length(COALESCE(c.payload->'documents_required', '[]'::jsonb))
THEN 'draft_docs_complete'
ELSE 'draft_docs_progress'
END
-- Иначе сохраняем существующий
ELSE c.status_code
END,
updated_at = now(),
expires_at = now() + interval '14 days'
FROM partial, claim_lookup
WHERE c.id = claim_lookup.id
RETURNING c.id, c.payload, c.status_code
)
SELECT
(SELECT jsonb_build_object(
'claim_id', u.id::text,
'status_code', u.status_code,
'payload', u.payload
) FROM upd_claim u) AS claim,
-- ✅ ИСПРАВЛЕНО: Возвращаем ВСЕ документы из upsert_docs с правильным claim_document_id
-- ✅ ДОБАВЛЕНО: document_type и document_label для передачи в OCR workflow
(
SELECT jsonb_agg(
jsonb_build_object(
'id', u.id::text, -- ✅ Это claim_document_id из таблицы clpr_claim_documents
'claim_document_id', u.id::text, -- ✅ Явно указываем для ясности
'field_name', u.field_name,
'file_id', u.file_id,
'file_url', d.file_url,
'file_name', d.file_name,
'original_file_name', d.original_file_name,
'uploaded_at', d.uploaded_at::text,
'document_type', u.document_type, -- ✅ Тип документа (contract, payment, etc.)
'document_label', u.document_label, -- ✅ Название поля формы
'filename_for_upload',
COALESCE(
NULLIF(d.original_file_name, ''),
NULLIF(d.file_name, ''),
regexp_replace(d.file_id, '^.*/', '')
)
)
ORDER BY u.field_name -- ✅ Сортируем для предсказуемости
)
FROM upsert_docs u
-- ✅ JOIN с docs для получения file_url и других метаданных
LEFT JOIN docs d ON d.claim_id = u.claim_id AND d.field_name = u.field_name AND d.file_id = u.file_id
-- ✅ Возвращаем ВСЕ документы из таблицы, даже если нет file_url в docs
-- (file_url может быть в documents_meta в payload)
) AS documents;

View File

@@ -0,0 +1,391 @@
-- ============================================================================
-- Исправленный SQL для сохранения claim (claimsave) - С ДЕДУПЛИКАЦИЕЙ
-- ============================================================================
-- Проблема: documents_meta накапливает дубликаты при каждом сохранении
-- Решение: При добавлении новых записей удаляем старые с тем же field_name
-- ============================================================================
WITH partial AS (
SELECT
$1::jsonb AS p,
$2::text AS claim_id_str
),
existing_claim AS (
SELECT
id,
payload,
status_code,
created_at
FROM clpr_claims
WHERE id = (SELECT claim_id_str::uuid FROM partial)
OR payload->>'claim_id' = (SELECT claim_id_str FROM partial)
ORDER BY
CASE WHEN id = (SELECT claim_id_str::uuid FROM partial) THEN 1 ELSE 2 END,
updated_at DESC
LIMIT 1
),
-- ✅ НОВОЕ: Дедуплицированный documents_meta
-- Приоритет: новые записи перезаписывают старые с тем же field_name
documents_meta_dedup AS (
SELECT COALESCE(
(
SELECT jsonb_agg(doc ORDER BY (doc->>'uploaded_at') DESC NULLS LAST)
FROM (
-- Уникальные записи: приоритет новым по field_name
SELECT DISTINCT ON (doc->>'field_name') doc
FROM (
-- 1. Сначала новые записи (приоритет)
SELECT jsonb_array_elements(
COALESCE((SELECT p->'documents_meta' FROM partial WHERE p->'documents_meta' IS NOT NULL), '[]'::jsonb)
) AS doc, 1 AS priority
UNION ALL
-- 2. Потом существующие записи
SELECT jsonb_array_elements(
COALESCE((SELECT payload->'documents_meta' FROM existing_claim), '[]'::jsonb)
) AS doc, 2 AS priority
) all_docs
-- Сортируем: сначала новые (priority=1), потом по дате
ORDER BY doc->>'field_name', priority, (doc->>'uploaded_at') DESC NULLS LAST
) unique_docs
),
'[]'::jsonb
) AS documents_meta
),
-- Парсим documents_required (или берём из БД)
documents_required_parsed AS (
SELECT
CASE
WHEN partial.p->'documents_required' IS NOT NULL
AND jsonb_typeof(partial.p->'documents_required') = 'array'
THEN partial.p->'documents_required'
WHEN partial.p->'edit_fields_parsed'->'documents_required' IS NOT NULL
AND jsonb_typeof(partial.p->'edit_fields_parsed'->'documents_required') = 'array'
THEN partial.p->'edit_fields_parsed'->'documents_required'
WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->'documents_required' IS NOT NULL)
THEN (SELECT payload->'documents_required' FROM existing_claim)
ELSE '[]'::jsonb
END AS documents_required
FROM partial
),
-- Парсим documents_uploaded (или берём из БД)
documents_uploaded_parsed AS (
SELECT
CASE
WHEN partial.p->'documents_uploaded' IS NOT NULL
AND jsonb_typeof(partial.p->'documents_uploaded') = 'array'
THEN partial.p->'documents_uploaded'
WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->'documents_uploaded' IS NOT NULL)
THEN (SELECT payload->'documents_uploaded' FROM existing_claim)
ELSE '[]'::jsonb
END AS documents_uploaded
FROM partial
),
-- Парсим documents_skipped (или берём из БД)
documents_skipped_parsed AS (
SELECT
CASE
WHEN partial.p->'documents_skipped' IS NOT NULL
AND jsonb_typeof(partial.p->'documents_skipped') = 'array'
THEN partial.p->'documents_skipped'
WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->'documents_skipped' IS NOT NULL)
THEN (SELECT payload->'documents_skipped' FROM existing_claim)
ELSE '[]'::jsonb
END AS documents_skipped
FROM partial
),
-- Парсим current_doc_index (или берём из БД)
current_doc_index_parsed AS (
SELECT
CASE
WHEN partial.p->'current_doc_index' IS NOT NULL
THEN (partial.p->'current_doc_index')::int
WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->'current_doc_index' IS NOT NULL)
THEN (SELECT (payload->'current_doc_index')::int FROM existing_claim)
ELSE 0
END AS current_doc_index
FROM partial
),
-- Парсим wizard_answers
wizard_answers_parsed AS (
SELECT
CASE
WHEN partial.p->'edit_fields_raw'->'body'->>'wizard_answers' IS NOT NULL
THEN (partial.p->'edit_fields_raw'->'body'->>'wizard_answers')::jsonb
WHEN partial.p->'edit_fields_parsed'->'body'->>'wizard_answers' IS NOT NULL
THEN (partial.p->'edit_fields_parsed'->'body'->>'wizard_answers')::jsonb
WHEN partial.p->>'wizard_answers' IS NOT NULL
THEN (partial.p->>'wizard_answers')::jsonb
WHEN partial.p->'wizard_answers' IS NOT NULL
AND jsonb_typeof(partial.p->'wizard_answers') = 'object'
THEN partial.p->'wizard_answers'
ELSE '{}'::jsonb
END AS answers
FROM partial
),
-- Парсим wizard_plan (или берём из существующей записи)
wizard_plan_parsed AS (
SELECT
CASE
WHEN partial.p->'edit_fields_parsed'->'wizard_plan_parsed' IS NOT NULL
AND jsonb_typeof(partial.p->'edit_fields_parsed'->'wizard_plan_parsed') = 'object'
THEN partial.p->'edit_fields_parsed'->'wizard_plan_parsed'
WHEN partial.p->>'wizard_plan' IS NOT NULL
THEN (partial.p->>'wizard_plan')::jsonb
WHEN partial.p->'wizard_plan' IS NOT NULL
AND jsonb_typeof(partial.p->'wizard_plan') = 'object'
THEN partial.p->'wizard_plan'
WHEN partial.p->'edit_fields_raw'->'body'->>'wizard_plan' IS NOT NULL
THEN (partial.p->'edit_fields_raw'->'body'->>'wizard_plan')::jsonb
WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->'wizard_plan' IS NOT NULL)
THEN (SELECT payload->'wizard_plan' FROM existing_claim)
ELSE NULL
END AS wizard_plan
FROM partial
),
-- Парсим problem_description (или берём из БД)
problem_description_parsed AS (
SELECT
CASE
WHEN partial.p->>'problem_description' IS NOT NULL
THEN partial.p->>'problem_description'
WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->>'problem_description' IS NOT NULL)
THEN (SELECT payload->>'problem_description' FROM existing_claim)
ELSE NULL
END AS problem_description
FROM partial
),
-- Определяем правильный статус
status_code_resolved AS (
SELECT
CASE
-- Если есть documents_required и документы загружаются - новый флоу
WHEN (SELECT jsonb_array_length(documents_required) FROM documents_required_parsed) > 0
THEN CASE
-- Все документы загружены или пропущены
WHEN (SELECT jsonb_array_length(documents_uploaded) FROM documents_uploaded_parsed) +
(SELECT jsonb_array_length(documents_skipped) FROM documents_skipped_parsed) >=
(SELECT jsonb_array_length(documents_required) FROM documents_required_parsed)
THEN 'draft_docs_complete'
-- Документы загружаются
WHEN (SELECT jsonb_array_length(documents_uploaded) FROM documents_uploaded_parsed) > 0
THEN 'draft_docs_progress'
-- Только описание
ELSE 'draft_new'
END
-- Старый флоу: проверяем wizard_answers
WHEN (SELECT answers->>'docs_exist' FROM wizard_answers_parsed) = 'true'
THEN 'in_work'
-- Сохраняем существующий статус, если он новый
WHEN EXISTS (SELECT 1 FROM existing_claim
WHERE status_code IN ('draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready'))
THEN (SELECT status_code FROM existing_claim)
-- По умолчанию
ELSE 'draft'
END AS status_code
FROM partial
),
-- UPSERT claim
claim_upsert AS (
INSERT INTO clpr_claims (
id,
session_token,
unified_id,
contact_id,
phone,
channel,
type_code,
status_code,
payload,
created_at,
updated_at,
expires_at
)
SELECT
COALESCE((SELECT id FROM existing_claim), partial.claim_id_str::uuid),
COALESCE(
partial.p->>'session_id',
partial.p->'edit_fields_parsed'->'body'->>'session_id',
partial.p->'edit_fields_raw'->'body'->>'session_id',
'sess-unknown'
),
COALESCE(
partial.p->>'unified_id',
partial.p->'edit_fields_parsed'->'body'->>'unified_id',
partial.p->'edit_fields_raw'->'body'->>'unified_id'
),
COALESCE(
partial.p->>'contact_id',
partial.p->'edit_fields_parsed'->'body'->>'contact_id',
partial.p->'edit_fields_raw'->'body'->>'contact_id'
),
COALESCE(
partial.p->>'phone',
partial.p->'edit_fields_parsed'->'body'->>'phone',
partial.p->'edit_fields_raw'->'body'->>'phone'
),
'web_form',
COALESCE(partial.p->>'type_code', 'consumer'),
(SELECT status_code FROM status_code_resolved),
jsonb_build_object(
'claim_id', partial.claim_id_str,
'problem_description', (SELECT problem_description FROM problem_description_parsed),
'answers', (SELECT answers FROM wizard_answers_parsed),
-- ✅ ДЕДУПЛИЦИРОВАННЫЙ documents_meta
'documents_meta', (SELECT documents_meta FROM documents_meta_dedup),
-- ✅ НОВЫЙ ФЛОУ: Сохраняем documents_required и связанные поля
'documents_required', (SELECT documents_required FROM documents_required_parsed),
'documents_uploaded', (SELECT documents_uploaded FROM documents_uploaded_parsed),
'documents_skipped', (SELECT documents_skipped FROM documents_skipped_parsed),
'current_doc_index', (SELECT current_doc_index FROM current_doc_index_parsed),
'wizard_plan', (SELECT wizard_plan FROM wizard_plan_parsed),
'phone', COALESCE(partial.p->>'phone', (SELECT payload->>'phone' FROM existing_claim)),
'email', COALESCE(partial.p->>'email', (SELECT payload->>'email' FROM existing_claim))
),
COALESCE((SELECT created_at FROM existing_claim), now()),
now(),
now() + interval '14 days'
FROM partial
ON CONFLICT (id) DO UPDATE SET
session_token = EXCLUDED.session_token,
unified_id = COALESCE(EXCLUDED.unified_id, clpr_claims.unified_id),
contact_id = COALESCE(EXCLUDED.contact_id, clpr_claims.contact_id),
phone = COALESCE(EXCLUDED.phone, clpr_claims.phone),
-- ✅ НЕ перезаписываем статус, если он новый (сохраняем существующий)
status_code = CASE
WHEN clpr_claims.status_code IN ('draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready')
THEN clpr_claims.status_code -- Сохраняем существующий новый статус
ELSE EXCLUDED.status_code -- Используем новый статус
END,
-- ✅ ИСПРАВЛЕНО: Дедуплицированное объединение documents_meta
payload = jsonb_set(
jsonb_set(
jsonb_set(
jsonb_set(
jsonb_set(
-- Сначала берём существующий payload и объединяем с новым (без критичных полей)
COALESCE(clpr_claims.payload, '{}'::jsonb) ||
(EXCLUDED.payload - 'documents_meta' - 'documents_required' - 'documents_uploaded' - 'documents_skipped' - 'current_doc_index'),
'{documents_meta}',
-- ✅ ДЕДУПЛИКАЦИЯ: новые записи перезаписывают старые с тем же field_name
(
SELECT COALESCE(jsonb_agg(doc), '[]'::jsonb)
FROM (
SELECT DISTINCT ON (doc->>'field_name') doc
FROM (
-- Новые записи (приоритет)
SELECT jsonb_array_elements(COALESCE(EXCLUDED.payload->'documents_meta', '[]'::jsonb)) AS doc, 1 AS priority
UNION ALL
-- Существующие записи
SELECT jsonb_array_elements(COALESCE(clpr_claims.payload->'documents_meta', '[]'::jsonb)) AS doc, 2 AS priority
) all_docs
ORDER BY doc->>'field_name', priority, (doc->>'uploaded_at') DESC NULLS LAST
) unique_docs
),
true
),
'{documents_required}',
COALESCE(
EXCLUDED.payload->'documents_required',
clpr_claims.payload->'documents_required',
'[]'::jsonb
),
true
),
'{documents_uploaded}',
COALESCE(
EXCLUDED.payload->'documents_uploaded',
clpr_claims.payload->'documents_uploaded',
'[]'::jsonb
),
true
),
'{documents_skipped}',
COALESCE(
EXCLUDED.payload->'documents_skipped',
clpr_claims.payload->'documents_skipped',
'[]'::jsonb
),
true
),
'{current_doc_index}',
COALESCE(
EXCLUDED.payload->'current_doc_index',
clpr_claims.payload->'current_doc_index',
to_jsonb(0)
),
true
),
updated_at = now(),
expires_at = now() + interval '14 days'
RETURNING id, status_code, payload, unified_id, contact_id, phone, session_token
),
-- UPSERT documents (если есть)
docs_upsert AS (
INSERT INTO clpr_claim_documents (
claim_id,
field_name,
file_id,
uploaded_at,
file_name,
original_file_name
)
SELECT
partial.claim_id_str AS claim_id,
doc.field_name,
doc.file_id,
COALESCE((doc.uploaded_at)::timestamptz, now()),
doc.file_name,
doc.original_file_name
FROM partial
CROSS JOIN LATERAL jsonb_to_recordset(
COALESCE(partial.p->'documents_meta', '[]'::jsonb)
) AS doc(
field_name text,
file_id text,
file_name text,
original_file_name text,
uploaded_at text
)
WHERE partial.p->'documents_meta' IS NOT NULL
AND jsonb_array_length(partial.p->'documents_meta') > 0
ON CONFLICT (claim_id, field_name) DO UPDATE SET
file_id = EXCLUDED.file_id,
uploaded_at = EXCLUDED.uploaded_at,
file_name = EXCLUDED.file_name,
original_file_name = EXCLUDED.original_file_name
RETURNING id, claim_id, field_name, file_id, file_name, original_file_name
)
-- Возвращаем результат
SELECT
(SELECT jsonb_build_object(
'claim_id', cu.id::text,
'claim_id_str', (cu.payload->>'claim_id'),
'status_code', cu.status_code,
'unified_id', cu.unified_id,
'contact_id', cu.contact_id,
'phone', cu.phone,
'session_token', cu.session_token,
'payload', cu.payload
) FROM claim_upsert cu) AS claim,
(SELECT jsonb_agg(jsonb_build_object(
'id', id,
'field_name', field_name,
'file_id', file_id,
'file_name', file_name,
'original_file_name', original_file_name
)) FROM docs_upsert) AS documents;

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
-- Исправление: возврат правильного claim_document_id из таблицы clpr_claim_documents
-- Проблема: возвращается не id из таблицы, а что-то другое (возможно из documents_meta)
-- В SQL запросе должно быть:
-- 1. В CTE upsert_docs: RETURNING id (это claim_document_id из таблицы)
-- 2. В финальном SELECT: использовать u.id из upsert_docs, а НЕ из documents_meta
-- ПРАВИЛЬНЫЙ ВАРИАНТ:
SELECT
(SELECT jsonb_build_object(...) FROM upd_claim u) AS claim,
(
SELECT jsonb_agg(
jsonb_build_object(
'id', u.id, -- ✅ Это claim_document_id из таблицы clpr_claim_documents
'claim_document_id', u.id, -- ✅ Явно указываем, что это claim_document_id
'field_name', u.field_name,
'file_id', u.file_id,
'file_url', d.file_url,
'file_name', d.file_name,
'original_file_name', d.original_file_name,
'uploaded_at', d.uploaded_at,
'filename_for_upload',
COALESCE(
NULLIF(d.original_file_name, ''),
NULLIF(d.file_name, ''),
regexp_replace(d.file_id, '^.*/', '')
)
)
)
FROM upsert_docs u -- ✅ Используем u.id из upsert_docs (таблица clpr_claim_documents)
JOIN docs d ON d.claim_id = u.claim_id AND d.field_name = u.field_name
WHERE d.file_url IS NOT NULL AND d.file_url <> ''
) AS documents;
-- ❌ НЕПРАВИЛЬНО (если используется id из documents_meta):
-- 'id', d.id -- Это НЕ claim_document_id из таблицы!
-- ✅ ПРАВИЛЬНО:
-- 'id', u.id -- Это claim_document_id из таблицы clpr_claim_documents

View File

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

View File

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

View File

@@ -3,10 +3,17 @@
--
-- Параметры:
-- $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() (время обновления)
--
-- После этого запись больше не будет показываться в списке черновиков
@@ -29,6 +36,14 @@ 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
@@ -37,5 +52,5 @@ RETURNING
c.payload->>'claim_id' AS claim_id,
c.status_code,
c.is_confirmed,
c.updated_at;
c.updated_at,
c.payload->'approved_form' IS NOT NULL AS has_approved_form;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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