feat(ticket_form): unified_id/contact_id передача, исправлен мерж сессии, новая сессия для жалобы
- Добавлены unified_id и contact_id в TicketFormDescriptionRequest - Исправлен CODE_MERGE_PROJECT_TO_SESSION.js - теперь сохраняются ВСЕ данные из body.other - Добавлен fallback на получение other из Webhook напрямую - Генерация новой session_id при создании новой жалобы (сохраняя авторизацию) - Добавлен SQL_SELECT_CONTACT_WITH_CUSTOM_FIELDS.sql для CRM контактов - Создан SESSION_LOG_2025-11-25.md с документацией сессии
This commit is contained in:
@@ -343,3 +343,4 @@ TTL: 86400 секунд
|
||||
**Статус:** ✅ Завершено
|
||||
|
||||
|
||||
|
||||
|
||||
135
ticket_form/SESSION_LOG_2025-11-25.md
Normal file
135
ticket_form/SESSION_LOG_2025-11-25.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Лог сессии 25.11.2025
|
||||
|
||||
## Основные задачи
|
||||
|
||||
### 1. Передача unified_id и contact_id в описание проблемы
|
||||
|
||||
**Файлы:**
|
||||
- `backend/app/api/models.py` — добавлены поля `unified_id` и `contact_id` в `TicketFormDescriptionRequest`
|
||||
- `backend/app/api/claims.py` — добавлена передача `unified_id` и `contact_id` в Redis событие
|
||||
- `frontend/src/components/form/StepDescription.tsx` — добавлена передача `unified_id` и `contact_id` при отправке описания
|
||||
|
||||
**Результат:** При отправке описания проблемы теперь передаются `unified_id` и `contact_id` пользователя.
|
||||
|
||||
---
|
||||
|
||||
### 2. Структура таблиц CRM MySQL для контактов
|
||||
|
||||
**Основные таблицы:**
|
||||
- `vtiger_contactdetails` — основные данные (firstname, lastname, email, mobile, phone)
|
||||
- `vtiger_contactscf` — кастомные поля:
|
||||
- `cf_1157` — Отчество (middle_name)
|
||||
- `cf_1263` — Место рождения (birthplace)
|
||||
- `cf_1257` — ИНН (inn)
|
||||
- `cf_1849` — Реквизиты (requisites)
|
||||
- `cf_1580` — Код (code)
|
||||
- `vtiger_contactsubdetails` — дополнительные данные (birthday, homephone)
|
||||
- `vtiger_contactaddress` — адреса (mailingstreet, mailingcity, и т.д.)
|
||||
|
||||
**Создан файл:** `docs/SQL_SELECT_CONTACT_WITH_CUSTOM_FIELDS.sql` — правильный SQL запрос для получения всех данных контакта
|
||||
|
||||
---
|
||||
|
||||
### 3. Исправление Code Node: Мерж данных проекта в сессию
|
||||
|
||||
**Проблема:** Данные из `body.other` (sessionData) не сохранялись в Redis — терялись все данные пользователя.
|
||||
|
||||
**Причина:** К моменту выполнения Code Node структура данных менялась (`body_keys: ["success", "result"]`), и `body.other` был недоступен.
|
||||
|
||||
**Решение:** Добавлен fallback на получение `other` напрямую из Webhook:
|
||||
```javascript
|
||||
// ✅ Пробуем также достать other из Webhook напрямую
|
||||
if (!rawOther) {
|
||||
try {
|
||||
const webhookJson = $('Webhook').first()?.json;
|
||||
if (webhookJson?.body?.other) {
|
||||
rawOther = webhookJson.body.other;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
```
|
||||
|
||||
**Файл:** `docs/CODE_MERGE_PROJECT_TO_SESSION.js`
|
||||
|
||||
**Результат:** Теперь в Redis сохраняются ВСЕ данные:
|
||||
- session_id, phone, unified_id, contact_id
|
||||
- lastname, firstname, middle_name
|
||||
- birthday, birthplace, inn
|
||||
- mailingzip, mailingstreet, email, tg_id
|
||||
- description
|
||||
- claim_id, project_id, project_name
|
||||
- is_new_project, current_step
|
||||
|
||||
---
|
||||
|
||||
### 4. Генерация новой сессии для новой жалобы
|
||||
|
||||
**Проблема:** При создании новой жалобы использовалась та же сессия, что и для предыдущей.
|
||||
|
||||
**Решение:**
|
||||
- Добавлена функция `generateUUIDv4()` в `ClaimForm.tsx`
|
||||
- При создании новой жалобы генерируется новый `session_id`
|
||||
- `session_token` в localStorage (авторизация) остаётся прежним
|
||||
- `unified_id`, `phone`, `contact_id` сохраняются
|
||||
|
||||
**Файл:** `frontend/src/pages/ClaimForm.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Созданные/обновлённые файлы
|
||||
|
||||
### Новые файлы:
|
||||
- `docs/SQL_SELECT_CONTACT_WITH_CUSTOM_FIELDS.sql` — SQL запрос для контактов с кастомными полями
|
||||
|
||||
### Обновлённые файлы:
|
||||
- `backend/app/api/models.py` — добавлены unified_id, contact_id
|
||||
- `backend/app/api/claims.py` — передача unified_id, contact_id в Redis
|
||||
- `frontend/src/components/form/StepDescription.tsx` — передача unified_id, contact_id
|
||||
- `frontend/src/pages/ClaimForm.tsx` — генерация новой сессии для новой жалобы
|
||||
- `docs/CODE_MERGE_PROJECT_TO_SESSION.js` — исправлен мерж данных в сессию
|
||||
|
||||
---
|
||||
|
||||
## Технические детали
|
||||
|
||||
### Redis канал для описания проблемы
|
||||
- Канал: `ticket_form:description`
|
||||
- Передаваемые данные: session_id, phone, email, unified_id, contact_id, problem_description
|
||||
|
||||
### Redis канал для подтверждения формы
|
||||
- Канал: `clientright:webform:approve`
|
||||
- Включает SMS код для верификации
|
||||
|
||||
### Структура сессии в Redis
|
||||
```json
|
||||
{
|
||||
"session_id": "sess_...",
|
||||
"phone": "79262306381",
|
||||
"unified_id": "usr_...",
|
||||
"contact_id": "320096",
|
||||
"lastname": "Коробков",
|
||||
"firstname": "Федор",
|
||||
"middle_name": "Владимирович",
|
||||
"birthday": "1981-09-18",
|
||||
"birthplace": "Москва",
|
||||
"inn": "123456789012",
|
||||
"mailingstreet": "...",
|
||||
"email": "help@clientright.ru",
|
||||
"tg_id": "295410106",
|
||||
"description": "...",
|
||||
"claim_id": "...",
|
||||
"project_id": "399171",
|
||||
"project_name": "Коробков_КлиентПрав",
|
||||
"is_new_project": false,
|
||||
"current_step": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Статус
|
||||
✅ Все задачи выполнены
|
||||
✅ Backend пересобран и перезапущен
|
||||
✅ Frontend обновлён через HMR
|
||||
✅ Тестирование успешно
|
||||
|
||||
@@ -201,6 +201,8 @@ async def list_drafts(
|
||||
c.updated_at
|
||||
FROM clpr_claims c
|
||||
WHERE c.unified_id = $1
|
||||
AND (c.status_code != 'approved' OR c.status_code IS NULL)
|
||||
AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT 20
|
||||
"""
|
||||
@@ -227,6 +229,8 @@ async def list_drafts(
|
||||
AND ua.channel_user_id = $1
|
||||
LIMIT 1
|
||||
)
|
||||
AND (c.status_code != 'approved' OR c.status_code IS NULL)
|
||||
AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT 20
|
||||
"""
|
||||
@@ -246,6 +250,8 @@ async def list_drafts(
|
||||
c.updated_at
|
||||
FROM clpr_claims c
|
||||
WHERE c.session_token = $1
|
||||
AND (c.status_code != 'approved' OR c.status_code IS NULL)
|
||||
AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT 20
|
||||
"""
|
||||
@@ -350,6 +356,7 @@ async def get_draft(claim_id: str):
|
||||
|
||||
# Ищем черновик по claim_id (может быть в payload->>'claim_id' или id = UUID)
|
||||
# Убираем фильтры по channel и status_code, чтобы находить черновики из всех каналов
|
||||
# ✅ Сортируем по updated_at DESC, чтобы получить самую свежую запись (которая может иметь send_to_form_approve)
|
||||
query = """
|
||||
SELECT
|
||||
id,
|
||||
@@ -362,6 +369,7 @@ async def get_draft(claim_id: str):
|
||||
updated_at
|
||||
FROM clpr_claims
|
||||
WHERE (payload->>'claim_id' = $1 OR id::text = $1)
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
@@ -449,6 +457,133 @@ async def delete_draft(claim_id: str):
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка при удалении черновика: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/approve")
|
||||
async def publish_form_approval(request: Request):
|
||||
"""
|
||||
Публикация данных подтвержденной формы в Redis канал
|
||||
|
||||
После SMS-апрува отправляет данные формы в Redis канал clientright:webform:approve
|
||||
для обработки в n8n workflow.
|
||||
|
||||
В будущем можно подключить RabbitMQ для очереди и защиты от дублей.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
|
||||
# Детальное логирование всего body для отладки
|
||||
logger.info(
|
||||
f"📥 Получен запрос на публикацию формы подтверждения",
|
||||
extra={
|
||||
"body_keys": list(body.keys()) if isinstance(body, dict) else "not_dict",
|
||||
"body_type": type(body).__name__,
|
||||
"sms_code_in_body": "sms_code" in body if isinstance(body, dict) else False,
|
||||
"sms_code_value": body.get("sms_code", "NOT_FOUND") if isinstance(body, dict) else "NOT_DICT",
|
||||
},
|
||||
)
|
||||
|
||||
claim_id = body.get("claim_id")
|
||||
session_token = body.get("session_token") or body.get("session_id")
|
||||
sms_code = body.get("sms_code", "")
|
||||
|
||||
# Логируем полученные данные для отладки
|
||||
logger.info(
|
||||
f"📥 Извлеченные данные из запроса",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"sms_code": sms_code if sms_code else "(пусто)",
|
||||
"sms_code_length": len(sms_code) if sms_code else 0,
|
||||
"has_sms_code": bool(sms_code),
|
||||
},
|
||||
)
|
||||
|
||||
if not claim_id:
|
||||
raise HTTPException(status_code=400, detail="claim_id обязателен")
|
||||
|
||||
# Генерируем idempotency key для защиты от дублей (для будущей интеграции с RabbitMQ)
|
||||
import time
|
||||
idempotency_key = f"{claim_id}_{int(time.time() * 1000)}_{body.get('user_id', 'unknown')}"
|
||||
|
||||
# Формируем событие для Redis
|
||||
event_data = {
|
||||
"event_type": "form_approve",
|
||||
"status": "approved",
|
||||
"message": "Форма подтверждена после SMS-верификации",
|
||||
"claim_id": claim_id,
|
||||
"session_token": session_token,
|
||||
"unified_id": body.get("unified_id"),
|
||||
"phone": body.get("phone"),
|
||||
"sms_code": sms_code, # SMS код для верификации
|
||||
"sms_verified": True,
|
||||
"idempotency_key": idempotency_key, # Для защиты от дублей в RabbitMQ
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
|
||||
# Данные формы подтверждения
|
||||
"form_data": body.get("form_data", {}),
|
||||
"user": body.get("user", {}),
|
||||
"project": body.get("project", {}),
|
||||
"offenders": body.get("offenders", []),
|
||||
"meta": body.get("meta", {}),
|
||||
|
||||
# Оригинальные данные для сравнения
|
||||
"original_data": body.get("original_data", {}),
|
||||
}
|
||||
|
||||
# Публикуем в Redis канал clientright:webform:approve
|
||||
channel = "clientright:webform:approve"
|
||||
|
||||
# Логируем event_data перед сериализацией
|
||||
logger.info(
|
||||
f"📢 Формируем событие для Redis канала {channel}",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
"sms_code": sms_code if sms_code else "(пусто)",
|
||||
"has_sms_code": bool(sms_code),
|
||||
"sms_code_in_event_data": "sms_code" in event_data,
|
||||
"event_data_sms_code_value": event_data.get("sms_code", "NOT_FOUND"),
|
||||
"event_data_keys": list(event_data.keys()),
|
||||
},
|
||||
)
|
||||
|
||||
event_json = json.dumps(event_data, ensure_ascii=False)
|
||||
|
||||
# Логируем после сериализации
|
||||
logger.info(
|
||||
f"📢 JSON для публикации готов",
|
||||
extra={
|
||||
"json_length": len(event_json),
|
||||
"sms_code_in_json": '"sms_code"' in event_json,
|
||||
},
|
||||
)
|
||||
|
||||
await redis_service.publish(channel, event_json)
|
||||
|
||||
logger.info(
|
||||
f"✅ Form approval published to {channel}",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
"sms_code_included": bool(sms_code),
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"channel": channel,
|
||||
"idempotency_key": idempotency_key,
|
||||
"message": "Данные формы отправлены на обработку",
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("❌ Failed to publish form approval")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ошибка при отправке данных формы: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{claim_id}")
|
||||
async def get_claim(claim_id: str):
|
||||
"""Получить информацию о заявке по ID"""
|
||||
@@ -553,18 +688,64 @@ async def publish_ticket_form_description(payload: TicketFormDescriptionRequest)
|
||||
"claim_id": payload.claim_id, # Опционально - может быть None
|
||||
"phone": payload.phone,
|
||||
"email": payload.email,
|
||||
"unified_id": payload.unified_id, # ✅ Unified ID пользователя
|
||||
"contact_id": payload.contact_id, # ✅ Contact ID пользователя
|
||||
"description": payload.problem_description.strip(),
|
||||
"source": payload.source,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
event_json = json.dumps(event, ensure_ascii=False)
|
||||
|
||||
logger.info(
|
||||
"📝 TicketForm description received",
|
||||
extra={"session_id": payload.session_id, "claim_id": payload.claim_id or "not_set"},
|
||||
extra={
|
||||
"session_id": payload.session_id,
|
||||
"claim_id": payload.claim_id or "not_set",
|
||||
"phone": payload.phone,
|
||||
"unified_id": payload.unified_id or "not_set",
|
||||
"contact_id": payload.contact_id or "not_set",
|
||||
"description_length": len(payload.problem_description),
|
||||
"channel": channel,
|
||||
},
|
||||
)
|
||||
await redis_service.publish(channel, json.dumps(event, ensure_ascii=False))
|
||||
|
||||
logger.info(
|
||||
"📡 TicketForm description published",
|
||||
extra={"channel": channel, "session_id": payload.session_id},
|
||||
"📡 Publishing to Redis channel",
|
||||
extra={
|
||||
"channel": channel,
|
||||
"event_type": event["type"],
|
||||
"event_keys": list(event.keys()),
|
||||
"json_length": len(event_json),
|
||||
},
|
||||
)
|
||||
|
||||
subscribers_count = await redis_service.publish(channel, event_json)
|
||||
|
||||
logger.info(
|
||||
"✅ TicketForm description published to Redis",
|
||||
extra={
|
||||
"channel": channel,
|
||||
"session_id": payload.session_id,
|
||||
"subscribers_count": subscribers_count,
|
||||
"event_json_preview": event_json[:500],
|
||||
},
|
||||
)
|
||||
|
||||
if subscribers_count == 0:
|
||||
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."
|
||||
)
|
||||
|
||||
# Дополнительная проверка: логируем полный event для отладки
|
||||
logger.debug(
|
||||
"🔍 Full event data published",
|
||||
extra={
|
||||
"channel": channel,
|
||||
"event": event,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -69,6 +69,8 @@ class TicketFormDescriptionRequest(BaseModel):
|
||||
claim_id: Optional[str] = Field(None, description="ID заявки (если уже создана)")
|
||||
phone: Optional[str] = Field(None, description="Номер телефона заявителя")
|
||||
email: Optional[str] = Field(None, description="Email заявителя")
|
||||
unified_id: Optional[str] = Field(None, description="Unified ID пользователя из PostgreSQL")
|
||||
contact_id: Optional[str] = Field(None, description="Contact ID пользователя в CRM")
|
||||
problem_description: str = Field(..., min_length=10, description="Свободное описание ситуации")
|
||||
source: str = Field("ticket_form", description="Источник события")
|
||||
channel: Optional[str] = Field(None, description="Переопределение Redis канала (опционально)")
|
||||
|
||||
@@ -54,9 +54,18 @@ class RedisService:
|
||||
async def publish(self, channel: str, message: str):
|
||||
"""Публикация сообщения в канал Redis Pub/Sub"""
|
||||
try:
|
||||
await self.client.publish(channel, message)
|
||||
subscribers_count = await self.client.publish(channel, message)
|
||||
logger.info(
|
||||
f"📢 Redis publish: channel={channel}, message_length={len(message)}, subscribers={subscribers_count}"
|
||||
)
|
||||
if subscribers_count == 0:
|
||||
logger.warning(
|
||||
f"⚠️ No subscribers on channel {channel}. Message published but no one is listening!"
|
||||
)
|
||||
return subscribers_count
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Redis publish error: {e}")
|
||||
raise
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
"""Удалить ключ"""
|
||||
|
||||
@@ -209,3 +209,4 @@ $2 = {{ $json.claim_id }} (TEXT, строка "CLM-2025-11-18-GEQ3K
|
||||
Оба запроса теперь используют строковый `claim_id` и правильно находят UUID.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -102,3 +102,4 @@ function mapCombinedDocs(cds = []) {
|
||||
Но для `mapDialogHistory` это критично, т.к. она вызывается первой и падает.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -211,3 +211,4 @@ const results = arr
|
||||
return results.length ? results : [{ json: null }];
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// ========================================
|
||||
// Code Node: Мерж данных проекта в сессию
|
||||
// v2.0 - с расширенным логированием для отладки
|
||||
// ========================================
|
||||
|
||||
// 1. Берём первый item
|
||||
@@ -12,25 +13,62 @@ if (!inputItem || !inputItem.json) {
|
||||
// root — то, что реально пришло в эту ноду
|
||||
const root = inputItem.json;
|
||||
|
||||
// ✅ ОТЛАДКА: смотрим что пришло
|
||||
console.log('🔍 DEBUG: root keys:', Object.keys(root));
|
||||
console.log('🔍 DEBUG: root.body exists:', !!root.body);
|
||||
console.log('🔍 DEBUG: root.other exists:', !!root.other);
|
||||
|
||||
// 2. Универсально получаем body
|
||||
// - если нода стоит сразу после Webhook → данные лежат в root.body
|
||||
// - если кто-то выше уже отдал только body → root и есть body
|
||||
const body = root.body || root;
|
||||
|
||||
console.log('🔍 DEBUG: body keys:', Object.keys(body));
|
||||
console.log('🔍 DEBUG: body.other exists:', !!body.other);
|
||||
console.log('🔍 DEBUG: body.other type:', typeof body.other);
|
||||
|
||||
// 3. Парсим body.other (если есть) как сессию
|
||||
// ✅ ВАЖНО: Также проверяем root.other напрямую (если данные пришли не через body)
|
||||
let sessionData = {};
|
||||
const rawOther = body.other;
|
||||
let rawOther = body.other || root.other;
|
||||
|
||||
// ✅ Пробуем также достать other из Webhook напрямую
|
||||
if (!rawOther) {
|
||||
try {
|
||||
const webhookJson = $('Webhook').first()?.json;
|
||||
if (webhookJson?.body?.other) {
|
||||
rawOther = webhookJson.body.other;
|
||||
console.log('✅ Взяли other напрямую из Webhook');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('⚠️ Не удалось достать other из Webhook:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🔍 DEBUG: rawOther exists:', !!rawOther);
|
||||
console.log('🔍 DEBUG: rawOther type:', typeof rawOther);
|
||||
if (rawOther) {
|
||||
console.log('🔍 DEBUG: rawOther preview:', typeof rawOther === 'string' ? rawOther.substring(0, 200) : JSON.stringify(rawOther).substring(0, 200));
|
||||
}
|
||||
|
||||
if (rawOther) {
|
||||
if (typeof rawOther === 'string') {
|
||||
try {
|
||||
sessionData = JSON.parse(rawOther);
|
||||
console.log('✅ Распарсили other как JSON. Ключи:', Object.keys(sessionData));
|
||||
console.log('✅ sessionData.session_id:', sessionData.session_id);
|
||||
console.log('✅ sessionData.phone:', sessionData.phone);
|
||||
console.log('✅ sessionData.firstname:', sessionData.firstname);
|
||||
} catch (e) {
|
||||
throw new Error('Не смог распарсить body.other как JSON: ' + e.message + '. rawOther: ' + rawOther);
|
||||
throw new Error('Не смог распарсить other как JSON: ' + e.message + '. rawOther: ' + rawOther.substring(0, 500));
|
||||
}
|
||||
} else if (typeof rawOther === 'object') {
|
||||
sessionData = rawOther;
|
||||
console.log('✅ other уже объект. Ключи:', Object.keys(sessionData));
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ other отсутствует или пустой. Проверьте структуру данных!');
|
||||
console.log('⚠️ root:', JSON.stringify(root).substring(0, 500));
|
||||
}
|
||||
|
||||
// 4. Определяем claimId (основной путь)
|
||||
@@ -94,19 +132,75 @@ if (!projectResult || !projectResult.project_id) {
|
||||
}
|
||||
|
||||
// 8. Собираем обновлённую сессию
|
||||
// ✅ Используем spread оператор, но с фильтрацией undefined значений
|
||||
// Сначала создаём базовый объект из sessionData, фильтруя undefined
|
||||
const baseSession = Object.keys(sessionData).reduce((acc, key) => {
|
||||
if (sessionData[key] !== undefined && sessionData[key] !== null) {
|
||||
acc[key] = sessionData[key];
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
console.log('📦 baseSession после фильтрации:', Object.keys(baseSession));
|
||||
console.log('📦 baseSession sample:', {
|
||||
session_id: baseSession.session_id,
|
||||
phone: baseSession.phone,
|
||||
unified_id: baseSession.unified_id,
|
||||
contact_id: baseSession.contact_id,
|
||||
firstname: baseSession.firstname,
|
||||
lastname: baseSession.lastname,
|
||||
});
|
||||
|
||||
const updatedSession = {
|
||||
...sessionData, // всё, что было в other
|
||||
claim_id: claimId, // актуальный claim_id
|
||||
// ✅ Шаг 1: Все данные из sessionData (body.other) - базовая сессия
|
||||
...baseSession,
|
||||
|
||||
// ✅ Шаг 2: Дополняем данными из body (если их нет в sessionData)
|
||||
...(body.phone && !baseSession.phone ? { phone: body.phone } : {}),
|
||||
...(body.unified_id && !baseSession.unified_id ? { unified_id: body.unified_id } : {}),
|
||||
...(body.contact_id && !baseSession.contact_id ? { contact_id: body.contact_id } : {}),
|
||||
...(body.email && !baseSession.email ? { email: body.email } : {}),
|
||||
|
||||
// ✅ Шаг 3: Данные проекта (новые, всегда перезаписываем)
|
||||
claim_id: claimId, // актуальный claim_id (перезаписываем null из sessionData)
|
||||
project_id: projectResult.project_id, // id проекта из CRM
|
||||
project_name: projectResult.project_name || null, // название проекта из CRM (новое поле)
|
||||
project_name: projectResult.project_name || null, // название проекта из CRM
|
||||
is_new_project: projectResult.is_new, // флаг новый/старый
|
||||
current_step: 2, // двигаем визард на шаг 2
|
||||
|
||||
// ✅ Шаг 4: Данные анализа из body (приоритет body)
|
||||
problem: body.problem || baseSession.problem || null,
|
||||
last_analysis_output: body.output || baseSession.last_analysis_output || null,
|
||||
|
||||
// ✅ Шаг 5: Метаданные (всегда обновляем)
|
||||
updated_at: new Date().toISOString(),
|
||||
// опционально дотащим полезные поля из body:
|
||||
problem: body.problem ?? sessionData.problem,
|
||||
last_analysis_output: body.output ?? sessionData.last_analysis_output,
|
||||
};
|
||||
|
||||
// ✅ Логируем результат для отладки
|
||||
console.log('📦 sessionData keys:', Object.keys(sessionData));
|
||||
console.log('📦 sessionData sample:', {
|
||||
session_id: sessionData.session_id,
|
||||
phone: sessionData.phone,
|
||||
unified_id: sessionData.unified_id,
|
||||
contact_id: sessionData.contact_id,
|
||||
firstname: sessionData.firstname,
|
||||
lastname: sessionData.lastname,
|
||||
middle_name: sessionData.middle_name,
|
||||
});
|
||||
console.log('📦 updatedSession keys:', Object.keys(updatedSession));
|
||||
console.log('📦 updatedSession sample:', {
|
||||
session_id: updatedSession.session_id,
|
||||
phone: updatedSession.phone,
|
||||
unified_id: updatedSession.unified_id,
|
||||
contact_id: updatedSession.contact_id,
|
||||
firstname: updatedSession.firstname,
|
||||
lastname: updatedSession.lastname,
|
||||
middle_name: updatedSession.middle_name,
|
||||
claim_id: updatedSession.claim_id,
|
||||
project_id: updatedSession.project_id,
|
||||
});
|
||||
console.log('📦 updatedSession FULL:', JSON.stringify(updatedSession, null, 2));
|
||||
|
||||
// 9. Возвращаем один item для Redis SET
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -182,3 +182,4 @@ clpr_users (id)
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -37,3 +37,4 @@ return {
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -46,3 +46,4 @@ return {
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
150
ticket_form/docs/N8N_DESCRIPTION_WORKFLOW.md
Normal file
150
ticket_form/docs/N8N_DESCRIPTION_WORKFLOW.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Настройка n8n Workflow для обработки описания проблемы
|
||||
|
||||
## Проблема
|
||||
|
||||
После отправки описания проблемы форма "тупит" на шаге рекомендаций. Это происходит потому, что n8n не обрабатывает событие из Redis канала.
|
||||
|
||||
## Текущий поток данных
|
||||
|
||||
1. **Frontend** отправляет описание на `/api/v1/claims/description`
|
||||
2. **Backend** публикует событие в Redis канал `ticket_form:description`
|
||||
3. **Frontend** подписывается на SSE `/api/v1/events/{session_id}` (слушает канал `ocr_events:{session_id}`)
|
||||
4. **n8n** должен:
|
||||
- Подписаться на канал `ticket_form:description` (или получить событие из него)
|
||||
- Обработать описание и сгенерировать `wizard_plan`
|
||||
- Опубликовать `wizard_plan` в канал `ocr_events:{session_id}` через POST `/api/v1/events/{session_id}`
|
||||
|
||||
## Структура события в Redis канале `ticket_form:description`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ticket_form_description",
|
||||
"session_id": "sess_xxx",
|
||||
"claim_id": "claim_id_xxx" или null,
|
||||
"phone": "79262306381",
|
||||
"email": "user@example.com",
|
||||
"description": "Описание проблемы...",
|
||||
"source": "ticket_form",
|
||||
"timestamp": "2025-11-25T12:30:36.262855"
|
||||
}
|
||||
```
|
||||
|
||||
## Настройка n8n Workflow
|
||||
|
||||
### Шаг 1: Redis Subscribe Node
|
||||
|
||||
1. Добавьте **Redis Subscribe** node
|
||||
2. Настройте подключение к Redis:
|
||||
- Host: `crm.clientright.ru` (или IP вашего Redis)
|
||||
- Port: `6379`
|
||||
- Password: `CRM_Redis_Pass_2025_Secure!`
|
||||
3. Channel: `ticket_form:description`
|
||||
4. Output: `JSON`
|
||||
|
||||
### Шаг 2: Обработка описания
|
||||
|
||||
После получения события из Redis:
|
||||
|
||||
1. Извлеките `session_id` из события: `{{ $json.session_id }}`
|
||||
2. Извлеките `description` из события: `{{ $json.description }}`
|
||||
3. Обработайте описание (AI, RAG и т.д.)
|
||||
4. Сгенерируйте `wizard_plan`
|
||||
|
||||
### Шаг 3: Сохранение wizard_plan в PostgreSQL
|
||||
|
||||
Сохраните `wizard_plan` в таблицу `clpr_claims` используя SQL скрипт (например, `SQL_CLAIMSAVE_UPSERT_SIMPLE.sql`).
|
||||
|
||||
### Шаг 4: Публикация wizard_plan обратно в Redis
|
||||
|
||||
**ВАЖНО:** После генерации `wizard_plan` нужно опубликовать событие обратно в Redis канал `ocr_events:{session_id}`.
|
||||
|
||||
Используйте **HTTP Request** node:
|
||||
|
||||
- **Method:** POST
|
||||
- **URL:** `http://147.45.146.17:8200/api/v1/events/{{ $json.session_id }}`
|
||||
- **Headers:**
|
||||
```json
|
||||
{
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
```
|
||||
- **Body (JSON):**
|
||||
```json
|
||||
{
|
||||
"event_type": "wizard_ready",
|
||||
"status": "ready",
|
||||
"message": "Wizard plan готов",
|
||||
"data": {
|
||||
"claim_id": "{{ $json.claim_id }}",
|
||||
"wizard_plan": {{ $json.wizard_plan }},
|
||||
"answers_prefill": {{ $json.answers_prefill }},
|
||||
"coverage_report": {{ $json.coverage_report }}
|
||||
},
|
||||
"timestamp": "{{ $now.toISO() }}"
|
||||
}
|
||||
```
|
||||
|
||||
**Альтернатива:** Используйте **Redis Publish** node напрямую:
|
||||
|
||||
- Channel: `ocr_events:{{ $json.session_id }}`
|
||||
- Message (JSON):
|
||||
```json
|
||||
{
|
||||
"event_type": "wizard_ready",
|
||||
"status": "ready",
|
||||
"message": "Wizard plan готов",
|
||||
"data": {
|
||||
"claim_id": "{{ $json.claim_id }}",
|
||||
"wizard_plan": {{ $json.wizard_plan }},
|
||||
"answers_prefill": {{ $json.answers_prefill }},
|
||||
"coverage_report": {{ $json.coverage_report }}
|
||||
},
|
||||
"timestamp": "{{ $now.toISO() }}"
|
||||
}
|
||||
```
|
||||
|
||||
## Проверка работы
|
||||
|
||||
1. Откройте консоль браузера (F12)
|
||||
2. Отправьте описание проблемы
|
||||
3. Проверьте логи backend:
|
||||
```bash
|
||||
docker-compose logs -f ticket_form_backend | grep -E "📝|📡|description"
|
||||
```
|
||||
4. Проверьте, что событие опубликовано в Redis:
|
||||
```bash
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PUBSUB CHANNELS "ticket_form:*"
|
||||
```
|
||||
5. Проверьте, что n8n получил событие (в логах n8n workflow)
|
||||
6. Проверьте, что n8n опубликовал `wizard_plan` обратно в канал `ocr_events:{session_id}`
|
||||
|
||||
## Типичные проблемы
|
||||
|
||||
### Проблема 1: n8n не получает события из Redis
|
||||
|
||||
**Решение:** Проверьте, что Redis Subscribe node правильно настроен и подключен к правильному каналу `ticket_form:description`.
|
||||
|
||||
### Проблема 2: Frontend не получает wizard_plan
|
||||
|
||||
**Решение:** Проверьте, что n8n публикует событие в правильный канал `ocr_events:{session_id}` (не `ocr_events:session_id`, а `ocr_events:{session_id}` где `{session_id}` - это значение из события).
|
||||
|
||||
### Проблема 3: Неправильный формат события
|
||||
|
||||
**Решение:** Убедитесь, что событие содержит поле `event_type: "wizard_ready"` и `status: "ready"`. Backend ожидает этот формат.
|
||||
|
||||
## Пример полного workflow в n8n
|
||||
|
||||
```
|
||||
Redis Subscribe (ticket_form:description)
|
||||
↓
|
||||
Code Node (обработка описания)
|
||||
↓
|
||||
AI/RAG Node (генерация wizard_plan)
|
||||
↓
|
||||
PostgreSQL Node (сохранение wizard_plan)
|
||||
↓
|
||||
HTTP Request Node (POST /api/v1/events/{session_id})
|
||||
или
|
||||
Redis Publish Node (ocr_events:{session_id})
|
||||
```
|
||||
|
||||
120
ticket_form/docs/N8N_FORM_APPROVAL_WORKFLOW.md
Normal file
120
ticket_form/docs/N8N_FORM_APPROVAL_WORKFLOW.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Настройка n8n Workflow для обработки подтвержденных форм
|
||||
|
||||
## Описание
|
||||
|
||||
После того, как пользователь подтвердил форму и прошел SMS-верификацию, данные публикуются в Redis канал `clientright:webform:approve`. n8n workflow должен:
|
||||
|
||||
1. Подписаться на Redis канал `clientright:webform:approve`
|
||||
2. Обработать данные формы
|
||||
3. Отметить форму как подтвержденную в PostgreSQL (чтобы она больше не показывалась в черновиках)
|
||||
|
||||
## Структура данных в Redis канале
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "form_approve",
|
||||
"status": "approved",
|
||||
"message": "Форма подтверждена после SMS-верификации",
|
||||
"claim_id": "0eb051ec-23a6-4e06-8b98-f02d20d35f68",
|
||||
"session_token": "sess_xxx",
|
||||
"unified_id": "usr_xxx",
|
||||
"phone": "79262306381",
|
||||
"sms_code": "123456",
|
||||
"sms_verified": true,
|
||||
"idempotency_key": "claim_id_timestamp_user_id",
|
||||
"timestamp": "2025-11-25T12:30:36.262855",
|
||||
"form_data": { /* данные формы */ },
|
||||
"user": { /* данные пользователя */ },
|
||||
"project": { /* данные проекта */ },
|
||||
"offenders": [ /* нарушители */ ],
|
||||
"meta": { /* метаданные */ }
|
||||
}
|
||||
```
|
||||
|
||||
## Настройка n8n Workflow
|
||||
|
||||
### Шаг 1: Redis Subscribe Node
|
||||
|
||||
1. Добавьте **Redis Subscribe** node
|
||||
2. Настройте подключение к Redis:
|
||||
- Host: `crm.clientright.ru` (или IP вашего Redis)
|
||||
- Port: `6379`
|
||||
- Password: `CRM_Redis_Pass_2025_Secure!`
|
||||
3. Channel: `clientright:webform:approve`
|
||||
4. Output: `JSON`
|
||||
|
||||
### Шаг 2: Обработка данных
|
||||
|
||||
После получения данных из Redis канала:
|
||||
|
||||
1. **Parse JSON** (если нужно)
|
||||
2. **Обработайте данные формы** (сохранение в CRM, отправка уведомлений и т.д.)
|
||||
3. **Отметьте форму как подтвержденную** (см. Шаг 3)
|
||||
|
||||
### Шаг 3: Отметка формы как подтвержденной
|
||||
|
||||
Используйте **PostgreSQL** node с SQL скриптом из `SQL_MARK_FORM_APPROVED.sql`:
|
||||
|
||||
```sql
|
||||
-- Используйте claim_id из данных Redis события
|
||||
WITH claim_lookup AS (
|
||||
SELECT
|
||||
c.id,
|
||||
c.payload,
|
||||
c.status_code,
|
||||
c.is_confirmed
|
||||
FROM clpr_claims c
|
||||
WHERE c.id::text = '{{ $json.claim_id }}'::text
|
||||
OR c.payload->>'claim_id' = '{{ $json.claim_id }}'::text
|
||||
ORDER BY
|
||||
CASE WHEN c.id::text = '{{ $json.claim_id }}'::text THEN 1 ELSE 2 END,
|
||||
c.updated_at DESC
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE clpr_claims c
|
||||
SET
|
||||
status_code = 'approved',
|
||||
is_confirmed = true,
|
||||
updated_at = now()
|
||||
FROM claim_lookup cl
|
||||
WHERE c.id = cl.id
|
||||
RETURNING
|
||||
c.id,
|
||||
c.payload->>'claim_id' AS claim_id,
|
||||
c.status_code,
|
||||
c.is_confirmed,
|
||||
c.updated_at;
|
||||
```
|
||||
|
||||
**Параметры:**
|
||||
- `{{ $json.claim_id }}` - claim_id из данных Redis события
|
||||
|
||||
**Результат:**
|
||||
- Форма помечается как `status_code = 'approved'`
|
||||
- Устанавливается `is_confirmed = true`
|
||||
- Форма больше не будет показываться в списке черновиков (`/api/v1/claims/drafts/list`)
|
||||
|
||||
## Проверка работы
|
||||
|
||||
После обработки события в n8n:
|
||||
|
||||
1. Проверьте, что запись в `clpr_claims` обновлена:
|
||||
```sql
|
||||
SELECT id, status_code, is_confirmed, updated_at
|
||||
FROM clpr_claims
|
||||
WHERE payload->>'claim_id' = 'YOUR_CLAIM_ID';
|
||||
```
|
||||
|
||||
2. Проверьте, что форма не показывается в черновиках:
|
||||
```bash
|
||||
curl "http://localhost:8200/api/v1/claims/drafts/list?unified_id=YOUR_UNIFIED_ID"
|
||||
```
|
||||
|
||||
## Важные поля из Redis события
|
||||
|
||||
- `claim_id` - ID заявки (используется для обновления статуса)
|
||||
- `sms_code` - SMS код, использованный для верификации (для аудита)
|
||||
- `form_data` - данные формы подтверждения
|
||||
- `user`, `project`, `offenders` - структурированные данные формы
|
||||
- `idempotency_key` - ключ для защиты от дублей (для будущей интеграции с RabbitMQ)
|
||||
|
||||
@@ -259,3 +259,4 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -399,3 +399,4 @@ IF "проверка наличия файлов"
|
||||
**Дата:** 2025-11-21
|
||||
**Статус:** Готово к внедрению ✅
|
||||
|
||||
|
||||
|
||||
225
ticket_form/docs/N8N_MEMORY_ISSUES.md
Normal file
225
ticket_form/docs/N8N_MEMORY_ISSUES.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# 🐛 Проблемы с памятью в n8n
|
||||
|
||||
## 🔍 Симптомы
|
||||
|
||||
- UI n8n не отвечает (нельзя сохранить workflow, включить/выключить)
|
||||
- Workflow не обрабатывает события
|
||||
- Страница зависает при попытке редактирования
|
||||
- Требуется перезагрузка сервера для восстановления
|
||||
|
||||
## 💾 Возможные причины
|
||||
|
||||
### 1. **Переполнение памяти (OOM)**
|
||||
- n8n процесс исчерпал доступную память
|
||||
- Система убивает процесс (OOM Killer)
|
||||
- Или процесс зависает в ожидании освобождения памяти
|
||||
|
||||
**Диагностика:**
|
||||
```bash
|
||||
# Проверка использования памяти n8n
|
||||
docker stats n8n_container --no-stream
|
||||
|
||||
# Проверка логов OOM Killer
|
||||
dmesg | grep -i "out of memory"
|
||||
dmesg | grep -i "killed process"
|
||||
|
||||
# Проверка использования памяти системой
|
||||
free -h
|
||||
```
|
||||
|
||||
### 2. **Утечки памяти в workflow**
|
||||
- Workflow накапливает данные в памяти
|
||||
- Большие массивы данных не освобождаются
|
||||
- Долгие операции держат данные в памяти
|
||||
|
||||
**Диагностика:**
|
||||
- Проверить Execution History - сколько данных хранится
|
||||
- Проверить размер данных в workflow (большие JSON объекты)
|
||||
- Проверить количество активных executions
|
||||
|
||||
### 3. **Слишком много активных workflows**
|
||||
- Много workflows работают одновременно
|
||||
- Каждый workflow держит соединения и данные в памяти
|
||||
- Redis Trigger для каждого workflow = отдельное соединение
|
||||
|
||||
**Диагностика:**
|
||||
```bash
|
||||
# Количество активных workflows (через n8n API или БД)
|
||||
# Проверить количество Redis подписок
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" CLIENT LIST | grep -c "SUBSCRIBE"
|
||||
```
|
||||
|
||||
### 4. **Большие данные в workflow**
|
||||
- Workflow обрабатывает большие файлы/JSON
|
||||
- Данные хранятся в памяти между нодами
|
||||
- Нет очистки промежуточных данных
|
||||
|
||||
**Диагностика:**
|
||||
- Проверить размер данных в Execution History
|
||||
- Проверить размер JSON payload между нодами
|
||||
- Проверить использование диска для execution data
|
||||
|
||||
### 5. **Проблемы с базой данных n8n**
|
||||
- База данных n8n переполнена старыми executions
|
||||
- Медленные запросы блокируют работу
|
||||
- Блокировки таблиц
|
||||
|
||||
**Диагностика:**
|
||||
```bash
|
||||
# Размер базы данных n8n
|
||||
# Проверить количество executions
|
||||
# Проверить медленные запросы
|
||||
```
|
||||
|
||||
## 🛠️ Решения
|
||||
|
||||
### 1. **Ограничить использование памяти**
|
||||
|
||||
В `docker-compose.yml` для n8n:
|
||||
```yaml
|
||||
services:
|
||||
n8n:
|
||||
mem_limit: 2g # Ограничить память до 2GB
|
||||
mem_reservation: 1g # Резервировать минимум 1GB
|
||||
oom_kill_disable: false # Разрешить OOM Killer убивать процесс
|
||||
```
|
||||
|
||||
Или через переменные окружения:
|
||||
```bash
|
||||
NODE_OPTIONS="--max-old-space-size=1536" # Ограничить heap до 1.5GB
|
||||
```
|
||||
|
||||
### 2. **Очистить старые executions**
|
||||
|
||||
Настроить автоматическую очистку в n8n:
|
||||
- Settings → Workflows → Execution Data Retention
|
||||
- Установить срок хранения (например, 7 дней)
|
||||
- Включить автоматическую очистку
|
||||
|
||||
Или через SQL (если используете PostgreSQL):
|
||||
```sql
|
||||
-- Удалить executions старше 7 дней
|
||||
DELETE FROM execution_entity
|
||||
WHERE "stoppedAt" < NOW() - INTERVAL '7 days';
|
||||
|
||||
-- Удалить execution_data для удалённых executions
|
||||
DELETE FROM execution_data
|
||||
WHERE "executionId" NOT IN (SELECT id FROM execution_entity);
|
||||
```
|
||||
|
||||
### 3. **Оптимизировать workflow**
|
||||
|
||||
- **Не хранить большие данные между нодами**
|
||||
- Использовать `Set` node для очистки ненужных полей
|
||||
- Не передавать большие файлы через workflow data
|
||||
|
||||
- **Использовать streaming для больших данных**
|
||||
- Обрабатывать данные порциями
|
||||
- Не загружать всё в память сразу
|
||||
|
||||
- **Ограничить размер данных в Redis Trigger**
|
||||
- Проверять размер сообщения перед обработкой
|
||||
- Отклонять слишком большие сообщения
|
||||
|
||||
### 4. **Мониторинг памяти**
|
||||
|
||||
Создать скрипт для мониторинга:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# monitor_n8n_memory.sh
|
||||
|
||||
CONTAINER="n8n_container"
|
||||
THRESHOLD=80 # Процент использования памяти
|
||||
|
||||
MEMORY_USAGE=$(docker stats $CONTAINER --no-stream --format "{{.MemPerc}}" | sed 's/%//')
|
||||
|
||||
if (( $(echo "$MEMORY_USAGE > $THRESHOLD" | bc -l) )); then
|
||||
echo "⚠️ ВНИМАНИЕ: n8n использует ${MEMORY_USAGE}% памяти!"
|
||||
# Можно добавить отправку алерта
|
||||
fi
|
||||
```
|
||||
|
||||
### 5. **Настроить swap**
|
||||
|
||||
Если сервер имеет swap, убедиться что он настроен:
|
||||
```bash
|
||||
# Проверить swap
|
||||
swapon --show
|
||||
|
||||
# Если нет swap, создать (осторожно - может замедлить работу)
|
||||
sudo fallocate -l 2G /swapfile
|
||||
sudo chmod 600 /swapfile
|
||||
sudo mkswap /swapfile
|
||||
sudo swapon /swapfile
|
||||
```
|
||||
|
||||
### 6. **Ограничить количество активных workflows**
|
||||
|
||||
- Отключить неиспользуемые workflows
|
||||
- Использовать один workflow вместо нескольких для похожих задач
|
||||
- Разделить сложные workflows на несколько простых
|
||||
|
||||
### 7. **Оптимизировать Redis Trigger**
|
||||
|
||||
- Использовать один Redis Trigger для нескольких каналов (если возможно)
|
||||
- Ограничить количество одновременных подписок
|
||||
- Использовать Redis Streams вместо Pub/Sub для больших объёмов данных
|
||||
|
||||
## 📊 Диагностика после перезагрузки
|
||||
|
||||
После перезагрузки сервера проверить:
|
||||
|
||||
```bash
|
||||
# 1. Использование памяти n8n
|
||||
docker stats n8n_container --no-stream
|
||||
|
||||
# 2. Логи n8n на ошибки памяти
|
||||
docker logs n8n_container 2>&1 | grep -i "memory\|oom\|heap"
|
||||
|
||||
# 3. Системные логи OOM Killer
|
||||
dmesg | grep -i "out of memory" | tail -20
|
||||
|
||||
# 4. Использование памяти системой
|
||||
free -h
|
||||
|
||||
# 5. Топ процессов по использованию памяти
|
||||
ps aux --sort=-%mem | head -10
|
||||
```
|
||||
|
||||
## 🔄 Профилактика
|
||||
|
||||
1. **Регулярная очистка executions**
|
||||
- Настроить автоматическую очистку старых данных
|
||||
- Ограничить срок хранения execution data
|
||||
|
||||
2. **Мониторинг ресурсов**
|
||||
- Настроить алерты при высоком использовании памяти
|
||||
- Регулярно проверять использование ресурсов
|
||||
|
||||
3. **Оптимизация workflows**
|
||||
- Избегать хранения больших данных в памяти
|
||||
- Использовать streaming для больших файлов
|
||||
- Очищать промежуточные данные
|
||||
|
||||
4. **Ограничения ресурсов**
|
||||
- Установить лимиты памяти для n8n контейнера
|
||||
- Настроить OOM Killer для корректной обработки
|
||||
|
||||
5. **Резервирование**
|
||||
- Рассмотреть использование нескольких инстансов n8n
|
||||
- Использовать load balancer для распределения нагрузки
|
||||
|
||||
## 📝 Рекомендации для продакшена
|
||||
|
||||
1. **Мониторинг**: Настроить Prometheus/Grafana для мониторинга памяти
|
||||
2. **Алерты**: Настроить уведомления при превышении порога памяти
|
||||
3. **Автоматическая очистка**: Настроить cron для очистки старых executions
|
||||
4. **Лимиты**: Установить жёсткие лимиты памяти для n8n
|
||||
5. **Логирование**: Включить детальное логирование использования памяти
|
||||
|
||||
## 🔗 Полезные ссылки
|
||||
|
||||
- [n8n Memory Management](https://docs.n8n.io/hosting/configuration/environment-variables/#memory-management)
|
||||
- [Docker Memory Limits](https://docs.docker.com/config/containers/resource_constraints/#memory)
|
||||
- [Node.js Memory Management](https://nodejs.org/api/cli.html#--max-old-space-sizesize-in-megabytes)
|
||||
|
||||
167
ticket_form/docs/N8N_REDIS_TRIGGER_TROUBLESHOOTING.md
Normal file
167
ticket_form/docs/N8N_REDIS_TRIGGER_TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# 🔧 Troubleshooting: Redis Trigger в n8n зависает
|
||||
|
||||
## 🐛 Проблема
|
||||
|
||||
Redis Trigger в n8n перестаёт слушать канал `ticket_form:description`, хотя workflow активен.
|
||||
|
||||
## 🔍 Возможные причины
|
||||
|
||||
### 1. **Потеря соединения с Redis**
|
||||
- Соединение оборвалось из-за сетевых проблем
|
||||
- Redis перезапустился, но n8n не переподключился
|
||||
- Таймаут соединения
|
||||
|
||||
**Решение:**
|
||||
- Проверить логи n8n на ошибки подключения
|
||||
- Убедиться, что Redis доступен: `redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PING`
|
||||
- Перезапустить workflow в n8n (отключить → включить)
|
||||
|
||||
### 2. **Проблемы с памятью/ресурсами**
|
||||
- n8n исчерпал память
|
||||
- Слишком много активных workflows
|
||||
|
||||
**Решение:**
|
||||
- Проверить использование памяти: `docker stats n8n_container`
|
||||
- Увеличить лимиты памяти для n8n
|
||||
- Перезапустить n8n контейнер
|
||||
|
||||
### 3. **Долгие операции в workflow**
|
||||
- Workflow обрабатывает сообщение слишком долго
|
||||
- Блокирует обработку новых сообщений
|
||||
|
||||
**Решение:**
|
||||
- Оптимизировать workflow (убрать долгие операции)
|
||||
- Использовать асинхронную обработку
|
||||
- Разбить workflow на несколько этапов
|
||||
|
||||
### 4. **Проблемы с сетью**
|
||||
- Временные сбои сети между n8n и Redis
|
||||
- Firewall блокирует соединение
|
||||
|
||||
**Решение:**
|
||||
- Проверить сетевую связность: `ping crm.clientright.ru`
|
||||
- Проверить firewall правила
|
||||
- Использовать retry-логику в workflow
|
||||
|
||||
## 🛠️ Решения для предотвращения
|
||||
|
||||
### 1. **Мониторинг подписчиков**
|
||||
|
||||
Запустить скрипт мониторинга:
|
||||
```bash
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
python3 monitor_n8n_redis_trigger.py
|
||||
```
|
||||
|
||||
Или добавить в cron для автоматической проверки:
|
||||
```bash
|
||||
# Проверка каждые 5 минут
|
||||
*/5 * * * * cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form && python3 monitor_n8n_redis_trigger.py >> logs/n8n_monitor_cron.log 2>&1
|
||||
```
|
||||
|
||||
### 2. **Health Check для Redis Trigger**
|
||||
|
||||
Добавить в workflow n8n:
|
||||
- **Schedule Trigger** (каждые 5 минут)
|
||||
- **Redis Publish** (отправить тестовое сообщение)
|
||||
- **If Node** (проверить, обработалось ли сообщение)
|
||||
- **Send Alert** (если нет - отправить уведомление)
|
||||
|
||||
### 3. **Автоматический перезапуск workflow**
|
||||
|
||||
Создать скрипт для автоматического перезапуска:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Проверка и перезапуск workflow если нет подписчиков
|
||||
|
||||
SUBS=$(redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PUBSUB NUMSUB "ticket_form:description" | tail -1)
|
||||
|
||||
if [ "$SUBS" -eq "0" ]; then
|
||||
echo "⚠️ Нет подписчиков! Требуется перезапуск workflow"
|
||||
# Здесь можно добавить API вызов для перезапуска workflow через n8n API
|
||||
fi
|
||||
```
|
||||
|
||||
### 4. **Настройка Redis для стабильности**
|
||||
|
||||
В `redis.conf`:
|
||||
```conf
|
||||
# Таймаут для неактивных соединений (0 = отключить)
|
||||
timeout 0
|
||||
|
||||
# Keepalive для TCP соединений
|
||||
tcp-keepalive 60
|
||||
|
||||
# Максимальное количество клиентов
|
||||
maxclients 10000
|
||||
```
|
||||
|
||||
### 5. **Логирование в n8n**
|
||||
|
||||
Включить детальное логирование для Redis Trigger:
|
||||
- Settings → Logging → Level: `debug`
|
||||
- Проверить логи на ошибки подключения
|
||||
|
||||
## 📊 Диагностика
|
||||
|
||||
### Проверка подписчиков
|
||||
```bash
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PUBSUB NUMSUB "ticket_form:description"
|
||||
```
|
||||
|
||||
### Проверка подключения n8n к Redis
|
||||
```bash
|
||||
# Из контейнера n8n
|
||||
docker exec -it n8n_container redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PING
|
||||
```
|
||||
|
||||
### Тестовая публикация
|
||||
```bash
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" \
|
||||
PUBLISH "ticket_form:description" '{"type":"test","session_id":"test123"}'
|
||||
```
|
||||
|
||||
### Проверка логов n8n
|
||||
```bash
|
||||
docker logs n8n_container | grep -i redis
|
||||
docker logs n8n_container | grep -i "ticket_form:description"
|
||||
```
|
||||
|
||||
## ✅ Быстрое решение
|
||||
|
||||
Если workflow завис:
|
||||
|
||||
1. **Отключить workflow** в n8n (кнопка "Active")
|
||||
2. **Сохранить** изменения
|
||||
3. **Включить обратно** (кнопка "Active")
|
||||
4. **Проверить подписчиков**: `PUBSUB NUMSUB "ticket_form:description"`
|
||||
|
||||
Если не помогло:
|
||||
|
||||
1. **Перезапустить n8n контейнер**:
|
||||
```bash
|
||||
docker restart n8n_container
|
||||
```
|
||||
|
||||
2. **Проверить Redis**:
|
||||
```bash
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PING
|
||||
```
|
||||
|
||||
3. **Проверить сеть** между n8n и Redis
|
||||
|
||||
## 🔄 Рекомендации для продакшена
|
||||
|
||||
1. **Мониторинг**: Настроить автоматический мониторинг подписчиков
|
||||
2. **Алерты**: Настроить уведомления при отсутствии подписчиков
|
||||
3. **Health Checks**: Регулярные проверки работоспособности
|
||||
4. **Логирование**: Детальное логирование всех операций с Redis
|
||||
5. **Резервирование**: Рассмотреть использование Redis Sentinel для высокой доступности
|
||||
|
||||
## 📝 Логи для анализа
|
||||
|
||||
Проверить логи:
|
||||
- `/var/www/fastuser/data/www/crm.clientright.ru/ticket_form/logs/n8n_redis_monitor.log` - мониторинг
|
||||
- `docker logs n8n_container` - логи n8n
|
||||
- `/var/www/fastuser/data/www/crm.clientright.ru/ticket_form/backend/logs/` - логи backend
|
||||
|
||||
@@ -93,3 +93,4 @@ updateFormData({
|
||||
5. **Response** → возвращает полный ответ с `unified_id`
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -143,3 +143,4 @@ return {
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -132,3 +132,4 @@ WHERE ua.channel = 'web_form'
|
||||
Должна быть запись с `unified_id` в формате `usr_...`.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -430,3 +430,4 @@ return claim;
|
||||
- ✅ Быстрая загрузка состояния формы
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -190,3 +190,4 @@ if (channel === 'telegram') {
|
||||
Все данные уже в PostgreSQL, и этого достаточно. Redis используется только для Pub/Sub событий (`ocr_events:{claim_id}`).
|
||||
|
||||
|
||||
|
||||
|
||||
163
ticket_form/docs/REDIS_FORM_APPROVE.md
Normal file
163
ticket_form/docs/REDIS_FORM_APPROVE.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# Redis канал для подтверждения формы (form_approve)
|
||||
|
||||
## 📋 Описание
|
||||
|
||||
После SMS-апрува данные подтвержденной формы публикуются в Redis канал `clientright:webform:approve` для обработки в n8n workflow.
|
||||
|
||||
## 🔄 Архитектура
|
||||
|
||||
```
|
||||
Frontend (StepClaimConfirmation)
|
||||
→ POST /api/v1/claims/approve
|
||||
→ Backend публикует в Redis канал clientright:webform:approve
|
||||
→ n8n подписывается на канал и обрабатывает данные
|
||||
```
|
||||
|
||||
## 📡 Endpoint
|
||||
|
||||
**POST** `/api/v1/claims/approve`
|
||||
|
||||
### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"claim_id": "0eb051ec-23a6-4e06-8b98-f02d20d35f68",
|
||||
"session_token": "sess_c9e7c0c2-de2e-40cd-ab7c-3bdc40282d34",
|
||||
"session_id": "sess_c9e7c0c2-de2e-40cd-ab7c-3bdc40282d34",
|
||||
"unified_id": "usr_90599ff2-ac79-4236-b950-0df85395096c",
|
||||
"user_id": "user_123",
|
||||
"phone": "79262306381",
|
||||
"form_data": {
|
||||
"user": {...},
|
||||
"project": {...},
|
||||
"offenders": [...],
|
||||
"meta": {...}
|
||||
},
|
||||
"user": {...},
|
||||
"project": {...},
|
||||
"offenders": [...],
|
||||
"meta": {...},
|
||||
"original_data": {...}
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"channel": "clientright:webform:approve",
|
||||
"idempotency_key": "0eb051ec-23a6-4e06-8b98-f02d20d35f68_1735123456789_user_123",
|
||||
"message": "Данные формы отправлены на обработку"
|
||||
}
|
||||
```
|
||||
|
||||
## 📢 Redis канал
|
||||
|
||||
**Канал:** `clientright:webform:approve`
|
||||
|
||||
**Формат сообщения:**
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "form_approve",
|
||||
"status": "approved",
|
||||
"message": "Форма подтверждена после SMS-верификации",
|
||||
"claim_id": "0eb051ec-23a6-4e06-8b98-f02d20d35f68",
|
||||
"session_token": "sess_c9e7c0c2-de2e-40cd-ab7c-3bdc40282d34",
|
||||
"unified_id": "usr_90599ff2-ac79-4236-b950-0df85395096c",
|
||||
"phone": "79262306381",
|
||||
"sms_verified": true,
|
||||
"idempotency_key": "0eb051ec-23a6-4e06-8b98-f02d20d35f68_1735123456789_user_123",
|
||||
"timestamp": "2025-11-24T14:15:26.03297+03:00",
|
||||
"form_data": {...},
|
||||
"user": {...},
|
||||
"project": {...},
|
||||
"offenders": [...],
|
||||
"meta": {...},
|
||||
"original_data": {...}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔐 Idempotency Key
|
||||
|
||||
Для защиты от дублей генерируется `idempotency_key`:
|
||||
```
|
||||
{claim_id}_{timestamp_ms}_{user_id}
|
||||
```
|
||||
|
||||
Этот ключ можно использовать в будущем для интеграции с RabbitMQ:
|
||||
- Проверка дублей перед обработкой
|
||||
- Дедупликация в очереди
|
||||
- Гарантия идемпотентности
|
||||
|
||||
## 🚀 Настройка n8n
|
||||
|
||||
### 1. Redis Subscribe Node
|
||||
|
||||
**Operation:** `Subscribe`
|
||||
**Channel:** `clientright:webform:approve`
|
||||
|
||||
### 2. Обработка события
|
||||
|
||||
После получения события из Redis:
|
||||
1. Проверить `idempotency_key` (для защиты от дублей)
|
||||
2. Обработать данные формы
|
||||
3. Сохранить в БД через SQL запрос
|
||||
4. Отправить уведомления (если нужно)
|
||||
|
||||
### 3. Пример workflow
|
||||
|
||||
```
|
||||
[Redis Subscribe] → [Check Idempotency] → [Process Form Data] → [Save to DB] → [Send Notifications]
|
||||
```
|
||||
|
||||
## 🔮 Будущая интеграция с RabbitMQ
|
||||
|
||||
При необходимости можно подключить RabbitMQ для:
|
||||
- **Очереди:** Гарантированная обработка всех событий
|
||||
- **Защита от дублей:** Проверка `idempotency_key` перед добавлением в очередь
|
||||
- **Retry механизм:** Автоматические повторы при ошибках
|
||||
- **Масштабирование:** Несколько воркеров для обработки
|
||||
|
||||
### Структура для RabbitMQ
|
||||
|
||||
```json
|
||||
{
|
||||
"queue": "form_approve",
|
||||
"message": {
|
||||
"idempotency_key": "...",
|
||||
"claim_id": "...",
|
||||
"data": {...}
|
||||
},
|
||||
"headers": {
|
||||
"idempotency-key": "...",
|
||||
"retry-count": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 Мониторинг
|
||||
|
||||
### Проверка канала в Redis
|
||||
|
||||
```bash
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a 'CRM_Redis_Pass_2025_Secure!' \
|
||||
MONITOR | grep clientright:webform:approve
|
||||
```
|
||||
|
||||
### Подписка на канал (тест)
|
||||
|
||||
```bash
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a 'CRM_Redis_Pass_2025_Secure!' \
|
||||
SUBSCRIBE clientright:webform:approve
|
||||
```
|
||||
|
||||
## ✅ Преимущества
|
||||
|
||||
1. **Высокая производительность:** Redis Pub/Sub очень быстрый
|
||||
2. **Не блокирует фронтенд:** Fire-and-forget подход
|
||||
3. **Масштабируемость:** Можно добавить несколько подписчиков
|
||||
4. **Готовность к RabbitMQ:** Idempotency key уже включен
|
||||
5. **Простота отладки:** Можно мониторить через Redis MONITOR
|
||||
|
||||
@@ -197,3 +197,4 @@ if (channel === 'web_form' && enable_cache === true) {
|
||||
Но это опционально и не обязательно для веб-формы.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -71,3 +71,4 @@
|
||||
4. Если SQL работает, но asyncpg не возвращает данные - проверить формат параметров
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -113,3 +113,4 @@ final_claim_id = row.get('claim_id') or claim_id_from_payload
|
||||
3. Убедиться, что все данные корректно восстанавливаются в форму
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -130,3 +130,4 @@ WITH existing AS (
|
||||
3. При создании/обновлении черновика заполнять `clpr_claims.unified_id = unified_id`
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -210,3 +210,4 @@ SELECT
|
||||
- ✅ Правильное слияние `answers` и `documents_meta`
|
||||
|
||||
|
||||
|
||||
|
||||
27
ticket_form/docs/n8n_code_error_response.js
Normal file
27
ticket_form/docs/n8n_code_error_response.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// Code23 — помещаем в n8n-nodes-base.code (JS), Mode = Run Once for All Items
|
||||
|
||||
// Берём все входные элементы
|
||||
const items = $input.all();
|
||||
|
||||
// Предполагаем, что нас интересует первый элемент массива
|
||||
const data = items[0].json;
|
||||
|
||||
// Всегда возвращаем сообщение об ошибке
|
||||
const answerText = 'Извините, произошла ошибка, мы уже работаем над ее устранением, попробуйте задать ваш вопрос еще раз через некоторое время';
|
||||
|
||||
// Собираем единый объект для следующего узла
|
||||
return [
|
||||
{
|
||||
json: {
|
||||
...data,
|
||||
respound: {
|
||||
type: 'text',
|
||||
text: answerText,
|
||||
replyMarkup: {
|
||||
remove_keyboard: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -112,3 +112,4 @@
|
||||
Выполни задачу прямо сейчас и верни JSON согласно схеме.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Card, Spin, message, Modal, Input, Button, Form } from 'antd';
|
||||
import { generateConfirmationFormHTML } from './generateConfirmationFormHTML';
|
||||
|
||||
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>('');
|
||||
|
||||
// SMS Approval state
|
||||
const [smsModalVisible, setSmsModalVisible] = useState(false);
|
||||
const [smsCodeSent, setSmsCodeSent] = useState(false);
|
||||
const [smsLoading, setSmsLoading] = useState(false);
|
||||
const [smsVerifyLoading, setSmsVerifyLoading] = useState(false);
|
||||
const [pendingFormData, setPendingFormData] = useState<any>(null);
|
||||
const [smsForm] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
if (!claimPlanData) {
|
||||
message.error('Данные заявления не получены');
|
||||
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);
|
||||
|
||||
// Формируем данные для формы подтверждения
|
||||
// Формат должен соответствовать тому, что ожидает 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 });
|
||||
|
||||
// Преобразуем данные из propertyName в формат для формы
|
||||
const applicant = claimPlanData?.propertyName?.applicant || {};
|
||||
const caseData = claimPlanData?.propertyName?.case || {};
|
||||
const contract = claimPlanData?.propertyName?.contract_or_service || {};
|
||||
const claimData = claimPlanData?.propertyName?.claim || {};
|
||||
const offenders = claimPlanData?.propertyName?.offenders || [];
|
||||
|
||||
// Передаем данные в формате propertyName, чтобы функция normalizeData могла их правильно обработать
|
||||
const formData = {
|
||||
propertyName: claimPlanData?.propertyName || {
|
||||
applicant: applicant,
|
||||
case: caseData,
|
||||
contract_or_service: contract,
|
||||
claim: claimData,
|
||||
offenders: offenders,
|
||||
meta: {
|
||||
...claimPlanData?.propertyName?.meta,
|
||||
claim_id: claimId,
|
||||
unified_id: unifiedId,
|
||||
user_id: claimPlanData?.user_id || claimPlanData?.propertyName?.meta?.user_id || '',
|
||||
},
|
||||
attachments_names: claimPlanData?.propertyName?.attachments_names || [],
|
||||
},
|
||||
session_token: claimPlanData?.session_token || '',
|
||||
telegram_id: claimPlanData?.telegram_id || '',
|
||||
prefix: claimPlanData?.prefix || '',
|
||||
claim_id: claimId,
|
||||
token: claimPlanData?.token || '',
|
||||
sms_meta: {
|
||||
session_token: claimPlanData?.session_token || '',
|
||||
prefix: claimPlanData?.prefix || '',
|
||||
telegram_id: claimPlanData?.telegram_id || '',
|
||||
claim_id: claimId,
|
||||
unified_id: unifiedId,
|
||||
user_id: claimPlanData?.user_id || claimPlanData?.propertyName?.meta?.user_id || '',
|
||||
},
|
||||
};
|
||||
|
||||
console.log('📋 Сформированные formData:', formData);
|
||||
console.log('📋 formData.propertyName:', formData.propertyName);
|
||||
console.log('📋 formData.propertyName?.meta:', formData.propertyName?.meta);
|
||||
|
||||
// Генерируем HTML форму здесь, на нашей стороне
|
||||
const html = generateConfirmationFormHTML(formData);
|
||||
setHtmlContent(html);
|
||||
setLoading(false);
|
||||
}, [claimPlanData]);
|
||||
|
||||
// Функция сохранения данных формы - публикация в Redis канал
|
||||
// ⚠️ ВАЖНО: Эта функция должна вызываться ТОЛЬКО после SMS-верификации!
|
||||
const saveFormData = useCallback(async (formData: any, smsCode?: string) => {
|
||||
console.log('💾 Публикуем данные формы в Redis канал:', formData);
|
||||
console.log('📱 SMS код для публикации:', smsCode || '(не передан)');
|
||||
|
||||
// Защита: если SMS код не передан, это ошибка (данные не должны отправляться без верификации)
|
||||
if (!smsCode || smsCode.trim() === '') {
|
||||
console.error('❌ ОШИБКА: saveFormData вызван БЕЗ SMS кода! Данные не должны отправляться без верификации.');
|
||||
message.error('Ошибка: данные не могут быть отправлены без SMS-верификации');
|
||||
return;
|
||||
}
|
||||
|
||||
// Получаем данные из claimPlanData для формирования payload
|
||||
const claimId = claimPlanData?.claim_id || claimPlanData?.propertyName?.meta?.claim_id || '';
|
||||
const unifiedId = claimPlanData?.unified_id || claimPlanData?.propertyName?.meta?.unified_id || '';
|
||||
const sessionToken = claimPlanData?.session_token || '';
|
||||
const userId = claimPlanData?.user_id || claimPlanData?.propertyName?.meta?.user_id || '';
|
||||
const phone = claimPlanData?.propertyName?.applicant?.phone ||
|
||||
claimPlanData?.propertyName?.user?.mobile ||
|
||||
claimPlanData?.phone || '';
|
||||
|
||||
// Формируем payload для Redis канала
|
||||
const payload = {
|
||||
claim_id: claimId,
|
||||
session_token: sessionToken,
|
||||
session_id: sessionToken,
|
||||
unified_id: unifiedId,
|
||||
user_id: userId,
|
||||
phone: phone,
|
||||
sms_code: smsCode || '', // SMS код для верификации
|
||||
|
||||
// Данные формы подтверждения
|
||||
form_data: formData,
|
||||
user: formData?.user || {},
|
||||
project: formData?.project || {},
|
||||
offenders: formData?.offenders || [],
|
||||
meta: formData?.meta || {},
|
||||
|
||||
// Оригинальные данные для сравнения (если есть)
|
||||
original_data: formData?.originalData || {},
|
||||
};
|
||||
|
||||
console.log('📦 Payload для Redis:', { ...payload, sms_code: smsCode ? '***' : '(пусто)' });
|
||||
|
||||
// Публикуем в Redis канал через backend endpoint (fire-and-forget)
|
||||
// Канал: clientright:webform:approve
|
||||
fetch('/api/v1/claims/approve', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
keepalive: true, // Продолжить отправку даже если страница закрывается
|
||||
}).catch((error) => {
|
||||
// Тихо логируем ошибки, но не блокируем пользователя
|
||||
console.error('Ошибка публикации данных формы в Redis:', error);
|
||||
});
|
||||
|
||||
console.log('✅ Данные формы опубликованы в Redis канал clientright:webform:approve');
|
||||
}, [claimPlanData]);
|
||||
|
||||
// Функция отправки SMS-кода
|
||||
const sendSMSCode = useCallback(async (phone: string) => {
|
||||
try {
|
||||
setSmsLoading(true);
|
||||
// SMS API ожидает телефон в формате +79001234567
|
||||
const phoneWithPlus = phone.startsWith('+') ? phone : `+${phone}`;
|
||||
|
||||
const response = await fetch('/api/v1/sms/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phone: phoneWithPlus }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
message.success('Код отправлен на ваш телефон');
|
||||
setSmsCodeSent(true);
|
||||
if (result.debug_code) {
|
||||
message.info(`DEBUG: Код ${result.debug_code}`);
|
||||
}
|
||||
} else {
|
||||
message.error(result.detail || 'Ошибка отправки кода');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('Ошибка соединения с сервером');
|
||||
} finally {
|
||||
setSmsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Функция проверки SMS-кода
|
||||
const verifySMSCode = useCallback(async (phone: string, code: string) => {
|
||||
try {
|
||||
setSmsVerifyLoading(true);
|
||||
// SMS API ожидает телефон в формате +79001234567
|
||||
const phoneWithPlus = phone.startsWith('+') ? phone : `+${phone}`;
|
||||
|
||||
const response = await fetch('/api/v1/sms/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phone: phoneWithPlus, code }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
message.success('Код подтвержден!');
|
||||
console.log('✅ SMS код успешно проверен:', code);
|
||||
|
||||
// Закрываем модалку
|
||||
setSmsModalVisible(false);
|
||||
setSmsCodeSent(false);
|
||||
smsForm.resetFields();
|
||||
|
||||
// Отправляем данные в Redis канал с SMS кодом
|
||||
console.log('📤 Вызываем saveFormData с SMS кодом:', code);
|
||||
saveFormData(pendingFormData, code);
|
||||
|
||||
// Показываем сообщение об успешной отправке
|
||||
message.success('Ваше заявление отправлено!');
|
||||
|
||||
// Переходим дальше
|
||||
onNext();
|
||||
} else {
|
||||
message.error(result.detail || 'Неверный код');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('Ошибка соединения с сервером');
|
||||
} finally {
|
||||
setSmsVerifyLoading(false);
|
||||
}
|
||||
}, [pendingFormData, saveFormData, smsForm, onNext]);
|
||||
|
||||
useEffect(() => {
|
||||
// Слушаем сообщения от iframe
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
console.log('📨 Message from iframe:', event.data);
|
||||
|
||||
if (event.data.type === 'claim_confirmed') {
|
||||
console.log('✅ Заявление подтверждено с данными:', event.data.data);
|
||||
|
||||
// Сохраняем данные формы для последующего сохранения после SMS-апрува
|
||||
setPendingFormData(event.data.data);
|
||||
|
||||
// Получаем телефон пользователя для отправки SMS
|
||||
const phone =
|
||||
claimPlanData?.propertyName?.applicant?.phone ||
|
||||
claimPlanData?.propertyName?.user?.mobile ||
|
||||
claimPlanData?.phone ||
|
||||
'';
|
||||
|
||||
if (!phone) {
|
||||
message.error('Не удалось определить номер телефона для подтверждения');
|
||||
return;
|
||||
}
|
||||
|
||||
// Показываем модалку SMS-апрува
|
||||
setSmsModalVisible(true);
|
||||
setSmsCodeSent(false);
|
||||
// Не вызываем resetFields() здесь, т.к. форма еще не отрендерена
|
||||
// Форма будет сброшена при первом рендере
|
||||
|
||||
// Автоматически отправляем SMS-код
|
||||
sendSMSCode(phone);
|
||||
} else if (event.data.type === 'claim_cancelled') {
|
||||
message.info('Подтверждение отменено');
|
||||
onPrev();
|
||||
} else if (event.data.type === 'claim_form_loaded') {
|
||||
setLoading(false);
|
||||
// Автоматически подстраиваем высоту iframe после загрузки
|
||||
if (iframeRef.current) {
|
||||
try {
|
||||
const iframe = iframeRef.current;
|
||||
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
|
||||
if (iframeDoc) {
|
||||
const height = Math.max(
|
||||
iframeDoc.body.scrollHeight,
|
||||
iframeDoc.body.offsetHeight,
|
||||
iframeDoc.documentElement.clientHeight,
|
||||
iframeDoc.documentElement.scrollHeight,
|
||||
iframeDoc.documentElement.offsetHeight
|
||||
);
|
||||
iframe.style.height = Math.max(height + 50, 800) + 'px';
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Не удалось автоматически подстроить высоту iframe:', e);
|
||||
}
|
||||
}
|
||||
} else if (event.data.type === 'iframe_resize') {
|
||||
// Обработка запроса на изменение размера от iframe
|
||||
if (iframeRef.current && event.data.height) {
|
||||
iframeRef.current.style.height = Math.max(event.data.height + 50, 800) + 'px';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, [onNext, onPrev, sendSMSCode, claimPlanData]);
|
||||
|
||||
// Обработчик отправки SMS-кода из модалки
|
||||
const handleSendCode = useCallback(async () => {
|
||||
const phone =
|
||||
claimPlanData?.propertyName?.applicant?.phone ||
|
||||
claimPlanData?.propertyName?.user?.mobile ||
|
||||
claimPlanData?.phone ||
|
||||
'';
|
||||
|
||||
if (!phone) {
|
||||
message.error('Не удалось определить номер телефона');
|
||||
return;
|
||||
}
|
||||
|
||||
await sendSMSCode(phone);
|
||||
}, [claimPlanData, sendSMSCode]);
|
||||
|
||||
// Обработчик проверки SMS-кода из модалки
|
||||
const handleVerifyCode = useCallback(async () => {
|
||||
try {
|
||||
const values = await smsForm.validateFields();
|
||||
const phone =
|
||||
claimPlanData?.propertyName?.applicant?.phone ||
|
||||
claimPlanData?.propertyName?.user?.mobile ||
|
||||
claimPlanData?.phone ||
|
||||
'';
|
||||
|
||||
if (!phone) {
|
||||
message.error('Не удалось определить номер телефона');
|
||||
return;
|
||||
}
|
||||
|
||||
await verifySMSCode(phone, values.code);
|
||||
} catch (error) {
|
||||
// Валидация не прошла
|
||||
}
|
||||
}, [claimPlanData, smsForm, verifySMSCode]);
|
||||
|
||||
// Обработчик отмены SMS-апрува
|
||||
const handleCancelSMS = useCallback(() => {
|
||||
setSmsModalVisible(false);
|
||||
setSmsCodeSent(false);
|
||||
setPendingFormData(null);
|
||||
// Сбрасываем форму только если она была отрендерена (smsCodeSent был true)
|
||||
if (smsCodeSent) {
|
||||
smsForm.resetFields();
|
||||
}
|
||||
message.info('Подтверждение отменено');
|
||||
}, [smsForm, smsCodeSent]);
|
||||
|
||||
// Вычисляем телефон для отображения (до условного рендера)
|
||||
const phone =
|
||||
claimPlanData?.propertyName?.applicant?.phone ||
|
||||
claimPlanData?.propertyName?.user?.mobile ||
|
||||
claimPlanData?.phone ||
|
||||
'';
|
||||
const displayPhone = phone ? (phone.length > 4 ? `${phone.slice(0, -4)}****` : '****') : '****';
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: '16px' }}>Загрузка формы подтверждения...</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0,
|
||||
height: 'calc(100vh - 200px)',
|
||||
minHeight: '800px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={htmlContent}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minHeight: '800px',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
flex: 1,
|
||||
}}
|
||||
title="Форма подтверждения заявления"
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Модальное окно SMS-апрува */}
|
||||
<Modal
|
||||
title="Подтверждение отправки заявления"
|
||||
open={smsModalVisible}
|
||||
onCancel={handleCancelSMS}
|
||||
footer={null}
|
||||
closable={!smsCodeSent}
|
||||
maskClosable={!smsCodeSent}
|
||||
width={400}
|
||||
>
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<p style={{ marginBottom: '16px', fontSize: '14px', color: '#666' }}>
|
||||
Для завершения отправки заявления необходимо подтвердить номер телефона.
|
||||
</p>
|
||||
|
||||
{phone && (
|
||||
<p style={{ marginBottom: '16px', fontSize: '14px' }}>
|
||||
Код отправлен на номер: <strong>{displayPhone}</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!smsCodeSent ? (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={smsLoading}
|
||||
onClick={handleSendCode}
|
||||
block
|
||||
>
|
||||
Отправить код подтверждения
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Form
|
||||
form={smsForm}
|
||||
layout="vertical"
|
||||
onFinish={handleVerifyCode}
|
||||
preserve={false}
|
||||
>
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="Введите код из SMS"
|
||||
rules={[
|
||||
{ required: true, message: 'Введите код' },
|
||||
{ len: 6, message: 'Код должен состоять из 6 цифр' },
|
||||
{ pattern: /^\d+$/, message: 'Код должен содержать только цифры' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
style={{ fontSize: '18px', textAlign: 'center', letterSpacing: '4px' }}
|
||||
autoFocus
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Button
|
||||
onClick={handleCancelSMS}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={smsVerifyLoading}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
Подтвердить
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: '8px' }}>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={handleSendCode}
|
||||
loading={smsLoading}
|
||||
size="small"
|
||||
>
|
||||
Отправить код повторно
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,6 +74,16 @@ export default function StepDescription({
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📝 Отправка описания проблемы на сервер:', {
|
||||
session_id: formData.session_id,
|
||||
phone: formData.phone,
|
||||
email: formData.email,
|
||||
unified_id: formData.unified_id,
|
||||
contact_id: formData.contact_id,
|
||||
description_length: safeDescription.length,
|
||||
description_preview: safeDescription.substring(0, 100),
|
||||
});
|
||||
|
||||
const response = await fetch('/api/v1/claims/description', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -81,14 +91,29 @@ export default function StepDescription({
|
||||
session_id: formData.session_id,
|
||||
phone: formData.phone,
|
||||
email: formData.email,
|
||||
unified_id: formData.unified_id, // ✅ Unified ID пользователя
|
||||
contact_id: formData.contact_id, // ✅ Contact ID пользователя
|
||||
problem_description: safeDescription,
|
||||
}),
|
||||
});
|
||||
|
||||
console.log('📝 Ответ сервера:', {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('❌ Ошибка отправки описания:', {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
throw new Error(`Ошибка API: ${response.status}`);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
console.log('✅ Описание успешно отправлено:', responseData);
|
||||
|
||||
message.success('Описание отправлено, подбираем рекомендации...');
|
||||
updateFormData({
|
||||
problemDescription: safeDescription,
|
||||
|
||||
@@ -371,10 +371,21 @@ export default function StepWizardPlan({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWaiting || !formData.session_id || plan) {
|
||||
console.log('⏭️ StepWizardPlan: пропускаем подписку SSE', {
|
||||
isWaiting,
|
||||
hasSessionId: !!formData.session_id,
|
||||
hasPlan: !!plan,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = formData.session_id;
|
||||
console.log('🔌 StepWizardPlan: подписываемся на SSE канал для получения wizard_plan', {
|
||||
session_id: sessionId,
|
||||
sse_url: `/events/${sessionId}`,
|
||||
redis_channel: `ocr_events:${sessionId}`,
|
||||
});
|
||||
|
||||
const source = new EventSource(`/events/${sessionId}`);
|
||||
eventSourceRef.current = source;
|
||||
debugLoggerRef.current?.('wizard', 'info', '🔌 Подключаемся к SSE для плана вопросов', { session_id: sessionId });
|
||||
@@ -761,17 +772,106 @@ export default function StepWizardPlan({
|
||||
response: parsed ?? text,
|
||||
});
|
||||
message.success('Мы изучаем ваш вопрос и документы.');
|
||||
|
||||
// Подписываемся на канал claim:plan для получения данных заявления
|
||||
if (formData.session_id) {
|
||||
subscribeToClaimPlan(formData.session_id);
|
||||
} else {
|
||||
console.warn('⚠️ session_id отсутствует, не можем подписаться на claim:plan');
|
||||
onNext();
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('Ошибка соединения при отправке визарда.');
|
||||
addDebugEvent?.('wizard', 'error', '❌ Ошибка соединения при отправке визарда', {
|
||||
error: String(error),
|
||||
});
|
||||
onNext();
|
||||
} finally {
|
||||
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) => {
|
||||
// Обработка по input_type для более точного определения типа поля
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import StepDescription from '../components/form/StepDescription';
|
||||
import Step1Policy from '../components/form/Step1Policy';
|
||||
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';
|
||||
import Step3Payment from '../components/form/Step3Payment';
|
||||
@@ -16,6 +17,18 @@ import './ClaimForm.css';
|
||||
|
||||
const { Step } = Steps;
|
||||
|
||||
/**
|
||||
* Генерация UUID v4
|
||||
* Формат: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
*/
|
||||
function generateUUIDv4(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
// Шаг 1: Phone
|
||||
phone?: string;
|
||||
@@ -42,6 +55,10 @@ interface FormData {
|
||||
wizardUploads?: Record<string, any>;
|
||||
wizardSkippedDocuments?: string[];
|
||||
|
||||
// Подтверждение заявления (после получения данных из claim:plan)
|
||||
showClaimConfirmation?: boolean;
|
||||
claimPlanData?: any; // Данные заявления от n8n из канала claim:plan
|
||||
|
||||
// Шаг 3: Event Type
|
||||
eventType?: string;
|
||||
ticket_id?: string; // ✅ ID заявки в vTiger (HelpDesk)
|
||||
@@ -71,6 +88,8 @@ export default function ClaimForm() {
|
||||
// session_id будет получен от n8n при создании контакта
|
||||
// Используем useRef чтобы sessionId не вызывал перерендер и был стабильным
|
||||
const sessionIdRef = useRef(`sess-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
|
||||
const claimPlanEventSourceRef = useRef<EventSource | null>(null);
|
||||
const claimPlanTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [sessionRestored, setSessionRestored] = useState(false); // Флаг: пытались восстановить сессию
|
||||
@@ -204,6 +223,26 @@ export default function ClaimForm() {
|
||||
fetchClientIp();
|
||||
}, []);
|
||||
|
||||
// Автоматический переход к шагу подтверждения, когда данные готовы
|
||||
useEffect(() => {
|
||||
if (formData.showClaimConfirmation && formData.claimPlanData) {
|
||||
// Вычисляем индекс шага подтверждения динамически
|
||||
// Шаг подтверждения добавляется после StepWizardPlan
|
||||
// После выбора черновика showDraftSelection = false, поэтому:
|
||||
// - Шаг 0 = Step1Phone
|
||||
// - Шаг 1 = StepDescription
|
||||
// - Шаг 2 = StepWizardPlan
|
||||
// - Шаг 3 = StepClaimConfirmation (если showClaimConfirmation=true)
|
||||
const confirmationStepIndex = 3; // Фиксированный индекс для шага подтверждения
|
||||
|
||||
console.log('✅ Данные заявления готовы, переходим к шагу подтверждения:', confirmationStepIndex);
|
||||
setTimeout(() => {
|
||||
setCurrentStep(confirmationStepIndex);
|
||||
}, 100);
|
||||
}
|
||||
}, [formData.showClaimConfirmation, formData.claimPlanData]);
|
||||
|
||||
|
||||
// Динамически определяем список шагов на основе выбранного eventType
|
||||
const documentConfigs = formData.eventType ? getDocumentsForEventType(formData.eventType) : [];
|
||||
const totalDocumentSteps = documentConfigs.length;
|
||||
@@ -244,6 +283,261 @@ export default function ClaimForm() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Преобразование данных черновика в формат propertyName для формы подтверждения
|
||||
const transformDraftToClaimPlanFormat = useCallback((data: {
|
||||
claim: any;
|
||||
payload: any;
|
||||
body: any;
|
||||
isTelegramFormat: boolean;
|
||||
finalClaimId: string;
|
||||
actualSessionId: string;
|
||||
currentFormData: FormData;
|
||||
}) => {
|
||||
const { claim, payload, body, finalClaimId, actualSessionId, currentFormData } = data;
|
||||
|
||||
console.log('🔄 transformDraftToClaimPlanFormat: входные данные:', {
|
||||
claimId: finalClaimId,
|
||||
claimUnifiedId: claim.unified_id,
|
||||
formDataUnifiedId: currentFormData.unified_id,
|
||||
claimKeys: Object.keys(claim),
|
||||
});
|
||||
|
||||
console.log('🔄 Данные из БД:', {
|
||||
hasApplicantData: !!(body.applicant || payload.applicant),
|
||||
hasCaseData: !!(body.case || payload.case),
|
||||
hasContractData: !!(body.contract_or_service || payload.contract_or_service),
|
||||
hasWizardAnswers: !!(body.answers || payload.answers || body.wizard_answers || payload.wizard_answers),
|
||||
hasSendToFormApprove: !!(payload.send_to_form_approve && payload.send_to_form_approve.draft),
|
||||
payloadKeys: Object.keys(payload),
|
||||
bodyKeys: Object.keys(body),
|
||||
});
|
||||
|
||||
// ✅ ПРИОРИТЕТ 1: Если есть данные в payload.send_to_form_approve.draft - используем их напрямую!
|
||||
const sendToFormApproveDraft = payload.send_to_form_approve?.draft;
|
||||
if (sendToFormApproveDraft) {
|
||||
console.log('✅ Найдены данные в payload.send_to_form_approve.draft, используем их напрямую!');
|
||||
console.log('✅ send_to_form_approve.draft:', sendToFormApproveDraft);
|
||||
|
||||
// Используем данные из send_to_form_approve.draft напрямую
|
||||
const draftData = sendToFormApproveDraft;
|
||||
|
||||
// Формируем propertyName из draft данных
|
||||
const propertyName = {
|
||||
applicant: draftData.applicant || {},
|
||||
case: draftData.case || {},
|
||||
contract_or_service: draftData.contract_or_service || {},
|
||||
offenders: draftData.offenders || [],
|
||||
claim: draftData.claim || {},
|
||||
meta: {
|
||||
...(draftData.meta || {}),
|
||||
claim_id: finalClaimId,
|
||||
unified_id: draftData.meta?.unified_id || claim.unified_id || currentFormData.unified_id || null,
|
||||
},
|
||||
attachments: draftData.attachments || [],
|
||||
attachments_count: draftData.attachments_count || 0,
|
||||
attachments_names: draftData.attachments_names || [],
|
||||
};
|
||||
|
||||
// Возвращаем данные в формате объекта (для компонента StepClaimConfirmation)
|
||||
const result = {
|
||||
propertyName: propertyName,
|
||||
session_token: actualSessionId,
|
||||
prefix: '',
|
||||
telegram_id: null,
|
||||
claim_id: finalClaimId,
|
||||
unified_id: propertyName.meta.unified_id,
|
||||
user_id: propertyName.meta.user_id || null,
|
||||
};
|
||||
|
||||
console.log('🔄 transformDraftToClaimPlanFormat: результат из send_to_form_approve:', {
|
||||
claim_id: result.claim_id,
|
||||
unified_id: result.unified_id,
|
||||
hasPropertyName: !!result.propertyName,
|
||||
hasMeta: !!result.propertyName?.meta,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ✅ ПРИОРИТЕТ 2: Если данных нет в send_to_form_approve, извлекаем из body/payload
|
||||
// Извлекаем данные из body (telegram) или напрямую из payload (web_form)
|
||||
const applicantData = body.applicant || payload.applicant || {};
|
||||
const caseData = body.case || payload.case || {};
|
||||
const contractData = body.contract_or_service || payload.contract_or_service || {};
|
||||
const offendersData = body.offenders || payload.offenders || [];
|
||||
const claimData = body.claim || payload.claim || {};
|
||||
const metaData = body.meta || payload.meta || {};
|
||||
const documentsMeta = body.documents_meta || payload.documents_meta || [];
|
||||
|
||||
// Извлекаем ответы на вопросы из wizard_answers
|
||||
const wizardAnswers = body.answers || payload.answers || body.wizard_answers || payload.wizard_answers || {};
|
||||
let answersParsed = wizardAnswers;
|
||||
if (typeof wizardAnswers === 'string') {
|
||||
try {
|
||||
answersParsed = JSON.parse(wizardAnswers);
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Не удалось распарсить answers:', e);
|
||||
answersParsed = {};
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🔄 wizard_answers parsed:', answersParsed);
|
||||
|
||||
// Преобразуем wizard_answers в формат propertyName, если данных нет в propertyName формате
|
||||
// Маппинг полей из wizard_answers в propertyName структуру
|
||||
const hasPropertyNameData = !!(applicantData.first_name || applicantData.last_name || caseData.category || contractData.subject);
|
||||
|
||||
if (!hasPropertyNameData && answersParsed && Object.keys(answersParsed).length > 0) {
|
||||
console.log('🔄 Преобразуем wizard_answers в propertyName формат');
|
||||
console.log('🔄 wizard_answers keys:', Object.keys(answersParsed));
|
||||
|
||||
// Маппинг полей из wizard_answers в contract_or_service
|
||||
if (answersParsed.item && !contractData.subject) {
|
||||
contractData.subject = answersParsed.item;
|
||||
}
|
||||
if (answersParsed.price && !contractData.amount_paid) {
|
||||
// Нормализуем цену (убираем "рублей", пробелы и т.д.)
|
||||
const priceStr = String(answersParsed.price).replace(/\s+/g, '').replace(/руб(лей|ль|\.)?/gi, '').replace(/₽|р\.|р$/gi, '');
|
||||
contractData.amount_paid = priceStr;
|
||||
contractData.amount_paid_fmt = priceStr;
|
||||
}
|
||||
if (answersParsed.place_date && !contractData.agreement_date) {
|
||||
contractData.agreement_date = answersParsed.place_date;
|
||||
contractData.agreement_date_fmt = answersParsed.place_date;
|
||||
}
|
||||
if (answersParsed.cancel_date && !contractData.period_start) {
|
||||
contractData.period_start = answersParsed.cancel_date;
|
||||
contractData.period_start_fmt = answersParsed.cancel_date;
|
||||
}
|
||||
|
||||
// Маппинг полей из wizard_answers в claim
|
||||
if (answersParsed.steps_taken && !claimData.description) {
|
||||
claimData.description = answersParsed.steps_taken;
|
||||
}
|
||||
if (answersParsed.expectation && !claimData.reason) {
|
||||
// expectation может быть "refund", "replacement", "compensation", "other"
|
||||
claimData.reason = answersParsed.expectation === 'refund' ? 'consumer' : 'consumer';
|
||||
}
|
||||
|
||||
// Маппинг в case
|
||||
if (!caseData.category) {
|
||||
caseData.category = 'consumer'; // По умолчанию consumer
|
||||
}
|
||||
if (!caseData.direction) {
|
||||
caseData.direction = 'web_form';
|
||||
}
|
||||
|
||||
// Если есть problem_description, используем его для claim.description
|
||||
const problemDesc = payload.problem_description || body.problem_description;
|
||||
if (problemDesc && !claimData.description) {
|
||||
claimData.description = problemDesc;
|
||||
}
|
||||
if (problemDesc && !contractData.subject) {
|
||||
contractData.subject = problemDesc;
|
||||
}
|
||||
}
|
||||
|
||||
// Данные заявителя берутся из других источников (phone, email из claim или formData)
|
||||
// ФИО, дата рождения, ИНН будут заполняться в форме подтверждения
|
||||
const applicantPhone = claim.phone || payload.phone || body.phone || currentFormData.phone || null;
|
||||
const applicantEmail = claim.email || payload.email || body.email || currentFormData.email || null;
|
||||
|
||||
// Если есть данные заявителя в applicantData, используем их
|
||||
if (!applicantData.phone && applicantPhone) {
|
||||
applicantData.phone = applicantPhone;
|
||||
}
|
||||
if (!applicantData.email && applicantEmail) {
|
||||
applicantData.email = applicantEmail;
|
||||
}
|
||||
|
||||
// Формируем attachments_names из documents_meta
|
||||
const attachmentsNames = documentsMeta.map((doc: any) => {
|
||||
return doc.original_file_name || doc.file_name || doc.field_name || 'Документ';
|
||||
});
|
||||
|
||||
// Формируем attachments с полной информацией
|
||||
const attachments = documentsMeta.map((doc: any) => ({
|
||||
label: doc.field_label || doc.original_file_name || doc.file_name || doc.field_name || 'Документ', // ✅ Используем field_label
|
||||
field_label: doc.field_label || doc.field_name || doc.original_file_name || doc.file_name || 'Документ', // ✅ Добавляем field_label отдельно
|
||||
url: doc.file_id ? `https://s3.twcstorage.ru${doc.file_id}` : '',
|
||||
file_id: doc.file_id || '',
|
||||
stored_file_name: doc.file_name || '',
|
||||
original_file_name: doc.original_file_name || doc.file_name || '',
|
||||
field_name: doc.field_name || '',
|
||||
uploaded_at: doc.uploaded_at || new Date().toISOString(),
|
||||
}));
|
||||
|
||||
// Формируем propertyName в нужном формате
|
||||
const propertyName = {
|
||||
applicant: {
|
||||
first_name: applicantData.first_name || null,
|
||||
middle_name: applicantData.middle_name || null,
|
||||
last_name: applicantData.last_name || null,
|
||||
full_name: applicantData.full_name || null,
|
||||
birth_date: applicantData.birth_date || null,
|
||||
birth_date_fmt: applicantData.birth_date_fmt || null,
|
||||
birth_place: applicantData.birth_place || null,
|
||||
inn: applicantData.inn || null,
|
||||
address: applicantData.address || null,
|
||||
phone: claim.phone || payload.phone || body.phone || currentFormData.phone || null,
|
||||
email: claim.email || payload.email || body.email || currentFormData.email || null,
|
||||
},
|
||||
case: {
|
||||
category: caseData.category || payload.case_type || 'consumer',
|
||||
direction: caseData.direction || 'web_form',
|
||||
country: caseData.country || null,
|
||||
},
|
||||
contract_or_service: {
|
||||
agreement_date: contractData.agreement_date || null,
|
||||
agreement_date_fmt: contractData.agreement_date_fmt || null,
|
||||
amount_paid: contractData.amount_paid || null,
|
||||
amount_paid_fmt: contractData.amount_paid_fmt || null,
|
||||
subject: contractData.subject || payload.problem_description || body.problem_description || null,
|
||||
period_start: contractData.period_start || null,
|
||||
period_start_fmt: contractData.period_start_fmt || null,
|
||||
period_end: contractData.period_end || null,
|
||||
period_end_fmt: contractData.period_end_fmt || null,
|
||||
period_text: contractData.period_text || null,
|
||||
},
|
||||
offenders: offendersData.length > 0 ? offendersData : [],
|
||||
claim: {
|
||||
reason: claimData.reason || caseData.category || 'consumer',
|
||||
description: claimData.description || payload.problem_description || body.problem_description || null,
|
||||
},
|
||||
meta: {
|
||||
claim_id: finalClaimId,
|
||||
unified_id: claim.unified_id || currentFormData.unified_id || null,
|
||||
status: claim.status_code || 'draft',
|
||||
created_at: claim.created_at || new Date().toISOString(),
|
||||
updated_at: claim.updated_at || new Date().toISOString(),
|
||||
user_id: metaData.user_id || null,
|
||||
},
|
||||
attachments: attachments,
|
||||
attachments_count: attachments.length,
|
||||
attachments_names: attachmentsNames,
|
||||
};
|
||||
|
||||
// Возвращаем данные в формате объекта (для компонента StepClaimConfirmation)
|
||||
const result = {
|
||||
propertyName: propertyName,
|
||||
session_token: actualSessionId,
|
||||
prefix: '',
|
||||
telegram_id: null,
|
||||
claim_id: finalClaimId,
|
||||
unified_id: claim.unified_id || currentFormData.unified_id || null,
|
||||
user_id: metaData.user_id || null,
|
||||
};
|
||||
|
||||
console.log('🔄 transformDraftToClaimPlanFormat: результат:', {
|
||||
claim_id: result.claim_id,
|
||||
unified_id: result.unified_id,
|
||||
hasPropertyName: !!result.propertyName,
|
||||
hasMeta: !!result.propertyName?.meta,
|
||||
});
|
||||
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
// Загрузка черновика
|
||||
const loadDraft = useCallback(async (claimId: string) => {
|
||||
try {
|
||||
@@ -272,6 +566,7 @@ export default function ClaimForm() {
|
||||
console.log('🔍 Claim объект:', claim);
|
||||
console.log('🔍 claim.claim_id:', claim.claim_id);
|
||||
console.log('🔍 claim.id:', claim.id);
|
||||
console.log('🔍 claim.unified_id:', claim.unified_id);
|
||||
console.log('🔍 Payload черновика:', payload);
|
||||
console.log('🔍 payload.body:', body);
|
||||
console.log('🔍 Формат:', isTelegramFormat ? 'telegram (body)' : 'web_form (прямой)');
|
||||
@@ -279,7 +574,16 @@ export default function ClaimForm() {
|
||||
// ✅ Извлекаем данные из body (telegram) или напрямую из payload (web_form)
|
||||
const wizardPlanRaw = body.wizard_plan || payload.wizard_plan;
|
||||
const answersRaw = body.answers || payload.answers;
|
||||
const problemDescription = body.problem_description || payload.problem_description || body.description || payload.description;
|
||||
// Ищем problem_description в разных местах (может быть в разных форматах)
|
||||
const problemDescription =
|
||||
body.problem_description ||
|
||||
payload.problem_description ||
|
||||
body.description ||
|
||||
payload.description ||
|
||||
payload.body?.problem_description || // Для вложенных структур
|
||||
payload.body?.description ||
|
||||
null;
|
||||
const documentsMeta = body.documents_meta || payload.documents_meta || [];
|
||||
|
||||
// ✅ Парсим wizard_plan и answers, если они строки (JSON)
|
||||
let wizardPlan = wizardPlanRaw;
|
||||
@@ -300,9 +604,34 @@ export default function ClaimForm() {
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🔍 problem_description:', problemDescription ? 'есть' : 'нет');
|
||||
// ✅ Проверяем, заполнены ли все шаги
|
||||
// Для problem_description: если его нет в payload, но есть wizard_plan и answers,
|
||||
// значит описание уже было введено ранее (wizard_plan генерируется на основе описания)
|
||||
const hasDescription = !!problemDescription || (!!wizardPlan && !!answers); // Если есть план и ответы - описание было
|
||||
const hasWizardPlan = !!wizardPlan;
|
||||
const hasAnswers = !!answers && Object.keys(answers).length > 0;
|
||||
const hasDocuments = Array.isArray(documentsMeta) && documentsMeta.length > 0;
|
||||
const isDraft = claim.status_code === 'draft';
|
||||
|
||||
const allStepsFilled = hasDescription && hasWizardPlan && hasAnswers && hasDocuments;
|
||||
const isReadyForConfirmation = allStepsFilled && isDraft;
|
||||
|
||||
console.log('🔍 Проверка полноты черновика:', {
|
||||
hasDescription,
|
||||
hasWizardPlan,
|
||||
hasAnswers,
|
||||
hasDocuments,
|
||||
isDraft,
|
||||
allStepsFilled,
|
||||
isReadyForConfirmation,
|
||||
problemDescriptionFound: !!problemDescription,
|
||||
inferredFromPlan: !problemDescription && !!wizardPlan && !!answers,
|
||||
});
|
||||
|
||||
console.log('🔍 problem_description:', problemDescription ? 'есть' : (wizardPlan && answers ? 'выведено из наличия плана и ответов' : 'нет'));
|
||||
console.log('🔍 wizard_plan:', wizardPlan ? 'есть' : 'нет');
|
||||
console.log('🔍 answers:', answers ? 'есть' : 'нет');
|
||||
console.log('🔍 documents_meta:', documentsMeta.length, 'документов');
|
||||
console.log('🔍 Все ключи payload:', Object.keys(payload));
|
||||
if (isTelegramFormat) {
|
||||
console.log('🔍 Все ключи body:', Object.keys(body));
|
||||
@@ -316,12 +645,21 @@ export default function ClaimForm() {
|
||||
console.log('🔄 Загрузка черновика: session_id из черновика:', claim.session_token);
|
||||
console.log('🔄 Загрузка черновика: текущий sessionIdRef.current:', sessionIdRef.current);
|
||||
console.log('🔄 Загрузка черновика: текущий formData.session_id:', formData.session_id);
|
||||
const actualSessionId = sessionIdRef.current || formData.session_id;
|
||||
|
||||
// ✅ При загрузке черновика используем session_id из черновика (для продолжения работы с той же жалобой)
|
||||
// Если session_id из черновика есть - используем его, иначе текущий
|
||||
const actualSessionId = claim.session_token || sessionIdRef.current || formData.session_id;
|
||||
console.log('🔄 Загрузка черновика: ИСПОЛЬЗУЕМ session_id:', actualSessionId);
|
||||
|
||||
// ✅ Обновляем sessionIdRef на сессию из черновика (если есть)
|
||||
if (claim.session_token && claim.session_token !== sessionIdRef.current) {
|
||||
sessionIdRef.current = claim.session_token;
|
||||
console.log('🔄 Обновляем sessionIdRef на сессию из черновика:', claim.session_token);
|
||||
}
|
||||
|
||||
updateFormData({
|
||||
claim_id: finalClaimId, // ✅ Используем извлечённый claim_id
|
||||
session_id: actualSessionId, // ✅ Используем ТЕКУЩИЙ session_id, а не старый из черновика
|
||||
session_id: actualSessionId, // ✅ Используем session_id из черновика (если есть) или текущий
|
||||
phone: body.phone || payload.phone || formData.phone,
|
||||
email: body.email || payload.email || formData.email,
|
||||
problemDescription: problemDescription || formData.problemDescription,
|
||||
@@ -349,6 +687,34 @@ export default function ClaimForm() {
|
||||
setSelectedDraftId(finalClaimId);
|
||||
setShowDraftSelection(false);
|
||||
|
||||
// ✅ Если все шаги заполнены и статус = draft → переходим к форме подтверждения
|
||||
if (isReadyForConfirmation) {
|
||||
console.log('✅ Все шаги заполнены, преобразуем данные для формы подтверждения');
|
||||
|
||||
setIsPhoneVerified(true);
|
||||
|
||||
// Преобразуем данные из БД в формат propertyName для формы подтверждения
|
||||
const claimPlanData = transformDraftToClaimPlanFormat({
|
||||
claim,
|
||||
payload,
|
||||
body,
|
||||
isTelegramFormat,
|
||||
finalClaimId,
|
||||
actualSessionId,
|
||||
currentFormData: formData,
|
||||
});
|
||||
|
||||
// Сохраняем данные заявления в formData
|
||||
updateFormData({
|
||||
claimPlanData: claimPlanData,
|
||||
showClaimConfirmation: true,
|
||||
});
|
||||
|
||||
// Переход к шагу подтверждения произойдёт автоматически через useEffect
|
||||
setCurrentStep(2); // StepWizardPlan (временно, useEffect переключит на подтверждение)
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ Определяем шаг для перехода на основе данных черновика
|
||||
// Приоритет: если есть wizard_plan → переходим к визарду (даже если нет problem_description)
|
||||
// После выбора черновика showDraftSelection = false, поэтому:
|
||||
@@ -448,12 +814,27 @@ export default function ClaimForm() {
|
||||
console.log('🆕 Текущий currentStep:', currentStep);
|
||||
console.log('🆕 isPhoneVerified:', isPhoneVerified);
|
||||
|
||||
// ✅ Генерируем НОВУЮ сессию для новой жалобы
|
||||
const newSessionId = 'sess_' + generateUUIDv4();
|
||||
console.log('🆕 Генерируем новую сессию для жалобы:', newSessionId);
|
||||
console.log('🆕 Старая сессия:', sessionIdRef.current);
|
||||
|
||||
// ✅ Обновляем sessionIdRef на новую сессию
|
||||
sessionIdRef.current = newSessionId;
|
||||
|
||||
// ✅ session_token в localStorage остаётся ПРЕЖНИМ (авторизация сохраняется)
|
||||
const savedSessionToken = localStorage.getItem('session_token');
|
||||
console.log('🆕 session_token в localStorage (авторизация):', savedSessionToken || '(не сохранён)');
|
||||
console.log('🆕 Авторизация сохранена: unified_id=', formData.unified_id, 'phone=', formData.phone);
|
||||
|
||||
setShowDraftSelection(false);
|
||||
setSelectedDraftId(null);
|
||||
setHasDrafts(false); // ✅ Сбрасываем флаг наличия черновиков
|
||||
|
||||
// Очищаем данные формы, кроме телефона и session_id
|
||||
// ✅ Очищаем данные формы и устанавливаем НОВЫЙ session_id
|
||||
// unified_id, phone, contact_id остаются прежними - авторизация сохранена!
|
||||
updateFormData({
|
||||
session_id: newSessionId, // ✅ Новая сессия для новой жалобы
|
||||
claim_id: undefined,
|
||||
problemDescription: undefined,
|
||||
wizardPlan: undefined,
|
||||
@@ -464,6 +845,7 @@ export default function ClaimForm() {
|
||||
wizardUploads: undefined,
|
||||
wizardSkippedDocuments: undefined,
|
||||
eventType: undefined,
|
||||
// ✅ unified_id, phone, contact_id НЕ очищаем - авторизация сохраняется!
|
||||
});
|
||||
|
||||
console.log('🆕 Переходим к шагу описания проблемы (пропускаем Phone и DraftSelection)');
|
||||
@@ -474,7 +856,7 @@ export default function ClaimForm() {
|
||||
// Шаг 1 - Description (сюда переходим)
|
||||
// Шаг 2 - WizardPlan
|
||||
setCurrentStep(1); // ✅ Переходим к описанию (индекс 1)
|
||||
}, [updateFormData, currentStep, isPhoneVerified]);
|
||||
}, [updateFormData, currentStep, isPhoneVerified, formData.unified_id, formData.phone]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
try {
|
||||
@@ -687,6 +1069,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 (всегда)
|
||||
stepsArray.push({
|
||||
title: 'Проверка полиса',
|
||||
|
||||
48
ticket_form/monitor_n8n_memory.sh
Executable file
48
ticket_form/monitor_n8n_memory.sh
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# Мониторинг использования памяти n8n
|
||||
# Проверяет использование памяти и отправляет алерт при превышении порога
|
||||
|
||||
N8N_CONTAINER="${N8N_CONTAINER:-n8n}" # Имя контейнера n8n
|
||||
THRESHOLD="${MEMORY_THRESHOLD:-80}" # Порог использования памяти (%)
|
||||
LOG_FILE="/var/www/fastuser/data/www/crm.clientright.ru/ticket_form/logs/n8n_memory_monitor.log"
|
||||
|
||||
# Создать директорию для логов если не существует
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Проверка существования контейнера
|
||||
if ! docker ps --format "{{.Names}}" | grep -q "^${N8N_CONTAINER}$"; then
|
||||
log "❌ Контейнер ${N8N_CONTAINER} не найден!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Получение использования памяти
|
||||
MEMORY_INFO=$(docker stats "$N8N_CONTAINER" --no-stream --format "{{.MemUsage}}|{{.MemPerc}}")
|
||||
MEMORY_USAGE=$(echo "$MEMORY_INFO" | cut -d'|' -f1)
|
||||
MEMORY_PERCENT=$(echo "$MEMORY_INFO" | cut -d'|' -f2 | sed 's/%//')
|
||||
|
||||
# Проверка порога
|
||||
if (( $(echo "$MEMORY_PERCENT > $THRESHOLD" | bc -l 2>/dev/null || echo "0") )); then
|
||||
log "⚠️ ВНИМАНИЕ: n8n использует ${MEMORY_PERCENT}% памяти (${MEMORY_USAGE})"
|
||||
log " Порог: ${THRESHOLD}%"
|
||||
|
||||
# Дополнительная информация
|
||||
log "📊 Дополнительная информация:"
|
||||
docker stats "$N8N_CONTAINER" --no-stream --format " CPU: {{.CPUPerc}} | Memory: {{.MemUsage}} | Network: {{.NetIO}}" | tee -a "$LOG_FILE"
|
||||
|
||||
# Проверка OOM Killer
|
||||
OOM_COUNT=$(dmesg | grep -i "out of memory" | grep -i "$N8N_CONTAINER" | wc -l)
|
||||
if [ "$OOM_COUNT" -gt 0 ]; then
|
||||
log "🚨 Обнаружены записи OOM Killer для n8n!"
|
||||
dmesg | grep -i "out of memory" | grep -i "$N8N_CONTAINER" | tail -5 | tee -a "$LOG_FILE"
|
||||
fi
|
||||
|
||||
exit 1
|
||||
else
|
||||
log "✅ Память в норме: ${MEMORY_PERCENT}% (${MEMORY_USAGE})"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
144
ticket_form/monitor_n8n_redis_trigger.py
Executable file
144
ticket_form/monitor_n8n_redis_trigger.py
Executable file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Мониторинг Redis Trigger в n8n
|
||||
Проверяет наличие подписчиков на канале ticket_form:description
|
||||
и отправляет алерт если подписчиков нет
|
||||
"""
|
||||
import redis
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import sys
|
||||
|
||||
# Настройки
|
||||
REDIS_HOST = "crm.clientright.ru"
|
||||
REDIS_PORT = 6379
|
||||
REDIS_PASSWORD = "CRM_Redis_Pass_2025_Secure!"
|
||||
CHANNEL = "ticket_form:description"
|
||||
CHECK_INTERVAL = 60 # Проверка каждую минуту
|
||||
ALERT_THRESHOLD = 0 # Если подписчиков меньше этого значения - алерт
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('/var/www/fastuser/data/www/crm.clientright.ru/ticket_form/logs/n8n_redis_monitor.log'),
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_subscribers():
|
||||
"""Проверка количества подписчиков на канале"""
|
||||
try:
|
||||
r = redis.Redis(
|
||||
host=REDIS_HOST,
|
||||
port=REDIS_PORT,
|
||||
password=REDIS_PASSWORD,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5
|
||||
)
|
||||
|
||||
# Проверка подключения
|
||||
r.ping()
|
||||
|
||||
# Проверка подписчиков
|
||||
numsub = r.pubsub_numsub(CHANNEL)
|
||||
subscribers = numsub[0][1] if numsub else 0
|
||||
|
||||
logger.info(f"📊 Канал {CHANNEL}: {subscribers} подписчиков")
|
||||
|
||||
if subscribers <= ALERT_THRESHOLD:
|
||||
logger.warning(
|
||||
f"⚠️ ВНИМАНИЕ: На канале {CHANNEL} нет подписчиков! "
|
||||
f"n8n workflow может быть неактивен или завис."
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except redis.ConnectionError as e:
|
||||
logger.error(f"❌ Ошибка подключения к Redis: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Неожиданная ошибка: {e}")
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
r.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def send_test_message():
|
||||
"""Отправка тестового сообщения для проверки"""
|
||||
try:
|
||||
r = redis.Redis(
|
||||
host=REDIS_HOST,
|
||||
port=REDIS_PORT,
|
||||
password=REDIS_PASSWORD,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5
|
||||
)
|
||||
|
||||
test_message = {
|
||||
"type": "test",
|
||||
"session_id": "monitor_test",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"message": "Health check from monitor script"
|
||||
}
|
||||
|
||||
import json
|
||||
subscribers = r.publish(CHANNEL, json.dumps(test_message))
|
||||
logger.info(f"📤 Тестовое сообщение отправлено. Получено подписчиками: {subscribers}")
|
||||
|
||||
r.close()
|
||||
return subscribers > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка отправки тестового сообщения: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Основной цикл мониторинга"""
|
||||
logger.info("🚀 Запуск мониторинга Redis Trigger для n8n")
|
||||
logger.info(f"📡 Канал: {CHANNEL}")
|
||||
logger.info(f"⏱️ Интервал проверки: {CHECK_INTERVAL} секунд")
|
||||
|
||||
consecutive_failures = 0
|
||||
max_failures = 3 # После 3 неудачных проверок подряд - критический алерт
|
||||
|
||||
while True:
|
||||
try:
|
||||
is_ok = check_subscribers()
|
||||
|
||||
if is_ok:
|
||||
consecutive_failures = 0
|
||||
else:
|
||||
consecutive_failures += 1
|
||||
|
||||
if consecutive_failures >= max_failures:
|
||||
logger.critical(
|
||||
f"🚨 КРИТИЧЕСКОЕ СОСТОЯНИЕ: "
|
||||
f"Канал {CHANNEL} не имеет подписчиков уже {consecutive_failures} проверок подряд! "
|
||||
f"Требуется перезапуск n8n workflow!"
|
||||
)
|
||||
# Можно добавить отправку уведомления (email, telegram, etc.)
|
||||
|
||||
time.sleep(CHECK_INTERVAL)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("⏹️ Остановка мониторинга по запросу пользователя")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Критическая ошибка в цикле мониторинга: {e}")
|
||||
time.sleep(CHECK_INTERVAL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user