feat: Telegram Mini App integration and UX improvements

- Добавлена полная интеграция с Telegram Mini App (динамическая загрузка SDK)
- Отдельный компактный дизайн для Telegram Mini App
- Добавлен loader при инициализации (предотвращает мелькание SMS-авторизации)
- Улучшена навигация: кнопки "Назад" и "К списку заявок" теперь сохраняют авторизацию
- Telegram Mini App: кнопка "Выход" просто закрывает приложение
- Telegram Mini App: заявки "В работе" скрыты из списка
- Веб-версия: для заявок "В работе" добавлена кнопка "Просмотреть в Telegram" (ссылка на @klientprav_bot)
- Telegram Mini App: кнопки действий в черновиках расположены вертикально
- Веб-версия: убрано отображение номера телефона в приветствии
- Исправлена проблема с возвратом к списку черновиков (не требует повторной SMS-авторизации)
- Заблокировано удаление и редактирование заявок со статусом "В работе"
- Добавлена документация по Telegram Mini App интеграции
This commit is contained in:
AI Assistant
2026-01-29 16:12:48 +03:00
parent 73524465fd
commit 2e45786e46
57 changed files with 6776 additions and 234 deletions

137
CURRENT_SETUP.md Normal file
View File

@@ -0,0 +1,137 @@
# 📍 Текущая структура запущенных окружений
**Дата проверки:** 2 января 2025
---
## 🟢 DEV окружение (запущено)
**Рабочая папка:**
```
/var/www/fastuser/data/www/crm.clientright.ru/aiform_dev/
```
**Контейнеры:**
- `aiform_frontend_dev` → порт **5177** → http://147.45.146.17:5177/
- `aiform_backend_dev` → порт **8201**
**Docker Compose:**
- Файл: `aiform_dev/docker-compose.dev.yml`
- Запуск: `cd aiform_dev && docker-compose -f docker-compose.dev.yml up -d`
**Монтированные папки:**
- Frontend: `aiform_dev/frontend/src``/app/src` (read-only, для live reload)
- Backend: использует `aiform_dev/backend/.env`
**Git репозиторий:**
- Remote: `aiform_dev` → http://147.45.146.17:3002/negodiy/aiform_dev.git
---
## 🔴 PROD окружение (запущено)
**Рабочая папка:**
```
/var/www/fastuser/data/www/crm.clientright.ru/ticket_form/
```
**Контейнеры:**
- `ticket_form_frontend_prod` → порт **5176** → https://aiform.clientright.ru/
- `ticket_form_backend` → порт **8200** (network_mode: host)
**Docker Compose:**
- Файл: `ticket_form/docker-compose.prod.yml` (новый) или старый `docker-compose.yml`
- Запуск: `cd ticket_form && docker-compose -f docker-compose.prod.yml up -d`
**Git репозиторий:**
- Remote: `aiform_prod` → http://147.45.146.17:3002/negodiy/aiform_prod.git
- Remote: `origin` → http://147.45.146.17:3002/negodiy/erv-platform.git
---
## 📊 Сравнение
| | DEV | PROD |
|---|---|---|
| **Папка** | `/aiform_dev/` | `/ticket_form/` |
| **Frontend порт** | 5177 | 5176 |
| **Backend порт** | 8201 | 8200 |
| **URL** | http://147.45.146.17:5177/ | https://aiform.clientright.ru/ |
| **Docker Compose** | `aiform_dev/docker-compose.dev.yml` | `ticket_form/docker-compose.prod.yml` |
| **Git** | `aiform_dev` | `aiform_prod` |
---
## 🔄 Как переносить изменения
### Из DEV в PROD:
```bash
# 1. Работаете в DEV папке
cd /var/www/fastuser/data/www/crm.clientright.ru/aiform_dev
# Вносите изменения, тестируете
# 2. Копируете изменения в PROD папку (или через git)
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
git pull aiform_prod main # или копируете файлы вручную
# 3. Перезапускаете PROD
docker-compose -f docker-compose.prod.yml up -d --build
```
### Или через git (рекомендуется):
```bash
# В DEV папке
cd /var/www/fastuser/data/www/crm.clientright.ru/aiform_dev
git add .
git commit -m "feat: Описание"
git push aiform_dev main
# В PROD папке
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
git pull aiform_prod main
docker-compose -f docker-compose.prod.yml up -d --build
```
---
## ⚠️ Важно
1. **DEV и PROD — это разные папки:**
- DEV: `/aiform_dev/`
- PROD: `/ticket_form/`
2. **Изменения в DEV не попадают в PROD автоматически** — нужно копировать/пушить через git
3. **У каждого окружения свой `.env` файл:**
- DEV: `aiform_dev/backend/.env`
- PROD: `ticket_form/.env`
---
## 🛠️ Полезные команды
```bash
# Проверить статус DEV
cd /var/www/fastuser/data/www/crm.clientright.ru/aiform_dev
docker-compose -f docker-compose.dev.yml ps
# Проверить статус PROD
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
docker-compose -f docker-compose.prod.yml ps
# Логи DEV
docker logs aiform_frontend_dev -f
docker logs aiform_backend_dev -f
# Логи PROD
docker logs ticket_form_frontend_prod -f
docker logs ticket_form_backend -f
```
---
**Автор:** AI Assistant + Фёдор
**Дата:** 2 января 2025

203
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,203 @@
# 🚀 Руководство по деплою: DEV → PROD
## 📍 Текущая структура
- **DEV:** http://147.45.146.17:5177/ (папка `aiform_dev/`)
- **PROD:** https://aiform.clientright.ru/ (домен продакшна)
---
## 🎯 Быстрый перенос изменений (1 команда)
```bash
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
./deploy-to-prod.sh
```
Этот скрипт:
1. ✅ Проверит незакоммиченные изменения
2. ✅ Отправит код в git репозитории (dev и prod)
3. ✅ Пересоберёт PROD контейнеры
4. ✅ Перезапустит PROD окружение
---
## 📝 Пошаговый процесс (вручную)
### Шаг 1: Сохранить изменения в git
```bash
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
# Проверить что изменилось
git status
# Добавить изменения
git add .
# Закоммитить
git commit -m "feat: Описание изменений"
# Отправить в dev репозиторий
git push aiform_dev main # или master
```
### Шаг 2: Отправить в prod репозиторий
```bash
# Отправить в prod
git push aiform_prod main # или master
```
### Шаг 3: Обновить PROD контейнеры
```bash
# Пересобрать
docker-compose -f docker-compose.prod.yml build
# Перезапустить
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml up -d
```
---
## 🔧 Про .env файл
### Почему один .env, а не два?
**✅ Преимущества одного .env:**
- Проще поддерживать (один файл вместо двух)
- Меньше путаницы
- Режим переключается через переменную `APP_ENV` в docker-compose
**Как это работает:**
В `docker-compose.dev.yml`:
```yaml
environment:
- APP_ENV=development # Переопределяет значение из .env
- DEBUG=true
```
В `docker-compose.prod.yml`:
```yaml
environment:
- APP_ENV=production # Переопределяет значение из .env
- DEBUG=false
```
**Ваш `.env` файл остаётся один**, но docker-compose переопределяет нужные переменные для каждого окружения.
---
## 📊 Структура репозиториев
```
Gitea (http://147.45.146.17:3002/negodiy):
├─ aiform_dev → DEV версия (http://147.45.146.17:5177/)
├─ aiform_prod → PROD версия (https://aiform.clientright.ru/)
└─ erv-platform → Основной репозиторий
```
**Локальные папки:**
- `/var/www/.../aiform_dev/` → DEV окружение
- `/var/www/.../ticket_form/` → Основной проект (может быть и DEV и PROD)
---
## 🔄 Типичный workflow
### 1. Разработка в DEV
```bash
cd /var/www/fastuser/data/www/crm.clientright.ru/aiform_dev
# или
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
# Вносите изменения
# Тестируете на http://147.45.146.17:5177/
```
### 2. Когда готово → деплой в PROD
```bash
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
# Вариант 1: Автоматический (рекомендуется)
./deploy-to-prod.sh
# Вариант 2: Вручную
git add .
git commit -m "feat: Описание"
git push aiform_prod main
docker-compose -f docker-compose.prod.yml up -d --build
```
### 3. Проверка PROD
```bash
# Проверить статус
docker-compose -f docker-compose.prod.yml ps
# Проверить логи
docker-compose -f docker-compose.prod.yml logs -f
# Открыть в браузере
# https://aiform.clientright.ru/
```
---
## ⚠️ Важные моменты
1. **Всегда тестируйте в DEV перед деплоем в PROD**
2. **Проверяйте `.env` файл** — убедитесь что там правильные настройки
3. **В PROD `APP_ENV=production` и `DEBUG=false`** (устанавливается через docker-compose)
4. **Не коммитьте `.env`** — он в `.gitignore`
5. **После деплоя проверяйте логи**`docker-compose -f docker-compose.prod.yml logs`
---
## 🐛 Откат изменений (если что-то пошло не так)
```bash
# Откатить к предыдущему коммиту
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
git log --oneline -5 # Найти нужный коммит
git checkout <commit-hash>
git push aiform_prod main --force
# Пересобрать
docker-compose -f docker-compose.prod.yml up -d --build
```
---
## 📞 Полезные команды
```bash
# Статус контейнеров
docker ps | grep aiform
# Логи DEV
docker logs aiform_frontend_dev -f
docker logs aiform_backend_dev -f
# Логи PROD
docker logs ticket_form_frontend_prod -f
docker logs ticket_form_backend_prod -f
# Перезапуск PROD
docker-compose -f docker-compose.prod.yml restart
# Полная пересборка PROD
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml up -d --build
```
---
**Автор:** AI Assistant + Фёдор
**Дата:** 2 января 2025

264
ENVIRONMENTS.md Normal file
View File

@@ -0,0 +1,264 @@
# 🚀 Руководство по DEV и PROD окружениям
## 📋 Обзор
Проект поддерживает два отдельных окружения:
- **DEV** (Development) — для разработки и тестирования
- **PROD** (Production) — для продакшна
---
## 🏗️ Структура файлов
```
ticket_form/
├─ docker-compose.dev.yml ← Конфигурация для разработки
├─ docker-compose.prod.yml ← Конфигурация для продакшна
├─ .env.dev ← Переменные окружения для DEV
├─ .env.prod ← Переменные окружения для PROD
├─ .env.example ← Шаблон переменных окружения
├─ start-dev.sh ← Скрипт запуска DEV
├─ start-prod.sh ← Скрипт запуска PROD
└─ ENVIRONMENTS.md ← Эта документация
```
---
## 🛠️ Быстрый старт
### 1. Первоначальная настройка
```bash
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
# Создаём .env файлы из шаблона
cp .env.example .env.dev
cp .env.example .env.prod
# Редактируем .env.dev (для разработки)
nano .env.dev
# Установите: APP_ENV=development, DEBUG=true
# Редактируем .env.prod (для продакшна)
nano .env.prod
# Установите: APP_ENV=production, DEBUG=false
# Проверьте все URL и API ключи
```
### 2. Запуск DEV окружения
```bash
# Вариант 1: Используя скрипт (рекомендуется)
./start-dev.sh
# Вариант 2: Вручную
docker-compose -f docker-compose.dev.yml up -d --build
```
**Доступ:**
- Frontend: http://localhost:5175
- Backend: http://localhost:8200
- API Docs: http://localhost:8200/docs
### 3. Запуск PROD окружения
```bash
# Вариант 1: Используя скрипт (рекомендуется)
./start-prod.sh
# Вариант 2: Вручную
docker-compose -f docker-compose.prod.yml up -d --build
```
**Доступ:**
- Frontend: http://localhost:5176
- Backend: http://localhost:8200
- API Docs: http://localhost:8200/docs
---
## 🔍 Различия между DEV и PROD
| Параметр | DEV | PROD |
|----------|-----|------|
| **Порты** | 5175 (frontend), 8200 (backend) | 5176 (frontend), 8200 (backend) |
| **Контейнеры** | `*_dev` | `*_prod` |
| **PostgreSQL** | Локальный контейнер (порт 5433) | Внешний (147.45.189.234:5432) |
| **Redis** | Локальный контейнер (порт 6380) | Системный (localhost:6379) |
| **Debug** | ✅ Включен | ❌ Выключен |
| **Логи** | DEBUG уровень | INFO уровень |
| **Hot Reload** | ✅ Включен | ❌ Выключен |
| **Build** | Dev режим | Production оптимизация |
| **Healthcheck** | ❌ Нет | ✅ Есть |
---
## 📝 Управление окружениями
### Остановка
```bash
# Остановить DEV
docker-compose -f docker-compose.dev.yml down
# Остановить PROD
docker-compose -f docker-compose.prod.yml down
```
### Просмотр логов
```bash
# Логи DEV
docker-compose -f docker-compose.dev.yml logs -f
# Логи PROD
docker-compose -f docker-compose.prod.yml logs -f
# Логи конкретного сервиса
docker-compose -f docker-compose.dev.yml logs -f ticket_form_backend_dev
```
### Перезапуск
```bash
# Перезапуск DEV
docker-compose -f docker-compose.dev.yml restart
# Перезапуск PROD
docker-compose -f docker-compose.prod.yml restart
```
### Пересборка
```bash
# Пересборка DEV
docker-compose -f docker-compose.dev.yml up -d --build
# Пересборка PROD
docker-compose -f docker-compose.prod.yml up -d --build
```
---
## 🔐 Переменные окружения
### Основные переменные
| Переменная | DEV значение | PROD значение |
|------------|--------------|---------------|
| `APP_ENV` | `development` | `production` |
| `DEBUG` | `true` | `false` |
| `LOG_LEVEL` | `DEBUG` | `INFO` |
| `VITE_API_URL` | `http://localhost:8200` | `https://aiform.clientright.ru/api` |
| `NODE_ENV` | `development` | `production` |
### Базы данных
**DEV:**
- PostgreSQL: `ticket_form_postgres_dev` (контейнер, порт 5433)
- Redis: `ticket_form_redis_dev` (контейнер, порт 6380)
**PROD:**
- PostgreSQL: `147.45.189.234:5432` (внешний)
- Redis: `localhost:6379` (системный)
- MySQL: `localhost:3306` (системный)
---
## 🐛 Отладка
### Проверка статуса
```bash
# Статус DEV контейнеров
docker-compose -f docker-compose.dev.yml ps
# Статус PROD контейнеров
docker-compose -f docker-compose.prod.yml ps
# Все контейнеры проекта
docker ps | grep ticket_form
```
### Проверка подключений
```bash
# Проверка backend health
curl http://localhost:8200/health
# Проверка frontend
curl http://localhost:5175
# Проверка PostgreSQL (DEV)
docker exec -it ticket_form_postgres_dev psql -U erv_user -d erv_db_dev
# Проверка Redis (DEV)
docker exec -it ticket_form_redis_dev redis-cli -a redis_dev_pass ping
```
---
## 📦 Git репозитории
### Структура репозиториев
- **`erv-platform`** (origin) — основной репозиторий
- **`aiform_prod`** — production версия
- **`aiform_dev`** — development версия (в папке `aiform_dev/`)
### Работа с Git
```bash
# Push в основной репозиторий
git push origin main
# Push в prod репозиторий
git push aiform_prod main
# Push в оба
git push origin main && git push aiform_prod main
```
---
## ⚠️ Важные замечания
1. **Никогда не коммитьте `.env.dev` и `.env.prod`** — они в `.gitignore`
2. **Всегда проверяйте `.env.prod`** перед деплоем в продакшн
3. **DEV и PROD могут работать одновременно** на разных портах
4. **В PROD используйте внешние БД** — не создавайте локальные контейнеры
5. **Healthcheck в PROD** — проверяйте статус регулярно
---
## 🔄 Миграция с текущей структуры
Если у вас уже запущены контейнеры со старыми именами:
```bash
# Остановите старые контейнеры
docker stop ticket_form_frontend ticket_form_backend ticket_form_frontend_prod
# Удалите старые контейнеры (опционально)
docker rm ticket_form_frontend ticket_form_backend ticket_form_frontend_prod
# Запустите новые через скрипты
./start-dev.sh
./start-prod.sh
```
---
## 📞 Поддержка
При проблемах:
1. Проверьте логи: `docker-compose -f docker-compose.*.yml logs`
2. Проверьте статус: `docker-compose -f docker-compose.*.yml ps`
3. Проверьте `.env` файлы на корректность
4. Убедитесь, что порты не заняты: `netstat -tulpn | grep -E "5175|5176|8200"`
---
**Автор:** AI Assistant + Фёдор
**Дата:** 2 января 2025

94
README_ENVIRONMENTS.md Normal file
View File

@@ -0,0 +1,94 @@
# 🚀 Быстрый старт: DEV и PROD окружения
## 📦 Что создано
`docker-compose.dev.yml` - конфигурация для разработки
`docker-compose.prod.yml` - конфигурация для продакшна
`start-dev.sh` - скрипт запуска DEV
`start-prod.sh` - скрипт запуска PROD
`.env.example` - шаблон переменных окружения
`ENVIRONMENTS.md` - полная документация
---
## 🎯 Быстрый старт (3 шага)
### Шаг 1: Создайте .env файлы
```bash
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
# Создаём из шаблона
cp .env.example .env.dev
cp .env.example .env.prod
# Редактируем DEV
nano .env.dev
# Установите: APP_ENV=development, DEBUG=true
# Редактируем PROD
nano .env.prod
# Установите: APP_ENV=production, DEBUG=false
# Проверьте все URL и ключи!
```
### Шаг 2: Запустите DEV
```bash
./start-dev.sh
```
**Доступ:** http://localhost:5175
### Шаг 3: Запустите PROD (когда готово)
```bash
./start-prod.sh
```
**Доступ:** http://localhost:5176
---
## 📊 Основные команды
```bash
# Остановить DEV
docker-compose -f docker-compose.dev.yml down
# Остановить PROD
docker-compose -f docker-compose.prod.yml down
# Логи DEV
docker-compose -f docker-compose.dev.yml logs -f
# Логи PROD
docker-compose -f docker-compose.prod.yml logs -f
# Статус
docker-compose -f docker-compose.dev.yml ps
docker-compose -f docker-compose.prod.yml ps
```
---
## 🔍 Различия
| | DEV | PROD |
|---|---|---|
| **Порты** | 5175, 8200 | 5176, 8200 |
| **PostgreSQL** | Локальный контейнер | Внешний (147.45.189.234) |
| **Redis** | Локальный контейнер | Системный (localhost) |
| **Debug** | ✅ Включен | ❌ Выключен |
| **Hot Reload** | ✅ Да | ❌ Нет |
---
## 📖 Полная документация
Смотрите `ENVIRONMENTS.md` для детальной информации.
---
**Всё готово к работе!** 🎉

60
backend/app/api/banks.py Normal file
View File

@@ -0,0 +1,60 @@
"""
Banks API - получение списка банков СБП
"""
from fastapi import APIRouter, HTTPException
import httpx
import logging
from ..config import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/banks", tags=["Banks"])
@router.get("/nspk")
async def get_nspk_banks():
"""
Получить список банков СБП из внешнего API
Проксирует запрос для избежания Mixed Content ошибок (HTTPS -> HTTP)
"""
try:
# URL внешнего API
external_api_url = "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(external_api_url)
if response.status_code != 200:
logger.error(f"Failed to fetch banks: HTTP {response.status_code}")
raise HTTPException(
status_code=response.status_code,
detail=f"Failed to fetch banks list: {response.status_code}"
)
banks_data = response.json()
logger.info(f"✅ Loaded {len(banks_data)} banks from external API")
return banks_data
except httpx.TimeoutException:
logger.error("Timeout while fetching banks")
raise HTTPException(
status_code=504,
detail="Timeout while fetching banks list"
)
except httpx.RequestError as e:
logger.error(f"Request error while fetching banks: {e}")
raise HTTPException(
status_code=502,
detail=f"Failed to connect to banks API: {str(e)}"
)
except Exception as e:
logger.error(f"Unexpected error while fetching banks: {e}")
raise HTTPException(
status_code=500,
detail=f"Internal error: {str(e)}"
)

View File

@@ -13,10 +13,11 @@ import uuid
from datetime import datetime from datetime import datetime
import json import json
import logging import logging
import asyncio
from ..services.redis_service import redis_service from ..services.redis_service import redis_service
from ..services.database import db from ..services.database import db
from ..services.crm_mysql_service import crm_mysql_service from ..services.crm_mysql_service import crm_mysql_service
from ..services.n8n_service import check_workflow_status, restart_workflow, MIN_RESTART_INTERVAL # Убрали импорты из n8n_service - больше не нужны для webhook подхода
from ..config import settings from ..config import settings
router = APIRouter(prefix="/api/v1/claims", tags=["Claims"]) router = APIRouter(prefix="/api/v1/claims", tags=["Claims"])
@@ -241,7 +242,7 @@ async def list_drafts(
OR c.payload->>'phone' = $2 OR c.payload->>'phone' = $2
OR c.payload->>'phone' = $3 OR c.payload->>'phone' = $3
) )
AND (c.status_code != 'approved' OR c.status_code IS NULL) AND (c.status_code NOT IN ('approved', 'in_work', 'submitted', 'completed', 'rejected') OR c.status_code IS NULL)
AND (c.is_confirmed IS NULL OR c.is_confirmed = false) AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
ORDER BY c.updated_at DESC ORDER BY c.updated_at DESC
LIMIT 20 LIMIT 20
@@ -268,7 +269,7 @@ async def list_drafts(
c.updated_at c.updated_at
FROM clpr_claims c FROM clpr_claims c
WHERE c.session_token = $1 WHERE c.session_token = $1
AND (c.status_code != 'approved' OR c.status_code IS NULL) AND (c.status_code NOT IN ('approved', 'in_work', 'submitted', 'completed', 'rejected') OR c.status_code IS NULL)
AND (c.is_confirmed IS NULL OR c.is_confirmed = false) AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
ORDER BY c.updated_at DESC ORDER BY c.updated_at DESC
LIMIT 20 LIMIT 20
@@ -392,10 +393,11 @@ async def list_drafts(
# Формируем список документов со статусами # Формируем список документов со статусами
documents_list = [] documents_list = []
for doc_req in documents_required: for doc_req in documents_required:
doc_name = doc_req.get('name', 'Документ') # Пробуем разные поля для названия документа (field_label приоритетнее)
doc_name = doc_req.get('field_label') or doc_req.get('name') or 'Документ'
doc_id = doc_req.get('id', '') doc_id = doc_req.get('id', '')
is_required = doc_req.get('required', False) is_required = doc_req.get('required', False)
# Проверяем загружен ли (по name или id) # Проверяем загружен ли (по field_label или name)
is_uploaded = doc_name in uploaded_labels or doc_id in uploaded_labels is_uploaded = doc_name in uploaded_labels or doc_id in uploaded_labels
documents_list.append({ documents_list.append({
"name": doc_name, "name": doc_name,
@@ -498,10 +500,40 @@ async def get_draft(claim_id: str):
# 🔍 ОТЛАДКА: Логируем наличие documents_required # 🔍 ОТЛАДКА: Логируем наличие documents_required
documents_required = payload.get('documents_required', []) if isinstance(payload, dict) else [] documents_required = payload.get('documents_required', []) if isinstance(payload, dict) else []
documents_meta = payload.get('documents_meta', []) if isinstance(payload, dict) else []
logger.info(f"🔍 Черновик {final_claim_id}: status_code={row.get('status_code')}, documents_required count={len(documents_required) if isinstance(documents_required, list) else 0}") logger.info(f"🔍 Черновик {final_claim_id}: status_code={row.get('status_code')}, documents_required count={len(documents_required) if isinstance(documents_required, list) else 0}")
if documents_required: if documents_required:
logger.info(f"🔍 documents_required: {documents_required[:2]}...") # Первые 2 для примера logger.info(f"🔍 documents_required: {documents_required[:2]}...") # Первые 2 для примера
# Подсчет документов (как в списке черновиков)
documents_required_list = documents_required if isinstance(documents_required, list) else []
documents_meta_list = documents_meta if isinstance(documents_meta, list) else []
# Считаем загруженные (уникальные по field_label)
uploaded_labels = set()
for doc in documents_meta_list:
label = doc.get('field_label') or doc.get('field_name')
if label:
uploaded_labels.add(label)
documents_uploaded = len(uploaded_labels)
documents_total = len(documents_required_list) if documents_required_list else 0
# Формируем список документов со статусами
documents_list = []
for doc_req in documents_required_list:
# Пробуем разные поля для названия документа (field_label приоритетнее)
doc_name = doc_req.get('field_label') or doc_req.get('name') or 'Документ'
doc_id = doc_req.get('id', '')
is_required = doc_req.get('required', False)
# Проверяем загружен ли (по field_label или name)
is_uploaded = doc_name in uploaded_labels or doc_id in uploaded_labels
documents_list.append({
"name": doc_name,
"required": is_required,
"uploaded": is_uploaded,
})
# ✅ Проверяем флаг подтверждения данных контакта из CRM (поле cf_2624) # ✅ Проверяем флаг подтверждения данных контакта из CRM (поле cf_2624)
# Простой способ: делаем прямой SQL запрос к БД (таблицы vtiger_*) # Простой способ: делаем прямой SQL запрос к БД (таблицы vtiger_*)
# ПРИМЕЧАНИЕ: Если таблицы vtiger_* находятся в MySQL (а не PostgreSQL), # ПРИМЕЧАНИЕ: Если таблицы vtiger_* находятся в MySQL (а не PostgreSQL),
@@ -604,7 +636,11 @@ async def get_draft(claim_id: str):
"channel": row.get('channel'), "channel": row.get('channel'),
"created_at": row['created_at'].isoformat() if row.get('created_at') else None, "created_at": row['created_at'].isoformat() if row.get('created_at') else None,
"updated_at": row['updated_at'].isoformat() if row.get('updated_at') else None, "updated_at": row['updated_at'].isoformat() if row.get('updated_at') else None,
"payload": payload "payload": payload,
# Информация о документах
"documents_total": documents_total,
"documents_uploaded": documents_uploaded,
"documents_list": documents_list,
}, },
# ✅ Флаги подтверждения данных контакта (из CRM поля cf_2624) # ✅ Флаги подтверждения данных контакта (из CRM поля cf_2624)
"contact_data_confirmed": contact_data_confirmed, "contact_data_confirmed": contact_data_confirmed,
@@ -908,48 +944,95 @@ async def load_wizard_data(claim_id: str):
raise HTTPException(status_code=500, detail=f"Ошибка при загрузке данных визарда: {str(e)}") raise HTTPException(status_code=500, detail=f"Ошибка при загрузке данных визарда: {str(e)}")
async def _check_and_restart_workflow_if_needed(channel: str): async def _send_buffered_messages_to_webhook():
""" """
Проверяет и перезапускает workflow если нужно (в фоне) Отправляет все сообщения из буфера в n8n webhook (вместо Redis pub/sub)
Защита от частых перезапусков через Redis lock
""" """
try: try:
# Проверяем lock - если недавно перезапускали, пропускаем if not settings.n8n_description_webhook:
lock_key = f"workflow_restart_lock:{channel}" logger.error("❌ N8N description webhook не настроен, не могу отправить из буфера")
lock_value = await redis_service.get(lock_key)
if lock_value:
logger.info(f"⏸️ Workflow недавно перезапускался, пропускаем (lock active)")
return return
# Проверяем статус workflow buffer_key = "description"
workflow_data = await check_workflow_status() messages = await redis_service.buffer_get_all(buffer_key)
if not messages:
logger.info("📭 Буфер пуст, нечего отправлять")
return
logger.info(f"📤 Отправляю {len(messages)} сообщений из буфера в n8n webhook...")
sent_count = 0
failed_count = 0
async with httpx.AsyncClient(timeout=10.0) as client:
for buffered_message in messages:
try:
# Восстанавливаем формат для n8n: массив с channel и message
channel = buffered_message.get("channel", f"{settings.redis_prefix}description")
message_data = buffered_message.get("message", buffered_message.get("event", buffered_message))
webhook_payload = [
{
"channel": channel,
"message": message_data
}
]
response = await client.post(
settings.n8n_description_webhook,
json=webhook_payload, # Отправляем в формате массива
headers={"Content-Type": "application/json"}
)
if response.status_code == 200:
sent_count += 1
logger.info(
f"✅ Буферированное сообщение отправлено: "
f"session_id={buffered_message.get('session_id', 'unknown')}"
)
# НЕ возвращаем в буфер - успешно отправили
else:
# HTTP ошибка - возвращаем в буфер
failed_count += 1
logger.warning(
f"⚠️ n8n вернул ошибку {response.status_code}, "
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}"
)
await redis_service.buffer_push(buffer_key, buffered_message)
except httpx.TimeoutException:
failed_count += 1
logger.warning(
f"⏱️ Таймаут при отправке из буфера, "
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}"
)
await redis_service.buffer_push(buffer_key, buffered_message)
except httpx.RequestError as e:
failed_count += 1
logger.error(
f"🔌 Ошибка подключения к n8n: {e}, "
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}"
)
await redis_service.buffer_push(buffer_key, buffered_message)
except Exception as e:
failed_count += 1
logger.error(
f"❌ Неожиданная ошибка при отправке из буфера: {e}, "
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}",
exc_info=True
)
await redis_service.buffer_push(buffer_key, buffered_message)
logger.info(
f"📊 Результат отправки буфера: {sent_count} отправлено, "
f"{failed_count} возвращено в буфер"
)
if workflow_data:
is_active = workflow_data.get("active", False)
if not is_active:
logger.warning(f"⚠️ Workflow НЕ активен! Активирую и перезапускаю...")
# Workflow выключен — нужно его ВКЛЮЧИТЬ
else:
logger.info(
f"⚠️ Workflow активен, но нет подписчиков. Перезапускаю workflow..."
)
# Устанавливаем lock на MIN_RESTART_INTERVAL секунд
await redis_service.set(lock_key, "1", expire=MIN_RESTART_INTERVAL)
# Перезапускаем
success = await restart_workflow()
if success:
logger.info("✅ Workflow успешно перезапущен")
else:
logger.error("Не удалось перезапустить workflow")
else:
logger.warning("⚠️ Не удалось проверить статус workflow, пропускаем перезапуск")
except Exception as e: except Exception as e:
logger.exception(f"❌ Ошибка при проверке/перезапуске workflow: {e}") logger.exception(f"❌ Ошибка при отправке буфера: {e}")
@router.post("/description") @router.post("/description")
@@ -958,12 +1041,18 @@ async def publish_ticket_form_description(
background_tasks: BackgroundTasks background_tasks: BackgroundTasks
): ):
""" """
Публикует свободное описание проблемы в Redis канал ticket_form:description Отправляет описание проблемы в n8n через webhook (вместо Redis pub/sub)
(слушается воркфлоу в n8n)
""" """
try: try:
if not settings.n8n_description_webhook:
raise HTTPException(
status_code=500,
detail="N8N description webhook не настроен"
)
# Формируем данные в формате, который ожидает n8n workflow
channel = payload.channel or f"{settings.redis_prefix}description" channel = payload.channel or f"{settings.redis_prefix}description"
event = { message = {
"type": "ticket_form_description", "type": "ticket_form_description",
"session_id": payload.session_id, "session_id": payload.session_id,
"claim_id": payload.claim_id, # Опционально - может быть None "claim_id": payload.claim_id, # Опционально - может быть None
@@ -976,7 +1065,13 @@ async def publish_ticket_form_description(
"timestamp": datetime.utcnow().isoformat(), "timestamp": datetime.utcnow().isoformat(),
} }
event_json = json.dumps(event, ensure_ascii=False) # n8n workflow ожидает массив с объектом, содержащим channel и message
webhook_payload = [
{
"channel": channel,
"message": message
}
]
logger.info( logger.info(
"📝 TicketForm description received", "📝 TicketForm description received",
@@ -991,81 +1086,111 @@ async def publish_ticket_form_description(
}, },
) )
logger.info( # Retry-логика: пытаемся отправить в n8n webhook
"📡 Publishing to Redis channel", max_attempts = 3
extra={ initial_delay = 1 # секунды
"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) for attempt in range(1, max_attempts + 1):
try:
logger.info( logger.info(
"✅ TicketForm description published to Redis", f"🔄 Попытка {attempt}/{max_attempts}: отправка в n8n webhook",
extra={ extra={"session_id": payload.session_id}
"channel": channel, )
"session_id": payload.session_id,
"subscribers_count": subscribers_count, async with httpx.AsyncClient(timeout=10.0) as client:
"event_json_preview": event_json[:500], response = await client.post(
}, settings.n8n_description_webhook,
) json=webhook_payload, # Отправляем в формате массива
headers={"Content-Type": "application/json"}
if subscribers_count == 0: )
logger.warning(
f"⚠️ WARNING: No subscribers on channel {channel}! " if response.status_code == 200:
f"n8n workflow is not listening to this channel. " logger.info(
f"Saving message to buffer and restarting workflow..." f"✅ Описание успешно отправлено в n8n webhook (попытка {attempt})",
) extra={
"session_id": payload.session_id,
"status_code": response.status_code,
}
)
# Успешно отправили - возвращаем успех
return {
"success": True,
"event": message,
"attempt": attempt,
}
else:
# HTTP ошибка (не 200)
logger.warning(
f"⚠️ Попытка {attempt}: n8n вернул статус {response.status_code}",
extra={
"session_id": payload.session_id,
"status_code": response.status_code,
"response_preview": response.text[:200],
}
)
except httpx.TimeoutException:
logger.warning(
f"⏱️ Попытка {attempt}: таймаут при отправке в n8n webhook",
extra={"session_id": payload.session_id}
)
except httpx.RequestError as e:
logger.warning(
f"🔌 Попытка {attempt}: ошибка подключения к n8n: {e}",
extra={"session_id": payload.session_id}
)
except Exception as e:
logger.error(
f"❌ Попытка {attempt}: неожиданная ошибка: {e}",
extra={"session_id": payload.session_id},
exc_info=True
)
# Сохраняем сообщение в буфер для последующей отправки # Если это не последняя попытка - ждём перед следующей
buffer_message = { if attempt < max_attempts:
"session_id": payload.session_id, wait_time = initial_delay * (2 ** (attempt - 1)) # Экспоненциальный backoff
"claim_id": payload.claim_id, logger.info(f"⏳ Жду {wait_time} секунд перед следующей попыткой...")
"event": event, await asyncio.sleep(wait_time)
"timestamp": datetime.utcnow().isoformat(),
}
await redis_service.buffer_push("description", buffer_message)
logger.info(f"💾 Сообщение сохранено в буфер: session_id={payload.session_id}")
# Запускаем проверку и перезапуск workflow в фоне
background_tasks.add_task(_check_and_restart_workflow_if_needed, channel)
# Дополнительная проверка: логируем полный event для отладки # Все попытки исчерпаны - сохраняем в буфер
logger.debug( logger.error(
"🔍 Full event data published", f"Все {max_attempts} попытки исчерпаны, сохраняю в буфер",
extra={ extra={"session_id": payload.session_id}
"channel": channel,
"event": event,
},
) )
# Формируем ответ с информацией о подписчиках
response_data = { buffer_message = {
"success": True, "session_id": payload.session_id,
"claim_id": payload.claim_id,
"channel": channel, "channel": channel,
"subscribers_count": subscribers_count, "message": message, # Сохраняем message для последующей отправки
"event": event, "timestamp": datetime.utcnow().isoformat(),
} }
await redis_service.buffer_push("description", buffer_message)
logger.info(f"💾 Сообщение сохранено в буфер: session_id={payload.session_id}")
# Если подписчиков нет - сообщаем что обработка займёт больше времени # Запускаем фоновую задачу для отправки из буфера
if subscribers_count == 0: background_tasks.add_task(_send_buffered_messages_to_webhook)
buffer_size = await redis_service.buffer_size("description")
response_data["warning"] = ( buffer_size = await redis_service.buffer_size("description")
return {
"success": True,
"event": message,
"buffered": True,
"warning": (
"Обработка вашего обращения займёт немного больше времени. " "Обработка вашего обращения займёт немного больше времени. "
"Идёт автоматическое восстановление системы. " "Идёт автоматическое восстановление системы. "
"Ваше сообщение сохранено и будет обработано в ближайшее время." "Ваше сообщение сохранено и будет обработано в ближайшее время."
) ),
response_data["workflow_recovering"] = True "buffer_size": buffer_size,
response_data["message_buffered"] = True }
response_data["buffer_size"] = buffer_size
return response_data except HTTPException:
raise
except Exception as e: except Exception as e:
logger.exception("❌ Failed to publish ticket form description") logger.exception("❌ Failed to send ticket form description to n8n")
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail=f"Не удалось опубликовать описание: {e}" detail=f"Не удалось отправить описание: {e}"
) )

