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:
@@ -197,3 +197,5 @@ const redisKey = `ocr_events:${sessionToken || 'temp-' + Date.now()}`;
|
|||||||
- `ticket_form/docs/CODE4_FIXED.js` - исправленный код узла Code4
|
- `ticket_form/docs/CODE4_FIXED.js` - исправленный код узла Code4
|
||||||
- `ticket_form/docs/CODE_CREATE_WEB_CONTACT_FINAL.js` - исправленный код создания контакта
|
- `ticket_form/docs/CODE_CREATE_WEB_CONTACT_FINAL.js` - исправленный код создания контакта
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -343,3 +343,4 @@ TTL: 86400 секунд
|
|||||||
**Статус:** ✅ Завершено
|
**Статус:** ✅ Завершено
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -209,3 +209,4 @@ $2 = {{ $json.claim_id }} (TEXT, строка "CLM-2025-11-18-GEQ3K
|
|||||||
Оба запроса теперь используют строковый `claim_id` и правильно находят UUID.
|
Оба запроса теперь используют строковый `claim_id` и правильно находят UUID.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -102,3 +102,4 @@ function mapCombinedDocs(cds = []) {
|
|||||||
Но для `mapDialogHistory` это критично, т.к. она вызывается первой и падает.
|
Но для `mapDialogHistory` это критично, т.к. она вызывается первой и падает.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -211,3 +211,4 @@ const results = arr
|
|||||||
return results.length ? results : [{ json: null }];
|
return results.length ? results : [{ json: null }];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -75,3 +75,5 @@ return [{
|
|||||||
}
|
}
|
||||||
}];
|
}];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -42,3 +42,5 @@ return {
|
|||||||
ttl: 604800
|
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
|
**Дата:** 2025-11-21
|
||||||
**Статус:** Готово к внедрению ✅
|
**Статус:** Готово к внедрению ✅
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -93,3 +93,4 @@ updateFormData({
|
|||||||
5. **Response** → возвращает полный ответ с `unified_id`
|
5. **Response** → возвращает полный ответ с `unified_id`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -143,3 +143,4 @@ return {
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -132,3 +132,4 @@ WHERE ua.channel = 'web_form'
|
|||||||
Должна быть запись с `unified_id` в формате `usr_...`.
|
Должна быть запись с `unified_id` в формате `usr_...`.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -430,3 +430,4 @@ return claim;
|
|||||||
- ✅ Быстрая загрузка состояния формы
|
- ✅ Быстрая загрузка состояния формы
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -190,3 +190,4 @@ if (channel === 'telegram') {
|
|||||||
Все данные уже в PostgreSQL, и этого достаточно. Redis используется только для Pub/Sub событий (`ocr_events:{claim_id}`).
|
Все данные уже в 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 не возвращает данные - проверить формат параметров
|
4. Если SQL работает, но asyncpg не возвращает данные - проверить формат параметров
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -113,3 +113,4 @@ final_claim_id = row.get('claim_id') or claim_id_from_payload
|
|||||||
3. Убедиться, что все данные корректно восстанавливаются в форму
|
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
|
COUNT(phone) as with_phone
|
||||||
FROM clpr_claims;
|
FROM clpr_claims;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -214,3 +214,5 @@ SELECT
|
|||||||
LEFT JOIN upd u ON true
|
LEFT JOIN upd u ON true
|
||||||
LIMIT 1) AS claim;
|
LIMIT 1) AS claim;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -130,3 +130,4 @@ WITH existing AS (
|
|||||||
3. При создании/обновлении черновика заполнять `clpr_claims.unified_id = unified_id`
|
3. При создании/обновлении черновика заполнять `clpr_claims.unified_id = unified_id`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -71,3 +71,4 @@ ORDER BY c.updated_at DESC
|
|||||||
LIMIT 20;
|
LIMIT 20;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -210,3 +210,4 @@ SELECT
|
|||||||
- ✅ Правильное слияние `answers` и `documents_meta`
|
- ✅ Правильное слияние `answers` и `documents_meta`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -112,3 +112,4 @@
|
|||||||
Выполни задачу прямо сейчас и верни JSON согласно схеме.
|
Выполни задачу прямо сейчас и верни JSON согласно схеме.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
222
frontend/src/components/form/StepClaimConfirmation.tsx
Normal file
222
frontend/src/components/form/StepClaimConfirmation.tsx
Normal file
@@ -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<HTMLIFrameElement>(null);
|
||||||
|
const [htmlContent, setHtmlContent] = useState<string>('');
|
||||||
|
|
||||||
|
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, '\\u003c')
|
||||||
|
.replace(/>/g, '\\u003e');
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||||
|
<title>Подтверждение данных</title>
|
||||||
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
background: #f0f9ff;
|
||||||
|
border: 1px solid #bae6fd;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.info p {
|
||||||
|
margin: 8px 0;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #d1d5db;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>📋 Подтверждение данных заявления</h1>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<p><strong>Статус:</strong> Данные заявления получены</p>
|
||||||
|
<p><strong>Claim ID:</strong> ${data.case?.meta?.claim_id || 'не указан'}</p>
|
||||||
|
<p><strong>Unified ID:</strong> ${data.case?.meta?.unified_id || 'не указан'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-group">
|
||||||
|
<button class="btn-primary" onclick="window.parent.postMessage({type: 'claim_confirmed'}, '*')">
|
||||||
|
✅ Подтвердить и отправить
|
||||||
|
</button>
|
||||||
|
<button class="btn-secondary" onclick="window.parent.postMessage({type: 'claim_cancelled'}, '*')">
|
||||||
|
❌ Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script id="case-data" type="application/json">${caseJson}</script>
|
||||||
|
<script>
|
||||||
|
// Слушаем сообщения от родительского окна
|
||||||
|
window.addEventListener('message', function(event) {
|
||||||
|
console.log('Message received:', event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отправляем сообщение родителю при загрузке
|
||||||
|
window.parent.postMessage({type: 'claim_form_loaded'}, '*');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
<p style={{ marginTop: '16px' }}>Загрузка формы подтверждения...</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
srcDoc={htmlContent}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '800px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}
|
||||||
|
title="Форма подтверждения заявления"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -204,7 +204,7 @@ export default function StepDraftSelection({
|
|||||||
avatar={<FileTextOutlined style={{ fontSize: 24, color: '#595959' }} />}
|
avatar={<FileTextOutlined style={{ fontSize: 24, color: '#595959' }} />}
|
||||||
title={
|
title={
|
||||||
<Space>
|
<Space>
|
||||||
<Text strong>Черновик {draft.claim_id}</Text>
|
<Text strong>Черновик</Text>
|
||||||
<Tag color="default">Черновик</Tag>
|
<Tag color="default">Черновик</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -705,10 +705,17 @@ export default function StepWizardPlan({
|
|||||||
block.description || ''
|
block.description || ''
|
||||||
);
|
);
|
||||||
|
|
||||||
// Имя "поля" группы
|
// Имя "поля" группы (используем docLabel если есть, иначе guessFieldName)
|
||||||
|
const fieldLabel = block.docLabel || block.fieldName || guessFieldName(group);
|
||||||
formPayload.append(
|
formPayload.append(
|
||||||
`uploads_field_names[${i}]`,
|
`uploads_field_names[${i}]`,
|
||||||
guessFieldName(group)
|
fieldLabel
|
||||||
|
);
|
||||||
|
|
||||||
|
// ✅ Добавляем реальное название поля (label) для использования в n8n
|
||||||
|
formPayload.append(
|
||||||
|
`uploads_field_labels[${i}]`,
|
||||||
|
block.docLabel || block.description || fieldLabel
|
||||||
);
|
);
|
||||||
|
|
||||||
// Файлы: uploads[i][j]
|
// Файлы: uploads[i][j]
|
||||||
@@ -754,18 +761,107 @@ export default function StepWizardPlan({
|
|||||||
response: parsed ?? text,
|
response: parsed ?? text,
|
||||||
});
|
});
|
||||||
message.success('Мы изучаем ваш вопрос и документы.');
|
message.success('Мы изучаем ваш вопрос и документы.');
|
||||||
|
|
||||||
|
// Подписываемся на канал claim:plan для получения данных заявления
|
||||||
|
if (formData.session_id) {
|
||||||
|
subscribeToClaimPlan(formData.session_id);
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ session_id отсутствует, не можем подписаться на claim:plan');
|
||||||
|
onNext();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('Ошибка соединения при отправке визарда.');
|
message.error('Ошибка соединения при отправке визарда.');
|
||||||
addDebugEvent?.('wizard', 'error', '❌ Ошибка соединения при отправке визарда', {
|
addDebugEvent?.('wizard', 'error', '❌ Ошибка соединения при отправке визарда', {
|
||||||
error: String(error),
|
error: String(error),
|
||||||
});
|
});
|
||||||
|
onNext();
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
onNext();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Функция подписки на канал claim:plan
|
||||||
|
const subscribeToClaimPlan = useCallback((sessionToken: string) => {
|
||||||
|
console.log('📡 Подписка на канал claim:plan для session:', sessionToken);
|
||||||
|
|
||||||
|
// Закрываем предыдущее соединение, если есть
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём новое SSE соединение
|
||||||
|
const eventSource = new EventSource(`/api/v1/claim-plan/${sessionToken}`);
|
||||||
|
eventSourceRef.current = eventSource;
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
console.log('✅ Подключено к каналу claim:plan');
|
||||||
|
addDebugEvent?.('claim-plan', 'info', '📡 Ожидание данных заявления...');
|
||||||
|
message.loading('Ожидание данных заявления...', 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
console.log('📥 Получены данные из claim:plan:', data);
|
||||||
|
|
||||||
|
if (data.event_type === 'claim_plan_ready' && data.status === 'ready') {
|
||||||
|
// Данные заявления получены!
|
||||||
|
message.destroy(); // Убираем loading сообщение
|
||||||
|
message.success('Данные заявления готовы!');
|
||||||
|
|
||||||
|
// Сохраняем данные заявления в formData
|
||||||
|
updateFormData({
|
||||||
|
claimPlanData: data.data, // Данные от n8n
|
||||||
|
showClaimConfirmation: true, // Флаг для показа формы подтверждения
|
||||||
|
});
|
||||||
|
|
||||||
|
// Закрываем SSE соединение
|
||||||
|
eventSource.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
|
||||||
|
// Переходим к следующему шагу (форма подтверждения)
|
||||||
|
onNext();
|
||||||
|
} else if (data.event_type === 'claim_plan_error' || data.status === 'error') {
|
||||||
|
message.destroy();
|
||||||
|
message.error(data.message || 'Ошибка получения данных заявления');
|
||||||
|
eventSource.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
onNext(); // Переходим дальше даже при ошибке
|
||||||
|
} else if (data.event_type === 'claim_plan_timeout' || data.status === 'timeout') {
|
||||||
|
message.destroy();
|
||||||
|
message.warning('Превышено время ожидания. Попробуйте обновить страницу.');
|
||||||
|
eventSource.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
onNext();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка парсинга данных из claim:plan:', error);
|
||||||
|
message.destroy();
|
||||||
|
message.error('Ошибка обработки данных заявления');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('❌ Ошибка SSE соединения claim:plan:', error);
|
||||||
|
message.destroy();
|
||||||
|
message.error('Ошибка подключения к серверу');
|
||||||
|
eventSource.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
onNext(); // Переходим дальше даже при ошибке
|
||||||
|
};
|
||||||
|
|
||||||
|
// Таймаут на 5 минут
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
console.warn('⏰ Таймаут ожидания данных заявления');
|
||||||
|
message.destroy();
|
||||||
|
message.warning('Превышено время ожидания данных заявления');
|
||||||
|
eventSource.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
onNext();
|
||||||
|
}, 300000); // 5 минут
|
||||||
|
}, [addDebugEvent, updateFormData, onNext]);
|
||||||
|
|
||||||
const renderQuestionField = (question: WizardQuestion) => {
|
const renderQuestionField = (question: WizardQuestion) => {
|
||||||
// Обработка по input_type для более точного определения типа поля
|
// Обработка по input_type для более точного определения типа поля
|
||||||
if (question.input_type === 'multi_choice' || question.control === 'input[type="checkbox"]') {
|
if (question.input_type === 'multi_choice' || question.control === 'input[type="checkbox"]') {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import StepDescription from '../components/form/StepDescription';
|
|||||||
import Step1Policy from '../components/form/Step1Policy';
|
import Step1Policy from '../components/form/Step1Policy';
|
||||||
import StepDraftSelection from '../components/form/StepDraftSelection';
|
import StepDraftSelection from '../components/form/StepDraftSelection';
|
||||||
import StepWizardPlan from '../components/form/StepWizardPlan';
|
import StepWizardPlan from '../components/form/StepWizardPlan';
|
||||||
|
import StepClaimConfirmation from '../components/form/StepClaimConfirmation';
|
||||||
import Step2EventType from '../components/form/Step2EventType';
|
import Step2EventType from '../components/form/Step2EventType';
|
||||||
import StepDocumentUpload from '../components/form/StepDocumentUpload';
|
import StepDocumentUpload from '../components/form/StepDocumentUpload';
|
||||||
import Step3Payment from '../components/form/Step3Payment';
|
import Step3Payment from '../components/form/Step3Payment';
|
||||||
@@ -42,6 +43,10 @@ interface FormData {
|
|||||||
wizardUploads?: Record<string, any>;
|
wizardUploads?: Record<string, any>;
|
||||||
wizardSkippedDocuments?: string[];
|
wizardSkippedDocuments?: string[];
|
||||||
|
|
||||||
|
// Подтверждение заявления (после получения данных из claim:plan)
|
||||||
|
showClaimConfirmation?: boolean;
|
||||||
|
claimPlanData?: any; // Данные заявления от n8n из канала claim:plan
|
||||||
|
|
||||||
// Шаг 3: Event Type
|
// Шаг 3: Event Type
|
||||||
eventType?: string;
|
eventType?: string;
|
||||||
ticket_id?: string; // ✅ ID заявки в vTiger (HelpDesk)
|
ticket_id?: string; // ✅ ID заявки в vTiger (HelpDesk)
|
||||||
@@ -392,15 +397,22 @@ export default function ClaimForm() {
|
|||||||
// Проверка наличия черновиков
|
// Проверка наличия черновиков
|
||||||
const checkDrafts = useCallback(async (unified_id?: string, phone?: string, sessionId?: string) => {
|
const checkDrafts = useCallback(async (unified_id?: string, phone?: string, sessionId?: string) => {
|
||||||
try {
|
try {
|
||||||
|
console.log('🔍 ========== checkDrafts вызван ==========');
|
||||||
|
console.log('🔍 Параметры:', { unified_id, phone, sessionId });
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
// Приоритет: unified_id > phone > session_id
|
// Приоритет: unified_id > phone > session_id
|
||||||
if (unified_id) {
|
if (unified_id) {
|
||||||
params.append('unified_id', unified_id);
|
params.append('unified_id', unified_id);
|
||||||
|
console.log('🔍 Используем unified_id:', unified_id);
|
||||||
} else if (phone) {
|
} else if (phone) {
|
||||||
params.append('phone', phone);
|
params.append('phone', phone);
|
||||||
|
console.log('🔍 Используем phone:', phone);
|
||||||
} else if (sessionId) {
|
} else if (sessionId) {
|
||||||
params.append('session_id', sessionId);
|
params.append('session_id', sessionId);
|
||||||
|
console.log('🔍 Используем session_id:', sessionId);
|
||||||
} else {
|
} else {
|
||||||
|
console.warn('⚠️ Нет параметров для поиска черновиков');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,22 +420,29 @@ export default function ClaimForm() {
|
|||||||
console.log('🔍 Запрос черновиков:', url);
|
console.log('🔍 Запрос черновиков:', url);
|
||||||
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
|
console.log('🔍 Статус ответа:', response.status, response.statusText);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error('❌ Ошибка запроса черновиков:', response.status, response.statusText);
|
const errorText = await response.text();
|
||||||
|
console.error('❌ Ошибка запроса черновиков:', response.status, response.statusText, errorText);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('🔍 Ответ API черновиков:', data);
|
console.log('🔍 Полный ответ API черновиков:', JSON.stringify(data, null, 2));
|
||||||
console.log('🔍 Debug info от backend:', data.debug);
|
console.log('🔍 Debug info от backend:', data.debug_info || data.debug);
|
||||||
const count = data.count || 0;
|
const count = data.count || 0;
|
||||||
console.log('🔍 Количество черновиков:', count);
|
console.log('🔍 Количество черновиков:', count);
|
||||||
|
console.log('🔍 Список черновиков:', data.drafts);
|
||||||
|
|
||||||
setHasDrafts(count > 0);
|
setHasDrafts(count > 0);
|
||||||
setShowDraftSelection(count > 0);
|
setShowDraftSelection(count > 0);
|
||||||
|
console.log('🔍 Установлены флаги: hasDrafts=', count > 0, 'showDraftSelection=', count > 0);
|
||||||
|
console.log('🔍 ========== checkDrafts завершён ==========');
|
||||||
return count > 0;
|
return count > 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка проверки черновиков:', error);
|
console.error('❌ Ошибка проверки черновиков:', error);
|
||||||
|
console.error('❌ Stack trace:', (error as Error).stack);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -592,27 +611,25 @@ export default function ClaimForm() {
|
|||||||
const shouldCheckDrafts = finalUnifiedId || (formData.phone && isPhoneVerified);
|
const shouldCheckDrafts = finalUnifiedId || (formData.phone && isPhoneVerified);
|
||||||
|
|
||||||
if (shouldCheckDrafts && !selectedDraftId) {
|
if (shouldCheckDrafts && !selectedDraftId) {
|
||||||
console.log('🔍 Проверка черновиков с unified_id:', finalUnifiedId, 'phone:', formData.phone);
|
console.log('🔍 Проверка черновиков с unified_id:', finalUnifiedId, 'phone:', formData.phone, 'sessionId:', sessionIdRef.current);
|
||||||
const hasDraftsResult = await checkDrafts(finalUnifiedId, formData.phone, sessionIdRef.current);
|
const hasDraftsResult = await checkDrafts(finalUnifiedId, formData.phone, sessionIdRef.current);
|
||||||
console.log('🔍 Результат checkDrafts:', hasDraftsResult);
|
console.log('🔍 Результат checkDrafts:', hasDraftsResult);
|
||||||
|
console.log('🔍 Текущие флаги после checkDrafts: hasDrafts=', hasDrafts, 'showDraftSelection=', showDraftSelection);
|
||||||
|
|
||||||
if (hasDraftsResult) {
|
if (hasDraftsResult) {
|
||||||
console.log('✅ Есть черновики, переходим к шагу 0');
|
console.log('✅ Есть черновики, переходим к шагу 0');
|
||||||
// ✅ ВАЖНО: Сначала устанавливаем флаги, потом переходим на шаг 0
|
// ✅ ВАЖНО: Сначала устанавливаем флаги, потом переходим на шаг 0
|
||||||
setShowDraftSelection(true);
|
setShowDraftSelection(true);
|
||||||
setHasDrafts(true);
|
setHasDrafts(true);
|
||||||
// ✅ Ждём следующего тика, чтобы useMemo пересчитался с новыми флагами
|
// ✅ Используем setTimeout для гарантии, что React обновил состояние
|
||||||
// Используем requestAnimationFrame для гарантии, что React обновил состояние
|
setTimeout(() => {
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
console.log('🔄 Переходим на шаг 0 после установки флагов');
|
console.log('🔄 Переходим на шаг 0 после установки флагов');
|
||||||
setCurrentStep(0); // Переходим к шагу выбора черновика
|
setCurrentStep(0); // Переходим к шагу выбора черновика
|
||||||
});
|
}, 100);
|
||||||
});
|
|
||||||
console.log('🛑 Остановка выполнения onNext - есть черновики');
|
console.log('🛑 Остановка выполнения onNext - есть черновики');
|
||||||
console.log('🛑 RETURN - функция должна остановиться здесь');
|
|
||||||
return; // ✅ ВАЖНО: Не идём дальше, если есть черновики
|
return; // ✅ ВАЖНО: Не идём дальше, если есть черновики
|
||||||
} else {
|
} else {
|
||||||
console.log('❌ Нет черновиков, идем дальше');
|
console.log('❌ Нет черновиков, идем дальше к описанию проблемы');
|
||||||
// Нет черновиков - идём дальше
|
// Нет черновиков - идём дальше
|
||||||
nextStep();
|
nextStep();
|
||||||
return;
|
return;
|
||||||
@@ -675,6 +692,21 @@ export default function ClaimForm() {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Шаг подтверждения заявления (показывается после получения данных из claim:plan)
|
||||||
|
if (formData.showClaimConfirmation && formData.claimPlanData) {
|
||||||
|
stepsArray.push({
|
||||||
|
title: 'Подтверждение',
|
||||||
|
description: 'Проверка данных',
|
||||||
|
content: (
|
||||||
|
<StepClaimConfirmation
|
||||||
|
claimPlanData={formData.claimPlanData}
|
||||||
|
onPrev={prevStep}
|
||||||
|
onNext={nextStep}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Шаг 3: Policy (всегда)
|
// Шаг 3: Policy (всегда)
|
||||||
stepsArray.push({
|
stepsArray.push({
|
||||||
title: 'Проверка полиса',
|
title: 'Проверка полиса',
|
||||||
|
|||||||
Reference in New Issue
Block a user