diff --git a/backend/app/api/claims.py b/backend/app/api/claims.py
index 0378be0..b1fa174 100644
--- a/backend/app/api/claims.py
+++ b/backend/app/api/claims.py
@@ -1,7 +1,7 @@
"""
Claims API Routes - Обработка заявок
"""
-from fastapi import APIRouter, HTTPException, Request, Query
+from fastapi import APIRouter, HTTPException, Request, Query, BackgroundTasks
from typing import Optional, List
import httpx
from .models import (
@@ -16,6 +16,7 @@ import logging
from ..services.redis_service import redis_service
from ..services.database import db
from ..services.crm_mysql_service import crm_mysql_service
+from ..services.n8n_service import check_workflow_status, restart_workflow, MIN_RESTART_INTERVAL
from ..config import settings
router = APIRouter(prefix="/api/v1/claims", tags=["Claims"])
@@ -907,8 +908,55 @@ async def load_wizard_data(claim_id: str):
raise HTTPException(status_code=500, detail=f"Ошибка при загрузке данных визарда: {str(e)}")
+async def _check_and_restart_workflow_if_needed(channel: str):
+ """
+ Проверяет и перезапускает workflow если нужно (в фоне)
+ Защита от частых перезапусков через Redis lock
+ """
+ try:
+ # Проверяем lock - если недавно перезапускали, пропускаем
+ lock_key = f"workflow_restart_lock:{channel}"
+ lock_value = await redis_service.get(lock_key)
+
+ if lock_value:
+ logger.info(f"⏸️ Workflow недавно перезапускался, пропускаем (lock active)")
+ return
+
+ # Проверяем статус workflow
+ workflow_data = await check_workflow_status()
+
+ if workflow_data:
+ is_active = workflow_data.get("active", False)
+ if not is_active:
+ logger.warning(f"⚠️ Workflow НЕ активен! Активирую и перезапускаю...")
+ # Workflow выключен — нужно его ВКЛЮЧИТЬ
+ else:
+ logger.info(
+ f"⚠️ Workflow активен, но нет подписчиков. Перезапускаю workflow..."
+ )
+
+ # Устанавливаем lock на MIN_RESTART_INTERVAL секунд
+ await redis_service.set(lock_key, "1", expire=MIN_RESTART_INTERVAL)
+
+ # Перезапускаем
+ success = await restart_workflow()
+
+ if success:
+ logger.info("✅ Workflow успешно перезапущен")
+ else:
+ logger.error("❌ Не удалось перезапустить workflow")
+ else:
+ logger.warning("⚠️ Не удалось проверить статус workflow, пропускаем перезапуск")
+
+ except Exception as e:
+ logger.exception(f"❌ Ошибка при проверке/перезапуске workflow: {e}")
+
+
@router.post("/description")
-async def publish_ticket_form_description(payload: TicketFormDescriptionRequest):
+async def publish_ticket_form_description(
+ payload: TicketFormDescriptionRequest,
+ background_tasks: BackgroundTasks
+):
"""
Публикует свободное описание проблемы в Redis канал ticket_form:description
(слушается воркфлоу в n8n)
@@ -969,8 +1017,21 @@ async def publish_ticket_form_description(payload: TicketFormDescriptionRequest)
logger.warning(
f"⚠️ WARNING: No subscribers on channel {channel}! "
f"n8n workflow is not listening to this channel. "
- f"Event was published but will be lost."
+ f"Saving message to buffer and restarting workflow..."
)
+
+ # Сохраняем сообщение в буфер для последующей отправки
+ buffer_message = {
+ "session_id": payload.session_id,
+ "claim_id": payload.claim_id,
+ "event": event,
+ "timestamp": datetime.utcnow().isoformat(),
+ }
+ await redis_service.buffer_push("description", buffer_message)
+ logger.info(f"💾 Сообщение сохранено в буфер: session_id={payload.session_id}")
+
+ # Запускаем проверку и перезапуск workflow в фоне
+ background_tasks.add_task(_check_and_restart_workflow_if_needed, channel)
# Дополнительная проверка: логируем полный event для отладки
logger.debug(
@@ -980,11 +1041,27 @@ async def publish_ticket_form_description(payload: TicketFormDescriptionRequest)
"event": event,
},
)
- return {
+ # Формируем ответ с информацией о подписчиках
+ response_data = {
"success": True,
"channel": channel,
+ "subscribers_count": subscribers_count,
"event": event,
}
+
+ # Если подписчиков нет - сообщаем что обработка займёт больше времени
+ if subscribers_count == 0:
+ buffer_size = await redis_service.buffer_size("description")
+ response_data["warning"] = (
+ "Обработка вашего обращения займёт немного больше времени. "
+ "Идёт автоматическое восстановление системы. "
+ "Ваше сообщение сохранено и будет обработано в ближайшее время."
+ )
+ response_data["workflow_recovering"] = True
+ response_data["message_buffered"] = True
+ response_data["buffer_size"] = buffer_size
+
+ return response_data
except Exception as e:
logger.exception("❌ Failed to publish ticket form description")
raise HTTPException(
diff --git a/backend/app/config.py b/backend/app/config.py
index ad11425..3c37554 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -171,8 +171,10 @@ class Settings(BaseSettings):
return self.cors_origins
# ============================================
- # N8N WEBHOOKS (скрыты от фронтенда)
+ # N8N API & WEBHOOKS
# ============================================
+ n8n_url: str = "https://n8n.clientright.pro"
+ n8n_api_key: str = "" # Нужно задать в .env
n8n_policy_check_webhook: str = ""
n8n_file_upload_webhook: str = ""
n8n_create_contact_webhook: str = ""
diff --git a/backend/app/services/n8n_service.py b/backend/app/services/n8n_service.py
new file mode 100644
index 0000000..4d40a2b
--- /dev/null
+++ b/backend/app/services/n8n_service.py
@@ -0,0 +1,179 @@
+"""
+Сервис для работы с n8n API
+"""
+import httpx
+import logging
+from typing import Optional
+from ..config import settings
+from ..services.redis_service import redis_service
+
+logger = logging.getLogger(__name__)
+
+# Workflow ID для ticket_form:description
+WORKFLOW_ID = "b4K4u851b4JFivyD"
+N8N_URL = "https://n8n.clientright.pro"
+MIN_RESTART_INTERVAL = 300 # Минимум 5 минут между перезапусками
+
+
+async def check_workflow_status() -> Optional[dict]:
+ """
+ Проверка статуса workflow через n8n API
+
+ Returns:
+ dict с данными workflow или None при ошибке
+ """
+ if not settings.n8n_api_key:
+ logger.warning("⚠️ N8N_API_KEY не настроен")
+ return None
+
+ headers = _get_headers()
+ if not headers:
+ return None
+
+ try:
+ async with httpx.AsyncClient(timeout=5.0) as client:
+ response = await client.get(
+ f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}",
+ headers=headers
+ )
+
+ if response.status_code == 200:
+ return response.json()
+ else:
+ logger.warning(f"⚠️ n8n API вернул статус {response.status_code}")
+ return None
+
+ except Exception as e:
+ logger.error(f"❌ Ошибка при проверке статуса workflow: {e}")
+ return None
+
+
+async def restart_workflow() -> bool:
+ """
+ Перезапуск workflow через n8n API
+
+ Returns:
+ True если успешно, False при ошибке
+ """
+ if not settings.n8n_api_key:
+ logger.error("❌ N8N_API_KEY не настроен! Не могу перезапустить workflow")
+ return False
+
+ headers = _get_headers()
+ if not headers:
+ return False
+
+ try:
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ # Шаг 1: Деактивировать workflow
+ logger.info(f"🔄 Деактивирую workflow {WORKFLOW_ID}...")
+ deactivate_response = await client.post(
+ f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/deactivate",
+ headers=headers
+ )
+
+ if deactivate_response.status_code not in [200, 404]:
+ logger.warning(
+ f"⚠️ Неожиданный статус при деактивации: "
+ f"{deactivate_response.status_code}"
+ )
+ else:
+ logger.info("✅ Workflow деактивирован")
+
+ # Задержка перед активацией
+ import asyncio
+ await asyncio.sleep(2)
+
+ # Шаг 2: Активировать workflow
+ logger.info(f"🔄 Активирую workflow {WORKFLOW_ID}...")
+ activate_response = await client.post(
+ f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/activate",
+ headers=headers
+ )
+
+ if activate_response.status_code == 200:
+ logger.info("✅ Workflow активирован")
+
+ # После успешного перезапуска отправляем сообщения из буфера
+ await _send_buffered_messages()
+
+ return True
+ else:
+ logger.error(
+ f"❌ Ошибка активации workflow: "
+ f"{activate_response.status_code} - {activate_response.text[:200]}"
+ )
+ return False
+
+ except Exception as e:
+ logger.error(f"❌ Неожиданная ошибка при перезапуске workflow: {e}")
+ return False
+
+
+async def _send_buffered_messages():
+ """
+ Отправить все сообщения из буфера после восстановления workflow
+ """
+ try:
+ buffer_key = "description" # Буфер для ticket_form:description
+ messages = await redis_service.buffer_get_all(buffer_key)
+
+ if not messages:
+ logger.info("📭 Буфер пуст, нечего отправлять")
+ return
+
+ logger.info(f"📤 Отправляю {len(messages)} сообщений из буфера...")
+
+ import json
+ channel = f"{settings.redis_prefix}description"
+ sent_count = 0
+ failed_count = 0
+
+ for message in messages:
+ try:
+ event_json = json.dumps(message.get("event", message), ensure_ascii=False)
+ subscribers = await redis_service.publish(channel, event_json)
+
+ if subscribers > 0:
+ sent_count += 1
+ logger.info(
+ f"✅ Буферированное сообщение отправлено: "
+ f"session_id={message.get('session_id', 'unknown')}, "
+ f"subscribers={subscribers}"
+ )
+ else:
+ failed_count += 1
+ logger.warning(
+ f"⚠️ Буферированное сообщение не доставлено "
+ f"(подписчиков нет): session_id={message.get('session_id', 'unknown')}"
+ )
+ # Возвращаем обратно в буфер если не доставлено
+ await redis_service.buffer_push(buffer_key, message)
+
+ except Exception as e:
+ failed_count += 1
+ logger.error(f"❌ Ошибка отправки буферизованного сообщения: {e}")
+ # Возвращаем обратно в буфер
+ await redis_service.buffer_push(buffer_key, message)
+
+ logger.info(
+ f"📊 Результат отправки буфера: {sent_count} отправлено, {failed_count} не доставлено"
+ )
+
+ except Exception as e:
+ logger.exception(f"❌ Ошибка при отправке буферизованных сообщений: {e}")
+
+
+def _get_headers() -> Optional[dict]:
+ """Получить заголовки для n8n API"""
+ if not settings.n8n_api_key:
+ return None
+
+ api_key = settings.n8n_api_key
+
+ # Убираем "Bearer " если есть - n8n API использует X-N8N-API-KEY
+ clean_key = api_key.replace("Bearer ", "").strip()
+
+ # n8n API принимает ключ в заголовке X-N8N-API-KEY
+ return {"X-N8N-API-KEY": clean_key}
+
diff --git a/backend/app/services/redis_service.py b/backend/app/services/redis_service.py
index ec32f52..7c26f81 100644
--- a/backend/app/services/redis_service.py
+++ b/backend/app/services/redis_service.py
@@ -2,7 +2,7 @@
Redis Service для кеширования, rate limiting, сессий
"""
import redis.asyncio as redis
-from typing import Optional, Any
+from typing import Optional, Any, List
import json
from ..config import settings
import logging
@@ -155,6 +155,58 @@ class RedisService:
async def cache_delete(self, cache_key: str):
"""Удалить из кеша"""
await self.delete(f"cache:{cache_key}")
+
+ # ============================================
+ # MESSAGE BUFFER (для буферизации сообщений при недоступности workflow)
+ # ============================================
+
+ async def buffer_push(self, buffer_key: str, message: dict):
+ """
+ Добавить сообщение в буфер (очередь)
+
+ Args:
+ buffer_key: Имя буфера (например, "description")
+ message: Сообщение для буферизации
+ """
+ full_key = f"{settings.redis_prefix}buffer:{buffer_key}"
+ await self.client.lpush(full_key, json.dumps(message))
+ # Устанавливаем TTL на буфер (24 часа)
+ await self.client.expire(full_key, 86400)
+
+ async def buffer_get_all(self, buffer_key: str) -> List[dict]:
+ """
+ Получить все сообщения из буфера (и очистить буфер)
+
+ Args:
+ buffer_key: Имя буфера
+
+ Returns:
+ Список сообщений
+ """
+ full_key = f"{settings.redis_prefix}buffer:{buffer_key}"
+
+ # Используем транзакцию для атомарности
+ pipe = self.client.pipeline()
+ pipe.lrange(full_key, 0, -1) # Получить все
+ pipe.delete(full_key) # Удалить буфер
+ results = await pipe.execute()
+
+ messages_data = results[0] if results else []
+
+ messages = []
+ for msg_str in messages_data:
+ try:
+ messages.append(json.loads(msg_str))
+ except json.JSONDecodeError:
+ logger.warning(f"⚠️ Не удалось распарсить сообщение из буфера: {msg_str}")
+
+ # Возвращаем в правильном порядке (FIFO - сначала старые)
+ return list(reversed(messages))
+
+ async def buffer_size(self, buffer_key: str) -> int:
+ """Получить размер буфера"""
+ full_key = f"{settings.redis_prefix}buffer:{buffer_key}"
+ return await self.client.llen(full_key)
# Глобальный экземпляр
diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod
new file mode 100644
index 0000000..4a005ab
--- /dev/null
+++ b/frontend/Dockerfile.prod
@@ -0,0 +1,36 @@
+# React Frontend Dockerfile (PRODUCTION BUILD)
+FROM node:18-alpine AS builder
+
+# Устанавливаем рабочую директорию
+WORKDIR /app
+
+# Копируем package.json
+COPY package*.json ./
+
+# Устанавливаем зависимости
+RUN npm ci
+
+# Копируем исходный код
+COPY . .
+
+# Собираем production build
+RUN npm run build
+
+# Production stage
+FROM node:18-alpine
+
+# Устанавливаем serve глобально
+RUN npm install -g serve
+
+# Копируем собранное приложение из builder stage
+COPY --from=builder /app/dist /app/dist
+
+# Устанавливаем рабочую директорию
+WORKDIR /app
+
+# Открываем порт
+EXPOSE 3000
+
+# Запускаем serve для раздачи статических файлов
+CMD ["serve", "-s", "dist", "-l", "3000"]
+
diff --git a/frontend/package.json b/frontend/package.json
index 2a13c36..35e2148 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -6,7 +6,7 @@
"type": "module",
"scripts": {
"dev": "vite",
- "build": "tsc && vite build",
+ "build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"type-check": "tsc --noEmit",
diff --git a/frontend/src/components/form/Step1Phone.tsx b/frontend/src/components/form/Step1Phone.tsx
index bfdc0f2..3fed8f5 100644
--- a/frontend/src/components/form/Step1Phone.tsx
+++ b/frontend/src/components/form/Step1Phone.tsx
@@ -49,9 +49,7 @@ export default function Step1Phone({
message.success('Код отправлен на ваш телефон');
setCodeSent(true);
updateFormData({ phone });
- if (result.debug_code) {
- message.info(`DEBUG: Код ${result.debug_code}`);
- }
+ // DEBUG код не показываем в продакшене
} else {
addDebugEvent?.('sms', 'error', `❌ Ошибка SMS: ${result.detail}`, { error: result.detail });
message.error(result.detail || 'Ошибка отправки кода');
@@ -336,38 +334,7 @@ export default function Step1Phone({
)}
- {/* 🔧 Технические кнопки для разработки */}
-
-
- 🔧 DEV MODE - Быстрая навигация (без валидации)
-
-
- {
- // Автозаполняем телефон и 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 секция удалена для продакшена */}
);
}
diff --git a/frontend/src/components/form/Step1Policy.tsx b/frontend/src/components/form/Step1Policy.tsx
index c547b6a..7f7dc1e 100644
--- a/frontend/src/components/form/Step1Policy.tsx
+++ b/frontend/src/components/form/Step1Policy.tsx
@@ -656,36 +656,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
) : null}
- {/* 🔧 Технические кнопки для разработки */}
-
-
- 🔧 DEV MODE - Быстрая навигация (без валидации)
-
-
- {
- // Пропускаем валидацию, заполняем минимальные данные
- const devData = {
- voucher: 'E1000-123456789',
- claim_id: `CLM-DEV-${Math.random().toString(36).substr(2, 6).toUpperCase()}`,
- };
- updateFormData(devData);
- onNext();
- }}
- size="small"
- style={{ flex: 1 }}
- >
- Далее → (Step 2) [пропустить]
-
-
-
+ {/* DEV MODE секция удалена для продакшена */}
);
}
diff --git a/frontend/src/components/form/Step2Details.tsx b/frontend/src/components/form/Step2Details.tsx
index e194c7b..a8d2ef8 100644
--- a/frontend/src/components/form/Step2Details.tsx
+++ b/frontend/src/components/form/Step2Details.tsx
@@ -593,45 +593,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
- {/* 🔧 Технические кнопки для разработки */}
-
-
- 🔧 DEV MODE - Быстрая навигация (без валидации)
-
-
-
- ← Назад (Step 1)
-
- {
- const devData = {
- eventType: 'delay_flight',
- processedDocuments: {
- boarding_or_ticket: { flight_number: 'DEV123', date: '2025-10-28' },
- delay_confirmation: { delay_duration: '4h' }
- }
- };
- updateFormData(devData);
- onNext();
- }}
- size="small"
- style={{ flex: 1 }}
- >
- Далее → (Step 3) [пропустить]
-
-
-
+ {/* DEV MODE секция удалена для продакшена */}
);
}
diff --git a/frontend/src/components/form/Step2EventType.tsx b/frontend/src/components/form/Step2EventType.tsx
index 864310c..4103bd8 100644
--- a/frontend/src/components/form/Step2EventType.tsx
+++ b/frontend/src/components/form/Step2EventType.tsx
@@ -147,39 +147,7 @@ const Step2EventType: React.FC = ({ formData, updateFormData, onNext, onP
- {/* 🔧 DEV MODE */}
-
-
- 🔧 DEV MODE - Быстрая навигация
-
-
-
- ← Назад (Step 1)
-
- {
- const devData = { eventType: 'cancel_flight' };
- form.setFieldsValue(devData);
- updateFormData(devData);
- onNext();
- }}
- size="small"
- style={{ flex: 1 }}
- >
- Далее → [Отмена рейса]
-
-
-
+ {/* DEV MODE секция удалена для продакшена */}
);
diff --git a/frontend/src/components/form/Step3Payment.tsx b/frontend/src/components/form/Step3Payment.tsx
index c125bd5..a231394 100644
--- a/frontend/src/components/form/Step3Payment.tsx
+++ b/frontend/src/components/form/Step3Payment.tsx
@@ -487,67 +487,7 @@ export default function Step3Payment({
- {/* 🔧 Технические кнопки для разработки */}
-
-
- 🔧 DEV MODE - Быстрая навигация (без валидации)
-
-
-
- ← Назад (Step 2)
-
- {
- // Пропускаем валидацию телефона
- setIsPhoneVerified(true);
- const devData = {
- fullName: 'Тест Тестов',
- email: 'test@test.ru',
- phone: '+79991234567',
- paymentMethod: 'sbp',
- bankId: banks.length > 0 ? banks[0].bankid : '100000000111', // Сбербанк по умолчанию
- bankName: banks.length > 0 ? banks[0].bankname : 'Сбербанк',
- };
- updateFormData(devData);
- message.success('DEV: Телефон автоматически подтверждён');
- }}
- size="small"
- style={{ flex: 1 }}
- >
- ✅ Автоподтверждение телефона [dev]
-
- {
- // Автоматически отправляем заявку
- setIsPhoneVerified(true);
- const devData = {
- fullName: 'Тест Тестов',
- email: 'test@test.ru',
- phone: '+79991234567',
- paymentMethod: 'sbp',
- bankId: banks.length > 0 ? banks[0].bankid : '100000000111', // Сбербанк по умолчанию
- bankName: banks.length > 0 ? banks[0].bankname : 'Сбербанк',
- };
- updateFormData(devData);
- onSubmit();
- }}
- size="small"
- >
- 🚀 Отправить [пропустить]
-
-
-
+ {/* DEV MODE секция удалена для продакшена */}
>
)}
diff --git a/frontend/src/components/form/StepDescription.tsx b/frontend/src/components/form/StepDescription.tsx
index 9627af8..876998d 100644
--- a/frontend/src/components/form/StepDescription.tsx
+++ b/frontend/src/components/form/StepDescription.tsx
@@ -20,7 +20,8 @@ export default function StepDescription({
}: Props) {
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
- const [useMockWizard, setUseMockWizard] = useState(true);
+ // В проде всегда false, в dev - true для тестирования
+ const [useMockWizard, setUseMockWizard] = useState(process.env.NODE_ENV === 'development');
const buildPrefillMap = (prefill?: Array<{ name: string; value: any }>) => {
if (!prefill) {
@@ -189,27 +190,30 @@ export default function StepDescription({
-
-
setUseMockWizard(e.target.checked)}
+ {/* DEV чекбокс - только в dev режиме */}
+ {process.env.NODE_ENV === 'development' && (
+
- Использовать сохранённые рекомендации (DEV)
-
-
- Если включено, план вопросов берётся из локального файла и не запускает модель.
-
-
+ setUseMockWizard(e.target.checked)}
+ >
+ Использовать сохранённые рекомендации (DEV)
+
+
+ Если включено, план вопросов берётся из локального файла и не запускает модель.
+
+
+ )}
-
+
Продолжить →
diff --git a/frontend/src/components/form/StepDocumentUpload.tsx b/frontend/src/components/form/StepDocumentUpload.tsx
index dd1d2b7..d65ebc3 100644
--- a/frontend/src/components/form/StepDocumentUpload.tsx
+++ b/frontend/src/components/form/StepDocumentUpload.tsx
@@ -318,53 +318,7 @@ const StepDocumentUpload: React.FC
= ({
)}
- {/* 🔧 DEV MODE */}
-
-
- 🔧 DEV MODE - Быстрая навигация
-
-
- {
- console.log('🔙 DEV Кнопка Назад нажата');
- onPrev();
- }}
- size="small"
- disabled={false}
- >
- ← Назад
-
- {
- console.log('⏭️ DEV Пропустить нажата');
- // Эмулируем загрузку документа
- const updatedDocuments = {
- ...(formData.documents || {}),
- [documentConfig.file_type]: {
- uploaded: true,
- data: { test: 'dev_mode_skip' },
- file_type: documentConfig.file_type
- }
- };
- updateFormData({ documents: updatedDocuments });
- message.success('DEV: Документ пропущен');
- onNext();
- }}
- size="small"
- style={{ flex: 1 }}
- disabled={false}
- >
- Пропустить [dev] →
-
-
-
+ {/* DEV MODE секция удалена для продакшена */}
{/* Модалка обработки */}
diff --git a/frontend/src/components/form/StepWizardPlan.tsx b/frontend/src/components/form/StepWizardPlan.tsx
index b673111..ead065c 100644
--- a/frontend/src/components/form/StepWizardPlan.tsx
+++ b/frontend/src/components/form/StepWizardPlan.tsx
@@ -119,6 +119,7 @@ export default function StepWizardPlan({
const debugLoggerRef = useRef
(addDebugEvent);
const [isWaiting, setIsWaiting] = useState(!formData.wizardPlan);
const [connectionError, setConnectionError] = useState(null);
+ const [outOfScopeData, setOutOfScopeData] = useState(null);
const [plan, setPlan] = useState(formData.wizardPlan || null);
const [prefillMap, setPrefillMap] = useState>(
formData.wizardPrefill || buildPrefillMap(formData.wizardPrefillArray)
@@ -391,17 +392,17 @@ export default function StepWizardPlan({
const sessionId = formData.session_id;
console.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 });
// Таймаут: если план не пришёл за 2 минуты (RAG может работать долго), показываем ошибку
timeoutRef.current = setTimeout(() => {
- setConnectionError('План вопросов не получен. Проверьте, что n8n обработал описание проблемы.');
+ setConnectionError('Обработка занимает больше времени, чем обычно. Пожалуйста, попробуйте ещё раз.');
debugLoggerRef.current?.('wizard', 'error', '⏱️ Таймаут ожидания плана вопросов (2 минуты)', { session_id: sessionId });
if (eventSourceRef.current) {
eventSourceRef.current.close();
@@ -461,6 +462,27 @@ export default function StepWizardPlan({
payload_preview: JSON.stringify(payload).substring(0, 200),
});
+ // ❌ OUT OF SCOPE: Вопрос не связан с защитой прав потребителей
+ if (eventType === 'out_of_scope') {
+ debugLoggerRef.current?.('wizard', 'warning', '⚠️ Вопрос вне скоупа', {
+ session_id: sessionId,
+ message: payload.message,
+ suggested_actions: payload.suggested_actions,
+ });
+
+ setIsWaiting(false);
+ setOutOfScopeData(payload); // Сохраняем полные данные
+ setConnectionError(null); // Не используем connectionError
+
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+ source.close();
+ eventSourceRef.current = null;
+ return;
+ }
+
// ✅ НОВЫЙ ФЛОУ: Обработка списка документов
if (eventType === 'documents_list_ready') {
const documentsRequired = payload.documents_required || [];
@@ -2379,7 +2401,7 @@ export default function StepWizardPlan({
})()}
{/* СТАРЫЙ ФЛОУ: Ожидание визарда */}
- {!hasNewFlowDocs && isWaiting && (
+ {!hasNewFlowDocs && isWaiting && !outOfScopeData && (
)}
+ {/* OUT OF SCOPE: Вопрос вне нашей компетенции */}
+ {outOfScopeData && (
+
+
+
+ ⚠️ К сожалению, мы не можем помочь с этим вопросом
+
+
+ {outOfScopeData.message || outOfScopeData.reason}
+
+
+ {outOfScopeData.ticket && (
+
+ Ваш запрос: {outOfScopeData.ticket}
+ {outOfScopeData.ticket_number && ` (№${outOfScopeData.ticket_number})`}
+
+ )}
+
+ {outOfScopeData.suggested_actions && outOfScopeData.suggested_actions.length > 0 && (
+
+
Что можно сделать:
+
+ {outOfScopeData.suggested_actions.map((action: any, index: number) => (
+
+ {action.title}
+ {action.description}
+ {action.actionType === 'external_link' && action.url && (
+
+ {action.urlText || 'Перейти →'}
+
+ )}
+ {action.actionType === 'contact_support' && (
+ {
+ try {
+ message.loading('Отправляем запрос в поддержку...', 0);
+ await fetch('https://n8n.clientright.pro/webhook/3ef6ff67-f3f2-418e-a300-86cb4659dbde', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ session_id: formData.session_id,
+ phone: formData.phone,
+ email: formData.email,
+ unified_id: formData.unified_id,
+ ticket_number: outOfScopeData.ticket_number,
+ ticket: outOfScopeData.ticket,
+ reason: outOfScopeData.reason,
+ message: outOfScopeData.message,
+ action: 'contact_support',
+ timestamp: new Date().toISOString(),
+ }),
+ });
+ message.destroy();
+ message.success('Запрос отправлен! Мы свяжемся с вами в ближайшее время. Возвращаем на главную...');
+ // Возвращаемся на главную через перезагрузку
+ setTimeout(() => {
+ window.location.reload();
+ }, 2000);
+ } catch (error) {
+ message.destroy();
+ message.error('Не удалось отправить запрос. Попробуйте позже.');
+ }
+ }}
+ >
+ Связаться с поддержкой →
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+
+ ← Изменить описание
+
+ {
+ // Сбрасываем состояние и возвращаемся на первый экран
+ updateFormData({
+ wizardPlan: null,
+ wizardPlanStatus: null,
+ problemDescription: '',
+ });
+ window.location.href = '/';
+ }}>
+ Новое обращение
+
+
+
+
+ )}
+
{/* СТАРЫЙ ФЛОУ: Визард готов */}
{!hasNewFlowDocs && !isWaiting && plan && (
diff --git a/frontend/src/pages/ClaimForm.tsx b/frontend/src/pages/ClaimForm.tsx
index a7d12ed..e0e5fd1 100644
--- a/frontend/src/pages/ClaimForm.tsx
+++ b/frontend/src/pages/ClaimForm.tsx
@@ -2,15 +2,14 @@ import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { Steps, Card, message, Row, Col, Space } from 'antd';
import Step1Phone from '../components/form/Step1Phone';
import StepDescription from '../components/form/StepDescription';
-import Step1Policy from '../components/form/Step1Policy';
+// Step1Policy убран - старый ERV флоу
import StepDraftSelection from '../components/form/StepDraftSelection';
import StepWizardPlan from '../components/form/StepWizardPlan';
import StepClaimConfirmation from '../components/form/StepClaimConfirmation';
-import Step2EventType from '../components/form/Step2EventType';
-import StepDocumentUpload from '../components/form/StepDocumentUpload';
+// Step2EventType, StepDocumentUpload убраны - старый ERV флоу
import Step3Payment from '../components/form/Step3Payment';
import DebugPanel from '../components/DebugPanel';
-import { getDocumentsForEventType } from '../constants/documentConfigs';
+// getDocumentsForEventType убран - старый ERV флоу
import './ClaimForm.css';
// Используем относительные пути - Vite proxy перенаправит на backend
@@ -244,9 +243,7 @@ export default function ClaimForm() {
}, [formData.showClaimConfirmation, formData.claimPlanData]);
- // Динамически определяем список шагов на основе выбранного eventType
- const documentConfigs = formData.eventType ? getDocumentsForEventType(formData.eventType) : [];
- const totalDocumentSteps = documentConfigs.length;
+ // Старый ERV флоу убран - documentConfigs больше не нужен
const addDebugEvent = (type: string, status: string, message: string, data?: any) => {
const event = {
@@ -1206,12 +1203,7 @@ export default function ClaimForm() {
{
- // Возвращаемся к списку заявок
- setShowDraftSelection(true);
- setSelectedDraftId(null);
- setCurrentStep(0);
- }}
+ onPrev={prevStep}
onNext={nextStep}
addDebugEvent={addDebugEvent}
/>
@@ -1235,63 +1227,6 @@ export default function ClaimForm() {
});
}
- // Шаги для СТАРОГО флоу (страхование ERV) — НЕ показываем для нового флоу защиты прав
- const isNewClaimFlow = formData.documents_required && formData.documents_required.length > 0;
-
- if (!isNewClaimFlow) {
- // Шаг 3: Policy (только для старого флоу)
- stepsArray.push({
- title: 'Проверка полиса',
- description: 'Полис ERV',
- content: (
-
- ),
- });
-
- // Шаг 4: Event Type Selection (только для старого флоу)
- stepsArray.push({
- title: 'Тип события',
- description: 'Выбор случая',
- content: (
-
- ),
- });
- }
-
- // Шаги Document Upload (только для старого флоу — если выбран eventType)
- if (!isNewClaimFlow && formData.eventType && documentConfigs.length > 0) {
- documentConfigs.forEach((docConfig, index) => {
- stepsArray.push({
- title: `Документ ${index + 1}`,
- description: docConfig.name,
- content: (
-
- ),
- });
- });
- }
-
// Последний шаг: Payment (всегда)
stepsArray.push({
title: 'Заявление',
@@ -1310,7 +1245,7 @@ export default function ClaimForm() {
});
return stepsArray;
- }, [formData, documentConfigs, isPhoneVerified, nextStep, prevStep, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts]);
+ }, [formData, isPhoneVerified, nextStep, prevStep, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts]);
const handleReset = () => {
setIsSubmitted(false);
@@ -1364,8 +1299,8 @@ export default function ClaimForm() {
return (
- {/* Левая часть - Форма */}
-
+ {/* Левая часть - Форма (в проде на всю ширину, в деве 14 из 24) */}
+
- {/* Правая часть - Debug консоль */}
-
-
-
+ {/* Правая часть - Debug консоль (только в dev режиме) */}
+ {process.env.NODE_ENV === 'development' && (
+
+
+
+ )}
);
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 184500a..474e8de 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -3,6 +3,10 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
+ // Удаляем console.log в продакшен билде
+ esbuild: {
+ drop: process.env.NODE_ENV === 'production' ? ['console', 'debugger'] : [],
+ },
server: {
host: '0.0.0.0',
port: 3000,