View File

@@ -20,6 +20,7 @@ N8N_POLICY_CHECK_WEBHOOK = settings.n8n_policy_check_webhook or None
N8N_FILE_UPLOAD_WEBHOOK = settings.n8n_file_upload_webhook or None N8N_FILE_UPLOAD_WEBHOOK = settings.n8n_file_upload_webhook or None
N8N_CREATE_CONTACT_WEBHOOK = settings.n8n_create_contact_webhook N8N_CREATE_CONTACT_WEBHOOK = settings.n8n_create_contact_webhook
N8N_CREATE_CLAIM_WEBHOOK = settings.n8n_create_claim_webhook N8N_CREATE_CLAIM_WEBHOOK = settings.n8n_create_claim_webhook
N8N_TG_AUTH_WEBHOOK = settings.n8n_tg_auth_webhook or None
@router.post("/policy/check") @router.post("/policy/check")
@@ -219,6 +220,72 @@ async def proxy_file_upload(
raise HTTPException(status_code=500, detail=f"Ошибка загрузки файла: {str(e)}") raise HTTPException(status_code=500, detail=f"Ошибка загрузки файла: {str(e)}")
@router.post("/tg/auth")
async def proxy_telegram_auth(request: Request):
"""
Проксирует авторизацию Telegram WebApp (Mini App) в n8n webhook.
Используется backend-эндпоинтом /api/v1/tg/auth:
- backend валидирует initData
- затем вызывает этот роут для маппинга telegram_user_id → unified_id в n8n
"""
if not N8N_TG_AUTH_WEBHOOK:
logger.error("[TG] N8N_TG_AUTH_WEBHOOK не задан в .env — webhook не вызывается")
raise HTTPException(status_code=500, detail="N8N Telegram auth webhook не настроен")
try:
body = await request.json()
logger.info(
"[TG] Proxy → n8n webhook %s: telegram_user_id=%s, session_token=%s",
N8N_TG_AUTH_WEBHOOK[:50] + "...",
body.get("telegram_user_id", "unknown"),
body.get("session_token", "unknown"),
)
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
N8N_TG_AUTH_WEBHOOK,
json=body,
headers={"Content-Type": "application/json"},
)
response_text = response.text or ""
logger.info("[TG] n8n webhook ответ: status=%s, body длина=%s", response.status_code, len(response_text))
if response.status_code == 200:
logger.info(
"[TG] n8n webhook success. Response: %s",
response_text[:500],
)
try:
return response.json()
except Exception as e:
logger.error(
"❌ Failed to parse Telegram auth JSON: %s. Response: %s",
e,
response_text[:500],
)
raise HTTPException(status_code=500, detail=f"Ошибка парсинга ответа n8n: {str(e)}")
logger.error(
"[TG] n8n webhook вернул ошибку %s: %s",
response.status_code,
response_text[:500],
)
raise HTTPException(
status_code=response.status_code,
detail=f"N8N Telegram auth error: {response_text}",
)
except httpx.TimeoutException:
logger.error("[TG] Таймаут при вызове n8n Telegram auth webhook")
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n (Telegram auth)")
except Exception as e:
logger.exception("[TG] Ошибка при вызове n8n Telegram auth: %s", e)
raise HTTPException(status_code=500, detail=f"Ошибка авторизации Telegram: {str(e)}")
@router.post("/claim/create") @router.post("/claim/create")
async def proxy_create_claim(request: Request): async def proxy_create_claim(request: Request):
""" """

View File

@@ -15,14 +15,19 @@ async def send_sms_code(request: SMSSendRequest):
- **phone**: Номер телефона в формате +79001234567 - **phone**: Номер телефона в формате +79001234567
""" """
from ..config import settings
code = await sms_service.send_verification_code(request.phone) code = await sms_service.send_verification_code(request.phone)
if code: if code:
return { response = {
"success": True, "success": True,
"message": "Код отправлен на указанный номер", "message": "Код отправлен на указанный номер"
"debug_code": code # Всегда возвращаем код для dev модалки
} }
# 🔧 DEV MODE: Возвращаем debug_code только в development
if settings.debug or settings.app_env == "development":
response["debug_code"] = code
return response
else: else:
raise HTTPException( raise HTTPException(
status_code=429, status_code=429,

View File

@@ -0,0 +1,154 @@
"""
Telegram Mini App (WebApp) auth endpoint.
/api/v1/tg/auth:
- Принимает init_data от Telegram WebApp и (опционально) session_token
- Валидирует init_data и извлекает данные пользователя Telegram
- Проксирует telegram_user_id в n8n для получения unified_id/контакта
- Создаёт сессию в Redis через существующий /api/v1/session/create
"""
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from ..services.telegram_auth import extract_telegram_user, TelegramAuthError
from ..config import settings
from . import n8n_proxy
from . import session as session_api
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/tg", tags=["Telegram"])
class TelegramAuthRequest(BaseModel):
init_data: str
session_token: Optional[str] = None
class TelegramAuthResponse(BaseModel):
success: bool
session_token: str
unified_id: str
contact_id: Optional[str] = None
phone: Optional[str] = None
has_drafts: Optional[bool] = None
def _generate_session_token() -> str:
"""Генерирует новый session_token в формате, похожем на текущий веб-флоу."""
import uuid
return f"sess-{uuid.uuid4()}"
@router.post("/auth", response_model=TelegramAuthResponse)
async def telegram_auth(request: TelegramAuthRequest):
"""
Авторизация пользователя через Telegram WebApp.
Ничего не ломает в текущем SMS-флоу: это параллельный способ входа.
"""
# Логирование: что пришло на бэкенд
init_data = request.init_data or ""
logger.info(
"[TG] POST /api/v1/tg/auth вызван: init_data длина=%s, session_token передан=%s",
len(init_data),
bool(request.session_token),
)
if not init_data:
logger.warning("[TG] init_data пустой — запрос отклонён")
raise HTTPException(status_code=400, detail="init_data обязателен")
bot_token_configured = bool((getattr(settings, "telegram_bot_token", None) or "").strip())
n8n_webhook_configured = bool((getattr(settings, "n8n_tg_auth_webhook", None) or "").strip())
logger.info("[TG] Конфиг: TELEGRAM_BOT_TOKEN задан=%s, N8N_TG_AUTH_WEBHOOK задан=%s", bot_token_configured, n8n_webhook_configured)
# 1. Валидация и разбор init_data
try:
tg_user = extract_telegram_user(request.init_data)
except TelegramAuthError as e:
logger.warning("[TG] Ошибка валидации initData: %s", e)
raise HTTPException(status_code=400, detail=str(e))
telegram_user_id = tg_user["telegram_user_id"]
logger.info("[TG] Telegram user валиден: id=%s, username=%s", telegram_user_id, tg_user.get("username"))
# 2. Определяем session_token
session_token = request.session_token or _generate_session_token()
# 3. Вызываем n8n через прокси для маппинга telegram_user_id → unified_id
n8n_payload = {
"telegram_user_id": telegram_user_id,
"username": tg_user.get("username"),
"first_name": tg_user.get("first_name"),
"last_name": tg_user.get("last_name"),
"session_token": session_token,
"form_id": "ticket_form",
"init_data": request.init_data, # сырая строка из Telegram (подпись уже проверена)
}
logger.info("[TG] Вызов n8n webhook, payload keys=%s", list(n8n_payload.keys()))
# Используем уже существующий n8n_proxy роут (внутренний вызов)
try:
from fastapi.encoders import jsonable_encoder
# Объект с async .json() для proxy_telegram_auth(request), без Pydantic __root__
class _DummyRequest:
def __init__(self, payload: dict):
self._payload = payload
async def json(self):
return self._payload
dummy_request = _DummyRequest(n8n_payload)
n8n_response = await n8n_proxy.proxy_telegram_auth(dummy_request) # type: ignore[arg-type]
n8n_data = jsonable_encoder(n8n_response)
logger.info("[TG] n8n ответ получен: keys=%s", list(n8n_data.keys()) if isinstance(n8n_data, dict) else type(n8n_data).__name__)
except HTTPException:
# Пробрасываем HTTPException наверх
raise
except Exception as e:
logger.exception("[TG] Ошибка вызова n8n Telegram auth webhook: %s", e)
raise HTTPException(status_code=500, detail=f"Ошибка обращения к n8n: {str(e)}")
# Ожидаем от n8n как минимум unified_id
unified_id = n8n_data.get("unified_id") or (n8n_data.get("result") or {}).get("unified_id")
contact_id = n8n_data.get("contact_id") or n8n_data.get("result", {}).get("contact_id")
phone = n8n_data.get("phone") or n8n_data.get("result", {}).get("phone")
has_drafts = n8n_data.get("has_drafts")
if not unified_id:
logger.error("[TG] n8n не вернул unified_id. Полный ответ: %s", n8n_data)
raise HTTPException(status_code=500, detail="n8n не вернул unified_id для Telegram пользователя")
# 4. Создаём сессию в Redis через существующий /api/v1/session/create
# Для Telegram телефон может быть ещё неизвестен, поэтому передаём пустые строки при отсутствии.
session_request = session_api.SessionCreateRequest(
session_token=session_token,
unified_id=unified_id,
phone=phone or "",
contact_id=contact_id or "",
ttl_hours=24,
)
try:
await session_api.create_session(session_request)
except HTTPException:
# Если ошибка уже обёрнута в HTTPException — пробрасываем как есть
raise
except Exception as e:
logger.exception("❌ Error creating Redis session for Telegram user")
raise HTTPException(status_code=500, detail=f"Ошибка создания сессии: {str(e)}")
return TelegramAuthResponse(
success=True,
session_token=session_token,
unified_id=unified_id,
contact_id=contact_id,
phone=phone,
has_drafts=has_drafts,
)

View File

@@ -1,15 +1,20 @@
""" """
Конфигурация приложения Конфигурация приложения
""" """
import os
from pathlib import Path from pathlib import Path
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from functools import lru_cache from typing import List, Optional
from typing import List
BASE_DIR = Path(__file__).resolve().parents[2] BASE_DIR = Path(__file__).resolve().parents[2]
ENV_PATH = BASE_DIR / ".env" ENV_PATH = BASE_DIR / ".env"
# Список CORS, обновляется при изменении .env (чтобы не перезапускать бэкенд)
_cors_origins_live: List[str] = []
_settings_cache: Optional["Settings"] = None
_env_mtime_cache: float = 0
class Settings(BaseSettings): class Settings(BaseSettings):
# ============================================ # ============================================
@@ -179,6 +184,13 @@ class Settings(BaseSettings):
n8n_file_upload_webhook: str = "" n8n_file_upload_webhook: str = ""
n8n_create_contact_webhook: str = "https://n8n.clientright.pro/webhook/511fde97-88bb-4fb4-bea5-cafdc364be27" n8n_create_contact_webhook: str = "https://n8n.clientright.pro/webhook/511fde97-88bb-4fb4-bea5-cafdc364be27"
n8n_create_claim_webhook: str = "https://n8n.clientright.pro/webhook/d5bf4ca6-9e44-44b9-9714-3186ea703e7d" n8n_create_claim_webhook: str = "https://n8n.clientright.pro/webhook/d5bf4ca6-9e44-44b9-9714-3186ea703e7d"
n8n_description_webhook: str = "https://n8n.clientright.pro/webhook/aiform_description" # Webhook для обработки описания проблемы
n8n_tg_auth_webhook: str = "" # Webhook для авторизации пользователей Telegram WebApp (Mini App)
# ============================================
# TELEGRAM BOT
# ============================================
telegram_bot_token: str = "" # Токен бота для проверки initData WebApp
# ============================================ # ============================================
# LOGGING # LOGGING
@@ -192,9 +204,25 @@ class Settings(BaseSettings):
extra = "ignore" # Игнорируем лишние поля из .env extra = "ignore" # Игнорируем лишние поля из .env
@lru_cache()
def get_settings() -> Settings: def get_settings() -> Settings:
return Settings() """Текущие настройки. При изменении .env подхватываются без перезапуска."""
global _settings_cache, _env_mtime_cache, _cors_origins_live
mtime = os.path.getmtime(ENV_PATH) if ENV_PATH.exists() else 0.0
if _settings_cache is None or mtime > _env_mtime_cache:
_settings_cache = Settings()
_env_mtime_cache = mtime
_cors_origins_live.clear()
_cors_origins_live.extend(_settings_cache.cors_origins_list)
return _settings_cache
def get_cors_origins_live() -> List[str]:
"""
Список CORS origins для middleware; обновляется при изменении .env без перезапуска.
Обработчики, которые используют get_settings() при каждом запросе, тоже видят новые значения.
"""
get_settings() # обновить кеш и _cors_origins_live при изменении .env
return _cors_origins_live
settings = get_settings() settings = get_settings()

View File

@@ -6,14 +6,14 @@ from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import logging import logging
from .config import settings from .config import settings, get_cors_origins_live, get_settings
from .services.database import db from .services.database import db
from .services.redis_service import redis_service from .services.redis_service import redis_service
from .services.rabbitmq_service import rabbitmq_service from .services.rabbitmq_service import rabbitmq_service
from .services.policy_service import policy_service from .services.policy_service import policy_service
from .services.crm_mysql_service import crm_mysql_service from .services.crm_mysql_service import crm_mysql_service
from .services.s3_service import s3_service from .services.s3_service import s3_service
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents, banks, telegram_auth
# Настройка логирования # Настройка логирования
logging.basicConfig( logging.basicConfig(
@@ -93,14 +93,19 @@ app = FastAPI(
lifespan=lifespan lifespan=lifespan
) )
# CORS # CORS (список обновляется при изменении .env без перезапуска)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins_list, allow_origins=get_cors_origins_live(),
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# Обновление конфига с .env при каждом запросе, чтобы CORS и прочее подхватывали изменения
@app.middleware("http")
async def refresh_config_on_request(request, call_next):
get_settings()
return await call_next(request)
# API Routes # API Routes
app.include_router(sms.router) app.include_router(sms.router)
@@ -112,6 +117,8 @@ app.include_router(events.router)
app.include_router(n8n_proxy.router) # 🔒 Безопасный proxy к n8n webhooks app.include_router(n8n_proxy.router) # 🔒 Безопасный proxy к n8n webhooks
app.include_router(session.router) # 🔑 Session management через Redis app.include_router(session.router) # 🔑 Session management через Redis
app.include_router(documents.router) # 📄 Documents upload and processing app.include_router(documents.router) # 📄 Documents upload and processing
app.include_router(banks.router) # 🏦 Banks API (NSPK banks list)
app.include_router(telegram_auth.router) # 🤖 Telegram Mini App auth
@app.get("/") @app.get("/")

View File

@@ -13,6 +13,7 @@ logger = logging.getLogger(__name__)
WORKFLOW_ID = "b4K4u851b4JFivyD" WORKFLOW_ID = "b4K4u851b4JFivyD"
N8N_URL = "https://n8n.clientright.pro" N8N_URL = "https://n8n.clientright.pro"
MIN_RESTART_INTERVAL = 300 # Минимум 5 минут между перезапусками MIN_RESTART_INTERVAL = 300 # Минимум 5 минут между перезапусками
MAX_RETRY_ATTEMPTS = 2 # Максимум попыток перезапуска подряд
async def check_workflow_status() -> Optional[dict]: async def check_workflow_status() -> Optional[dict]:
@@ -50,7 +51,7 @@ async def check_workflow_status() -> Optional[dict]:
async def restart_workflow() -> bool: async def restart_workflow() -> bool:
""" """
Перезапуск workflow через n8n API Перезапуск workflow через n8n API с улучшенной обработкой зависших состояний
Returns: Returns:
True если успешно, False при ошибке True если успешно, False при ошибке
@@ -63,50 +64,86 @@ async def restart_workflow() -> bool:
if not headers: if not headers:
return False return False
import asyncio
try: try:
async with httpx.AsyncClient(timeout=10.0) as client: # Увеличиваем таймаут для обработки зависших workflow
# Шаг 1: Деактивировать workflow async with httpx.AsyncClient(timeout=30.0) as client:
# Шаг 1: Проверяем текущий статус
logger.info(f"🔍 Проверяю текущий статус workflow {WORKFLOW_ID}...")
status_response = await client.get(
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}",
headers=headers
)
if status_response.status_code == 200:
workflow_data = status_response.json()
is_active = workflow_data.get("active", False)
logger.info(f"📊 Workflow активен: {is_active}")
# Шаг 2: Деактивировать workflow (даже если уже неактивен - для сброса состояния)
logger.info(f"🔄 Деактивирую workflow {WORKFLOW_ID}...") logger.info(f"🔄 Деактивирую workflow {WORKFLOW_ID}...")
deactivate_response = await client.post( try:
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/deactivate", deactivate_response = await client.post(
headers=headers f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/deactivate",
) headers=headers,
timeout=15.0 # Отдельный таймаут для деактивации
if deactivate_response.status_code not in [200, 404]:
logger.warning(
f"⚠️ Неожиданный статус при деактивации: "
f"{deactivate_response.status_code}"
) )
else:
logger.info("✅ Workflow деактивирован") if deactivate_response.status_code in [200, 404]:
logger.info("✅ Workflow деактивирован")
else:
logger.warning(
f"⚠️ Неожиданный статус при деактивации: "
f"{deactivate_response.status_code} - {deactivate_response.text[:200]}"
)
# Продолжаем даже если деактивация не удалась - возможно workflow уже неактивен
except httpx.TimeoutException:
logger.warning("⏱️ Таймаут при деактивации workflow (возможно завис)")
# Продолжаем попытку активации - иногда помогает
except Exception as e:
logger.warning(f"⚠️ Ошибка при деактивации: {e}, продолжаю...")
# Задержка перед активацией # Задержка перед активацией (увеличена для стабильности)
import asyncio await asyncio.sleep(3)
await asyncio.sleep(2)
# Шаг 2: Активировать workflow # Шаг 3: Активировать workflow
logger.info(f"🔄 Активирую workflow {WORKFLOW_ID}...") logger.info(f"🔄 Активирую workflow {WORKFLOW_ID}...")
activate_response = await client.post( try:
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/activate", activate_response = await client.post(
headers=headers f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/activate",
) headers=headers,
timeout=15.0 # Отдельный таймаут для активации
if activate_response.status_code == 200:
logger.info("✅ Workflow активирован")
# После успешного перезапуска отправляем сообщения из буфера
await _send_buffered_messages()
return True
else:
logger.error(
f"❌ Ошибка активации workflow: "
f"{activate_response.status_code} - {activate_response.text[:200]}"
) )
if activate_response.status_code == 200:
logger.info("✅ Workflow активирован")
# Дополнительная задержка для инициализации trigger node
await asyncio.sleep(2)
# После успешного перезапуска отправляем сообщения из буфера
await _send_buffered_messages()
return True
else:
logger.error(
f"❌ Ошибка активации workflow: "
f"{activate_response.status_code} - {activate_response.text[:200]}"
)
return False
except httpx.TimeoutException:
logger.error("⏱️ Таймаут при активации workflow - возможно n8n перегружен")
return False
except Exception as e:
logger.error(f"❌ Ошибка при активации workflow: {e}")
return False return False
except httpx.TimeoutException:
logger.error("⏱️ Общий таймаут при перезапуске workflow")
return False
except Exception as e: except Exception as e:
logger.error(f"❌ Неожиданная ошибка при перезапуске workflow: {e}") logger.error(f"❌ Неожиданная ошибка при перезапуске workflow: {e}", exc_info=True)
return False return False

View File

@@ -65,17 +65,11 @@ class SMSService:
logger.warning("SMS отправка отключена в конфигурации") logger.warning("SMS отправка отключена в конфигурации")
return False return False
# 🔧 DEV: ПРИНУДИТЕЛЬНО ОТКЛЮЧЕНА ОТПРАВКА SMS # 🔧 DEV MODE: Не отправляем реальные SMS в development, экономим бюджет
# Раскомментировать для продакшена! if settings.debug or settings.app_env == "development":
logger.info(f"🔧 DEV MODE: SMS to {phone} ЗАБЛОКИРОВАНА (экономим бюджет!)") logger.info(f"🔧 DEV MODE: SMS to {phone} not sent (saving money!)")
logger.info(f"📱 Message: {message}") logger.info(f"📱 Message would be: {message}")
return True return True # Возвращаем True чтобы код сохранился в Redis для проверки
# DEBUG MODE: Не отправляем реальные SMS, экономим бюджет
# if settings.debug or settings.app_env == "development":
# logger.info(f"🔧 DEBUG MODE: SMS to {phone} not sent (saving money!)")
# logger.info(f"📱 Message would be: {message}")
# return True
try: try:
# Получаем актуальный токен # Получаем актуальный токен

