From 2e45786e4620a1246fa7c94ed3b13b1fa1253001 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Thu, 29 Jan 2026 16:12:48 +0300 Subject: [PATCH] feat: Telegram Mini App integration and UX improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлена полная интеграция с 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 интеграции --- CURRENT_SETUP.md | 137 ++++ DEPLOYMENT.md | 203 +++++ ENVIRONMENTS.md | 264 +++++++ README_ENVIRONMENTS.md | 94 +++ backend/app/api/banks.py | 60 ++ backend/app/api/claims.py | 339 ++++++--- backend/app/api/n8n_proxy.py | 67 ++ backend/app/api/sms.py | 11 +- backend/app/api/telegram_auth.py | 154 ++++ backend/app/config.py | 36 +- backend/app/main.py | 15 +- backend/app/services/n8n_service.py | 107 ++- backend/app/services/sms_service.py | 16 +- backend/app/services/telegram_auth.py | 132 ++++ deploy-to-prod.sh | 86 +++ docker-compose.prod.yml | 62 ++ docs/BROWSERLESS_CURL_EXAMPLE.sh | 68 ++ docs/N8N_BROWSERLESS_FUNCTION_GUIDE.md | 163 ++++ docs/N8N_BROWSERLESS_FUNCTION_SETUP.json | 29 + docs/N8N_BROWSERLESS_HTTP_REQUEST_SETUP.md | 135 ++++ docs/N8N_CODE_PROCESS_FLIGHTS_DATA.js | 698 ++++++++++++++++++ docs/N8N_EXTRACT_BASE64_FROM_RESPONSE.js | 110 +++ docs/N8N_FLIGHTS_BROWSERLESS_COMPLETE.js | 99 +++ docs/N8N_FLIGHTS_BROWSERLESS_PDF.js | 99 +++ docs/N8N_FLIGHTS_BROWSERLESS_PDF_V2.js | 124 ++++ docs/N8N_FLIGHTS_COMPLETE_WORKFLOW.md | 112 +++ docs/N8N_FLIGHTS_HTML_TO_PDF_BROWSER.js | 132 ++++ docs/N8N_FLIGHTS_HTML_TO_PDF_EXAMPLE.js | 96 +++ docs/N8N_FLIGHTS_IMPROVED_FORMATTING.md | 35 + docs/N8N_FLIGHTS_PDF_BASE64_COMPLETE.js | 72 ++ docs/N8N_FLIGHTS_PDF_BASE64_FULL.js | 81 ++ docs/N8N_FLIGHTS_PREPARE_REQUEST_DATA.js | 65 ++ docs/N8N_FLIGHTS_PROCESSING_GUIDE.md | 193 +++++ docs/N8N_FLIGHTS_QUICK_START.md | 236 ++++++ docs/N8N_FLIGHTS_REQUESTED_INFO.md | 103 +++ docs/N8N_FLIGHTS_SIMPLE_BINARY.js | 370 ++++++++++ docs/N8N_FLIGHTS_TO_BASE64.js | 630 ++++++++++++++++ docs/N8N_FLIGHTS_WORKFLOW_EXAMPLE.md | 320 ++++++++ docs/N8N_FLIGHTS_WORKING_SOLUTION.md | 140 ++++ docs/N8N_FLIGHTS_WORKING_WORKFLOW.json | 53 ++ docs/N8N_HTML_TO_BASE64_PDF_SIMPLE.js | 102 +++ docs/N8N_PARSE_INIT_DATA.js | 62 ++ docs/N8N_WORKFLOW_STUCK_FIX.md | 147 ++++ docs/TELEGRAM_MINIAPP_FLOW.md | 122 +++ frontend/index.html | 1 + frontend/public/index.html | 1 + frontend/src/components/form/Step1Phone.tsx | 9 +- frontend/src/components/form/Step3Payment.tsx | 13 +- .../components/form/StepClaimConfirmation.tsx | 3 +- .../components/form/StepDraftSelection.tsx | 68 +- .../src/components/form/StepWizardPlan.tsx | 4 +- .../form/generateConfirmationFormHTML.ts | 9 +- frontend/src/pages/ClaimForm.css | 74 ++ frontend/src/pages/ClaimForm.tsx | 303 ++++++-- frontend/src/vite-env.d.ts | 24 + start-dev.sh | 55 ++ start-prod.sh | 67 ++ 57 files changed, 6776 insertions(+), 234 deletions(-) create mode 100644 CURRENT_SETUP.md create mode 100644 DEPLOYMENT.md create mode 100644 ENVIRONMENTS.md create mode 100644 README_ENVIRONMENTS.md create mode 100644 backend/app/api/banks.py create mode 100644 backend/app/api/telegram_auth.py create mode 100644 backend/app/services/telegram_auth.py create mode 100755 deploy-to-prod.sh create mode 100644 docker-compose.prod.yml create mode 100644 docs/BROWSERLESS_CURL_EXAMPLE.sh create mode 100644 docs/N8N_BROWSERLESS_FUNCTION_GUIDE.md create mode 100644 docs/N8N_BROWSERLESS_FUNCTION_SETUP.json create mode 100644 docs/N8N_BROWSERLESS_HTTP_REQUEST_SETUP.md create mode 100644 docs/N8N_CODE_PROCESS_FLIGHTS_DATA.js create mode 100644 docs/N8N_EXTRACT_BASE64_FROM_RESPONSE.js create mode 100644 docs/N8N_FLIGHTS_BROWSERLESS_COMPLETE.js create mode 100644 docs/N8N_FLIGHTS_BROWSERLESS_PDF.js create mode 100644 docs/N8N_FLIGHTS_BROWSERLESS_PDF_V2.js create mode 100644 docs/N8N_FLIGHTS_COMPLETE_WORKFLOW.md create mode 100644 docs/N8N_FLIGHTS_HTML_TO_PDF_BROWSER.js create mode 100644 docs/N8N_FLIGHTS_HTML_TO_PDF_EXAMPLE.js create mode 100644 docs/N8N_FLIGHTS_IMPROVED_FORMATTING.md create mode 100644 docs/N8N_FLIGHTS_PDF_BASE64_COMPLETE.js create mode 100644 docs/N8N_FLIGHTS_PDF_BASE64_FULL.js create mode 100644 docs/N8N_FLIGHTS_PREPARE_REQUEST_DATA.js create mode 100644 docs/N8N_FLIGHTS_PROCESSING_GUIDE.md create mode 100644 docs/N8N_FLIGHTS_QUICK_START.md create mode 100644 docs/N8N_FLIGHTS_REQUESTED_INFO.md create mode 100644 docs/N8N_FLIGHTS_SIMPLE_BINARY.js create mode 100644 docs/N8N_FLIGHTS_TO_BASE64.js create mode 100644 docs/N8N_FLIGHTS_WORKFLOW_EXAMPLE.md create mode 100644 docs/N8N_FLIGHTS_WORKING_SOLUTION.md create mode 100644 docs/N8N_FLIGHTS_WORKING_WORKFLOW.json create mode 100644 docs/N8N_HTML_TO_BASE64_PDF_SIMPLE.js create mode 100644 docs/N8N_PARSE_INIT_DATA.js create mode 100644 docs/N8N_WORKFLOW_STUCK_FIX.md create mode 100644 docs/TELEGRAM_MINIAPP_FLOW.md create mode 100755 start-dev.sh create mode 100755 start-prod.sh diff --git a/CURRENT_SETUP.md b/CURRENT_SETUP.md new file mode 100644 index 0000000..1d23b4e --- /dev/null +++ b/CURRENT_SETUP.md @@ -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 + diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..8f629b5 --- /dev/null +++ b/DEPLOYMENT.md @@ -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 +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 + diff --git a/ENVIRONMENTS.md b/ENVIRONMENTS.md new file mode 100644 index 0000000..8325219 --- /dev/null +++ b/ENVIRONMENTS.md @@ -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 + diff --git a/README_ENVIRONMENTS.md b/README_ENVIRONMENTS.md new file mode 100644 index 0000000..635b019 --- /dev/null +++ b/README_ENVIRONMENTS.md @@ -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` для детальной информации. + +--- + +**Всё готово к работе!** 🎉 + diff --git a/backend/app/api/banks.py b/backend/app/api/banks.py new file mode 100644 index 0000000..705e1c8 --- /dev/null +++ b/backend/app/api/banks.py @@ -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)}" + ) + + + + diff --git a/backend/app/api/claims.py b/backend/app/api/claims.py index b1fa174..4212cab 100644 --- a/backend/app/api/claims.py +++ b/backend/app/api/claims.py @@ -13,10 +13,11 @@ import uuid from datetime import datetime import json import logging +import asyncio from ..services.redis_service import redis_service from ..services.database import db from ..services.crm_mysql_service import crm_mysql_service -from ..services.n8n_service import check_workflow_status, restart_workflow, MIN_RESTART_INTERVAL +# Убрали импорты из n8n_service - больше не нужны для webhook подхода from ..config import settings 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' = $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) ORDER BY c.updated_at DESC LIMIT 20 @@ -268,7 +269,7 @@ async def list_drafts( c.updated_at FROM clpr_claims c WHERE c.session_token = $1 - AND (c.status_code != 'approved' OR c.status_code IS NULL) + AND (c.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) ORDER BY c.updated_at DESC LIMIT 20 @@ -392,10 +393,11 @@ async def list_drafts( # Формируем список документов со статусами documents_list = [] 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', '') 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 documents_list.append({ "name": doc_name, @@ -498,10 +500,40 @@ async def get_draft(claim_id: str): # 🔍 ОТЛАДКА: Логируем наличие documents_required 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}") if documents_required: 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) # Простой способ: делаем прямой SQL запрос к БД (таблицы vtiger_*) # ПРИМЕЧАНИЕ: Если таблицы vtiger_* находятся в MySQL (а не PostgreSQL), @@ -604,7 +636,11 @@ async def get_draft(claim_id: str): "channel": row.get('channel'), "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, - "payload": payload + "payload": payload, + # Информация о документах + "documents_total": documents_total, + "documents_uploaded": documents_uploaded, + "documents_list": documents_list, }, # ✅ Флаги подтверждения данных контакта (из CRM поля cf_2624) "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)}") -async def _check_and_restart_workflow_if_needed(channel: str): +async def _send_buffered_messages_to_webhook(): """ - Проверяет и перезапускает workflow если нужно (в фоне) - Защита от частых перезапусков через Redis lock + Отправляет все сообщения из буфера в n8n webhook (вместо Redis pub/sub) """ try: - # Проверяем lock - если недавно перезапускали, пропускаем - lock_key = f"workflow_restart_lock:{channel}" - lock_value = await redis_service.get(lock_key) - - if lock_value: - logger.info(f"⏸️ Workflow недавно перезапускался, пропускаем (lock active)") + if not settings.n8n_description_webhook: + logger.error("❌ N8N description webhook не настроен, не могу отправить из буфера") return - # Проверяем статус workflow - workflow_data = await check_workflow_status() + buffer_key = "description" + 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: - logger.exception(f"❌ Ошибка при проверке/перезапуске workflow: {e}") + logger.exception(f"❌ Ошибка при отправке буфера: {e}") @router.post("/description") @@ -958,12 +1041,18 @@ async def publish_ticket_form_description( background_tasks: BackgroundTasks ): """ - Публикует свободное описание проблемы в Redis канал ticket_form:description - (слушается воркфлоу в n8n) + Отправляет описание проблемы в n8n через webhook (вместо Redis pub/sub) """ 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" - event = { + message = { "type": "ticket_form_description", "session_id": payload.session_id, "claim_id": payload.claim_id, # Опционально - может быть None @@ -976,7 +1065,13 @@ async def publish_ticket_form_description( "timestamp": datetime.utcnow().isoformat(), } - event_json = json.dumps(event, ensure_ascii=False) + # n8n workflow ожидает массив с объектом, содержащим channel и message + webhook_payload = [ + { + "channel": channel, + "message": message + } + ] logger.info( "📝 TicketForm description received", @@ -991,81 +1086,111 @@ async def publish_ticket_form_description( }, ) - logger.info( - "📡 Publishing to Redis channel", - extra={ - "channel": channel, - "event_type": event["type"], - "event_keys": list(event.keys()), - "json_length": len(event_json), - }, - ) + # Retry-логика: пытаемся отправить в n8n webhook + max_attempts = 3 + initial_delay = 1 # секунды - subscribers_count = await redis_service.publish(channel, event_json) - - logger.info( - "✅ TicketForm description published to Redis", - extra={ - "channel": channel, - "session_id": payload.session_id, - "subscribers_count": subscribers_count, - "event_json_preview": event_json[:500], - }, - ) - - if subscribers_count == 0: - logger.warning( - f"⚠️ WARNING: No subscribers on channel {channel}! " - f"n8n workflow is not listening to this channel. " - f"Saving message to buffer and restarting workflow..." - ) + for attempt in range(1, max_attempts + 1): + try: + logger.info( + f"🔄 Попытка {attempt}/{max_attempts}: отправка в n8n webhook", + extra={"session_id": payload.session_id} + ) + + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + settings.n8n_description_webhook, + json=webhook_payload, # Отправляем в формате массива + headers={"Content-Type": "application/json"} + ) + + if response.status_code == 200: + logger.info( + 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 = { - "session_id": payload.session_id, - "claim_id": payload.claim_id, - "event": event, - "timestamp": datetime.utcnow().isoformat(), - } - await redis_service.buffer_push("description", buffer_message) - logger.info(f"💾 Сообщение сохранено в буфер: session_id={payload.session_id}") - - # Запускаем проверку и перезапуск workflow в фоне - background_tasks.add_task(_check_and_restart_workflow_if_needed, channel) + # Если это не последняя попытка - ждём перед следующей + if attempt < max_attempts: + wait_time = initial_delay * (2 ** (attempt - 1)) # Экспоненциальный backoff + logger.info(f"⏳ Жду {wait_time} секунд перед следующей попыткой...") + await asyncio.sleep(wait_time) - # Дополнительная проверка: логируем полный event для отладки - logger.debug( - "🔍 Full event data published", - extra={ - "channel": channel, - "event": event, - }, + # Все попытки исчерпаны - сохраняем в буфер + logger.error( + f"❌ Все {max_attempts} попытки исчерпаны, сохраняю в буфер", + extra={"session_id": payload.session_id} ) - # Формируем ответ с информацией о подписчиках - response_data = { - "success": True, + + buffer_message = { + "session_id": payload.session_id, + "claim_id": payload.claim_id, "channel": channel, - "subscribers_count": subscribers_count, - "event": event, + "message": message, # Сохраняем message для последующей отправки + "timestamp": datetime.utcnow().isoformat(), } + await redis_service.buffer_push("description", buffer_message) + logger.info(f"💾 Сообщение сохранено в буфер: session_id={payload.session_id}") - # Если подписчиков нет - сообщаем что обработка займёт больше времени - if subscribers_count == 0: - buffer_size = await redis_service.buffer_size("description") - response_data["warning"] = ( + # Запускаем фоновую задачу для отправки из буфера + background_tasks.add_task(_send_buffered_messages_to_webhook) + + buffer_size = await redis_service.buffer_size("description") + return { + "success": True, + "event": message, + "buffered": True, + "warning": ( "Обработка вашего обращения займёт немного больше времени. " "Идёт автоматическое восстановление системы. " "Ваше сообщение сохранено и будет обработано в ближайшее время." - ) - response_data["workflow_recovering"] = True - response_data["message_buffered"] = True - response_data["buffer_size"] = buffer_size + ), + "buffer_size": buffer_size, + } - return response_data + except HTTPException: + raise except Exception as e: - logger.exception("❌ Failed to publish ticket form description") + logger.exception("❌ Failed to send ticket form description to n8n") raise HTTPException( status_code=500, - detail=f"Не удалось опубликовать описание: {e}" + detail=f"Не удалось отправить описание: {e}" ) diff --git a/backend/app/api/n8n_proxy.py b/backend/app/api/n8n_proxy.py index 1fe5ab3..ce342c6 100644 --- a/backend/app/api/n8n_proxy.py +++ b/backend/app/api/n8n_proxy.py @@ -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_CREATE_CONTACT_WEBHOOK = settings.n8n_create_contact_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") @@ -219,6 +220,72 @@ async def proxy_file_upload( 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") async def proxy_create_claim(request: Request): """ diff --git a/backend/app/api/sms.py b/backend/app/api/sms.py index c7b154b..6aad2ad 100644 --- a/backend/app/api/sms.py +++ b/backend/app/api/sms.py @@ -15,14 +15,19 @@ async def send_sms_code(request: SMSSendRequest): - **phone**: Номер телефона в формате +79001234567 """ + from ..config import settings + code = await sms_service.send_verification_code(request.phone) if code: - return { + response = { "success": True, - "message": "Код отправлен на указанный номер", - "debug_code": code # Всегда возвращаем код для dev модалки + "message": "Код отправлен на указанный номер" } + # 🔧 DEV MODE: Возвращаем debug_code только в development + if settings.debug or settings.app_env == "development": + response["debug_code"] = code + return response else: raise HTTPException( status_code=429, diff --git a/backend/app/api/telegram_auth.py b/backend/app/api/telegram_auth.py new file mode 100644 index 0000000..328f1e3 --- /dev/null +++ b/backend/app/api/telegram_auth.py @@ -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, + ) + diff --git a/backend/app/config.py b/backend/app/config.py index 5b55b7d..e56694e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,15 +1,20 @@ """ Конфигурация приложения """ +import os from pathlib import Path from pydantic_settings import BaseSettings -from functools import lru_cache -from typing import List +from typing import List, Optional BASE_DIR = Path(__file__).resolve().parents[2] 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): # ============================================ @@ -179,6 +184,13 @@ class Settings(BaseSettings): n8n_file_upload_webhook: str = "" 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_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 @@ -192,9 +204,25 @@ class Settings(BaseSettings): extra = "ignore" # Игнорируем лишние поля из .env -@lru_cache() 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() diff --git a/backend/app/main.py b/backend/app/main.py index 6bdf28d..4630703 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,14 +6,14 @@ from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager import logging -from .config import settings +from .config import settings, get_cors_origins_live, get_settings from .services.database import db from .services.redis_service import redis_service from .services.rabbitmq_service import rabbitmq_service from .services.policy_service import policy_service from .services.crm_mysql_service import crm_mysql_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( @@ -93,14 +93,19 @@ app = FastAPI( lifespan=lifespan ) -# CORS +# CORS (список обновляется при изменении .env без перезапуска) app.add_middleware( CORSMiddleware, - allow_origins=settings.cors_origins_list, + allow_origins=get_cors_origins_live(), allow_credentials=True, allow_methods=["*"], 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 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(session.router) # 🔑 Session management через Redis 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("/") diff --git a/backend/app/services/n8n_service.py b/backend/app/services/n8n_service.py index 4d40a2b..c62f280 100644 --- a/backend/app/services/n8n_service.py +++ b/backend/app/services/n8n_service.py @@ -13,6 +13,7 @@ logger = logging.getLogger(__name__) WORKFLOW_ID = "b4K4u851b4JFivyD" N8N_URL = "https://n8n.clientright.pro" MIN_RESTART_INTERVAL = 300 # Минимум 5 минут между перезапусками +MAX_RETRY_ATTEMPTS = 2 # Максимум попыток перезапуска подряд async def check_workflow_status() -> Optional[dict]: @@ -50,7 +51,7 @@ async def check_workflow_status() -> Optional[dict]: async def restart_workflow() -> bool: """ - Перезапуск workflow через n8n API + Перезапуск workflow через n8n API с улучшенной обработкой зависших состояний Returns: True если успешно, False при ошибке @@ -63,50 +64,86 @@ async def restart_workflow() -> bool: if not headers: return False + import asyncio + try: - async with httpx.AsyncClient(timeout=10.0) as client: - # Шаг 1: Деактивировать workflow + # Увеличиваем таймаут для обработки зависших 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}...") - deactivate_response = await client.post( - f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/deactivate", - headers=headers - ) - - if deactivate_response.status_code not in [200, 404]: - logger.warning( - f"⚠️ Неожиданный статус при деактивации: " - f"{deactivate_response.status_code}" + try: + deactivate_response = await client.post( + f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/deactivate", + headers=headers, + timeout=15.0 # Отдельный таймаут для деактивации ) - 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(2) + # Задержка перед активацией (увеличена для стабильности) + await asyncio.sleep(3) - # Шаг 2: Активировать workflow + # Шаг 3: Активировать workflow logger.info(f"🔄 Активирую workflow {WORKFLOW_ID}...") - activate_response = await client.post( - f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/activate", - headers=headers - ) - - if activate_response.status_code == 200: - logger.info("✅ Workflow активирован") - - # После успешного перезапуска отправляем сообщения из буфера - await _send_buffered_messages() - - return True - else: - logger.error( - f"❌ Ошибка активации workflow: " - f"{activate_response.status_code} - {activate_response.text[:200]}" + try: + activate_response = await client.post( + f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/activate", + headers=headers, + timeout=15.0 # Отдельный таймаут для активации ) + + 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 + except httpx.TimeoutException: + logger.error("⏱️ Общий таймаут при перезапуске workflow") + return False except Exception as e: - logger.error(f"❌ Неожиданная ошибка при перезапуске workflow: {e}") + logger.error(f"❌ Неожиданная ошибка при перезапуске workflow: {e}", exc_info=True) return False diff --git a/backend/app/services/sms_service.py b/backend/app/services/sms_service.py index 29ea1d1..0b05fde 100644 --- a/backend/app/services/sms_service.py +++ b/backend/app/services/sms_service.py @@ -65,17 +65,11 @@ class SMSService: logger.warning("SMS отправка отключена в конфигурации") return False - # 🔧 DEV: ПРИНУДИТЕЛЬНО ОТКЛЮЧЕНА ОТПРАВКА SMS - # Раскомментировать для продакшена! - logger.info(f"🔧 DEV MODE: SMS to {phone} ЗАБЛОКИРОВАНА (экономим бюджет!)") - logger.info(f"📱 Message: {message}") - return True - - # 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 + # 🔧 DEV MODE: Не отправляем реальные SMS в development, экономим бюджет + if settings.debug or settings.app_env == "development": + logger.info(f"🔧 DEV MODE: SMS to {phone} not sent (saving money!)") + logger.info(f"📱 Message would be: {message}") + return True # Возвращаем True чтобы код сохранился в Redis для проверки try: # Получаем актуальный токен diff --git a/backend/app/services/telegram_auth.py b/backend/app/services/telegram_auth.py new file mode 100644 index 0000000..7869b43 --- /dev/null +++ b/backend/app/services/telegram_auth.py @@ -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: строки "=" по всем полям, кроме '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, + } + diff --git a/deploy-to-prod.sh b/deploy-to-prod.sh new file mode 100755 index 0000000..a025779 --- /dev/null +++ b/deploy-to-prod.sh @@ -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 "" + diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..53b0bae --- /dev/null +++ b/docker-compose.prod.yml @@ -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 + diff --git a/docs/BROWSERLESS_CURL_EXAMPLE.sh b/docs/BROWSERLESS_CURL_EXAMPLE.sh new file mode 100644 index 0000000..742a79c --- /dev/null +++ b/docs/BROWSERLESS_CURL_EXAMPLE.sh @@ -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": "

Test

", +# "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 +# ============================================================================ diff --git a/docs/N8N_BROWSERLESS_FUNCTION_GUIDE.md b/docs/N8N_BROWSERLESS_FUNCTION_GUIDE.md new file mode 100644 index 0000000..8d466b3 --- /dev/null +++ b/docs/N8N_BROWSERLESS_FUNCTION_GUIDE.md @@ -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 diff --git a/docs/N8N_BROWSERLESS_FUNCTION_SETUP.json b/docs/N8N_BROWSERLESS_FUNCTION_SETUP.json new file mode 100644 index 0000000..395a7b1 --- /dev/null +++ b/docs/N8N_BROWSERLESS_FUNCTION_SETUP.json @@ -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" + } + ] +} diff --git a/docs/N8N_BROWSERLESS_HTTP_REQUEST_SETUP.md b/docs/N8N_BROWSERLESS_HTTP_REQUEST_SETUP.md new file mode 100644 index 0000000..826eb55 --- /dev/null +++ b/docs/N8N_BROWSERLESS_HTTP_REQUEST_SETUP.md @@ -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 diff --git a/docs/N8N_CODE_PROCESS_FLIGHTS_DATA.js b/docs/N8N_CODE_PROCESS_FLIGHTS_DATA.js new file mode 100644 index 0000000..f8feae6 --- /dev/null +++ b/docs/N8N_CODE_PROCESS_FLIGHTS_DATA.js @@ -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: '

Ошибка: данные не получены

', + 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 = ` +
+
+

