fix: исправлены ошибки clipboard и добавлена передача ticket_number в webhook поддержки

- Исправлена ошибка 'Cannot read properties of undefined (reading writeText)' в Step1Phone, StepClaimConfirmation, Step3Payment
  - Добавлена проверка на существование navigator.clipboard
  - Добавлен fallback для старых браузеров (document.execCommand)
  - Добавлена обработка ошибок с try/catch

- Добавлена передача ticket_number и ticket_id в webhook поддержки
  - При обработке события out_of_scope сохраняются claim_id, ticket_number, ticket_id из payload
  - Эти поля теперь передаются в webhook при отправке в поддержку

- Обновлён n8n код для обработки ошибок и out_of_scope
  - Добавлено явное сохранение ticket_number в data объекта события
  - ticket_number теперь гарантированно попадает в событие для Redis
This commit is contained in:
Fedor
2025-12-05 12:39:08 +03:00
parent ab54530500
commit 1fdb244fd4
5 changed files with 888 additions and 152 deletions

View File

@@ -0,0 +1,156 @@
// Code для n8n-nodes-base.code (JS), Mode = Run Once for All Items
// Обработка ошибок и случаев "не нашей тематики" для веб-формы
// Берём все входные элементы
const items = $input.all();
// Предполагаем, что нас интересует первый элемент массива
const data = items[0].json;
// Извлекаем данные
const consumerRights = data.consumer_rights; // true/false
const sessionToken = data.session_token || data.session_id;
const reason = data.reason || '';
const ticket = data.ticket || '';
const answerText = data.answer_text || '';
const ticketNumber = data.ticket_number || data.ticketnomber || null; // ✅ Номер заявки (HD001234)
// Формируем timestamp
const timestamp = new Date().toISOString();
// Определяем тип события и сообщение
let eventType;
let status;
let message;
let errorDetails = null;
let suggestedActions = [];
if (consumerRights === false) {
// ⚠️ СЛУЧАЙ: Вопрос не нашей тематики
eventType = 'out_of_scope';
status = 'out_of_scope';
message = reason ||
'К сожалению, ваш вопрос не относится к нашей компетенции. Мы помогаем с защитой прав потребителей в сфере услуг, товаров и туризма.';
// Рекомендуемые действия
suggestedActions = [
{
title: 'Обратиться в другую организацию',
description: reason || 'Для вашего вопроса лучше обратиться в соответствующую организацию',
url: 'https://akn16.ru',
urlText: 'Вы можете обратиться к нашим партнёрам',
actionType: 'external_link' // Для внешней ссылки
},
{
title: 'Связаться с поддержкой',
description: 'Если вы считаете, что это ошибка, свяжитесь с нами',
actionType: 'contact_support', // Для модалки с отправкой в поддержку
url: null
}
];
console.log('⚠️ Вопрос не нашей тематики:', {
ticket,
reason,
session_token: sessionToken
});
} else {
// ❌ СЛУЧАЙ: Ошибка обработки (но вопрос нашей тематики)
eventType = 'documents_list_error';
status = 'error';
message = 'Не удалось обработать ваш запрос. Попробуйте позже или обратитесь в поддержку.';
errorDetails = {
code: 'PROCESSING_FAILED',
reason: reason || 'Ошибка при обработке запроса',
ticket: ticket,
answer_text: answerText,
timestamp: timestamp
};
console.log('❌ Ошибка обработки:', {
ticket,
reason,
session_token: sessionToken
});
}
// Формируем событие для публикации в Redis
const event = {
event_type: eventType,
status: status,
session_id: sessionToken,
message: message,
timestamp: timestamp
};
// Добавляем специфичные поля в зависимости от типа события
if (eventType === 'out_of_scope') {
event.suggested_actions = suggestedActions;
event.reason = reason;
event.ticket = ticket;
} else if (eventType === 'documents_list_error') {
event.error_message = message;
event.error_details = errorDetails;
}
// Если есть claim_id, добавляем его
if (data.claim_id) {
event.claim_id = data.claim_id;
}
// Если есть ticket_number, добавляем его
if (ticketNumber) {
event.ticket_number = ticketNumber;
}
// Формируем канал Redis
const channel = `ocr_events:${sessionToken}`;
// Возвращаем данные для HTTP Request или Redis Publish node
return [
{
json: {
// Для HTTP Request к /api/v1/events/{session_token}
event_type: event.event_type,
status: event.status,
message: event.message,
data: {
...event,
// ✅ ticket_number должен быть в data (если был в event)
ticket_number: event.ticket_number || ticketNumber || null,
// Убираем дублирующиеся поля из data
event_type: undefined,
status: undefined,
message: undefined
},
timestamp: event.timestamp,
// Для Redis Publish (альтернативный вариант)
channel: channel,
redis_message: JSON.stringify(event),
// Передаём дальше для следующих нод
session_token: sessionToken,
claim_id: data.claim_id,
ticket_number: ticketNumber, // ✅ Номер заявки (HD001234)
user_id: data.user_id,
contactid: data.contactid,
mobile: data.mobile,
ticket: ticket,
reason: reason,
consumer_rights: consumerRights,
// Для отладки
_debug: {
event_type: eventType,
status: status,
channel: channel
}
}
}
];

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Form, Input, Button, message, Space } from 'antd';
import { PhoneOutlined, SafetyOutlined } from '@ant-design/icons';
import { Form, Input, Button, message, Space, Modal } from 'antd';
import { PhoneOutlined, SafetyOutlined, CopyOutlined } from '@ant-design/icons';
import { debugLog } from '../../utils/debugLog';
interface Props {
formData: any;
@@ -18,11 +19,13 @@ export default function Step1Phone({
addDebugEvent
}: Props) {
// 🆕 VERSION CHECK: 2025-11-20 12:40 - session_id fix
console.log('📱 Step1Phone v2.0 - 2025-11-20 14:40 - Session creation with debug logs');
debugLog.log('📱 Step1Phone v2.0 - 2025-11-20 14:40 - Session creation with debug logs');
const [form] = Form.useForm();
const [codeSent, setCodeSent] = useState(false);
const [loading, setLoading] = useState(false);
const [verifyLoading, setVerifyLoading] = useState(false);
const [debugCode, setDebugCode] = useState<string | null>(null);
const [debugModalVisible, setDebugModalVisible] = useState(false);
const sendCode = async () => {
try {
@@ -41,17 +44,24 @@ export default function Step1Phone({
const result = await response.json();
if (response.ok) {
addDebugEvent?.('sms', 'success', `✅ SMS отправлен (DEBUG mode)`, {
addDebugEvent?.('sms', 'success', `✅ SMS отправлен`, {
phone,
debug_code: result.debug_code,
message: result.message
message: result.message,
is_dev_mode: !!result.debug_code
});
message.success('Код отправлен на ваш телефон');
// ✅ Если есть debug_code - показываем модалку (dev режим на бэкенде)
if (result.debug_code) {
setDebugCode(result.debug_code);
setDebugModalVisible(true);
} else {
// В проде - обычное сообщение (SMS отправлена реально)
message.success('Код отправлен на ваш телефон');
}
setCodeSent(true);
updateFormData({ phone });
if (result.debug_code) {
message.info(`DEBUG: Код ${result.debug_code}`);
}
} else {
addDebugEvent?.('sms', 'error', `❌ Ошибка SMS: ${result.detail}`, { error: result.detail });
message.error(result.detail || 'Ошибка отправки кода');
@@ -110,17 +120,17 @@ export default function Step1Phone({
crmResult = crmResult[0];
}
console.log('🔥 N8N CRM Response (after array check):', crmResult);
console.log('🔥 N8N CRM Response FULL:', JSON.stringify(crmResult, null, 2));
debugLog.log('🔥 N8N CRM Response (after array check):', crmResult);
debugLog.log('🔥 N8N CRM Response FULL:', JSON.stringify(crmResult, null, 2));
if (crmResponse.ok && crmResult.success) {
// n8n возвращает: {success: true, result: {claim_id, contact_id, ...}}
const result = crmResult.result || crmResult;
console.log('🔥 Extracted result:', result);
console.log('🔥 result.unified_id:', result.unified_id);
console.log('🔥 typeof result.unified_id:', typeof result.unified_id);
console.log('🔥 result keys:', Object.keys(result));
debugLog.log('🔥 Extracted result:', result);
debugLog.log('🔥 result.unified_id:', result.unified_id);
debugLog.log('🔥 typeof result.unified_id:', typeof result.unified_id);
debugLog.log('🔥 result keys:', Object.keys(result));
// ✅ ВАЖНО: Проверяем наличие unified_id
if (!result.unified_id) {
@@ -129,25 +139,25 @@ export default function Step1Phone({
console.error('❌ Полный ответ crmResult:', crmResult);
message.warning('⚠️ unified_id не получен от n8n, черновики могут не отображаться');
} else {
console.log('✅ unified_id получен:', result.unified_id);
debugLog.log('✅ unified_id получен:', result.unified_id);
}
// ✅ Извлекаем session_id от n8n (если есть)
const session_id_from_n8n = result.session;
console.log('🔍 Проверка session_id от n8n:');
console.log('🔍 result.session:', result.session);
console.log('🔍 session_id_from_n8n:', session_id_from_n8n);
console.log('🔍 formData.session_id (текущий):', formData.session_id);
debugLog.log('🔍 Проверка session_id от n8n:');
debugLog.log('🔍 result.session:', result.session);
debugLog.log('🔍 session_id_from_n8n:', session_id_from_n8n);
debugLog.log('🔍 formData.session_id (текущий):', formData.session_id);
if (session_id_from_n8n) {
console.log('✅ session_id получен от n8n:', session_id_from_n8n);
debugLog.log('✅ session_id получен от n8n:', session_id_from_n8n);
} else {
console.warn('⚠️ session_id не найден в ответе n8n, используем текущий:', formData.session_id);
debugLog.warn('⚠️ session_id не найден в ответе n8n, используем текущий:', formData.session_id);
}
const finalSessionId = session_id_from_n8n || formData.session_id;
console.log('🔍 finalSessionId (будет сохранён):', finalSessionId);
debugLog.log('🔍 finalSessionId (будет сохранён):', finalSessionId);
const dataToSave = {
phone,
@@ -159,11 +169,11 @@ export default function Step1Phone({
is_new_contact: result.is_new_contact
};
console.log('🔥 ========== SAVING TO FORMDATA ==========');
console.log('🔥 Saving to formData:', JSON.stringify(dataToSave, null, 2));
console.log('🔥 dataToSave.unified_id:', dataToSave.unified_id);
console.log('🔥 dataToSave.session_id:', dataToSave.session_id);
console.log('🔥 =========================================');
debugLog.log('🔥 ========== SAVING TO FORMDATA ==========');
debugLog.log('🔥 Saving to formData:', JSON.stringify(dataToSave, null, 2));
debugLog.log('🔥 dataToSave.unified_id:', dataToSave.unified_id);
debugLog.log('🔥 dataToSave.session_id:', dataToSave.session_id);
debugLog.log('🔥 =========================================');
addDebugEvent?.('crm', 'success', `✅ Контакт создан/найден в CRM`, result);
@@ -177,7 +187,7 @@ export default function Step1Phone({
// 🔑 Создаём сессию в Redis для живучести (24 часа)
try {
console.log('🔑 Создаём сессию в Redis:', {
debugLog.log('🔑 Создаём сессию в Redis:', {
session_token: finalSessionId,
unified_id: result.unified_id,
phone: phone,
@@ -196,20 +206,20 @@ export default function Step1Phone({
})
});
console.log('🔑 Session create response status:', sessionResponse.status);
debugLog.log('🔑 Session create response status:', sessionResponse.status);
if (sessionResponse.ok) {
const sessionData = await sessionResponse.json();
console.log('🔑 Session create response data:', sessionData);
debugLog.log('🔑 Session create response data:', sessionData);
// Сохраняем session_token в localStorage для последующих визитов
localStorage.setItem('session_token', finalSessionId);
console.log('✅ Сессия создана в Redis, session_token сохранён в localStorage:', finalSessionId);
console.log('✅ Проверка: localStorage.getItem("session_token"):', localStorage.getItem('session_token'));
debugLog.log('✅ Сессия создана в Redis, session_token сохранён в localStorage:', finalSessionId);
debugLog.log('✅ Проверка: localStorage.getItem("session_token"):', localStorage.getItem('session_token'));
addDebugEvent?.('session', 'success', '✅ Сессия создана (TTL 24h)');
} else {
const errorText = await sessionResponse.text();
console.warn('⚠️ Не удалось создать сессию в Redis:', sessionResponse.status, errorText);
debugLog.warn('⚠️ Не удалось создать сессию в Redis:', sessionResponse.status, errorText);
}
} catch (sessionError) {
console.error('❌ Ошибка создания сессии:', sessionError);
@@ -219,11 +229,11 @@ export default function Step1Phone({
// ✅ Передаем unified_id напрямую в onNext для проверки черновиков
// Это нужно, потому что formData может еще не обновиться
const unifiedIdToPass = result.unified_id;
console.log('🔥 ============================================');
console.log('🔥 Передаём unified_id в onNext:', unifiedIdToPass);
console.log('🔥 typeof unifiedIdToPass:', typeof unifiedIdToPass);
console.log('🔥 Вызываем onNext с unified_id:', unifiedIdToPass);
console.log('🔥 ============================================');
debugLog.log('🔥 ============================================');
debugLog.log('🔥 Передаём unified_id в onNext:', unifiedIdToPass);
debugLog.log('🔥 typeof unifiedIdToPass:', typeof unifiedIdToPass);
debugLog.log('🔥 Вызываем onNext с unified_id:', unifiedIdToPass);
debugLog.log('🔥 ============================================');
onNext(unifiedIdToPass);
} else {
addDebugEvent?.('crm', 'error', '❌ Ошибка создания контакта в CRM', crmResult);
@@ -336,38 +346,106 @@ export default function Step1Phone({
)}
</Form.Item>
{/* 🔧 Технические кнопки для разработки */}
<div style={{
marginTop: 24,
padding: 16,
background: '#f0f0f0',
borderRadius: 8,
border: '2px dashed #999'
}}>
<div style={{ marginBottom: 8, fontSize: 12, color: '#666', fontWeight: 'bold' }}>
🔧 DEV MODE - Быстрая навигация (без валидации)
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Button
type="dashed"
onClick={() => {
// Автозаполняем телефон и email
const devData = {
phone: '79001234567', // БЕЗ +
email: 'test@test.ru',
};
updateFormData(devData);
setIsPhoneVerified(true);
message.success('DEV: Телефон автоматически подтверждён');
onNext();
}}
size="small"
style={{ flex: 1 }}
>
Далее (Step 2) [пропустить]
{/* 🔧 DEV MODE: Модалка с SMS кодом */}
<Modal
title="🔧 DEV MODE - SMS Код верификации"
open={debugModalVisible}
onCancel={() => setDebugModalVisible(false)}
footer={[
<Button key="copy" icon={<CopyOutlined />} onClick={async () => {
if (debugCode) {
try {
// Проверяем доступность Clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(debugCode);
message.success('Код скопирован в буфер обмена');
} else {
// Fallback для старых браузеров
const textArea = document.createElement('textarea');
textArea.value = debugCode;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
message.success('Код скопирован в буфер обмена');
} catch (err) {
message.error('Не удалось скопировать код');
}
document.body.removeChild(textArea);
}
} catch (err) {
message.error('Не удалось скопировать код');
}
}
}}>
Скопировать код
</Button>,
<Button key="close" type="primary" onClick={() => setDebugModalVisible(false)}>
Закрыть
</Button>
]}
width={400}
>
<div style={{ padding: '20px 0', textAlign: 'center' }}>
<p style={{ marginBottom: '16px', fontSize: '14px', color: '#666' }}>
В режиме разработки SMS не отправляется реально.
Используйте этот код для верификации:
</p>
<div style={{
fontSize: '32px',
fontWeight: 'bold',
color: '#1890ff',
letterSpacing: '8px',
padding: '20px',
background: '#f0f2f5',
borderRadius: '8px',
marginBottom: '16px',
fontFamily: 'monospace'
}}>
{debugCode}
</div>
<p style={{ fontSize: '12px', color: '#999' }}>
Код действителен 10 минут
</p>
</div>
</div>
</Modal>
{/* 🔧 Технические кнопки для разработки (только в dev режиме) */}
{import.meta.env.DEV && (
<div style={{
marginTop: 24,
padding: 16,
background: '#f0f0f0',
borderRadius: 8,
border: '2px dashed #999'
}}>
<div style={{ marginBottom: 8, fontSize: 12, color: '#666', fontWeight: 'bold' }}>
🔧 DEV MODE - Быстрая навигация (без валидации)
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Button
type="dashed"
onClick={() => {
// Автозаполняем телефон и email
const devData = {
phone: '79001234567', // БЕЗ +
email: 'test@test.ru',
};
updateFormData(devData);
setIsPhoneVerified(true);
message.success('DEV: Телефон автоматически подтверждён');
onNext();
}}
size="small"
style={{ flex: 1 }}
>
Далее (Step 2) [пропустить]
</Button>
</div>
</div>
)}
</Form>
);
}

View File

@@ -51,7 +51,13 @@ export default function Step3Payment({
throw new Error(`HTTP ${response.status}`);
}
const banksData: Bank[] = await response.json();
const banksDataRaw: any[] = await response.json();
// ✅ Нормализуем формат данных (API возвращает bankId/bankName, а код ожидает bankid/bankname)
const banksData: Bank[] = banksDataRaw.map((bank: any) => ({
bankid: bank.bankId || bank.bankid,
bankname: bank.bankName || bank.bankname
}));
// Сортируем по названию для удобства
banksData.sort((a, b) => a.bankname.localeCompare(b.bankname, 'ru'));
@@ -133,7 +139,9 @@ export default function Step3Payment({
if (result.debug_code) {
setDebugCode(result.debug_code);
updateFormData({ smsDebugCode: result.debug_code });
message.info(`DEBUG: Код ${result.debug_code}`);
if (import.meta.env.DEV) {
message.info(`DEBUG: Код ${result.debug_code}`);
}
}
} else {
addDebugEvent?.('sms', 'error', `❌ Ошибка SMS: ${result.detail}`, { error: result.detail });
@@ -329,7 +337,7 @@ export default function Step3Payment({
</Form.Item>
)}
{debugCode && !isPhoneVerified && (
{import.meta.env.DEV && debugCode && !isPhoneVerified && (
<div
style={{
marginTop: 8,
@@ -348,9 +356,29 @@ export default function Step3Payment({
<Button
icon={<CopyOutlined />}
size="small"
onClick={() => {
navigator.clipboard.writeText(debugCode);
message.success('Код скопирован');
onClick={async () => {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(debugCode);
message.success('Код скопирован');
} else {
const textArea = document.createElement('textarea');
textArea.value = debugCode;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
message.success('Код скопирован');
} catch (err) {
message.error('Не удалось скопировать код');
}
document.body.removeChild(textArea);
}
} catch (err) {
message.error('Не удалось скопировать код');
}
}}
>
Скопировать
@@ -427,7 +455,6 @@ export default function Step3Payment({
<AutoComplete
placeholder={banksLoading ? "Загрузка списка банков..." : "Начните вводить название банка"}
size="large"
loading={banksLoading}
notFoundContent={banksLoading ? "Загрузка..." : "Банк не найден. Попробуйте ввести другое название"}
options={banks.map((bank) => ({
value: bank.bankname,

View File

@@ -1,12 +1,14 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { debugLog } from '../../utils/debugLog';
import { Card, Spin, message, Modal, Input, Button, Form } from 'antd';
import { CopyOutlined } from '@ant-design/icons';
import { generateConfirmationFormHTML } from './generateConfirmationFormHTML';
interface Props {
claimPlanData: any; // Данные заявления от n8n
contact_data_confirmed?: boolean; // ✅ Флаг подтверждения данных контакта
onNext: () => void;
onPrev: () => void;
onPrev?: () => void; // Опциональный, так как не всегда используется
onSubmitted?: () => void; // ✅ Callback после успешной отправки
}
@@ -27,6 +29,7 @@ export default function StepClaimConfirmation({
const [smsLoading, setSmsLoading] = useState(false);
const [smsVerifyLoading, setSmsVerifyLoading] = useState(false);
const [pendingFormData, setPendingFormData] = useState<any>(null);
const [debugCode, setDebugCode] = useState<string | null>(null); // ✅ Дебажный код для SMS
const [smsForm] = Form.useForm();
useEffect(() => {
@@ -35,18 +38,18 @@ export default function StepClaimConfirmation({
return;
}
console.log('📋 StepClaimConfirmation: получены данные claimPlanData:', claimPlanData);
console.log('📋 claimPlanData.claim_id:', claimPlanData?.claim_id);
console.log('📋 claimPlanData.unified_id:', claimPlanData?.unified_id);
console.log('📋 claimPlanData.propertyName?.meta?.claim_id:', claimPlanData?.propertyName?.meta?.claim_id);
console.log('📋 claimPlanData.propertyName?.meta?.unified_id:', claimPlanData?.propertyName?.meta?.unified_id);
debugLog.log('📋 StepClaimConfirmation: получены данные claimPlanData:', claimPlanData);
debugLog.log('📋 claimPlanData.claim_id:', claimPlanData?.claim_id);
debugLog.log('📋 claimPlanData.unified_id:', claimPlanData?.unified_id);
debugLog.log('📋 claimPlanData.propertyName?.meta?.claim_id:', claimPlanData?.propertyName?.meta?.claim_id);
debugLog.log('📋 claimPlanData.propertyName?.meta?.unified_id:', claimPlanData?.propertyName?.meta?.unified_id);
// Формируем данные для формы подтверждения
// Формат должен соответствовать тому, что ожидает HTML форма
const claimId = claimPlanData?.claim_id || claimPlanData?.propertyName?.meta?.claim_id || '';
const unifiedId = claimPlanData?.unified_id || claimPlanData?.propertyName?.meta?.unified_id || '';
console.log('📋 Извлечённые ID:', { claimId, unifiedId });
debugLog.log('📋 Извлечённые ID:', { claimId, unifiedId });
// Преобразуем данные из propertyName в формат для формы
const applicant = claimPlanData?.propertyName?.applicant || {};
@@ -86,9 +89,9 @@ export default function StepClaimConfirmation({
},
};
console.log('📋 Сформированные formData:', formData);
console.log('📋 formData.propertyName:', formData.propertyName);
console.log('📋 formData.propertyName?.meta:', formData.propertyName?.meta);
debugLog.log('📋 Сформированные formData:', formData);
debugLog.log('📋 formData.propertyName:', formData.propertyName);
debugLog.log('📋 formData.propertyName?.meta:', formData.propertyName?.meta);
// ✅ Получаем флаги подтверждения данных из props, claimPlanData или formData
const contact_data_confirmed =
@@ -97,6 +100,12 @@ export default function StepClaimConfirmation({
claimPlanData?.propertyName?.meta?.contact_data_confirmed ||
false;
// ✅ Логируем для отладки
debugLog.log('🔒 StepClaimConfirmation: contact_data_confirmed =', contact_data_confirmed);
debugLog.log('🔒 prop_contact_data_confirmed =', prop_contact_data_confirmed);
debugLog.log('🔒 claimPlanData?.contact_data_confirmed =', claimPlanData?.contact_data_confirmed);
debugLog.log('🔒 claimPlanData?.propertyName?.meta?.contact_data_confirmed =', claimPlanData?.propertyName?.meta?.contact_data_confirmed);
// Генерируем HTML форму здесь, на нашей стороне
const html = generateConfirmationFormHTML(formData, contact_data_confirmed);
setHtmlContent(html);
@@ -106,8 +115,8 @@ export default function StepClaimConfirmation({
// Функция сохранения данных формы - публикация в Redis канал
// ⚠️ ВАЖНО: Эта функция должна вызываться ТОЛЬКО после SMS-верификации!
const saveFormData = useCallback(async (formData: any, smsCode?: string) => {
console.log('💾 Публикуем данные формы в Redis канал:', formData);
console.log('📱 SMS код для публикации:', smsCode || '(не передан)');
debugLog.log('💾 Публикуем данные формы в Redis канал:', formData);
debugLog.log('📱 SMS код для публикации:', smsCode || '(не передан)');
// Защита: если SMS код не передан, это ошибка (данные не должны отправляться без верификации)
if (!smsCode || smsCode.trim() === '') {
@@ -165,7 +174,7 @@ export default function StepClaimConfirmation({
original_data: formData?.originalData || {},
};
console.log('📦 Payload для Redis:', { ...payload, sms_code: smsCode ? '***' : '(пусто)' });
debugLog.log('📦 Payload для Redis:', { ...payload, sms_code: smsCode ? '***' : '(пусто)' });
// Публикуем в Redis канал через backend endpoint (fire-and-forget)
// Канал: clientright:webform:approve
@@ -181,7 +190,7 @@ export default function StepClaimConfirmation({
console.error('Ошибка публикации данных формы в Redis:', error);
});
console.log('✅ Данные формы опубликованы в Redis канал clientright:webform:approve');
debugLog.log('✅ Данные формы опубликованы в Redis канал clientright:webform:approve');
}, [claimPlanData]);
// Функция отправки SMS-кода
@@ -202,8 +211,12 @@ export default function StepClaimConfirmation({
if (response.ok) {
message.success('Код отправлен на ваш телефон');
setSmsCodeSent(true);
// ✅ Сохраняем дебажный код для отображения
if (result.debug_code) {
message.info(`DEBUG: Код ${result.debug_code}`);
setDebugCode(result.debug_code);
if (import.meta.env.DEV) {
message.info(`DEBUG: Код ${result.debug_code}`);
}
}
} else {
message.error(result.detail || 'Ошибка отправки кода');
@@ -232,7 +245,10 @@ export default function StepClaimConfirmation({
if (response.ok) {
message.success('Код подтвержден!');
console.log('✅ SMS код успешно проверен:', code);
debugLog.log('✅ SMS код успешно проверен:', code);
// Очищаем дебажный код
setDebugCode(null);
// Закрываем модалку
setSmsModalVisible(false);
@@ -240,7 +256,7 @@ export default function StepClaimConfirmation({
smsForm.resetFields();
// Отправляем данные в Redis канал с SMS кодом
console.log('📤 Вызываем saveFormData с SMS кодом:', code);
debugLog.log('📤 Вызываем saveFormData с SMS кодом:', code);
saveFormData(pendingFormData, code);
// Показываем сообщение об успешной отправке
@@ -266,10 +282,10 @@ export default function StepClaimConfirmation({
useEffect(() => {
// Слушаем сообщения от iframe
const handleMessage = (event: MessageEvent) => {
console.log('📨 Message from iframe:', event.data);
debugLog.log('📨 Message from iframe:', event.data);
if (event.data.type === 'claim_confirmed') {
console.log('✅ Заявление подтверждено с данными:', event.data.data);
debugLog.log('✅ Заявление подтверждено с данными:', event.data.data);
// Сохраняем данные формы для последующего сохранения после SMS-апрува
setPendingFormData(event.data.data);
@@ -296,7 +312,7 @@ export default function StepClaimConfirmation({
sendSMSCode(phone);
} else if (event.data.type === 'claim_cancelled') {
message.info('Подтверждение отменено');
onPrev();
if (onPrev) onPrev();
} else if (event.data.type === 'claim_form_loaded') {
setLoading(false);
// Автоматически подстраиваем высоту iframe после загрузки
@@ -315,7 +331,7 @@ export default function StepClaimConfirmation({
iframe.style.height = Math.max(height + 50, 800) + 'px';
}
} catch (e) {
console.warn('Не удалось автоматически подстроить высоту iframe:', e);
debugLog.warn('Не удалось автоматически подстроить высоту iframe:', e);
}
}
} else if (event.data.type === 'iframe_resize') {
@@ -485,6 +501,58 @@ export default function StepClaimConfirmation({
/>
</Form.Item>
{/* ✅ Дебажный код (только в DEV режиме) */}
{import.meta.env.DEV && debugCode && (
<div
style={{
marginTop: 8,
marginBottom: 16,
padding: 12,
background: '#fafafa',
borderRadius: 8,
border: '1px dashed #d9d9d9',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
}}
>
<span style={{ fontSize: '14px' }}>
<strong>DEBUG код:</strong> {debugCode}
</span>
<Button
icon={<CopyOutlined />}
size="small"
onClick={async () => {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(debugCode);
message.success('Код скопирован');
} else {
const textArea = document.createElement('textarea');
textArea.value = debugCode;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
message.success('Код скопирован');
} catch (err) {
message.error('Не удалось скопировать код');
}
document.body.removeChild(textArea);
}
} catch (err) {
message.error('Не удалось скопировать код');
}
}}
>
Скопировать
</Button>
</div>
)}
<Form.Item>
<div style={{ display: 'flex', gap: '8px' }}>
<Button

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Card, Checkbox, Form, Input, Radio, Result, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd';
import { debugLog } from '../../utils/debugLog';
import { Button, Card, Checkbox, Form, Input, Radio, Result, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress, Alert, Modal } from 'antd';
import { LoadingOutlined, PlusOutlined, ThunderboltOutlined, InboxOutlined, FileTextOutlined } from '@ant-design/icons';
import AiWorkingIllustration from '../../assets/ai-working.svg';
import type { UploadFile } from 'antd/es/upload/interface';
@@ -51,6 +52,7 @@ interface Props {
onNext: () => void;
onPrev: () => void;
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
onGoToStart?: () => void; // Callback для перехода к началу (список черновиков)
}
const evaluateCondition = (condition: WizardQuestion['ask_if'], values: Record<string, any>) => {
@@ -111,14 +113,17 @@ export default function StepWizardPlan({
onNext,
onPrev,
addDebugEvent,
onGoToStart,
}: Props) {
console.log('🔥 StepWizardPlan v1.4 - 2025-11-20 15:00 - Add unified_id and claim_id to wizard payload');
debugLog.log('🔥 StepWizardPlan v1.4 - 2025-11-20 15:00 - Add unified_id and claim_id to wizard payload');
const [form] = Form.useForm();
const eventSourceRef = useRef<EventSource | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const debugLoggerRef = useRef<typeof addDebugEvent>(addDebugEvent);
const [isWaiting, setIsWaiting] = useState(!formData.wizardPlan);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [supportModalVisible, setSupportModalVisible] = useState(false);
const [sendingToSupport, setSendingToSupport] = useState(false);
const [plan, setPlan] = useState<any>(formData.wizardPlan || null);
const [prefillMap, setPrefillMap] = useState<Record<string, any>>(
formData.wizardPrefill || buildPrefillMap(formData.wizardPrefillArray)
@@ -160,7 +165,7 @@ export default function StepWizardPlan({
const hasAnswers = Object.keys(answers).some(key => answers[key] !== undefined && answers[key] !== '');
if (hasAnswers) {
console.log('💾 Автосохранение прогресса:', { claim_id: formData.claim_id, answersCount: Object.keys(answers).length });
debugLog.log('💾 Автосохранение прогресса:', { claim_id: formData.claim_id, answersCount: Object.keys(answers).length });
// Обновляем formData с текущими ответами
updateFormData({
@@ -380,7 +385,7 @@ export default function StepWizardPlan({
useEffect(() => {
if (!isWaiting || !formData.session_id || plan) {
console.log('⏭️ StepWizardPlan: пропускаем подписку SSE', {
debugLog.log('⏭️ StepWizardPlan: пропускаем подписку SSE', {
isWaiting,
hasSessionId: !!formData.session_id,
hasPlan: !!plan,
@@ -389,13 +394,13 @@ export default function StepWizardPlan({
}
const sessionId = formData.session_id;
console.log('🔌 StepWizardPlan: подписываемся на SSE канал для получения wizard_plan', {
debugLog.log('🔌 StepWizardPlan: подписываемся на SSE канал для получения wizard_plan', {
session_id: sessionId,
sse_url: `/events/${sessionId}`,
sse_url: `/api/v1/events/${sessionId}`,
redis_channel: `ocr_events:${sessionId}`,
});
const source = new EventSource(`/events/${sessionId}`);
const source = new EventSource(`/api/v1/events/${sessionId}`);
eventSourceRef.current = source;
debugLoggerRef.current?.('wizard', 'info', '🔌 Подключаемся к SSE для плана вопросов', { session_id: sessionId });
@@ -461,25 +466,113 @@ export default function StepWizardPlan({
payload_preview: JSON.stringify(payload).substring(0, 200),
});
// ✅ НОВЫЙ ФЛОУ: Обработка списка документов
if (eventType === 'documents_list_ready') {
const documentsRequired = payload.documents_required || [];
// ✅ ОБРАБОТКА ОШИБОК: Если пришла ошибка обработки
if (eventType === 'documents_list_error' ||
eventType === 'processing_error' ||
payload.status === 'error' && eventType === 'documents_list_ready') {
const errorMessage = payload.message || payload.error_message || 'Произошла ошибка при обработке вашего запроса';
const errorDetails = payload.error_details || payload.details || null;
debugLoggerRef.current?.('wizard', 'error', '❌ Ошибка обработки запроса', {
session_id: sessionId,
event_type: eventType,
error_message: errorMessage,
error_details: errorDetails,
});
setIsWaiting(false);
setConnectionError(errorMessage);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
message.error(errorMessage);
source.close();
// Сохраняем информацию об ошибке
updateFormData({
wizardPlanStatus: 'error',
errorMessage: errorMessage,
errorDetails: errorDetails,
});
return;
}
// ✅ ОБРАБОТКА "НЕ НАШЕЙ ТЕМАТИКИ": Если вопрос не нашей тематики
if (eventType === 'out_of_scope' ||
eventType === 'not_our_topic' ||
eventType === 'topic_not_supported') {
const outOfScopeMessage = payload.message ||
'К сожалению, ваш вопрос не относится к нашей компетенции. Мы помогаем с защитой прав потребителей в сфере услуг, товаров и туризма.';
const suggestedActions = payload.suggested_actions || payload.actions || [];
// ✅ Извлекаем claim_id и ticket_number из payload (могут быть в data или в корне)
const claimIdFromPayload = payload.claim_id || payload.data?.claim_id;
const ticketNumberFromPayload = payload.ticket_number || payload.data?.ticket_number;
const ticketIdFromPayload = payload.ticket_id || payload.data?.ticket_id;
debugLoggerRef.current?.('wizard', 'warning', '⚠️ Вопрос не нашей тематики', {
session_id: sessionId,
event_type: eventType,
message: outOfScopeMessage,
suggested_actions: suggestedActions,
claim_id: claimIdFromPayload,
ticket_number: ticketNumberFromPayload,
});
setIsWaiting(false);
setConnectionError(outOfScopeMessage);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
message.warning(outOfScopeMessage);
source.close();
// Сохраняем информацию о том, что вопрос не нашей тематики
updateFormData({
wizardPlanStatus: 'out_of_scope',
outOfScopeMessage: outOfScopeMessage,
suggestedActions: suggestedActions,
// ✅ Сохраняем claim_id и ticket_number из payload (если есть)
claim_id: claimIdFromPayload || formData.claim_id,
ticket_number: ticketNumberFromPayload || formData.ticket_number,
ticket_id: ticketIdFromPayload || formData.ticket_id,
});
return;
}
// ✅ НОВЫЙ ФЛОУ: Приоритет - если есть documents_required, показываем загрузку документов
// Проверяем documents_required в payload (может быть в корне или в wizard_plan)
const documentsRequired = payload.documents_required || payload.documents || [];
// ✅ ВАЖНО: Если eventType === 'documents_list_ready' ИЛИ есть documents_required, показываем загрузку документов
const isDocumentsListReady = eventType === 'documents_list_ready' || documentsRequired.length > 0;
if (isDocumentsListReady && documentsRequired.length > 0) {
debugLoggerRef.current?.('wizard', 'success', '📋 Получен список документов!', {
session_id: sessionId,
documents_count: documentsRequired.length,
documents: documentsRequired.map((d: any) => d.name),
documents: documentsRequired.map((d: any) => d.name || d.id),
});
console.log('📋 documents_list_ready:', {
debugLog.log('📋 documents_required найден в payload:', {
eventType,
claim_id: payload.claim_id,
documents_required: documentsRequired,
payload_keys: Object.keys(payload),
});
// Сохраняем в formData для нового флоу
updateFormData({
documents_required: documentsRequired,
claim_id: payload.claim_id,
claim_id: payload.claim_id || formData.claim_id,
wizardPlanStatus: 'documents_ready', // Новый статус
});
@@ -491,13 +584,13 @@ export default function StepWizardPlan({
timeoutRef.current = null;
}
// Пока показываем alert для теста, потом переход к StepDocumentsNew
message.success(`Получен список документов: ${documentsRequired.length} шт.`);
// TODO: onNext() для перехода к StepDocumentsNew
// Не показываем визард, если есть документы для загрузки
return;
}
// ✅ СТАРЫЙ ФЛОУ: Если нет documents_required, показываем визард
const wizardPayload = extractWizardPayload(payload);
const hasWizardPlan = Boolean(wizardPayload);
@@ -797,7 +890,7 @@ export default function StepWizardPlan({
);
// 🔍 Логируем отправляемые метаданные документов
console.log(`📁 Группа ${i}:`, {
debugLog.log(`📁 Группа ${i}:`, {
field_name: fieldLabel,
field_label: finalFieldLabel,
description: block.description,
@@ -814,7 +907,7 @@ export default function StepWizardPlan({
});
// Логируем ключевые поля перед отправкой
console.log('📤 Отправка в n8n:', {
debugLog.log('📤 Отправка в n8n:', {
session_id: formData.session_id,
unified_id: formData.unified_id,
claim_id: formData.claim_id,
@@ -853,7 +946,7 @@ export default function StepWizardPlan({
if (formData.session_id) {
subscribeToClaimPlan(formData.session_id);
} else {
console.warn('⚠️ session_id отсутствует, не можем подписаться на claim:plan');
debugLog.warn('⚠️ session_id отсутствует, не можем подписаться на claim:plan');
onNext();
}
} catch (error) {
@@ -869,7 +962,7 @@ export default function StepWizardPlan({
// Функция подписки на канал claim:plan
const subscribeToClaimPlan = useCallback((sessionToken: string) => {
console.log('📡 Подписка на канал claim:plan для session:', sessionToken);
debugLog.log('📡 Подписка на канал claim:plan для session:', sessionToken);
// Закрываем предыдущее соединение, если есть
if (eventSourceRef.current) {
@@ -882,7 +975,7 @@ export default function StepWizardPlan({
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
console.log('✅ Подключено к каналу claim:plan');
debugLog.log('✅ Подключено к каналу claim:plan');
addDebugEvent?.('claim-plan', 'info', '📡 Ожидание данных заявления...');
message.loading('Ожидание данных заявления...', 0);
};
@@ -890,7 +983,7 @@ export default function StepWizardPlan({
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('📥 Получены данные из claim:plan:', data);
debugLog.log('📥 Получены данные из claim:plan:', data);
if (data.event_type === 'claim_plan_ready' && data.status === 'ready') {
// Данные заявления получены!
@@ -940,7 +1033,7 @@ export default function StepWizardPlan({
// Таймаут на 5 минут
timeoutRef.current = setTimeout(() => {
console.warn('⏰ Таймаут ожидания данных заявления');
debugLog.warn('⏰ Таймаут ожидания данных заявления');
message.destroy();
message.warning('Превышено время ожидания данных заявления');
eventSource.close();
@@ -1287,7 +1380,7 @@ export default function StepWizardPlan({
);
const renderQuestions = () => {
console.log('🔍 StepWizardPlan renderQuestions:', {
debugLog.log('🔍 StepWizardPlan renderQuestions:', {
questionsCount: questions.length,
documentsCount: documents.length,
questions: questions.map(q => ({ name: q.name, label: q.label, input_type: q.input_type, required: q.required }))
@@ -1330,7 +1423,7 @@ export default function StepWizardPlan({
{() => {
const values = form.getFieldsValue(true);
if (!evaluateCondition(question.ask_if, values)) {
console.log(`⏭️ Question ${question.name} skipped: condition not met`, question.ask_if, values);
debugLog.log(`⏭️ Question ${question.name} skipped: condition not met`, question.ask_if, values);
return null;
}
const questionDocs = documentGroups[question.name] || [];
@@ -1347,7 +1440,7 @@ export default function StepWizardPlan({
questionNameLower === 'correspondence_exist' ||
questionNameLower.includes('docs_exist');
if (isDocsExistQuestion && documents.length > 0) {
console.log(`🚫 Question ${question.name} hidden: docs_exist with documents`);
debugLog.log(`🚫 Question ${question.name} hidden: docs_exist with documents`);
return null;
}
@@ -1371,11 +1464,11 @@ export default function StepWizardPlan({
// (даже если вопрос не связан с documentGroups)
// Загрузка файлов уже реализована через блоки документов (documents)
if (isDocumentUploadQuestion && documents.length > 0) {
console.log(`🚫 Question ${question.name} hidden: isDocumentUploadQuestion=true, documents.length=${documents.length}`);
debugLog.log(`🚫 Question ${question.name} hidden: isDocumentUploadQuestion=true, documents.length=${documents.length}`);
return null;
}
console.log(`✅ Question ${question.name} will render:`, { input_type: question.input_type, label: question.label, required: question.required });
debugLog.log(`✅ Question ${question.name} will render:`, { input_type: question.input_type, label: question.label, required: question.required });
return (
<>
@@ -1438,11 +1531,38 @@ export default function StepWizardPlan({
}
// ✅ НОВЫЙ ФЛОУ: Если есть documents_required, показываем загрузку документов
// Также проверяем plan.documents - если n8n вернул wizard_plan с documents, конвертируем их
const documentsRequired = formData.documents_required || [];
const hasNewFlowDocs = documentsRequired.length > 0;
const planDocuments = plan?.documents || [];
// ✅ ВАЖНО: Если есть documents в plan, но нет в formData.documents_required, конвертируем их
const hasPlanDocuments = planDocuments.length > 0 && documentsRequired.length === 0;
const hasNewFlowDocs = documentsRequired.length > 0 || hasPlanDocuments;
// Конвертируем plan.documents в documents_required при первом рендере
useEffect(() => {
if (hasPlanDocuments && !formData.documents_required) {
debugLog.log('🔄 Конвертируем plan.documents в documents_required:', planDocuments);
const convertedDocs = planDocuments.map((doc: any) => ({
id: doc.id || doc.name?.toLowerCase().replace(/\s+/g, '_'),
name: doc.name,
hints: doc.hints || doc.description,
accept: doc.accept || ['pdf', 'jpg', 'png'],
required: doc.required !== false,
priority: doc.priority || 1,
}));
updateFormData({
documents_required: convertedDocs,
wizardPlanStatus: 'documents_ready',
});
debugLog.log('✅ Конвертировано документов:', convertedDocs.length);
}
}, [hasPlanDocuments, planDocuments, formData.documents_required, updateFormData]);
// 🔍 ОТЛАДКА: Логируем состояние для диагностики
console.log('🔍 StepWizardPlan - определение флоу:', {
debugLog.log('🔍 StepWizardPlan - определение флоу:', {
documentsRequiredCount: documentsRequired.length,
documentsRequired: documentsRequired,
hasNewFlowDocs,
@@ -1459,7 +1579,7 @@ export default function StepWizardPlan({
// Отладка: логируем инициализацию
useEffect(() => {
console.log('🔍 Инициализация документов:', {
debugLog.log('🔍 Инициализация документов:', {
documentsRequiredCount: documentsRequired.length,
initialUploadedDocs,
uploadedDocs,
@@ -1512,7 +1632,7 @@ export default function StepWizardPlan({
}
}
console.log('🔍 Инициализация currentDocIndex:', {
debugLog.log('🔍 Инициализация currentDocIndex:', {
savedIndex,
firstUnprocessed,
documentsRequiredLength: documentsRequired.length,
@@ -1536,7 +1656,7 @@ export default function StepWizardPlan({
// Если текущий индекс выходит за границы, исправляем
if (currentDocIndex >= documentsRequired.length) {
const firstUnprocessed = findFirstUnprocessedDoc(0);
console.log('🔄 Исправление currentDocIndex (выход за границы):', {
debugLog.log('🔄 Исправление currentDocIndex (выход за границы):', {
currentIndex: currentDocIndex,
documentsRequiredLength: documentsRequired.length,
firstUnprocessed,
@@ -1552,7 +1672,7 @@ export default function StepWizardPlan({
const docId = currentDoc.id || currentDoc.name;
if (uploadedDocs.includes(docId) || skippedDocs.includes(docId)) {
const firstUnprocessed = findFirstUnprocessedDoc(currentDocIndex + 1);
console.log('🔄 Исправление currentDocIndex (документ уже обработан):', {
debugLog.log('🔄 Исправление currentDocIndex (документ уже обработан):', {
currentIndex: currentDocIndex,
currentDocId: docId,
firstUnprocessed,
@@ -1581,7 +1701,7 @@ export default function StepWizardPlan({
const processedCount = uploadedDocs.length + skippedDocs.length;
const allProcessed = allRequiredDocsProcessed && processedCount >= documentsRequired.length;
console.log('🔍 Проверка завершённости:', {
debugLog.log('🔍 Проверка завершённости:', {
uploadedDocs: uploadedDocs.length,
skippedDocs: skippedDocs.length,
totalRequired: documentsRequired.length,
@@ -1606,7 +1726,7 @@ export default function StepWizardPlan({
// Отладка: логируем состояние текущего документа
useEffect(() => {
console.log('🔍 Текущий документ для загрузки:', {
debugLog.log('🔍 Текущий документ для загрузки:', {
currentDocIndex,
documentsRequiredLength: documentsRequired.length,
currentDoc: currentDoc ? { id: currentDoc.id, name: currentDoc.name } : null,
@@ -1625,7 +1745,7 @@ export default function StepWizardPlan({
const isAlreadySkipped = skippedDocs.includes(docId);
if (isAlreadyUploaded || isAlreadySkipped) {
console.log(`⏭️ Документ "${currentDoc.name}" уже обработан, переходим к следующему`);
debugLog.log(`⏭️ Документ "${currentDoc.name}" уже обработан, переходим к следующему`);
const nextIndex = findFirstUnprocessedDoc(currentDocIndex + 1);
// Обновляем только если следующий индекс отличается от текущего
@@ -1640,7 +1760,7 @@ export default function StepWizardPlan({
// Обработчик выбора файлов (НЕ отправляем сразу, только сохраняем)
const handleFilesChange = (fileList: any[]) => {
console.log('📁 handleFilesChange:', fileList.length, 'файлов', fileList.map(f => f.name));
debugLog.log('📁 handleFilesChange:', fileList.length, 'файлов', fileList.map(f => f.name));
setCurrentUploadedFiles(fileList);
if (fileList.length > 0) {
setDocChoice('upload');
@@ -1661,7 +1781,7 @@ export default function StepWizardPlan({
setSkippedDocs(newSkipped);
// ✅ ЛОГИРОВАНИЕ: Пропуск документа
console.log('⏭️ Документ пропущен:', {
debugLog.log('⏭️ Документ пропущен:', {
document_id: currentDoc.id,
document_name: currentDoc.name,
document_type: currentDoc.type || currentDoc.id,
@@ -1714,7 +1834,7 @@ export default function StepWizardPlan({
if (formData.contact_id) formDataToSend.append('contact_id', formData.contact_id);
if (formData.phone) formDataToSend.append('phone', formData.phone);
console.log('💾 Отправка пропущенного документа в n8n:', {
debugLog.log('💾 Отправка пропущенного документа в n8n:', {
claim_id: formData.claim_id,
document_type: currentDoc.type || currentDoc.id,
document_name: currentDoc.name,
@@ -1732,7 +1852,7 @@ export default function StepWizardPlan({
// Не блокируем пользователя - данные сохранятся при следующем сохранении черновика
} else {
const result = await response.json();
console.log('✅ Пропущенный документ отправлен в n8n:', result);
debugLog.log('✅ Пропущенный документ отправлен в n8n:', result);
}
} catch (error) {
console.error('❌ Ошибка отправки пропущенного документа в n8n:', error);
@@ -1750,7 +1870,7 @@ export default function StepWizardPlan({
try {
setSubmitting(true);
console.log('📤 Загружаем все файлы одним запросом:', {
debugLog.log('📤 Загружаем все файлы одним запросом:', {
totalFiles: currentUploadedFiles.length,
files: currentUploadedFiles.map(f => ({ name: f.name, uid: f.uid, size: f.size }))
});
@@ -1782,7 +1902,7 @@ export default function StepWizardPlan({
}
const result = await response.json();
console.log('✅ Все файлы загружены:', result);
debugLog.log('✅ Все файлы загружены:', result);
// Обновляем состояние
const uploadedDocsData = [...(formData.documents_uploaded || [])];
@@ -1885,7 +2005,7 @@ export default function StepWizardPlan({
if (!response.ok) {
console.error('❌ Ошибка отправки кастомного документа:', await response.text());
} else {
console.log('✅ Кастомный документ отправлен:', block.description);
debugLog.log('✅ Кастомный документ отправлен:', block.description);
}
}
@@ -1915,11 +2035,11 @@ export default function StepWizardPlan({
if (response.ok) {
const data = await response.json();
console.log('✅ OCR status check:', data);
debugLog.log('✅ OCR status check:', data);
// Если есть кэш — сразу переходим
if (data.from_cache && data.form_draft) {
console.log('✅ Используем кэшированные данные:', data.form_draft);
debugLog.log('✅ Используем кэшированные данные:', data.form_draft);
const formDraft = data.form_draft;
const user = formDraft.user || {};
const project = formDraft.project || {};
@@ -1997,20 +2117,20 @@ export default function StepWizardPlan({
// Иначе подключаемся к SSE и ждём результат от n8n
const sessionId = formData.session_id;
console.log('📡 Подключаемся к SSE:', `/api/v1/events/${sessionId}`);
debugLog.log('📡 Подключаемся к SSE:', `/api/v1/events/${sessionId}`);
const eventSource = new EventSource(`/api/v1/events/${sessionId}`);
eventSource.onmessage = (event) => {
try {
const eventData = JSON.parse(event.data);
console.log('📥 SSE event:', eventData);
debugLog.log('📥 SSE event:', eventData);
// Обрабатываем событие ocr_status
if (eventData.event_type === 'ocr_status') {
if (eventData.status === 'ready') {
// ✅ Успех — данные готовы
console.log('✅ Заявление готово:', eventData.data);
debugLog.log('✅ Заявление готово:', eventData.data);
const formDraft = eventData.data?.form_draft;
// Формируем claimPlanData для StepClaimConfirmation
@@ -2116,7 +2236,7 @@ export default function StepWizardPlan({
// Таймаут 3 минуты (RAG может занять время)
setTimeout(() => {
if (eventSource.readyState !== EventSource.CLOSED) {
console.warn('⏰ SSE timeout');
debugLog.warn('⏰ SSE timeout');
message.destroy();
setIsFormingClaim(false);
setRagError('Превышено время ожидания. Попробуйте ещё раз.');
@@ -2125,7 +2245,7 @@ export default function StepWizardPlan({
}, 180000); // 3 минуты для RAG
} else {
console.warn('⚠️ OCR status check failed:', await response.text());
debugLog.warn('⚠️ OCR status check failed:', await response.text());
message.destroy();
onNext();
}
@@ -2378,8 +2498,151 @@ export default function StepWizardPlan({
);
})()}
{/* СТАРЫЙ ФЛОУ: Ожидание визарда */}
{!hasNewFlowDocs && isWaiting && (
{/* ❌ ЭКРАН ОШИБКИ: Показываем, если wizardPlanStatus === 'error' */}
{formData.wizardPlanStatus === 'error' && formData.errorMessage && (
<div style={{ textAlign: 'center', padding: '60px 20px', maxWidth: 600, margin: '0 auto' }}>
<div style={{ fontSize: 64, marginBottom: 24 }}></div>
<Title level={3} style={{ marginBottom: 16 }}>Ошибка обработки запроса</Title>
<Paragraph style={{ fontSize: 16, marginBottom: 24, color: '#595959' }}>
{formData.errorMessage}
</Paragraph>
{formData.errorDetails && (
<Alert
type="info"
message="Детали ошибки"
description={
<div>
<Text type="secondary">Код: {formData.errorDetails.code || 'UNKNOWN'}</Text>
{formData.errorDetails.reason && (
<div style={{ marginTop: 8 }}>
<Text type="secondary">Причина: {formData.errorDetails.reason}</Text>
</div>
)}
</div>
}
style={{ marginBottom: 24, textAlign: 'left' }}
/>
)}
<Space>
<Button type="primary" size="large" onClick={handleRefreshPlan}>
Попробовать снова
</Button>
<Button size="large" onClick={onPrev}>
Вернуться назад
</Button>
</Space>
</div>
)}
{/* ⚠️ ЭКРАН "НЕ НАШЕЙ ТЕМАТИКИ": Показываем, если wizardPlanStatus === 'out_of_scope' */}
{formData.wizardPlanStatus === 'out_of_scope' && formData.outOfScopeMessage && (
<div style={{ textAlign: 'center', padding: '60px 20px', maxWidth: 700, margin: '0 auto' }}>
<div style={{ fontSize: 64, marginBottom: 24 }}></div>
<Title level={3} style={{ marginBottom: 16 }}>Вопрос не нашей компетенции</Title>
<Paragraph style={{ fontSize: 16, marginBottom: 32, color: '#595959', lineHeight: 1.8 }}>
{formData.outOfScopeMessage}
</Paragraph>
{formData.suggestedActions && formData.suggestedActions.length > 0 && (
<div style={{ marginBottom: 32, textAlign: 'left' }}>
<Title level={4} style={{ marginBottom: 16, textAlign: 'center' }}>
Рекомендуемые действия:
</Title>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
{formData.suggestedActions.map((action: any, index: number) => {
// Обработка кнопки "Связаться с поддержкой"
if (action.actionType === 'contact_support' || (!action.url && action.title?.toLowerCase().includes('поддержк'))) {
return (
<Card
key={index}
size="small"
style={{ textAlign: 'left' }}
actions={[
<Button
type="link"
onClick={() => setSupportModalVisible(true)}
>
Отправить в поддержку
</Button>
]}
>
<Card.Meta
title={action.title}
description={action.description}
/>
</Card>
);
}
// Обработка внешней ссылки (партнёры) - если есть URL, показываем ссылку
if (action.url) {
const isPartnerLink = action.url.includes('akn16.ru') || action.actionType === 'external_link';
const buttonText = isPartnerLink && action.urlText
? action.urlText
: (action.urlText || 'Перейти →');
return (
<Card
key={index}
size="small"
style={{ textAlign: 'left' }}
actions={[
<Button
type="link"
href={action.url}
target="_blank"
rel="noopener noreferrer"
style={{ fontWeight: 500 }}
>
{buttonText}
</Button>
]}
>
<Card.Meta
title={action.title}
description={
<div>
<div>{action.description}</div>
{isPartnerLink && action.urlText && (
<div style={{ marginTop: 8, color: '#1890ff', fontWeight: 500 }}>
{action.urlText}
</div>
)}
</div>
}
/>
</Card>
);
}
// Обычная карточка без ссылки
return (
<Card
key={index}
size="small"
style={{ textAlign: 'left' }}
>
<Card.Meta
title={action.title}
description={action.description}
/>
</Card>
);
})}
</Space>
</div>
)}
<Space>
<Button type="primary" size="large" onClick={onPrev}>
Вернуться к описанию
</Button>
</Space>
</div>
)}
{/* СТАРЫЙ ФЛОУ: Ожидание визарда (только если нет ошибок) */}
{!hasNewFlowDocs && isWaiting && formData.wizardPlanStatus !== 'error' && formData.wizardPlanStatus !== 'out_of_scope' && (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<img
src={AiWorkingIllustration}
@@ -2411,8 +2674,44 @@ export default function StepWizardPlan({
</div>
)}
{/* СТАРЫЙ ФЛОУ: Визард готов */}
{!hasNewFlowDocs && !isWaiting && plan && (
{/* ✅ ПЕРЕХОДНЫЙ СЛУЧАЙ: Если есть documents в plan, но нет в formData.documents_required */}
{!hasNewFlowDocs && !isWaiting && plan && plan.documents && plan.documents.length > 0 && (
<div style={{ padding: '24px 0' }}>
<Alert
message="Обнаружены документы в плане"
description="Конвертируем план в новый формат загрузки документов..."
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Button
type="primary"
onClick={() => {
// Конвертируем plan.documents в documents_required
const convertedDocs = plan.documents.map((doc: any) => ({
id: doc.id || doc.name?.toLowerCase().replace(/\s+/g, '_'),
name: doc.name,
hints: doc.hints || doc.description,
accept: doc.accept || ['pdf', 'jpg', 'png'],
required: doc.required !== false,
priority: doc.priority || 1,
}));
updateFormData({
documents_required: convertedDocs,
wizardPlanStatus: 'documents_ready',
});
message.success(`Конвертировано ${convertedDocs.length} документов`);
}}
>
Перейти к пошаговой загрузке документов
</Button>
</div>
)}
{/* СТАРЫЙ ФЛОУ: Визард готов (только если НЕТ documents в plan) */}
{!hasNewFlowDocs && !isWaiting && plan && (!plan.documents || plan.documents.length === 0) && (
<div>
<Title level={4} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<ThunderboltOutlined style={{ color: '#595959' }} /> План действий
@@ -2482,6 +2781,114 @@ export default function StepWizardPlan({
</div>
)}
</Card>
{/* Модалка для отправки в поддержку */}
<Modal
title="Отправка в поддержку"
open={supportModalVisible}
onCancel={() => setSupportModalVisible(false)}
footer={[
<Button key="cancel" onClick={() => setSupportModalVisible(false)}>
Отмена
</Button>,
<Button
key="submit"
type="primary"
loading={sendingToSupport}
onClick={async () => {
setSendingToSupport(true);
try {
const endpoint = 'https://n8n.clientright.pro/webhook/3ef6ff67-f3f2-418e-a300-86cb4659dbde';
// Собираем максимум данных для отправки
const payload = {
// Основные идентификаторы
session_id: formData.session_id,
session_token: formData.session_id,
claim_id: formData.claim_id,
claim_number: formData.claim_id, // Номер заявки (дублируем для удобства)
ticket_number: formData.ticket_number, // ✅ Номер заявки из n8n (HD001234)
ticket_id: formData.ticket_id, // ✅ ID заявки в vTiger
user_id: formData.unified_id,
contact_id: formData.contact_id,
// Контактные данные
phone: formData.phone,
email: formData.email,
// Информация о проблеме
problem_description: formData.problemDescription || formData.description,
reason: formData.outOfScopeMessage,
ticket: formData.problemDescription || formData.description,
// Статус и действия
wizard_plan_status: formData.wizardPlanStatus,
out_of_scope_message: formData.outOfScopeMessage,
suggested_actions: formData.suggestedActions,
// Дополнительные данные формы
wizard_plan: formData.wizardPlan,
wizard_answers: formData.wizardAnswers,
documents_required: formData.documents_required,
documents_uploaded: formData.documents_uploaded,
documents_skipped: formData.documents_skipped,
// Метаданные
channel: 'web_form',
timestamp: new Date().toISOString(),
user_agent: navigator.userAgent,
referrer: document.referrer,
// Дополнительные поля из formData (если есть)
...(formData.contact_data_from_crm ? {
contact_data_from_crm: formData.contact_data_from_crm
} : {}),
...(formData.contact_data_confirmed ? {
contact_data_confirmed: formData.contact_data_confirmed
} : {}),
};
debugLog.log('📤 Отправка в поддержку:', payload);
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (response.ok) {
const result = await response.json().catch(() => ({}));
debugLog.log('✅ Ответ от поддержки:', result);
message.success('Ситуация направлена в поддержку для оценки');
setSupportModalVisible(false);
// Переходим к началу (список черновиков)
if (onGoToStart) {
setTimeout(() => {
onGoToStart();
}, 500); // Небольшая задержка для показа сообщения
}
} else {
const errorText = await response.text().catch(() => 'Ошибка отправки');
throw new Error(errorText);
}
} catch (error) {
debugLog.error('❌ Ошибка отправки в поддержку:', error);
message.error('Не удалось отправить запрос. Попробуйте позже.');
} finally {
setSendingToSupport(false);
}
}}
>
Отправить
</Button>,
]}
>
<p>
Ваша ситуация будет направлена в поддержку для оценки.
Мы проверим, относится ли ваш вопрос к нашей компетенции, и свяжемся с вами.
</p>
</Modal>
</div>
);
}