View File

@@ -0,0 +1,132 @@
"""
Telegram WebApp (Mini App) auth helper.
В этом модуле:
- Парсим и валидируем initData от Telegram WebApp
- Проверяем подпись по токену бота из настроек
- Возвращаем разобранные данные пользователя Telegram
"""
import hashlib
import hmac
import logging
from typing import Dict, Any
from urllib.parse import parse_qsl
from ..config import settings
logger = logging.getLogger(__name__)
class TelegramAuthError(Exception):
"""Ошибка проверки подлинности Telegram initData."""
def _parse_init_data(init_data: str) -> Dict[str, Any]:
"""
Разбирает строку initData в словарь.
Формат initData — это query string, см. Telegram WebApp docs.
"""
data: Dict[str, Any] = {}
for key, value in parse_qsl(init_data, keep_blank_values=True):
data[key] = value
return data
def verify_telegram_init_data(init_data: str) -> Dict[str, Any]:
"""
Проверяет подпись initData согласно Telegram WebApp правилам.
Алгоритм из официальной документации:
- Берём токен бота: BOT_TOKEN
- Вычисляем secret_key = HMAC_SHA256("WebAppData", BOT_TOKEN)
- Собираем data_check_string: строки "<key>=<value>" по всем полям, кроме 'hash',
отсортированные по key, соединённые '\n'
- Считаем хэш: HMAC_SHA256(secret_key, data_check_string)
- Сравниваем с полем 'hash' из initData (hex)
"""
if not init_data:
logger.warning("[TG] verify_telegram_init_data: init_data пустой")
raise TelegramAuthError("init_data is empty")
bot_token = (getattr(settings, "telegram_bot_token", None) or "").strip()
if not bot_token:
logger.warning("[TG] verify_telegram_init_data: TELEGRAM_BOT_TOKEN не задан в .env")
raise TelegramAuthError("Telegram bot token is not configured")
parsed = _parse_init_data(init_data)
logger.info("[TG] initData распарсен, ключи: %s", list(parsed.keys()))
received_hash = parsed.pop("hash", None)
if not received_hash:
logger.warning("[TG] В initData отсутствует поле hash")
raise TelegramAuthError("Missing hash in init_data")
# Формируем data_check_string
data_check_items = []
for key in sorted(parsed.keys()):
value = parsed[key]
data_check_items.append(f"{key}={value}")
data_check_string = "\n".join(data_check_items)
# secret_key = HMAC_SHA256("WebAppData", BOT_TOKEN)
secret_key = hmac.new(
key="WebAppData".encode("utf-8"),
msg=bot_token.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
# HMAC_SHA256(secret_key, data_check_string)
calculated_hash = hmac.new(
key=secret_key,
msg=data_check_string.encode("utf-8"),
digestmod=hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(calculated_hash, received_hash):
logger.warning("[TG] Подпись initData не совпадает (неверный токен бота или поддельные данные)")
raise TelegramAuthError("Invalid init_data hash")
return parsed
def extract_telegram_user(init_data: str) -> Dict[str, Any]:
"""
Валидирует initData и возвращает данные пользователя Telegram.
В field `user` лежит JSON-строка с полями:
{
"id": 123456789,
"first_name": "...",
"last_name": "...",
"username": "...",
...
}
"""
import json
parsed = verify_telegram_init_data(init_data)
user_raw = parsed.get("user")
if not user_raw:
logger.warning("[TG] В initData отсутствует поле user")
raise TelegramAuthError("No user field in init_data")
try:
user_obj = json.loads(user_raw)
except Exception as e:
raise TelegramAuthError(f"Failed to parse user JSON: {e}") from e
if "id" not in user_obj:
raise TelegramAuthError("Telegram user.id is missing")
return {
"telegram_user_id": str(user_obj.get("id")),
"username": user_obj.get("username"),
"first_name": user_obj.get("first_name"),
"last_name": user_obj.get("last_name"),
"language_code": user_obj.get("language_code"),
"raw": user_obj,
}

86
deploy-to-prod.sh Executable file
View File

@@ -0,0 +1,86 @@
#!/bin/bash
# ============================================
# Скрипт переноса изменений из DEV в PROD
# ============================================
set -e
cd "$(dirname "$0")"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🚀 Перенос изменений из DEV в PROD"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Проверка что мы в правильной директории
if [ ! -f "docker-compose.dev.yml" ]; then
echo "❌ Ошибка: запустите скрипт из корня проекта ticket_form"
exit 1
fi
# 1. Проверка изменений в git
echo "📊 Проверяю изменения в git..."
if [ -n "$(git status --porcelain)" ]; then
echo "⚠️ Есть незакоммиченные изменения!"
echo ""
git status --short
echo ""
read -p "Закоммитить изменения перед деплоем? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "💾 Коммичу изменения..."
git add -A
git commit -m "chore: Изменения перед деплоем в prod $(date +%Y-%m-%d)"
fi
fi
# 2. Push в dev репозиторий
echo ""
echo "📤 Отправляю изменения в DEV репозиторий..."
if git remote | grep -q "aiform_dev"; then
git push aiform_dev main 2>/dev/null || git push aiform_dev master 2>/dev/null || echo "⚠️ Не удалось запушить в aiform_dev"
fi
# 3. Push в prod репозиторий
echo ""
echo "📤 Отправляю изменения в PROD репозиторий..."
if git remote | grep -q "aiform_prod"; then
git push aiform_prod main 2>/dev/null || git push aiform_prod master 2>/dev/null || echo "⚠️ Не удалось запушить в aiform_prod"
else
echo "⚠️ Remote 'aiform_prod' не найден. Добавьте:"
echo " git remote add aiform_prod http://147.45.146.17:3002/negodiy/aiform_prod.git"
fi
# 4. Пересборка prod контейнеров
echo ""
echo "🔨 Пересобираю PROD контейнеры..."
docker-compose -f docker-compose.prod.yml build --no-cache
# 5. Перезапуск prod
echo ""
echo "🔄 Перезапускаю PROD окружение..."
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml up -d
# 6. Проверка статуса
echo ""
echo "⏳ Жду запуска (5 сек)..."
sleep 5
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "✅ Деплой завершён!"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "📍 PROD доступен:"
echo " Frontend: http://localhost:5176"
echo " Backend: http://localhost:8200"
echo " Production: https://aiform.clientright.ru"
echo ""
echo "📊 Статус контейнеров:"
docker-compose -f docker-compose.prod.yml ps
echo ""
echo "📋 Логи (последние 20 строк):"
docker-compose -f docker-compose.prod.yml logs --tail=20
echo ""

62
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,62 @@
version: '3.8'
# ============================================
# PRODUCTION ENVIRONMENT
# Запуск: docker-compose -f docker-compose.prod.yml up -d
# ============================================
services:
ticket_form_frontend_prod:
container_name: ticket_form_frontend_prod
build:
context: ./frontend
dockerfile: Dockerfile.prod
ports:
- "5176:3000"
environment:
- VITE_API_URL=https://aiform.clientright.ru
- NODE_ENV=production
networks:
- ticket-form-prod-network
restart: unless-stopped
labels:
- "environment=production"
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000"]
interval: 30s
timeout: 10s
retries: 3
ticket_form_backend_prod:
container_name: ticket_form_backend_prod
build:
context: ./backend
dockerfile: Dockerfile
network_mode: host # Для доступа к localhost MySQL/Redis
env_file:
- .env
environment:
- APP_ENV=production
- DEBUG=false
- LOG_LEVEL=INFO
volumes:
- ./backend/logs:/app/logs
restart: unless-stopped
labels:
- "environment=production"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8200/health"]
interval: 30s
timeout: 10s
retries: 3
# В проде используем внешние БД (не создаём локальные)
# PostgreSQL: 147.45.189.234:5432
# Redis: localhost:6379 (системный)
# MySQL: localhost:3306 (системный)
networks:
ticket-form-prod-network:
driver: bridge
name: ticket-form-prod-network

View File

@@ -0,0 +1,68 @@
#!/bin/bash
# ============================================================================
# Пример curl запроса для Browserless (HTML → PDF)
# Используйте этот запрос в HTTP Request ноде n8n
# ============================================================================
# ВАРИАНТ 1: С data URL (HTML в base64)
curl -X POST http://147.45.146.17:3000/pdf \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"url": "data:text/html;base64,PCFET0NUWVBFIGh0bWw+PGh0bWw+PGJvZHk+PGgxPlRlc3Q8L2gxPjwvYm9keT48L2h0bWw+",
"options": {
"format": "A4",
"printBackground": true,
"margin": {
"top": "20mm",
"right": "15mm",
"bottom": "20mm",
"left": "15mm"
}
}
}'
# ============================================================================
# ВАРИАНТ 2: С прямым HTML (если Browserless поддерживает)
# ============================================================================
# curl -X POST http://147.45.146.17:3000/pdf \
# -H "Content-Type: application/json" \
# -H "Authorization: Bearer YOUR_TOKEN" \
# -d '{
# "html": "<!DOCTYPE html><html><body><h1>Test</h1></body></html>",
# "options": {
# "format": "A4",
# "printBackground": true,
# "margin": {
# "top": "20mm",
# "right": "15mm",
# "bottom": "20mm",
# "left": "15mm"
# }
# }
# }'
# ============================================================================
# НАСТРОЙКА В HTTP REQUEST НОДЕ:
# ============================================================================
# Method: POST
# URL: http://147.45.146.17:3000/pdf
# Headers:
# Content-Type: application/json
# Authorization: Bearer YOUR_TOKEN (если требуется)
# Body (JSON):
# {
# "url": "data:text/html;base64,{{ $json.html_base64_encoded }}",
# "options": {
# "format": "A4",
# "printBackground": true,
# "margin": {
# "top": "20mm",
# "right": "15mm",
# "bottom": "20mm",
# "left": "15mm"
# }
# }
# }
# Response Format: Binary
# ============================================================================

View File

@@ -0,0 +1,163 @@
# Настройка HTTP Request для Browserless Function API
## Готовые настройки для HTTP Request ноды
### Method
`POST`
### URL
```
http://147.45.146.17:3000/function?token=9ahhnpjkchxtcho9
```
### Headers
```json
{
"Content-Type": "application/javascript"
}
```
### Body (Raw)
**Content Type:** `application/javascript`
**Body:**
```javascript
export default async function ({ page }) {
const html = `{{ $json.html }}`;
if (!html) {
throw new Error('❌ HTML не передан');
}
// универсальный sleep
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
await page.setViewport({ width: 1240, height: 1754 });
// Загружаем HTML напрямую
await page.setContent(html, {
waitUntil: ['load', 'domcontentloaded', 'networkidle0'],
});
// Даём браузеру применить стили
await sleep(300);
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm',
},
});
return {
status: 'success',
pdf_base64: pdfBuffer.toString('base64'),
size_bytes: pdfBuffer.length,
};
}
```
### Options
- **Timeout:** `40000` (40 секунд)
### Response Format
`JSON` (Browserless вернёт JSON с `pdf_base64`)
---
## Вариант с html_base64
Если у вас HTML в base64, используйте этот вариант:
```javascript
export default async function ({ page }) {
// Получаем HTML из base64
const htmlBase64 = `{{ $json.html_base64 }}`;
const html = Buffer.from(htmlBase64, 'base64').toString('utf8');
if (!html) {
throw new Error('❌ HTML не передан');
}
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
await page.setViewport({ width: 1240, height: 1754 });
await page.setContent(html, {
waitUntil: ['load', 'domcontentloaded', 'networkidle0'],
});
await sleep(300);
const pdfBuffer = await page.pdf({
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm',
},
});
return {
status: 'success',
pdf_base64: pdfBuffer.toString('base64'),
size_bytes: pdfBuffer.length,
};
}
```
---
## Полный Workflow
```
[Code: Process Flights Data] ← Генерирует HTML
[HTTP Request: Browserless Function] ← Используйте настройки выше
[Code: Extract PDF Base64] ← Если нужно обработать ответ
```
---
## Code Node: Extract PDF Base64 (опционально)
Если Browserless уже вернул `pdf_base64` в JSON, можно просто передать дальше:
```javascript
const response = $input.first().json;
return [{
json: {
pdf_base64: response.pdf_base64,
pdf_size_bytes: response.size_bytes,
pdf_size_mb: (response.size_bytes / (1024 * 1024)).toFixed(2),
status: response.status,
success: true
}
}];
```
---
## Преимущества этого подхода
**Прямая работа с HTML** - не нужно конвертировать в data URL
**Полный контроль** - можете добавить любую логику в функцию
**Готовый base64** - Browserless сразу возвращает base64 PDF
**Надёжность** - sleep даёт время браузеру применить стили
---
## Отладка
Если получаете ошибки:
- **"HTML не передан"** → Проверьте, что предыдущая нода вернула `html` или `html_base64`
- **Timeout** → Увеличьте timeout в Options до 60000 (60 секунд)
- **Пустой PDF** → Увеличьте sleep до 500-1000ms

View File

@@ -0,0 +1,29 @@
{
"nodes": [
{
"parameters": {
"method": "POST",
"url": "http://147.45.146.17:3000/function?token=9ahhnpjkchxtcho9",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/javascript"
}
]
},
"sendBody": true,
"contentType": "raw",
"rawContentType": "application/javascript",
"body": "export default async function ({ page }) {\n const html = `{{ $json.html }}`;\n\n if (!html) {\n throw new Error('❌ HTML не передан');\n }\n\n // универсальный sleep\n const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));\n\n await page.setViewport({ width: 1240, height: 1754 });\n\n // Загружаем HTML напрямую\n await page.setContent(html, {\n waitUntil: ['load', 'domcontentloaded', 'networkidle0'],\n });\n\n // Даём браузеру применить стили\n await sleep(300);\n\n const pdfBuffer = await page.pdf({\n format: 'A4',\n printBackground: true,\n margin: {\n top: '20mm',\n right: '15mm',\n bottom: '20mm',\n left: '15mm',\n },\n });\n\n return {\n status: 'success',\n pdf_base64: pdfBuffer.toString('base64'),\n size_bytes: pdfBuffer.length,\n };\n}",
"options": {
"timeout": 40000
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"name": "Browserless: HTML to PDF"
}
]
}

View File

@@ -0,0 +1,135 @@
# Настройка HTTP Request ноды для Browserless
## Готовый запрос для вставки
### Вариант 1: С использованием html_base64 из предыдущей ноды
**Method:** `POST`
**URL:** `http://147.45.146.17:3000/pdf`
**Headers:**
```json
{
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_TOKEN"
}
```
*Примечание: Если токен не требуется, уберите строку Authorization*
**Body (JSON):**
```json
{
"url": "data:text/html;base64,{{ $json.html_base64 }}",
"options": {
"format": "A4",
"printBackground": true,
"margin": {
"top": "20mm",
"right": "15mm",
"bottom": "20mm",
"left": "15mm"
}
}
}
```
**Response Format:** `Binary`
---
### Вариант 2: Если у вас HTML в строке (не base64)
**Method:** `POST`
**URL:** `http://147.45.146.17:3000/pdf`
**Headers:**
```json
{
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_TOKEN"
}
```
**Body (JSON):**
```json
{
"html": "{{ $json.html }}",
"options": {
"format": "A4",
"printBackground": true,
"margin": {
"top": "20mm",
"right": "15mm",
"bottom": "20mm",
"left": "15mm"
}
}
}
```
**Response Format:** `Binary`
---
## Полный workflow
```
[Code: Process Flights Data] ← Генерирует HTML
[Code: HTML to Base64] ← Конвертирует HTML в base64 (если нужно)
[HTTP Request: Browserless PDF] ← Используйте настройки выше
[Code: Extract Base64 PDF] ← Конвертирует binary в base64
```
---
## Code Node: HTML to Base64 (если нужно)
Если у вас HTML в строке, а нужен base64 для data URL:
```javascript
const html = $json.html;
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
return [{
json: {
html_base64: htmlBase64,
html: html
}
}];
```
---
## Code Node: Extract Base64 PDF (после HTTP Request)
```javascript
const pdfBinary = $binary.data;
const base64 = Buffer.isBuffer(pdfBinary)
? pdfBinary.toString('base64')
: Buffer.from(pdfBinary).toString('base64');
const sizeBytes = Buffer.from(base64, 'base64').length;
return [{
json: {
pdf_base64: base64,
pdf_size_bytes: sizeBytes,
pdf_size_mb: (sizeBytes / (1024 * 1024)).toFixed(2),
success: true
}
}];
```
---
## Отладка
Если получаете ошибку:
- **"Bad or missing authentication"** → Проверьте токен или уберите Authorization header
- **"Not Found"** → Проверьте URL эндпоинта
- **Пустой ответ** → Проверьте формат HTML и data URL

View File

@@ -0,0 +1,698 @@
// ============================================================================
// n8n Code Node: Обработка данных о рейсах из FlightAware и FlightRadar24
// ============================================================================
// Объединяет данные из двух источников и формирует красивый HTML для PDF
// ============================================================================
// ==== ПОЛУЧЕНИЕ ВХОДНЫХ ДАННЫХ ====
// Ожидаемая структура: массив с двумя элементами
// [0] - данные из FlightAware (body.flights[])
// [1] - данные из FlightRadar24 (body.data[])
const inputItems = $input.all();
if (!inputItems || inputItems.length === 0) {
return [{
json: {
error: 'Нет входных данных',
html: '<html><body><h1>Ошибка: данные не получены</h1></body></html>',
flights: [],
sources: { flightaware: false, flightradar24: false }
}
}];
}
// ==== ИЗВЛЕЧЕНИЕ ДАННЫХ ИЗ ИСТОЧНИКОВ ====
let flightAwareData = [];
let flightRadar24Data = [];
try {
// Первый элемент - FlightAware
const faItem = inputItems[0];
if (faItem && faItem.json && faItem.json.body && faItem.json.body.flights) {
flightAwareData = Array.isArray(faItem.json.body.flights)
? faItem.json.body.flights
: [];
}
} catch (e) {
console.log('⚠️ Ошибка извлечения FlightAware:', e.message);
}
try {
// Второй элемент - FlightRadar24
const fr24Item = inputItems[1];
if (fr24Item && fr24Item.json && fr24Item.json.body && fr24Item.json.body.data) {
flightRadar24Data = Array.isArray(fr24Item.json.body.data)
? fr24Item.json.body.data
: [];
}
} catch (e) {
console.log('⚠️ Ошибка извлечения FlightRadar24:', e.message);
}
// ==== УТИЛИТЫ ====
const safeStr = (v) => (v == null ? '' : String(v));
const safeDate = (v) => {
if (!v) return '—';
try {
const d = new Date(v);
return d.toLocaleString('ru-RU', {
timeZone: 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return v;
}
};
const formatDuration = (seconds) => {
if (!seconds) return '—';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}ч ${minutes}м`;
};
const formatDistance = (km) => {
if (!km) return '—';
return `${Number(km).toFixed(2)} км`;
};
// ==== ОБЪЕДИНЕНИЕ ДАННЫХ ПО REGISTRATION ====
// Создаём карту для быстрого поиска
const flightsMap = new Map();
// Добавляем данные из FlightAware
flightAwareData.forEach(flight => {
const reg = safeStr(flight.registration).trim();
if (!reg) return;
if (!flightsMap.has(reg)) {
flightsMap.set(reg, {
registration: reg,
flightNumber: safeStr(flight.flight_number),
ident: safeStr(flight.ident),
identIata: safeStr(flight.ident_iata),
aircraftType: safeStr(flight.aircraft_type),
flightAware: flight,
flightRadar24: null
});
} else {
flightsMap.get(reg).flightAware = flight;
}
});
// Добавляем данные из FlightRadar24
flightRadar24Data.forEach(flight => {
const reg = safeStr(flight.reg).trim();
if (!reg) return;
if (!flightsMap.has(reg)) {
flightsMap.set(reg, {
registration: reg,
flightNumber: safeStr(flight.flight),
ident: safeStr(flight.callsign),
identIata: safeStr(flight.flight),
aircraftType: safeStr(flight.type),
flightAware: null,
flightRadar24: flight
});
} else {
flightsMap.get(reg).flightRadar24 = flight;
}
});
// Преобразуем Map в массив
const mergedFlights = Array.from(flightsMap.values());
// ==== ГЕНЕРАЦИЯ HTML ====
const generateFlightCard = (flight) => {
const fa = flight.flightAware;
const fr24 = flight.flightRadar24;
let html = `
<div class="flight-card">
<div class="flight-header">
<h2>Рейс ${flight.flightNumber || flight.ident || 'N/A'}</h2>
<span class="registration">${flight.registration}</span>
</div>
<div class="flight-info">
<div class="info-row">
<span class="label">Тип самолёта:</span>
<span class="value">${flight.aircraftType || '—'}</span>
</div>
<div class="info-row">
<span class="label">Идентификатор:</span>
<span class="value">${flight.ident || '—'} (${flight.identIata || '—'})</span>
</div>
</div>
`;
// Данные из FlightAware
if (fa) {
html += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightaware">FlightAware</span>
</div>
<div class="source-content">
<div class="route-info">
<div class="route-item">
<span class="route-label">Откуда:</span>
<span class="route-value">${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')})</span>
</div>
<div class="route-item">
<span class="route-label">Куда:</span>
<span class="route-value">${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')})</span>
</div>
</div>
<div class="timeline">
<div class="timeline-item">
<span class="timeline-label">Плановый вылет:</span>
<span class="timeline-value">${safeDate(fa.scheduled_out)}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Фактический вылет:</span>
<span class="timeline-value">${safeDate(fa.actual_out)}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Взлёт:</span>
<span class="timeline-value">${safeDate(fa.actual_off)} ${fa.actual_runway_off ? `(ВПП ${fa.actual_runway_off})` : ''}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Посадка:</span>
<span class="timeline-value">${safeDate(fa.actual_on)} ${fa.actual_runway_on ? `(ВПП ${fa.actual_runway_on})` : ''}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Фактический прилёт:</span>
<span class="timeline-value">${safeDate(fa.actual_in)}</span>
</div>
</div>
<div class="status-info">
<div class="status-item">
<span class="status-label">Статус:</span>
<span class="status-value">${safeStr(fa.status || '—')}</span>
</div>
${fa.departure_delay !== null && fa.departure_delay !== undefined ? `
<div class="status-item">
<span class="status-label">Задержка вылета:</span>
<span class="status-value ${fa.departure_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.departure_delay > 0 ? '+' : ''}${Math.floor(fa.departure_delay / 60)} мин</span>
</div>
` : ''}
${fa.arrival_delay !== null && fa.arrival_delay !== undefined ? `
<div class="status-item">
<span class="status-label">Задержка прилёта:</span>
<span class="status-value ${fa.arrival_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.arrival_delay > 0 ? '+' : ''}${Math.floor(fa.arrival_delay / 60)} мин</span>
</div>
` : ''}
${fa.gate_origin ? `
<div class="status-item">
<span class="status-label">Гейт вылета:</span>
<span class="status-value">${fa.gate_origin}</span>
</div>
` : ''}
${fa.gate_destination ? `
<div class="status-item">
<span class="status-label">Гейт прилёта:</span>
<span class="status-value">${fa.gate_destination}</span>
</div>
` : ''}
${fa.baggage_claim ? `
<div class="status-item">
<span class="status-label">Выдача багажа:</span>
<span class="status-value">${fa.baggage_claim}</span>
</div>
` : ''}
</div>
</div>
</div>
`;
} else {
html += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightaware">FlightAware</span>
<span class="source-missing">Данные не получены</span>
</div>
</div>
`;
}
// Данные из FlightRadar24
if (fr24) {
html += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightradar24">FlightRadar24</span>
</div>
<div class="source-content">
<div class="route-info">
<div class="route-item">
<span class="route-label">Откуда:</span>
<span class="route-value">${safeStr(fr24.orig_iata || '—')} (${safeStr(fr24.orig_icao || '—')})</span>
</div>
<div class="route-item">
<span class="route-label">Куда:</span>
<span class="route-value">${safeStr(fr24.dest_iata || '—')} (${safeStr(fr24.dest_icao || '—')})</span>
</div>
</div>
<div class="timeline">
<div class="timeline-item">
<span class="timeline-label">Взлёт:</span>
<span class="timeline-value">${safeDate(fr24.datetime_takeoff)} ${fr24.runway_takeoff ? `(ВПП ${fr24.runway_takeoff})` : ''}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Посадка:</span>
<span class="timeline-value">${safeDate(fr24.datetime_landed)} ${fr24.runway_landed ? `(ВПП ${fr24.runway_landed})` : ''}</span>
</div>
</div>
<div class="status-info">
<div class="status-item">
<span class="status-label">Время полёта:</span>
<span class="status-value">${formatDuration(fr24.flight_time)}</span>
</div>
<div class="status-item">
<span class="status-label">Фактическое расстояние:</span>
<span class="status-value">${formatDistance(fr24.actual_distance)}</span>
</div>
<div class="status-item">
<span class="status-label">Кратчайшее расстояние:</span>
<span class="status-value">${formatDistance(fr24.circle_distance)}</span>
</div>
<div class="status-item">
<span class="status-label">Статус полёта:</span>
<span class="status-value">${fr24.flight_ended ? 'Завершён' : 'В процессе'}</span>
</div>
</div>
</div>
</div>
`;
} else {
html += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightradar24">FlightRadar24</span>
<span class="source-missing">Данные не получены</span>
</div>
</div>
`;
}
html += `</div>`;
return html;
};
// ==== ГЕНЕРАЦИЯ ПОЛНОГО HTML ДОКУМЕНТА ====
const generateFullHTML = (flights) => {
const now = new Date();
const reportDate = now.toLocaleString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
let flightsHTML = '';
if (flights.length === 0) {
flightsHTML = '<div class="no-data">Данные о рейсах не найдены</div>';
} else {
flightsHTML = flights.map(flight => generateFlightCard(flight)).join('');
}
return `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Отчёт о рейсах</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header {
border-bottom: 3px solid #2563eb;
padding-bottom: 20px;
margin-bottom: 30px;
}
.header h1 {
color: #1e40af;
font-size: 28px;
margin-bottom: 10px;
}
.header-meta {
color: #666;
font-size: 14px;
}
.sources-info {
display: flex;
gap: 15px;
margin-top: 10px;
flex-wrap: wrap;
}
.source-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.source-tag.available {
background: #d1fae5;
color: #065f46;
}
.source-tag.unavailable {
background: #fee2e2;
color: #991b1b;
}
.flight-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
margin-bottom: 25px;
overflow: hidden;
background: white;
}
.flight-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.flight-header h2 {
font-size: 24px;
margin: 0;
}
.registration {
background: rgba(255,255,255,0.2);
padding: 6px 12px;
border-radius: 4px;
font-weight: 600;
font-size: 14px;
}
.flight-info {
padding: 15px 20px;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.info-row {
display: flex;
margin-bottom: 8px;
}
.info-row .label {
font-weight: 600;
color: #4b5563;
width: 150px;
flex-shrink: 0;
}
.info-row .value {
color: #111827;
}
.source-section {
border-top: 1px solid #e5e7eb;
padding: 20px;
}
.source-section:first-of-type {
border-top: none;
}
.source-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 15px;
}
.source-badge {
display: inline-block;
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
color: white;
}
.source-badge.source-flightaware {
background: #3b82f6;
}
.source-badge.source-flightradar24 {
background: #10b981;
}
.source-missing {
color: #ef4444;
font-size: 13px;
font-style: italic;
}
.source-content {
margin-left: 0;
}
.route-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-bottom: 20px;
padding: 15px;
background: #f9fafb;
border-radius: 6px;
}
.route-item {
display: flex;
flex-direction: column;
}
.route-label {
font-size: 12px;
color: #6b7280;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.route-value {
font-size: 16px;
font-weight: 600;
color: #111827;
}
.timeline {
margin-bottom: 20px;
}
.timeline-item {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid #e5e7eb;
}
.timeline-item:last-child {
border-bottom: none;
}
.timeline-label {
font-weight: 500;
color: #4b5563;
width: 180px;
flex-shrink: 0;
}
.timeline-value {
color: #111827;
text-align: right;
}
.status-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
padding: 15px;
background: #f9fafb;
border-radius: 6px;
}
.status-item {
display: flex;
flex-direction: column;
}
.status-label {
font-size: 12px;
color: #6b7280;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-value {
font-size: 14px;
font-weight: 600;
color: #111827;
}
.delay-negative {
color: #10b981;
}
.delay-positive {
color: #ef4444;
}
.no-data {
text-align: center;
padding: 60px 20px;
color: #6b7280;
font-size: 18px;
}
@media print {
body {
background: white;
padding: 0;
}
.container {
box-shadow: none;
padding: 20px;
}
.flight-card {
page-break-inside: avoid;
margin-bottom: 20px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Отчёт о рейсах</h1>
<div class="header-meta">
<div>Дата формирования: ${reportDate}</div>
<div class="sources-info">
<span class="source-tag ${flightAwareData.length > 0 ? 'available' : 'unavailable'}">
FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
</span>
<span class="source-tag ${flightRadar24Data.length > 0 ? 'available' : 'unavailable'}">
FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
</span>
</div>
</div>
</div>
<div class="flights-container">
${flightsHTML}
</div>
</div>
</body>
</html>`;
};
// ==== ФОРМИРОВАНИЕ РЕЗУЛЬТАТА ====
const html = generateFullHTML(mergedFlights);
// ==== ПОДГОТОВКА ДАННЫХ ДЛЯ КОНВЕРТАЦИИ В BASE64 PDF ====
// Эти данные будут использованы в следующей HTTP Request ноде
// для конвертации HTML в PDF и получения base64
// Настройки сервиса конвертации (замените на ваши)
const PDF_SERVICE_URL = 'https://api.htmlpdfapi.com/v1/pdf'; // Или другой сервис
const PDF_API_KEY = 'YOUR_API_KEY'; // ⚠️ ЗАМЕНИТЕ на ваш API ключ
// Подготовка запроса для HTTP Request ноды
const pdfRequestData = {
method: 'POST',
url: PDF_SERVICE_URL,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${PDF_API_KEY}`
},
body: JSON.stringify({
html: html,
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
},
base64: true // Запрашиваем base64 напрямую
})
};
return [{
json: {
html: html,
flights: mergedFlights,
flights_count: mergedFlights.length,
sources: {
flightaware: {
available: flightAwareData.length > 0,
count: flightAwareData.length
},
flightradar24: {
available: flightRadar24Data.length > 0,
count: flightRadar24Data.length
}
},
generated_at: new Date().toISOString(),
// Данные для конвертации в PDF (используйте в следующей HTTP Request ноде)
pdf_request: pdfRequestData,
pdf_request_method: pdfRequestData.method,
pdf_request_url: pdfRequestData.url,
pdf_request_headers: pdfRequestData.headers,
pdf_request_body: pdfRequestData.body
}
}];

