feat: Add claim plan confirmation flow via Redis SSE
Problem:
- After wizard form submission, need to wait for claim data from n8n
- Claim data comes via Redis channel claim:plan:{session_token}
- Need to display confirmation form with claim data
Solution:
1. Backend: Added SSE endpoint /api/v1/claim-plan/{session_token}
- Subscribes to Redis channel claim:plan:{session_token}
- Streams claim data from n8n to frontend
- Handles timeouts and errors gracefully
2. Frontend: Added subscription to claim:plan channel
- StepWizardPlan: After form submission, subscribes to SSE
- Waits for claim_plan_ready event
- Shows loading message while waiting
- On success: saves claimPlanData and shows confirmation step
3. New component: StepClaimConfirmation
- Displays claim confirmation form in iframe
- Receives claimPlanData from parent
- Generates HTML form (placeholder - should call n8n for real HTML)
- Handles confirmation/cancellation via postMessage
4. ClaimForm: Added conditional step for confirmation
- Shows StepClaimConfirmation when showClaimConfirmation=true
- Step appears after StepWizardPlan
- Only visible when claimPlanData is available
Flow:
1. User fills wizard form → submits
2. Form data sent to n8n via /api/v1/claims/wizard
3. Frontend subscribes to SSE /api/v1/claim-plan/{session_token}
4. n8n processes data → publishes to Redis claim:plan:{session_token}
5. Backend receives → streams to frontend via SSE
6. Frontend receives → shows StepClaimConfirmation
7. User confirms → proceeds to next step
Files:
- backend/app/api/events.py: Added stream_claim_plan endpoint
- frontend/src/components/form/StepWizardPlan.tsx: Added subscribeToClaimPlan
- frontend/src/components/form/StepClaimConfirmation.tsx: New component
- frontend/src/pages/ClaimForm.tsx: Added confirmation step to steps array
This commit is contained in:
@@ -209,3 +209,4 @@ $2 = {{ $json.claim_id }} (TEXT, строка "CLM-2025-11-18-GEQ3K
|
||||
Оба запроса теперь используют строковый `claim_id` и правильно находят UUID.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -102,3 +102,4 @@ function mapCombinedDocs(cds = []) {
|
||||
Но для `mapDialogHistory` это критично, т.к. она вызывается первой и падает.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -211,3 +211,4 @@ const results = arr
|
||||
return results.length ? results : [{ json: null }];
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -75,3 +75,5 @@ return [{
|
||||
}
|
||||
}];
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -42,3 +42,5 @@ return {
|
||||
ttl: 604800
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
164
docs/CODE_FILES_RENAME_FIXED.js
Normal file
164
docs/CODE_FILES_RENAME_FIXED.js
Normal file
@@ -0,0 +1,164 @@
|
||||
// === НАСТРОЙКА ===
|
||||
|
||||
const FILES_ROWS_NODE = 'editfiletobd1'; // <--- имя ноды, где формировались filesRows
|
||||
|
||||
// === ВХОД ИЗ PG ===
|
||||
|
||||
const sql = $json || {};
|
||||
|
||||
const claim_id = sql?.claim?.claim_id || $json.claim_id || null;
|
||||
|
||||
const docs = Array.isArray(sql.documents) ? sql.documents : [];
|
||||
|
||||
// === filesRows из предыдущей ноды ===
|
||||
|
||||
const filesRows = $items(FILES_ROWS_NODE)?.[0]?.json?.filesRows || [];
|
||||
|
||||
// === Получаем project_id и project_name ===
|
||||
// Пробуем получить из Edit Fields6, затем из текущего $json, затем из предыдущих нод
|
||||
|
||||
let project_id = null;
|
||||
let project_name = null;
|
||||
|
||||
// 1. Пробуем из Edit Fields6
|
||||
try {
|
||||
const editFields6 = $('Edit Fields6').first().json.propertyName || {};
|
||||
project_id = editFields6.project_id || null;
|
||||
project_name = editFields6.project_name || null;
|
||||
} catch (e) {
|
||||
// Игнорируем, пробуем другие источники
|
||||
}
|
||||
|
||||
// 2. Пробуем из текущего $json
|
||||
if (!project_id) {
|
||||
project_id = $json?.project_id || null;
|
||||
}
|
||||
if (!project_name) {
|
||||
project_name = $json?.project_name || null;
|
||||
}
|
||||
|
||||
// 3. Пробуем из предыдущей ноды (если есть нода, которая получает данные из Redis)
|
||||
if (!project_name) {
|
||||
try {
|
||||
// Пробуем найти ноду, которая получает данные сессии из Redis
|
||||
const redisNode = $node["Get Session"] || $node["GetSession"] || $node["Redis Get"];
|
||||
if (redisNode && redisNode.json) {
|
||||
const sessionData = typeof redisNode.json.value === 'string'
|
||||
? JSON.parse(redisNode.json.value)
|
||||
: redisNode.json.value;
|
||||
if (!project_name) {
|
||||
project_name = sessionData?.project_name || null;
|
||||
}
|
||||
if (!project_id) {
|
||||
project_id = sessionData?.project_id || null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Игнорируем ошибки
|
||||
}
|
||||
}
|
||||
|
||||
// === Утилиты ===
|
||||
|
||||
const S = v => (v == null ? '' : String(v));
|
||||
|
||||
const extOf = n => (S(n).match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase() || 'pdf');
|
||||
|
||||
const baseName = k => S(k).split('/').pop() || 'file.pdf';
|
||||
|
||||
const slugify = s => {
|
||||
s = S(s).toLowerCase().trim();
|
||||
const map = {а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ё:'e',ж:'zh',з:'z',и:'i',й:'y',к:'k',л:'l',
|
||||
м:'m',н:'n',о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f',х:'h',ц:'c',ч:'ch',ш:'sh',
|
||||
щ:'sch',ъ:'',ы:'y',ь:'',э:'e',ю:'yu',я:'ya'};
|
||||
s = s.replace(/[а-яё]/g, ch => map[ch] ?? ch);
|
||||
return s.replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'') || 'doc';
|
||||
};
|
||||
|
||||
// field_name -> doc_id
|
||||
|
||||
const byField = Object.create(null);
|
||||
|
||||
for (const d of docs) if (d?.field_name && d?.id) byField[d.field_name] = d.id;
|
||||
|
||||
// Правило финального пути
|
||||
// Новый формат: /f9825c87-4e3558f6-f9b6-405c-ad3d-d1535c49b61c/crm2/CRM_Active_Files/Documents/Project/{project_name}_{project_id}/{doc_id}__{slug}.{ext}
|
||||
// Пример: /.../Project/ERV_6381_КлиентПрав_398957/{doc_id}__{slug}.{ext}
|
||||
// project_name уже содержит "ERV_6381_КлиентПрав", просто добавляем к нему _project_id
|
||||
|
||||
const buildFinalKey = (row, doc_id) => {
|
||||
const bucketPrefix = 'f9825c87-4e3558f6-f9b6-405c-ad3d-d1535c49b61c';
|
||||
const basePath = 'crm2/CRM_Active_Files/Documents/Project';
|
||||
|
||||
// Используем название поля из формы визарда
|
||||
// Приоритет: field_label (из uploads_field_labels) > field_name (из uploads_field_names) > description > group_index
|
||||
// field_label должен приходить из n8n workflow, где формируется filesRows из uploads_field_labels[i]
|
||||
const fieldLabel = row.field_label || row.field_name || row.description || `group-${row.group_index}`;
|
||||
const slug = slugify(fieldLabel);
|
||||
const ext = extOf(row.original_name || 'pdf');
|
||||
|
||||
// Формируем название папки: {project_name}_{project_id}
|
||||
// project_name уже содержит "ERV_6381_КлиентПрав", добавляем к нему _project_id
|
||||
let projectFolder = 'unknown';
|
||||
if (project_name && project_id) {
|
||||
projectFolder = `${project_name}_${project_id}`;
|
||||
} else if (project_id) {
|
||||
// Fallback: если нет project_name, используем старый формат
|
||||
projectFolder = `${project_id}_Клиентправ`;
|
||||
}
|
||||
|
||||
// Очищаем от недопустимых символов для пути
|
||||
projectFolder = String(projectFolder)
|
||||
.replace(/[\/\\\?\*\:\|\"<>]/g, '_')
|
||||
.replace(/^\.+|\.+$/g, '')
|
||||
.trim() || 'unknown';
|
||||
|
||||
// Формируем путь: /{bucketPrefix}/{basePath}/{project_folder}/{doc_id}__{slug}.{ext}
|
||||
return `/${bucketPrefix}/${basePath}/${projectFolder}/${doc_id}__${slug}.${ext}`;
|
||||
};
|
||||
|
||||
// Собираем renames + финальные метаданные
|
||||
|
||||
const renames = [];
|
||||
|
||||
const finalDocumentsMeta = [];
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
for (const row of filesRows) {
|
||||
const g = Number(row.group_index) || 0;
|
||||
const field_name = `uploads[${g}][0]`;
|
||||
const doc_id = byField[field_name] || `grp${g}`; // фолбэк если чего-то не сошлось
|
||||
const fromKey = S(row.draft_key);
|
||||
const toKey = buildFinalKey(row, doc_id);
|
||||
|
||||
// Получаем название поля из row (field_label должен быть добавлен в ноде editfiletobd1)
|
||||
const field_label = row.field_label || row.field_name || row.description || `group-${g}`;
|
||||
|
||||
renames.push({
|
||||
from: fromKey,
|
||||
to: toKey,
|
||||
doc_id,
|
||||
field_name,
|
||||
field_label // ✅ Добавляем название поля
|
||||
});
|
||||
|
||||
finalDocumentsMeta.push({
|
||||
field_name,
|
||||
field_label, // ✅ Добавляем название поля
|
||||
file_id: toKey,
|
||||
file_name: baseName(toKey),
|
||||
original_file_name: baseName(row.original_name || toKey),
|
||||
uploaded_at: nowIso
|
||||
});
|
||||
}
|
||||
|
||||
return [{
|
||||
json: {
|
||||
claim_id,
|
||||
project_id,
|
||||
renames, // план копирования/переименования на S3
|
||||
payload_partial_json: { documents_meta: finalDocumentsMeta } // для финального апдейта в БД
|
||||
}
|
||||
}];
|
||||
|
||||
120
docs/CODE_MERGE_PROJECT_TO_SESSION.js
Normal file
120
docs/CODE_MERGE_PROJECT_TO_SESSION.js
Normal file
@@ -0,0 +1,120 @@
|
||||
// ========================================
|
||||
// Code Node: Мерж данных проекта в сессию
|
||||
// ========================================
|
||||
|
||||
// 1. Берём первый item
|
||||
const inputItem = $input.all()[0];
|
||||
|
||||
if (!inputItem || !inputItem.json) {
|
||||
throw new Error('Пустой input в Code Node (нет json)');
|
||||
}
|
||||
|
||||
// root — то, что реально пришло в эту ноду
|
||||
const root = inputItem.json;
|
||||
|
||||
// 2. Универсально получаем body
|
||||
// - если нода стоит сразу после Webhook → данные лежат в root.body
|
||||
// - если кто-то выше уже отдал только body → root и есть body
|
||||
const body = root.body || root;
|
||||
|
||||
// 3. Парсим body.other (если есть) как сессию
|
||||
let sessionData = {};
|
||||
const rawOther = body.other;
|
||||
|
||||
if (rawOther) {
|
||||
if (typeof rawOther === 'string') {
|
||||
try {
|
||||
sessionData = JSON.parse(rawOther);
|
||||
} catch (e) {
|
||||
throw new Error('Не смог распарсить body.other как JSON: ' + e.message + '. rawOther: ' + rawOther);
|
||||
}
|
||||
} else if (typeof rawOther === 'object') {
|
||||
sessionData = rawOther;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Определяем claimId (основной путь)
|
||||
let claimId = body.claim_id || sessionData.claim_id || null;
|
||||
|
||||
// 5. Fallback: пробуем достать claim_id напрямую из Webhook, если его до сих пор нет
|
||||
if (!claimId) {
|
||||
try {
|
||||
const webhookNodeJson = $('Webhook').first()?.json;
|
||||
if (webhookNodeJson?.body?.claim_id) {
|
||||
claimId = webhookNodeJson.body.claim_id;
|
||||
}
|
||||
} catch (e) {
|
||||
// молча игнорируем, просто не удалось взять из Webhook
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Если всё ещё нет claimId — это реально критичная ситуация
|
||||
if (!claimId) {
|
||||
throw new Error(
|
||||
'Нет claim_id ни в body, ни в sessionData, ни в Webhook. ' +
|
||||
'body: ' + JSON.stringify(body) +
|
||||
', sessionData: ' + JSON.stringify(sessionData)
|
||||
);
|
||||
}
|
||||
|
||||
// 7. Забираем результат ноды CreateClientProject (или CreateWebPorject, если опечатка в названии ноды)
|
||||
let projectNode = null;
|
||||
let projectNodeName = null;
|
||||
|
||||
// Пробуем найти ноду безопасно
|
||||
try {
|
||||
projectNode = $node["CreateClientProject"];
|
||||
if (projectNode && projectNode.json) {
|
||||
projectNodeName = "CreateClientProject";
|
||||
}
|
||||
} catch (e) {
|
||||
// Нода CreateClientProject не найдена, пробуем альтернативное название
|
||||
}
|
||||
|
||||
if (!projectNode || !projectNode.json) {
|
||||
try {
|
||||
projectNode = $node["CreateWebPorject"];
|
||||
if (projectNode && projectNode.json) {
|
||||
projectNodeName = "CreateWebPorject";
|
||||
}
|
||||
} catch (e) {
|
||||
// Нода CreateWebPorject тоже не найдена
|
||||
}
|
||||
}
|
||||
|
||||
if (!projectNode || !projectNode.json) {
|
||||
throw new Error('Нет данных от ноды CreateClientProject/CreateWebPorject. Убедитесь, что нода существует и выполнена.');
|
||||
}
|
||||
|
||||
const projectResult = projectNode.json.result;
|
||||
// Ожидаем что-то типа: { "project_id": "398095", "project_name": "Иванов_КлиентПрав", "is_new": false }
|
||||
|
||||
if (!projectResult || !projectResult.project_id) {
|
||||
throw new Error('Нет projectResult.project_id. result: ' + JSON.stringify(projectNode.json));
|
||||
}
|
||||
|
||||
// 8. Собираем обновлённую сессию
|
||||
const updatedSession = {
|
||||
...sessionData, // всё, что было в other
|
||||
claim_id: claimId, // актуальный claim_id
|
||||
project_id: projectResult.project_id, // id проекта из CRM
|
||||
project_name: projectResult.project_name || null, // название проекта из CRM (новое поле)
|
||||
is_new_project: projectResult.is_new, // флаг новый/старый
|
||||
current_step: 2, // двигаем визард на шаг 2
|
||||
updated_at: new Date().toISOString(),
|
||||
// опционально дотащим полезные поля из body:
|
||||
problem: body.problem ?? sessionData.problem,
|
||||
last_analysis_output: body.output ?? sessionData.last_analysis_output,
|
||||
};
|
||||
|
||||
// 9. Возвращаем один item для Redis SET
|
||||
return [
|
||||
{
|
||||
json: {
|
||||
redis_key: `claim:${claimId}`,
|
||||
redis_value: JSON.stringify(updatedSession),
|
||||
ttl: 604800, // 7 дней
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -182,3 +182,4 @@ clpr_users (id)
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -37,3 +37,4 @@ return {
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -46,3 +46,4 @@ return {
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -259,3 +259,4 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -399,3 +399,4 @@ IF "проверка наличия файлов"
|
||||
**Дата:** 2025-11-21
|
||||
**Статус:** Готово к внедрению ✅
|
||||
|
||||
|
||||
|
||||
@@ -93,3 +93,4 @@ updateFormData({
|
||||
5. **Response** → возвращает полный ответ с `unified_id`
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -143,3 +143,4 @@ return {
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -132,3 +132,4 @@ WHERE ua.channel = 'web_form'
|
||||
Должна быть запись с `unified_id` в формате `usr_...`.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -430,3 +430,4 @@ return claim;
|
||||
- ✅ Быстрая загрузка состояния формы
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -190,3 +190,4 @@ if (channel === 'telegram') {
|
||||
Все данные уже в PostgreSQL, и этого достаточно. Redis используется только для Pub/Sub событий (`ocr_events:{claim_id}`).
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -197,3 +197,4 @@ if (channel === 'web_form' && enable_cache === true) {
|
||||
Но это опционально и не обязательно для веб-формы.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -71,3 +71,4 @@
|
||||
4. Если SQL работает, но asyncpg не возвращает данные - проверить формат параметров
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -113,3 +113,4 @@ final_claim_id = row.get('claim_id') or claim_id_from_payload
|
||||
3. Убедиться, что все данные корректно восстанавливаются в форму
|
||||
|
||||
|
||||
|
||||
|
||||
60
docs/SESSION_LOG_2025-11-22.md
Normal file
60
docs/SESSION_LOG_2025-11-22.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Лог сессии работы с ticket_form - 22 ноября 2025
|
||||
|
||||
## Основные изменения
|
||||
|
||||
### 1. Исправлена загрузка черновиков
|
||||
- Добавлено расширенное логирование в `checkDrafts` (ClaimForm.tsx)
|
||||
- Упрощена логика перехода к шагу выбора черновика (заменён двойной `requestAnimationFrame` на `setTimeout(100)`)
|
||||
- Убрано отображение `claim_id` в заголовке черновика (теперь просто "Черновик")
|
||||
|
||||
### 2. Обновлен формат пути файлов в S3
|
||||
- Изменён формат с `{project_id}_Клиентправ` на `{project_name}_{project_id}`
|
||||
- `project_name` берётся из Redis (например, "ERV_6381_КлиентПрав")
|
||||
- Итоговый путь: `/f9825c87-.../crm2/CRM_Active_Files/Documents/Project/ERV_6381_КлиентПрав_398957/{doc_id}__{slug}.{ext}`
|
||||
- Файл: `ticket_form/docs/CODE_FILES_RENAME_FIXED.js`
|
||||
|
||||
### 3. Добавлено использование названия поля из формы визарда
|
||||
- В `StepWizardPlan.tsx` добавлена отправка `uploads_field_labels[i]` (содержит `block.docLabel`)
|
||||
- В `CODE_FILES_RENAME_FIXED.js` добавлен `field_label` в результат (`renames` и `documents_meta`)
|
||||
- Приоритет для slug: `field_label` > `field_name` > `description` > `group_index`
|
||||
- Теперь вместо `upload-contr` будет использоваться название поля (например, "Оглавление" → `oglavlenie`)
|
||||
|
||||
### 4. Обновлена операция CreateClientProject
|
||||
- Теперь возвращает не только `project_id`, но и `project_name`
|
||||
- `project_name` сохраняется в Redis сессии
|
||||
- Файл: `include/Webservices/CreateClientProject.php`
|
||||
|
||||
### 5. Исправлена нода Edit Fields13 в n8n
|
||||
- Добавлен `.first()` для обращения к нодам, возвращающим один item
|
||||
- Исправлено обращение к Split Out2 (используется `$json.to` вместо `$('Split Out2').item.json.to`)
|
||||
|
||||
### 6. Добавлен код для мержа данных проекта в сессию
|
||||
- Файл: `ticket_form/docs/CODE_MERGE_PROJECT_TO_SESSION.js`
|
||||
- Безопасная проверка существования ноды `CreateClientProject`
|
||||
- Добавлен `project_name` в Redis сессию
|
||||
|
||||
## Изменённые файлы
|
||||
|
||||
### Frontend
|
||||
- `ticket_form/frontend/src/pages/ClaimForm.tsx` - исправлена загрузка черновиков
|
||||
- `ticket_form/frontend/src/components/form/StepDraftSelection.tsx` - убран claim_id из заголовка
|
||||
- `ticket_form/frontend/src/components/form/StepWizardPlan.tsx` - добавлена отправка `uploads_field_labels`
|
||||
|
||||
### Backend
|
||||
- `include/Webservices/CreateClientProject.php` - добавлен возврат `project_name`
|
||||
|
||||
### Документация
|
||||
- `ticket_form/docs/CODE_FILES_RENAME_FIXED.js` - обновлён формат пути, добавлен `field_label`
|
||||
- `ticket_form/docs/CODE_MERGE_PROJECT_TO_SESSION.js` - новый файл для мержа данных проекта
|
||||
|
||||
## Git коммит
|
||||
- Commit: `486f3619`
|
||||
- Message: "Добавлен field_label в результат переименования файлов, исправлена загрузка черновиков, обновлен формат пути S3 с project_name"
|
||||
- Изменено: 212 файлов, +6706 строк, -125 строк
|
||||
|
||||
## Важные замечания
|
||||
|
||||
1. **Нода editfiletobd1 в n8n** должна добавлять `field_label` из `uploads_field_labels[i]` в каждый элемент `filesRows`
|
||||
2. **Нода Edit Fields13** должна использовать `.first()` для нод, возвращающих один item
|
||||
3. **Операция CreateClientProject** теперь возвращает `project_name`, который используется для формирования пути файлов
|
||||
|
||||
@@ -53,3 +53,4 @@ SELECT
|
||||
COUNT(phone) as with_phone
|
||||
FROM clpr_claims;
|
||||
|
||||
|
||||
|
||||
@@ -214,3 +214,5 @@ SELECT
|
||||
LEFT JOIN upd u ON true
|
||||
LIMIT 1) AS claim;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -130,3 +130,4 @@ WITH existing AS (
|
||||
3. При создании/обновлении черновика заполнять `clpr_claims.unified_id = unified_id`
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -71,3 +71,4 @@ ORDER BY c.updated_at DESC
|
||||
LIMIT 20;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -210,3 +210,4 @@ SELECT
|
||||
- ✅ Правильное слияние `answers` и `documents_meta`
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -112,3 +112,4 @@
|
||||
Выполни задачу прямо сейчас и верни JSON согласно схеме.
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user