diff --git a/ticket_form/SESSION_LOG_2025-11-20.md b/ticket_form/SESSION_LOG_2025-11-20.md index d1303b7e..d126a5cc 100644 --- a/ticket_form/SESSION_LOG_2025-11-20.md +++ b/ticket_form/SESSION_LOG_2025-11-20.md @@ -343,3 +343,4 @@ TTL: 86400 секунд **Статус:** ✅ Завершено + diff --git a/ticket_form/SESSION_LOG_2025-11-25.md b/ticket_form/SESSION_LOG_2025-11-25.md new file mode 100644 index 00000000..d1196af5 --- /dev/null +++ b/ticket_form/SESSION_LOG_2025-11-25.md @@ -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 +✅ Тестирование успешно + diff --git a/ticket_form/backend/app/api/claims.py b/ticket_form/backend/app/api/claims.py index 7663cc3f..18ae0f8d 100644 --- a/ticket_form/backend/app/api/claims.py +++ b/ticket_form/backend/app/api/claims.py @@ -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, diff --git a/ticket_form/backend/app/api/events.py b/ticket_form/backend/app/api/events.py index 87a84591..3b9c9fe3 100644 --- a/ticket_form/backend/app/api/events.py +++ b/ticket_form/backend/app/api/events.py @@ -238,3 +238,129 @@ async def stream_events(task_id: str): } ) + +@router.get("/claim-plan/{session_token}") +async def stream_claim_plan(session_token: str): + """ + SSE стрим для получения данных заявления из канала claim:plan:{session_token} + + Используется после отправки формы визарда для получения данных заявления + от n8n workflow, которые затем отображаются в форме подтверждения. + + Args: + session_token: Session token (например, sess_c9e7c0c2-de2e-40cd-ab7c-3bdc40282d34) + Используется для формирования канала claim:plan:{session_token} + + Returns: + StreamingResponse с данными заявления в формате: + { + "event_type": "claim_plan_ready", + "status": "ready", + "data": { + "propertyName": {...}, // Данные заявления из n8n + ... + } + } + """ + logger.info(f"🚀 Claim plan SSE connection requested for session_token: {session_token}") + + async def claim_plan_generator(): + """Генератор событий из Redis Pub/Sub для claim:plan канала""" + channel = f"claim:plan:{session_token}" + + # Подписываемся на канал Redis + pubsub = redis_service.client.pubsub() + await pubsub.subscribe(channel) + + logger.info(f"📡 Client subscribed to {channel}") + + # Отправляем начальное событие + yield f"data: {json.dumps({'status': 'connected', 'message': 'Ожидание данных заявления...'})}\n\n" + + try: + # Слушаем события (таймаут 5 минут для обработки в n8n) + while True: + logger.info(f"⏳ Waiting for claim plan data on {channel}...") + message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=300.0) + + if message: + logger.info(f"📥 Received claim plan message type: {message['type']}") + if message['type'] == 'message': + event_data_raw = message['data'] # Уже строка (decode_responses=True) + logger.info(f"📦 Raw claim plan data length: {len(event_data_raw)}") + + try: + # Парсим данные от n8n + claim_data = json.loads(event_data_raw) + + # Формируем событие в стандартном формате + event = { + "event_type": "claim_plan_ready", + "status": "ready", + "message": "Данные заявления готовы", + "data": claim_data, # Весь объект от n8n + "timestamp": None + } + + logger.info(f"✅ Claim plan data received for session {session_token}") + + # Отправляем событие клиенту + event_json = json.dumps(event, ensure_ascii=False) + yield f"data: {event_json}\n\n" + + # После получения данных закрываем соединение + logger.info(f"✅ Claim plan sent to client, closing SSE") + break + + except json.JSONDecodeError as e: + logger.error(f"❌ Failed to parse claim plan JSON: {e}") + error_event = { + "event_type": "claim_plan_error", + "status": "error", + "message": f"Ошибка парсинга данных: {str(e)}", + "data": {}, + "timestamp": None + } + yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n" + break + else: + logger.info(f"⏰ Timeout waiting for claim plan on {channel}") + # Отправляем timeout событие + timeout_event = { + "event_type": "claim_plan_timeout", + "status": "timeout", + "message": "Превышено время ожидания данных заявления", + "data": {}, + "timestamp": None + } + yield f"data: {json.dumps(timeout_event, ensure_ascii=False)}\n\n" + break + + await asyncio.sleep(0.1) + + except asyncio.CancelledError: + logger.info(f"❌ Client disconnected from {channel}") + except Exception as e: + logger.error(f"❌ Error in claim plan stream: {e}") + error_event = { + "event_type": "claim_plan_error", + "status": "error", + "message": f"Ошибка получения данных: {str(e)}", + "data": {}, + "timestamp": None + } + yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n" + finally: + await pubsub.unsubscribe(channel) + await pubsub.close() + + return StreamingResponse( + claim_plan_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" # Отключаем буферизацию nginx + } + ) + diff --git a/ticket_form/backend/app/api/models.py b/ticket_form/backend/app/api/models.py index 8d55873c..165fc668 100644 --- a/ticket_form/backend/app/api/models.py +++ b/ticket_form/backend/app/api/models.py @@ -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 канала (опционально)") diff --git a/ticket_form/backend/app/services/redis_service.py b/ticket_form/backend/app/services/redis_service.py index e25ebab0..ec32f524 100644 --- a/ticket_form/backend/app/services/redis_service.py +++ b/ticket_form/backend/app/services/redis_service.py @@ -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: """Удалить ключ""" diff --git a/ticket_form/docs/CLAIMSAVE_FINAL_SQL.md b/ticket_form/docs/CLAIMSAVE_FINAL_SQL.md index 36b7313d..828eeaf9 100644 --- a/ticket_form/docs/CLAIMSAVE_FINAL_SQL.md +++ b/ticket_form/docs/CLAIMSAVE_FINAL_SQL.md @@ -209,3 +209,4 @@ $2 = {{ $json.claim_id }} (TEXT, строка "CLM-2025-11-18-GEQ3K Оба запроса теперь используют строковый `claim_id` и правильно находят UUID. + diff --git a/ticket_form/docs/CODE1_FIX.md b/ticket_form/docs/CODE1_FIX.md index f9cfca9a..49663776 100644 --- a/ticket_form/docs/CODE1_FIX.md +++ b/ticket_form/docs/CODE1_FIX.md @@ -102,3 +102,4 @@ function mapCombinedDocs(cds = []) { Но для `mapDialogHistory` это критично, т.к. она вызывается первой и падает. + diff --git a/ticket_form/docs/CODE1_FIXED_CODE.js b/ticket_form/docs/CODE1_FIXED_CODE.js index b9932688..e4176f02 100644 --- a/ticket_form/docs/CODE1_FIXED_CODE.js +++ b/ticket_form/docs/CODE1_FIXED_CODE.js @@ -211,3 +211,4 @@ const results = arr return results.length ? results : [{ json: null }]; + diff --git a/ticket_form/docs/CODE_MERGE_PROJECT_TO_SESSION.js b/ticket_form/docs/CODE_MERGE_PROJECT_TO_SESSION.js index 0603c425..5a514fa1 100644 --- a/ticket_form/docs/CODE_MERGE_PROJECT_TO_SESSION.js +++ b/ticket_form/docs/CODE_MERGE_PROJECT_TO_SESSION.js @@ -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 [ { diff --git a/ticket_form/docs/DATABASE_SCHEMA.md b/ticket_form/docs/DATABASE_SCHEMA.md index 211e3892..317ebd09 100644 --- a/ticket_form/docs/DATABASE_SCHEMA.md +++ b/ticket_form/docs/DATABASE_SCHEMA.md @@ -182,3 +182,4 @@ clpr_users (id) ``` + diff --git a/ticket_form/docs/N8N_CODE_NODE_RESPONSE.js b/ticket_form/docs/N8N_CODE_NODE_RESPONSE.js index e97b300f..a1b40c80 100644 --- a/ticket_form/docs/N8N_CODE_NODE_RESPONSE.js +++ b/ticket_form/docs/N8N_CODE_NODE_RESPONSE.js @@ -37,3 +37,4 @@ return { }; + diff --git a/ticket_form/docs/N8N_CODE_NODE_RESPONSE_SAFE.js b/ticket_form/docs/N8N_CODE_NODE_RESPONSE_SAFE.js index 1c8f2826..bfa89620 100644 --- a/ticket_form/docs/N8N_CODE_NODE_RESPONSE_SAFE.js +++ b/ticket_form/docs/N8N_CODE_NODE_RESPONSE_SAFE.js @@ -46,3 +46,4 @@ return { }; + diff --git a/ticket_form/docs/N8N_DESCRIPTION_WORKFLOW.md b/ticket_form/docs/N8N_DESCRIPTION_WORKFLOW.md new file mode 100644 index 00000000..5a3d8fb2 --- /dev/null +++ b/ticket_form/docs/N8N_DESCRIPTION_WORKFLOW.md @@ -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}) +``` + diff --git a/ticket_form/docs/N8N_FORM_APPROVAL_WORKFLOW.md b/ticket_form/docs/N8N_FORM_APPROVAL_WORKFLOW.md new file mode 100644 index 00000000..f671374f --- /dev/null +++ b/ticket_form/docs/N8N_FORM_APPROVAL_WORKFLOW.md @@ -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) + diff --git a/ticket_form/docs/N8N_FORM_GET_NO_FILES_BRANCH.json b/ticket_form/docs/N8N_FORM_GET_NO_FILES_BRANCH.json index 20b2a44a..ed6c415d 100644 --- a/ticket_form/docs/N8N_FORM_GET_NO_FILES_BRANCH.json +++ b/ticket_form/docs/N8N_FORM_GET_NO_FILES_BRANCH.json @@ -259,3 +259,4 @@ } } + diff --git a/ticket_form/docs/N8N_FORM_GET_NO_FILES_INSTRUCTIONS.md b/ticket_form/docs/N8N_FORM_GET_NO_FILES_INSTRUCTIONS.md index a5efbcc5..01f2c5c6 100644 --- a/ticket_form/docs/N8N_FORM_GET_NO_FILES_INSTRUCTIONS.md +++ b/ticket_form/docs/N8N_FORM_GET_NO_FILES_INSTRUCTIONS.md @@ -399,3 +399,4 @@ IF "проверка наличия файлов" **Дата:** 2025-11-21 **Статус:** Готово к внедрению ✅ + diff --git a/ticket_form/docs/N8N_MEMORY_ISSUES.md b/ticket_form/docs/N8N_MEMORY_ISSUES.md new file mode 100644 index 00000000..121307a4 --- /dev/null +++ b/ticket_form/docs/N8N_MEMORY_ISSUES.md @@ -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) + diff --git a/ticket_form/docs/N8N_REDIS_TRIGGER_TROUBLESHOOTING.md b/ticket_form/docs/N8N_REDIS_TRIGGER_TROUBLESHOOTING.md new file mode 100644 index 00000000..313bc468 --- /dev/null +++ b/ticket_form/docs/N8N_REDIS_TRIGGER_TROUBLESHOOTING.md @@ -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 + diff --git a/ticket_form/docs/N8N_RESPONSE_FORMAT.md b/ticket_form/docs/N8N_RESPONSE_FORMAT.md index 3521aab1..1eb1a17f 100644 --- a/ticket_form/docs/N8N_RESPONSE_FORMAT.md +++ b/ticket_form/docs/N8N_RESPONSE_FORMAT.md @@ -93,3 +93,4 @@ updateFormData({ 5. **Response** → возвращает полный ответ с `unified_id` + diff --git a/ticket_form/docs/N8N_RESPONSE_WITH_UNIFIED_ID.md b/ticket_form/docs/N8N_RESPONSE_WITH_UNIFIED_ID.md index 39fcc271..976aed47 100644 --- a/ticket_form/docs/N8N_RESPONSE_WITH_UNIFIED_ID.md +++ b/ticket_form/docs/N8N_RESPONSE_WITH_UNIFIED_ID.md @@ -143,3 +143,4 @@ return { ``` + diff --git a/ticket_form/docs/N8N_USER_CREATION_INSTRUCTIONS.md b/ticket_form/docs/N8N_USER_CREATION_INSTRUCTIONS.md index efddd069..4c646b69 100644 --- a/ticket_form/docs/N8N_USER_CREATION_INSTRUCTIONS.md +++ b/ticket_form/docs/N8N_USER_CREATION_INSTRUCTIONS.md @@ -132,3 +132,4 @@ WHERE ua.channel = 'web_form' Должна быть запись с `unified_id` в формате `usr_...`. + diff --git a/ticket_form/docs/PERSONAL_CABINET_ARCHITECTURE.md b/ticket_form/docs/PERSONAL_CABINET_ARCHITECTURE.md index bfd4f072..97a5f291 100644 --- a/ticket_form/docs/PERSONAL_CABINET_ARCHITECTURE.md +++ b/ticket_form/docs/PERSONAL_CABINET_ARCHITECTURE.md @@ -430,3 +430,4 @@ return claim; - ✅ Быстрая загрузка состояния формы + diff --git a/ticket_form/docs/REDIS_CLAIM_STORAGE_ANALYSIS.md b/ticket_form/docs/REDIS_CLAIM_STORAGE_ANALYSIS.md index 890926ee..b37a92b9 100644 --- a/ticket_form/docs/REDIS_CLAIM_STORAGE_ANALYSIS.md +++ b/ticket_form/docs/REDIS_CLAIM_STORAGE_ANALYSIS.md @@ -190,3 +190,4 @@ if (channel === 'telegram') { Все данные уже в PostgreSQL, и этого достаточно. Redis используется только для Pub/Sub событий (`ocr_events:{claim_id}`). + diff --git a/ticket_form/docs/REDIS_FORM_APPROVE.md b/ticket_form/docs/REDIS_FORM_APPROVE.md new file mode 100644 index 00000000..2b4fa50f --- /dev/null +++ b/ticket_form/docs/REDIS_FORM_APPROVE.md @@ -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 + diff --git a/ticket_form/docs/REDIS_VS_POSTGRESQL_SPEED.md b/ticket_form/docs/REDIS_VS_POSTGRESQL_SPEED.md index 47ad6273..55e88ebd 100644 --- a/ticket_form/docs/REDIS_VS_POSTGRESQL_SPEED.md +++ b/ticket_form/docs/REDIS_VS_POSTGRESQL_SPEED.md @@ -197,3 +197,4 @@ if (channel === 'web_form' && enable_cache === true) { Но это опционально и не обязательно для веб-формы. + diff --git a/ticket_form/docs/SESSION_LOG_2025-11-19.md b/ticket_form/docs/SESSION_LOG_2025-11-19.md index 9510e6dc..2accb7ad 100644 --- a/ticket_form/docs/SESSION_LOG_2025-11-19.md +++ b/ticket_form/docs/SESSION_LOG_2025-11-19.md @@ -71,3 +71,4 @@ 4. Если SQL работает, но asyncpg не возвращает данные - проверить формат параметров + diff --git a/ticket_form/docs/SESSION_LOG_2025-11-20.md b/ticket_form/docs/SESSION_LOG_2025-11-20.md index e64fc097..d8f70632 100644 --- a/ticket_form/docs/SESSION_LOG_2025-11-20.md +++ b/ticket_form/docs/SESSION_LOG_2025-11-20.md @@ -113,3 +113,4 @@ final_claim_id = row.get('claim_id') or claim_id_from_payload 3. Убедиться, что все данные корректно восстанавливаются в форму + diff --git a/ticket_form/docs/SQL_FIND_OR_CREATE_USER_WEB_FORM_N8N.md b/ticket_form/docs/SQL_FIND_OR_CREATE_USER_WEB_FORM_N8N.md index f9b9d0ab..5cc6a0e6 100644 --- a/ticket_form/docs/SQL_FIND_OR_CREATE_USER_WEB_FORM_N8N.md +++ b/ticket_form/docs/SQL_FIND_OR_CREATE_USER_WEB_FORM_N8N.md @@ -130,3 +130,4 @@ WITH existing AS ( 3. При создании/обновлении черновика заполнять `clpr_claims.unified_id = unified_id` + diff --git a/ticket_form/docs/WORKFLOW_ANALYSIS.md b/ticket_form/docs/WORKFLOW_ANALYSIS.md index 29457481..c6844408 100644 --- a/ticket_form/docs/WORKFLOW_ANALYSIS.md +++ b/ticket_form/docs/WORKFLOW_ANALYSIS.md @@ -210,3 +210,4 @@ SELECT - ✅ Правильное слияние `answers` и `documents_meta` + diff --git a/ticket_form/docs/n8n_code_error_response.js b/ticket_form/docs/n8n_code_error_response.js new file mode 100644 index 00000000..a31e2d84 --- /dev/null +++ b/ticket_form/docs/n8n_code_error_response.js @@ -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 + } + } + } + } +]; + diff --git a/ticket_form/docs/wizard_prompt_n8n.txt b/ticket_form/docs/wizard_prompt_n8n.txt index e98a204a..4d689313 100644 --- a/ticket_form/docs/wizard_prompt_n8n.txt +++ b/ticket_form/docs/wizard_prompt_n8n.txt @@ -112,3 +112,4 @@ Выполни задачу прямо сейчас и верни JSON согласно схеме. + diff --git a/ticket_form/frontend/src/components/form/StepClaimConfirmation.tsx b/ticket_form/frontend/src/components/form/StepClaimConfirmation.tsx new file mode 100644 index 00000000..572f36d4 --- /dev/null +++ b/ticket_form/frontend/src/components/form/StepClaimConfirmation.tsx @@ -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(null); + const [htmlContent, setHtmlContent] = useState(''); + + // 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(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 ( + +
+ +

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

+
+
+ ); + } + + return ( + <> + +