View File

@@ -0,0 +1,110 @@
// ============================================================================
// n8n Code Node: Извлечение Base64 PDF из ответа HTTP Request
// ============================================================================
// Используйте этот код ПОСЛЕ HTTP Request ноды, которая конвертировала HTML в PDF
// ============================================================================
const response = $input.first();
if (!response) {
throw new Error('Ответ от HTTP Request не получен');
}
let base64 = null;
let pdfSize = 0;
// ==== ВАРИАНТ 1: Сервис вернул base64 в JSON ====
if (response.json) {
// htmlpdfapi.com возвращает: { pdf: "base64..." }
if (response.json.pdf) {
base64 = response.json.pdf;
pdfSize = Math.floor(base64.length * 0.75); // Примерный размер
}
// api2pdf.com возвращает: { Pdf: "base64..." }
else if (response.json.Pdf) {
base64 = response.json.Pdf;
pdfSize = Math.floor(base64.length * 0.75);
}
// pdfshift.io возвращает: { pdf: "base64..." }
else if (response.json.pdf) {
base64 = response.json.pdf;
pdfSize = Math.floor(base64.length * 0.75);
}
// Если base64 в другом поле
else if (response.json.base64) {
base64 = response.json.base64;
pdfSize = Math.floor(base64.length * 0.75);
}
// Если base64 в body
else if (response.json.body && typeof response.json.body === 'string') {
base64 = response.json.body;
pdfSize = Math.floor(base64.length * 0.75);
}
}
// ==== ВАРИАНТ 2: Сервис вернул binary PDF ====
if (!base64 && response.binary && response.binary.data) {
const pdfBinary = response.binary.data;
// Конвертируем binary в base64
if (Buffer.isBuffer(pdfBinary)) {
base64 = pdfBinary.toString('base64');
pdfSize = pdfBinary.length;
} else if (typeof pdfBinary === 'string') {
// Если уже base64 строка
base64 = pdfBinary;
pdfSize = Buffer.from(base64, 'base64').length;
} else {
// Пытаемся преобразовать
const buffer = Buffer.from(pdfBinary);
base64 = buffer.toString('base64');
pdfSize = buffer.length;
}
}
// ==== ВАРИАНТ 3: PDF в текстовом формате (base64 строка) ====
if (!base64 && response.json && typeof response.json === 'string') {
base64 = response.json;
pdfSize = Buffer.from(base64, 'base64').length;
}
// ==== ПРОВЕРКА РЕЗУЛЬТАТА ====
if (!base64) {
console.error('❌ Не удалось извлечь base64. Структура ответа:', Object.keys(response));
throw new Error('Не удалось извлечь base64 PDF из ответа. Проверьте формат ответа сервиса.');
}
// Проверяем, что это действительно base64
if (!/^[A-Za-z0-9+/=]+$/.test(base64)) {
throw new Error('Извлечённые данные не являются валидным base64');
}
const pdfSizeMB = (pdfSize / (1024 * 1024)).toFixed(2);
const timestamp = new Date().toISOString().split('T')[0];
const filename = `flights-report-${timestamp}.pdf`;
console.log('✅ Base64 PDF извлечён успешно');
console.log('📊 Размер PDF:', pdfSizeMB, 'MB');
// ==== ВОЗВРАТ РЕЗУЛЬТАТА ====
return [{
json: {
pdf_base64: base64,
pdf_size_bytes: pdfSize,
pdf_size_mb: pdfSizeMB,
filename: filename,
success: true,
generated_at: new Date().toISOString()
}
}];
// ============================================================================
// ИСПОЛЬЗОВАНИЕ РЕЗУЛЬТАТА:
// ============================================================================
// Теперь у вас есть base64 PDF в поле pdf_base64
// Вы можете:
// 1. Сохранить в файл
// 2. Отправить по email
// 3. Загрузить в S3/Nextcloud
// 4. Вернуть в API response
// ============================================================================

View File

@@ -0,0 +1,99 @@
// ============================================================================
// n8n Code Node: HTML → PDF через Browserless (полная версия)
// ============================================================================
// Используйте этот код ПОСЛЕ ноды, которая вернула HTML или html_base64
// ============================================================================
// Получаем HTML из предыдущей ноды
let html = null;
if ($json.html) {
html = $json.html;
} else if ($json.html_base64) {
html = Buffer.from($json.html_base64, 'base64').toString('utf8');
} else if ($json.body?.html) {
html = $json.body.html;
} else if ($binary && $binary.data) {
html = $binary.data.toString('utf8');
} else {
throw new Error('HTML не найден. Проверьте, что предыдущая нода вернула html или html_base64');
}
console.log('📄 HTML получен, длина:', html.length);
// ================== НАСТРОЙКИ BROWSERLESS ==================
const BROWSERLESS_URL = 'http://147.45.146.17:3000';
// ⚠️ ВАЖНО: Если Browserless требует токен, замените на ваш токен
// Если токен не требуется, оставьте пустую строку или удалите Authorization header
const BROWSERLESS_TOKEN = ''; // Замените на ваш токен, если требуется
// Конвертируем HTML в data URL для передачи в Browserless
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
const dataUrl = `data:text/html;base64,${htmlBase64}`;
// Формируем headers
const headers = {
'Content-Type': 'application/json'
};
// Добавляем токен, если он указан
if (BROWSERLESS_TOKEN) {
headers['Authorization'] = `Bearer ${BROWSERLESS_TOKEN}`;
}
// ================== ПОДГОТОВКА ЗАПРОСА ==================
return [{
json: {
// Данные для HTTP Request ноды
method: 'POST',
url: `${BROWSERLESS_URL}/pdf`,
headers: headers,
// Тело запроса - передаём HTML через data URL
body: JSON.stringify({
url: dataUrl,
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
}
}),
// Метаданные для отладки
html_length: html.length,
data_url_length: dataUrl.length,
browserless_url: BROWSERLESS_URL
}
}];
// ============================================================================
// ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ:
// ============================================================================
// 1. Замените BROWSERLESS_TOKEN на ваш токен (если требуется)
// 2. Добавьте HTTP Request ноду после этого Code Node
// 3. В HTTP Request ноде настройте:
// - Method: {{ $json.method }}
// - URL: {{ $json.url }}
// - Headers: {{ $json.headers }}
// - Body: {{ $json.body }}
// - Response Format: Binary (Browserless возвращает PDF как binary)
// 4. После HTTP Request добавьте Code Node для конвертации binary в base64:
//
// const pdfBinary = $binary.data;
// const base64 = Buffer.isBuffer(pdfBinary)
// ? pdfBinary.toString('base64')
// : Buffer.from(pdfBinary).toString('base64');
//
// return [{
// json: {
// pdf_base64: base64,
// pdf_size_bytes: Buffer.from(base64, 'base64').length,
// success: true
// }
// }];
// ============================================================================

View File

@@ -0,0 +1,99 @@
// ============================================================================
// n8n Code Node: HTML → PDF через Browserless
// ============================================================================
// Используйте этот код ПОСЛЕ ноды, которая вернула HTML или html_base64
// Подготавливает запрос для HTTP Request ноды к Browserless
// ============================================================================
// Получаем HTML из предыдущей ноды
let html = null;
// Вариант 1: HTML уже есть в json.html
if ($json.html) {
html = $json.html;
}
// Вариант 2: HTML в base64
else if ($json.html_base64) {
html = Buffer.from($json.html_base64, 'base64').toString('utf8');
}
// Вариант 3: HTML в другом поле
else if ($json.body?.html) {
html = $json.body.html;
}
// Вариант 4: Пытаемся получить из binary
else if ($binary && $binary.data) {
html = $binary.data.toString('utf8');
}
else {
throw new Error('HTML не найден. Проверьте, что предыдущая нода вернула html или html_base64');
}
console.log('📄 HTML получен, длина:', html.length);
// ================== НАСТРОЙКИ BROWSERLESS ==================
const BROWSERLESS_URL = 'http://147.45.146.17:3000';
const BROWSERLESS_TOKEN = 'YOUR_TOKEN'; // ⚠️ ЗАМЕНИТЕ на ваш токен Browserless
// ================== ВАРИАНТ 1: Использование data URL ==================
// Browserless может принимать HTML через data URL
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
const dataUrl = `data:text/html;base64,${htmlBase64}`;
return [{
json: {
// Данные для HTTP Request ноды
method: 'POST',
url: `${BROWSERLESS_URL}/pdf`,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${BROWSERLESS_TOKEN}` // Если требуется токен
},
body: JSON.stringify({
url: dataUrl, // Передаём HTML через data URL
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
}
}),
// Альтернативный вариант (если Browserless поддерживает прямой HTML)
body_alternative: JSON.stringify({
html: html, // Прямая передача HTML (если поддерживается)
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
}
}),
// Метаданные
html_length: html.length,
data_url_length: dataUrl.length
}
}];
// ============================================================================
// ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ:
// ============================================================================
// 1. Замените YOUR_TOKEN на ваш реальный токен Browserless (если требуется)
// 2. Добавьте HTTP Request ноду после этого Code Node
// 3. В HTTP Request ноде настройте:
// - Method: {{ $json.method }}
// - URL: {{ $json.url }}
// - Headers: {{ $json.headers }}
// - Body: {{ $json.body }}
// - Response Format: Binary (или JSON, если Browserless возвращает base64)
// 4. После HTTP Request добавьте Code Node для извлечения base64 из ответа
// (используйте N8N_EXTRACT_BASE64_FROM_RESPONSE.js)
// ============================================================================

View File

@@ -0,0 +1,124 @@
// ============================================================================
// n8n Code Node: HTML → PDF через Browserless (вариант с прямым HTML)
// ============================================================================
// Альтернативный вариант - передача HTML напрямую в body
// ============================================================================
// Получаем HTML из предыдущей ноды
let html = null;
if ($json.html) {
html = $json.html;
} else if ($json.html_base64) {
html = Buffer.from($json.html_base64, 'base64').toString('utf8');
} else if ($json.body?.html) {
html = $json.body.html;
} else if ($binary && $binary.data) {
html = $binary.data.toString('utf8');
} else {
throw new Error('HTML не найден');
}
console.log('📄 HTML получен, длина:', html.length);
// ================== НАСТРОЙКИ ==================
const BROWSERLESS_URL = 'http://147.45.146.17:3000';
const BROWSERLESS_TOKEN = 'YOUR_TOKEN'; // ⚠️ ЗАМЕНИТЕ на ваш токен
// ================== ВАРИАНТ: Использование /screenshot или /pdf ==================
// Browserless может иметь разные эндпоинты
// Вариант A: POST /pdf с HTML в body
const requestA = {
method: 'POST',
url: `${BROWSERLESS_URL}/pdf`,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${BROWSERLESS_TOKEN}`
},
body: JSON.stringify({
html: html,
options: {
format: 'A4',
printBackground: true,
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
}
})
};
// Вариант B: POST /pdf с data URL
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
const dataUrl = `data:text/html;base64,${htmlBase64}`;
const requestB = {
method: 'POST',
url: `${BROWSERLESS_URL}/pdf`,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${BROWSERLESS_TOKEN}`
},
body: JSON.stringify({
url: dataUrl,
options: {
format: 'A4',
printBackground: true,
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
}
})
};
// Вариант C: POST /screenshot (если /pdf не работает)
const requestC = {
method: 'POST',
url: `${BROWSERLESS_URL}/screenshot`,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${BROWSERLESS_TOKEN}`
},
body: JSON.stringify({
url: dataUrl,
options: {
type: 'pdf',
format: 'A4',
printBackground: true
}
})
};
return [{
json: {
// Используйте один из вариантов ниже
// Попробуйте сначала вариант A, если не работает - B, затем C
// === ВАРИАНТ A: Прямой HTML ===
method_a: requestA.method,
url_a: requestA.url,
headers_a: requestA.headers,
body_a: requestA.body,
// === ВАРИАНТ B: Data URL ===
method_b: requestB.method,
url_b: requestB.url,
headers_b: requestB.headers,
body_b: requestB.body,
// === ВАРИАНТ C: Screenshot (PDF) ===
method_c: requestC.method,
url_c: requestC.url,
headers_c: requestC.headers,
body_c: requestC.body,
// Метаданные
html_length: html.length,
instruction: 'Попробуйте сначала вариант A в HTTP Request ноде'
}
}];
// ============================================================================
// ОТЛАДКА:
// ============================================================================
// Если получаете ошибку аутентификации:
// 1. Проверьте, нужен ли токен для вашего Browserless
// 2. Если токен не требуется, уберите строку Authorization из headers
// 3. Проверьте документацию Browserless: https://docs.browserless.io
// ============================================================================

View File

@@ -0,0 +1,112 @@
# Полный Workflow: HTML → Base64 PDF
## Структура
```
[HTTP Request: FlightAware]
[HTTP Request: FlightRadar24]
[Code: Process Flights Data] ← Генерирует HTML + подготавливает запрос для PDF
[HTTP Request: Convert to PDF] ← Конвертирует HTML в base64 PDF
[Code: Extract Base64 PDF] ← Извлекает base64 из ответа
[Использование base64 PDF]
```
## Настройка нод
### 1. Code: Process Flights Data
**Код:** Используйте обновлённый `N8N_CODE_PROCESS_FLIGHTS_DATA.js`
**Выходные данные:**
```json
{
"html": "<!DOCTYPE html>...",
"flights": [...],
"pdf_request_method": "POST",
"pdf_request_url": "https://api.htmlpdfapi.com/v1/pdf",
"pdf_request_headers": {...},
"pdf_request_body": "{...}"
}
```
### 2. HTTP Request: Convert to PDF
**Название:** `HTTP Request: Convert to PDF`
**Настройка:**
- **Method:** `{{ $json.pdf_request_method }}`
- **URL:** `{{ $json.pdf_request_url }}`
- **Authentication:** None (или по необходимости)
- **Headers:**
```json
{{ $json.pdf_request_headers }}
```
- **Body:**
```json
{{ $json.pdf_request_body }}
```
- **Response Format:** `JSON`
### 3. Code: Extract Base64 PDF
**Название:** `Code: Extract Base64 PDF`
**Код:** Используйте `N8N_EXTRACT_BASE64_FROM_RESPONSE.js`
**Выходные данные:**
```json
{
"pdf_base64": "JVBERi0xLjQKJeLjz9MK...",
"pdf_size_mb": "0.12",
"filename": "flights-report-2026-01-16.pdf",
"success": true
}
```
## Альтернатива: Использование Convert to File
Если вы хотите использовать ноду **Convert to File** для создания HTML файла, а затем конвертировать его в PDF:
### Вариант A: HTML файл → PDF через сервис
```
[Code: Process Flights Data]
[Convert to File] ← Operation: "html", Put Output File in Field: {{ $json.html }}
[HTTP Request: Convert to PDF] ← Отправьте binary HTML файл в сервис конвертации
[Code: Extract Base64 PDF]
```
### Вариант B: Прямая конвертация HTML → Base64 PDF
Пропустите ноду Convert to File и используйте HTML напрямую:
```
[Code: Process Flights Data]
[HTTP Request: Convert to PDF] ← Используйте {{ $json.html }} в body
[Code: Extract Base64 PDF]
```
## Настройка API ключа
В файле `N8N_CODE_PROCESS_FLIGHTS_DATA.js` найдите строку:
```javascript
const PDF_API_KEY = 'YOUR_API_KEY';
```
Замените `YOUR_API_KEY` на ваш реальный API ключ от сервиса конвертации.
## Популярные сервисы
1. **htmlpdfapi.com** - 100 PDF/месяц бесплатно
2. **pdfshift.io** - 100 PDF/месяц бесплатно
3. **api2pdf.com** - 50 PDF/месяц бесплатно

View File

@@ -0,0 +1,132 @@
// ============================================================================
// n8n Code Node: HTML → PDF через браузер (Puppeteer/Playwright)
// ============================================================================
// Используйте этот код ПОСЛЕ ноды, которая вернула HTML или html_base64
// Подготавливает команду для Execute Command ноды с puppeteer
// ============================================================================
// Получаем HTML из предыдущей ноды
let html = null;
// Вариант 1: HTML уже есть в json.html
if ($json.html) {
html = $json.html;
}
// Вариант 2: HTML в base64
else if ($json.html_base64) {
html = Buffer.from($json.html_base64, 'base64').toString('utf8');
}
// Вариант 3: HTML в другом поле
else if ($json.body?.html) {
html = $json.body.html;
}
// Вариант 4: Пытаемся получить из binary
else if ($binary && $binary.data) {
html = $binary.data.toString('utf8');
}
else {
throw new Error('HTML не найден. Проверьте, что предыдущая нода вернула html или html_base64');
}
console.log('📄 HTML получен, длина:', html.length);
// ================== ВАРИАНТ 1: Execute Command с Puppeteer ==================
// Требует: npm install puppeteer в контейнере n8n
// Команда для Execute Command ноды:
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
const timestamp = Date.now();
const htmlFile = `/tmp/flights-${timestamp}.html`;
const pdfFile = `/tmp/flights-${timestamp}.pdf`;
// Команда для Execute Command ноды:
const command = `node -e "
const puppeteer = require('puppeteer');
const fs = require('fs');
const html = Buffer.from('${htmlBase64}', 'base64').toString('utf8');
(async () => {
const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] });
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
await page.pdf({
path: '${pdfFile}',
format: 'A4',
printBackground: true,
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
});
await browser.close();
const pdfBuffer = fs.readFileSync('${pdfFile}');
const base64 = pdfBuffer.toString('base64');
console.log(base64);
fs.unlinkSync('${pdfFile}');
})();
"`;
return [{
json: {
// Команда для Execute Command ноды
command: command,
// Или используйте этот вариант (проще):
html_file: htmlFile,
pdf_file: pdfFile,
html_base64: htmlBase64,
// Инструкция
instruction: 'Используйте Execute Command ноду с одной из команд ниже'
}
}];
// ================== ВАРИАНТ 2: HTTP Request к сервису с браузером ==================
// Раскомментируйте, если используете внешний сервис (Gotenberg, Browserless, etc.)
/*
const PDF_SERVICE_URL = 'https://api.gotenberg.dev/forms/chromium/convert/html';
// Или Browserless: 'https://chrome.browserless.io/pdf'
return [{
json: {
method: 'POST',
url: PDF_SERVICE_URL,
headers: {
'Content-Type': 'multipart/form-data'
},
body: {
files: [{
name: 'index.html',
content: html
}],
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
}
}
}
}];
*/
// ============================================================================
// ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ:
// ============================================================================
// ВАРИАНТ 1: Execute Command (если puppeteer установлен)
// 1. Установите puppeteer в контейнере n8n:
// docker exec -it <n8n_container> npm install puppeteer
// 2. Добавьте Execute Command ноду после этого Code Node
// 3. В команде используйте: {{ $json.command }}
// 4. После Execute Command добавьте Code Node для извлечения base64 из вывода
//
// ВАРИАНТ 2: HTTP Request к Gotenberg (self-hosted браузер)
// 1. Запустите Gotenberg: docker run -p 3000:3000 gotenberg/gotenberg:7
// 2. Используйте код выше (раскомментируйте)
// 3. Добавьте HTTP Request ноду
//
// ВАРИАНТ 3: HTTP Request к Browserless (cloud сервис)
// 1. Зарегистрируйтесь на browserless.io
// 2. Используйте их API для конвертации
// ============================================================================

View File

@@ -0,0 +1,96 @@
// ============================================================================
// n8n Code Node: Конвертация HTML в Base64 PDF
// ============================================================================
// Используйте этот код после "Code: Process Flights Data"
// для подготовки данных для конвертации в PDF и получения base64
// ============================================================================
// Получаем HTML из предыдущей ноды
const processedData = $('Code: Process Flights Data').first().json;
if (!processedData || !processedData.html) {
throw new Error('HTML не получен из предыдущей ноды');
}
const html = processedData.html;
// ==== ВАРИАНТ 1: HTTP Request к сервису, который возвращает base64 PDF ====
// Используйте этот вариант с HTTP Request нодой после этого Code Node
// Сервисы, которые поддерживают base64:
// - htmlpdfapi.com
// - pdfshift.io
// - api2pdf.com
// - и другие
return [{
json: {
method: 'POST',
url: 'https://api.htmlpdfapi.com/v1/pdf', // Замените на ваш сервис
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_API_KEY' // Замените на ваш API ключ
},
body: JSON.stringify({
html: html,
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
},
// Если сервис поддерживает прямое возвращение base64
base64: true
})
}
}];
// ==== ВАРИАНТ 2: Если сервис возвращает binary, конвертируем в base64 ====
// Используйте этот код в Code Node ПОСЛЕ HTTP Request ноды
// (когда получили PDF в binary формате)
/*
const pdfBinary = $binary.data; // Получаем binary данные из HTTP Request
// Конвертируем binary в base64
const base64 = pdfBinary.toString('base64');
return [{
json: {
pdf_base64: base64,
pdf_size_bytes: pdfBinary.length,
pdf_size_mb: (pdfBinary.length / (1024 * 1024)).toFixed(2),
flights_count: processedData.flights_count,
generated_at: processedData.generated_at,
filename: `flights-report-${new Date().toISOString().split('T')[0]}.pdf`
}
}];
*/
// ==== ВАРИАНТ 3: Использование Execute Command с wkhtmltopdf ====
// Если у вас установлен wkhtmltopdf на сервере n8n
// Раскомментируйте и используйте в Execute Command ноде
/*
// Сохраняем HTML во временный файл
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
const timestamp = Date.now();
const htmlFile = `/tmp/flights-${timestamp}.html`;
const pdfFile = `/tmp/flights-${timestamp}.pdf`;
// Команда для Execute Command ноды:
// echo '{{ $json.html_base64 }}' | base64 -d > {{ $json.html_file }} && \
// wkhtmltopdf --page-size A4 --margin-top 20mm --margin-right 15mm --margin-bottom 20mm --margin-left 15mm \
// --print-media-type {{ $json.html_file }} {{ $json.pdf_file }} && \
// cat {{ $json.pdf_file }} | base64 && \
// rm -f {{ $json.html_file }} {{ $json.pdf_file }}
return [{
json: {
html_base64: htmlBase64,
html_file: htmlFile,
pdf_file: pdfFile
}
}];
*/

View File

@@ -0,0 +1,35 @@
# Улучшенное форматирование PDF отчёта
## Что изменено
### Уменьшены отступы:
- **Padding секций:** с `20px``12px 18px`
- **Margin между элементами:** с `20px``12px`
- **Padding карточек:** с `20px``14px 18px`
- **Отступы в timeline:** с `10px``6px`
### Уменьшены размеры шрифтов:
- **Заголовки:** с `24px``20px`
- **Основной текст:** с `14px``13px`
- **Метки:** с `12px``11px`
### Более компактная компоновка:
- **Route info:** уменьшен gap с `15px``12px`
- **Status info:** уменьшен gap с `15px``10px`, minmax с `200px``180px`
- **Timeline:** уменьшена ширина label с `180px``160px`
### Общие улучшения:
- Уменьшен `line-height` с `1.6``1.4` для более плотного текста
- Уменьшены отступы body с `20px``15px`
- Уменьшены отступы container с `30px``20px`
## Результат
**Более компактное отображение** - данные расположены ближе друг к другу
**Меньше разрывов** - плавные переходы между секциями
**Лучшая читаемость** - оптимальный баланс между компактностью и читаемостью
**Экономия места** - больше информации на странице
## Как применить
Скопируйте обновлённый код из `N8N_FLIGHTS_TO_BASE64.js` в вашу Code Node "причесываем данные".

View File

