feat: Add PostgreSQL fields and workflow for form without files

Database changes:
- Add unified_id, contact_id, phone columns to clpr_claims table
- Create indexes for fast lookup by these fields
- Migrate existing data from payload to new columns
- SQL migration: docs/SQL_ALTER_CLPR_CLAIMS_ADD_FIELDS.sql

SQL improvements:
- Simplify claimsave query: remove complex claim_lookup logic
- Use UPSERT (INSERT ON CONFLICT) with known claim_id
- Always return claim (fix NULL issue)
- Store unified_id, contact_id, phone directly in table columns
- SQL: docs/SQL_CLAIMSAVE_UPSERT_SIMPLE.sql

Workflow enhancements:
- Add branch for form submissions WITHOUT files
- Create 6 new nodes: extract, prepare, save, redis, respond
- Separate flow for has_files=false in IF node
- Instructions: docs/N8N_FORM_GET_NO_FILES_INSTRUCTIONS.md
- Node config: docs/N8N_FORM_GET_NO_FILES_BRANCH.json

Migration stats:
- Total claims: 81
- With unified_id: 77
- Migrated from payload: 2

Next steps:
1. Add 6 nodes to n8n workflow form_get (ID: 8ZVMTsuH7Cmw7snw)
2. Connect TRUE branch of IF node to extract_webhook_data_no_files
3. Test form submission without files
4. Verify PostgreSQL save and Redis event
This commit is contained in:
AI Assistant
2025-11-21 15:57:18 +03:00
parent 3621ae6021
commit d6b17baa7d
23 changed files with 963 additions and 0 deletions

View File

@@ -342,3 +342,4 @@ TTL: 86400 секунд
**Дата:** 2025-11-20
**Статус:** ✅ Завершено

View File

