fix: Исправление загрузки документов и SQL запросов

- Исправлена потеря документов при обновлении черновика (SQL объединяет вместо перезаписи)
- Исправлено определение типа документа (приоритет field_label над field_name)
- Исправлены дубликаты в documents_meta и documents_uploaded
- Добавлена передача group_index с фронтенда для правильного field_name
- Исправлены все документы в таблице clpr_claim_documents с правильными field_name
- Обновлены SQL запросы: claimsave и claimsave_final для нового флоу
- Добавлена поддержка multi-file upload для одного документа
- Исправлены дубликаты в списке загруженных документов на фронтенде

Файлы:
- SQL: SQL_CLAIMSAVE_FIXED_NEW_FLOW.sql, SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql
- n8n: N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js (поддержка group_index)
- Backend: documents.py (передача group_index в n8n)
- Frontend: StepWizardPlan.tsx (передача group_index, исправление дубликатов)
- Скрипты: fix_claim_documents_field_names.py, fix_documents_meta_duplicates.py

Результат: документы больше не теряются, имеют правильные типы и field_name
This commit is contained in:
AI Assistant
2025-11-26 19:54:51 +03:00
parent 1d6c9d1f52
commit 02689e65db
42 changed files with 8314 additions and 232 deletions

View File

@@ -1,5 +1,6 @@
// ========================================
// Code Node: Мерж данных проекта в сессию
// v2.0 - с расширенным логированием для отладки
// ========================================
// 1. Берём первый item
@@ -12,25 +13,62 @@ if (!inputItem || !inputItem.json) {
// root — то, что реально пришло в эту ноду
const root = inputItem.json;
// ✅ ОТЛАДКА: смотрим что пришло
console.log('🔍 DEBUG: root keys:', Object.keys(root));
console.log('🔍 DEBUG: root.body exists:', !!root.body);
console.log('🔍 DEBUG: root.other exists:', !!root.other);
// 2. Универсально получаем body
// - если нода стоит сразу после Webhook → данные лежат в root.body
// - если кто-то выше уже отдал только body → root и есть body
const body = root.body || root;
console.log('🔍 DEBUG: body keys:', Object.keys(body));
console.log('🔍 DEBUG: body.other exists:', !!body.other);
console.log('🔍 DEBUG: body.other type:', typeof body.other);
// 3. Парсим body.other (если есть) как сессию
// ✅ ВАЖНО: Также проверяем root.other напрямую (если данные пришли не через body)
let sessionData = {};
const rawOther = body.other;
let rawOther = body.other || root.other;
// ✅ Пробуем также достать other из Webhook напрямую
if (!rawOther) {
try {
const webhookJson = $('Webhook').first()?.json;
if (webhookJson?.body?.other) {
rawOther = webhookJson.body.other;
console.log('✅ Взяли other напрямую из Webhook');
}
} catch (e) {
console.log('⚠️ Не удалось достать other из Webhook:', e.message);
}
}
console.log('🔍 DEBUG: rawOther exists:', !!rawOther);
console.log('🔍 DEBUG: rawOther type:', typeof rawOther);
if (rawOther) {
console.log('🔍 DEBUG: rawOther preview:', typeof rawOther === 'string' ? rawOther.substring(0, 200) : JSON.stringify(rawOther).substring(0, 200));
}
if (rawOther) {
if (typeof rawOther === 'string') {
try {
sessionData = JSON.parse(rawOther);
console.log('✅ Распарсили other как JSON. Ключи:', Object.keys(sessionData));
console.log('✅ sessionData.session_id:', sessionData.session_id);
console.log('✅ sessionData.phone:', sessionData.phone);
console.log('✅ sessionData.firstname:', sessionData.firstname);
} catch (e) {
throw new Error('Не смог распарсить body.other как JSON: ' + e.message + '. rawOther: ' + rawOther);
throw new Error('Не смог распарсить other как JSON: ' + e.message + '. rawOther: ' + rawOther.substring(0, 500));
}
} else if (typeof rawOther === 'object') {
sessionData = rawOther;
console.log('✅ other уже объект. Ключи:', Object.keys(sessionData));
}
} else {
console.log('⚠️ other отсутствует или пустой. Проверьте структуру данных!');
console.log('⚠️ root:', JSON.stringify(root).substring(0, 500));
}
// 4. Определяем claimId (основной путь)
@@ -94,19 +132,75 @@ if (!projectResult || !projectResult.project_id) {
}
// 8. Собираем обновлённую сессию
// ✅ Используем spread оператор, но с фильтрацией undefined значений
// Сначала создаём базовый объект из sessionData, фильтруя undefined
const baseSession = Object.keys(sessionData).reduce((acc, key) => {
if (sessionData[key] !== undefined && sessionData[key] !== null) {
acc[key] = sessionData[key];
}
return acc;
}, {});
console.log('📦 baseSession после фильтрации:', Object.keys(baseSession));
console.log('📦 baseSession sample:', {
session_id: baseSession.session_id,
phone: baseSession.phone,
unified_id: baseSession.unified_id,
contact_id: baseSession.contact_id,
firstname: baseSession.firstname,
lastname: baseSession.lastname,
});
const updatedSession = {
...sessionData, // всё, что было в other
claim_id: claimId, // актуальный claim_id
// ✅ Шаг 1: Все данные из sessionData (body.other) - базовая сессия
...baseSession,
// ✅ Шаг 2: Дополняем данными из body (если их нет в sessionData)
...(body.phone && !baseSession.phone ? { phone: body.phone } : {}),
...(body.unified_id && !baseSession.unified_id ? { unified_id: body.unified_id } : {}),
...(body.contact_id && !baseSession.contact_id ? { contact_id: body.contact_id } : {}),
...(body.email && !baseSession.email ? { email: body.email } : {}),
// ✅ Шаг 3: Данные проекта (новые, всегда перезаписываем)
claim_id: claimId, // актуальный claim_id (перезаписываем null из sessionData)
project_id: projectResult.project_id, // id проекта из CRM
project_name: projectResult.project_name || null, // название проекта из CRM (новое поле)
project_name: projectResult.project_name || null, // название проекта из CRM
is_new_project: projectResult.is_new, // флаг новый/старый
current_step: 2, // двигаем визард на шаг 2
// ✅ Шаг 4: Данные анализа из body (приоритет body)
problem: body.problem || baseSession.problem || null,
last_analysis_output: body.output || baseSession.last_analysis_output || null,
// ✅ Шаг 5: Метаданные (всегда обновляем)
updated_at: new Date().toISOString(),
// опционально дотащим полезные поля из body:
problem: body.problem ?? sessionData.problem,
last_analysis_output: body.output ?? sessionData.last_analysis_output,
};
// ✅ Логируем результат для отладки
console.log('📦 sessionData keys:', Object.keys(sessionData));
console.log('📦 sessionData sample:', {
session_id: sessionData.session_id,
phone: sessionData.phone,
unified_id: sessionData.unified_id,
contact_id: sessionData.contact_id,
firstname: sessionData.firstname,
lastname: sessionData.lastname,
middle_name: sessionData.middle_name,
});
console.log('📦 updatedSession keys:', Object.keys(updatedSession));
console.log('📦 updatedSession sample:', {
session_id: updatedSession.session_id,
phone: updatedSession.phone,
unified_id: updatedSession.unified_id,
contact_id: updatedSession.contact_id,
firstname: updatedSession.firstname,
lastname: updatedSession.lastname,
middle_name: updatedSession.middle_name,
claim_id: updatedSession.claim_id,
project_id: updatedSession.project_id,
});
console.log('📦 updatedSession FULL:', JSON.stringify(updatedSession, null, 2));
// 9. Возвращаем один item для Redis SET
return [
{

View File

@@ -0,0 +1,157 @@
// ============================================================================
// n8n Code Node: Обработка загруженных файлов (ИСПРАВЛЕННАЯ ВЕРСИЯ)
// ============================================================================
// OCR возвращает объединённые документы: один файл на группу (group_index)
// Структура: { data: [{ group_index_num: 0, files_count: 2, newfile: "...", ... }] }
// Решение: обрабатываем каждый элемент из data как объединённый документ
// ============================================================================
// ==== INPUT SHAPE SUPPORT ====
// OCR возвращает: { data: [ ...объединённые документы... ] }
const raw = $json;
const items = Array.isArray(raw?.data) ? raw.data : (Array.isArray(raw) ? raw : []);
if (!items.length) {
return [{
json: {
claim_id: null,
payload_partial_json: { documents_meta: [], edit_fields_raw: null, edit_fields_parsed: null },
filesRows: []
}
}];
}
// ==== CLAIM_ID DISCOVERY ====
let claim_id = $json.claim_id
|| $items('Edit Fields6')?.[0]?.json?.propertyName?.case_id
|| $('Edit Fields6').first().json.body.claim_id
|| null;
// ==== UTILS ====
const safeStr = (v) => (v == null ? '' : String(v));
const nowIso = new Date().toISOString();
const tryParseJSON = (x) => {
if (x == null) return null;
if (typeof x === 'object') return x;
if (typeof x === 'string') { try { return JSON.parse(x); } catch { return null; } }
return null;
};
// ==== ПРЕДВАРИТЕЛЬНО СОБИРАЕМ uploads_field_labels ИЗ BODY ====
const editRaw = $items('Edit Fields6')?.[0]?.json || null;
const body = editRaw?.body || null;
let uploads_descriptions = [];
let uploads_field_names = [];
let uploads_field_labels = [];
if (body && typeof body === 'object') {
const d = [];
const f = [];
const l = [];
for (const k of Object.keys(body)) {
const mD = k.match(/^uploads_descriptions\[(\d+)\]$/);
const mF = k.match(/^uploads_field_names\[(\d+)\]$/);
const mL = k.match(/^uploads_field_labels\[(\d+)\]$/);
if (mD) d[Number(mD[1])] = safeStr(body[k]);
if (mF) f[Number(mF[1])] = safeStr(body[k]);
if (mL) l[Number(mL[1])] = safeStr(body[k]);
}
uploads_descriptions = d.filter(v => v !== undefined);
uploads_field_names = f.filter(v => v !== undefined);
uploads_field_labels = l.filter(v => v !== undefined);
}
// ==== BUILD documents_meta + filesRows ====
// OCR возвращает объединённые документы: один файл на group_index
// Каждый элемент из data - это уже объединённый PDF (может содержать несколько страниц)
const documents_meta = [];
const filesRows = [];
for (const it of items) {
// ✅ ПРИОРИТЕТ: Используем group_index из body (переданный с фронтенда)
// Если его нет - используем group_index_num из OCR
// Если и его нет - пытаемся определить по document_type из uploads_field_names
let grp = null;
if (body && body.group_index !== undefined && body.group_index !== null) {
grp = Number(body.group_index);
} else if (it.group_index_num !== undefined && it.group_index_num !== null) {
grp = Number(it.group_index_num);
} else {
// Fallback: пытаемся определить по document_type
const doc_type = uploads_field_names[0] || uploads_field_labels[0] || '';
// Ищем индекс в documents_required по типу документа
// Это не идеально, но лучше чем всегда 0
grp = 0; // По умолчанию 0, если не можем определить
}
grp = grp || 0;
const file_index = 0; // После объединения всегда один файл на группу
const field_name = `uploads[${grp}][${file_index}]`;
const field_label = uploads_field_labels[grp] || uploads_field_names[grp] || uploads_descriptions[grp] || `group-${grp}`;
// OCR уже объединил файлы, используем newfile (путь к объединённому файлу)
const draft_key = safeStr(it.newfile || (it.folder && it.file_name ? `${it.folder}/${it.file_name}` : ''));
const original_name = safeStr(it.file_name || `group_${grp}.pdf`);
const description = safeStr(it.description || uploads_descriptions[grp] || '');
const prefix = safeStr(it.prefix || '');
// files_count показывает, сколько исходных файлов было объединено
const files_count = Number(it.files_count) || 1;
const pages = Number(it.pages) || null;
documents_meta.push({
field_name,
field_label,
file_id: draft_key,
file_name: original_name,
original_file_name: original_name,
uploaded_at: nowIso,
files_count, // Информация: сколько файлов было объединено
pages, // Информация: сколько страниц в объединённом PDF
});
filesRows.push({
claim_id,
group_index: grp,
file_index, // Всегда 0 для объединённого документа
original_name,
draft_key,
mime: 'application/pdf',
size_bytes: null,
description,
prefix,
field_name,
field_label,
files_count, // Информация для отладки
pages, // Информация для отладки
});
}
// ==== ПОДТЯГИВАЕМ ВСЁ ИЗ "Edit Fields" ====
const propertyName = editRaw?.propertyName || null;
const answers_parsed = body ? (tryParseJSON(body.answers) || null) : null;
const wizard_plan_parsed = body ? (tryParseJSON(body.wizard_plan) || null) : null;
// ==== OUTPUT ====
return [{
json: {
claim_id,
payload_partial_json: {
documents_meta,
edit_fields_raw: editRaw || null,
edit_fields_parsed: {
propertyName,
body,
uploads_descriptions,
uploads_field_names,
uploads_field_labels,
answers_parsed,
wizard_plan_parsed,
}
},
filesRows
}
}];

View File

@@ -0,0 +1,115 @@
// ============================================================================
// n8n Code Node: Пуш списка документов в Redis
// ============================================================================
// Расположение в workflow:
// Redis Trigger (ticket_form:description)
// → AI Agent (анализ проблемы)
// → PostgreSQL (SQL_SAVE_DRAFT_NEW_FLOW.sql)
// → [ЭТОТ CODE NODE]
// → Redis Publish
// ============================================================================
// Получаем результат из PostgreSQL
const sqlResult = $input.first().json;
// claim содержит результат SQL запроса
const claim = sqlResult.claim || sqlResult;
// Валидация
if (!claim.session_token) {
throw new Error('Нет session_token в результате SQL');
}
if (!claim.documents_required || claim.documents_required.length === 0) {
console.log('⚠️ Список документов пуст, но продолжаем');
}
// Формируем событие для Redis
const event = {
event_type: 'documents_list_ready',
status: 'ready',
// Идентификаторы
claim_id: claim.claim_id,
session_id: claim.session_token,
// ✅ Список документов для фронтенда
documents_required: claim.documents_required || [],
documents_count: claim.documents_count || 0,
// Метаданные
timestamp: new Date().toISOString(),
message: 'Список необходимых документов готов'
};
// Логируем для отладки
console.log('📤 Публикуем событие documents_list_ready:', {
channel: `ocr_events:${claim.session_token}`,
documents_count: event.documents_count,
claim_id: event.claim_id
});
// Возвращаем для Redis Publish node
return {
json: {
// Канал Redis (ocr_events:{session_id})
channel: `ocr_events:${claim.session_token}`,
// Данные события (будут JSON.stringify в Redis node)
message: JSON.stringify(event),
// Дополнительно передаём для следующих нод
claim_id: claim.claim_id,
session_token: claim.session_token,
documents_required: claim.documents_required
}
};
// ============================================================================
// Пример структуры documents_required:
// ============================================================================
// [
// {
// "id": "contract",
// "name": "Договор или заказ",
// "required": false,
// "priority": 1,
// "accept": ["pdf", "jpg", "png"],
// "hints": "Поскольку договор не выслан, можно приложить публичную оферту"
// },
// {
// "id": "payment",
// "name": "Чек или подтверждение оплаты",
// "required": false,
// "priority": 1,
// "accept": ["pdf", "jpg", "png"],
// "hints": "Копия квитанции, чека или банковской выписки"
// },
// {
// "id": "correspondence",
// "name": "Переписка",
// "required": true, // ⚠️ КРИТИЧНЫЙ документ
// "priority": 2,
// "accept": ["pdf", "jpg", "png"],
// "hints": "Скриншоты переписки с организацией, претензии"
// }
// ]
// ============================================================================
// ============================================================================
// Настройка Redis Publish node (следующая нода):
// ============================================================================
//
// Operation: Publish
// Channel: {{ $json.channel }}
// Message: {{ $json.message }}
//
// Или через Execute Command:
// Command: PUBLISH
// Arguments:
// - {{ $json.channel }}
// - {{ $json.message }}
// ============================================================================

225
docs/N8N_MEMORY_ISSUES.md Normal file
View File

@@ -0,0 +1,225 @@
# 🐛 Проблемы с памятью в n8n
## 🔍 Симптомы
- UI n8n не отвечает (нельзя сохранить workflow, включить/выключить)
- Workflow не обрабатывает события
- Страница зависает при попытке редактирования
- Требуется перезагрузка сервера для восстановления
## 💾 Возможные причины
### 1. **Переполнение памяти (OOM)**
- n8n процесс исчерпал доступную память
- Система убивает процесс (OOM Killer)
- Или процесс зависает в ожидании освобождения памяти
**Диагностика:**
```bash
# Проверка использования памяти n8n
docker stats n8n_container --no-stream
# Проверка логов OOM Killer
dmesg | grep -i "out of memory"
dmesg | grep -i "killed process"
# Проверка использования памяти системой
free -h
```
### 2. **Утечки памяти в workflow**
- Workflow накапливает данные в памяти
- Большие массивы данных не освобождаются
- Долгие операции держат данные в памяти
**Диагностика:**
- Проверить Execution History - сколько данных хранится
- Проверить размер данных в workflow (большие JSON объекты)
- Проверить количество активных executions
### 3. **Слишком много активных workflows**
- Много workflows работают одновременно
- Каждый workflow держит соединения и данные в памяти
- Redis Trigger для каждого workflow = отдельное соединение
**Диагностика:**
```bash
# Количество активных workflows (через n8n API или БД)
# Проверить количество Redis подписок
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" CLIENT LIST | grep -c "SUBSCRIBE"
```
### 4. **Большие данные в workflow**
- Workflow обрабатывает большие файлы/JSON
- Данные хранятся в памяти между нодами
- Нет очистки промежуточных данных
**Диагностика:**
- Проверить размер данных в Execution History
- Проверить размер JSON payload между нодами
- Проверить использование диска для execution data
### 5. **Проблемы с базой данных n8n**
- База данных n8n переполнена старыми executions
- Медленные запросы блокируют работу
- Блокировки таблиц
**Диагностика:**
```bash
# Размер базы данных n8n
# Проверить количество executions
# Проверить медленные запросы
```
## 🛠️ Решения
### 1. **Ограничить использование памяти**
В `docker-compose.yml` для n8n:
```yaml
services:
n8n:
mem_limit: 2g # Ограничить память до 2GB
mem_reservation: 1g # Резервировать минимум 1GB
oom_kill_disable: false # Разрешить OOM Killer убивать процесс
```
Или через переменные окружения:
```bash
NODE_OPTIONS="--max-old-space-size=1536" # Ограничить heap до 1.5GB
```
### 2. **Очистить старые executions**
Настроить автоматическую очистку в n8n:
- Settings → Workflows → Execution Data Retention
- Установить срок хранения (например, 7 дней)
- Включить автоматическую очистку
Или через SQL (если используете PostgreSQL):
```sql
-- Удалить executions старше 7 дней
DELETE FROM execution_entity
WHERE "stoppedAt" < NOW() - INTERVAL '7 days';
-- Удалить execution_data для удалённых executions
DELETE FROM execution_data
WHERE "executionId" NOT IN (SELECT id FROM execution_entity);
```
### 3. **Оптимизировать workflow**
- **Не хранить большие данные между нодами**
- Использовать `Set` node для очистки ненужных полей
- Не передавать большие файлы через workflow data
- **Использовать streaming для больших данных**
- Обрабатывать данные порциями
- Не загружать всё в память сразу
- **Ограничить размер данных в Redis Trigger**
- Проверять размер сообщения перед обработкой
- Отклонять слишком большие сообщения
### 4. **Мониторинг памяти**
Создать скрипт для мониторинга:
```bash
#!/bin/bash
# monitor_n8n_memory.sh
CONTAINER="n8n_container"
THRESHOLD=80 # Процент использования памяти
MEMORY_USAGE=$(docker stats $CONTAINER --no-stream --format "{{.MemPerc}}" | sed 's/%//')
if (( $(echo "$MEMORY_USAGE > $THRESHOLD" | bc -l) )); then
echo "⚠️ ВНИМАНИЕ: n8n использует ${MEMORY_USAGE}% памяти!"
# Можно добавить отправку алерта
fi
```
### 5. **Настроить swap**
Если сервер имеет swap, убедиться что он настроен:
```bash
# Проверить swap
swapon --show
# Если нет swap, создать (осторожно - может замедлить работу)
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
```
### 6. **Ограничить количество активных workflows**
- Отключить неиспользуемые workflows
- Использовать один workflow вместо нескольких для похожих задач
- Разделить сложные workflows на несколько простых
### 7. **Оптимизировать Redis Trigger**
- Использовать один Redis Trigger для нескольких каналов (если возможно)
- Ограничить количество одновременных подписок
- Использовать Redis Streams вместо Pub/Sub для больших объёмов данных
## 📊 Диагностика после перезагрузки
После перезагрузки сервера проверить:
```bash
# 1. Использование памяти n8n
docker stats n8n_container --no-stream
# 2. Логи n8n на ошибки памяти
docker logs n8n_container 2>&1 | grep -i "memory\|oom\|heap"
# 3. Системные логи OOM Killer
dmesg | grep -i "out of memory" | tail -20
# 4. Использование памяти системой
free -h
# 5. Топ процессов по использованию памяти
ps aux --sort=-%mem | head -10
```
## 🔄 Профилактика
1. **Регулярная очистка executions**
- Настроить автоматическую очистку старых данных
- Ограничить срок хранения execution data
2. **Мониторинг ресурсов**
- Настроить алерты при высоком использовании памяти
- Регулярно проверять использование ресурсов
3. **Оптимизация workflows**
- Избегать хранения больших данных в памяти
- Использовать streaming для больших файлов
- Очищать промежуточные данные
4. **Ограничения ресурсов**
- Установить лимиты памяти для n8n контейнера
- Настроить OOM Killer для корректной обработки
5. **Резервирование**
- Рассмотреть использование нескольких инстансов n8n
- Использовать load balancer для распределения нагрузки
## 📝 Рекомендации для продакшена
1. **Мониторинг**: Настроить Prometheus/Grafana для мониторинга памяти
2. **Алерты**: Настроить уведомления при превышении порога памяти
3. **Автоматическая очистка**: Настроить cron для очистки старых executions
4. **Лимиты**: Установить жёсткие лимиты памяти для n8n
5. **Логирование**: Включить детальное логирование использования памяти
## 🔗 Полезные ссылки
- [n8n Memory Management](https://docs.n8n.io/hosting/configuration/environment-variables/#memory-management)
- [Docker Memory Limits](https://docs.docker.com/config/containers/resource_constraints/#memory)
- [Node.js Memory Management](https://nodejs.org/api/cli.html#--max-old-space-sizesize-in-megabytes)

View File

@@ -0,0 +1,167 @@
# 🔧 Troubleshooting: Redis Trigger в n8n зависает
## 🐛 Проблема
Redis Trigger в n8n перестаёт слушать канал `ticket_form:description`, хотя workflow активен.
## 🔍 Возможные причины
### 1. **Потеря соединения с Redis**
- Соединение оборвалось из-за сетевых проблем
- Redis перезапустился, но n8n не переподключился
- Таймаут соединения
**Решение:**
- Проверить логи n8n на ошибки подключения
- Убедиться, что Redis доступен: `redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PING`
- Перезапустить workflow в n8n (отключить → включить)
### 2. **Проблемы с памятью/ресурсами**
- n8n исчерпал память
- Слишком много активных workflows
**Решение:**
- Проверить использование памяти: `docker stats n8n_container`
- Увеличить лимиты памяти для n8n
- Перезапустить n8n контейнер
### 3. **Долгие операции в workflow**
- Workflow обрабатывает сообщение слишком долго
- Блокирует обработку новых сообщений
**Решение:**
- Оптимизировать workflow (убрать долгие операции)
- Использовать асинхронную обработку
- Разбить workflow на несколько этапов
### 4. **Проблемы с сетью**
- Временные сбои сети между n8n и Redis
- Firewall блокирует соединение
**Решение:**
- Проверить сетевую связность: `ping crm.clientright.ru`
- Проверить firewall правила
- Использовать retry-логику в workflow
## 🛠️ Решения для предотвращения
### 1. **Мониторинг подписчиков**
Запустить скрипт мониторинга:
```bash
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
python3 monitor_n8n_redis_trigger.py
```
Или добавить в cron для автоматической проверки:
```bash
# Проверка каждые 5 минут
*/5 * * * * cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form && python3 monitor_n8n_redis_trigger.py >> logs/n8n_monitor_cron.log 2>&1
```
### 2. **Health Check для Redis Trigger**
Добавить в workflow n8n:
- **Schedule Trigger** (каждые 5 минут)
- **Redis Publish** (отправить тестовое сообщение)
- **If Node** (проверить, обработалось ли сообщение)
- **Send Alert** (если нет - отправить уведомление)
### 3. **Автоматический перезапуск workflow**
Создать скрипт для автоматического перезапуска:
```bash
#!/bin/bash
# Проверка и перезапуск workflow если нет подписчиков
SUBS=$(redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PUBSUB NUMSUB "ticket_form:description" | tail -1)
if [ "$SUBS" -eq "0" ]; then
echo "⚠️ Нет подписчиков! Требуется перезапуск workflow"
# Здесь можно добавить API вызов для перезапуска workflow через n8n API
fi
```
### 4. **Настройка Redis для стабильности**
В `redis.conf`:
```conf
# Таймаут для неактивных соединений (0 = отключить)
timeout 0
# Keepalive для TCP соединений
tcp-keepalive 60
# Максимальное количество клиентов
maxclients 10000
```
### 5. **Логирование в n8n**
Включить детальное логирование для Redis Trigger:
- Settings → Logging → Level: `debug`
- Проверить логи на ошибки подключения
## 📊 Диагностика
### Проверка подписчиков
```bash
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PUBSUB NUMSUB "ticket_form:description"
```
### Проверка подключения n8n к Redis
```bash
# Из контейнера n8n
docker exec -it n8n_container redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PING
```
### Тестовая публикация
```bash
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" \
PUBLISH "ticket_form:description" '{"type":"test","session_id":"test123"}'
```
### Проверка логов n8n
```bash
docker logs n8n_container | grep -i redis
docker logs n8n_container | grep -i "ticket_form:description"
```
## ✅ Быстрое решение
Если workflow завис:
1. **Отключить workflow** в n8n (кнопка "Active")
2. **Сохранить** изменения
3. **Включить обратно** (кнопка "Active")
4. **Проверить подписчиков**: `PUBSUB NUMSUB "ticket_form:description"`
Если не помогло:
1. **Перезапустить n8n контейнер**:
```bash
docker restart n8n_container
```
2. **Проверить Redis**:
```bash
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PING
```
3. **Проверить сеть** между n8n и Redis
## 🔄 Рекомендации для продакшена
1. **Мониторинг**: Настроить автоматический мониторинг подписчиков
2. **Алерты**: Настроить уведомления при отсутствии подписчиков
3. **Health Checks**: Регулярные проверки работоспособности
4. **Логирование**: Детальное логирование всех операций с Redis
5. **Резервирование**: Рассмотреть использование Redis Sentinel для высокой доступности
## 📝 Логи для анализа
Проверить логи:
- `/var/www/fastuser/data/www/crm.clientright.ru/ticket_form/logs/n8n_redis_monitor.log` - мониторинг
- `docker logs n8n_container` - логи n8n
- `/var/www/fastuser/data/www/crm.clientright.ru/ticket_form/backend/logs/` - логи backend

View File

@@ -0,0 +1,767 @@
# 🚀 Новая архитектура: Быстрая загрузка документов
**Дата создания:** 2025-11-26
**Статус:** В разработке
---
## 📋 Проблема
Текущий флоу слишком медленный:
1. **2 минуты** — генерация визарда (RAG + AI анализ)
2. **Длинная анкета** — слишком много вопросов для пользователя
---
## ✅ Новое решение
### Концепция
1. После описания проблемы → сразу запрашиваем документы (без ожидания визарда)
2. Пока пользователь загружает документы → в бэке генерируется визард + OCR
3. После всех документов → показываем готовое заявление на апрув
### Преимущества
- **Быстрый старт** — пользователь не ждёт 2 минуты
- **Параллельная работа** — OCR и визард генерируются пока пользователь ищет документы
- **Меньше вопросов** — большая часть данных извлекается из документов
---
## 🔄 Новый флоу (шаги)
```
┌─────────────────┐
│ 1. Телефон │ (уже есть)
│ SMS верификация
└────────┬────────┘
┌─────────────────┐
│ 2. Черновики │ (уже есть, обновить UI)
│ - Новые статусы│
│ - Legacy→"Начать заново"
└────────┬────────┘
┌─────────────────┐
│ 3. Описание │ (уже есть)
│ Свободный текст│
└────────┬────────┘
▼ → n8n: быстрая генерация списка документов (5-10 сек)
│ → n8n: параллельно запускает генерацию визарда (в фоне)
┌─────────────────┐
│ 4. Документы │ 🆕 НОВЫЙ КОМПОНЕНТ
│ - Поэкранная загрузка
│ - Критичные помечены
│ - Можно пропустить
└────────┬────────┘
▼ → n8n: OCR каждого документа → заполнение визарда (в фоне)
┌─────────────────┐
│ 5. Ожидание │ 🆕 НОВЫЙ КОМПОНЕНТ
│ "Формируем заявление..."
│ Loader + прогресс
└────────┬────────┘
▼ ← n8n: claim_ready event (SSE)
┌─────────────────┐
│ 6. Заявление │ (уже есть StepClaimConfirmation)
│ Просмотр + редактирование
└────────┬────────┘
┌─────────────────┐
│ 7. SMS апрув │ (уже есть)
└─────────────────┘
```
---
## 📊 Статусы черновика (status_code)
| Статус | Описание | UI при открытии |
|--------|----------|-----------------|
| `draft_new` | Только описание | → Шаг документов |
| `draft_docs_progress` | Часть документов загружена | → Продолжить с текущего документа |
| `draft_docs_complete` | Все документы загружены | → Показать loader |
| `draft_claim_ready` | Заявление готово | → Показать заявление |
| `awaiting_sms` | Ждёт SMS | → Форма SMS |
| `approved` | Отправлено | Не показываем |
### Legacy черновики (старый формат)
- Нет `documents_required` → показываем с пометкой "устаревший"
- Кнопка "Начать заново" → копирует description, создаёт новый черновик
---
## 📦 Структура payload черновика
```json
{
// === Идентификаторы ===
"claim_id": "CLM-2025-11-26-X7Y8Z9",
"session_token": "sess_abc123...",
"unified_id": "user_456...",
"phone": "+79991234567",
"email": "user@example.com",
// === Описание проблемы ===
"problem_description": "Купил курсы за 50000р, компания не отвечает...",
// === Документы (новое!) ===
"documents_required": [
{
"type": "contract",
"name": "Договор или оферта",
"critical": true,
"hints": "Скриншот или PDF договора/оферты"
},
{
"type": "payment",
"name": "Подтверждение оплаты",
"critical": true,
"hints": "Чек, выписка из банка, скриншот платежа"
},
{
"type": "correspondence",
"name": "Переписка с продавцом",
"critical": false,
"hints": "Скриншоты переписки, email, чаты"
}
],
"documents_uploaded": [
{
"type": "contract",
"file_id": "s3://...",
"ocr_status": "completed",
"ocr_data": {...}
}
],
"documents_skipped": ["correspondence"],
"current_doc_index": 1,
// === Визард (генерируется в фоне) ===
"wizard_plan": {...}, // AI-generated questions
"wizard_answers": {...}, // Auto-filled from OCR
"wizard_ready": true, // Флаг готовности
// === Заявление ===
"claim_ready": false, // Флаг готовности заявления
"claim_data": { // Готовое заявление для апрува
"applicant": {...},
"case": {...},
"contract_or_service": {...},
"offenders": [...],
"claim": {...},
"attachments": [...]
},
// === Метаданные ===
"created_at": "2025-11-26T10:00:00Z",
"updated_at": "2025-11-26T10:05:00Z"
}
```
---
## 🔌 API Endpoints
### Существующие (без изменений)
- `POST /api/v1/claims/description` — публикация описания в Redis
- `GET /api/v1/claims/drafts/list` — список черновиков
- `GET /api/v1/claims/drafts/{claim_id}` — полные данные черновика
- `POST /api/v1/claims/approve` — финальный апрув (SMS)
### Новые/Изменённые
#### 1. SSE: Получение списка документов
```
GET /api/v1/events/{session_id}
Event: documents_list_ready
Data: {
"event_type": "documents_list_ready",
"documents_required": [...]
}
```
#### 2. Загрузка документа
```
POST /api/v1/documents/upload
Content-Type: multipart/form-data
Body:
- claim_id: string
- document_type: string (contract, payment, etc.)
- file: binary
Response:
{
"success": true,
"file_id": "s3://...",
"ocr_status": "processing"
}
```
#### 3. SSE: Статус OCR и формирования заявления
```
GET /api/v1/events/{session_id}
Event: document_ocr_completed
Data: {
"event_type": "document_ocr_completed",
"document_type": "contract",
"ocr_data": {...}
}
Event: claim_ready
Data: {
"event_type": "claim_ready",
"claim_data": {...}
}
```
#### 4. Получение статуса черновика
```
GET /api/v1/claims/drafts/{claim_id}/status
Response:
{
"status_code": "draft_docs_progress",
"documents_total": 3,
"documents_uploaded": 1,
"documents_skipped": 0,
"wizard_ready": false,
"claim_ready": false
}
```
---
## 🖥️ Frontend компоненты
### 1. StepDocumentsNew.tsx (НОВЫЙ)
```tsx
// Поэкранная загрузка документов
// Один документ на экран
// Критичные помечены алертом
// Кнопки: "Загрузить", "Пропустить", "Назад"
interface Props {
documents: DocumentConfig[];
currentIndex: number;
onUpload: (file: File) => void;
onSkip: () => void;
onNext: () => void;
onPrev: () => void;
}
```
### 2. StepWaitingClaim.tsx (НОВЫЙ)
```tsx
// Loader пока формируется заявление
// Прогресс: "OCR документов...", "Анализ данных...", "Формирование заявления..."
// SSE подписка на claim_ready
interface Props {
sessionId: string;
onClaimReady: (claimData: any) => void;
}
```
### 3. StepDraftSelection.tsx (ОБНОВИТЬ)
```tsx
// Новые статусы черновиков
// Разные действия для разных статусов
// Legacy черновики → "Начать заново"
```
### 4. ClaimForm.tsx (ОБНОВИТЬ)
```tsx
// Новая логика шагов
// Убрать StepWizardPlan из основного флоу
// Добавить StepDocumentsNew и StepWaitingClaim
```
---
## ⚙️ n8n Воркфлоу
### 1. Генерация списка документов (быстрая)
```
Redis Trigger (ticket_form:description)
AI: Быстрый анализ → список документов (5-10 сек)
Redis Publish (ocr_events:{session_id})
+ event_type: documents_list_ready
PostgreSQL: Сохранить documents_required в черновик
Параллельно: Запустить генерацию визарда (отдельный воркфлоу)
```
### 2. Генерация визарда (фоновая)
```
(Запускается из воркфлоу 1)
AI Agent: RAG + генерация вопросов (2 мин)
PostgreSQL: Сохранить wizard_plan в черновик
+ wizard_ready = true
```
### 3. OCR документа
```
Webhook (upload документа)
S3 Upload
AI Vision: OCR + извлечение данных
PostgreSQL: Сохранить в documents_uploaded
Redis Publish: document_ocr_completed
Если все документы загружены:
↓ (Запустить формирование заявления)
```
### 4. Формирование заявления
```
(После всех документов)
Собрать данные из:
- wizard_plan
- documents_uploaded (OCR данные)
- CRM контакт
AI: Сформировать заявление
PostgreSQL: Сохранить claim_data
+ claim_ready = true
Redis Publish: claim_ready
```
---
## 📝 План реализации
### Фаза 1: Frontend (без n8n)
1. ✅ Создать `StepDocumentsNew.tsx` — заглушка с mock данными
2. ✅ Создать `StepWaitingClaim.tsx` — loader
3. ✅ Обновить `ClaimForm.tsx` — новый флоу шагов
4. ✅ Обновить `StepDraftSelection.tsx` — новые статусы
### Фаза 2: Backend
1. ✅ Эндпоинт `POST /api/v1/documents/upload`
2. ✅ SSE events: `documents_list_ready`, `document_ocr_completed`, `claim_ready`
3. ✅ Эндпоинт `GET /api/v1/claims/drafts/{claim_id}/status`
### Фаза 3: n8n
1. ✅ Воркфлоу: Генерация списка документов
2. ✅ Воркфлоу: OCR документа
3. ✅ Воркфлоу: Формирование заявления
### Фаза 4: Интеграция и тестирование
1. ✅ Полный цикл с реальными данными
2. ✅ Обработка ошибок
3. ✅ Legacy черновики
---
## 🎯 Ожидаемый результат
| Метрика | Было | Стало |
|---------|------|-------|
| Время до первого действия | ~2 мин | ~10 сек |
| Количество вопросов | 10-15 | 0-3 (только уточняющие) |
| Конверсия | ? | ↑ (меньше отвала) |
**Дата создания:** 2025-11-26
**Статус:** В разработке
---
## 📋 Проблема
Текущий флоу слишком медленный:
1. **2 минуты** — генерация визарда (RAG + AI анализ)
2. **Длинная анкета** — слишком много вопросов для пользователя
---
## ✅ Новое решение
### Концепция
1. После описания проблемы → сразу запрашиваем документы (без ожидания визарда)
2. Пока пользователь загружает документы → в бэке генерируется визард + OCR
3. После всех документов → показываем готовое заявление на апрув
### Преимущества
- **Быстрый старт** — пользователь не ждёт 2 минуты
- **Параллельная работа** — OCR и визард генерируются пока пользователь ищет документы
- **Меньше вопросов** — большая часть данных извлекается из документов
---
## 🔄 Новый флоу (шаги)
```
┌─────────────────┐
│ 1. Телефон │ (уже есть)
│ SMS верификация
└────────┬────────┘
┌─────────────────┐
│ 2. Черновики │ (уже есть, обновить UI)
│ - Новые статусы│
│ - Legacy→"Начать заново"
└────────┬────────┘
┌─────────────────┐
│ 3. Описание │ (уже есть)
│ Свободный текст│
└────────┬────────┘
▼ → n8n: быстрая генерация списка документов (5-10 сек)
│ → n8n: параллельно запускает генерацию визарда (в фоне)
┌─────────────────┐
│ 4. Документы │ 🆕 НОВЫЙ КОМПОНЕНТ
│ - Поэкранная загрузка
│ - Критичные помечены
│ - Можно пропустить
└────────┬────────┘
▼ → n8n: OCR каждого документа → заполнение визарда (в фоне)
┌─────────────────┐
│ 5. Ожидание │ 🆕 НОВЫЙ КОМПОНЕНТ
│ "Формируем заявление..."
│ Loader + прогресс
└────────┬────────┘
▼ ← n8n: claim_ready event (SSE)
┌─────────────────┐
│ 6. Заявление │ (уже есть StepClaimConfirmation)
│ Просмотр + редактирование
└────────┬────────┘
┌─────────────────┐
│ 7. SMS апрув │ (уже есть)
└─────────────────┘
```
---
## 📊 Статусы черновика (status_code)
| Статус | Описание | UI при открытии |
|--------|----------|-----------------|
| `draft_new` | Только описание | → Шаг документов |
| `draft_docs_progress` | Часть документов загружена | → Продолжить с текущего документа |
| `draft_docs_complete` | Все документы загружены | → Показать loader |
| `draft_claim_ready` | Заявление готово | → Показать заявление |
| `awaiting_sms` | Ждёт SMS | → Форма SMS |
| `approved` | Отправлено | Не показываем |
### Legacy черновики (старый формат)
- Нет `documents_required` → показываем с пометкой "устаревший"
- Кнопка "Начать заново" → копирует description, создаёт новый черновик
---
## 📦 Структура payload черновика
```json
{
// === Идентификаторы ===
"claim_id": "CLM-2025-11-26-X7Y8Z9",
"session_token": "sess_abc123...",
"unified_id": "user_456...",
"phone": "+79991234567",
"email": "user@example.com",
// === Описание проблемы ===
"problem_description": "Купил курсы за 50000р, компания не отвечает...",
// === Документы (новое!) ===
"documents_required": [
{
"type": "contract",
"name": "Договор или оферта",
"critical": true,
"hints": "Скриншот или PDF договора/оферты"
},
{
"type": "payment",
"name": "Подтверждение оплаты",
"critical": true,
"hints": "Чек, выписка из банка, скриншот платежа"
},
{
"type": "correspondence",
"name": "Переписка с продавцом",
"critical": false,
"hints": "Скриншоты переписки, email, чаты"
}
],
"documents_uploaded": [
{
"type": "contract",
"file_id": "s3://...",
"ocr_status": "completed",
"ocr_data": {...}
}
],
"documents_skipped": ["correspondence"],
"current_doc_index": 1,
// === Визард (генерируется в фоне) ===
"wizard_plan": {...}, // AI-generated questions
"wizard_answers": {...}, // Auto-filled from OCR
"wizard_ready": true, // Флаг готовности
// === Заявление ===
"claim_ready": false, // Флаг готовности заявления
"claim_data": { // Готовое заявление для апрува
"applicant": {...},
"case": {...},
"contract_or_service": {...},
"offenders": [...],
"claim": {...},
"attachments": [...]
},
// === Метаданные ===
"created_at": "2025-11-26T10:00:00Z",
"updated_at": "2025-11-26T10:05:00Z"
}
```
---
## 🔌 API Endpoints
### Существующие (без изменений)
- `POST /api/v1/claims/description` — публикация описания в Redis
- `GET /api/v1/claims/drafts/list` — список черновиков
- `GET /api/v1/claims/drafts/{claim_id}` — полные данные черновика
- `POST /api/v1/claims/approve` — финальный апрув (SMS)
### Новые/Изменённые
#### 1. SSE: Получение списка документов
```
GET /api/v1/events/{session_id}
Event: documents_list_ready
Data: {
"event_type": "documents_list_ready",
"documents_required": [...]
}
```
#### 2. Загрузка документа
```
POST /api/v1/documents/upload
Content-Type: multipart/form-data
Body:
- claim_id: string
- document_type: string (contract, payment, etc.)
- file: binary
Response:
{
"success": true,
"file_id": "s3://...",
"ocr_status": "processing"
}
```
#### 3. SSE: Статус OCR и формирования заявления
```
GET /api/v1/events/{session_id}
Event: document_ocr_completed
Data: {
"event_type": "document_ocr_completed",
"document_type": "contract",
"ocr_data": {...}
}
Event: claim_ready
Data: {
"event_type": "claim_ready",
"claim_data": {...}
}
```
#### 4. Получение статуса черновика
```
GET /api/v1/claims/drafts/{claim_id}/status
Response:
{
"status_code": "draft_docs_progress",
"documents_total": 3,
"documents_uploaded": 1,
"documents_skipped": 0,
"wizard_ready": false,
"claim_ready": false
}
```
---
## 🖥️ Frontend компоненты
### 1. StepDocumentsNew.tsx (НОВЫЙ)
```tsx
// Поэкранная загрузка документов
// Один документ на экран
// Критичные помечены алертом
// Кнопки: "Загрузить", "Пропустить", "Назад"
interface Props {
documents: DocumentConfig[];
currentIndex: number;
onUpload: (file: File) => void;
onSkip: () => void;
onNext: () => void;
onPrev: () => void;
}
```
### 2. StepWaitingClaim.tsx (НОВЫЙ)
```tsx
// Loader пока формируется заявление
// Прогресс: "OCR документов...", "Анализ данных...", "Формирование заявления..."
// SSE подписка на claim_ready
interface Props {
sessionId: string;
onClaimReady: (claimData: any) => void;
}
```
### 3. StepDraftSelection.tsx (ОБНОВИТЬ)
```tsx
// Новые статусы черновиков
// Разные действия для разных статусов
// Legacy черновики → "Начать заново"
```
### 4. ClaimForm.tsx (ОБНОВИТЬ)
```tsx
// Новая логика шагов
// Убрать StepWizardPlan из основного флоу
// Добавить StepDocumentsNew и StepWaitingClaim
```
---
## ⚙️ n8n Воркфлоу
### 1. Генерация списка документов (быстрая)
```
Redis Trigger (ticket_form:description)
AI: Быстрый анализ → список документов (5-10 сек)
Redis Publish (ocr_events:{session_id})
+ event_type: documents_list_ready
PostgreSQL: Сохранить documents_required в черновик
Параллельно: Запустить генерацию визарда (отдельный воркфлоу)
```
### 2. Генерация визарда (фоновая)
```
(Запускается из воркфлоу 1)
AI Agent: RAG + генерация вопросов (2 мин)
PostgreSQL: Сохранить wizard_plan в черновик
+ wizard_ready = true
```
### 3. OCR документа
```
Webhook (upload документа)
S3 Upload
AI Vision: OCR + извлечение данных
PostgreSQL: Сохранить в documents_uploaded
Redis Publish: document_ocr_completed
Если все документы загружены:
↓ (Запустить формирование заявления)
```
### 4. Формирование заявления
```
(После всех документов)
Собрать данные из:
- wizard_plan
- documents_uploaded (OCR данные)
- CRM контакт
AI: Сформировать заявление
PostgreSQL: Сохранить claim_data
+ claim_ready = true
Redis Publish: claim_ready
```
---
## 📝 План реализации
### Фаза 1: Frontend (без n8n)
1. ✅ Создать `StepDocumentsNew.tsx` — заглушка с mock данными
2. ✅ Создать `StepWaitingClaim.tsx` — loader
3. ✅ Обновить `ClaimForm.tsx` — новый флоу шагов
4. ✅ Обновить `StepDraftSelection.tsx` — новые статусы
### Фаза 2: Backend
1. ✅ Эндпоинт `POST /api/v1/documents/upload`
2. ✅ SSE events: `documents_list_ready`, `document_ocr_completed`, `claim_ready`
3. ✅ Эндпоинт `GET /api/v1/claims/drafts/{claim_id}/status`
### Фаза 3: n8n
1. ✅ Воркфлоу: Генерация списка документов
2. ✅ Воркфлоу: OCR документа
3. ✅ Воркфлоу: Формирование заявления
### Фаза 4: Интеграция и тестирование
1. ✅ Полный цикл с реальными данными
2. ✅ Обработка ошибок
3. ✅ Legacy черновики
---
## 🎯 Ожидаемый результат
| Метрика | Было | Стало |
|---------|------|-------|
| Время до первого действия | ~2 мин | ~10 сек |
| Количество вопросов | 10-15 | 0-3 (только уточняющие) |
| Конверсия | ? | ↑ (меньше отвала) |

View File

@@ -0,0 +1,130 @@
-- ============================================================================
-- Исправленный SQL для сохранения документов (claimsave_final) - ПОДДЕРЖКА НОВОГО ФЛОУ
-- ============================================================================
-- Проблема: SQL не сохранял documents_required и мог перезаписать статус
-- Решение: Сохраняем documents_required и не перезаписываем новые статусы
-- ============================================================================
WITH partial AS (
SELECT $1::jsonb AS p, $2::text AS claim_id_str
),
claim_lookup AS (
SELECT
c.id,
c.payload,
c.status_code
FROM clpr_claims c, partial
WHERE c.id::text = partial.claim_id_str
OR c.payload->>'claim_id' = partial.claim_id_str
ORDER BY
CASE WHEN c.id::text = partial.claim_id_str THEN 1 ELSE 2 END,
c.updated_at DESC
LIMIT 1
),
docs AS (
SELECT
claim_lookup.id::text AS claim_id,
doc.field_name::text AS field_name,
doc.file_id::text AS file_id,
doc.file_name::text AS file_name,
doc.original_file_name::text AS original_file_name,
(doc.uploaded_at)::timestamptz AS uploaded_at,
doc.file_url::text AS file_url
FROM partial, claim_lookup
CROSS JOIN LATERAL jsonb_to_recordset(
COALESCE(partial.p->'documents_meta','[]'::jsonb)
) AS doc(
field_name text, file_id text, file_name text,
original_file_name text, uploaded_at text, file_url text
)
),
upsert_docs AS (
INSERT INTO clpr_claim_documents
(claim_id, field_name, file_id, uploaded_at, file_name, original_file_name)
SELECT claim_id, field_name, file_id, uploaded_at, file_name, original_file_name
FROM docs
ON CONFLICT (claim_id, field_name) DO UPDATE
SET file_id = EXCLUDED.file_id,
uploaded_at = EXCLUDED.uploaded_at,
file_name = EXCLUDED.file_name,
original_file_name = EXCLUDED.original_file_name
RETURNING id, claim_id, field_name, file_id
),
-- ✅ ИСПРАВЛЕНО: Сохраняем documents_required и обновляем статус правильно
upd_claim AS (
UPDATE clpr_claims c
SET
-- ✅ Объединяем payload: сохраняем documents_required и documents_meta
payload = jsonb_set(
jsonb_set(
COALESCE(c.payload, '{}'::jsonb),
'{documents_meta}',
COALESCE((SELECT p->'documents_meta' FROM partial), '[]'::jsonb),
true
),
'{documents_required}',
COALESCE(
(SELECT p->'documents_required' FROM partial WHERE partial.p->'documents_required' IS NOT NULL),
c.payload->'documents_required', -- Сохраняем существующий, если новый не пришёл
'[]'::jsonb
),
true
),
-- ✅ Обновляем статус только если нужно (не перезаписываем новые статусы)
status_code = CASE
-- Если статус уже новый - сохраняем его
WHEN c.status_code IN ('draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready')
THEN c.status_code
-- Если есть documents_required и документы загружены - обновляем статус
WHEN c.payload->'documents_required' IS NOT NULL
AND jsonb_array_length(COALESCE(c.payload->'documents_required', '[]'::jsonb)) > 0
AND (SELECT COUNT(*) FROM docs) > 0
THEN CASE
WHEN (SELECT COUNT(*) FROM docs) >= jsonb_array_length(COALESCE(c.payload->'documents_required', '[]'::jsonb))
THEN 'draft_docs_complete'
ELSE 'draft_docs_progress'
END
-- Иначе сохраняем существующий
ELSE c.status_code
END,
updated_at = now(),
expires_at = now() + interval '14 days'
FROM partial, claim_lookup
WHERE c.id = claim_lookup.id
RETURNING c.id, c.payload, c.status_code
)
SELECT
(SELECT jsonb_build_object(
'claim_id', u.id::text,
'status_code', u.status_code,
'payload', u.payload
) FROM upd_claim u) AS claim,
(
SELECT jsonb_agg(
jsonb_build_object(
'id', u.id,
'field_name', u.field_name,
'file_id', u.file_id,
'file_url', d.file_url,
'file_name', d.file_name,
'original_file_name', d.original_file_name,
'uploaded_at', d.uploaded_at,
'filename_for_upload',
COALESCE(
NULLIF(d.original_file_name, ''),
NULLIF(d.file_name, ''),
regexp_replace(d.file_id, '^.*/', '')
)
)
)
FROM upsert_docs u
JOIN docs d ON d.claim_id = u.claim_id AND d.field_name = u.field_name
WHERE d.file_url IS NOT NULL AND d.file_url <> ''
) AS documents;

View File

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

View File

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

View File

@@ -0,0 +1,81 @@
# Структура documents_meta в SQL запросах
## Текущая структура после OCR объединения
После обработки файлов OCR возвращает объединённые документы со следующей структурой:
```json
{
"documents_meta": [
{
"field_name": "uploads[0][0]",
"field_label": "Договор или заказ",
"file_id": "clientright/0/1764167196926.pdf",
"file_name": "1764167196926.pdf",
"original_file_name": "1764167196926.pdf",
"uploaded_at": "2025-11-26T14:44:51.430Z",
"files_count": 2, // ✅ Новое поле: сколько файлов было объединено
"pages": 4 // ✅ Новое поле: сколько страниц в объединённом PDF
}
]
}
```
## Как SQL обрабатывает эту структуру
### 1. Сохранение в `clpr_claim_documents`
SQL использует `jsonb_to_recordset` для извлечения только нужных полей:
```sql
CROSS JOIN LATERAL jsonb_to_recordset(
COALESCE(partial.p->'documents_meta', '[]'::jsonb)
) AS doc(
field_name text,
file_id text,
file_name text,
original_file_name text,
uploaded_at text
)
```
**Важно:** `field_label`, `files_count`, `pages` не извлекаются, но это нормально - они не нужны в таблице `clpr_claim_documents`.
### 2. Сохранение в `payload->'documents_meta'`
Полный JSON сохраняется в `payload` через `jsonb_build_object`:
```sql
jsonb_build_object(
'claim_id', partial.claim_id_str,
'documents_meta', COALESCE(partial.p->'documents_meta', '[]'::jsonb),
...
)
```
**Результат:** Все поля (`field_label`, `files_count`, `pages`) сохраняются в `payload->'documents_meta'` в полном объёме.
## Проверка сохранения
После выполнения SQL запроса можно проверить:
```sql
SELECT
payload->'documents_meta'->0->>'field_label' AS field_label,
payload->'documents_meta'->0->>'files_count' AS files_count,
payload->'documents_meta'->0->>'pages' AS pages
FROM clpr_claims
WHERE payload->>'claim_id' = 'bddb6815-8e17-4d54-a721-5e94382942c7';
```
Должны вернуться:
- `field_label`: "Договор или заказ"
- `files_count`: "2"
- `pages`: "4"
## Вывод
**SQL запрос работает правильно** - дополнительные поля сохраняются в `payload->'documents_meta'` и доступны для использования в дальнейших операциях.
**Не нужно менять SQL** - текущая структура достаточна для работы.

View File

@@ -0,0 +1,98 @@
-- ============================================================================
-- SQL для исправления field_name в таблице clpr_claim_documents
-- ============================================================================
-- Проблема: Все документы имеют одинаковый field_name (uploads[0][0])
-- Решение: Пересоздаём записи с правильными field_name на основе documents_uploaded
-- ============================================================================
-- Для конкретного claim_id
WITH claim_data AS (
SELECT
id,
payload
FROM clpr_claims
WHERE id::text = 'bddb6815-8e17-4d54-a721-5e94382942c7'
OR payload->>'claim_id' = 'bddb6815-8e17-4d54-a721-5e94382942c7'
ORDER BY updated_at DESC
LIMIT 1
),
-- Извлекаем documents_required для определения индексов
documents_required_array AS (
SELECT
jsonb_array_elements(payload->'documents_required') WITH ORDINALITY AS doc_req(doc, idx)
FROM claim_data
),
-- Извлекаем documents_uploaded с правильными индексами
documents_uploaded_mapped AS (
SELECT
doc_up.*,
(doc_req.idx - 1)::int AS group_index -- Индекс документа (0-based)
FROM claim_data,
jsonb_array_elements(payload->'documents_uploaded') AS doc_up,
documents_required_array doc_req
WHERE (doc_up->>'id' = doc_req.doc->>'id' OR doc_up->>'type' = doc_req.doc->>'id')
),
-- Удаляем старые записи
deleted_old AS (
DELETE FROM clpr_claim_documents
WHERE claim_id = (SELECT id::text FROM claim_data)
RETURNING claim_id, field_name, file_id
),
-- Вставляем новые записи с правильными field_name
inserted_new AS (
INSERT INTO clpr_claim_documents (
claim_id,
field_name,
file_id,
file_name,
original_file_name,
uploaded_at
)
SELECT
(SELECT id::text FROM claim_data) AS claim_id,
'uploads[' || group_index || '][0]' AS field_name,
doc_up->>'file_id' AS file_id,
doc_up->>'file_name' AS file_name,
doc_up->>'original_file_name' AS original_file_name,
COALESCE(
(doc_up->>'uploaded_at')::timestamptz,
now()
) AS uploaded_at
FROM documents_uploaded_mapped doc_up
WHERE doc_up->>'file_id' IS NOT NULL
AND doc_up->>'file_id' <> ''
ON CONFLICT (claim_id, field_name) DO UPDATE SET
file_id = EXCLUDED.file_id,
file_name = EXCLUDED.file_name,
original_file_name = EXCLUDED.original_file_name,
uploaded_at = EXCLUDED.uploaded_at
RETURNING claim_id, field_name, file_id, file_name
)
-- Возвращаем результат
SELECT
'Удалено старых записей' AS action,
COUNT(*) AS count
FROM deleted_old
UNION ALL
SELECT
'Вставлено новых записей' AS action,
COUNT(*) AS count
FROM inserted_new;
-- Проверка результата
SELECT
ccd.claim_id,
ccd.field_name,
ccd.file_id,
ccd.file_name,
ccd.original_file_name,
ccd.uploaded_at
FROM clpr_claim_documents ccd
WHERE ccd.claim_id = 'bddb6815-8e17-4d54-a721-5e94382942c7'
ORDER BY ccd.field_name;

View File

@@ -0,0 +1,79 @@
-- ============================================================================
-- SQL для исправления черновика bddb6815-8e17-4d54-a721-5e94382942c7
-- ============================================================================
-- Проблема: У черновика нет documents_required и неправильный статус
-- Решение: Добавляем documents_required и устанавливаем правильный статус
-- ============================================================================
UPDATE clpr_claims
SET
status_code = CASE
-- Если документы уже загружены - ставим draft_docs_progress или draft_docs_complete
WHEN jsonb_array_length(COALESCE(payload->'documents_uploaded', '[]'::jsonb)) > 0
THEN CASE
WHEN jsonb_array_length(COALESCE(payload->'documents_uploaded', '[]'::jsonb)) >= 4
THEN 'draft_docs_complete'
ELSE 'draft_docs_progress'
END
-- Если документов нет - ставим draft_new
ELSE 'draft_new'
END,
-- Добавляем documents_required в payload
payload = jsonb_set(
COALESCE(payload, '{}'::jsonb),
'{documents_required}',
'[
{
"id": "contract",
"name": "Договор или заказ",
"hints": "Фото или скан подписанного договора или квитанции",
"accept": ["pdf", "jpg", "png"],
"priority": 1,
"required": true
},
{
"id": "payment",
"name": "Чек или подтверждение оплаты",
"hints": "Копия кассового чека, онлайн-платежа или квитанции",
"accept": ["pdf", "jpg", "png"],
"priority": 1,
"required": true
},
{
"id": "correspondence",
"name": "Переписка",
"hints": "Скриншоты сообщений, писем, жалоб",
"accept": ["pdf", "jpg", "png"],
"priority": 2,
"required": false
},
{
"id": "evidence_photo",
"name": "Фото доказательства",
"hints": "Фото дефектов товара, видео процесса ремонта или передачи",
"accept": ["jpg", "png", "pdf"],
"priority": 2,
"required": false
}
]'::jsonb,
true
),
updated_at = now()
WHERE id::text = 'bddb6815-8e17-4d54-a721-5e94382942c7'
OR payload->>'claim_id' = 'bddb6815-8e17-4d54-a721-5e94382942c7';
-- Проверяем результат
SELECT
id::text,
status_code,
payload->>'claim_id' as claim_id,
jsonb_array_length(COALESCE(payload->'documents_required', '[]'::jsonb)) as docs_required_count,
jsonb_array_length(COALESCE(payload->'documents_uploaded', '[]'::jsonb)) as docs_uploaded_count,
payload->'documents_required'->0->>'name' as first_doc_name
FROM clpr_claims
WHERE id::text = 'bddb6815-8e17-4d54-a721-5e94382942c7'
OR payload->>'claim_id' = 'bddb6815-8e17-4d54-a721-5e94382942c7';

View File

@@ -0,0 +1,345 @@
-- ============================================================================
-- SQL запрос для n8n: Сохранение черновика (НОВЫЙ ФЛОУ с документами)
-- ============================================================================
-- Назначение: Сохранить черновик сразу после анализа описания проблемы
-- AI Agent возвращает facts + docs (список документов)
--
-- Вход от AI Agent:
-- output: { facts_short, facts_full, problem, recommendation, docs: [...] }
-- propertyName: { session_id, phone, unified_id, contact_id, ФИО и т.д. }
--
-- Параметры:
-- $1 = payload_json (jsonb) - полный payload с output и propertyName
-- $2 = session_token (text) - сессия пользователя (из propertyName.session_id)
-- $3 = unified_id (text) - unified_id пользователя
-- $4 = problem_description (text) - исходное описание проблемы от пользователя
--
-- Возвращает:
-- claim - объект с claim_id, session_token, status_code, documents_required
-- ============================================================================
WITH input_data AS (
SELECT
$1::jsonb AS payload,
$2::text AS session_token_str,
NULLIF($3::text, '') AS unified_id_str,
NULLIF($4::text, '') AS problem_desc
),
-- Извлекаем данные из payload
parsed_data AS (
SELECT
input_data.*,
input_data.payload->'output' AS ai_output,
input_data.payload->'propertyName' AS user_data,
input_data.payload->'output'->'docs' AS documents_required
FROM input_data
),
-- Проверяем существующий черновик по session_token
existing_claim AS (
SELECT id, payload
FROM clpr_claims
WHERE session_token = (SELECT session_token_str FROM input_data)
LIMIT 1
),
-- Генерируем или используем существующий UUID
claim_id_resolved AS (
SELECT
COALESCE(
(SELECT id FROM existing_claim),
gen_random_uuid()
) AS claim_uuid
),
-- INSERT или UPDATE черновика
upserted_claim AS (
INSERT INTO clpr_claims (
id,
session_token,
unified_id,
channel,
type_code,
status_code,
payload,
created_at,
updated_at,
expires_at
)
SELECT
claim_id_resolved.claim_uuid,
parsed_data.session_token_str,
COALESCE(parsed_data.unified_id_str, parsed_data.user_data->>'unified_id'),
'web_form',
'consumer',
'draft_new', -- ✅ Новый статус: только описание + документы
jsonb_build_object(
'claim_id', claim_id_resolved.claim_uuid::text,
'problem_description', COALESCE(parsed_data.problem_desc, parsed_data.user_data->>'problem_description'),
-- AI анализ
'ai_analysis', jsonb_build_object(
'facts_short', parsed_data.ai_output->>'facts_short',
'facts_full', parsed_data.ai_output->>'facts_full',
'problem', parsed_data.ai_output->>'problem',
'recommendation', parsed_data.ai_output->>'recommendation'
),
-- ✅ Список необходимых документов (новое!)
'documents_required', COALESCE(parsed_data.documents_required, '[]'::jsonb),
'documents_uploaded', '[]'::jsonb,
'documents_skipped', '[]'::jsonb,
'current_doc_index', 0,
-- Данные пользователя
'phone', COALESCE(parsed_data.user_data->>'phone', ''),
'email', COALESCE(parsed_data.user_data->>'email', ''),
'contact_id', parsed_data.user_data->>'contact_id',
-- ФИО и паспортные данные (для заявления)
'applicant', jsonb_build_object(
'lastname', parsed_data.user_data->>'lastname',
'firstname', parsed_data.user_data->>'firstname',
'middle_name', parsed_data.user_data->>'middle_name',
'birthday', parsed_data.user_data->>'birthday',
'birthplace', parsed_data.user_data->>'birthplace',
'inn', parsed_data.user_data->>'inn',
'address', parsed_data.user_data->>'mailingstreet',
'zip', parsed_data.user_data->>'mailingzip'
),
-- Telegram ID если есть
'tg_id', parsed_data.user_data->>'tg_id',
-- Флаги готовности
'wizard_ready', false,
'claim_ready', false
),
now(),
now(),
now() + interval '14 days'
FROM parsed_data, claim_id_resolved
ON CONFLICT (id) DO UPDATE SET
unified_id = COALESCE(EXCLUDED.unified_id, clpr_claims.unified_id),
status_code = 'draft_new',
payload = clpr_claims.payload || EXCLUDED.payload,
updated_at = now(),
expires_at = now() + interval '14 days'
RETURNING id, session_token, status_code, payload
)
-- Возвращаем результат для n8n
SELECT
jsonb_build_object(
'claim_id', upserted_claim.id::text,
'session_token', upserted_claim.session_token,
'status_code', upserted_claim.status_code,
'documents_required', upserted_claim.payload->'documents_required',
'documents_count', jsonb_array_length(COALESCE(upserted_claim.payload->'documents_required', '[]'::jsonb))
) AS claim
FROM upserted_claim;
-- ============================================================================
-- Пример вызова в n8n (PostgreSQL Node):
-- ============================================================================
--
-- Параметры:
-- $1 = {{ JSON.stringify($json) }} -- Весь payload от AI Agent
-- $2 = {{ $json.propertyName.session_id }} -- session_token
-- $3 = {{ $json.propertyName.unified_id }} -- unified_id
-- $4 = {{ $node["Redis Trigger"].json.description }} -- Исходное описание проблемы
--
-- После выполнения SQL, в Code Node пушим в Redis:
--
-- const result = $input.first().json.claim;
--
-- return {
-- json: {
-- channel: `ocr_events:${result.session_token}`,
-- event: {
-- event_type: 'documents_list_ready',
-- claim_id: result.claim_id,
-- session_id: result.session_token,
-- documents_required: result.documents_required,
-- documents_count: result.documents_count,
-- timestamp: new Date().toISOString()
-- }
-- }
-- };
-- ============================================================================
-- SQL запрос для n8n: Сохранение черновика (НОВЫЙ ФЛОУ с документами)
-- ============================================================================
-- Назначение: Сохранить черновик сразу после анализа описания проблемы
-- AI Agent возвращает facts + docs (список документов)
--
-- Вход от AI Agent:
-- output: { facts_short, facts_full, problem, recommendation, docs: [...] }
-- propertyName: { session_id, phone, unified_id, contact_id, ФИО и т.д. }
--
-- Параметры:
-- $1 = payload_json (jsonb) - полный payload с output и propertyName
-- $2 = session_token (text) - сессия пользователя (из propertyName.session_id)
-- $3 = unified_id (text) - unified_id пользователя
-- $4 = problem_description (text) - исходное описание проблемы от пользователя
--
-- Возвращает:
-- claim - объект с claim_id, session_token, status_code, documents_required
-- ============================================================================
WITH input_data AS (
SELECT
$1::jsonb AS payload,
$2::text AS session_token_str,
NULLIF($3::text, '') AS unified_id_str,
NULLIF($4::text, '') AS problem_desc
),
-- Извлекаем данные из payload
parsed_data AS (
SELECT
input_data.*,
input_data.payload->'output' AS ai_output,
input_data.payload->'propertyName' AS user_data,
input_data.payload->'output'->'docs' AS documents_required
FROM input_data
),
-- Проверяем существующий черновик по session_token
existing_claim AS (
SELECT id, payload
FROM clpr_claims
WHERE session_token = (SELECT session_token_str FROM input_data)
LIMIT 1
),
-- Генерируем или используем существующий UUID
claim_id_resolved AS (
SELECT
COALESCE(
(SELECT id FROM existing_claim),
gen_random_uuid()
) AS claim_uuid
),
-- INSERT или UPDATE черновика
upserted_claim AS (
INSERT INTO clpr_claims (
id,
session_token,
unified_id,
channel,
type_code,
status_code,
payload,
created_at,
updated_at,
expires_at
)
SELECT
claim_id_resolved.claim_uuid,
parsed_data.session_token_str,
COALESCE(parsed_data.unified_id_str, parsed_data.user_data->>'unified_id'),
'web_form',
'consumer',
'draft_new', -- ✅ Новый статус: только описание + документы
jsonb_build_object(
'claim_id', claim_id_resolved.claim_uuid::text,
'problem_description', COALESCE(parsed_data.problem_desc, parsed_data.user_data->>'problem_description'),
-- AI анализ
'ai_analysis', jsonb_build_object(
'facts_short', parsed_data.ai_output->>'facts_short',
'facts_full', parsed_data.ai_output->>'facts_full',
'problem', parsed_data.ai_output->>'problem',
'recommendation', parsed_data.ai_output->>'recommendation'
),
-- ✅ Список необходимых документов (новое!)
'documents_required', COALESCE(parsed_data.documents_required, '[]'::jsonb),
'documents_uploaded', '[]'::jsonb,
'documents_skipped', '[]'::jsonb,
'current_doc_index', 0,
-- Данные пользователя
'phone', COALESCE(parsed_data.user_data->>'phone', ''),
'email', COALESCE(parsed_data.user_data->>'email', ''),
'contact_id', parsed_data.user_data->>'contact_id',
-- ФИО и паспортные данные (для заявления)
'applicant', jsonb_build_object(
'lastname', parsed_data.user_data->>'lastname',
'firstname', parsed_data.user_data->>'firstname',
'middle_name', parsed_data.user_data->>'middle_name',
'birthday', parsed_data.user_data->>'birthday',
'birthplace', parsed_data.user_data->>'birthplace',
'inn', parsed_data.user_data->>'inn',
'address', parsed_data.user_data->>'mailingstreet',
'zip', parsed_data.user_data->>'mailingzip'
),
-- Telegram ID если есть
'tg_id', parsed_data.user_data->>'tg_id',
-- Флаги готовности
'wizard_ready', false,
'claim_ready', false
),
now(),
now(),
now() + interval '14 days'
FROM parsed_data, claim_id_resolved
ON CONFLICT (id) DO UPDATE SET
unified_id = COALESCE(EXCLUDED.unified_id, clpr_claims.unified_id),
status_code = 'draft_new',
payload = clpr_claims.payload || EXCLUDED.payload,
updated_at = now(),
expires_at = now() + interval '14 days'
RETURNING id, session_token, status_code, payload
)
-- Возвращаем результат для n8n
SELECT
jsonb_build_object(
'claim_id', upserted_claim.id::text,
'session_token', upserted_claim.session_token,
'status_code', upserted_claim.status_code,
'documents_required', upserted_claim.payload->'documents_required',
'documents_count', jsonb_array_length(COALESCE(upserted_claim.payload->'documents_required', '[]'::jsonb))
) AS claim
FROM upserted_claim;
-- ============================================================================
-- Пример вызова в n8n (PostgreSQL Node):
-- ============================================================================
--
-- Параметры:
-- $1 = {{ JSON.stringify($json) }} -- Весь payload от AI Agent
-- $2 = {{ $json.propertyName.session_id }} -- session_token
-- $3 = {{ $json.propertyName.unified_id }} -- unified_id
-- $4 = {{ $node["Redis Trigger"].json.description }} -- Исходное описание проблемы
--
-- После выполнения SQL, в Code Node пушим в Redis:
--
-- const result = $input.first().json.claim;
--
-- return {
-- json: {
-- channel: `ocr_events:${result.session_token}`,
-- event: {
-- event_type: 'documents_list_ready',
-- claim_id: result.claim_id,
-- session_id: result.session_token,
-- documents_required: result.documents_required,
-- documents_count: result.documents_count,
-- timestamp: new Date().toISOString()
-- }
-- }
-- };
-- ============================================================================

View File

@@ -0,0 +1,31 @@
-- Правильный SQL запрос для получения всех данных контакта с кастомными полями
-- Исправлено: birthday в vtiger_contactsubdetails, mailingstreet в vtiger_contactaddress
SELECT
cd.contactid,
cd.firstname,
cd.lastname,
cd.email,
cd.mobile,
cd.phone,
cs.birthday, -- ✅ Из vtiger_contactsubdetails
ca.mailingstreet, -- ✅ Из vtiger_contactaddress
ca.mailingcity,
ca.mailingstate,
ca.mailingzip,
ca.mailingcountry,
-- Кастомные поля из vtiger_contactscf:
ccf.cf_1157 AS middle_name, -- Отчество
ccf.cf_1263 AS birthplace, -- Место рождения
ccf.cf_1257 AS inn, -- ИНН
ccf.cf_1849 AS requisites, -- Реквизиты
ccf.cf_1580 AS code, -- Код
ccf.cf_1706 AS sms -- SMS
FROM vtiger_contactdetails cd
LEFT JOIN vtiger_contactscf ccf ON ccf.contactid = cd.contactid
LEFT JOIN vtiger_contactsubdetails cs ON cs.contactsubscriptionid = cd.contactid
LEFT JOIN vtiger_contactaddress ca ON ca.contactaddressid = cd.contactid
LEFT JOIN vtiger_crmentity ce ON ce.crmid = cd.contactid
WHERE cd.contactid = {{ $json.contact_id }}
AND ce.deleted = 0

View File

@@ -0,0 +1,27 @@
// Code23 — помещаем в n8n-nodes-base.code (JS), Mode = Run Once for All Items
// Берём все входные элементы
const items = $input.all();
// Предполагаем, что нас интересует первый элемент массива
const data = items[0].json;
// Всегда возвращаем сообщение об ошибке
const answerText = 'Извините, произошла ошибка, мы уже работаем над ее устранением, попробуйте задать ваш вопрос еще раз через некоторое время';
// Собираем единый объект для следующего узла
return [
{
json: {
...data,
respound: {
type: 'text',
text: answerText,
replyMarkup: {
remove_keyboard: true
}
}
}
}
];