@@ -0,0 +1,72 @@
// ============================================================================
// n8n Code Node: Полный цикл - HTML → Base64 PDF (всё в одном)
// ============================================================================
// Этот код делает всё: получает HTML, отправляет на конвертацию, получает base64
// Требует настройки HTTP Request ноды или внешнего сервиса
// ============================================================================
// Получаем HTML из предыдущей ноды "Code: Process Flights Data"
const processedData = $('Code: Process Flights Data').first().json;
if (!processedData || !processedData.html) {
throw new Error('HTML не получен из предыдущей ноды');
}
const html = processedData.html;
// ==== НАСТРОЙКИ ====
// Замените на ваши параметры
const PDF_SERVICE_URL = 'https://api.htmlpdfapi.com/v1/pdf'; // Или другой сервис
const PDF_API_KEY = 'YOUR_API_KEY'; // Замените на ваш ключ
// ==== ПОДГОТОВКА ЗАПРОСА ДЛЯ HTTP REQUEST ====
// Этот код подготавливает данные для HTTP Request ноды
// После этого Code Node добавьте HTTP Request ноду и используйте эти данные
return [{
json: {
// Данные для HTTP Request ноды
http_method: 'POST',
http_url: PDF_SERVICE_URL,
http_headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${PDF_API_KEY}`
},
http_body: JSON.stringify({
html: html,
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
},
base64: true // Запрашиваем base64 напрямую
}),
// Метаданные
html_length: html.length,
flights_count: processedData.flights_count,
generated_at: processedData.generated_at,
// Инструкция для следующей ноды
next_step: 'HTTP Request → Code: Extract Base64 PDF'
}
}];
// ============================================================================
// ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ:
// ============================================================================
// 1. Этот Code Node подготавливает запрос
// 2. Добавьте HTTP Request ноду после этого Code Node
// 3. В HTTP Request ноде используйте:
// - Method: {{ $json.http_method }}
// - URL: {{ $json.http_url }}
// - Headers: {{ $json.http_headers }}
// - Body: {{ $json.http_body }}
// 4. После HTTP Request добавьте Code Node с кодом из N8N_FLIGHTS_PDF_BASE64_FULL.js
// для извлечения base64 из ответа
// ============================================================================

View File

@@ -0,0 +1,81 @@
// ============================================================================
// n8n Code Node: Полная обработка - HTML → Base64 PDF
// ============================================================================
// Этот код обрабатывает ответ от HTTP Request и возвращает base64 PDF
// Используйте ПОСЛЕ HTTP Request ноды, которая конвертирует HTML в PDF
// ============================================================================
// Получаем данные из HTTP Request ноды
const httpResponse = $input.first();
if (!httpResponse) {
throw new Error('Ответ от HTTP Request не получен');
}
// ==== ВАРИАНТ 1: Сервис вернул base64 напрямую в JSON ====
if (httpResponse.json && httpResponse.json.pdf) {
const base64 = httpResponse.json.pdf;
return [{
json: {
pdf_base64: base64,
pdf_size_bytes: Math.floor(base64.length * 0.75), // Примерный размер
pdf_size_mb: (Math.floor(base64.length * 0.75) / (1024 * 1024)).toFixed(2),
success: true,
source: 'json_response'
}
}];
}
// ==== ВАРИАНТ 2: Сервис вернул binary данные ====
if (httpResponse.binary && httpResponse.binary.data) {
const pdfBinary = httpResponse.binary.data;
// Конвертируем binary в base64
// В n8n binary.data может быть Buffer или строка
let base64;
if (Buffer.isBuffer(pdfBinary)) {
base64 = pdfBinary.toString('base64');
} else if (typeof pdfBinary === 'string') {
// Если уже base64 строка
base64 = pdfBinary;
} else {
// Пытаемся преобразовать
base64 = Buffer.from(pdfBinary).toString('base64');
}
const sizeBytes = Buffer.from(base64, 'base64').length;
return [{
json: {
pdf_base64: base64,
pdf_size_bytes: sizeBytes,
pdf_size_mb: (sizeBytes / (1024 * 1024)).toFixed(2),
success: true,
source: 'binary_response'
}
}];
}
// ==== ВАРИАНТ 3: Сервис вернул base64 в поле body или data ====
if (httpResponse.json) {
const body = httpResponse.json.body || httpResponse.json.data || httpResponse.json;
if (body.pdf || body.base64 || body.content) {
const base64 = body.pdf || body.base64 || body.content;
const sizeBytes = Buffer.from(base64, 'base64').length;
return [{
json: {
pdf_base64: base64,
pdf_size_bytes: sizeBytes,
pdf_size_mb: (sizeBytes / (1024 * 1024)).toFixed(2),
success: true,
source: 'body_field'
}
}];
}
}
// ==== ОШИБКА: Не удалось извлечь PDF ====
throw new Error('Не удалось извлечь PDF из ответа. Структура ответа: ' + JSON.stringify(Object.keys(httpResponse), null, 2));

View File

@@ -0,0 +1,65 @@
// ============================================================================
// n8n Code Node: Подготовка данных запроса рейса
// ============================================================================
// Используйте эту ноду ПЕРЕД "причесываем данные"
// Она безопасно получает данные из ноды "запрос рейса" и передаёт их дальше
// ============================================================================
// Получаем данные из ноды "запрос рейса"
let requestData = {
flight_number: null,
departure_date_local: null,
arrival_date_local: null
};
try {
const requestNode = $('запрос рейса');
if (requestNode && requestNode.first()) {
const requestJson = requestNode.first().json;
if (requestJson) {
requestData = {
flight_number: requestJson.flight_number || requestJson.ident || requestJson.flight || null,
departure_date_local: requestJson.departure_date_local || null,
arrival_date_local: requestJson.arrival_date_local || null
};
}
}
} catch (e) {
console.log('⚠️ Не удалось получить данные из ноды "запрос рейса":', e.message);
}
// Получаем данные из входных элементов (fallback)
const inputItems = $input.all();
inputItems.forEach(item => {
if (item.json) {
if (!requestData.flight_number && item.json.flight_number) {
requestData.flight_number = item.json.flight_number;
}
if (!requestData.departure_date_local && item.json.departure_date_local) {
requestData.departure_date_local = item.json.departure_date_local;
}
if (!requestData.arrival_date_local && item.json.arrival_date_local) {
requestData.arrival_date_local = item.json.arrival_date_local;
}
}
});
// Передаём данные дальше вместе с входными данными
const outputItems = inputItems.map(item => ({
...item,
json: {
...item.json,
// Добавляем данные запроса
request_flight_number: requestData.flight_number,
request_departure_date: requestData.departure_date_local,
request_arrival_date: requestData.arrival_date_local
}
}));
return outputItems.length > 0 ? outputItems : [{
json: {
request_flight_number: requestData.flight_number,
request_departure_date: requestData.departure_date_local,
request_arrival_date: requestData.arrival_date_local
}
}];

View File

@@ -0,0 +1,193 @@
# Обработка данных о рейсах в n8n
## Описание
Код для обработки данных о рейсах из двух источников (FlightAware и FlightRadar24), объединения их и генерации красивого HTML для последующей конвертации в PDF.
## Структура входных данных
Workflow должен получать данные в следующем формате:
```json
[
{
"body": {
"flights": [
{
"ident": "CES747",
"registration": "B-1308",
"origin": { "code_iata": "KMG", "name": "Kunming Changshui Int'l" },
"destination": { "code_iata": "PVG", "name": "Shanghai Pudong Int'l" },
...
}
]
}
},
{
"body": {
"data": [
{
"flight": "MU747",
"reg": "B-1308",
"orig_iata": "KMG",
"dest_iata": "PVG",
...
}
]
}
}
]
```
## Установка в n8n
### Шаг 1: Добавить Code Node
1. В вашем workflow после получения данных из FlightAware и FlightRadar24
2. Добавьте ноду **Code** (JavaScript)
3. Назовите её: `Code: Process Flights Data`
### Шаг 2: Вставить код
Скопируйте содержимое файла `N8N_CODE_PROCESS_FLIGHTS_DATA.js` в Code Node.
### Шаг 3: Настройка выхода
Code Node вернёт объект с полями:
- `html` - готовый HTML для конвертации в PDF
- `flights` - массив объединённых данных о рейсах
- `flights_count` - количество рейсов
- `sources` - информация о доступности источников
- `generated_at` - время генерации
## Конвертация HTML в Base64 PDF
### Вариант 1: HTTP Request → Base64 PDF (Рекомендуется)
**Шаг 1:** После Code Node добавьте Code Node с кодом из `N8N_FLIGHTS_PDF_BASE64_COMPLETE.js`
- Этот код подготавливает запрос для HTTP Request
**Шаг 2:** Добавьте HTTP Request ноду:
- Method: `POST`
- URL: `{{ $json.http_url }}` (например, `https://api.htmlpdfapi.com/v1/pdf`)
- Headers: `{{ $json.http_headers }}`
- Body: `{{ $json.http_body }}`
- Response Format: `JSON` или `Binary` (в зависимости от сервиса)
**Шаг 3:** После HTTP Request добавьте Code Node с кодом из `N8N_FLIGHTS_PDF_BASE64_FULL.js`
- Этот код извлекает base64 из ответа сервиса
**Результат:** В выходных данных будет поле `pdf_base64` с готовым PDF в формате base64
### Вариант 2: Прямой запрос к сервису
Используйте код из `N8N_FLIGHTS_HTML_TO_PDF_EXAMPLE.js` для подготовки запроса к сервису конвертации.
**Популярные сервисы:**
- **htmlpdfapi.com** - возвращает base64 в JSON
- **pdfshift.io** - поддерживает base64
- **api2pdf.com** - возвращает base64
- **gotenberg.dev** - бесплатный self-hosted вариант
### Вариант 3: Execute Command с wkhtmltopdf
Если на сервере n8n установлен `wkhtmltopdf`:
1. Сохраните HTML во временный файл
2. Выполните команду:
```bash
wkhtmltopdf --page-size A4 \
--margin-top 20mm --margin-right 15mm \
--margin-bottom 20mm --margin-left 15mm \
--print-media-type input.html output.pdf && \
cat output.pdf | base64
```
3. Получите base64 из вывода команды
### Использование base64 PDF
После получения base64 вы можете:
- Сохранить в файл
- Отправить по email
- Загрузить в S3/Nextcloud
- Вернуть в API response
- Использовать в других workflow
## Особенности обработки
### Объединение данных
Данные объединяются по полю `registration` (номер самолёта):
- FlightAware: `flight.registration`
- FlightRadar24: `flight.reg`
Если для рейса есть данные только из одного источника, они всё равно будут отображены.
### Обработка отсутствующих данных
- Если данные из источника отсутствуют, показывается сообщение "Данные не получены"
- Пустые значения отображаются как "—"
- Даты форматируются в читаемый формат
### Форматирование
HTML включает:
- Красивый дизайн с градиентами и карточками
- Адаптивную вёрстку
- Стили для печати (media queries для print)
- Цветовую индикацию источников данных
- Информацию о задержках (зелёный/красный)
## Пример workflow
```
HTTP Request (FlightAware)
HTTP Request (FlightRadar24)
Code: Process Flights Data ← Вставить код отсюда
HTML/CSS to PDF (или HTTP Request для конвертации)
Save File / Send Email / etc.
```
## Отладка
Если данные не обрабатываются:
1. Проверьте структуру входных данных через `console.log`:
```javascript
console.log('FlightAware:', JSON.stringify(flightAwareData, null, 2));
console.log('FlightRadar24:', JSON.stringify(flightRadar24Data, null, 2));
```
2. Убедитесь, что данные приходят в правильном порядке:
- Первый элемент = FlightAware
- Второй элемент = FlightRadar24
3. Проверьте наличие полей `body.flights` и `body.data`
## Дополнительные возможности
### Кастомизация HTML
Вы можете изменить стили в функции `generateFullHTML()`:
- Цвета
- Шрифты
- Размеры
- Расположение элементов
### Добавление дополнительных полей
В функции `generateFlightCard()` можно добавить отображение дополнительных полей из API.
### Фильтрация рейсов
Перед генерацией HTML можно отфильтровать рейсы:
```javascript
const filteredFlights = mergedFlights.filter(flight => {
// Ваша логика фильтрации
return flight.flightAware || flight.flightRadar24;
});
```

View File

@@ -0,0 +1,236 @@
# Быстрый старт: HTML → Base64 PDF в n8n
## Проблема
У вас есть HTML в формате:
```json
{
"html": "<!DOCTYPE html>..."
}
```
Нужно получить base64 PDF.
## Решение: 3 ноды
### Шаг 1: Code Node - Подготовка запроса
**Название:** `Code: Prepare PDF Request`
**Код:** Скопируйте из `N8N_HTML_TO_BASE64_PDF_SIMPLE.js`
**Важно:**
- Замените `YOUR_API_KEY` на ваш реальный API ключ
- Выберите сервис конвертации (htmlpdfapi.com, pdfshift.io и т.д.)
**Выходные данные:**
```json
{
"method": "POST",
"url": "https://api.htmlpdfapi.com/v1/pdf",
"headers": {...},
"body": "{...}"
}
```
---
### Шаг 2: HTTP Request - Конвертация
**Название:** `HTTP Request: Convert to PDF`
**Настройка:**
- **Method:** `{{ $json.method }}`
- **URL:** `{{ $json.url }}`
- **Authentication:** None (или Basic, если требуется)
- **Headers:**
```json
{{ $json.headers }}
```
- **Body:**
```json
{{ $json.body }}
```
- **Response Format:** `JSON` (или `Binary`, если сервис возвращает binary)
**Что делает:** Отправляет HTML в сервис конвертации и получает PDF
---
### Шаг 3: Code Node - Извлечение Base64
**Название:** `Code: Extract Base64 PDF`
**Код:** Скопируйте из `N8N_EXTRACT_BASE64_FROM_RESPONSE.js`
**Выходные данные:**
```json
{
"pdf_base64": "JVBERi0xLjQKJeLjz9MK...",
"pdf_size_bytes": 123456,
"pdf_size_mb": "0.12",
"filename": "flights-report-2026-01-16.pdf",
"success": true
}
```
---
## Готово!
Теперь у вас есть base64 PDF в поле `pdf_base64`.
## Что дальше?
### Вариант A: Сохранить в файл
Добавьте Code Node:
```javascript
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
const pdfBuffer = Buffer.from(base64, 'base64');
return [{
binary: {
data: pdfBuffer,
fileName: $('Code: Extract Base64 PDF').first().json.filename,
mimeType: 'application/pdf'
}
}];
```
Затем используйте ноду **Write Binary File** или **Save to S3**.
### Вариант B: Вернуть в API
Добавьте Code Node перед Response:
```javascript
const pdfData = $('Code: Extract Base64 PDF').first().json;
return [{
json: {
success: true,
pdf_base64: pdfData.pdf_base64,
pdf_size_mb: pdfData.pdf_size_mb,
filename: pdfData.filename
}
}];
```
### Вариант C: Отправить по Email
Добавьте Code Node:
```javascript
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
const pdfBuffer = Buffer.from(base64, 'base64');
const filename = $('Code: Extract Base64 PDF').first().json.filename;
return [{
json: {
to: 'recipient@example.com',
subject: 'Отчёт о рейсах',
text: 'Во вложении отчёт о рейсах.',
attachments: [{
filename: filename,
content: pdfBuffer,
contentType: 'application/pdf'
}]
}
}];
```
Затем используйте ноду **Email Send**.
---
## Популярные сервисы конвертации
### 1. htmlpdfapi.com (рекомендуется)
- **Бесплатно:** 100 PDF/месяц
- **Платно:** от $9/месяц
- **URL:** https://htmlpdfapi.com
- **Возвращает:** `{ pdf: "base64..." }`
### 2. pdfshift.io
- **Бесплатно:** 100 PDF/месяц
- **Платно:** от $9/месяц
- **URL:** https://pdfshift.io
- **Возвращает:** binary или base64
### 3. api2pdf.com
- **Бесплатно:** 50 PDF/месяц
- **Платно:** от $9/месяц
- **URL:** https://www.api2pdf.com
- **Возвращает:** `{ Pdf: "base64..." }`
### 4. Self-hosted: Gotenberg
- **Бесплатно:** полностью
- **Требует:** Docker
- **URL:** https://gotenberg.dev
- **Возвращает:** binary PDF
---
## Отладка
### Проверка HTML
В Code Node добавьте:
```javascript
console.log('HTML length:', html.length);
console.log('HTML preview:', html.substring(0, 200));
```
### Проверка ответа сервиса
После HTTP Request добавьте Code Node:
```javascript
const response = $input.first();
console.log('Response keys:', Object.keys(response));
console.log('Response json keys:', response.json ? Object.keys(response.json) : 'no json');
console.log('Response binary:', response.binary ? 'yes' : 'no');
```
### Проверка base64
После извлечения base64:
```javascript
const base64 = $json.pdf_base64;
console.log('Base64 length:', base64.length);
console.log('Base64 preview:', base64.substring(0, 50));
```
---
## Частые проблемы
### Проблема: "HTML не найден"
**Решение:** Проверьте, что HTML приходит в поле `html`. Если нет, измените первую строку в `N8N_HTML_TO_BASE64_PDF_SIMPLE.js`:
```javascript
const html = $json.html || $json.body?.html || $json.data?.html || $json;
```
### Проблема: "Не удалось извлечь base64"
**Решение:**
1. Проверьте формат ответа сервиса
2. Добавьте логирование в `N8N_EXTRACT_BASE64_FROM_RESPONSE.js`
3. Убедитесь, что сервис действительно вернул PDF
### Проблема: PDF пустой или повреждён
**Решение:**
1. Проверьте, что HTML валидный
2. Убедитесь, что CSS включён в HTML (inline styles)
3. Проверьте, что сервис поддерживает все используемые CSS свойства
---
## Готовый Workflow
```
[Ваша нода с HTML]
Code: Prepare PDF Request
HTTP Request: Convert to PDF
Code: Extract Base64 PDF
[Использование base64]
```
Всё готово! 🎉

View File

@@ -0,0 +1,103 @@
# Отображение запрошенных рейсов без данных
## Проблема
Когда данных о рейсе нет, нужно показывать, по какому рейсу и запросу информация отсутствует.
## Решение
Код автоматически извлекает информацию о запрошенных рейсах и показывает их даже если данных нет.
## Способы передачи информации о запрошенных рейсах
### Вариант 1: Прямая передача (рекомендуется)
В предыдущей ноде (перед Code Node) добавьте информацию о запрошенных рейсах:
```javascript
// В Code Node перед "причесываем данные"
return [{
json: {
// Ваши данные
...existingData,
// Информация о запрошенных рейсах
requested_flights: ['MU747', 'CES747'], // Массив номеров рейсов
// ИЛИ
flight_number: 'MU747', // Один рейс
// ИЛИ
flight_numbers: ['MU747', 'CES747'] // Альтернативный формат
}
}];
```
### Вариант 2: Автоматическое извлечение
Код автоматически пытается извлечь информацию о рейсах из:
- URL запросов (параметры `ident`, `flight_number`, `flight`, `callsign`)
- Query параметров
- Body запросов
- Прямых полей в JSON
## Что отображается
Если рейс был запрошен, но данных нет, показывается карточка:
```
┌─ Рейс MU747 ───────────────┐
│ Запрошен │
│ │
│ Запрошенный рейс: MU747 │
│ │
│ [FlightAware] │
│ ✗ Данные не получены │
│ │
│ [FlightRadar24] │
│ ✗ Данные не получены │
└────────────────────────────┘
```
## Пример использования
### В предыдущей ноде (HTTP Request или Code Node):
```javascript
// После запросов к FlightAware и FlightRadar24
return [{
json: {
data: [
{ body: { flights: [...] } }, // FlightAware ответ
{ body: { data: [...] } } // FlightRadar24 ответ
],
// Добавляем информацию о запрошенных рейсах
requested_flights: ['MU747', 'CES747']
}
}];
```
### Или в отдельной ноде перед обработкой:
```javascript
// Code Node: Prepare Request Info
const flightNumbers = ['MU747', 'CES747']; // Из вашего запроса
return [{
json: {
requested_flights: flightNumbers,
// Другие данные...
}
}];
```
## Преимущества
**Прозрачность** - видно, какие рейсы запрашивались
**Отладка** - легко понять, почему данных нет
**Информативность** - пользователь видит, что запрос был выполнен
**Автоматика** - код пытается извлечь информацию автоматически
## Если данные всё равно не показываются
1. Проверьте, что передаёте `requested_flights` в предыдущей ноде
2. Убедитесь, что формат правильный: массив строк или объект с полем `flight_number`
3. Проверьте логи в Code Node - там будут сообщения о найденных запрошенных рейсах

View File

@@ -0,0 +1,370 @@
// ============================================================================
// n8n Code Node: Отчёт о рейсах (HTML → Binary + Base64 PDF)
// ============================================================================
// Упрощённая версия с возвратом binary HTML и подготовкой для PDF конвертации
// ============================================================================
const inputItems = $input.all();
// ================== FALLBACK ==================
if (!inputItems || inputItems.length === 0) {
const html = '<!DOCTYPE html><html><body><h1>Ошибка: данные не получены</h1></body></html>';
return [{
binary: {
data: Buffer.from(html, 'utf8'),
mimeType: 'text/html',
fileName: 'flights-report.html'
},
json: {
html: html,
flights_count: 0,
error: 'Нет входных данных'
}
}];
}
// ================== ИЗВЛЕЧЕНИЕ ДАННЫХ ==================
let flightAwareData = [];
let flightRadar24Data = [];
try {
const fa = inputItems[0]?.json?.body?.flights;
if (Array.isArray(fa)) flightAwareData = fa;
} catch (e) {
console.log('⚠️ Ошибка извлечения FlightAware:', e.message);
}
try {
const fr = inputItems[1]?.json?.body?.data;
if (Array.isArray(fr)) flightRadar24Data = fr;
} catch (e) {
console.log('⚠️ Ошибка извлечения FlightRadar24:', e.message);
}
// ================== УТИЛИТЫ ==================
const safeStr = v => (v == null ? '' : String(v));
const safeDate = v => {
if (!v) return '—';
try {
const d = new Date(v);
return isNaN(d.getTime()) ? '—' : d.toLocaleString('ru-RU', {
timeZone: 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return '—';
}
};
const formatDuration = s => !s ? '—' : `${Math.floor(s / 3600)}ч ${Math.floor((s % 3600) / 60)}м`;
const formatDistance = km => !km ? '—' : `${Number(km).toFixed(2)} км`;
// ================== MERGE ПО REGISTRATION ==================
const flightsMap = new Map();
flightAwareData.forEach(f => {
const reg = safeStr(f.registration).trim();
if (!reg) return;
if (!flightsMap.has(reg)) {
flightsMap.set(reg, {
registration: reg,
flightNumber: safeStr(f.flight_number),
ident: safeStr(f.ident),
identIata: safeStr(f.ident_iata),
aircraftType: safeStr(f.aircraft_type),
fa: f,
fr: null
});
} else {
flightsMap.get(reg).fa = f;
}
});
flightRadar24Data.forEach(f => {
const reg = safeStr(f.reg).trim();
if (!reg) return;
if (!flightsMap.has(reg)) {
flightsMap.set(reg, {
registration: reg,
flightNumber: safeStr(f.flight),
ident: safeStr(f.callsign),
identIata: safeStr(f.flight),
aircraftType: safeStr(f.type),
fa: null,
fr: f
});
} else {
flightsMap.get(reg).fr = f;
}
});
const flights = Array.from(flightsMap.values());
// ================== HTML GENERATION ==================
const generateFlightCard = f => {
const fa = f.fa;
const fr = f.fr;
let card = `
<div class="flight-card">
<div class="flight-header">
<h2>Рейс ${f.flightNumber || f.ident || '—'}</h2>
<span class="registration">${f.registration}</span>
</div>
<div class="flight-info">
<div class="info-row">
<span class="label">Тип самолёта:</span>
<span class="value">${f.aircraftType || '—'}</span>
</div>
<div class="info-row">
<span class="label">Идентификатор:</span>
<span class="value">${f.ident || '—'} (${f.identIata || '—'})</span>
</div>
</div>`;
if (fa) {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightaware">FlightAware</span>
</div>
<div class="source-content">
<div class="route-info">
<div class="route-item">
<span class="route-label">Откуда:</span>
<span class="route-value">${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')})</span>
</div>
<div class="route-item">
<span class="route-label">Куда:</span>
<span class="route-value">${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')})</span>
</div>
</div>
<div class="timeline">
<div class="timeline-item">
<span class="timeline-label">Вылет:</span>
<span class="timeline-value">${safeDate(fa.actual_out)}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Прилёт:</span>
<span class="timeline-value">${safeDate(fa.actual_in)}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Статус:</span>
<span class="timeline-value">${safeStr(fa.status || '—')}</span>
</div>
</div>
</div>
</div>`;
} else {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightaware">FlightAware</span>
<span class="source-missing">Данные не получены</span>
</div>
</div>`;
}
if (fr) {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightradar24">FlightRadar24</span>
</div>
<div class="source-content">
<div class="route-info">
<div class="route-item">
<span class="route-label">Откуда:</span>
<span class="route-value">${safeStr(fr.orig_iata || '—')} (${safeStr(fr.orig_icao || '—')})</span>
</div>
<div class="route-item">
<span class="route-label">Куда:</span>
<span class="route-value">${safeStr(fr.dest_iata || '—')} (${safeStr(fr.dest_icao || '—')})</span>
</div>
</div>
<div class="status-info">
<div class="status-item">
<span class="status-label">Время полёта:</span>
<span class="status-value">${formatDuration(fr.flight_time)}</span>
</div>
<div class="status-item">
<span class="status-label">Расстояние:</span>
<span class="status-value">${formatDistance(fr.actual_distance)}</span>
</div>
</div>
</div>
</div>`;
} else {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightradar24">FlightRadar24</span>
<span class="source-missing">Данные не получены</span>
</div>
</div>`;
}
card += `</div>`;
return card;
};
const now = new Date();
const reportDate = now.toLocaleString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
const html = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Отчёт о рейсах</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; line-height: 1.6; color: #333; background: #f5f5f5; padding: 20px; }
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.header { border-bottom: 3px solid #2563eb; padding-bottom: 20px; margin-bottom: 30px; }
.header h1 { color: #1e40af; font-size: 28px; margin-bottom: 10px; }
.header-meta { color: #666; font-size: 14px; }
.sources-info { display: flex; gap: 15px; margin-top: 10px; flex-wrap: wrap; }
.source-tag { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }
.source-tag.available { background: #d1fae5; color: #065f46; }
.source-tag.unavailable { background: #fee2e2; color: #991b1b; }
.flight-card { border: 1px solid #e5e7eb; border-radius: 8px; margin-bottom: 25px; overflow: hidden; background: white; }
.flight-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; display: flex; justify-content: space-between; align-items: center; }
.flight-header h2 { font-size: 24px; margin: 0; }
.registration { background: rgba(255,255,255,0.2); padding: 6px 12px; border-radius: 4px; font-weight: 600; font-size: 14px; }
.flight-info { padding: 15px 20px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; }
.info-row { display: flex; margin-bottom: 8px; }
.info-row .label { font-weight: 600; color: #4b5563; width: 150px; flex-shrink: 0; }
.info-row .value { color: #111827; }
.source-section { border-top: 1px solid #e5e7eb; padding: 20px; }
.source-section:first-of-type { border-top: none; }
.source-header { display: flex; align-items: center; gap: 10px; margin-bottom: 15px; }
.source-badge { display: inline-block; padding: 6px 14px; border-radius: 6px; font-size: 13px; font-weight: 600; color: white; }
.source-badge.source-flightaware { background: #3b82f6; }
.source-badge.source-flightradar24 { background: #10b981; }
.source-missing { color: #ef4444; font-size: 13px; font-style: italic; }
.source-content { margin-left: 0; }
.route-info { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px; padding: 15px; background: #f9fafb; border-radius: 6px; }
.route-item { display: flex; flex-direction: column; }
.route-label { font-size: 12px; color: #6b7280; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.route-value { font-size: 16px; font-weight: 600; color: #111827; }
.timeline { margin-bottom: 20px; }
.timeline-item { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }
.timeline-item:last-child { border-bottom: none; }
.timeline-label { font-weight: 500; color: #4b5563; width: 180px; flex-shrink: 0; }
.timeline-value { color: #111827; text-align: right; }
.status-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; padding: 15px; background: #f9fafb; border-radius: 6px; }
.status-item { display: flex; flex-direction: column; }
.status-label { font-size: 12px; color: #6b7280; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
.status-value { font-size: 14px; font-weight: 600; color: #111827; }
.no-data { text-align: center; padding: 60px 20px; color: #6b7280; font-size: 18px; }
@media print { body { background: white; padding: 0; } .container { box-shadow: none; padding: 20px; } .flight-card { page-break-inside: avoid; margin-bottom: 20px; } }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Отчёт о рейсах</h1>
<div class="header-meta">
<div>Дата формирования: ${reportDate}</div>
<div class="sources-info">
<span class="source-tag ${flightAwareData.length > 0 ? 'available' : 'unavailable'}">
FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
</span>
<span class="source-tag ${flightRadar24Data.length > 0 ? 'available' : 'unavailable'}">
FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
</span>
</div>
</div>
</div>
<div class="flights-container">
${flights.length ? flights.map(generateFlightCard).join('') : '<div class="no-data">Данные о рейсах не найдены</div>'}
</div>
</div>
</body>
</html>`;
// ================== ПОДГОТОВКА ДАННЫХ ДЛЯ PDF КОНВЕРТАЦИИ ==================
// Настройки сервиса (замените на ваши)
const PDF_SERVICE_URL = 'https://api.htmlpdfapi.com/v1/pdf';
const PDF_API_KEY = 'YOUR_API_KEY'; // ⚠️ ЗАМЕНИТЕ на ваш API ключ
// ================== RETURN ==================
return [{
// Binary HTML файл (для использования в Convert to File ноде или сохранения)
binary: {
data: Buffer.from(html, 'utf8'),
mimeType: 'text/html',
fileName: `flights-report-${now.toISOString().split('T')[0]}.html`
},
// JSON данные
json: {
// HTML строка (для конвертации в PDF через HTTP Request)
html: html,
// Метаданные
flights_count: flights.length,
generated_at: now.toISOString(),
sources: {
flightaware: { available: flightAwareData.length > 0, count: flightAwareData.length },
flightradar24: { available: flightRadar24Data.length > 0, count: flightRadar24Data.length }
},
// Данные для конвертации в base64 PDF (используйте в следующей HTTP Request ноде)
pdf_request: {
method: 'POST',
url: PDF_SERVICE_URL,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${PDF_API_KEY}`
},
body: JSON.stringify({
html: html,
options: {
format: 'A4',
printBackground: true,
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
},
base64: true
})
},
// Удобные поля для HTTP Request ноды
pdf_request_method: 'POST',
pdf_request_url: PDF_SERVICE_URL,
pdf_request_headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${PDF_API_KEY}`
},
pdf_request_body: JSON.stringify({
html: html,
options: {
format: 'A4',
printBackground: true,
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
},
base64: true
})
}
}];
// ============================================================================
// ИСПОЛЬЗОВАНИЕ:
// ============================================================================
// 1. Binary HTML можно использовать в ноде "Convert to File" или сохранить
// 2. JSON.html можно использовать для конвертации в PDF через HTTP Request
// 3. JSON.pdf_request_* поля готовы для использования в HTTP Request ноде
// 4. После HTTP Request используйте N8N_EXTRACT_BASE64_FROM_RESPONSE.js
// для извлечения base64 PDF из ответа
// ============================================================================

View File

@@ -0,0 +1,630 @@
// ============================================================================
// n8n Code Node: Обработка данных о рейсах → Base64 HTML
// ============================================================================
// Вход: [{ data: [{ body: { flights: [...] }}, { body: { data: [...] }}] }]
// Выход: base64 HTML
// ============================================================================
const inputItems = $input.all();
// ================== FALLBACK ==================
if (!inputItems || inputItems.length === 0) {
const html = '<!DOCTYPE html><html><body><h1>Ошибка: данные не получены</h1></body></html>';
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
return [{
json: {
html_base64: htmlBase64,
html: html,
flights_count: 0,
error: 'Нет входных данных'
}
}];
}
// ================== ИЗВЛЕЧЕНИЕ ДАННЫХ ==================
// Новая структура: [{ data: [{ body: { flights: [...] }}, { error: {...} }, { flight_number, ... }] }]
let flightAwareData = [];
let flightRadar24Data = [];
let requestData = null; // Данные из ноды "запрос рейса"
let flightRadar24Error = null; // Ошибка от FlightRadar24
try {
const firstItem = inputItems[0];
if (firstItem && firstItem.json && firstItem.json.data && Array.isArray(firstItem.json.data)) {
// Первый элемент массива data - FlightAware
if (firstItem.json.data[0] && firstItem.json.data[0].body) {
if (firstItem.json.data[0].body.flights) {
flightAwareData = Array.isArray(firstItem.json.data[0].body.flights)
? firstItem.json.data[0].body.flights
: [];
}
}
// Второй элемент массива data - FlightRadar24 (может быть ошибка)
if (firstItem.json.data[1]) {
// Проверяем, есть ли ошибка
if (firstItem.json.data[1].error) {
flightRadar24Error = firstItem.json.data[1].error;
console.log('⚠️ Ошибка FlightRadar24:', flightRadar24Error.message);
flightRadar24Data = [];
} else if (firstItem.json.data[1].body && firstItem.json.data[1].body.data) {
flightRadar24Data = Array.isArray(firstItem.json.data[1].body.data)
? firstItem.json.data[1].body.data
: [];
}
}
// Третий элемент массива data - данные из ноды "запрос рейса"
if (firstItem.json.data[2] && firstItem.json.data[2].flight_number) {
requestData = {
flight_number: firstItem.json.data[2].flight_number,
departure_date_local: firstItem.json.data[2].departure_date_local || null,
arrival_date_local: firstItem.json.data[2].arrival_date_local || null
};
console.log('✅ Данные запроса получены:', requestData);
}
}
} catch (e) {
console.log('⚠️ Ошибка извлечения данных:', e.message);
}
// ================== УТИЛИТЫ ==================
const safeStr = v => (v == null ? '' : String(v));
const safeDate = v => {
if (!v) return '—';
try {
const d = new Date(v);
return isNaN(d.getTime()) ? '—' : d.toLocaleString('ru-RU', {
timeZone: 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return '—';
}
};
const formatDuration = s => !s ? '—' : `${Math.floor(s / 3600)}ч ${Math.floor((s % 3600) / 60)}м`;
const formatDistance = km => !km ? '—' : `${Number(km).toFixed(2)} км`;
// ================== MERGE ПО REGISTRATION ==================
const flightsMap = new Map();
// Добавляем данные из FlightAware
flightAwareData.forEach(f => {
const reg = safeStr(f.registration).trim();
if (!reg) return;
if (!flightsMap.has(reg)) {
flightsMap.set(reg, {
registration: reg,
flightNumber: safeStr(f.flight_number),
ident: safeStr(f.ident),
identIata: safeStr(f.ident_iata),
aircraftType: safeStr(f.aircraft_type),
fa: f,
fr: null
});
} else {
flightsMap.get(reg).fa = f;
}
});
// Добавляем данные из FlightRadar24
flightRadar24Data.forEach(f => {
const reg = safeStr(f.reg).trim();
if (!reg) return;
if (!flightsMap.has(reg)) {
flightsMap.set(reg, {
registration: reg,
flightNumber: safeStr(f.flight),
ident: safeStr(f.callsign),
identIata: safeStr(f.flight),
aircraftType: safeStr(f.type),
fa: null,
fr: f
});
} else {
flightsMap.get(reg).fr = f;
}
});
// ================== ДОБАВЛЕНИЕ ЗАПРОШЕННЫХ РЕЙСОВ БЕЗ ДАННЫХ ==================
// Если есть информация о запрошенных рейсах, но нет данных - добавляем их
// Пытаемся извлечь из предыдущих нод (HTTP Request) или получить из входных данных
const allInputItems = $input.all();
const firstItemForRequest = inputItems[0]; // Используем уже определённую переменную из блока выше
// Ищем информацию о запрошенных рейсах
let requestedFlightNumbers = new Set();
// ВАРИАНТ 1: Получение данных из ноды "запрос рейса"
// Используем данные, извлечённые выше из data[2]
let requestFlightNumber = null;
let requestDepartureDate = null;
let requestArrivalDate = null;
if (requestData) {
requestFlightNumber = requestData.flight_number;
requestDepartureDate = requestData.departure_date_local;
requestArrivalDate = requestData.arrival_date_local;
if (requestFlightNumber) {
requestedFlightNumbers.add(String(requestFlightNumber));
}
}
// Дополнительно ищем в других местах (fallback)
allInputItems.forEach(item => {
if (item.json) {
// Прямые поля из ноды "запрос рейса"
if (item.json.flight_number && (item.json.departure_date_local || item.json.arrival_date_local)) {
if (!requestFlightNumber) {
requestFlightNumber = item.json.flight_number || item.json.ident || item.json.flight;
requestDepartureDate = item.json.departure_date_local || null;
requestArrivalDate = item.json.arrival_date_local || null;
if (requestFlightNumber) {
requestedFlightNumbers.add(String(requestFlightNumber));
}
}
}
// Данные, переданные из предыдущей ноды
if (item.json.request_flight_number) {
if (!requestFlightNumber) {
requestFlightNumber = item.json.request_flight_number;
requestDepartureDate = item.json.request_departure_date || null;
requestArrivalDate = item.json.request_arrival_date || null;
if (requestFlightNumber) {
requestedFlightNumbers.add(String(requestFlightNumber));
}
}
}
}
});
// ВАРИАНТ 2: Прямая передача из предыдущей ноды
if (firstItemForRequest && firstItemForRequest.json) {
// Массив запрошенных рейсов
if (firstItemForRequest.json.requested_flights && Array.isArray(firstItemForRequest.json.requested_flights)) {
firstItemForRequest.json.requested_flights.forEach(flight => {
const flightNum = typeof flight === 'string' ? flight : (flight.flight_number || flight.ident || flight);
if (flightNum) {
requestedFlightNumbers.add(flightNum);
}
});
}
// Один рейс
if (firstItemForRequest.json.flight_number || firstItemForRequest.json.ident || firstItemForRequest.json.flight) {
const flightNum = firstItemForRequest.json.flight_number || firstItemForRequest.json.ident || firstItemForRequest.json.flight;
requestedFlightNumbers.add(flightNum);
}
// Массив flight_numbers
if (firstItemForRequest.json.flight_numbers && Array.isArray(firstItemForRequest.json.flight_numbers)) {
firstItemForRequest.json.flight_numbers.forEach(flightNum => {
if (flightNum) requestedFlightNumbers.add(String(flightNum));
});
}
}
// ВАРИАНТ 3: Извлечение из всех входных элементов
allInputItems.forEach(item => {
if (item.json) {
if (item.json.flight_number) {
requestedFlightNumbers.add(String(item.json.flight_number));
}
if (item.json.ident) {
requestedFlightNumbers.add(String(item.json.ident));
}
if (item.json.flight) {
requestedFlightNumbers.add(String(item.json.flight));
}
}
});
// ВАРИАНТ 2: Извлечение из URL и параметров запросов
allInputItems.forEach(item => {
// Из URL запроса
if (item.json && item.json.url) {
const url = item.json.url;
const flightMatch = url.match(/(?:ident|flight_number|flight|callsign)=([^&]+)/i);
if (flightMatch) {
requestedFlightNumbers.add(flightMatch[1]);
}
}
// Из query параметров
if (item.json && item.json.query) {
const query = item.json.query;
const flightNum = query.ident || query.flight_number || query.flight || query.callsign;
if (flightNum) {
requestedFlightNumbers.add(flightNum);
}
}
// Из body запроса
if (item.json && item.json.body) {
const body = item.json.body;
const flightNum = body.ident || body.flight_number || body.flight || body.callsign;
if (flightNum) {
requestedFlightNumbers.add(flightNum);
}
}
// Прямо из json
if (item.json) {
const flightNum = item.json.ident || item.json.flight_number || item.json.flight || item.json.callsign;
if (flightNum) {
requestedFlightNumbers.add(flightNum);
}
}
});
// Добавляем запрошенные рейсы, для которых нет данных
requestedFlightNumbers.forEach(flightNum => {
// Проверяем, есть ли уже этот рейс в flightsMap
let found = false;
flightsMap.forEach((flight, reg) => {
if (flight.flightNumber === flightNum || flight.ident === flightNum || flight.identIata === flightNum) {
found = true;
}
});
// Если не найден - добавляем как запрошенный без данных
if (!found) {
flightsMap.set(`REQUESTED-${flightNum}`, {
registration: '—',
flightNumber: flightNum,
ident: flightNum,
identIata: flightNum,
aircraftType: '—',
fa: null,
fr: null,
isRequested: true // Флаг, что это запрошенный рейс без данных
});
}
});
const flights = Array.from(flightsMap.values());
// ================== HTML GENERATION ==================
// Делаем flightRadar24Error доступным в функции generateFlightCard
const generateFlightCard = (f, fr24ErrorParam = null) => {
const fa = f.fa;
const fr = f.fr;
const fr24Error = fr24ErrorParam; // Локальная переменная для использования в функции
// Если это запрошенный рейс без данных
if (f.isRequested && !fa && !fr) {
// Используем данные, полученные ранее из ноды "запрос рейса"
let requestInfo = '';
// Проверяем, соответствует ли этот рейс запрошенному
const matchesRequest = requestFlightNumber && (
String(f.flightNumber) === String(requestFlightNumber) ||
String(f.ident) === String(requestFlightNumber)
);
if (matchesRequest) {
if (requestDepartureDate) {
requestInfo += `<div class="info-row"><span class="label">Дата вылета (запрос):</span><span class="value">${requestDepartureDate}</span></div>`;
}
if (requestArrivalDate) {
requestInfo += `<div class="info-row"><span class="label">Дата прилёта (запрос):</span><span class="value">${requestArrivalDate}</span></div>`;
}
}
return `
<div class="flight-card">
<div class="flight-header">
<h2>Рейс ${f.flightNumber || f.ident || '—'}</h2>
<span class="registration">Запрошен</span>
</div>
<div class="flight-info">
<div class="info-row">
<span class="label">Запрошенный рейс:</span>
<span class="value">${f.flightNumber || f.ident || '—'}</span>
</div>
${requestInfo}
</div>
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightaware">FlightAware</span>
<span class="source-missing">Данные не получены</span>
</div>
<div class="source-content">
<div style="padding: 10px; color: #666; font-size: 13px;">
По запросу рейса <strong>${f.flightNumber || f.ident || '—'}</strong>${requestDepartureDate ? ` на ${requestDepartureDate}` : ''} данные не найдены.
</div>
</div>
</div>
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightradar24">FlightRadar24</span>
<span class="source-missing">Данные не получены</span>
</div>
<div class="source-content">
<div style="padding: 10px; color: #666; font-size: 13px;">
По запросу рейса <strong>${f.flightNumber || f.ident || '—'}</strong>${requestDepartureDate ? ` на ${requestDepartureDate}` : ''} данные не найдены.
</div>
</div>
</div>
</div>`;
}
let card = `
<div class="flight-card">
<div class="flight-header">
<h2>Рейс ${f.flightNumber || f.ident || '—'}</h2>
<span class="registration">${f.registration || '—'}</span>
</div>
<div class="flight-info">
<div class="info-row">
<span class="label">Тип самолёта:</span>
<span class="value">${f.aircraftType || '—'}</span>
</div>
<div class="info-row">
<span class="label">Идентификатор:</span>
<span class="value">${f.ident || '—'} (${f.identIata || '—'})</span>
</div>
</div>`;
// Данные из FlightAware
if (fa) {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightaware">FlightAware</span>
</div>
<div class="source-content">
<div class="route-info">
<div class="route-item">
<span class="route-label">Откуда:</span>
<span class="route-value">${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')})</span>
</div>
<div class="route-item">
<span class="route-label">Куда:</span>
<span class="route-value">${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')})</span>
</div>
</div>
<div class="timeline">
<div class="timeline-item">
<span class="timeline-label">Плановый вылет:</span>
<span class="timeline-value">${safeDate(fa.scheduled_out)}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Фактический вылет:</span>
<span class="timeline-value">${safeDate(fa.actual_out)}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Взлёт:</span>
<span class="timeline-value">${safeDate(fa.actual_off)} ${fa.actual_runway_off ? `(ВПП ${fa.actual_runway_off})` : ''}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Посадка:</span>
<span class="timeline-value">${safeDate(fa.actual_on)} ${fa.actual_runway_on ? `(ВПП ${fa.actual_runway_on})` : ''}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Фактический прилёт:</span>
<span class="timeline-value">${safeDate(fa.actual_in)}</span>
</div>
</div>
<div class="status-info">
<div class="status-item">
<span class="status-label">Статус:</span>
<span class="status-value">${safeStr(fa.status || '—')}</span>
</div>
${fa.departure_delay !== null && fa.departure_delay !== undefined ? `
<div class="status-item">
<span class="status-label">Задержка вылета:</span>
<span class="status-value ${fa.departure_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.departure_delay > 0 ? '+' : ''}${Math.floor(fa.departure_delay / 60)} мин</span>
</div>
` : ''}
${fa.arrival_delay !== null && fa.arrival_delay !== undefined ? `
<div class="status-item">
<span class="status-label">Задержка прилёта:</span>
<span class="status-value ${fa.arrival_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.arrival_delay > 0 ? '+' : ''}${Math.floor(fa.arrival_delay / 60)} мин</span>
</div>
` : ''}
${fa.gate_origin ? `
<div class="status-item">
<span class="status-label">Гейт вылета:</span>
<span class="status-value">${fa.gate_origin}</span>
</div>
` : ''}
${fa.gate_destination ? `
<div class="status-item">
<span class="status-label">Гейт прилёта:</span>
<span class="status-value">${fa.gate_destination}</span>
</div>
` : ''}
${fa.baggage_claim ? `
<div class="status-item">
<span class="status-label">Выдача багажа:</span>
<span class="status-value">${fa.baggage_claim}</span>
</div>
` : ''}
</div>
</div>
</div>`;
} else {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightaware">FlightAware</span>
<span class="source-missing">Данные не получены</span>
</div>
</div>`;
}
// Данные из FlightRadar24
if (fr) {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightradar24">FlightRadar24</span>
</div>
<div class="source-content">
<div class="route-info">
<div class="route-item">
<span class="route-label">Откуда:</span>
<span class="route-value">${safeStr(fr.orig_iata || '—')} (${safeStr(fr.orig_icao || '—')})</span>
</div>
<div class="route-item">
<span class="route-label">Куда:</span>
<span class="route-value">${safeStr(fr.dest_iata || '—')} (${safeStr(fr.dest_icao || '—')})</span>
</div>
</div>
<div class="timeline">
<div class="timeline-item">
<span class="timeline-label">Взлёт:</span>
<span class="timeline-value">${safeDate(fr.datetime_takeoff)} ${fr.runway_takeoff ? `(ВПП ${fr.runway_takeoff})` : ''}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Посадка:</span>
<span class="timeline-value">${safeDate(fr.datetime_landed)} ${fr.runway_landed ? `(ВПП ${fr.runway_landed})` : ''}</span>
</div>
</div>
<div class="status-info">
<div class="status-item">
<span class="status-label">Время полёта:</span>
<span class="status-value">${formatDuration(fr.flight_time)}</span>
</div>
<div class="status-item">
<span class="status-label">Фактическое расстояние:</span>
<span class="status-value">${formatDistance(fr.actual_distance)}</span>
</div>
<div class="status-item">
<span class="status-label">Кратчайшее расстояние:</span>
<span class="status-value">${formatDistance(fr.circle_distance)}</span>
</div>
<div class="status-item">
<span class="status-label">Статус полёта:</span>
<span class="status-value">${fr.flight_ended ? 'Завершён' : 'В процессе'}</span>
</div>
</div>
</div>
</div>`;
} else {
card += `
<div class="source-section">
<div class="source-header">
<span class="source-badge source-flightradar24">FlightRadar24</span>
<span class="source-missing">Данные не получены</span>
</div>
</div>`;
}
card += `</div>`;
return card;
};
// Генерация полного HTML
const now = new Date();
const reportDate = now.toLocaleString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
const html = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Отчёт о рейсах</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.4; color: #333; background: #f5f5f5; padding: 15px; }
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.header { border-bottom: 3px solid #2563eb; padding-bottom: 8px; margin-bottom: 8px; }
.header h1 { color: #1e40af; font-size: 24px; margin-bottom: 4px; }
.header-meta { color: #666; font-size: 13px; }
.sources-info { display: flex; gap: 10px; margin-top: 4px; flex-wrap: wrap; }
.source-tag { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 500; }
.source-tag.available { background: #d1fae5; color: #065f46; }
.source-tag.unavailable { background: #fee2e2; color: #991b1b; }
.flight-card { border: 1px solid #e5e7eb; border-radius: 8px; margin-bottom: 18px; overflow: hidden; background: white; }
.flight-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 18px; display: flex; justify-content: space-between; align-items: center; }
.flight-header h2 { font-size: 20px; margin: 0; }
.registration { background: rgba(255,255,255,0.2); padding: 4px 10px; border-radius: 4px; font-weight: 600; font-size: 13px; }
.flight-info { padding: 12px 18px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; }
.info-row { display: flex; margin-bottom: 6px; }
.info-row:last-child { margin-bottom: 0; }
.info-row .label { font-weight: 600; color: #4b5563; width: 140px; flex-shrink: 0; font-size: 13px; }
.info-row .value { color: #111827; font-size: 13px; }
.source-section { border-top: 1px solid #e5e7eb; padding: 12px 18px; }
.source-section:first-of-type { border-top: none; }
.source-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.source-badge { display: inline-block; padding: 5px 12px; border-radius: 5px; font-size: 12px; font-weight: 600; color: white; }
.source-badge.source-flightaware { background: #3b82f6; }
.source-badge.source-flightradar24 { background: #10b981; }
.source-missing { color: #ef4444; font-size: 12px; font-style: italic; }
.source-content { margin-left: 0; }
.route-info { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; padding: 12px; background: #f9fafb; border-radius: 6px; }
.route-item { display: flex; flex-direction: column; }
.route-label { font-size: 11px; color: #6b7280; margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
.route-value { font-size: 14px; font-weight: 600; color: #111827; }
.timeline { margin-bottom: 12px; }
.timeline-item { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #e5e7eb; }
.timeline-item:last-child { border-bottom: none; }
.timeline-label { font-weight: 500; color: #4b5563; width: 160px; flex-shrink: 0; font-size: 12px; }
.timeline-value { color: #111827; text-align: right; font-size: 12px; }
.status-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; padding: 12px; background: #f9fafb; border-radius: 6px; }
.status-item { display: flex; flex-direction: column; }
.status-label { font-size: 11px; color: #6b7280; margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
.status-value { font-size: 13px; font-weight: 600; color: #111827; }
.delay-negative { color: #10b981; }
.delay-positive { color: #ef4444; }
.no-data { text-align: center; padding: 40px 20px; color: #6b7280; font-size: 16px; }
@media print { body { background: white; padding: 0; } .container { box-shadow: none; padding: 15px; } .flight-card { page-break-inside: avoid; margin-bottom: 15px; } }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Отчёт о рейсах</h1>
<div class="header-meta">
<div>Дата формирования: ${reportDate}</div>
<div class="sources-info">
<span class="source-tag ${flightAwareData.length > 0 ? 'available' : 'unavailable'}">
FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
</span>
<span class="source-tag ${flightRadar24Data.length > 0 ? 'available' : 'unavailable'}">
FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
</span>
</div>
</div>
</div>
<div class="flights-container">
${flights.length ? flights.map(f => generateFlightCard(f, flightRadar24Error)).join('') : '<div class="no-data">Данные о рейсах не найдены</div>'}
</div>
</div>
</body>
</html>`;
// ================== HTML → BASE64 ==================
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
// ================== RETURN ==================
return [{
json: {
html_base64: htmlBase64,
html: html,
flights_count: flights.length,
sources: {
flightaware: { available: flightAwareData.length > 0, count: flightAwareData.length },
flightradar24: { available: flightRadar24Data.length > 0, count: flightRadar24Data.length }
},
generated_at: now.toISOString()
}
}];

View File

@@ -0,0 +1,320 @@
# Пример Workflow для обработки рейсов с Base64 PDF
## Структура Workflow
```
HTTP Request (FlightAware)
HTTP Request (FlightRadar24)
Code: Process Flights Data ← N8N_CODE_PROCESS_FLIGHTS_DATA.js
Code: Prepare PDF Request ← N8N_FLIGHTS_PDF_BASE64_COMPLETE.js
HTTP Request (Convert to PDF) ← Внешний сервис конвертации
Code: Extract Base64 PDF ← N8N_FLIGHTS_PDF_BASE64_FULL.js
[Использование base64 PDF]
├─→ Save File
├─→ Send Email
├─→ Upload to S3
└─→ Return in API Response
```
## Детальная настройка нод
### 1. HTTP Request: FlightAware
- **Method:** GET/POST (в зависимости от API)
- **URL:** `https://flightaware.com/api/...`
- **Authentication:** По необходимости
### 2. HTTP Request: FlightRadar24
- **Method:** GET/POST (в зависимости от API)
- **URL:** `https://flightradar24.com/api/...`
- **Authentication:** По необходимости
### 3. Code: Process Flights Data
**Код:** Скопируйте из `N8N_CODE_PROCESS_FLIGHTS_DATA.js`
**Входные данные:**
- Два элемента из предыдущих HTTP Request нод
**Выходные данные:**
```json
{
"html": "<!DOCTYPE html>...",
"flights": [...],
"flights_count": 2,
"sources": {...},
"generated_at": "2026-01-14T..."
}
```
### 4. Code: Prepare PDF Request
**Код:** Скопируйте из `N8N_FLIGHTS_PDF_BASE64_COMPLETE.js`
**Настройка:**
- Замените `PDF_SERVICE_URL` на URL вашего сервиса
- Замените `PDF_API_KEY` на ваш API ключ
**Выходные данные:**
```json
{
"http_method": "POST",
"http_url": "https://api.htmlpdfapi.com/v1/pdf",
"http_headers": {...},
"http_body": "{...}",
"html_length": 12345,
"flights_count": 2
}
```
### 5. HTTP Request: Convert to PDF
**Настройка:**
- **Method:** `{{ $json.http_method }}`
- **URL:** `{{ $json.http_url }}`
- **Authentication:** По необходимости (через Headers)
- **Headers:**
```json
{{ $json.http_headers }}
```
- **Body:**
```json
{{ $json.http_body }}
```
- **Response Format:** `JSON` или `Binary` (зависит от сервиса)
**Пример для htmlpdfapi.com:**
```json
{
"method": "POST",
"url": "https://api.htmlpdfapi.com/v1/pdf",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_API_KEY"
},
"body": {
"html": "{{ $('Code: Process Flights Data').first().json.html }}",
"options": {
"format": "A4",
"printBackground": true
},
"base64": true
}
}
```
### 6. Code: Extract Base64 PDF
**Код:** Скопируйте из `N8N_FLIGHTS_PDF_BASE64_FULL.js`
**Входные данные:**
- Ответ от HTTP Request ноды (JSON или Binary)
**Выходные данные:**
```json
{
"pdf_base64": "JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9MZW5ndGg...",
"pdf_size_bytes": 123456,
"pdf_size_mb": "0.12",
"success": true,
"source": "json_response"
}
```
## Использование base64 PDF
### Вариант A: Сохранение в файл
**Code Node:**
```javascript
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
const filename = `flights-report-${new Date().toISOString().split('T')[0]}.pdf`;
// Конвертируем base64 в binary
const pdfBuffer = Buffer.from(base64, 'base64');
return [{
binary: {
data: pdfBuffer,
fileName: filename,
mimeType: 'application/pdf'
},
json: {
filename: filename,
size_bytes: pdfBuffer.length
}
}];
```
Затем используйте ноду **Write Binary File** или **Save to S3**.
### Вариант B: Отправка по Email
**Code Node:**
```javascript
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
const pdfBuffer = Buffer.from(base64, 'base64');
return [{
json: {
to: 'recipient@example.com',
subject: 'Отчёт о рейсах',
text: 'Во вложении отчёт о рейсах.',
attachments: [{
filename: 'flights-report.pdf',
content: pdfBuffer,
contentType: 'application/pdf'
}]
}
}];
```
Затем используйте ноду **Email Send**.
### Вариант C: Возврат в API Response
**Code Node (перед Response нодой):**
```javascript
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
const processedData = $('Code: Process Flights Data').first().json;
return [{
json: {
success: true,
flights_count: processedData.flights_count,
pdf_base64: base64,
pdf_size_mb: $('Code: Extract Base64 PDF').first().json.pdf_size_mb,
generated_at: processedData.generated_at
}
}];
```
### Вариант D: Загрузка в S3/Nextcloud
**Code Node:**
```javascript
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
const pdfBuffer = Buffer.from(base64, 'base64');
const filename = `flights-report-${new Date().toISOString().split('T')[0]}.pdf`;
return [{
binary: {
data: pdfBuffer,
fileName: filename,
mimeType: 'application/pdf'
},
json: {
bucket: 'your-bucket',
key: `reports/${filename}`,
contentType: 'application/pdf'
}
}];
```
Затем используйте ноду **S3 Upload** или **Nextcloud Upload**.
## Альтернативные сервисы конвертации
### 1. htmlpdfapi.com
```javascript
{
"url": "https://api.htmlpdfapi.com/v1/pdf",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_API_KEY"
},
"body": {
"html": "{{ HTML }}",
"base64": true
}
}
```
### 2. pdfshift.io
```javascript
{
"url": "https://api.pdfshift.io/v3/convert/pdf",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Basic " + Buffer.from("api:YOUR_API_KEY").toString("base64")
},
"body": {
"source": "{{ HTML }}",
"format": "A4"
}
}
// Ответ содержит base64 в поле "pdf"
```
### 3. api2pdf.com
```javascript
{
"url": "https://v2.api2pdf.com/chrome/html",
"method": "POST",
"headers": {
"Authorization": "YOUR_API_KEY",
"Content-Type": "application/json"
},
"body": {
"html": "{{ HTML }}",
"inlinePdf": true,
"fileName": "flights-report.pdf"
}
}
// Ответ содержит base64 в поле "pdf"
```
### 4. Self-hosted: Gotenberg
```javascript
{
"url": "http://your-gotenberg-server:3000/forms/chromium/convert/html",
"method": "POST",
"headers": {
"Content-Type": "multipart/form-data"
},
"body": {
"files": [{
"name": "index.html",
"content": "{{ HTML }}"
}]
}
}
// Ответ - binary PDF, конвертируем в base64
```
## Отладка
### Проверка HTML
```javascript
const html = $('Code: Process Flights Data').first().json.html;
console.log('HTML length:', html.length);
console.log('HTML preview:', html.substring(0, 500));
```
### Проверка base64
```javascript
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
console.log('Base64 length:', base64.length);
console.log('Base64 preview:', base64.substring(0, 100));
```
### Проверка размера PDF
```javascript
const data = $('Code: Extract Base64 PDF').first().json;
console.log('PDF size:', data.pdf_size_mb, 'MB');
console.log('PDF size bytes:', data.pdf_size_bytes);
```
## Обработка ошибок
Добавьте IF Node после HTTP Request для проверки успешности:
```javascript
// IF Node: Check PDF Conversion Success
{{ $json.success === true }}
```
Если ошибка - отправьте уведомление или сохраните HTML для ручной конвертации.

View File

@@ -0,0 +1,140 @@
# ✅ Рабочее решение: Обработка данных о рейсах → PDF
## Структура Workflow
```
[Входные данные: FlightAware + FlightRadar24]
[Code: причесываем данные] ← Генерирует HTML и конвертирует в base64
[HTTP Request: Browserless PDF] ← Конвертирует HTML в PDF через браузер
[Результат: PDF binary]
```
---
## Нода 1: Code - "причесываем данные"
**Тип:** Code (JavaScript)
**Код:** См. файл `N8N_FLIGHTS_TO_BASE64.js`
**Что делает:**
1. Извлекает данные из структуры `[{ data: [{ body: { flights: [...] }}, { body: { data: [...] }}] }]`
2. Объединяет рейсы по `registration` (номер самолёта)
3. Генерирует красивый HTML с CSS
4. Конвертирует HTML в base64
**Выходные данные:**
```json
{
"html_base64": "PCFET0NUWVBFIGh0bWw+...",
"html": "<!DOCTYPE html>...",
"flights_count": 2,
"sources": {
"flightaware": { "available": true, "count": 2 },
"flightradar24": { "available": true, "count": 2 }
},
"generated_at": "2026-01-16T07:23:00.000Z"
}
```
---
## Нода 2: HTTP Request - "Browserless PDF"
**Тип:** HTTP Request
**Настройки:**
- **Method:** `POST`
- **URL:** `http://147.45.146.17:3000/pdf?token=9ahhnpjkchxtcho9`
- **Send Body:** ✅ Да
- **Specify Body:** `JSON`
- **JSON Body:**
```json
{
"url": "data:text/html;base64, {{ $json.html_base64 }}",
"options": {
"format": "A4",
"printBackground": true,
"margin": {
"top": "20mm",
"right": "15mm",
"bottom": "20mm",
"left": "20mm"
}
}
}
```
**Response Format:** `Binary` (или `JSON`, если Browserless возвращает JSON)
---
## Результат
HTTP Request нода вернёт PDF в binary формате, который можно:
- Сохранить в файл
- Отправить по email
- Загрузить в S3/Nextcloud
- Конвертировать в base64 для API response
---
## Конвертация Binary PDF → Base64 (опционально)
Если нужен base64 PDF, добавьте Code Node после HTTP Request:
```javascript
const pdfBinary = $binary.data;
const base64 = Buffer.isBuffer(pdfBinary)
? pdfBinary.toString('base64')
: Buffer.from(pdfBinary).toString('base64');
const sizeBytes = Buffer.from(base64, 'base64').length;
return [{
json: {
pdf_base64: base64,
pdf_size_bytes: sizeBytes,
pdf_size_mb: (sizeBytes / (1024 * 1024)).toFixed(2),
success: true
}
}];
```
---
## Преимущества решения
**Простота** - всего 2 ноды
**Надёжность** - Browserless использует реальный браузер
**Качество** - PDF с правильным форматированием и стилями
**Гибкость** - можно легко изменить параметры PDF (формат, отступы)
---
## Отладка
Если что-то не работает:
1. **Проверьте HTML** - в Code Node добавьте:
```javascript
console.log('HTML length:', html.length);
console.log('HTML preview:', html.substring(0, 200));
```
2. **Проверьте base64** - в Code Node добавьте:
```javascript
console.log('Base64 length:', htmlBase64.length);
```
3. **Проверьте ответ Browserless** - в HTTP Request включите "Always Output Data" и проверьте ответ
---
## Готово! 🎉
Workflow работает и генерирует красивые PDF отчёты о рейсах!

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,102 @@
// ============================================================================
// n8n Code Node: HTML → Base64 PDF (простой вариант)
// ============================================================================
// Используйте этот код ПОСЛЕ ноды, которая вернула HTML
// Этот код подготовит запрос для HTTP Request ноды
// ============================================================================
// Получаем HTML из предыдущей ноды
// Если HTML пришёл в поле "html", используем его
const html = $json.html || $json.body?.html || $json;
if (!html || typeof html !== 'string') {
throw new Error('HTML не найден в входных данных. Проверьте структуру данных.');
}
console.log('📄 HTML получен, длина:', html.length);
// ==== НАСТРОЙКИ СЕРВИСА КОНВЕРТАЦИИ ====
// Выберите один из вариантов ниже и раскомментируйте его
// ==== ВАРИАНТ 1: htmlpdfapi.com (рекомендуется) ====
// Бесплатный план: 100 PDF в месяц
// URL: https://htmlpdfapi.com
return [{
json: {
// Данные для HTTP Request ноды
method: 'POST',
url: 'https://api.htmlpdfapi.com/v1/pdf',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_API_KEY' // ⚠️ ЗАМЕНИТЕ на ваш API ключ
},
body: JSON.stringify({
html: html,
options: {
format: 'A4',
printBackground: true,
margin: {
top: '20mm',
right: '15mm',
bottom: '20mm',
left: '15mm'
}
},
base64: true // Запрашиваем base64 напрямую
})
}
}];
// ==== ВАРИАНТ 2: pdfshift.io ====
// Раскомментируйте, если используете pdfshift.io
/*
return [{
json: {
method: 'POST',
url: 'https://api.pdfshift.io/v3/convert/pdf',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Basic ' + Buffer.from('api:YOUR_API_KEY').toString('base64')
},
body: JSON.stringify({
source: html,
format: 'A4',
margin: '20mm'
})
}
}];
*/
// ==== ВАРИАНТ 3: api2pdf.com ====
// Раскомментируйте, если используете api2pdf.com
/*
return [{
json: {
method: 'POST',
url: 'https://v2.api2pdf.com/chrome/html',
headers: {
'Authorization': 'YOUR_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
html: html,
inlinePdf: true,
fileName: 'flights-report.pdf'
})
}
}];
*/
// ============================================================================
// ИНСТРУКЦИЯ:
// ============================================================================
// 1. Этот Code Node подготавливает запрос
// 2. Добавьте HTTP Request ноду после этого Code Node
// 3. В HTTP Request ноде настройте:
// - Method: {{ $json.method }}
// - URL: {{ $json.url }}
// - Headers: {{ $json.headers }}
// - Body: {{ $json.body }}
// 4. После HTTP Request добавьте Code Node с кодом из N8N_EXTRACT_BASE64_FROM_RESPONSE.js
// для извлечения base64 из ответа
// ============================================================================

View File

@@ -0,0 +1,62 @@
/**
* n8n Code node: парсинг сырого init_data из Telegram WebApp
*
* Вход: объект с полем init_data (строка query string от Telegram).
* Выход: тот же объект + поля init_data_parsed и user_decoded.
*
* Подключение: после Webhook — в Code передаётся $input.item.json.
* init_data должен быть в $json.init_data (как шлёт наш бэкенд).
*/
const item = $input.first().json;
// Сырая строка init_data (query string)
const rawInitData = item.init_data || item.body?.init_data || '';
if (!rawInitData) {
return [{ json: { ...item, init_data_error: 'init_data отсутствует' } }];
}
/**
* Парсит query string в объект (значения URL-декодированы)
*/
function parseQueryString(qs) {
const result = {};
const pairs = qs.split('&');
for (const pair of pairs) {
const [key, value] = pair.split('=').map(s => decodeURIComponent(s || ''));
if (key) result[key] = value;
}
return result;
}
const parsed = parseQueryString(rawInitData);
// user приходит как URL-encoded JSON строка
let userDecoded = null;
if (parsed.user) {
try {
userDecoded = JSON.parse(parsed.user);
} catch (e) {
userDecoded = { _parse_error: String(e), raw: parsed.user };
}
}
return [{
json: {
...item,
init_data_parsed: {
query_id: parsed.query_id || null,
auth_date: parsed.auth_date ? parseInt(parsed.auth_date, 10) : null,
hash: parsed.hash || null,
signature: parsed.signature || null,
user_raw: parsed.user || null,
},
user_decoded: userDecoded,
// удобные поля для маппинга в CRM
telegram_user_id: userDecoded?.id ?? null,
telegram_username: userDecoded?.username ?? null,
telegram_first_name: userDecoded?.first_name ?? null,
telegram_last_name: userDecoded?.last_name ?? null,
},
}];

View File

@@ -0,0 +1,147 @@
# 🔧 Решение проблемы зависших n8n workflow
## 🐛 Проблема
Workflow в n8n зависает и не может быть перезапущен даже через интерфейс. Redis Trigger node теряет соединение и не переподключается автоматически.
## ✅ Что сделано
### 1. Улучшена логика перезапуска workflow
**Файл:** `backend/app/services/n8n_service.py`
**Изменения:**
- ✅ Увеличены таймауты с 10 до 30 секунд (общий) и 15 секунд (для отдельных операций)
- ✅ Добавлена обработка таймаутов при деактивации (продолжаем даже если деактивация зависла)
- ✅ Увеличена задержка между деактивацией и активацией (3 секунды вместо 2)
- ✅ Добавлена дополнительная задержка после активации для инициализации trigger node
- ✅ Улучшено логирование ошибок с полным traceback
### 2. Улучшена проверка и перезапуск в фоне
**Файл:** `backend/app/api/claims.py`
**Изменения:**
- ✅ Добавлены повторные попытки перезапуска (до 2 попыток)
- ✅ Добавлена проверка подписчиков после перезапуска
- ✅ Улучшено логирование процесса перезапуска
## 🚀 Как это работает
1. **При публикации сообщения в Redis:**
- Проверяется количество подписчиков
- Если подписчиков нет → сообщение сохраняется в буфер
- Запускается фоновая задача перезапуска workflow
2. **Процесс перезапуска:**
- Проверяется Redis lock (защита от частых перезапусков)
- Проверяется статус workflow через API
- Деактивируется workflow (даже если завис)
- Ждёт 3 секунды
- Активирует workflow
- Ждёт 2 секунды для инициализации
- Проверяет подписчиков
- Отправляет сообщения из буфера
3. **Повторные попытки:**
- Если первая попытка не удалась → повтор через 5 секунд
- Максимум 2 попытки
## 📊 Мониторинг
### Проверка подписчиков вручную:
```bash
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PUBSUB NUMSUB "ticket_form:description"
```
### Проверка статуса workflow:
```bash
curl -H "X-N8N-API-KEY: ..." "https://n8n.clientright.pro/api/v1/workflows/b4K4u851b4JFivyD" | jq '.active'
```
### Логи backend:
```bash
tail -f /var/www/fastuser/data/www/crm.clientright.ru/ticket_form/backend.log | grep -i "workflow\|redis\|subscriber"
```
## 🛠️ Если проблема повторится
### Вариант 1: Перезапуск через API (автоматически)
Код теперь автоматически пытается перезапустить workflow при обнаружении проблемы.
### Вариант 2: Ручной перезапуск через API
```bash
# Деактивировать
curl -X POST -H "X-N8N-API-KEY: ..." \
"https://n8n.clientright.pro/api/v1/workflows/b4K4u851b4JFivyD/deactivate"
# Подождать 5 секунд
sleep 5
# Активировать
curl -X POST -H "X-N8N-API-KEY: ..." \
"https://n8n.clientright.pro/api/v1/workflows/b4K4u851b4JFivyD/activate"
```
### Вариант 3: Перезапуск n8n (крайний случай)
Если workflow всё ещё завис, может потребоваться перезапуск самого n8n:
```bash
# Если n8n в Docker
docker restart <n8n_container>
# Если n8n как системный сервис
systemctl restart n8n
```
## 🔍 Диагностика
### Проверка что workflow активен но не слушает:
```bash
# 1. Проверить статус workflow
curl -H "X-N8N-API-KEY: ..." \
"https://n8n.clientright.pro/api/v1/workflows/b4K4u851b4JFivyD" | jq '{active: .active, updatedAt: .updatedAt}'
# 2. Проверить подписчиков
redis-cli -h crm.clientright.ru -p 6379 -a "..." PUBSUB NUMSUB "ticket_form:description"
# 3. Если active=true но подписчиков 0 → workflow завис
```
### Проверка Redis соединений:
```bash
redis-cli -h crm.clientright.ru -p 6379 -a "..." CLIENT LIST | grep "sub=1"
```
## 📝 Рекомендации на будущее
1. **Мониторинг:**
- Настроить автоматический мониторинг подписчиков (cron каждые 5 минут)
- Алерты при отсутствии подписчиков более 10 минут
2. **Автоматический перезапуск n8n:**
- Настроить health check для n8n
- Автоматический перезапуск при обнаружении проблем
3. **Логирование:**
- Включить детальное логирование в n8n
- Мониторинг логов на ошибки Redis соединений
4. **Настройка Redis:**
- Увеличить `tcp-keepalive` для стабильности соединений
- Настроить `timeout` для неактивных соединений
## 🔗 Связанные файлы
- `backend/app/services/n8n_service.py` - логика перезапуска workflow
- `backend/app/api/claims.py` - проверка подписчиков и запуск перезапуска
- `docs/N8N_REDIS_TRIGGER_TROUBLESHOOTING.md` - общая диагностика Redis Trigger
- `docs/N8N_MEMORY_ISSUES.md` - проблемы с памятью в n8n

View File

@@ -0,0 +1,122 @@
# Как срабатывает Telegram Mini App (по шагам)
Ты в Telegram нажимаешь кнопку «Открыть мини-апп» → открывается **aiform.clientright.ru**. Ниже — что происходит дальше и где.
---
## 1. Где открывается страница
- **Кто:** Telegram (клиент на телефоне/десктопе).
- **Что:** Открывает aiform.clientright.ru **в своём встроенном браузере (WebView)** как Mini App.
- **Важно:** В этом режиме Telegram сам подставляет в страницу свой скрипт и объект `window.Telegram.WebApp` с полем **initData** (подпись пользователя и данные). В обычном браузере по прямой ссылке этого объекта нет.
---
## 2. Загрузка фронта (aiform.clientright.ru)
- Загружается твой SPA (React): главная страница — форма заявки **ClaimForm**.
- Рендерится первый экран формы (шаг 0).
- Сразу при монтировании компонента запускается **useEffect** с функцией `tryTelegramAuth()``ClaimForm.tsx`).
**Где в коде:** `frontend/src/pages/ClaimForm.tsx`, блок «Telegram Mini App: попытка авторизоваться через initData при первом заходе».
---
## 3. Проверка: это Mini App или обычный сайт?
Фронт делает:
1. Смотрит, есть ли `window.Telegram?.WebApp?.initData`.
2. Если нет — ждёт 300 ms (на случай асинхронной подгрузки скрипта Telegram) и проверяет снова.
3. Если после этого **нет** `initData` → в консоль пишется «Telegram WebApp не обнаружен», авторизация по Telegram **не вызывается**, форма ведёт себя как обычный веб-сайт (SMS, сессия из localStorage и т.д.).
4. Если **есть** `initData`:
- Проверяет, есть ли уже в **localStorage** ключ `session_token`.
- Если **есть** → считаем, что пользователь уже залогинен, tg/auth не вызываем, дальше работает обычное восстановление сессии.
- Если **нет** → идём в шаг 4.
**Итого:** срабатывание tg/auth **только** когда:
- страница открыта **из Telegram** (есть `initData`),
- и в localStorage **нет** сохранённого `session_token`.
---
## 4. Запрос на бэкенд: POST /api/v1/tg/auth
- **Кто:** фронт (ClaimForm).
- **Куда:** на тот же домен aiform.clientright.ru → запрос уходит на твой backend (через nginx/proxy на порт 8200).
- **URL:** `POST /api/v1/tg/auth`.
- **Тело:** `{ "init_data": "<строка initData от Telegram>" }`.
**Где в коде:** `ClaimForm.tsx``fetch('/api/v1/tg/auth', { method: 'POST', body: JSON.stringify({ init_data: webApp.initData }) })`.
---
## 5. Обработка на бэкенде (tg/auth)
- **Где:** `backend/app/api/telegram_auth.py`, эндпоинт `POST /api/v1/tg/auth`.
Последовательно:
1. **Валидация initData** (`backend/app/services/telegram_auth.py`):
- Проверка подписи через **TELEGRAM_BOT_TOKEN** из `.env`.
- Если токена нет или подпись не совпадает → ответ **400** (или 500), фронт пишет «Telegram auth failed» и ведёт себя как обычный сайт.
2. **Извлечение пользователя Telegram:** из initData достаются `id`, `username`, `first_name`, `last_name`.
3. **Запрос в n8n:**
- Бэкенд дергает **N8N_TG_AUTH_WEBHOOK** (URL из `.env`).
- Передаёт: `telegram_user_id`, `username`, `first_name`, `last_name`, `session_token`, `form_id`.
- Ожидает в ответе минимум **unified_id** (и при необходимости contact_id, phone, has_drafts).
4. **Создание сессии в Redis:**
- По `session_token` + `unified_id` (+ phone, contact_id) создаётся запись сессии (как после SMS-логина).
5. **Ответ фронту:**
`{ success: true, session_token, unified_id, contact_id?, phone?, has_drafts? }`.
Если на любом шаге ошибка (нет токена, n8n не вернул unified_id и т.д.) — бэкенд отдаёт ошибку, фронт считает tg/auth неуспешным и продолжает как обычный веб.
---
## 6. Что делает фронт после успешного ответа
- Сохраняет **session_token** в **localStorage** и в `sessionIdRef`.
- Обновляет состояние формы: `unified_id`, `phone`, `contact_id`, `session_id`.
- Ставит **isPhoneVerified = true** (шаг «телефон» считаем пройденным).
- Если в ответе **has_drafts === true** → показывает экран выбора черновиков.
- Если **has_drafts** нет или false → переводит на **шаг 1** (описание проблемы).
Дальше пользователь идёт по форме как обычно: описание → черновик/визард → подтверждение → оплата и т.д., но уже без ввода телефона и SMS, потому что он «залогинен» через Telegram.
---
## Сводка: где что срабатывает
| Шаг | Где | Что происходит |
|-----|-----|----------------|
| 1 | Telegram | Открывает aiform.clientright.ru в WebView, подставляет WebApp и initData |
| 2 | Браузер (WebView) | Загружается SPA, монтируется ClaimForm |
| 3 | ClaimForm.tsx (фронт) | Проверка: есть ли Telegram.WebApp.initData и нет ли session_token в localStorage |
| 4 | ClaimForm.tsx (фронт) | POST /api/v1/tg/auth с init_data |
| 5 | telegram_auth.py (бэкенд) | Валидация initData, запрос в n8n, создание сессии в Redis |
| 6 | ClaimForm.tsx (фронт) | Сохранение session_token, переход на шаг черновиков или описание |
---
## Если открыть aiform.clientright.ru не из Telegram
- В обычном браузере (Chrome, Safari по прямой ссылке) **нет** `window.Telegram.WebApp`.
- Фронт пишет в консоль «Telegram WebApp не обнаружен» и **не вызывает** /api/v1/tg/auth.
- Работает обычный сценарий: ввод телефона → SMS → сессия и т.д.
---
## Что должно быть настроено
1. **В Telegram:** у бота должна быть кнопка/меню, открывающее Mini App с URL **https://aiform.clientright.ru** (или с путём на эту форму).
2. **Backend .env:**
- **TELEGRAM_BOT_TOKEN** — токен этого же бота (для проверки initData).
- **N8N_TG_AUTH_WEBHOOK** — URL webhook в n8n, который по telegram_user_id возвращает unified_id (и при необходимости contact_id, phone, has_drafts).
3. **n8n:** workflow по этому webhook принимает JSON с telegram_user_id и т.д. и отдаёт JSON с полем **unified_id** (обязательно).
Если что-то из этого не настроено, цепочка обрывается на шаге 5 (бэкенд/n8n), и пользователь остаётся в «обычном» режиме формы без авторизации через Telegram.

View File

@@ -5,6 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Clientright — защита прав потребителей</title> <title>Clientright — защита прав потребителей</title>
<!-- Telegram SDK загружается динамически только при заходе из Telegram -->
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -5,6 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Clientright — защита прав потребителей</title> <title>Clientright — защита прав потребителей</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -52,8 +52,9 @@ export default function Step1Phone({
setCodeSent(true); setCodeSent(true);
updateFormData({ phone }); updateFormData({ phone });
// 🔧 DEV MODE: показываем debug код в модалке // 🔧 DEV MODE: показываем debug код в модалке (только в development)
if (result.debug_code) { // В production debug_code не приходит с сервера, поэтому модалка не покажется
if (result.debug_code && import.meta.env.MODE === 'development') {
setDebugCode(result.debug_code); setDebugCode(result.debug_code);
setShowDebugModal(true); setShowDebugModal(true);
} }
@@ -341,7 +342,8 @@ export default function Step1Phone({
)} )}
</Form.Item> </Form.Item>
{/* 🔧 DEV MODE: Модалка с SMS кодом */} {/* 🔧 DEV MODE: Модалка с SMS кодом (только в development) */}
{import.meta.env.MODE === 'development' && (
<Modal <Modal
title="🔧 DEV MODE - SMS Код" title="🔧 DEV MODE - SMS Код"
open={showDebugModal} open={showDebugModal}
@@ -393,6 +395,7 @@ export default function Step1Phone({
</div> </div>
</div> </div>
</Modal> </Modal>
)}
</Form> </Form>
); );
} }

View File

@@ -3,7 +3,8 @@ import { Form, Input, Button, AutoComplete, message, Space, Divider } from 'antd
import { PhoneOutlined, SafetyOutlined, QrcodeOutlined, MailOutlined, CopyOutlined } from '@ant-design/icons'; import { PhoneOutlined, SafetyOutlined, QrcodeOutlined, MailOutlined, CopyOutlined } from '@ant-design/icons';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8200'; const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8200';
const NSPK_BANKS_API = 'http://212.193.27.93/api/payouts/dictionaries/nspk-banks'; // API для получения списка банков СБП через backend (избегаем Mixed Content ошибок)
const NSPK_BANKS_API = `${API_BASE_URL}/api/v1/banks/nspk`;
interface Bank { interface Bank {
bankid: string; bankid: string;
@@ -51,10 +52,16 @@ export default function Step3Payment({
throw new Error(`HTTP ${response.status}`); throw new Error(`HTTP ${response.status}`);
} }
// Наш API возвращает формат: [{"bankId":"...","bankName":"..."}]
let banksData: Bank[] = await response.json(); let banksData: Bank[] = await response.json();
// ✅ Фильтруем банки без названия // Преобразуем формат нашего API в наш внутренний формат
banksData = banksData.filter(bank => bank && bank.bankname && typeof bank.bankname === 'string'); banksData = banksData
.filter((bank: any) => bank && bank.bankName && typeof bank.bankName === 'string')
.map((bank: any) => ({
bankid: bank.bankId || '',
bankname: bank.bankName
}));
// Сортируем по названию для удобства // Сортируем по названию для удобства
banksData.sort((a, b) => { banksData.sort((a, b) => {

View File

@@ -98,7 +98,8 @@ export default function StepClaimConfirmation({
false; false;
// Генерируем HTML форму здесь, на нашей стороне // Генерируем HTML форму здесь, на нашей стороне
const html = generateConfirmationFormHTML(formData, contact_data_confirmed); const apiBaseUrl = import.meta.env.VITE_API_URL || 'https://aiform.clientright.ru';
const html = generateConfirmationFormHTML(formData, contact_data_confirmed, apiBaseUrl);
setHtmlContent(html); setHtmlContent(html);
setLoading(false); setLoading(false);
}, [claimPlanData]); }, [claimPlanData]);

View File

@@ -100,6 +100,7 @@ interface Props {
phone?: string; phone?: string;
session_id?: string; session_id?: string;
unified_id?: string; unified_id?: string;
isTelegramMiniApp?: boolean; // ✅ Флаг Telegram Mini App
onSelectDraft: (claimId: string) => void; onSelectDraft: (claimId: string) => void;
onNewClaim: () => void; onNewClaim: () => void;
onRestartDraft?: (claimId: string, description: string) => void; // Для legacy черновиков onRestartDraft?: (claimId: string, description: string) => void; // Для legacy черновиков
@@ -175,6 +176,7 @@ export default function StepDraftSelection({
phone, phone,
session_id, session_id,
unified_id, unified_id,
isTelegramMiniApp,
onSelectDraft, onSelectDraft,
onNewClaim, onNewClaim,
onRestartDraft, onRestartDraft,
@@ -211,7 +213,7 @@ export default function StepDraftSelection({
console.log('🔍 StepDraftSelection: ответ API:', data); console.log('🔍 StepDraftSelection: ответ API:', data);
// Определяем legacy черновики (без documents_required в payload) // Определяем legacy черновики (без documents_required в payload)
const processedDrafts = (data.drafts || []).map((draft: Draft) => { let processedDrafts = (data.drafts || []).map((draft: Draft) => {
// Legacy только если: // Legacy только если:
// 1. Статус 'draft' (старый формат) ИЛИ // 1. Статус 'draft' (старый формат) ИЛИ
// 2. Нет новых статусов (draft_new, draft_docs_progress, draft_docs_complete, draft_claim_ready) // 2. Нет новых статусов (draft_new, draft_docs_progress, draft_docs_complete, draft_claim_ready)
@@ -224,6 +226,12 @@ export default function StepDraftSelection({
}; };
}); });
// ✅ В Telegram Mini App скрываем заявки "В работе"
if (isTelegramMiniApp) {
processedDrafts = processedDrafts.filter((draft: Draft) => draft.status_code !== 'in_work');
console.log('🔍 Telegram Mini App: заявки "В работе" скрыты');
}
setDrafts(processedDrafts); setDrafts(processedDrafts);
} catch (error) { } catch (error) {
console.error('Ошибка загрузки черновиков:', error); console.error('Ошибка загрузки черновиков:', error);
@@ -291,6 +299,27 @@ export default function StepDraftSelection({
// Кнопка действия // Кнопка действия
const getActionButton = (draft: Draft) => { const getActionButton = (draft: Draft) => {
// Для заявок "В работе"
if (draft.status_code === 'in_work') {
// ✅ В веб-версии показываем кнопку "Просмотреть в Telegram"
if (!isTelegramMiniApp) {
return (
<Button
type="primary"
icon={<FileSearchOutlined />}
onClick={() => {
// Открываем Telegram бота
window.open('https://t.me/klientprav_bot', '_blank');
}}
>
Просмотреть в Telegram
</Button>
);
}
// ✅ В Telegram Mini App не показываем (но этот код не выполнится, т.к. заявки отфильтрованы)
return null;
}
const config = getStatusConfig(draft); const config = getStatusConfig(draft);
return ( return (
@@ -521,7 +550,7 @@ export default function StepDraftSelection({
</Text> </Text>
{/* Кнопки действий */} {/* Кнопки действий */}
<div style={{ <div className="draft-actions" style={{
display: 'flex', display: 'flex',
gap: 12, gap: 12,
marginTop: 12, marginTop: 12,
@@ -529,22 +558,25 @@ export default function StepDraftSelection({
borderTop: '1px solid #f0f0f0', borderTop: '1px solid #f0f0f0',
}}> }}>
{getActionButton(draft)} {getActionButton(draft)}
<Popconfirm {/* Скрываем кнопку "Удалить" для заявок "В работе" */}
title="Удалить заявку?" {draft.status_code !== 'in_work' && (
description="Это действие нельзя отменить" <Popconfirm
onConfirm={() => handleDelete(draft.claim_id || draft.id)} title="Удалить заявку?"
okText="Да, удалить" description="Это действие нельзя отменить"
cancelText="Отмена" onConfirm={() => handleDelete(draft.claim_id || draft.id)}
> okText="Да, удалить"
<Button cancelText="Отмена"
danger >
icon={<DeleteOutlined />} <Button
loading={deletingId === (draft.claim_id || draft.id)} danger
disabled={deletingId === (draft.claim_id || draft.id)} icon={<DeleteOutlined />}
> loading={deletingId === (draft.claim_id || draft.id)}
Удалить disabled={deletingId === (draft.claim_id || draft.id)}
</Button> >
</Popconfirm> Удалить
</Button>
</Popconfirm>
)}
</div> </div>
</Space> </Space>
} }

View File

@@ -50,6 +50,7 @@ interface Props {
updateFormData: (data: any) => void; updateFormData: (data: any) => void;
onNext: () => void; onNext: () => void;
onPrev: () => void; onPrev: () => void;
backToDraftsList?: () => void; // ✅ Возврат к списку черновиков напрямую
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void; addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
} }
@@ -110,6 +111,7 @@ export default function StepWizardPlan({
updateFormData, updateFormData,
onNext, onNext,
onPrev, onPrev,
backToDraftsList,
addDebugEvent, addDebugEvent,
}: Props) { }: Props) {
console.log('🔥 StepWizardPlan v1.4 - 2025-11-20 15:00 - Add unified_id and claim_id to wizard payload'); console.log('🔥 StepWizardPlan v1.4 - 2025-11-20 15:00 - Add unified_id and claim_id to wizard payload');
@@ -2271,7 +2273,7 @@ export default function StepWizardPlan({
{/* Кнопки */} {/* Кнопки */}
<Space style={{ marginTop: 16 }}> <Space style={{ marginTop: 16 }}>
<Button onClick={onPrev}> К списку заявок</Button> <Button onClick={backToDraftsList || onPrev}> К списку заявок</Button>
<Button <Button
type="primary" type="primary"
onClick={handleDocContinue} onClick={handleDocContinue}

View File

@@ -1,7 +1,10 @@
// Функция генерации HTML формы подтверждения заявления // Функция генерации HTML формы подтверждения заявления
// Основана на структуре из n8n Code node "Mini-app Подтверждение данных" // Основана на структуре из n8n Code node "Mini-app Подтверждение данных"
export function generateConfirmationFormHTML(data: any, contact_data_confirmed: boolean = false): string { export function generateConfirmationFormHTML(data: any, contact_data_confirmed: boolean = false, apiBaseUrl?: string): string {
// API URL для загрузки банков (избегаем Mixed Content)
const API_BASE_URL = apiBaseUrl || (typeof window !== 'undefined' && (window as any).API_BASE_URL) || 'https://aiform.clientright.ru';
const BANKS_API_URL = `${API_BASE_URL}/api/v1/banks/nspk`;
// Извлекаем SMS данные (до нормализации, так как структура может быть разной) // Извлекаем SMS данные (до нормализации, так как структура может быть разной)
const smsInputData = { const smsInputData = {
prefix: data.sms_meta?.prefix || data.prefix || '', prefix: data.sms_meta?.prefix || data.prefix || '',
@@ -1667,7 +1670,9 @@ export function generateConfirmationFormHTML(data: any, contact_data_confirmed:
console.log('Loading NSPK banks...'); console.log('Loading NSPK banks...');
fetch('http://212.193.27.93/api/payouts/dictionaries/nspk-banks') // Используем backend endpoint через HTTPS (избегаем Mixed Content)
var banksApiUrl = ${JSON.stringify(BANKS_API_URL)};
fetch(banksApiUrl)
.then(function(response) { .then(function(response) {
if (!response.ok) throw new Error('HTTP ' + response.status); if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json(); return response.json();

View File

@@ -1,3 +1,4 @@
/* ========== ВЕБ (дефолт): как в aiform_dev ========== */
.claim-form-container { .claim-form-container {
min-height: 100vh; min-height: 100vh;
padding: 40px 20px; padding: 40px 20px;
@@ -51,3 +52,76 @@
} }
} }
/* ========== Telegram Mini App: отдельный компактный скин ========== */
.claim-form-container.telegram-mini-app {
min-height: 100vh;
min-height: 100dvh;
padding: 12px 10px max(16px, env(safe-area-inset-bottom));
align-items: flex-start;
justify-content: flex-start;
}
.claim-form-container.telegram-mini-app .claim-form-card {
max-width: 100%;
box-shadow: none;
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.08);
}
.claim-form-container.telegram-mini-app .claim-form-card .ant-card-head {
padding: 10px 12px;
min-height: auto;
}
.claim-form-container.telegram-mini-app .claim-form-card .ant-card-head-title {
font-size: 16px;
font-weight: 600;
line-height: 1.3;
white-space: normal;
}
.claim-form-container.telegram-mini-app .claim-form-card .ant-card-body {
padding: 12px;
}
.claim-form-container.telegram-mini-app .steps {
margin-bottom: 16px;
}
.claim-form-container.telegram-mini-app .steps .ant-steps-item-title {
font-size: 12px;
line-height: 1.2;
}
.claim-form-container.telegram-mini-app .steps .ant-steps-item-description {
font-size: 11px;
}
.claim-form-container.telegram-mini-app .steps-content {
min-height: 280px;
padding: 8px 4px 12px;
}
.claim-form-container.telegram-mini-app .ant-btn {
font-size: 14px;
}
.claim-form-container.telegram-mini-app .ant-input,
.claim-form-container.telegram-mini-app .ant-select-selector {
font-size: 16px;
}
.claim-form-container.telegram-mini-app .ant-card-extra .ant-space-item .ant-btn,
.claim-form-container.telegram-mini-app .ant-card-extra button {
padding: 6px 10px;
font-size: 13px;
}
/* Кнопки действий в черновиках - вертикально в Telegram */
.claim-form-container.telegram-mini-app .draft-actions {
flex-direction: column !important;
}
.claim-form-container.telegram-mini-app .draft-actions .ant-btn {
width: 100%;
}

View File

@@ -1,5 +1,5 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { Steps, Card, message, Row, Col, Space } from 'antd'; import { Steps, Card, message, Row, Col, Space, Spin } from 'antd';
import Step1Phone from '../components/form/Step1Phone'; import Step1Phone from '../components/form/Step1Phone';
import StepDescription from '../components/form/StepDescription'; import StepDescription from '../components/form/StepDescription';
// Step1Policy убран - старый ERV флоу // Step1Policy убран - старый ERV флоу
@@ -7,7 +7,7 @@ import StepDraftSelection from '../components/form/StepDraftSelection';
import StepWizardPlan from '../components/form/StepWizardPlan'; import StepWizardPlan from '../components/form/StepWizardPlan';
import StepClaimConfirmation from '../components/form/StepClaimConfirmation'; import StepClaimConfirmation from '../components/form/StepClaimConfirmation';
// Step2EventType, StepDocumentUpload убраны - старый ERV флоу // Step2EventType, StepDocumentUpload убраны - старый ERV флоу
import Step3Payment from '../components/form/Step3Payment'; // Step3Payment убран - не используется
import DebugPanel from '../components/DebugPanel'; import DebugPanel from '../components/DebugPanel';
// getDocumentsForEventType убран - старый ERV флоу // getDocumentsForEventType убран - старый ERV флоу
import './ClaimForm.css'; import './ClaimForm.css';
@@ -105,14 +105,173 @@ export default function ClaimForm() {
const [showDraftSelection, setShowDraftSelection] = useState(false); const [showDraftSelection, setShowDraftSelection] = useState(false);
const [selectedDraftId, setSelectedDraftId] = useState<string | null>(null); const [selectedDraftId, setSelectedDraftId] = useState<string | null>(null);
const [hasDrafts, setHasDrafts] = useState(false); const [hasDrafts, setHasDrafts] = useState(false);
const [telegramAuthChecked, setTelegramAuthChecked] = useState(false);
/** Статус Telegram auth — показываем на странице, т.к. консоль Mini App отдельная */
const [tgDebug, setTgDebug] = useState<string>('');
/** Дефолт = веб. Скин TG подставляется только при заходе через Telegram Mini App. */
const [isTelegramMiniApp, setIsTelegramMiniApp] = useState(false);
useEffect(() => { useEffect(() => {
// 🔥 VERSION CHECK: Если видишь это в консоли - фронт обновился! // 🔥 VERSION CHECK: Если видишь это в консоли - фронт обновился!
console.log('🔥 ClaimForm v3.9 - 2025-12-29 - Auto redirect to drafts after success'); console.log('🔥 ClaimForm v3.9 - 2025-12-29 - Auto redirect to drafts after success');
}, []); }, []);
// ✅ Восстановление сессии при загрузке страницы // Определение: зашли с веба или из Telegram Mini App. Дефолт — веб; при TG вешаем класс для отдельного скина.
// Загружаем telegram-web-app.js только если есть признаки Telegram (чтобы не мусорить в консоли).
useEffect(() => { useEffect(() => {
const isTelegramContext = () => {
// Проверяем URL, referrer и user agent на признаки Telegram
const url = window.location.href;
const ref = document.referrer;
const ua = navigator.userAgent;
return (
url.includes('tgWebAppData') ||
url.includes('tgWebAppVersion') ||
ref.includes('telegram') ||
ua.includes('Telegram')
);
};
if (isTelegramContext()) {
// Загружаем скрипт Telegram SDK динамически
const script = document.createElement('script');
script.src = 'https://telegram.org/js/telegram-web-app.js';
script.async = true;
script.onload = () => {
setTimeout(() => {
const tg = (window as any).Telegram;
const webApp = tg?.WebApp;
const hasInitData = webApp?.initData && webApp.initData.length > 0;
if (webApp && hasInitData) {
setIsTelegramMiniApp(true);
try {
webApp.ready?.();
webApp.expand?.();
} catch (_) {}
}
}, 100);
};
document.head.appendChild(script);
}
}, []);
// ✅ Telegram Mini App: попытка авторизоваться через initData при первом заходе
useEffect(() => {
const tryTelegramAuth = async () => {
try {
// Только window: parent недоступен из-за cross-origin (iframe Telegram)
const getTg = () => (window as any).Telegram;
// Ждём появления initData: скрипт Telegram может подгрузиться с задержкой
const maxWaitMs = 2500;
const intervalMs = 150;
let webApp: TelegramWebApp | null = null;
let attempts = 0;
while (attempts * intervalMs < maxWaitMs) {
const tg = getTg();
webApp = tg?.WebApp ?? null;
if (webApp?.initData) {
console.log('[TG] initData появился через', attempts * intervalMs, 'ms, длина=', webApp.initData.length);
break;
}
attempts++;
await new Promise((r) => setTimeout(r, intervalMs));
}
if (!webApp?.initData) {
const tg = getTg();
console.log('[TG] После ожидания', maxWaitMs, 'ms: Telegram=', !!tg, 'WebApp=', !!tg?.WebApp, 'initData=', !!tg?.WebApp?.initData, '→ пропускаем tg/auth');
setTelegramAuthChecked(true);
return;
}
// Логирование для отладки
if (webApp.initDataUnsafe?.user) {
const u = webApp.initDataUnsafe.user;
console.log('[TG] initDataUnsafe.user:', { id: u.id, username: u.username, first_name: u.first_name });
}
// Если сессия уже есть в localStorage — ничего не делаем, дальше сработает обычное restoreSession
const existingToken = localStorage.getItem('session_token');
if (existingToken) {
setTgDebug('TG: session_token уже есть → tg/auth не вызываем');
console.log('[TG] session_token уже в localStorage → tg/auth не вызываем');
setTelegramAuthChecked(true);
return;
}
setTgDebug('TG: POST /api/v1/tg/auth...');
console.log('[TG] Вызываем POST /api/v1/tg/auth, initData длина=', webApp.initData.length);
const response = await fetch('/api/v1/tg/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
init_data: webApp.initData,
}),
});
const data = await response.json();
console.log('[TG] /api/v1/tg/auth ответ: status=', response.status, 'ok=', response.ok, 'data=', data);
if (!response.ok || !data.success) {
console.warn('[TG] Telegram auth не успешен → показываем экран телефона/SMS. detail=', data.detail || data);
setTelegramAuthChecked(true);
return;
}
const sessionToken = data.session_token;
// Сохраняем session_token так же, как после SMS-логина
if (sessionToken) {
localStorage.setItem('session_token', sessionToken);
sessionIdRef.current = sessionToken;
}
// Сохраняем базовые данные пользователя (phone может быть пустым)
setFormData((prev) => ({
...prev,
unified_id: data.unified_id,
phone: data.phone,
contact_id: data.contact_id,
session_id: sessionToken,
}));
// Помечаем телефон как уже "подтверждённый" для Telegram-флоу
setIsPhoneVerified(true);
// Если n8n сразу сообщил о наличии черновиков — показываем экран выбора
if (data.has_drafts) {
console.log('🤖 Telegram auth: has_drafts=true, переходим на экран черновиков');
setShowDraftSelection(true);
setHasDrafts(true);
setCurrentStep(0);
} else {
// Иначе переходим сразу к описанию проблемы
console.log('🤖 Telegram auth: черновиков нет, переходим к описанию проблемы');
setCurrentStep(1);
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
setTgDebug(`TG: ошибка: ${msg}`);
console.error('[TG] Ошибка при tg/auth (сеть или парсинг):', error);
} finally {
setTelegramAuthChecked(true);
}
};
tryTelegramAuth();
}, []);
// ✅ Восстановление сессии при загрузке страницы (после попытки Telegram auth)
useEffect(() => {
if (!telegramAuthChecked) {
// Ждём, пока не закончим попытку Telegram-авторизации,
// чтобы не гонять два параллельных restoreSession.
return;
}
const restoreSession = async () => { const restoreSession = async () => {
console.log('🔑 🔑 🔑 НАЧАЛО ВОССТАНОВЛЕНИЯ СЕССИИ 🔑 🔑 🔑'); console.log('🔑 🔑 🔑 НАЧАЛО ВОССТАНОВЛЕНИЯ СЕССИИ 🔑 🔑 🔑');
console.log('🔑 Все ключи в localStorage:', Object.keys(localStorage)); console.log('🔑 Все ключи в localStorage:', Object.keys(localStorage));
@@ -180,12 +339,12 @@ export default function ClaimForm() {
}); });
}); });
message.success(`Добро пожаловать! Сессия восстановлена (${data.phone})`); message.success('Добро пожаловать!');
addDebugEvent('session', 'success', '✅ Сессия восстановлена, найдены черновики'); addDebugEvent('session', 'success', '✅ Сессия восстановлена, найдены черновики');
} else { } else {
// Нет черновиков - переходим к описанию // Нет черновиков - переходим к описанию
setCurrentStep(1); setCurrentStep(1);
message.success(`Добро пожаловать! Сессия восстановлена (${data.phone})`); message.success('Добро пожаловать!');
addDebugEvent('session', 'success', '✅ Сессия восстановлена'); addDebugEvent('session', 'success', '✅ Сессия восстановлена');
} }
} else { } else {
@@ -204,7 +363,7 @@ export default function ClaimForm() {
}; };
restoreSession(); restoreSession();
}, []); // Запускаем только при загрузке }, [telegramAuthChecked]); // Запускаем только один раз, после попытки Telegram auth
// Получаем IP клиента один раз при монтировании // Получаем IP клиента один раз при монтировании
useEffect(() => { useEffect(() => {
@@ -277,8 +436,24 @@ export default function ClaimForm() {
console.log('⏪ prevStep called'); console.log('⏪ prevStep called');
setCurrentStep((prev) => { setCurrentStep((prev) => {
console.log('📍 Current step:', prev, '→ Prev:', prev - 1); console.log('📍 Current step:', prev, '→ Prev:', prev - 1);
// ✅ Если возвращаемся к шагу 0 и есть черновики - показываем список
if (prev - 1 === 0 && formData.unified_id && hasDrafts) {
console.log('📍 Возврат к списку черновиков');
setShowDraftSelection(true);
setSelectedDraftId(null);
}
return prev - 1; return prev - 1;
}); });
}, [formData.unified_id, hasDrafts]);
// ✅ Возврат к списку черновиков напрямую (без промежуточных шагов)
const backToDraftsList = useCallback(() => {
console.log('📋 Возврат к списку черновиков');
setShowDraftSelection(true);
setSelectedDraftId(null);
setCurrentStep(0);
}, []); }, []);
// Преобразование данных черновика в формат propertyName для формы подтверждения // Преобразование данных черновика в формат propertyName для формы подтверждения
@@ -624,6 +799,13 @@ export default function ClaimForm() {
const hasDocuments = Array.isArray(documentsMeta) && documentsMeta.length > 0; const hasDocuments = Array.isArray(documentsMeta) && documentsMeta.length > 0;
const isDraft = claim.status_code === 'draft'; const isDraft = claim.status_code === 'draft';
// ✅ Запрещаем редактирование заявок "В работе"
if (claim.status_code === 'in_work') {
message.warning('Эта заявка уже в работе и не может быть изменена');
console.log('⚠️ Попытка открыть заявку "В работе" для редактирования - запрещено');
return;
}
// ✅ НОВОЕ: Проверяем наличие form_draft (собранные данные из RAG) // ✅ НОВОЕ: Проверяем наличие form_draft (собранные данные из RAG)
const formDraft = payload.form_draft; const formDraft = payload.form_draft;
const hasFormDraft = !!(formDraft && formDraft.user && formDraft.offenders); const hasFormDraft = !!(formDraft && formDraft.user && formDraft.offenders);
@@ -1126,6 +1308,7 @@ export default function ClaimForm() {
phone={formData.phone || ''} phone={formData.phone || ''}
session_id={sessionIdRef.current} session_id={sessionIdRef.current}
unified_id={formData.unified_id} // ✅ Передаём unified_id unified_id={formData.unified_id} // ✅ Передаём unified_id
isTelegramMiniApp={isTelegramMiniApp} // ✅ Передаём флаг Telegram
onSelectDraft={handleSelectDraft} onSelectDraft={handleSelectDraft}
onNewClaim={handleNewClaim} onNewClaim={handleNewClaim}
/> />
@@ -1241,6 +1424,7 @@ export default function ClaimForm() {
updateFormData={updateFormData} updateFormData={updateFormData}
onPrev={prevStep} onPrev={prevStep}
onNext={nextStep} onNext={nextStep}
backToDraftsList={backToDraftsList}
addDebugEvent={addDebugEvent} addDebugEvent={addDebugEvent}
/> />
), ),
@@ -1262,48 +1446,75 @@ export default function ClaimForm() {
/> />
), ),
}); });
} else {
// ✅ СТАРЫЙ ФЛОУ: Step3Payment (только если нет StepClaimConfirmation)
// Используется как fallback, если данные claim:plan не получены
stepsArray.push({
title: 'Заявление',
description: 'Подтверждение',
content: (
<Step3Payment
formData={formData}
updateFormData={updateFormData}
onPrev={prevStep}
onSubmit={handleSubmit}
isPhoneVerified={isPhoneVerified}
setIsPhoneVerified={setIsPhoneVerified}
addDebugEvent={addDebugEvent}
/>
),
});
} }
// Step3Payment убран - не используется
return stepsArray; return stepsArray;
}, [formData, isPhoneVerified, nextStep, prevStep, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts]); }, [formData, isPhoneVerified, nextStep, prevStep, backToDraftsList, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts]);
const handleReset = () => { const handleReset = () => {
console.log('🔄 Начать заново - возврат к списку черновиков');
// ✅ Генерируем новую сессию для новой заявки (но сохраняем авторизацию)
const newSessionId = 'sess_' + generateUUIDv4();
sessionIdRef.current = newSessionId;
setIsSubmitted(false); setIsSubmitted(false);
setFormData({ setShowDraftSelection(false);
setSelectedDraftId(null);
// ✅ Очищаем данные формы, НО сохраняем авторизацию (unified_id, phone, contact_id, isPhoneVerified)
updateFormData({
session_id: newSessionId,
claim_id: undefined,
voucher: '', voucher: '',
claim_id: undefined, // ✅ Очищаем для новой заявки
session_id: sessionIdRef.current,
paymentMethod: 'sbp', paymentMethod: 'sbp',
problemDescription: undefined,
wizardPlan: undefined,
wizardAnswers: undefined,
wizardPrefill: undefined,
wizardPrefillArray: undefined,
wizardCoverageReport: undefined,
wizardUploads: undefined,
wizardSkippedDocuments: undefined,
eventType: undefined,
// ✅ unified_id, phone, contact_id, isPhoneVerified НЕ очищаем
}); });
setCurrentStep(0);
setIsPhoneVerified(false); // ✅ Проверяем черновики и возвращаемся к списку
if (formData.unified_id && hasDrafts) {
console.log('🔄 Есть черновики - показываем список');
setShowDraftSelection(true);
setCurrentStep(0);
} else {
console.log('🔄 Нет черновиков - переходим к новой заявке');
setCurrentStep(1); // StepDescription
}
message.info('Форма сброшена'); message.info('Форма сброшена');
addDebugEvent('system', 'info', '🔄 Форма сброшена'); addDebugEvent('system', 'info', '🔄 Форма сброшена');
}; };
// Обработчик кнопки "Выход" - завершить сессию и вернуться к Step1Phone // Обработчик кнопки "Выход"
const handleExitToList = useCallback(async () => { const handleExitToList = useCallback(async () => {
console.log('🚪 Выход из системы'); console.log('🚪 Выход из системы');
addDebugEvent('system', 'info', '🚪 Выход из системы'); addDebugEvent('system', 'info', '🚪 Выход из системы');
// ✅ В Telegram Mini App — просто закрываем приложение
if (isTelegramMiniApp) {
try {
const tg = (window as any).Telegram;
const webApp = tg?.WebApp;
if (webApp && typeof webApp.close === 'function') {
webApp.close();
}
} catch (error) {
console.warn('⚠️ Ошибка при закрытии Telegram Mini App:', error);
}
return;
}
// ✅ В обычном веб — полный сброс сессии и возврат к Step1Phone
// Получаем session_token из localStorage // Получаем session_token из localStorage
const sessionToken = localStorage.getItem('session_token') || formData.session_id; const sessionToken = localStorage.getItem('session_token') || formData.session_id;
@@ -1328,42 +1539,50 @@ export default function ClaimForm() {
// Удаляем session_token из localStorage // Удаляем session_token из localStorage
localStorage.removeItem('session_token'); localStorage.removeItem('session_token');
// Полный сброс: очищаем все данные авторизации и черновиков // Полный сброс: очищаем все данные авторизации и черновиков
setIsSubmitted(false); setIsSubmitted(false);
setShowDraftSelection(false); setShowDraftSelection(false);
setHasDrafts(false); setHasDrafts(false);
setSelectedDraftId(null); setSelectedDraftId(null);
// Генерируем новую сессию для нового пользователя // Генерируем новую сессию для нового пользователя
const newSessionId = `sess-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const newSessionId = `sess-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
sessionIdRef.current = newSessionId; sessionIdRef.current = newSessionId;
// Полностью очищаем formData, включая unified_id и phone // Полностью очищаем formData, включая unified_id и phone
setFormData({ setFormData({
voucher: '', voucher: '',
claim_id: undefined, claim_id: undefined,
session_id: newSessionId, session_id: newSessionId,
paymentMethod: 'sbp', paymentMethod: 'sbp',
unified_id: undefined, // ✅ Очищаем unified_id unified_id: undefined,
phone: undefined, // ✅ Очищаем phone phone: undefined,
contact_id: undefined, // ✅ Очищаем contact_id contact_id: undefined,
is_new_contact: undefined, is_new_contact: undefined,
isPhoneVerified: false, isPhoneVerified: false,
}); });
// Сбрасываем флаг верификации телефона // Сбрасываем флаг верификации телефона
setIsPhoneVerified(false); setIsPhoneVerified(false);
// Переходим на экран входа (Step1Phone) // Переходим на экран входа (Step1Phone)
// Если showDraftSelection = false и нет unified_id, то шаг 0 будет Step1Phone
setCurrentStep(0); setCurrentStep(0);
message.info('Сессия завершена. До свидания!'); message.info('Сессия завершена. До свидания!');
addDebugEvent('system', 'info', '🔄 Форма сброшена'); addDebugEvent('system', 'info', '🔄 Форма сброшена');
}, [formData.session_id, addDebugEvent]); }, [formData.session_id, addDebugEvent, isTelegramMiniApp]);
// ✅ Показываем loader пока идёт проверка Telegram auth и восстановление сессии
if (!telegramAuthChecked || !sessionRestored) {
return (
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px', background: '#ffffff', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<Spin size="large" tip="Загрузка..." />
</div>
);
}
return ( return (
<div className="claim-form-container" style={{ padding: '20px', background: '#ffffff' }}> <div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px', background: '#ffffff' }}>
<Row gutter={16}> <Row gutter={16}>
{/* Левая часть - Форма (в проде на всю ширину, в деве 14 из 24) */} {/* Левая часть - Форма (в проде на всю ширину, в деве 14 из 24) */}
<Col xs={24} lg={process.env.NODE_ENV === 'development' ? 14 : 24}> <Col xs={24} lg={process.env.NODE_ENV === 'development' ? 14 : 24}>

View File

@@ -5,4 +5,28 @@ declare module '*.svg' {
export default content; export default content;
} }
interface TelegramWebAppUser {
id: number;
first_name?: string;
last_name?: string;
username?: string;
language_code?: string;
}
interface TelegramWebApp {
initData: string;
initDataUnsafe: {
user?: TelegramWebAppUser;
[key: string]: any;
};
}
interface TelegramNamespace {
WebApp?: TelegramWebApp;
}
interface Window {
Telegram?: TelegramNamespace;
}

55
start-dev.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/bin/bash
# ============================================
# Запуск DEVELOPMENT окружения
# ============================================
set -e
cd "$(dirname "$0")"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🚀 Запуск DEVELOPMENT окружения"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Проверка .env.dev
if [ ! -f .env.dev ]; then
echo "⚠️ Файл .env.dev не найден!"
echo "📝 Создаю из .env.example..."
if [ -f .env.example ]; then
cp .env.example .env.dev
echo "✅ Создан .env.dev (отредактируйте его!)"
else
echo "❌ Файл .env.example не найден!"
exit 1
fi
fi
echo "📦 Останавливаю существующие контейнеры..."
docker-compose -f docker-compose.dev.yml down 2>/dev/null || true
echo ""
echo "🔨 Собираю и запускаю контейнеры..."
docker-compose -f docker-compose.dev.yml up -d --build
echo ""
echo "⏳ Жду запуска сервисов..."
sleep 5
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "✅ DEVELOPMENT окружение запущено!"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "📍 Доступные сервисы:"
echo " Frontend: http://localhost:5175"
echo " Backend: http://localhost:8200"
echo " API Docs: http://localhost:8200/docs"
echo ""
echo "📊 Статус контейнеров:"
docker-compose -f docker-compose.dev.yml ps
echo ""
echo "📋 Логи:"
echo " docker-compose -f docker-compose.dev.yml logs -f"
echo ""

67
start-prod.sh Executable file
View File

@@ -0,0 +1,67 @@
#!/bin/bash
# ============================================
# Запуск PRODUCTION окружения
# ============================================
set -e
cd "$(dirname "$0")"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "🚀 Запуск PRODUCTION окружения"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Проверка .env.prod
if [ ! -f .env.prod ]; then
echo "⚠️ Файл .env.prod не найден!"
echo "📝 Создаю из .env.example..."
if [ -f .env.example ]; then
cp .env.example .env.prod
echo "✅ Создан .env.prod"
echo "⚠️ ВАЖНО: Отредактируйте .env.prod перед запуском!"
echo " - Установите APP_ENV=production"
echo " - Установите DEBUG=false"
echo " - Проверьте все URL и ключи API"
read -p "Продолжить? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
else
echo "❌ Файл .env.example не найден!"
exit 1
fi
fi
echo "📦 Останавливаю существующие контейнеры..."
docker-compose -f docker-compose.prod.yml down 2>/dev/null || true
echo ""
echo "🔨 Собираю и запускаю контейнеры..."
docker-compose -f docker-compose.prod.yml up -d --build
echo ""
echo "⏳ Жду запуска сервисов..."
sleep 5
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "✅ PRODUCTION окружение запущено!"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "📍 Доступные сервисы:"
echo " Frontend: http://localhost:5176"
echo " Backend: http://localhost:8200"
echo " API Docs: http://localhost:8200/docs"
echo ""
echo "📊 Статус контейнеров:"
docker-compose -f docker-compose.prod.yml ps
echo ""
echo "📋 Логи:"
echo " docker-compose -f docker-compose.prod.yml logs -f"
echo ""
echo "⚠️ ВАЖНО: Проверьте healthcheck статус!"
docker-compose -f docker-compose.prod.yml ps
echo ""