@@ -208,3 +208,4 @@ $2 = {{ $json.claim_id }} (TEXT, строка "CLM-2025-11-18-GEQ3K
Оба запроса теперь используют строковый `claim_id` и правильно находят UUID.

View File

@@ -101,3 +101,4 @@ function mapCombinedDocs(cds = []) {
Но для `mapDialogHistory` это критично, т.к. она вызывается первой и падает.

View File

@@ -210,3 +210,4 @@ const results = arr
return results.length ? results : [{ json: null }];

View File

@@ -181,3 +181,4 @@ clpr_user_accounts (channel='telegram', channel_user_id=telegram_id)
clpr_users (id)
```

View File

@@ -36,3 +36,4 @@ return {
}
};

View File

@@ -45,3 +45,4 @@ return {
}
};

View File

@@ -0,0 +1,261 @@
{
"meta": {
"description": "Ноды для обработки формы БЕЗ файлов в workflow form_get",
"date": "2025-11-21",
"action": "Добавить в TRUE ветку IF-ноды 'проверка наличия файлов'"
},
"nodes": [
{
"name": "extract_webhook_data_no_files",
"description": "Извлекаем данные из webhook для случая без файлов",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [-320, 400],
"parameters": {
"assignments": {
"assignments": [
{
"id": "session_id",
"name": "session_id",
"value": "={{ $('Webhook').item.json.body.session_id }}",
"type": "string"
},
{
"id": "claim_id",
"name": "claim_id",
"value": "={{ $('Webhook').item.json.body.claim_id }}",
"type": "string"
},
{
"id": "unified_id",
"name": "unified_id",
"value": "={{ $('Webhook').item.json.body.unified_id }}",
"type": "string"
},
{
"id": "contact_id",
"name": "contact_id",
"value": "={{ $('Webhook').item.json.body.contact_id }}",
"type": "string"
},
{
"id": "phone",
"name": "phone",
"value": "={{ $('Webhook').item.json.body.phone }}",
"type": "string"
},
{
"id": "wizard_plan",
"name": "wizard_plan",
"value": "={{ $('Webhook').item.json.body.wizard_plan }}",
"type": "object"
},
{
"id": "wizard_answers",
"name": "wizard_answers",
"value": "={{ $('Webhook').item.json.body.wizard_answers }}",
"type": "object"
},
{
"id": "type_code",
"name": "type_code",
"value": "={{ $('Webhook').item.json.body.type_code || 'consumer' }}",
"type": "string"
}
]
},
"options": {}
}
},
{
"name": "prepare_payload_no_files",
"description": "Формируем payload для PostgreSQL",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [-80, 400],
"parameters": {
"assignments": {
"assignments": [
{
"id": "payload_partial_json",
"name": "payload_partial_json",
"value": "={{ {\n session_id: $json.session_id,\n unified_id: $json.unified_id,\n contact_id: $json.contact_id,\n phone: $json.phone,\n type_code: $json.type_code,\n wizard_plan: $json.wizard_plan,\n wizard_answers: $json.wizard_answers,\n documents_meta: []\n} }}",
"type": "object"
},
{
"id": "claim_id",
"name": "claim_id",
"value": "={{ $json.claim_id }}",
"type": "string"
}
]
},
"options": {}
}
},
{
"name": "save_claim_no_files",
"description": "Сохраняем claim без файлов в PostgreSQL",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [180, 400],
"parameters": {
"operation": "executeQuery",
"query": "WITH partial AS (\n SELECT \n $1::jsonb AS p, \n $2::text AS claim_id_str\n),\n\n-- Парсим wizard_answers\nwizard_answers_parsed AS (\n SELECT \n CASE \n WHEN partial.p->>'wizard_answers' IS NOT NULL \n THEN (partial.p->>'wizard_answers')::jsonb\n WHEN partial.p->'wizard_answers' IS NOT NULL \n AND jsonb_typeof(partial.p->'wizard_answers') = 'object'\n THEN partial.p->'wizard_answers'\n ELSE '{}'::jsonb\n END AS answers\n FROM partial\n),\n\n-- Парсим wizard_plan\nwizard_plan_parsed AS (\n SELECT \n CASE \n WHEN partial.p->>'wizard_plan' IS NOT NULL \n THEN (partial.p->>'wizard_plan')::jsonb\n WHEN partial.p->'wizard_plan' IS NOT NULL \n AND jsonb_typeof(partial.p->'wizard_plan') = 'object'\n THEN partial.p->'wizard_plan'\n ELSE NULL\n END AS wizard_plan\n FROM partial\n),\n\n-- UPSERT claim\nclaim_upsert AS (\n INSERT INTO clpr_claims (\n id,\n session_token,\n unified_id,\n contact_id,\n phone,\n channel,\n type_code,\n status_code,\n payload,\n created_at,\n updated_at,\n expires_at\n )\n SELECT \n partial.claim_id_str::uuid,\n COALESCE(partial.p->>'session_id', 'sess-unknown'),\n partial.p->>'unified_id',\n partial.p->>'contact_id',\n partial.p->>'phone',\n 'web_form',\n COALESCE(partial.p->>'type_code', 'consumer'),\n 'draft',\n jsonb_build_object(\n 'claim_id', partial.claim_id_str,\n 'answers', (SELECT answers FROM wizard_answers_parsed),\n 'documents_meta', '[]'::jsonb,\n 'wizard_plan', (SELECT wizard_plan FROM wizard_plan_parsed)\n ),\n COALESCE(\n (SELECT created_at FROM clpr_claims WHERE id = partial.claim_id_str::uuid),\n now()\n ),\n now(),\n now() + interval '14 days'\n FROM partial\n ON CONFLICT (id) DO UPDATE SET\n session_token = EXCLUDED.session_token,\n unified_id = COALESCE(EXCLUDED.unified_id, clpr_claims.unified_id),\n contact_id = COALESCE(EXCLUDED.contact_id, clpr_claims.contact_id),\n phone = COALESCE(EXCLUDED.phone, clpr_claims.phone),\n status_code = 'draft',\n payload = (\n clpr_claims.payload \n - 'answers' \n - 'documents_meta' \n - 'wizard_plan' \n - 'claim_id'\n ) || EXCLUDED.payload,\n updated_at = now(),\n expires_at = now() + interval '14 days'\n RETURNING id, status_code, payload, unified_id, contact_id, phone, session_token\n)\n\nSELECT\n (SELECT jsonb_build_object(\n 'claim_id', cu.id::text,\n 'claim_id_str', (cu.payload->>'claim_id'),\n 'status_code', cu.status_code,\n 'unified_id', cu.unified_id,\n 'contact_id', cu.contact_id,\n 'phone', cu.phone,\n 'session_token', cu.session_token,\n 'payload', cu.payload\n ) FROM claim_upsert cu) AS claim;",
"options": {
"queryReplacement": "={{ $json.payload_partial_json }}, {{ $json.claim_id }}"
}
},
"credentials": {
"postgres": {
"id": "sGJ0fJhU8rz88w3k",
"name": "timeweb_bd"
}
}
},
{
"name": "prepare_redis_event_no_files",
"description": "Готовим событие для публикации в Redis",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [440, 400],
"parameters": {
"assignments": {
"assignments": [
{
"id": "redis_key",
"name": "redis_key",
"value": "=ocr_events:{{ $('extract_webhook_data_no_files').item.json.session_id }}",
"type": "string"
},
{
"id": "redis_value",
"name": "redis_value",
"value": "={{ {\n event_type: 'form_saved',\n claim_id: $json.claim.claim_id,\n status_code: $json.claim.status_code,\n unified_id: $json.claim.unified_id,\n contact_id: $json.claim.contact_id,\n phone: $json.claim.phone,\n session_token: $json.claim.session_token,\n has_files: false,\n timestamp: new Date().toISOString()\n} }}",
"type": "object"
}
]
},
"options": {}
}
},
{
"name": "publish_to_redis_no_files",
"description": "Публикуем событие в Redis",
"type": "n8n-nodes-base.redis",
"typeVersion": 1,
"position": [700, 400],
"parameters": {
"operation": "publish",
"channel": "={{ $json.redis_key }}",
"value": "={{ JSON.stringify($json.redis_value) }}",
"options": {}
},
"credentials": {
"redis": {
"id": "RKICQB2ZaisVK4WS",
"name": "Local Redis"
}
}
},
{
"name": "respond_no_files",
"description": "Возвращаем ответ клиенту",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [960, 400],
"parameters": {
"options": {
"responseCode": 200
},
"respondWith": "json",
"responseBody": "={{ {\n success: true,\n claim_id: $('save_claim_no_files').item.json.claim.claim_id,\n status_code: $('save_claim_no_files').item.json.claim.status_code,\n has_files: false,\n message: 'Заявка сохранена без файлов'\n} }}"
}
}
],
"connections": {
"проверка наличия файлов": {
"main": [
[
{
"node": "extract_webhook_data_no_files",
"type": "main",
"index": 0
}
],
[
{
"node": "set_token1",
"type": "main",
"index": 0
}
]
]
},
"extract_webhook_data_no_files": {
"main": [
[
{
"node": "prepare_payload_no_files",
"type": "main",
"index": 0
}
]
]
},
"prepare_payload_no_files": {
"main": [
[
{
"node": "save_claim_no_files",
"type": "main",
"index": 0
}
]
]
},
"save_claim_no_files": {
"main": [
[
{
"node": "prepare_redis_event_no_files",
"type": "main",
"index": 0
}
]
]
},
"prepare_redis_event_no_files": {
"main": [
[
{
"node": "publish_to_redis_no_files",
"type": "main",
"index": 0
}
]
]
},
"publish_to_redis_no_files": {
"main": [
[
{
"node": "respond_no_files",
"type": "main",
"index": 0
}
]
]
}
},
"instructions": {
"step1": "Открыть workflow 'form_get' (ID: 8ZVMTsuH7Cmw7snw) в n8n",
"step2": "Найти IF-ноду 'проверка наличия файлов' (ID: b7497f29-dab3-41cd-aaa3-a43ee83e607c)",
"step3": "TRUE ветка (index 0) сейчас пустая - туда нужно добавить новые ноды",
"step4": "Импортировать ноды из этого JSON или создать вручную по схеме",
"step5": "Подключить TRUE ветку IF к первой ноде: extract_webhook_data_no_files",
"step6": "Сохранить и активировать workflow",
"step7": "Протестировать отправку формы БЕЗ файлов"
}
}

View File

@@ -0,0 +1,401 @@
# Инструкция: Добавление обработки формы БЕЗ файлов в workflow form_get
**Дата:** 2025-11-21
**Workflow ID:** `8ZVMTsuH7Cmw7snw`
**Workflow Name:** `form_get`
---
## 🎯 Цель
Добавить ветку обработки для случая, когда форма отправляется **без файлов** (`has_files === false`).
---
## 📍 Где добавлять
В workflow `form_get` есть IF-нода **"проверка наличия файлов"** (ID: `b7497f29-dab3-41cd-aaa3-a43ee83e607c`):
- **TRUE ветка** (index 0) — файлов НЕТ**ПУСТАЯ**
- **FALSE ветка** (index 1) — файлы ЕСТЬ → существующий flow ✅
**Задача:** Добавить ноды в TRUE ветку.
---
## 📝 Пошаговая инструкция
### Шаг 1: Открыть workflow
1. Перейти в n8n: https://n8n.clientright.pro
2. Открыть workflow **"form_get"**
3. Найти ноду **"проверка наличия файлов"** (IF)
---
### Шаг 2: Добавить ноду #1 - Extract Data
**Название:** `extract_webhook_data_no_files`
**Тип:** `Edit Fields` (Set)
**Позиция:** справа от IF-ноды, выше основного flow
**Параметры:**
| Field Name | Type | Value |
|------------|------|-------|
| `session_id` | String | `={{ $('Webhook').item.json.body.session_id }}` |
| `claim_id` | String | `={{ $('Webhook').item.json.body.claim_id }}` |
| `unified_id` | String | `={{ $('Webhook').item.json.body.unified_id }}` |
| `contact_id` | String | `={{ $('Webhook').item.json.body.contact_id }}` |
| `phone` | String | `={{ $('Webhook').item.json.body.phone }}` |
| `wizard_plan` | Object | `={{ $('Webhook').item.json.body.wizard_plan }}` |
| `wizard_answers` | Object | `={{ $('Webhook').item.json.body.wizard_answers }}` |
| `type_code` | String | `={{ $('Webhook').item.json.body.type_code || 'consumer' }}` |
**Подключение:**
- Из ноды **"проверка наличия файлов"** → TRUE (верхний выход)
- К ноде **"extract_webhook_data_no_files"**
---
### Шаг 3: Добавить ноду #2 - Prepare Payload
**Название:** `prepare_payload_no_files`
**Тип:** `Edit Fields` (Set)
**Параметры:**
| Field Name | Type | Value |
|------------|------|-------|
| `payload_partial_json` | Object | См. ниже ⬇️ |
| `claim_id` | String | `={{ $json.claim_id }}` |
**Значение для `payload_partial_json`:**
```javascript
={{ {
session_id: $json.session_id,
unified_id: $json.unified_id,
contact_id: $json.contact_id,
phone: $json.phone,
type_code: $json.type_code,
wizard_plan: $json.wizard_plan,
wizard_answers: $json.wizard_answers,
documents_meta: []
} }}
```
**Подключение:**
- Из ноды **"extract_webhook_data_no_files"**
- К ноде **"prepare_payload_no_files"**
---
### Шаг 4: Добавить ноду #3 - Save to PostgreSQL
**Название:** `save_claim_no_files`
**Тип:** `Postgres`
**Operation:** `Execute Query`
**Credentials:** `timeweb_bd` (существующие)
**Query:**
```sql
WITH partial AS (
SELECT
$1::jsonb AS p,
$2::text AS claim_id_str
),
wizard_answers_parsed AS (
SELECT
CASE
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_parsed AS (
SELECT
CASE
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'
ELSE NULL
END AS wizard_plan
FROM partial
),
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
partial.claim_id_str::uuid,
COALESCE(partial.p->>'session_id', 'sess-unknown'),
partial.p->>'unified_id',
partial.p->>'contact_id',
partial.p->>'phone',
'web_form',
COALESCE(partial.p->>'type_code', 'consumer'),
'draft',
jsonb_build_object(
'claim_id', partial.claim_id_str,
'answers', (SELECT answers FROM wizard_answers_parsed),
'documents_meta', '[]'::jsonb,
'wizard_plan', (SELECT wizard_plan FROM wizard_plan_parsed)
),
COALESCE(
(SELECT created_at FROM clpr_claims WHERE id = partial.claim_id_str::uuid),
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 = 'draft',
payload = (
clpr_claims.payload
- 'answers'
- 'documents_meta'
- 'wizard_plan'
- 'claim_id'
) || EXCLUDED.payload,
updated_at = now(),
expires_at = now() + interval '14 days'
RETURNING id, status_code, payload, unified_id, contact_id, phone, session_token
)
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;
```
**Query Replacement:** `={{ $json.payload_partial_json }}, {{ $json.claim_id }}`
**Подключение:**
- Из ноды **"prepare_payload_no_files"**
- К ноде **"save_claim_no_files"**
---
### Шаг 5: Добавить ноду #4 - Prepare Redis Event
**Название:** `prepare_redis_event_no_files`
**Тип:** `Edit Fields` (Set)
**Параметры:**
| Field Name | Type | Value |
|------------|------|-------|
| `redis_key` | String | `=ocr_events:{{ $('extract_webhook_data_no_files').item.json.session_id }}` |
| `redis_value` | Object | См. ниже ⬇️ |
**Значение для `redis_value`:**
```javascript
={{ {
event_type: 'form_saved_no_files',
claim_id: $json.claim.claim_id,
status_code: $json.claim.status_code,
unified_id: $json.claim.unified_id,
contact_id: $json.claim.contact_id,
phone: $json.claim.phone,
session_token: $json.claim.session_token,
has_files: false,
timestamp: new Date().toISOString()
} }}
```
**Подключение:**
- Из ноды **"save_claim_no_files"**
- К ноде **"prepare_redis_event_no_files"**
---
### Шаг 6: Добавить ноду #5 - Publish to Redis
**Название:** `publish_to_redis_no_files`
**Тип:** `Redis`
**Operation:** `Publish`
**Credentials:** `Local Redis` (существующие)
**Параметры:**
| Parameter | Value |
|-----------|-------|
| Channel | `={{ $json.redis_key }}` |
| Value | `={{ JSON.stringify($json.redis_value) }}` |
**Подключение:**
- Из ноды **"prepare_redis_event_no_files"**
- К ноде **"publish_to_redis_no_files"**
---
### Шаг 7: Добавить ноду #6 - Respond to Webhook
**Название:** `respond_no_files`
**Тип:** `Respond to Webhook`
**Response Code:** `200`
**Response Body:**
```javascript
={{ {
success: true,
claim_id: $('save_claim_no_files').item.json.claim.claim_id,
status_code: $('save_claim_no_files').item.json.claim.status_code,
has_files: false,
message: 'Заявка сохранена без файлов'
} }}
```
**Подключение:**
- Из ноды **"publish_to_redis_no_files"**
- К ноде **"respond_no_files"** (финальная нода)
---
## 🔄 Финальная структура workflow
```
Webhook → Code17 (парсинг файлов)
IF "проверка наличия файлов"
├─ TRUE (файлов НЕТ) → [НОВАЯ ВЕТКА]
│ ↓
│ extract_webhook_data_no_files
│ ↓
│ prepare_payload_no_files
│ ↓
│ save_claim_no_files (PostgreSQL)
│ ↓
│ prepare_redis_event_no_files
│ ↓
│ publish_to_redis_no_files
│ ↓
│ respond_no_files
└─ FALSE (файлы ЕСТЬ) → существующий flow
set_token1 → get_data1 → Upload → ...
```
---
## ✅ Проверка
После добавления нод:
1. **Сохранить workflow** (Ctrl+S)
2. **Активировать workflow** (если не активен)
3. **Протестировать:**
- Отправить форму БЕЗ файлов
- Проверить, что заявка сохранилась в PostgreSQL
- Проверить событие в Redis: `redis-cli GET "ocr_events:sess-xxx"`
- Проверить ответ webhook: `success: true, has_files: false`
---
## 📊 Ожидаемый результат
**Вход (webhook body):**
```json
{
"session_id": "sess_xxx",
"claim_id": "uuid",
"unified_id": "usr_xxx",
"contact_id": "12345",
"phone": "79262306381",
"wizard_answers": {"q1": "answer1"},
"wizard_plan": null
}
```
**Выход (claim в PostgreSQL):**
```json
{
"claim": {
"claim_id": "uuid",
"status_code": "draft",
"unified_id": "usr_xxx",
"contact_id": "12345",
"phone": "79262306381",
"session_token": "sess_xxx",
"payload": {
"claim_id": "uuid",
"answers": {"q1": "answer1"},
"documents_meta": [],
"wizard_plan": null
}
}
}
```
**Redis событие:**
```json
{
"event_type": "form_saved_no_files",
"claim_id": "uuid",
"status_code": "draft",
"has_files": false,
"timestamp": "2025-11-21T15:00:00.000Z"
}
```
---
## 🛠️ Troubleshooting
### Проблема 1: "session_token": "sess-unknown"
**Причина:** В payload не передан `session_id`
**Решение:** Проверить, что фронтенд отправляет `session_id` в body
### Проблема 2: "contact_id": null
**Причина:** Поле не извлекается из webhook
**Решение:** Проверить путь в expression: `$('Webhook').item.json.body.contact_id`
### Проблема 3: Ошибка PostgreSQL
**Причина:** Неправильный формат данных
**Решение:** Проверить логи n8n и формат `payload_partial_json`
---
**Автор:** AI Assistant
**Дата:** 2025-11-21
**Статус:** Готово к внедрению ✅

View File

@@ -92,3 +92,4 @@ updateFormData({
4. **PostgreSQL: Find or Create User** → выполняет SQL запрос → возвращает `unified_id`
5. **Response** → возвращает полный ответ с `unified_id`

View File

@@ -142,3 +142,4 @@ return {
}
```

View File

@@ -131,3 +131,4 @@ WHERE ua.channel = 'web_form'
Должна быть запись с `unified_id` в формате `usr_...`.

View File

@@ -429,3 +429,4 @@ return claim;
- ✅ Возобновление заполнения формы
- ✅ Быстрая загрузка состояния формы

View File

@@ -189,3 +189,4 @@ if (channel === 'telegram') {
Все данные уже в PostgreSQL, и этого достаточно. Redis используется только для Pub/Sub событий (`ocr_events:{claim_id}`).

View File

@@ -196,3 +196,4 @@ if (channel === 'web_form' && enable_cache === true) {
Но это опционально и не обязательно для веб-формы.

View File

@@ -70,3 +70,4 @@
3. Если API вызывается, но возвращает 0 - проверить SQL запрос в backend
4. Если SQL работает, но asyncpg не возвращает данные - проверить формат параметров

View File

@@ -112,3 +112,4 @@ final_claim_id = row.get('claim_id') or claim_id_from_payload
2. Протестировать загрузку черновика из Telegram формата
3. Убедиться, что все данные корректно восстанавливаются в форму

View File

@@ -0,0 +1,55 @@
-- Миграция для добавления полей unified_id, contact_id, phone в таблицу clpr_claims
-- Дата: 2025-11-21
-- Описание: Добавляем поля для хранения идентификаторов пользователя напрямую в таблице заявок
-- 1. Добавляем unified_id (уникальный идентификатор пользователя из CRM)
ALTER TABLE clpr_claims
ADD COLUMN IF NOT EXISTS unified_id TEXT;
-- 2. Добавляем contact_id (ID контакта в RetailCRM)
ALTER TABLE clpr_claims
ADD COLUMN IF NOT EXISTS contact_id TEXT;
-- 3. Добавляем phone (номер телефона пользователя)
ALTER TABLE clpr_claims
ADD COLUMN IF NOT EXISTS phone TEXT;
-- 4. Создаём индексы для быстрого поиска
CREATE INDEX IF NOT EXISTS idx_clpr_claims_unified_id
ON clpr_claims(unified_id)
WHERE unified_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_clpr_claims_contact_id
ON clpr_claims(contact_id)
WHERE contact_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_clpr_claims_phone
ON clpr_claims(phone)
WHERE phone IS NOT NULL;
-- 5. Комментарии к полям
COMMENT ON COLUMN clpr_claims.unified_id IS 'Уникальный идентификатор пользователя из CRM (формат: usr_xxx)';
COMMENT ON COLUMN clpr_claims.contact_id IS 'ID контакта в RetailCRM';
COMMENT ON COLUMN clpr_claims.phone IS 'Номер телефона пользователя (формат: 79262306381)';
-- 6. Опционально: заполняем существующие записи из payload (если есть)
UPDATE clpr_claims
SET
unified_id = payload->>'unified_id',
contact_id = payload->>'contact_id',
phone = payload->>'phone'
WHERE unified_id IS NULL
AND (
payload->>'unified_id' IS NOT NULL
OR payload->>'contact_id' IS NOT NULL
OR payload->>'phone' IS NOT NULL
);
-- Проверка результата
SELECT
COUNT(*) as total_claims,
COUNT(unified_id) as with_unified_id,
COUNT(contact_id) as with_contact_id,
COUNT(phone) as with_phone
FROM clpr_claims;

View File

@@ -0,0 +1,227 @@
-- Упрощённый UPSERT для сохранения claim с известным claim_id
-- Используется в n8n workflow: form_get (нода claimsave)
-- Дата: 2025-11-21
-- Описание: Простой INSERT/UPDATE для claim, т.к. claim_id уже известен
-- Входные параметры:
-- $1: payload_partial_json (jsonb) - данные формы с wizard_answers, wizard_plan, documents_meta
-- $2: claim_id (text) - UUID заявки
WITH partial AS (
SELECT
$1::jsonb AS p,
$2::text AS claim_id_str
),
-- Парсим wizard_answers
wizard_answers_parsed AS (
SELECT
CASE
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->>'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'
ELSE NULL
END AS wizard_plan
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
partial.claim_id_str::uuid,
COALESCE(partial.p->>'session_id', 'sess-unknown'),
partial.p->>'unified_id',
partial.p->>'contact_id',
partial.p->>'phone',
'web_form',
COALESCE(partial.p->>'type_code', 'consumer'),
CASE
WHEN (SELECT answers->>'docs_exist' FROM wizard_answers_parsed) = 'true'
THEN 'in_work'
ELSE 'draft'
END,
jsonb_build_object(
'claim_id', partial.claim_id_str,
'answers', (SELECT answers FROM wizard_answers_parsed),
'documents_meta', COALESCE(partial.p->'documents_meta', '[]'::jsonb),
'wizard_plan', (SELECT wizard_plan FROM wizard_plan_parsed)
),
COALESCE(
(SELECT created_at FROM clpr_claims WHERE id = partial.claim_id_str::uuid),
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 = EXCLUDED.status_code,
payload = (
-- Сохраняем старые поля, которых нет в новом payload
clpr_claims.payload
- 'answers'
- 'documents_meta'
- 'wizard_plan'
- 'claim_id'
) || EXCLUDED.payload,
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;
/*
ПРИМЕРЫ ИСПОЛЬЗОВАНИЯ:
1. Вызов с wizard_answers и wizard_plan:
SELECT * FROM ... WHERE ... = (
'{
"session_id": "sess_xxx",
"unified_id": "usr_xxx",
"contact_id": "12345",
"phone": "79262306381",
"wizard_answers": "{\\"q1\\": \\"answer1\\"}",
"wizard_plan": "{\\"questions\\": [...]}",
"documents_meta": [
{
"field_name": "uploads[0][0]",
"file_id": "clientright/0/file.pdf",
"file_name": "file.pdf",
"original_file_name": "original.pdf",
"uploaded_at": "2025-11-21T12:00:00Z"
}
]
}'::jsonb,
'uuid-here'::text
);
2. Вызов БЕЗ файлов (только answers):
SELECT * FROM ... WHERE ... = (
'{
"session_id": "sess_xxx",
"unified_id": "usr_xxx",
"contact_id": "12345",
"phone": "79262306381",
"wizard_answers": "{\\"q1\\": \\"answer1\\"}",
"wizard_plan": null,
"documents_meta": []
}'::jsonb,
'uuid-here'::text
);
РЕЗУЛЬТАТ:
{
"claim": {
"claim_id": "uuid",
"claim_id_str": "uuid",
"status_code": "draft" or "in_work",
"unified_id": "usr_xxx",
"contact_id": "12345",
"phone": "79262306381",
"session_token": "sess_xxx",
"payload": {...}
},
"documents": [
{
"id": "uuid",
"field_name": "uploads[0][0]",
"file_id": "clientright/0/file.pdf",
"file_name": "file.pdf",
"original_file_name": "original.pdf"
}
]
}
*/

View File

@@ -129,3 +129,4 @@ WITH existing AS (
2. Вернуть `unified_id` в ответе frontend (в `result.unified_id`)
3. При создании/обновлении черновика заполнять `clpr_claims.unified_id = unified_id`

View File

@@ -70,3 +70,4 @@ WHERE c.session_token = $1 -- session_id
ORDER BY c.updated_at DESC
LIMIT 20;

View File

@@ -209,3 +209,4 @@ SELECT
-Все подзапросы используют `LIMIT 1` для гарантии одной строки
- ✅ Правильное слияние `answers` и `documents_meta`

View File

@@ -111,3 +111,4 @@
Выполни задачу прямо сейчас и верни JSON согласно схеме.