Добавлен field_label в результат переименования файлов, исправлена загрузка черновиков, обновлен формат пути S3 с project_name

This commit is contained in:
Fedor
2025-11-22 09:38:38 +03:00
parent d3ba054027
commit 486f3619ff
212 changed files with 6704 additions and 123 deletions

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

@@ -75,3 +75,5 @@ return [{
}
}];

View File

@@ -0,0 +1,163 @@
// ============================================================================
// Code Node: Подготовка данных для claimsave_primary
// ============================================================================
// Назначение: Собрать все данные из предыдущих узлов и подготовить payload
// для сохранения первичного черновика в PostgreSQL
//
// Позиция: После Code4, перед claimsave_primary (PostgreSQL)
// ============================================================================
const items = $input.all();
// Получаем данные из разных узлов
const code4Data = $('Code4').first().json.redis_value || {};
const editFields16 = $('Edit Fields16').first().json;
const aiAgent1Facts = $('пробрасываем факт фул и факт шорт1').first().json;
const aiAgent13 = $('AI Agent13').first().json;
const redisTrigger = $('Redis Trigger').first().json.message || {};
const editFields11 = $('Edit Fields11').first().json || {};
const editFields10 = $('Edit Fields10').first().json || {};
const propertyNameRaw = $('propertyName').first().json || {};
// propertyName может быть массивом или объектом
// Если массив - берем первый элемент, если объект - используем как есть
const propertyName = Array.isArray(propertyNameRaw)
? (propertyNameRaw[0] || {})
: propertyNameRaw;
// Логирование для отладки unified_id
console.log('🔍 Поиск unified_id:');
console.log(' - propertyNameRaw (тип):', Array.isArray(propertyNameRaw) ? 'массив' : 'объект');
console.log(' - propertyName:', propertyName);
console.log(' - propertyName.unified_id:', propertyName?.unified_id);
console.log(' - propertyName.body?.unified_id:', propertyName?.body?.unified_id);
console.log(' - propertyName.result?.unified_id:', propertyName?.result?.unified_id);
console.log(' - editFields10.unified_id:', editFields10?.unified_id);
console.log(' - redisTrigger.unified_id:', redisTrigger?.unified_id);
// Собираем payload для сохранения
const payload = {
// Описание проблемы от пользователя
problem_description: editFields16?.chatInput || redisTrigger?.description || null,
// Wizard plan от AI Agent12 (через Code4)
wizard_plan: code4Data.wizard_plan || null,
// Предзаполненные ответы (если есть)
answers_prefill: code4Data.answers_prefill || [],
// Отчёт о покрытии (если есть)
coverage_report: code4Data.coverage_report || {},
// Данные из AI Agent1 (факты)
ai_agent1_facts: {
facts_short: aiAgent1Facts?.facts_short || null,
facts_full: aiAgent1Facts?.facts_full || null,
problem: aiAgent1Facts?.problem || null
},
// Данные из AI Agent13 (RAG ответ)
ai_agent13_rag: aiAgent13?.output || null,
// Контакты
phone: redisTrigger?.phone || null,
email: redisTrigger?.email || null,
// Тип дела (из wizard_plan или по умолчанию)
type_code: code4Data.wizard_plan?.case_type || 'consumer'
};
// Получаем session_token (приоритет: Edit Fields11 > Redis Trigger)
const session_token = editFields11.session_token
|| redisTrigger.session_id
|| null;
// Получаем unified_id (приоритет: propertyName > Edit Fields10 > Redis Trigger)
// propertyName может быть массивом, объектом, или содержать unified_id в вложенных объектах
let unified_id = null;
// Если propertyName - массив, ищем unified_id в элементах массива
if (Array.isArray(propertyNameRaw)) {
for (const item of propertyNameRaw) {
unified_id = item?.unified_id
|| item?.body?.unified_id
|| item?.result?.unified_id
|| item?.data?.unified_id
|| null;
if (unified_id) break;
}
} else {
// Если propertyName - объект, ищем unified_id напрямую или в вложенных объектах
unified_id = propertyName.unified_id
|| propertyName.body?.unified_id
|| propertyName.result?.unified_id
|| propertyName.data?.unified_id
|| null;
}
// Fallback на другие источники
if (!unified_id) {
unified_id = editFields10.unified_id
|| redisTrigger.unified_id
|| null;
}
// Валидация обязательных полей
if (!session_token) {
throw new Error('❌ session_token не найден! Проверьте узлы Edit Fields11 и Redis Trigger.');
}
if (!payload.wizard_plan) {
console.warn('⚠️ wizard_plan отсутствует! Черновик будет сохранён без плана вопросов.');
}
if (!payload.problem_description) {
console.warn('⚠️ problem_description отсутствует! Черновик будет сохранён без описания проблемы.');
}
// Логирование для отладки
console.log('🔍 Подготовка данных для claimsave_primary:');
console.log(' - session_token:', session_token ? '✅' : '❌');
console.log(' - unified_id:', unified_id || 'null');
if (!unified_id) {
console.warn('⚠️ unified_id не найден! Проверьте ноды: propertyName, Edit Fields10, Redis Trigger');
}
console.log(' - wizard_plan:', payload.wizard_plan ? '✅' : '❌');
console.log(' - problem_description:', payload.problem_description ? '✅' : '❌');
console.log(' - ai_agent1_facts:', payload.ai_agent1_facts.facts_short ? '✅' : '❌');
console.log(' - ai_agent13_rag:', payload.ai_agent13_rag ? '✅' : '❌');
console.log(' - phone:', payload.phone || 'null');
console.log(' - email:', payload.email || 'null');
// Возвращаем данные для PostgreSQL узла
return {
json: {
// Payload для параметра $1
payload_json: payload,
// Session token для параметра $2
session_token: session_token,
// Unified ID для параметра $3 (может быть null)
unified_id: unified_id,
// Дополнительная информация для отладки
_debug: {
has_wizard_plan: !!payload.wizard_plan,
has_problem_description: !!payload.problem_description,
has_ai_agent1: !!payload.ai_agent1_facts.facts_short,
has_ai_agent13: !!payload.ai_agent13_rag,
source_nodes: {
code4: 'Code4',
editFields16: 'Edit Fields16',
aiAgent1: 'пробрасываем факт фул и факт шорт1',
aiAgent13: 'AI Agent13',
redisTrigger: 'Redis Trigger',
editFields11: 'Edit Fields11',
editFields10: 'Edit Fields10',
propertyName: 'propertyName'
}
}
}
};

