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:
@@ -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 [
|
||||
{
|
||||
|
||||
157
docs/N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js
Normal file
157
docs/N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js
Normal 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
|
||||
}
|
||||
}];
|
||||
115
docs/N8N_CODE_PUSH_DOCUMENTS_LIST.js
Normal file
115
docs/N8N_CODE_PUSH_DOCUMENTS_LIST.js
Normal 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
225
docs/N8N_MEMORY_ISSUES.md
Normal 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)
|
||||
|
||||
167
docs/N8N_REDIS_TRIGGER_TROUBLESHOOTING.md
Normal file
167
docs/N8N_REDIS_TRIGGER_TROUBLESHOOTING.md
Normal 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
|
||||
|
||||
767
docs/NEW_FLOW_ARCHITECTURE.md
Normal file
767
docs/NEW_FLOW_ARCHITECTURE.md
Normal 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 (только уточняющие) |
|
||||
| Конверсия | ? | ↑ (меньше отвала) |
|
||||
|
||||
|
||||
130
docs/SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW.sql
Normal file
130
docs/SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW.sql
Normal 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;
|
||||
|
||||
299
docs/SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql
Normal file
299
docs/SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql
Normal 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;
|
||||
|
||||
362
docs/SQL_CLAIMSAVE_FIXED_NEW_FLOW.sql
Normal file
362
docs/SQL_CLAIMSAVE_FIXED_NEW_FLOW.sql
Normal 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;
|
||||
|
||||
81
docs/SQL_DOCUMENTS_META_STRUCTURE.md
Normal file
81
docs/SQL_DOCUMENTS_META_STRUCTURE.md
Normal 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** - текущая структура достаточна для работы.
|
||||
|
||||
98
docs/SQL_FIX_CLAIM_DOCUMENTS_FIELD_NAMES.sql
Normal file
98
docs/SQL_FIX_CLAIM_DOCUMENTS_FIELD_NAMES.sql
Normal 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;
|
||||
|
||||
79
docs/SQL_FIX_DRAFT_BDDB6815.sql
Normal file
79
docs/SQL_FIX_DRAFT_BDDB6815.sql
Normal 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';
|
||||
|
||||
345
docs/SQL_SAVE_DRAFT_NEW_FLOW.sql
Normal file
345
docs/SQL_SAVE_DRAFT_NEW_FLOW.sql
Normal 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()
|
||||
-- }
|
||||
-- }
|
||||
-- };
|
||||
-- ============================================================================
|
||||
|
||||
|
||||
31
docs/SQL_SELECT_CONTACT_WITH_CUSTOM_FIELDS.sql
Normal file
31
docs/SQL_SELECT_CONTACT_WITH_CUSTOM_FIELDS.sql
Normal 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
|
||||
|
||||
27
docs/n8n_code_error_response.js
Normal file
27
docs/n8n_code_error_response.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user