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:
@@ -342,3 +342,4 @@ TTL: 86400 секунд
|
||||
**Дата:** 2025-11-20
|
||||
**Статус:** ✅ Завершено
|
||||
|
||||
|
||||
|
||||
@@ -208,3 +208,4 @@ $2 = {{ $json.claim_id }} (TEXT, строка "CLM-2025-11-18-GEQ3K
|
||||
|
||||
Оба запроса теперь используют строковый `claim_id` и правильно находят UUID.
|
||||
|
||||
|
||||
|
||||
@@ -101,3 +101,4 @@ function mapCombinedDocs(cds = []) {
|
||||
|
||||
Но для `mapDialogHistory` это критично, т.к. она вызывается первой и падает.
|
||||
|
||||
|
||||
|
||||
@@ -210,3 +210,4 @@ const results = arr
|
||||
|
||||
return results.length ? results : [{ json: null }];
|
||||
|
||||
|
||||
|
||||
@@ -181,3 +181,4 @@ clpr_user_accounts (channel='telegram', channel_user_id=telegram_id)
|
||||
clpr_users (id)
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -36,3 +36,4 @@ return {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -45,3 +45,4 @@ return {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
261
docs/N8N_FORM_GET_NO_FILES_BRANCH.json
Normal file
261
docs/N8N_FORM_GET_NO_FILES_BRANCH.json
Normal 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": "Протестировать отправку формы БЕЗ файлов"
|
||||
}
|
||||
}
|
||||
|
||||
401
docs/N8N_FORM_GET_NO_FILES_INSTRUCTIONS.md
Normal file
401
docs/N8N_FORM_GET_NO_FILES_INSTRUCTIONS.md
Normal 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
|
||||
**Статус:** Готово к внедрению ✅
|
||||
|
||||
@@ -92,3 +92,4 @@ updateFormData({
|
||||
4. **PostgreSQL: Find or Create User** → выполняет SQL запрос → возвращает `unified_id`
|
||||
5. **Response** → возвращает полный ответ с `unified_id`
|
||||
|
||||
|
||||
|
||||
@@ -142,3 +142,4 @@ return {
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -131,3 +131,4 @@ WHERE ua.channel = 'web_form'
|
||||
|
||||
Должна быть запись с `unified_id` в формате `usr_...`.
|
||||
|
||||
|
||||
|
||||
@@ -429,3 +429,4 @@ return claim;
|
||||
- ✅ Возобновление заполнения формы
|
||||
- ✅ Быстрая загрузка состояния формы
|
||||
|
||||
|
||||
|
||||
@@ -189,3 +189,4 @@ if (channel === 'telegram') {
|
||||
|
||||
Все данные уже в PostgreSQL, и этого достаточно. Redis используется только для Pub/Sub событий (`ocr_events:{claim_id}`).
|
||||
|
||||
|
||||
|
||||
@@ -196,3 +196,4 @@ if (channel === 'web_form' && enable_cache === true) {
|
||||
|
||||
Но это опционально и не обязательно для веб-формы.
|
||||
|
||||
|
||||
|
||||
@@ -70,3 +70,4 @@
|
||||
3. Если API вызывается, но возвращает 0 - проверить SQL запрос в backend
|
||||
4. Если SQL работает, но asyncpg не возвращает данные - проверить формат параметров
|
||||
|
||||
|
||||
|
||||
@@ -112,3 +112,4 @@ final_claim_id = row.get('claim_id') or claim_id_from_payload
|
||||
2. Протестировать загрузку черновика из Telegram формата
|
||||
3. Убедиться, что все данные корректно восстанавливаются в форму
|
||||
|
||||
|
||||
|
||||
55
docs/SQL_ALTER_CLPR_CLAIMS_ADD_FIELDS.sql
Normal file
55
docs/SQL_ALTER_CLPR_CLAIMS_ADD_FIELDS.sql
Normal 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;
|
||||
|
||||
227
docs/SQL_CLAIMSAVE_UPSERT_SIMPLE.sql
Normal file
227
docs/SQL_CLAIMSAVE_UPSERT_SIMPLE.sql
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -129,3 +129,4 @@ WITH existing AS (
|
||||
2. Вернуть `unified_id` в ответе frontend (в `result.unified_id`)
|
||||
3. При создании/обновлении черновика заполнять `clpr_claims.unified_id = unified_id`
|
||||
|
||||
|
||||
|
||||
@@ -70,3 +70,4 @@ WHERE c.session_token = $1 -- session_id
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT 20;
|
||||
|
||||
|
||||
|
||||
@@ -209,3 +209,4 @@ SELECT
|
||||
- ✅ Все подзапросы используют `LIMIT 1` для гарантии одной строки
|
||||
- ✅ Правильное слияние `answers` и `documents_meta`
|
||||
|
||||
|
||||
|
||||
@@ -111,3 +111,4 @@
|
||||
|
||||
Выполни задачу прямо сейчас и верни JSON согласно схеме.
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user