View File

@@ -42,3 +42,5 @@ return {
ttl: 604800
};

View File

@@ -0,0 +1,164 @@
// === НАСТРОЙКА ===
const FILES_ROWS_NODE = 'editfiletobd1'; // <--- имя ноды, где формировались filesRows
// === ВХОД ИЗ PG ===
const sql = $json || {};
const claim_id = sql?.claim?.claim_id || $json.claim_id || null;
const docs = Array.isArray(sql.documents) ? sql.documents : [];
// === filesRows из предыдущей ноды ===
const filesRows = $items(FILES_ROWS_NODE)?.[0]?.json?.filesRows || [];
// === Получаем project_id и project_name ===
// Пробуем получить из Edit Fields6, затем из текущего $json, затем из предыдущих нод
let project_id = null;
let project_name = null;
// 1. Пробуем из Edit Fields6
try {
const editFields6 = $('Edit Fields6').first().json.propertyName || {};
project_id = editFields6.project_id || null;
project_name = editFields6.project_name || null;
} catch (e) {
// Игнорируем, пробуем другие источники
}
// 2. Пробуем из текущего $json
if (!project_id) {
project_id = $json?.project_id || null;
}
if (!project_name) {
project_name = $json?.project_name || null;
}
// 3. Пробуем из предыдущей ноды (если есть нода, которая получает данные из Redis)
if (!project_name) {
try {
// Пробуем найти ноду, которая получает данные сессии из Redis
const redisNode = $node["Get Session"] || $node["GetSession"] || $node["Redis Get"];
if (redisNode && redisNode.json) {
const sessionData = typeof redisNode.json.value === 'string'
? JSON.parse(redisNode.json.value)
: redisNode.json.value;
if (!project_name) {
project_name = sessionData?.project_name || null;
}
if (!project_id) {
project_id = sessionData?.project_id || null;
}
}
} catch (e) {
// Игнорируем ошибки
}
}
// === Утилиты ===
const S = v => (v == null ? '' : String(v));
const extOf = n => (S(n).match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase() || 'pdf');
const baseName = k => S(k).split('/').pop() || 'file.pdf';
const slugify = s => {
s = S(s).toLowerCase().trim();
const map = {а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ё:'e',ж:'zh',з:'z',и:'i',й:'y',к:'k',л:'l',
м:'m',н:'n',о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f',х:'h',ц:'c',ч:'ch',ш:'sh',
щ:'sch',ъ:'',ы:'y',ь:'',э:'e',ю:'yu',я:'ya'};
s = s.replace(/[а-яё]/g, ch => map[ch] ?? ch);
return s.replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'') || 'doc';
};
// field_name -> doc_id
const byField = Object.create(null);
for (const d of docs) if (d?.field_name && d?.id) byField[d.field_name] = d.id;
// Правило финального пути
// Новый формат: /f9825c87-4e3558f6-f9b6-405c-ad3d-d1535c49b61c/crm2/CRM_Active_Files/Documents/Project/{project_name}_{project_id}/{doc_id}__{slug}.{ext}
// Пример: /.../Project/ERV_6381_КлиентПрав_398957/{doc_id}__{slug}.{ext}
// project_name уже содержит "ERV_6381_КлиентПрав", просто добавляем к нему _project_id
const buildFinalKey = (row, doc_id) => {
const bucketPrefix = 'f9825c87-4e3558f6-f9b6-405c-ad3d-d1535c49b61c';
const basePath = 'crm2/CRM_Active_Files/Documents/Project';
// Используем название поля из формы визарда
// Приоритет: field_label (из uploads_field_labels) > field_name (из uploads_field_names) > description > group_index
// field_label должен приходить из n8n workflow, где формируется filesRows из uploads_field_labels[i]
const fieldLabel = row.field_label || row.field_name || row.description || `group-${row.group_index}`;
const slug = slugify(fieldLabel);
const ext = extOf(row.original_name || 'pdf');
// Формируем название папки: {project_name}_{project_id}
// project_name уже содержит "ERV_6381_КлиентПрав", добавляем к нему _project_id
let projectFolder = 'unknown';
if (project_name && project_id) {
projectFolder = `${project_name}_${project_id}`;
} else if (project_id) {
// Fallback: если нет project_name, используем старый формат
projectFolder = `${project_id}_Клиентправ`;
}
// Очищаем от недопустимых символов для пути
projectFolder = String(projectFolder)
.replace(/[\/\\\?\*\:\|\"<>]/g, '_')
.replace(/^\.+|\.+$/g, '')
.trim() || 'unknown';
// Формируем путь: /{bucketPrefix}/{basePath}/{project_folder}/{doc_id}__{slug}.{ext}
return `/${bucketPrefix}/${basePath}/${projectFolder}/${doc_id}__${slug}.${ext}`;
};
// Собираем renames + финальные метаданные
const renames = [];
const finalDocumentsMeta = [];
const nowIso = new Date().toISOString();
for (const row of filesRows) {
const g = Number(row.group_index) || 0;
const field_name = `uploads[${g}][0]`;
const doc_id = byField[field_name] || `grp${g}`; // фолбэк если чего-то не сошлось
const fromKey = S(row.draft_key);
const toKey = buildFinalKey(row, doc_id);
// Получаем название поля из row (field_label должен быть добавлен в ноде editfiletobd1)
const field_label = row.field_label || row.field_name || row.description || `group-${g}`;
renames.push({
from: fromKey,
to: toKey,
doc_id,
field_name,
field_label // ✅ Добавляем название поля
});
finalDocumentsMeta.push({
field_name,
field_label, // ✅ Добавляем название поля
file_id: toKey,
file_name: baseName(toKey),
original_file_name: baseName(row.original_name || toKey),
uploaded_at: nowIso
});
}
return [{
json: {
claim_id,
project_id,
renames, // план копирования/переименования на S3
payload_partial_json: { documents_meta: finalDocumentsMeta } // для финального апдейта в БД
}
}];

View File

@@ -0,0 +1,120 @@
// ========================================
// Code Node: Мерж данных проекта в сессию
// ========================================
// 1. Берём первый item
const inputItem = $input.all()[0];
if (!inputItem || !inputItem.json) {
throw new Error('Пустой input в Code Node (нет json)');
}
// root — то, что реально пришло в эту ноду
const root = inputItem.json;
// 2. Универсально получаем body
// - если нода стоит сразу после Webhook → данные лежат в root.body
// - если кто-то выше уже отдал только body → root и есть body
const body = root.body || root;
// 3. Парсим body.other (если есть) как сессию
let sessionData = {};
const rawOther = body.other;
if (rawOther) {
if (typeof rawOther === 'string') {
try {
sessionData = JSON.parse(rawOther);
} catch (e) {
throw new Error('Не смог распарсить body.other как JSON: ' + e.message + '. rawOther: ' + rawOther);
}
} else if (typeof rawOther === 'object') {
sessionData = rawOther;
}
}
// 4. Определяем claimId (основной путь)
let claimId = body.claim_id || sessionData.claim_id || null;
// 5. Fallback: пробуем достать claim_id напрямую из Webhook, если его до сих пор нет
if (!claimId) {
try {
const webhookNodeJson = $('Webhook').first()?.json;
if (webhookNodeJson?.body?.claim_id) {
claimId = webhookNodeJson.body.claim_id;
}
} catch (e) {
// молча игнорируем, просто не удалось взять из Webhook
}
}
// 6. Если всё ещё нет claimId — это реально критичная ситуация
if (!claimId) {
throw new Error(
'Нет claim_id ни в body, ни в sessionData, ни в Webhook. ' +
'body: ' + JSON.stringify(body) +
', sessionData: ' + JSON.stringify(sessionData)
);
}
// 7. Забираем результат ноды CreateClientProject (или CreateWebPorject, если опечатка в названии ноды)
let projectNode = null;
let projectNodeName = null;
// Пробуем найти ноду безопасно
try {
projectNode = $node["CreateClientProject"];
if (projectNode && projectNode.json) {
projectNodeName = "CreateClientProject";
}
} catch (e) {
// Нода CreateClientProject не найдена, пробуем альтернативное название
}
if (!projectNode || !projectNode.json) {
try {
projectNode = $node["CreateWebPorject"];
if (projectNode && projectNode.json) {
projectNodeName = "CreateWebPorject";
}
} catch (e) {
// Нода CreateWebPorject тоже не найдена
}
}
if (!projectNode || !projectNode.json) {
throw new Error('Нет данных от ноды CreateClientProject/CreateWebPorject. Убедитесь, что нода существует и выполнена.');
}
const projectResult = projectNode.json.result;
// Ожидаем что-то типа: { "project_id": "398095", "project_name": "Иванов_КлиентПрав", "is_new": false }
if (!projectResult || !projectResult.project_id) {
throw new Error('Нет projectResult.project_id. result: ' + JSON.stringify(projectNode.json));
}
// 8. Собираем обновлённую сессию
const updatedSession = {
...sessionData, // всё, что было в other
claim_id: claimId, // актуальный claim_id
project_id: projectResult.project_id, // id проекта из CRM
project_name: projectResult.project_name || null, // название проекта из CRM (новое поле)
is_new_project: projectResult.is_new, // флаг новый/старый
current_step: 2, // двигаем визард на шаг 2
updated_at: new Date().toISOString(),
// опционально дотащим полезные поля из body:
problem: body.problem ?? sessionData.problem,
last_analysis_output: body.output ?? sessionData.last_analysis_output,
};
// 9. Возвращаем один item для Redis SET
return [
{
json: {
redis_key: `claim:${claimId}`,
redis_value: JSON.stringify(updatedSession),
ttl: 604800, // 7 дней
},
},
];

View File

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

View File

@@ -0,0 +1,225 @@
# Инструкция: Добавление узла `claimsave_primary` в workflow b4K4u851b4JFivyD
## Позиция узла
**Между узлами:**
- **После:** `Code4` (форматирует данные для Redis)
- **Перед:** `push_wizard1` (пушит wizard_plan в Redis для SSE)
## Порядок узлов в workflow
```
1. Redis Trigger
2. get_claime_data1
3. Edit Fields8
4. Merge2
5. Get row(s) in sheet2
6. Edit Fields16
7. AI Agent1
8. пробрасываем факт фул и факт шорт1
9. AI Agent13
10. output_set1
11. Edit Fields11
12. AI Agent12
13. Code
14. Code4
15. ⭐ Code: Prepare Claimsave Data ← ВСТАВИТЬ ЗДЕСЬ (Code Node)
16. ⭐ claimsave_primary ← ВСТАВИТЬ ЗДЕСЬ (PostgreSQL)
17. push_wizard1
```
## Шаги настройки
### 1. Добавить Code Node для подготовки данных
**Рекомендуется:** Добавить Code Node перед PostgreSQL для удобства отладки и валидации данных.
1. Откройте workflow `b4K4u851b4JFivyD` в n8n
2. Найдите узел `Code4`
3. Добавьте новый узел **Code** после `Code4`
4. Назовите узел: `Code: Prepare Claimsave Data`
5. Подключите:
- **Вход:** от узла `Code4`
- **Выход:** к узлу `claimsave_primary` (PostgreSQL)
**Код для Code Node:** См. файл `docs/CODE_CLAIMSAVE_PRIMARY_PREPARE.js`
**Режим выполнения:** `Run Once for All Items`
### 2. Добавить новый узел PostgreSQL
1. Откройте workflow `b4K4u851b4JFivyD` в n8n
2. Найдите узел `Code4`
3. Добавьте новый узел **PostgreSQL** после `Code4`
4. Назовите узел: `claimsave_primary`
5. Подключите:
- **Вход:** от узла `Code4`
- **Выход:** к узлу `push_wizard1`
### 2. Настройка PostgreSQL узла
**Connection:** Выберите подключение к PostgreSQL (то же, что используется в других узлах)
**Operation:** `Execute Query`
**Query:** Вставьте SQL из файла `docs/SQL_CLAIMSAVE_PRIMARY_DRAFT_CLEAN.sql` (чистая версия без плейсхолдеров)
**⚠️ ВАЖНО:** Используйте файл `SQL_CLAIMSAVE_PRIMARY_DRAFT_CLEAN.sql`, а не `SQL_CLAIMSAVE_PRIMARY_DRAFT.sql`!
### 3. Параметры запроса
**Query Replacement:** Оставьте пустым (не используем)
**Parameters:** Добавьте 3 параметра (если используете Code Node для подготовки):
#### Параметр $1 (payload_json):
```javascript
{{ JSON.stringify($('Code: Prepare Claimsave Data').first().json.payload_json) }}
```
#### Параметр $2 (session_token):
```javascript
{{ $('Code: Prepare Claimsave Data').first().json.session_token }}
```
#### Параметр $3 (unified_id):
```javascript
{{ $('Code: Prepare Claimsave Data').first().json.unified_id }}
```
**Примечание:** Code Node берёт `unified_id` из ноды `propertyName` (приоритет: `propertyName` > `Edit Fields10` > `Redis Trigger`)
---
**Альтернатива (без Code Node):** Если не используете Code Node, можно собрать данные напрямую:
#### Параметр $1 (payload_json):
```javascript
{{ JSON.stringify({
problem_description: $('Edit Fields16').first().json.chatInput,
wizard_plan: $('Code4').first().json.redis_value.wizard_plan,
answers_prefill: $('Code4').first().json.redis_value.answers_prefill || [],
coverage_report: $('Code4').first().json.redis_value.coverage_report || {},
ai_agent1_facts: {
facts_short: $('пробрасываем факт фул и факт шорт1').first().json.facts_short,
facts_full: $('пробрасываем факт фул и факт шорт1').first().json.facts_full,
problem: $('пробрасываем факт фул и факт шорт1').first().json.problem
},
ai_agent13_rag: $('AI Agent13').first().json.output,
phone: $('Redis Trigger').first().json.message.phone,
email: $('Redis Trigger').first().json.message.email || null,
type_code: $('Code4').first().json.redis_value.wizard_plan?.case_type || 'consumer'
}) }}
```
#### Параметр $2 (session_token):
```javascript
{{ $('Edit Fields11').first().json.session_token || $('Redis Trigger').first().json.message.session_id }}
```
#### Параметр $3 (unified_id):
```javascript
{{ $('propertyName').first().json.unified_id || $('Edit Fields10').first().json.unified_id || $('Redis Trigger').first().json.message.unified_id || null }}
```
**Примечание:** Приоритет источников `unified_id`: `propertyName` > `Edit Fields10` > `Redis Trigger`
### 4. Проверка подключений
Убедитесь, что:
- ✅ Узел `Code: Prepare Claimsave Data` получает данные от `Code4`
- ✅ Узел `claimsave_primary` получает данные от `Code: Prepare Claimsave Data`
- ✅ Узел `push_wizard1` получает данные от `claimsave_primary` (или от `Code4`, если нужно)
-Все пути данных корректны
## Преимущества использования Code Node
**Упрощение параметров PostgreSQL:** Вместо сложных выражений в параметрах SQL, используем простые ссылки на Code Node
**Валидация данных:** Code Node проверяет наличие обязательных полей и выводит предупреждения
**Отладка:** Легче отслеживать, какие данные собраны, через логи Code Node
**Обработка edge cases:** Можно добавить fallback значения и обработку ошибок
**Читаемость:** Код подготовки данных отделён от SQL запроса
## Что сохраняется
После выполнения узла `claimsave_primary` в БД будет создана/обновлена запись в `clpr_claims`:
-`session_token` - для связи
-`unified_id` - если передан
-`status_code = 'draft'` - статус черновика
-`payload.wizard_plan` - план вопросов
-`payload.problem_description` - описание проблемы
-`payload.answers_prefill` - предзаполненные ответы
-`payload.coverage_report` - отчёт о покрытии
-`payload.ai_agent1_facts` - факты из AI Agent1
-`payload.ai_agent13_rag` - RAG ответ
-`payload.phone`, `payload.email` - контакты
- ⚠️ `payload.claim_id = NULL` - будет сгенерирован позже
## Возвращаемое значение
Узел возвращает объект:
```json
{
"claim": {
"claim_id": "uuid-записи",
"session_token": "sess-...",
"status_code": "draft",
"payload": { ... }
}
}
```
## Важные замечания
1. **Узел работает в режиме UPSERT:**
- Если запись с таким `session_token` существует → обновляет её
- Если записи нет → создаёт новую
2. **`claim_id` генерируется позже:**
- На этом этапе `claim_id` в `payload` = `NULL`
- UUID записи (`clpr_claims.id`) используется как временный идентификатор
- Позже `claim_id` будет сгенерирован в формате `CLM-YYYY-MM-DD-XXXXXX`
3. **Данные из предыдущих узлов:**
- `wizard_plan` берётся из `Code4.redis_value.wizard_plan`
- `problem_description` берётся из `Edit Fields16.chatInput`
- `ai_agent1_facts` берётся из узла `пробрасываем факт фул и факт шорт1`
- `ai_agent13_rag` берётся из `AI Agent13.output`
## Тестирование
После добавления узла:
1. Запустите workflow с тестовыми данными
2. Проверьте, что узел выполняется без ошибок
3. Проверьте в БД, что запись создана/обновлена:
```sql
SELECT id, session_token, unified_id, status_code, payload->>'wizard_plan'
FROM clpr_claims
WHERE session_token = 'sess-...'
ORDER BY updated_at DESC
LIMIT 1;
```
## Если что-то не работает
1. **Ошибка "column does not exist":**
- Проверьте, что все поля в SQL запросе существуют в таблице `clpr_claims`
2. **Ошибка "invalid input syntax for type jsonb":**
- Проверьте, что параметр `$1` правильно сериализован через `JSON.stringify()`
- Убедитесь, что все вложенные объекты корректны
3. **Ошибка "session_token is null":**
- Проверьте, что `Edit Fields11` содержит `session_token`
- Проверьте fallback на `Redis Trigger.message.session_id`
4. **Данные не сохраняются:**
- Проверьте логи n8n на наличие ошибок
- Проверьте, что все узлы-источники данных выполнены успешно

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

@@ -0,0 +1,104 @@
# Минимальный payload для ocr_events
## Назначение
После сохранения первичного черновика в PostgreSQL через `claimsave_primary`, n8n должен пушить в Redis канал `ocr_events:{session_token}` только минимальный набор данных.
**Важно:** Канал формируется как `ocr_events:{session_token}`, где `session_token` - это токен сессии, который генерируется на фронтенде (например, `sess-1763201209156-hyjye5u9h`).
Бэкенд сам достанет полные данные из PostgreSQL по `claim_id` через эндпоинт `/api/v1/claims/wizard/load/{claim_id}`.
## Формат данных для ocr_events
```json
{
"event_type": "wizard_ready",
"status": "ready",
"message": "Wizard plan готов",
"data": {
"claim_id": "9d22d3f4-0306-4b77-a102-c0ca57b24a70",
"session_token": "sess-1763201209156-hyjye5u9h",
"status_code": "draft",
"unified_id": "usr_90599ff2-ac79-4236-b950-0df85395096c",
"contact_id": "320096",
"phone": "79262306381"
},
"timestamp": "2025-11-20T11:40:41Z"
}
```
## Поля data
- **claim_id** (string, обязательное) - UUID заявки из PostgreSQL
- **session_token** (string, обязательное) - токен сессии пользователя
- **status_code** (string, опциональное) - статус заявки (обычно "draft")
- **unified_id** (string, опциональное) - unified_id пользователя
- **contact_id** (string, опциональное) - ID контакта в CRM
- **phone** (string, опциональное) - нормализованный номер телефона
## Что НЕ нужно пушить
-`wizard_plan` - бэкенд достанет из PostgreSQL
-`problem_description` - бэкенд достанет из PostgreSQL
-`wizard_answers` - бэкенд достанет из PostgreSQL
-`ai_agent1_facts` - бэкенд достанет из PostgreSQL
-`ai_agent13_rag` - бэкенд достанет из PostgreSQL
- ❌ Любые другие данные из `payload` - всё в PostgreSQL
## Как бэкенд получает данные
1. Фронтенд подключается к SSE через `/events/{session_token}` (например, `/events/sess-1763201209156-hyjye5u9h`)
2. Бэкенд подписывается на Redis канал `ocr_events:{session_token}` (например, `ocr_events:sess-1763201209156-hyjye5u9h`)
3. n8n пушит событие в этот канал с минимальным payload (только `claim_id`, `session_token` и т.д.)
4. Бэкенд получает событие, извлекает `claim_id` из `data.claim_id`
5. Бэкенд вызывает `GET /api/v1/claims/wizard/load/{claim_id}` для получения полных данных из PostgreSQL
6. Бэкенд отправляет полные данные (wizard_plan, problem_description и т.д.) на фронтенд через SSE
## Пример использования в n8n
После выполнения `claimsave_primary`:
1. **Code Node** - формирует минимальный payload и определяет канал:
```javascript
const claimData = $('claimsave_primary').first().json.claim;
const sessionToken = claimData.session_token;
const result = {
event_type: "wizard_ready",
status: "ready",
message: "Wizard plan готов",
data: {
claim_id: claimData.claim_id,
session_token: sessionToken,
status_code: claimData.status_code,
unified_id: $('propertyName').first().json.unified_id || null,
contact_id: $('Edit Fields10').first().json.contact_id || null,
phone: $('Edit Fields10').first().json.phone || null
},
timestamp: new Date().toISOString()
};
// Сохраняем session_token для использования в URL
return [{
json: result,
session_token: sessionToken // Для использования в следующей ноде
}];
```
2. **HTTP Request Node** - пушит в бэкенд:
- URL: `http://backend:8000/api/v1/events/{{ $json.session_token }}`
- Method: POST
- Body: JSON из Code Node (весь объект `result`)
**Важно:** URL должен быть `http://backend:8000/api/v1/events/{session_token}`, где `{session_token}` берётся из предыдущей ноды (например, `sess-1763201209156-hyjye5u9h`).
Это создаст канал `ocr_events:sess-1763201209156-hyjye5u9h`, к которому подключён фронтенд через SSE.
## Преимущества
- ✅ Минимум данных в Redis (только идентификаторы)
- ✅ PostgreSQL как единственный источник истины
- ✅ Легче отлаживать (всё в одном месте)
- ✅ Меньше нагрузка на Redis
- ✅ Проще масштабировать

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

@@ -0,0 +1,115 @@
# Лог сессии разработки - 20 ноября 2025
## Проблема (из предыдущей сессии)
После верификации телефона не отображался список черновиков, хотя в базе данных есть заявки с `unified_id`.
## Решение
### 1. Исправлен SQL запрос в backend (`claims.py`)
**Проблема:** Запрос строился через конкатенацию строк, что могло приводить к проблемам с параметрами.
**Решение:** Переписан SQL запрос - теперь используется прямой запрос для каждого случая:
- Для `unified_id`: прямой запрос `WHERE c.unified_id = $1`
- Для `phone`: подзапрос через `clpr_user_accounts` и `clpr_users`
- Для `session_id`: прямой запрос `WHERE c.session_token = $1`
```python
if unified_id:
query = """
SELECT
c.id,
c.payload->>'claim_id' as claim_id,
c.session_token,
c.status_code,
c.channel,
c.payload,
c.created_at,
c.updated_at
FROM clpr_claims c
WHERE c.unified_id = $1
ORDER BY c.updated_at DESC
LIMIT 20
"""
params = [unified_id]
```
### 2. Улучшена обработка черновиков в frontend (`ClaimForm.tsx`)
**Проблема:** Черновики из Telegram имеют другую структуру данных (данные в `payload.body`), а не напрямую в `payload`.
**Решение:** Добавлена поддержка обоих форматов:
- **Telegram формат:** данные в `payload.body.wizard_plan`, `payload.body.answers`
- **Web form формат:** данные напрямую в `payload.wizard_plan`, `payload.answers`
```typescript
// ✅ Для telegram черновиков данные могут быть в payload.body
const body = payload.body || {};
const isTelegramFormat = !!payload.body;
// ✅ Извлекаем данные из body (telegram) или напрямую из payload (web_form)
const wizardPlanRaw = body.wizard_plan || payload.wizard_plan;
const answersRaw = body.answers || payload.answers;
const problemDescription = body.problem_description || payload.problem_description || body.description || payload.description;
// ✅ Парсим wizard_plan и answers, если они строки (JSON)
let wizardPlan = wizardPlanRaw;
if (typeof wizardPlanRaw === 'string') {
try {
wizardPlan = JSON.parse(wizardPlanRaw);
} catch (e) {
console.warn('⚠️ Не удалось распарсить wizard_plan:', e);
}
}
```
### 3. Улучшена обработка `claim_id`
**Проблема:** `claim_id` может быть в разных местах в зависимости от формата данных.
**Решение:** Добавлен поиск `claim_id` в нескольких местах:
```typescript
const finalClaimId = claim.claim_id || payload.claim_id || body.claim_id || claim.id || formData.claim_id || claimId;
```
### 4. Добавлено детальное логирование
- В `loadDraft`: логирование всех этапов загрузки черновика
- В `get_draft` (backend): логирование найденных данных
- В `list_drafts` (backend): тестовые COUNT запросы для отладки
### 5. Исправлена обработка `claim_id` в backend
В `get_draft` теперь извлекается `claim_id` из `payload`, если его нет в `row`:
```python
claim_id_from_payload = payload.get('claim_id') if isinstance(payload, dict) else None
final_claim_id = row.get('claim_id') or claim_id_from_payload
```
## Результат
**Черновики теперь возвращаются!** API корректно возвращает список черновиков для `unified_id`.
## Файлы изменены
1. `backend/app/api/claims.py`:
- Переписан SQL запрос для `list_drafts`
- Добавлено логирование и тестовые COUNT запросы
- Улучшена обработка `claim_id` в `get_draft`
2. `frontend/src/pages/ClaimForm.tsx`:
- Добавлена поддержка формата Telegram черновиков
- Улучшена обработка `claim_id` из разных источников
- Добавлено детальное логирование загрузки черновика
3. `frontend/src/components/form/Step1Phone.tsx`:
- (Возможно, были изменения для передачи unified_id)
4. `frontend/src/components/form/StepDraftSelection.tsx`:
- (Возможно, были изменения для отображения черновиков)
## Текущий статус
**Работает:** API возвращает черновики
**Работает:** Загрузка черновиков поддерживает оба формата (Telegram и web_form)
⚠️ **Требует проверки:** Отображение черновиков в UI (StepDraftSelection)
## Следующие шаги
1. Проверить отображение черновиков в UI
2. Протестировать загрузку черновика из Telegram формата
3. Убедиться, что все данные корректно восстанавливаются в форму

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

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

View File

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