From 0978e485dcfc2a31a603f76691989acc05fec4b9 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Mon, 24 Nov 2025 13:36:14 +0300 Subject: [PATCH] feat: Add claim plan confirmation flow via Redis SSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- SESSION_LOG_2025-11-19.md | 2 + SESSION_LOG_2025-11-20.md | 1 + backend/app/api/events.py | 126 ++++++++++ docs/CLAIMSAVE_FINAL_SQL.md | 1 + docs/CODE1_FIX.md | 1 + docs/CODE1_FIXED_CODE.js | 1 + docs/CODE4_FIXED.js | 2 + docs/CODE_CREATE_WEB_CONTACT_FIXED.js | 2 + docs/CODE_FILES_RENAME_FIXED.js | 164 +++++++++++++ docs/CODE_MERGE_PROJECT_TO_SESSION.js | 120 ++++++++++ docs/DATABASE_SCHEMA.md | 1 + docs/N8N_CODE_NODE_RESPONSE.js | 1 + docs/N8N_CODE_NODE_RESPONSE_SAFE.js | 1 + docs/N8N_FORM_GET_NO_FILES_BRANCH.json | 1 + docs/N8N_FORM_GET_NO_FILES_INSTRUCTIONS.md | 1 + docs/N8N_RESPONSE_FORMAT.md | 1 + docs/N8N_RESPONSE_WITH_UNIFIED_ID.md | 1 + docs/N8N_USER_CREATION_INSTRUCTIONS.md | 1 + docs/PERSONAL_CABINET_ARCHITECTURE.md | 1 + docs/REDIS_CLAIM_STORAGE_ANALYSIS.md | 1 + docs/REDIS_VS_POSTGRESQL_SPEED.md | 1 + docs/SESSION_LOG_2025-11-19.md | 1 + docs/SESSION_LOG_2025-11-20.md | 1 + docs/SESSION_LOG_2025-11-22.md | 60 +++++ docs/SQL_ALTER_CLPR_CLAIMS_ADD_FIELDS.sql | 1 + docs/SQL_CLAIMSAVE_PRIMARY_DRAFT.sql | 2 + docs/SQL_FIND_OR_CREATE_USER_WEB_FORM_N8N.md | 1 + docs/SQL_GET_ALL_CLAIMS_BY_UNIFIED_ID.sql | 1 + docs/WORKFLOW_ANALYSIS.md | 1 + docs/wizard_prompt_n8n.txt | 1 + .../components/form/StepClaimConfirmation.tsx | 222 ++++++++++++++++++ .../components/form/StepDraftSelection.tsx | 2 +- .../src/components/form/StepWizardPlan.tsx | 104 +++++++- frontend/src/pages/ClaimForm.tsx | 62 +++-- 34 files changed, 870 insertions(+), 20 deletions(-) create mode 100644 docs/CODE_FILES_RENAME_FIXED.js create mode 100644 docs/CODE_MERGE_PROJECT_TO_SESSION.js create mode 100644 docs/SESSION_LOG_2025-11-22.md create mode 100644 frontend/src/components/form/StepClaimConfirmation.tsx diff --git a/SESSION_LOG_2025-11-19.md b/SESSION_LOG_2025-11-19.md index e397fb9..5bf7dc3 100644 --- a/SESSION_LOG_2025-11-19.md +++ b/SESSION_LOG_2025-11-19.md @@ -197,3 +197,5 @@ const redisKey = `ocr_events:${sessionToken || 'temp-' + Date.now()}`; - `ticket_form/docs/CODE4_FIXED.js` - исправленный код узла Code4 - `ticket_form/docs/CODE_CREATE_WEB_CONTACT_FINAL.js` - исправленный код создания контакта + + diff --git a/SESSION_LOG_2025-11-20.md b/SESSION_LOG_2025-11-20.md index d1303b7..d126a5c 100644 --- a/SESSION_LOG_2025-11-20.md +++ b/SESSION_LOG_2025-11-20.md @@ -343,3 +343,4 @@ TTL: 86400 секунд **Статус:** ✅ Завершено + diff --git a/backend/app/api/events.py b/backend/app/api/events.py index 87a8459..3b9c9fe 100644 --- a/backend/app/api/events.py +++ b/backend/app/api/events.py @@ -238,3 +238,129 @@ async def stream_events(task_id: str): } ) + +@router.get("/claim-plan/{session_token}") +async def stream_claim_plan(session_token: str): + """ + SSE стрим для получения данных заявления из канала claim:plan:{session_token} + + Используется после отправки формы визарда для получения данных заявления + от n8n workflow, которые затем отображаются в форме подтверждения. + + Args: + session_token: Session token (например, sess_c9e7c0c2-de2e-40cd-ab7c-3bdc40282d34) + Используется для формирования канала claim:plan:{session_token} + + Returns: + StreamingResponse с данными заявления в формате: + { + "event_type": "claim_plan_ready", + "status": "ready", + "data": { + "propertyName": {...}, // Данные заявления из n8n + ... + } + } + """ + logger.info(f"🚀 Claim plan SSE connection requested for session_token: {session_token}") + + async def claim_plan_generator(): + """Генератор событий из Redis Pub/Sub для claim:plan канала""" + channel = f"claim:plan:{session_token}" + + # Подписываемся на канал Redis + pubsub = redis_service.client.pubsub() + await pubsub.subscribe(channel) + + logger.info(f"📡 Client subscribed to {channel}") + + # Отправляем начальное событие + yield f"data: {json.dumps({'status': 'connected', 'message': 'Ожидание данных заявления...'})}\n\n" + + try: + # Слушаем события (таймаут 5 минут для обработки в n8n) + while True: + logger.info(f"⏳ Waiting for claim plan data on {channel}...") + message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=300.0) + + if message: + logger.info(f"📥 Received claim plan message type: {message['type']}") + if message['type'] == 'message': + event_data_raw = message['data'] # Уже строка (decode_responses=True) + logger.info(f"📦 Raw claim plan data length: {len(event_data_raw)}") + + try: + # Парсим данные от n8n + claim_data = json.loads(event_data_raw) + + # Формируем событие в стандартном формате + event = { + "event_type": "claim_plan_ready", + "status": "ready", + "message": "Данные заявления готовы", + "data": claim_data, # Весь объект от n8n + "timestamp": None + } + + logger.info(f"✅ Claim plan data received for session {session_token}") + + # Отправляем событие клиенту + event_json = json.dumps(event, ensure_ascii=False) + yield f"data: {event_json}\n\n" + + # После получения данных закрываем соединение + logger.info(f"✅ Claim plan sent to client, closing SSE") + break + + except json.JSONDecodeError as e: + logger.error(f"❌ Failed to parse claim plan JSON: {e}") + error_event = { + "event_type": "claim_plan_error", + "status": "error", + "message": f"Ошибка парсинга данных: {str(e)}", + "data": {}, + "timestamp": None + } + yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n" + break + else: + logger.info(f"⏰ Timeout waiting for claim plan on {channel}") + # Отправляем timeout событие + timeout_event = { + "event_type": "claim_plan_timeout", + "status": "timeout", + "message": "Превышено время ожидания данных заявления", + "data": {}, + "timestamp": None + } + yield f"data: {json.dumps(timeout_event, ensure_ascii=False)}\n\n" + break + + await asyncio.sleep(0.1) + + except asyncio.CancelledError: + logger.info(f"❌ Client disconnected from {channel}") + except Exception as e: + logger.error(f"❌ Error in claim plan stream: {e}") + error_event = { + "event_type": "claim_plan_error", + "status": "error", + "message": f"Ошибка получения данных: {str(e)}", + "data": {}, + "timestamp": None + } + yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n" + finally: + await pubsub.unsubscribe(channel) + await pubsub.close() + + return StreamingResponse( + claim_plan_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" # Отключаем буферизацию nginx + } + ) + diff --git a/docs/CLAIMSAVE_FINAL_SQL.md b/docs/CLAIMSAVE_FINAL_SQL.md index 36b7313..828eeaf 100644 --- a/docs/CLAIMSAVE_FINAL_SQL.md +++ b/docs/CLAIMSAVE_FINAL_SQL.md @@ -209,3 +209,4 @@ $2 = {{ $json.claim_id }} (TEXT, строка "CLM-2025-11-18-GEQ3K Оба запроса теперь используют строковый `claim_id` и правильно находят UUID. + diff --git a/docs/CODE1_FIX.md b/docs/CODE1_FIX.md index f9cfca9..4966377 100644 --- a/docs/CODE1_FIX.md +++ b/docs/CODE1_FIX.md @@ -102,3 +102,4 @@ function mapCombinedDocs(cds = []) { Но для `mapDialogHistory` это критично, т.к. она вызывается первой и падает. + diff --git a/docs/CODE1_FIXED_CODE.js b/docs/CODE1_FIXED_CODE.js index b993268..e4176f0 100644 --- a/docs/CODE1_FIXED_CODE.js +++ b/docs/CODE1_FIXED_CODE.js @@ -211,3 +211,4 @@ const results = arr return results.length ? results : [{ json: null }]; + diff --git a/docs/CODE4_FIXED.js b/docs/CODE4_FIXED.js index ac22295..ff3e161 100644 --- a/docs/CODE4_FIXED.js +++ b/docs/CODE4_FIXED.js @@ -75,3 +75,5 @@ return [{ } }]; + + diff --git a/docs/CODE_CREATE_WEB_CONTACT_FIXED.js b/docs/CODE_CREATE_WEB_CONTACT_FIXED.js index 32ad8db..0050f86 100644 --- a/docs/CODE_CREATE_WEB_CONTACT_FIXED.js +++ b/docs/CODE_CREATE_WEB_CONTACT_FIXED.js @@ -42,3 +42,5 @@ return { ttl: 604800 }; + + diff --git a/docs/CODE_FILES_RENAME_FIXED.js b/docs/CODE_FILES_RENAME_FIXED.js new file mode 100644 index 0000000..da7a194 --- /dev/null +++ b/docs/CODE_FILES_RENAME_FIXED.js @@ -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 } // для финального апдейта в БД + } +}]; + diff --git a/docs/CODE_MERGE_PROJECT_TO_SESSION.js b/docs/CODE_MERGE_PROJECT_TO_SESSION.js new file mode 100644 index 0000000..0603c42 --- /dev/null +++ b/docs/CODE_MERGE_PROJECT_TO_SESSION.js @@ -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 дней + }, + }, +]; + diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 211e389..317ebd0 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -182,3 +182,4 @@ clpr_users (id) ``` + diff --git a/docs/N8N_CODE_NODE_RESPONSE.js b/docs/N8N_CODE_NODE_RESPONSE.js index e97b300..a1b40c8 100644 --- a/docs/N8N_CODE_NODE_RESPONSE.js +++ b/docs/N8N_CODE_NODE_RESPONSE.js @@ -37,3 +37,4 @@ return { }; + diff --git a/docs/N8N_CODE_NODE_RESPONSE_SAFE.js b/docs/N8N_CODE_NODE_RESPONSE_SAFE.js index 1c8f282..bfa8962 100644 --- a/docs/N8N_CODE_NODE_RESPONSE_SAFE.js +++ b/docs/N8N_CODE_NODE_RESPONSE_SAFE.js @@ -46,3 +46,4 @@ return { }; + diff --git a/docs/N8N_FORM_GET_NO_FILES_BRANCH.json b/docs/N8N_FORM_GET_NO_FILES_BRANCH.json index 20b2a44..ed6c415 100644 --- a/docs/N8N_FORM_GET_NO_FILES_BRANCH.json +++ b/docs/N8N_FORM_GET_NO_FILES_BRANCH.json @@ -259,3 +259,4 @@ } } + diff --git a/docs/N8N_FORM_GET_NO_FILES_INSTRUCTIONS.md b/docs/N8N_FORM_GET_NO_FILES_INSTRUCTIONS.md index a5efbcc..01f2c5c 100644 --- a/docs/N8N_FORM_GET_NO_FILES_INSTRUCTIONS.md +++ b/docs/N8N_FORM_GET_NO_FILES_INSTRUCTIONS.md @@ -399,3 +399,4 @@ IF "проверка наличия файлов" **Дата:** 2025-11-21 **Статус:** Готово к внедрению ✅ + diff --git a/docs/N8N_RESPONSE_FORMAT.md b/docs/N8N_RESPONSE_FORMAT.md index 3521aab..1eb1a17 100644 --- a/docs/N8N_RESPONSE_FORMAT.md +++ b/docs/N8N_RESPONSE_FORMAT.md @@ -93,3 +93,4 @@ updateFormData({ 5. **Response** → возвращает полный ответ с `unified_id` + diff --git a/docs/N8N_RESPONSE_WITH_UNIFIED_ID.md b/docs/N8N_RESPONSE_WITH_UNIFIED_ID.md index 39fcc27..976aed4 100644 --- a/docs/N8N_RESPONSE_WITH_UNIFIED_ID.md +++ b/docs/N8N_RESPONSE_WITH_UNIFIED_ID.md @@ -143,3 +143,4 @@ return { ``` + diff --git a/docs/N8N_USER_CREATION_INSTRUCTIONS.md b/docs/N8N_USER_CREATION_INSTRUCTIONS.md index efddd06..4c646b6 100644 --- a/docs/N8N_USER_CREATION_INSTRUCTIONS.md +++ b/docs/N8N_USER_CREATION_INSTRUCTIONS.md @@ -132,3 +132,4 @@ WHERE ua.channel = 'web_form' Должна быть запись с `unified_id` в формате `usr_...`. + diff --git a/docs/PERSONAL_CABINET_ARCHITECTURE.md b/docs/PERSONAL_CABINET_ARCHITECTURE.md index bfd4f07..97a5f29 100644 --- a/docs/PERSONAL_CABINET_ARCHITECTURE.md +++ b/docs/PERSONAL_CABINET_ARCHITECTURE.md @@ -430,3 +430,4 @@ return claim; - ✅ Быстрая загрузка состояния формы + diff --git a/docs/REDIS_CLAIM_STORAGE_ANALYSIS.md b/docs/REDIS_CLAIM_STORAGE_ANALYSIS.md index 890926e..b37a92b 100644 --- a/docs/REDIS_CLAIM_STORAGE_ANALYSIS.md +++ b/docs/REDIS_CLAIM_STORAGE_ANALYSIS.md @@ -190,3 +190,4 @@ if (channel === 'telegram') { Все данные уже в PostgreSQL, и этого достаточно. Redis используется только для Pub/Sub событий (`ocr_events:{claim_id}`). + diff --git a/docs/REDIS_VS_POSTGRESQL_SPEED.md b/docs/REDIS_VS_POSTGRESQL_SPEED.md index 47ad627..55e88eb 100644 --- a/docs/REDIS_VS_POSTGRESQL_SPEED.md +++ b/docs/REDIS_VS_POSTGRESQL_SPEED.md @@ -197,3 +197,4 @@ if (channel === 'web_form' && enable_cache === true) { Но это опционально и не обязательно для веб-формы. + diff --git a/docs/SESSION_LOG_2025-11-19.md b/docs/SESSION_LOG_2025-11-19.md index 9510e6d..2accb7a 100644 --- a/docs/SESSION_LOG_2025-11-19.md +++ b/docs/SESSION_LOG_2025-11-19.md @@ -71,3 +71,4 @@ 4. Если SQL работает, но asyncpg не возвращает данные - проверить формат параметров + diff --git a/docs/SESSION_LOG_2025-11-20.md b/docs/SESSION_LOG_2025-11-20.md index e64fc09..d8f7063 100644 --- a/docs/SESSION_LOG_2025-11-20.md +++ b/docs/SESSION_LOG_2025-11-20.md @@ -113,3 +113,4 @@ final_claim_id = row.get('claim_id') or claim_id_from_payload 3. Убедиться, что все данные корректно восстанавливаются в форму + diff --git a/docs/SESSION_LOG_2025-11-22.md b/docs/SESSION_LOG_2025-11-22.md new file mode 100644 index 0000000..9d0b01d --- /dev/null +++ b/docs/SESSION_LOG_2025-11-22.md @@ -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`, который используется для формирования пути файлов + diff --git a/docs/SQL_ALTER_CLPR_CLAIMS_ADD_FIELDS.sql b/docs/SQL_ALTER_CLPR_CLAIMS_ADD_FIELDS.sql index dcb4c46..bc7b580 100644 --- a/docs/SQL_ALTER_CLPR_CLAIMS_ADD_FIELDS.sql +++ b/docs/SQL_ALTER_CLPR_CLAIMS_ADD_FIELDS.sql @@ -53,3 +53,4 @@ SELECT COUNT(phone) as with_phone FROM clpr_claims; + diff --git a/docs/SQL_CLAIMSAVE_PRIMARY_DRAFT.sql b/docs/SQL_CLAIMSAVE_PRIMARY_DRAFT.sql index 5b2391f..d22d299 100644 --- a/docs/SQL_CLAIMSAVE_PRIMARY_DRAFT.sql +++ b/docs/SQL_CLAIMSAVE_PRIMARY_DRAFT.sql @@ -214,3 +214,5 @@ SELECT LEFT JOIN upd u ON true LIMIT 1) AS claim; + + diff --git a/docs/SQL_FIND_OR_CREATE_USER_WEB_FORM_N8N.md b/docs/SQL_FIND_OR_CREATE_USER_WEB_FORM_N8N.md index f9b9d0a..5cc6a0e 100644 --- a/docs/SQL_FIND_OR_CREATE_USER_WEB_FORM_N8N.md +++ b/docs/SQL_FIND_OR_CREATE_USER_WEB_FORM_N8N.md @@ -130,3 +130,4 @@ WITH existing AS ( 3. При создании/обновлении черновика заполнять `clpr_claims.unified_id = unified_id` + diff --git a/docs/SQL_GET_ALL_CLAIMS_BY_UNIFIED_ID.sql b/docs/SQL_GET_ALL_CLAIMS_BY_UNIFIED_ID.sql index bcb5422..46564c9 100644 --- a/docs/SQL_GET_ALL_CLAIMS_BY_UNIFIED_ID.sql +++ b/docs/SQL_GET_ALL_CLAIMS_BY_UNIFIED_ID.sql @@ -71,3 +71,4 @@ ORDER BY c.updated_at DESC LIMIT 20; + diff --git a/docs/WORKFLOW_ANALYSIS.md b/docs/WORKFLOW_ANALYSIS.md index 2945748..c684440 100644 --- a/docs/WORKFLOW_ANALYSIS.md +++ b/docs/WORKFLOW_ANALYSIS.md @@ -210,3 +210,4 @@ SELECT - ✅ Правильное слияние `answers` и `documents_meta` + diff --git a/docs/wizard_prompt_n8n.txt b/docs/wizard_prompt_n8n.txt index e98a204..4d68931 100644 --- a/docs/wizard_prompt_n8n.txt +++ b/docs/wizard_prompt_n8n.txt @@ -112,3 +112,4 @@ Выполни задачу прямо сейчас и верни JSON согласно схеме. + diff --git a/frontend/src/components/form/StepClaimConfirmation.tsx b/frontend/src/components/form/StepClaimConfirmation.tsx new file mode 100644 index 0000000..5e8625b --- /dev/null +++ b/frontend/src/components/form/StepClaimConfirmation.tsx @@ -0,0 +1,222 @@ +import { useEffect, useRef, useState } from 'react'; +import { Card, Spin, message } from 'antd'; + +interface Props { + claimPlanData: any; // Данные заявления от n8n + onNext: () => void; + onPrev: () => void; +} + +export default function StepClaimConfirmation({ + claimPlanData, + onNext, + onPrev, +}: Props) { + const [loading, setLoading] = useState(true); + const iframeRef = useRef(null); + const [htmlContent, setHtmlContent] = useState(''); + + useEffect(() => { + if (!claimPlanData) { + message.error('Данные заявления не получены'); + return; + } + + // Формируем данные для формы подтверждения + // Формат должен соответствовать тому, что ожидает HTML форма + const formData = { + case: { + user: claimPlanData?.propertyName?.applicant || {}, + project: claimPlanData?.propertyName?.case || {}, + offenders: claimPlanData?.propertyName?.offenders || [], + attachments: claimPlanData?.propertyName?.attachments_names || [], + meta: { + ...claimPlanData?.propertyName?.meta, + session_token: claimPlanData?.session_token || '', + prefix: claimPlanData?.prefix || '', + telegram_id: claimPlanData?.telegram_id || '', + claim_id: claimPlanData?.claim_id || claimPlanData?.propertyName?.meta?.claim_id || '', + unified_id: claimPlanData?.unified_id || claimPlanData?.propertyName?.meta?.unified_id || '', + user_id: claimPlanData?.user_id || claimPlanData?.propertyName?.meta?.user_id || '', + }, + }, + session_token: claimPlanData?.session_token || '', + telegram_id: claimPlanData?.telegram_id || '', + token: claimPlanData?.token || '', + sms_meta: { + session_token: claimPlanData?.session_token || '', + prefix: claimPlanData?.prefix || '', + telegram_id: claimPlanData?.telegram_id || '', + claim_id: claimPlanData?.claim_id || claimPlanData?.propertyName?.meta?.claim_id || '', + unified_id: claimPlanData?.unified_id || claimPlanData?.propertyName?.meta?.unified_id || '', + user_id: claimPlanData?.user_id || claimPlanData?.propertyName?.meta?.user_id || '', + }, + }; + + // Здесь нужно будет получить HTML форму от n8n или использовать готовый шаблон + // Пока используем заглушку - в реальности нужно будет вызывать n8n workflow для генерации HTML + const html = generateConfirmationFormHTML(formData); + setHtmlContent(html); + setLoading(false); + }, [claimPlanData]); + + // Функция генерации HTML формы (временная заглушка) + // В реальности это должен делать n8n workflow + const generateConfirmationFormHTML = (data: any): string => { + // Экранируем данные для безопасной вставки в HTML + const caseJson = JSON.stringify(data) + .replace(//g, '\\u003e'); + + return ` + + + + +Подтверждение данных + + + + +
+

📋 Подтверждение данных заявления

+ +
+

Статус: Данные заявления получены

+

Claim ID: ${data.case?.meta?.claim_id || 'не указан'}

+

Unified ID: ${data.case?.meta?.unified_id || 'не указан'}

+
+ +
+ + +
+
+ + + + +`; + }; + + useEffect(() => { + // Слушаем сообщения от iframe + const handleMessage = (event: MessageEvent) => { + console.log('📨 Message from iframe:', event.data); + + if (event.data.type === 'claim_confirmed') { + message.success('Заявление подтверждено!'); + onNext(); + } else if (event.data.type === 'claim_cancelled') { + message.info('Подтверждение отменено'); + onPrev(); + } else if (event.data.type === 'claim_form_loaded') { + setLoading(false); + } + }; + + window.addEventListener('message', handleMessage); + return () => { + window.removeEventListener('message', handleMessage); + }; + }, [onNext, onPrev]); + + if (loading) { + return ( + +
+ +

Загрузка формы подтверждения...

+
+
+ ); + } + + return ( + +