Рейс ${flight.flightNumber || flight.ident || 'N/A'}

+ ${flight.registration} +
+ +
+
+ Тип самолёта: + ${flight.aircraftType || '—'} +
+
+ Идентификатор: + ${flight.ident || '—'} (${flight.identIata || '—'}) +
+
+ `; + + // Данные из FlightAware + if (fa) { + html += ` +
+
+ FlightAware +
+
+
+
+ Откуда: + ${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')}) +
+
+ Куда: + ${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')}) +
+
+ +
+
+ Плановый вылет: + ${safeDate(fa.scheduled_out)} +
+
+ Фактический вылет: + ${safeDate(fa.actual_out)} +
+
+ Взлёт: + ${safeDate(fa.actual_off)} ${fa.actual_runway_off ? `(ВПП ${fa.actual_runway_off})` : ''} +
+
+ Посадка: + ${safeDate(fa.actual_on)} ${fa.actual_runway_on ? `(ВПП ${fa.actual_runway_on})` : ''} +
+
+ Фактический прилёт: + ${safeDate(fa.actual_in)} +
+
+ +
+
+ Статус: + ${safeStr(fa.status || '—')} +
+ ${fa.departure_delay !== null && fa.departure_delay !== undefined ? ` +
+ Задержка вылета: + ${fa.departure_delay > 0 ? '+' : ''}${Math.floor(fa.departure_delay / 60)} мин +
+ ` : ''} + ${fa.arrival_delay !== null && fa.arrival_delay !== undefined ? ` +
+ Задержка прилёта: + ${fa.arrival_delay > 0 ? '+' : ''}${Math.floor(fa.arrival_delay / 60)} мин +
+ ` : ''} + ${fa.gate_origin ? ` +
+ Гейт вылета: + ${fa.gate_origin} +
+ ` : ''} + ${fa.gate_destination ? ` +
+ Гейт прилёта: + ${fa.gate_destination} +
+ ` : ''} + ${fa.baggage_claim ? ` +
+ Выдача багажа: + ${fa.baggage_claim} +
+ ` : ''} +
+
+
+ `; + } else { + html += ` +
+
+ FlightAware + Данные не получены +
+
+ `; + } + + // Данные из FlightRadar24 + if (fr24) { + html += ` +
+
+ FlightRadar24 +
+
+
+
+ Откуда: + ${safeStr(fr24.orig_iata || '—')} (${safeStr(fr24.orig_icao || '—')}) +
+
+ Куда: + ${safeStr(fr24.dest_iata || '—')} (${safeStr(fr24.dest_icao || '—')}) +
+
+ +
+
+ Взлёт: + ${safeDate(fr24.datetime_takeoff)} ${fr24.runway_takeoff ? `(ВПП ${fr24.runway_takeoff})` : ''} +
+
+ Посадка: + ${safeDate(fr24.datetime_landed)} ${fr24.runway_landed ? `(ВПП ${fr24.runway_landed})` : ''} +
+
+ +
+
+ Время полёта: + ${formatDuration(fr24.flight_time)} +
+
+ Фактическое расстояние: + ${formatDistance(fr24.actual_distance)} +
+
+ Кратчайшее расстояние: + ${formatDistance(fr24.circle_distance)} +
+
+ Статус полёта: + ${fr24.flight_ended ? 'Завершён' : 'В процессе'} +
+
+
+
+ `; + } else { + html += ` +
+
+ FlightRadar24 + Данные не получены +
+
+ `; + } + + html += `
`; + 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 = '
Данные о рейсах не найдены
'; + } else { + flightsHTML = flights.map(flight => generateFlightCard(flight)).join(''); + } + + return ` + + + + + Отчёт о рейсах + + + +
+
+

Отчёт о рейсах

+
+
Дата формирования: ${reportDate}
+
+ + FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'} + + + FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'} + +
+
+
+ +
+ ${flightsHTML} +
+
+ +`; +}; + +// ==== ФОРМИРОВАНИЕ РЕЗУЛЬТАТА ==== +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 + } +}]; diff --git a/docs/N8N_EXTRACT_BASE64_FROM_RESPONSE.js b/docs/N8N_EXTRACT_BASE64_FROM_RESPONSE.js new file mode 100644 index 0000000..c2702e6 --- /dev/null +++ b/docs/N8N_EXTRACT_BASE64_FROM_RESPONSE.js @@ -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 +// ============================================================================ diff --git a/docs/N8N_FLIGHTS_BROWSERLESS_COMPLETE.js b/docs/N8N_FLIGHTS_BROWSERLESS_COMPLETE.js new file mode 100644 index 0000000..5153cb6 --- /dev/null +++ b/docs/N8N_FLIGHTS_BROWSERLESS_COMPLETE.js @@ -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 +// } +// }]; +// ============================================================================ diff --git a/docs/N8N_FLIGHTS_BROWSERLESS_PDF.js b/docs/N8N_FLIGHTS_BROWSERLESS_PDF.js new file mode 100644 index 0000000..e8176e2 --- /dev/null +++ b/docs/N8N_FLIGHTS_BROWSERLESS_PDF.js @@ -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) +// ============================================================================ diff --git a/docs/N8N_FLIGHTS_BROWSERLESS_PDF_V2.js b/docs/N8N_FLIGHTS_BROWSERLESS_PDF_V2.js new file mode 100644 index 0000000..642f8dc --- /dev/null +++ b/docs/N8N_FLIGHTS_BROWSERLESS_PDF_V2.js @@ -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 +// ============================================================================ diff --git a/docs/N8N_FLIGHTS_COMPLETE_WORKFLOW.md b/docs/N8N_FLIGHTS_COMPLETE_WORKFLOW.md new file mode 100644 index 0000000..36f0e78 --- /dev/null +++ b/docs/N8N_FLIGHTS_COMPLETE_WORKFLOW.md @@ -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": "...", + "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/месяц бесплатно diff --git a/docs/N8N_FLIGHTS_HTML_TO_PDF_BROWSER.js b/docs/N8N_FLIGHTS_HTML_TO_PDF_BROWSER.js new file mode 100644 index 0000000..b72ae86 --- /dev/null +++ b/docs/N8N_FLIGHTS_HTML_TO_PDF_BROWSER.js @@ -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 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 для конвертации +// ============================================================================ diff --git a/docs/N8N_FLIGHTS_HTML_TO_PDF_EXAMPLE.js b/docs/N8N_FLIGHTS_HTML_TO_PDF_EXAMPLE.js new file mode 100644 index 0000000..3cbd5a9 --- /dev/null +++ b/docs/N8N_FLIGHTS_HTML_TO_PDF_EXAMPLE.js @@ -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 + } +}]; +*/ diff --git a/docs/N8N_FLIGHTS_IMPROVED_FORMATTING.md b/docs/N8N_FLIGHTS_IMPROVED_FORMATTING.md new file mode 100644 index 0000000..d2469c2 --- /dev/null +++ b/docs/N8N_FLIGHTS_IMPROVED_FORMATTING.md @@ -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 "причесываем данные". diff --git a/docs/N8N_FLIGHTS_PDF_BASE64_COMPLETE.js b/docs/N8N_FLIGHTS_PDF_BASE64_COMPLETE.js new file mode 100644 index 0000000..d3eb355 --- /dev/null +++ b/docs/N8N_FLIGHTS_PDF_BASE64_COMPLETE.js @@ -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 из ответа +// ============================================================================ diff --git a/docs/N8N_FLIGHTS_PDF_BASE64_FULL.js b/docs/N8N_FLIGHTS_PDF_BASE64_FULL.js new file mode 100644 index 0000000..479813d --- /dev/null +++ b/docs/N8N_FLIGHTS_PDF_BASE64_FULL.js @@ -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)); diff --git a/docs/N8N_FLIGHTS_PREPARE_REQUEST_DATA.js b/docs/N8N_FLIGHTS_PREPARE_REQUEST_DATA.js new file mode 100644 index 0000000..b0f3101 --- /dev/null +++ b/docs/N8N_FLIGHTS_PREPARE_REQUEST_DATA.js @@ -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 + } +}]; diff --git a/docs/N8N_FLIGHTS_PROCESSING_GUIDE.md b/docs/N8N_FLIGHTS_PROCESSING_GUIDE.md new file mode 100644 index 0000000..aac845a --- /dev/null +++ b/docs/N8N_FLIGHTS_PROCESSING_GUIDE.md @@ -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; +}); +``` diff --git a/docs/N8N_FLIGHTS_QUICK_START.md b/docs/N8N_FLIGHTS_QUICK_START.md new file mode 100644 index 0000000..9e954e8 --- /dev/null +++ b/docs/N8N_FLIGHTS_QUICK_START.md @@ -0,0 +1,236 @@ +# Быстрый старт: HTML → Base64 PDF в n8n + +## Проблема +У вас есть HTML в формате: +```json +{ + "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] +``` + +Всё готово! 🎉 diff --git a/docs/N8N_FLIGHTS_REQUESTED_INFO.md b/docs/N8N_FLIGHTS_REQUESTED_INFO.md new file mode 100644 index 0000000..571babd --- /dev/null +++ b/docs/N8N_FLIGHTS_REQUESTED_INFO.md @@ -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 - там будут сообщения о найденных запрошенных рейсах diff --git a/docs/N8N_FLIGHTS_SIMPLE_BINARY.js b/docs/N8N_FLIGHTS_SIMPLE_BINARY.js new file mode 100644 index 0000000..ec96374 --- /dev/null +++ b/docs/N8N_FLIGHTS_SIMPLE_BINARY.js @@ -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 = '

Ошибка: данные не получены

'; + 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 = ` +
+
+

Рейс ${f.flightNumber || f.ident || '—'}

+ ${f.registration} +
+
+
+ Тип самолёта: + ${f.aircraftType || '—'} +
+
+ Идентификатор: + ${f.ident || '—'} (${f.identIata || '—'}) +
+
`; + + if (fa) { + card += ` +
+
+ FlightAware +
+
+
+
+ Откуда: + ${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')}) +
+
+ Куда: + ${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')}) +
+
+
+
+ Вылет: + ${safeDate(fa.actual_out)} +
+
+ Прилёт: + ${safeDate(fa.actual_in)} +
+
+ Статус: + ${safeStr(fa.status || '—')} +
+
+
+
`; + } else { + card += ` +
+
+ FlightAware + Данные не получены +
+
`; + } + + if (fr) { + card += ` +
+
+ FlightRadar24 +
+
+
+
+ Откуда: + ${safeStr(fr.orig_iata || '—')} (${safeStr(fr.orig_icao || '—')}) +
+
+ Куда: + ${safeStr(fr.dest_iata || '—')} (${safeStr(fr.dest_icao || '—')}) +
+
+
+
+ Время полёта: + ${formatDuration(fr.flight_time)} +
+
+ Расстояние: + ${formatDistance(fr.actual_distance)} +
+
+
+
`; + } else { + card += ` +
+
+ FlightRadar24 + Данные не получены +
+
`; + } + + card += `
`; + 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 = ` + + + + + Отчёт о рейсах + + + +
+
+

Отчёт о рейсах

+
+
Дата формирования: ${reportDate}
+
+ + FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'} + + + FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'} + +
+
+
+
+ ${flights.length ? flights.map(generateFlightCard).join('') : '
Данные о рейсах не найдены
'} +
+
+ +`; + +// ================== ПОДГОТОВКА ДАННЫХ ДЛЯ 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 из ответа +// ============================================================================ diff --git a/docs/N8N_FLIGHTS_TO_BASE64.js b/docs/N8N_FLIGHTS_TO_BASE64.js new file mode 100644 index 0000000..93476ba --- /dev/null +++ b/docs/N8N_FLIGHTS_TO_BASE64.js @@ -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 = '

Ошибка: данные не получены

'; + 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 += `
Дата вылета (запрос):${requestDepartureDate}
`; + } + if (requestArrivalDate) { + requestInfo += `
Дата прилёта (запрос):${requestArrivalDate}
`; + } + } + + return ` +
+
+

Рейс ${f.flightNumber || f.ident || '—'}

+ Запрошен +
+
+
+ Запрошенный рейс: + ${f.flightNumber || f.ident || '—'} +
+ ${requestInfo} +
+
+
+ FlightAware + Данные не получены +
+
+
+ По запросу рейса ${f.flightNumber || f.ident || '—'}${requestDepartureDate ? ` на ${requestDepartureDate}` : ''} данные не найдены. +
+
+
+
+
+ FlightRadar24 + Данные не получены +
+
+
+ По запросу рейса ${f.flightNumber || f.ident || '—'}${requestDepartureDate ? ` на ${requestDepartureDate}` : ''} данные не найдены. +
+
+
+
`; + } + + let card = ` +
+
+

Рейс ${f.flightNumber || f.ident || '—'}

+ ${f.registration || '—'} +
+
+
+ Тип самолёта: + ${f.aircraftType || '—'} +
+
+ Идентификатор: + ${f.ident || '—'} (${f.identIata || '—'}) +
+
`; + + // Данные из FlightAware + if (fa) { + card += ` +
+
+ FlightAware +
+
+
+
+ Откуда: + ${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')}) +
+
+ Куда: + ${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')}) +
+
+
+
+ Плановый вылет: + ${safeDate(fa.scheduled_out)} +
+
+ Фактический вылет: + ${safeDate(fa.actual_out)} +
+
+ Взлёт: + ${safeDate(fa.actual_off)} ${fa.actual_runway_off ? `(ВПП ${fa.actual_runway_off})` : ''} +
+
+ Посадка: + ${safeDate(fa.actual_on)} ${fa.actual_runway_on ? `(ВПП ${fa.actual_runway_on})` : ''} +
+
+ Фактический прилёт: + ${safeDate(fa.actual_in)} +
+
+
+
+ Статус: + ${safeStr(fa.status || '—')} +
+ ${fa.departure_delay !== null && fa.departure_delay !== undefined ? ` +
+ Задержка вылета: + ${fa.departure_delay > 0 ? '+' : ''}${Math.floor(fa.departure_delay / 60)} мин +
+ ` : ''} + ${fa.arrival_delay !== null && fa.arrival_delay !== undefined ? ` +
+ Задержка прилёта: + ${fa.arrival_delay > 0 ? '+' : ''}${Math.floor(fa.arrival_delay / 60)} мин +
+ ` : ''} + ${fa.gate_origin ? ` +
+ Гейт вылета: + ${fa.gate_origin} +
+ ` : ''} + ${fa.gate_destination ? ` +
+ Гейт прилёта: + ${fa.gate_destination} +
+ ` : ''} + ${fa.baggage_claim ? ` +
+ Выдача багажа: + ${fa.baggage_claim} +
+ ` : ''} +
+
+
`; + } else { + card += ` +
+
+ FlightAware + Данные не получены +
+
`; + } + + // Данные из FlightRadar24 + if (fr) { + card += ` +
+
+ FlightRadar24 +
+
+
+
+ Откуда: + ${safeStr(fr.orig_iata || '—')} (${safeStr(fr.orig_icao || '—')}) +
+
+ Куда: + ${safeStr(fr.dest_iata || '—')} (${safeStr(fr.dest_icao || '—')}) +
+
+
+
+ Взлёт: + ${safeDate(fr.datetime_takeoff)} ${fr.runway_takeoff ? `(ВПП ${fr.runway_takeoff})` : ''} +
+
+ Посадка: + ${safeDate(fr.datetime_landed)} ${fr.runway_landed ? `(ВПП ${fr.runway_landed})` : ''} +
+
+
+
+ Время полёта: + ${formatDuration(fr.flight_time)} +
+
+ Фактическое расстояние: + ${formatDistance(fr.actual_distance)} +
+
+ Кратчайшее расстояние: + ${formatDistance(fr.circle_distance)} +
+
+ Статус полёта: + ${fr.flight_ended ? 'Завершён' : 'В процессе'} +
+
+
+
`; + } else { + card += ` +
+
+ FlightRadar24 + Данные не получены +
+
`; + } + + card += `
`; + 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 = ` + + + + + Отчёт о рейсах + + + +
+
+

Отчёт о рейсах

+
+
Дата формирования: ${reportDate}
+
+ + FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'} + + + FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'} + +
+
+
+
+ ${flights.length ? flights.map(f => generateFlightCard(f, flightRadar24Error)).join('') : '
Данные о рейсах не найдены
'} +
+
+ +`; + +// ================== 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() + } +}]; diff --git a/docs/N8N_FLIGHTS_WORKFLOW_EXAMPLE.md b/docs/N8N_FLIGHTS_WORKFLOW_EXAMPLE.md new file mode 100644 index 0000000..4ad58a4 --- /dev/null +++ b/docs/N8N_FLIGHTS_WORKFLOW_EXAMPLE.md @@ -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": "...", + "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 для ручной конвертации. diff --git a/docs/N8N_FLIGHTS_WORKING_SOLUTION.md b/docs/N8N_FLIGHTS_WORKING_SOLUTION.md new file mode 100644 index 0000000..9e59b0c --- /dev/null +++ b/docs/N8N_FLIGHTS_WORKING_SOLUTION.md @@ -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": "...", + "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 отчёты о рейсах! diff --git a/docs/N8N_FLIGHTS_WORKING_WORKFLOW.json b/docs/N8N_FLIGHTS_WORKING_WORKFLOW.json new file mode 100644 index 0000000..6815c4c --- /dev/null +++ b/docs/N8N_FLIGHTS_WORKING_WORKFLOW.json @@ -0,0 +1,53 @@ +{ + "nodes": [ + { + "parameters": { + "jsCode": "// ============================================================================\n// n8n Code Node: Обработка данных о рейсах → Base64 HTML\n// ============================================================================\n// Вход: [{ data: [{ body: { flights: [...] }}, { body: { data: [...] }}] }]\n// Выход: base64 HTML\n// ============================================================================\n\nconst inputItems = $input.all();\n\n// ================== FALLBACK ==================\nif (!inputItems || inputItems.length === 0) {\n const html = '

Ошибка: данные не получены

';\n const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');\n \n return [{\n json: {\n html_base64: htmlBase64,\n html: html,\n flights_count: 0,\n error: 'Нет входных данных'\n }\n }];\n}\n\n// ================== ИЗВЛЕЧЕНИЕ ДАННЫХ ==================\n// Структура: [{ data: [{ body: { flights: [...] }}, { body: { data: [...] }}] }]\nlet flightAwareData = [];\nlet flightRadar24Data = [];\n\ntry {\n const firstItem = inputItems[0];\n if (firstItem && firstItem.json && firstItem.json.data && Array.isArray(firstItem.json.data)) {\n // Первый элемент массива data - FlightAware\n if (firstItem.json.data[0] && firstItem.json.data[0].body && firstItem.json.data[0].body.flights) {\n flightAwareData = Array.isArray(firstItem.json.data[0].body.flights) \n ? firstItem.json.data[0].body.flights \n : [];\n }\n \n // Второй элемент массива data - FlightRadar24\n if (firstItem.json.data[1] && firstItem.json.data[1].body && firstItem.json.data[1].body.data) {\n flightRadar24Data = Array.isArray(firstItem.json.data[1].body.data) \n ? firstItem.json.data[1].body.data \n : [];\n }\n }\n} catch (e) {\n console.log('⚠️ Ошибка извлечения данных:', e.message);\n}\n\n// ================== УТИЛИТЫ ==================\nconst safeStr = v => (v == null ? '' : String(v));\nconst safeDate = v => {\n if (!v) return '—';\n try {\n const d = new Date(v);\n return isNaN(d.getTime()) ? '—' : d.toLocaleString('ru-RU', {\n timeZone: 'UTC',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit'\n });\n } catch {\n return '—';\n }\n};\n\nconst formatDuration = s => !s ? '—' : `${Math.floor(s / 3600)}ч ${Math.floor((s % 3600) / 60)}м`;\nconst formatDistance = km => !km ? '—' : `${Number(km).toFixed(2)} км`;\n\n// ================== MERGE ПО REGISTRATION ==================\nconst flightsMap = new Map();\n\n// Добавляем данные из FlightAware\nflightAwareData.forEach(f => {\n const reg = safeStr(f.registration).trim();\n if (!reg) return;\n if (!flightsMap.has(reg)) {\n flightsMap.set(reg, {\n registration: reg,\n flightNumber: safeStr(f.flight_number),\n ident: safeStr(f.ident),\n identIata: safeStr(f.ident_iata),\n aircraftType: safeStr(f.aircraft_type),\n fa: f,\n fr: null\n });\n } else {\n flightsMap.get(reg).fa = f;\n }\n});\n\n// Добавляем данные из FlightRadar24\nflightRadar24Data.forEach(f => {\n const reg = safeStr(f.reg).trim();\n if (!reg) return;\n if (!flightsMap.has(reg)) {\n flightsMap.set(reg, {\n registration: reg,\n flightNumber: safeStr(f.flight),\n ident: safeStr(f.callsign),\n identIata: safeStr(f.flight),\n aircraftType: safeStr(f.type),\n fa: null,\n fr: f\n });\n } else {\n flightsMap.get(reg).fr = f;\n }\n});\n\nconst flights = Array.from(flightsMap.values());\n\n// ================== HTML GENERATION ==================\nconst generateFlightCard = f => {\n const fa = f.fa;\n const fr = f.fr;\n \n let card = `\n
\n
\n

Рейс ${f.flightNumber || f.ident || '—'}

\n ${f.registration}\n
\n
\n
\n Тип самолёта:\n ${f.aircraftType || '—'}\n
\n
\n Идентификатор:\n ${f.ident || '—'} (${f.identIata || '—'})\n
\n
`;\n\n // Данные из FlightAware\n if (fa) {\n card += `\n
\n
\n FlightAware\n
\n
\n
\n
\n Откуда:\n ${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')})\n
\n
\n Куда:\n ${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')})\n
\n
\n
\n
\n Плановый вылет:\n ${safeDate(fa.scheduled_out)}\n
\n
\n Фактический вылет:\n ${safeDate(fa.actual_out)}\n
\n
\n Взлёт:\n ${safeDate(fa.actual_off)} ${fa.actual_runway_off ? `(ВПП ${fa.actual_runway_off})` : ''}\n
\n
\n Посадка:\n ${safeDate(fa.actual_on)} ${fa.actual_runway_on ? `(ВПП ${fa.actual_runway_on})` : ''}\n
\n
\n Фактический прилёт:\n ${safeDate(fa.actual_in)}\n
\n
\n
\n
\n Статус:\n ${safeStr(fa.status || '—')}\n
\n ${fa.departure_delay !== null && fa.departure_delay !== undefined ? `\n
\n Задержка вылета:\n ${fa.departure_delay > 0 ? '+' : ''}${Math.floor(fa.departure_delay / 60)} мин\n
\n ` : ''}\n ${fa.arrival_delay !== null && fa.arrival_delay !== undefined ? `\n
\n Задержка прилёта:\n ${fa.arrival_delay > 0 ? '+' : ''}${Math.floor(fa.arrival_delay / 60)} мин\n
\n ` : ''}\n ${fa.gate_origin ? `\n
\n Гейт вылета:\n ${fa.gate_origin}\n
\n ` : ''}\n ${fa.gate_destination ? `\n
\n Гейт прилёта:\n ${fa.gate_destination}\n
\n ` : ''}\n ${fa.baggage_claim ? `\n
\n Выдача багажа:\n ${fa.baggage_claim}\n
\n ` : ''}\n
\n
\n
`;\n } else {\n card += `\n
\n
\n FlightAware\n Данные не получены\n
\n
`;\n }\n\n // Данные из FlightRadar24\n if (fr) {\n card += `\n
\n
\n FlightRadar24\n
\n
\n
\n
\n Откуда:\n ${safeStr(fr.orig_iata || '—')} (${safeStr(fr.orig_icao || '—')})\n
\n
\n Куда:\n ${safeStr(fr.dest_iata || '—')} (${safeStr(fr.dest_icao || '—')})\n
\n
\n
\n
\n Взлёт:\n ${safeDate(fr.datetime_takeoff)} ${fr.runway_takeoff ? `(ВПП ${fr.runway_takeoff})` : ''}\n
\n
\n Посадка:\n ${safeDate(fr.datetime_landed)} ${fr.runway_landed ? `(ВПП ${fr.runway_landed})` : ''}\n
\n
\n
\n
\n Время полёта:\n ${formatDuration(fr.flight_time)}\n
\n
\n Фактическое расстояние:\n ${formatDistance(fr.actual_distance)}\n
\n
\n Кратчайшее расстояние:\n ${formatDistance(fr.circle_distance)}\n
\n
\n Статус полёта:\n ${fr.flight_ended ? 'Завершён' : 'В процессе'}\n
\n
\n
\n
`;\n } else {\n card += `\n
\n
\n FlightRadar24\n Данные не получены\n
\n
`;\n }\n\n card += `
`;\n return card;\n};\n\n// Генерация полного HTML\nconst now = new Date();\nconst reportDate = now.toLocaleString('ru-RU', {\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: '2-digit',\n minute: '2-digit'\n});\n\nconst html = `\n\n\n \n \n Отчёт о рейсах\n \n\n\n
\n
\n

Отчёт о рейсах

\n
\n
Дата формирования: ${reportDate}
\n
\n 0 ? 'available' : 'unavailable'}\">\n FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}\n \n 0 ? 'available' : 'unavailable'}\">\n FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}\n \n
\n
\n
\n
\n ${flights.length ? flights.map(generateFlightCard).join('') : '
Данные о рейсах не найдены
'}\n
\n
\n\n`;\n\n// ================== HTML → BASE64 ==================\nconst htmlBase64 = Buffer.from(html, 'utf8').toString('base64');\n\n// ================== RETURN ==================\nreturn [{\n json: {\n html_base64: htmlBase64,\n html: html,\n flights_count: flights.length,\n sources: {\n flightaware: { available: flightAwareData.length > 0, count: flightAwareData.length },\n flightradar24: { available: flightRadar24Data.length > 0, count: flightRadar24Data.length }\n },\n generated_at: now.toISOString()\n }\n}];\n" + }, + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 384, + 128 + ], + "id": "a44a16f4-0ae7-4947-8f2b-3d70a6e6cfe0", + "name": "причесываем данные" + }, + { + "parameters": { + "method": "POST", + "url": "http://147.45.146.17:3000/pdf?token=9ahhnpjkchxtcho9", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"url\": \"data:text/html;base64, {{ $json.html_base64 }}\",\n \"options\": {\n \"format\": \"A4\",\n \"printBackground\": true,\n \"margin\": {\n \"top\": \"20mm\",\n \"right\": \"15mm\",\n \"bottom\": \"20mm\",\n \"left\": \"20mm\"\n }\n }\n}", + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.3, + "position": [ + 624, + 128 + ], + "id": "fbbf533a-b3b8-4aba-8a38-106545a5a9e2", + "name": "HTTP Request: Browserless PDF" + } + ], + "connections": { + "причесываем данные": { + "main": [ + [ + { + "node": "HTTP Request: Browserless PDF", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": {}, + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "ad5e78bf27056f72474a633d21d938f3223861ac866f2ebe4e46b867e404f489" + } +} diff --git a/docs/N8N_HTML_TO_BASE64_PDF_SIMPLE.js b/docs/N8N_HTML_TO_BASE64_PDF_SIMPLE.js new file mode 100644 index 0000000..3a66bf8 --- /dev/null +++ b/docs/N8N_HTML_TO_BASE64_PDF_SIMPLE.js @@ -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 из ответа +// ============================================================================ diff --git a/docs/N8N_PARSE_INIT_DATA.js b/docs/N8N_PARSE_INIT_DATA.js new file mode 100644 index 0000000..7e8e346 --- /dev/null +++ b/docs/N8N_PARSE_INIT_DATA.js @@ -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, + }, +}]; diff --git a/docs/N8N_WORKFLOW_STUCK_FIX.md b/docs/N8N_WORKFLOW_STUCK_FIX.md new file mode 100644 index 0000000..9171b60 --- /dev/null +++ b/docs/N8N_WORKFLOW_STUCK_FIX.md @@ -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 как системный сервис +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 diff --git a/docs/TELEGRAM_MINIAPP_FLOW.md b/docs/TELEGRAM_MINIAPP_FLOW.md new file mode 100644 index 0000000..305c6d0 --- /dev/null +++ b/docs/TELEGRAM_MINIAPP_FLOW.md @@ -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. diff --git a/frontend/index.html b/frontend/index.html index 9ba69bc..0002d09 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,7 @@ Clientright — защита прав потребителей +
diff --git a/frontend/public/index.html b/frontend/public/index.html index 9ba69bc..833d70b 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -5,6 +5,7 @@ Clientright — защита прав потребителей +
diff --git a/frontend/src/components/form/Step1Phone.tsx b/frontend/src/components/form/Step1Phone.tsx index 3a44345..3991a01 100644 --- a/frontend/src/components/form/Step1Phone.tsx +++ b/frontend/src/components/form/Step1Phone.tsx @@ -52,8 +52,9 @@ export default function Step1Phone({ setCodeSent(true); updateFormData({ phone }); - // 🔧 DEV MODE: показываем debug код в модалке - if (result.debug_code) { + // 🔧 DEV MODE: показываем debug код в модалке (только в development) + // В production debug_code не приходит с сервера, поэтому модалка не покажется + if (result.debug_code && import.meta.env.MODE === 'development') { setDebugCode(result.debug_code); setShowDebugModal(true); } @@ -341,7 +342,8 @@ export default function Step1Phone({ )} - {/* 🔧 DEV MODE: Модалка с SMS кодом */} + {/* 🔧 DEV MODE: Модалка с SMS кодом (только в development) */} + {import.meta.env.MODE === 'development' && ( + )} ); } diff --git a/frontend/src/components/form/Step3Payment.tsx b/frontend/src/components/form/Step3Payment.tsx index 43fb4b0..676482b 100644 --- a/frontend/src/components/form/Step3Payment.tsx +++ b/frontend/src/components/form/Step3Payment.tsx @@ -3,7 +3,8 @@ import { Form, Input, Button, AutoComplete, message, Space, Divider } from 'antd import { PhoneOutlined, SafetyOutlined, QrcodeOutlined, MailOutlined, CopyOutlined } from '@ant-design/icons'; 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 { bankid: string; @@ -51,10 +52,16 @@ export default function Step3Payment({ throw new Error(`HTTP ${response.status}`); } + // Наш API возвращает формат: [{"bankId":"...","bankName":"..."}] let banksData: Bank[] = await response.json(); - // ✅ Фильтруем банки без названия - banksData = banksData.filter(bank => bank && bank.bankname && typeof bank.bankname === 'string'); + // Преобразуем формат нашего API в наш внутренний формат + 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) => { diff --git a/frontend/src/components/form/StepClaimConfirmation.tsx b/frontend/src/components/form/StepClaimConfirmation.tsx index 428f8cf..879a468 100644 --- a/frontend/src/components/form/StepClaimConfirmation.tsx +++ b/frontend/src/components/form/StepClaimConfirmation.tsx @@ -98,7 +98,8 @@ export default function StepClaimConfirmation({ false; // Генерируем 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); setLoading(false); }, [claimPlanData]); diff --git a/frontend/src/components/form/StepDraftSelection.tsx b/frontend/src/components/form/StepDraftSelection.tsx index a805c12..c3e6828 100644 --- a/frontend/src/components/form/StepDraftSelection.tsx +++ b/frontend/src/components/form/StepDraftSelection.tsx @@ -100,6 +100,7 @@ interface Props { phone?: string; session_id?: string; unified_id?: string; + isTelegramMiniApp?: boolean; // ✅ Флаг Telegram Mini App onSelectDraft: (claimId: string) => void; onNewClaim: () => void; onRestartDraft?: (claimId: string, description: string) => void; // Для legacy черновиков @@ -175,6 +176,7 @@ export default function StepDraftSelection({ phone, session_id, unified_id, + isTelegramMiniApp, onSelectDraft, onNewClaim, onRestartDraft, @@ -211,7 +213,7 @@ export default function StepDraftSelection({ console.log('🔍 StepDraftSelection: ответ API:', data); // Определяем legacy черновики (без documents_required в payload) - const processedDrafts = (data.drafts || []).map((draft: Draft) => { + let processedDrafts = (data.drafts || []).map((draft: Draft) => { // Legacy только если: // 1. Статус 'draft' (старый формат) ИЛИ // 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); } catch (error) { console.error('Ошибка загрузки черновиков:', error); @@ -291,6 +299,27 @@ export default function StepDraftSelection({ // Кнопка действия const getActionButton = (draft: Draft) => { + // Для заявок "В работе" + if (draft.status_code === 'in_work') { + // ✅ В веб-версии показываем кнопку "Просмотреть в Telegram" + if (!isTelegramMiniApp) { + return ( + + ); + } + // ✅ В Telegram Mini App не показываем (но этот код не выполнится, т.к. заявки отфильтрованы) + return null; + } + const config = getStatusConfig(draft); return ( @@ -521,7 +550,7 @@ export default function StepDraftSelection({ {/* Кнопки действий */} -
{getActionButton(draft)} - handleDelete(draft.claim_id || draft.id)} - okText="Да, удалить" - cancelText="Отмена" - > - - + {/* Скрываем кнопку "Удалить" для заявок "В работе" */} + {draft.status_code !== 'in_work' && ( + handleDelete(draft.claim_id || draft.id)} + okText="Да, удалить" + cancelText="Отмена" + > + + + )}
} diff --git a/frontend/src/components/form/StepWizardPlan.tsx b/frontend/src/components/form/StepWizardPlan.tsx index ead065c..1123fa8 100644 --- a/frontend/src/components/form/StepWizardPlan.tsx +++ b/frontend/src/components/form/StepWizardPlan.tsx @@ -50,6 +50,7 @@ interface Props { updateFormData: (data: any) => void; onNext: () => void; onPrev: () => void; + backToDraftsList?: () => void; // ✅ Возврат к списку черновиков напрямую addDebugEvent?: (type: string, status: string, message: string, data?: any) => void; } @@ -110,6 +111,7 @@ export default function StepWizardPlan({ updateFormData, onNext, onPrev, + backToDraftsList, addDebugEvent, }: Props) { 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({ {/* Кнопки */} - +