Compare commits
20 Commits
1a653f2154
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b9665b27f | ||
|
|
e630d03e67 | ||
|
|
66a0065df8 | ||
|
|
c39b12630e | ||
|
|
b5c31b43dd | ||
|
|
f2e144e9ca | ||
|
|
06b89d20e7 | ||
|
|
9c65b6a4ea | ||
|
|
62fc57f108 | ||
|
|
b3a7396d32 | ||
|
|
d8fe0b605b | ||
|
|
6350f9015b | ||
|
|
4536210284 | ||
|
|
1887336aba | ||
|
|
8c3e993eb7 | ||
|
|
a4cc4f9de6 | ||
|
|
2e45786e46 | ||
|
|
73524465fd | ||
|
|
f7d27388a0 | ||
|
|
56516fdd7d |
60
CHANGELOG_MINIAPP.md
Normal file
60
CHANGELOG_MINIAPP.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Доработки мини-приложения Clientright (TG/MAX и веб)
|
||||
|
||||
## Консультации, CRM, кнопка «Назад» (2026-02-25)
|
||||
|
||||
### Консультации
|
||||
- **Страница «Консультации»**: список тикетов из тех же данных, что и «Мои обращения» (общий контекст `DraftsContext`), без отдельного эндпоинта списка.
|
||||
- По клику на тикет — запрос `POST /api/v1/consultations/ticket-detail` (session + `ticket_id`), вызов вебхука «подробнее» (`n8n_ticket_form_podrobnee_webhook`), ответ показывается карточкой с полями: заголовок, статус, категория, описание, решение, приоритет (русские подписи).
|
||||
- Убраны подпись «Тикеты из CRM» и кнопка «Назад к списку» — возврат только через кнопку «Назад» в баре.
|
||||
- **Кнопка «Назад» в баре на консультациях**: подписка на `miniapp:goBack` в `Consultations.tsx` — в детали тикета возврат к списку, со списка переход на «Мои обращения» (`onNavigate('/')`).
|
||||
|
||||
### Поддержка
|
||||
- **Кнопка «Назад» в баре в чате поддержки**: на маршруте `/support` кнопка «Назад» больше не отключается (`BottomBar.tsx` — убран `isSupport` из условия отключения).
|
||||
- В `Support.tsx` добавлена подписка на `miniapp:goBack`: в режиме чата — возврат к списку обращений, в списке — переход на «Мои обращения» (`onNavigate('/')`).
|
||||
|
||||
### CRM и дашборд
|
||||
- **n8n**: Code-нода нормализации ответа CRM — из `projects_json` и `tickets_json` формируется массив элементов с полями для фронта (`type_code`, `payload`, `status_code`). Файл `docs/n8n_CODE_CRM_NORMALIZE.js`, на выходе объект с полем `crm_items`.
|
||||
- **n8n**: разворот ответа в плоский список — `docs/n8n_CODE_FLATTEN_DATA.js` разворачивает элементы вида `{ crm_items: [...] }` в плоский массив в `data`.
|
||||
- **Дашборд по категориям**: при клике по плиткам («В работе», «Решены» и т.д.) список фильтруется в том числе для элементов из CRM: добавлены `isFromCrm()`, `getItemCategory()` по `status_code`/payload, расширен `STATUS_CONFIG` для `active`/`completed`/`rejected`.
|
||||
- **Карточка обращения**: у контейнера и Card заданы `width: '100%'` и `boxSizing: 'border-box'` в `StepDraftSelection.tsx`.
|
||||
|
||||
### Бэкенд
|
||||
- В `config.py` — переменная `n8n_ticket_form_podrobnee_webhook` для вебхука «подробнее» по тикету.
|
||||
- В `config.py` — переменная **`n8n_project_form_podrobnee_webhook`** (`N8N_PROJECT_FORM_PODROBNEE_WEBHOOK` в .env) для вебхука «подробнее» по делу/проекту из CRM (по project_id). Эндпоинт, вызывающий этот хук, пока не добавлен — зарезервировано под будущий case-detail.
|
||||
- Модуль `backend/app/api/consultations.py`: эндпоинт `POST /api/v1/consultations/ticket-detail` (session + `ticket_id`), вызов вебхука, ответ как есть.
|
||||
|
||||
---
|
||||
|
||||
## Системные баннеры на экране приветствия (2026-02)
|
||||
- **Баннер «Профиль не заполнен»** вынесен в отдельную зону справа от текста «Теперь ты в системе — можно продолжать» (на десктопе — колонка ~260px), чтобы не занимал полстраницы и не сдвигал контент.
|
||||
- Реализовано **единое место для системных баннеров**: массив `systemBanners`, при одном баннере показывается один Alert, при нескольких — карусель (Ant Design Carousel). В будущем сюда можно добавлять другие критические уведомления.
|
||||
- **Мобильная вёрстка**: баннер на всю ширину, нормальный перенос текста (без разбиения по слогам), кнопка «Заполнить профиль» переносится под текст, крестик закрытия остаётся в первой строке справа (через `order` и `flex-wrap`).
|
||||
- **Профиль**: убрана дублирующая ссылка «Домой» из шапки карточки профиля — навигация остаётся через нижний бар.
|
||||
|
||||
## UI и навигация
|
||||
- **«Мои обращения»**: дашборд с плитками по статусам (На рассмотрении, В работе, Решённые, Отклонённые, Все), заголовок переименован с «Жалобы потребителей».
|
||||
- Убрана внешняя рамка у дашборда; карточки с hover-эффектом (подъём, тень), единая высота плиток, прозрачный фон у иконок.
|
||||
- Список обращений по категориям в виде карточек с hover; фильтр по выбранной категории.
|
||||
- Кнопка **«Назад»** перенесена в нижний бар; убраны дублирующие кнопки «Назад» из контента (описание, документы).
|
||||
|
||||
## Telegram и MAX
|
||||
- **Выход**: корректное закрытие приложения — в TG вызывается `Telegram.WebApp.close()`, в MAX — `window.WebApp.close()` / `postEvent('web_app_close')`. Определение платформы по initData/URL.
|
||||
- Подключение скриптов по платформе: при наличии `tgWebAppData`/`tgWebAppVersion` в URL грузится только `telegram-web-app.js`, иначе — только `max-web-app.js` (устранены ошибки UnsupportedEvent в MAX).
|
||||
- В TG/MAX **не показывается экран ввода телефона** — шаг «Вход» только для обычного веба; раннее определение платформы (опрос `WebApp.initData`), флаг `platformChecked` чтобы не мелькал телефон до определения.
|
||||
|
||||
## Сессия и авторизация
|
||||
- Сессию не сбрасывать при сетевых/временных ошибках `session/verify` — удалять `session_token` только при явном ответе `valid: false`.
|
||||
- При нажатии «Назад» с авторизованного пользователя не вести на шаг «Вход» — переход на дашборд «Мои обращения» или на `/hello`.
|
||||
- Переход на «Подать обращение» через роут `/new` и `pushState` для стабильного флоу без возврата на телефон.
|
||||
|
||||
## Исправления
|
||||
- **TDZ-ошибка** (пустой экран после перехода с /hello): `useEffect` для `miniapp:goBack` перенесён после объявления `prevStep` (useCallback).
|
||||
- Тостер **«Добро пожаловать!»** показывается только в вебе (не в TG/MAX), проверка по `Telegram.WebApp.initData` и `WebApp.initData`.
|
||||
|
||||
## Отладка и логи
|
||||
- Клиентский логгер `miniappLogger`: сбор событий, ошибок, отправка на `POST /api/v1/utils/client-log`; идентификация бандла (build/moduleUrl); очистка логов при смене сборки.
|
||||
- Бэкенд: приём логов в `main.py`, запись в `logs/cursor-debug-*.log` (NDJSON), без PII.
|
||||
|
||||
## Файлы
|
||||
- Новые: `StepComplaintsDashboard.tsx/.css`, `StepDraftSelection.css`, `miniappLogger.ts`.
|
||||
- Правки: `ClaimForm.tsx`, `HelloAuth.tsx`, `BottomBar.tsx`, `StepDescription.tsx`, `StepWizardPlan.tsx`, `StepDraftSelection.tsx`, `App.tsx`, `main.tsx`, `index.html`, `main.py`, `api/claims.py`, `ClaimForm.css`, `BottomBar.css`, `Dockerfile.prod`.
|
||||
28
CHANGELOG_PROFILE_VALIDATION.md
Normal file
28
CHANGELOG_PROFILE_VALIDATION.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Изменения: форма профиля, валидация, DaData, банки
|
||||
|
||||
## Backend
|
||||
|
||||
### auth_universal.py
|
||||
- Чтение N8N_AUTH_WEBHOOK: fallback на `os.environ.get("N8N_AUTH_WEBHOOK")`, если в config нет поля `n8n_auth_webhook` (чтобы webhook auth_miniapp вызывался при отсутствии config.py на хосте).
|
||||
|
||||
### banks.py
|
||||
- URL списка банков берётся из .env: `BANK_IP` (в config — `bank_ip`), fallback на `bank_api_url` и запасной URL. Прокси запроса к внешнему API для мини-аппа.
|
||||
|
||||
### profile.py
|
||||
- Новый эндпоинт `GET /api/v1/profile/dadata/address?query=...&count=10` — подсказки адресов через DaData API (ключи FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET в .env). Ответ: `{ "suggestions": [ { "value", "unrestricted_value" } ] }`.
|
||||
|
||||
### config.py
|
||||
- Добавлены поля: `bank_ip` (BANK_IP), `bank_api_url`; `forma_dadata_api_key`, `forma_dadata_secret` (FORMA_DADATA_*).
|
||||
|
||||
## Frontend (Profile.tsx)
|
||||
|
||||
- **Дата рождения:** календарь (DatePicker), формат DD.MM.YYYY, нельзя выбрать будущую дату.
|
||||
- **ИНН:** строго 12 цифр, валидация и ввод только цифр; подсказка «Узнать свой ИНН вы можете здесь» со ссылкой на сервис ФНС (service.nalog.ru).
|
||||
- **Email:** валидация формата (type: email).
|
||||
- **Адрес регистрации / Почтовый адрес:** чекбокс «Совпадает с адресом регистрации» — при включении почтовый подставляется и блокируется; оба поля — AutoComplete с подсказками из DaData (запрос к /api/v1/profile/dadata/address).
|
||||
- **Банк для возмещения:** выпадающий список (Select) с поиском, данные с /api/v1/banks/nspk (API из BANK_IP); учтён формат ответа с полями bankId, bankName (camelCase).
|
||||
|
||||
## .env
|
||||
|
||||
- BANK_IP — URL API списка банков (например http://212.193.27.93/api/payouts/dictionaries/nspk-banks).
|
||||
- FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET — ключи DaData для подсказок адресов.
|
||||
18
CHANGES_AUTH2_HELLO.md
Normal file
18
CHANGES_AUTH2_HELLO.md
Normal file
@@ -0,0 +1,18 @@
|
||||
## Что изменили
|
||||
|
||||
Добавлен **параллельный** (не ломающий текущий) флоу авторизации и приветственная страница:
|
||||
|
||||
- **Новый endpoint**: `POST /api/v1/auth2/login`
|
||||
- **platform=tg**: проверка `init_data` Telegram WebApp → вызов n8n webhook → создание сессии в Redis.
|
||||
- **platform=max**: проверка `init_data` MAX WebApp → вызов n8n webhook → создание сессии в Redis.
|
||||
- **platform=sms**: проверка SMS-кода → создание/поиск пользователя через n8n → создание сессии в Redis.
|
||||
- Ответ включает `greeting` и (для TG/MAX) `avatar_url`, чтобы можно было показать приветствие и аватар.
|
||||
|
||||
- **Новая страница**: `GET /hello`
|
||||
- После авторизации показывает “привет” и плитки в стиле **Soft UI / Modern SaaS** (Ant Design + Lucide outline icons).
|
||||
- Текущий основной UI/роуты/эндпоинты не менялись — это отдельная ветка для новой архитектуры.
|
||||
|
||||
## Зачем
|
||||
|
||||
Чтобы развивать новую архитектуру входа и “кабинет” **параллельно** со старым флоу, без риска что-то сломать.
|
||||
|
||||
5
COMMIT_MSG_PROFILE_EDIT.txt
Normal file
5
COMMIT_MSG_PROFILE_EDIT.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Profile: редактируемый профиль при verification="0", сохранение через N8N_PROFILE_UPDATE_WEBHOOK
|
||||
|
||||
- Backend: config.py — добавлена настройка n8n_profile_update_webhook (читает N8N_PROFILE_UPDATE_WEBHOOK из .env).
|
||||
- Backend: profile.py — общий хелпер _resolve_profile_identity(), обновлён _fetch_contact(), новый эндпоинт POST /api/v1/profile/contact/update, который отправляет данные профиля в N8N_PROFILE_UPDATE_WEBHOOK.
|
||||
- Frontend: Profile.tsx — если verification === "0", показывается форма редактирования (все поля, кроме телефона, обязательны к заполнению, телефон только для чтения) и сохранение вызывает /api/v1/profile/contact/update; иначе профиль остаётся только для просмотра.
|
||||
8
COMMIT_MSG_SESSION_DUPLICATE_EXTERNAL_REDIS.txt
Normal file
8
COMMIT_MSG_SESSION_DUPLICATE_EXTERNAL_REDIS.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
Session: дублировать сессии во внешний Redis для доступа из n8n
|
||||
|
||||
- backend/app/api/session.py: при записи сессии в локальный Redis (6383) теперь также дублируем те же ключи
|
||||
в внешний Redis (REDIS_HOST/REDIS_PORT) через redis_service.client.
|
||||
- Дублируются оба вида ключей:
|
||||
- session:{channel}:{channel_user_id}
|
||||
- session:{session_token}
|
||||
- Ошибки внешнего Redis не ломают авторизацию: при недоступности — warning в логах.
|
||||
4
COMMIT_MSG_SUPPORT_ROUTE.txt
Normal file
4
COMMIT_MSG_SUPPORT_ROUTE.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
Support: маршрут /support на страницу чата поддержки
|
||||
|
||||
- App.tsx: добавлен импорт страницы Support и роутинг pathname === '/support' на компонент Support.
|
||||
- При клике на иконку «Поддержка» в нижнем баре теперь открывается список обращений и чат, а не форма «Мои обращения».
|
||||
137
CURRENT_SETUP.md
Normal file
137
CURRENT_SETUP.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# 📍 Текущая структура запущенных окружений
|
||||
|
||||
**Дата проверки:** 2 января 2025
|
||||
|
||||
---
|
||||
|
||||
## 🟢 DEV окружение (запущено)
|
||||
|
||||
**Рабочая папка:**
|
||||
```
|
||||
/var/www/fastuser/data/www/crm.clientright.ru/aiform_dev/
|
||||
```
|
||||
|
||||
**Контейнеры:**
|
||||
- `aiform_frontend_dev` → порт **5177** → http://147.45.146.17:5177/
|
||||
- `aiform_backend_dev` → порт **8201**
|
||||
|
||||
**Docker Compose:**
|
||||
- Файл: `aiform_dev/docker-compose.dev.yml`
|
||||
- Запуск: `cd aiform_dev && docker-compose -f docker-compose.dev.yml up -d`
|
||||
|
||||
**Монтированные папки:**
|
||||
- Frontend: `aiform_dev/frontend/src` → `/app/src` (read-only, для live reload)
|
||||
- Backend: использует `aiform_dev/backend/.env`
|
||||
|
||||
**Git репозиторий:**
|
||||
- Remote: `aiform_dev` → http://147.45.146.17:3002/negodiy/aiform_dev.git
|
||||
|
||||
---
|
||||
|
||||
## 🔴 PROD окружение (запущено)
|
||||
|
||||
**Рабочая папка:**
|
||||
```
|
||||
/var/www/fastuser/data/www/crm.clientright.ru/ticket_form/
|
||||
```
|
||||
|
||||
**Контейнеры:**
|
||||
- `ticket_form_frontend_prod` → порт **5176** → https://aiform.clientright.ru/
|
||||
- `ticket_form_backend` → порт **8200** (network_mode: host)
|
||||
|
||||
**Docker Compose:**
|
||||
- Файл: `ticket_form/docker-compose.prod.yml` (новый) или старый `docker-compose.yml`
|
||||
- Запуск: `cd ticket_form && docker-compose -f docker-compose.prod.yml up -d`
|
||||
|
||||
**Git репозиторий:**
|
||||
- Remote: `aiform_prod` → http://147.45.146.17:3002/negodiy/aiform_prod.git
|
||||
- Remote: `origin` → http://147.45.146.17:3002/negodiy/erv-platform.git
|
||||
|
||||
---
|
||||
|
||||
## 📊 Сравнение
|
||||
|
||||
| | DEV | PROD |
|
||||
|---|---|---|
|
||||
| **Папка** | `/aiform_dev/` | `/ticket_form/` |
|
||||
| **Frontend порт** | 5177 | 5176 |
|
||||
| **Backend порт** | 8201 | 8200 |
|
||||
| **URL** | http://147.45.146.17:5177/ | https://aiform.clientright.ru/ |
|
||||
| **Docker Compose** | `aiform_dev/docker-compose.dev.yml` | `ticket_form/docker-compose.prod.yml` |
|
||||
| **Git** | `aiform_dev` | `aiform_prod` |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Как переносить изменения
|
||||
|
||||
### Из DEV в PROD:
|
||||
|
||||
```bash
|
||||
# 1. Работаете в DEV папке
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/aiform_dev
|
||||
# Вносите изменения, тестируете
|
||||
|
||||
# 2. Копируете изменения в PROD папку (или через git)
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
git pull aiform_prod main # или копируете файлы вручную
|
||||
|
||||
# 3. Перезапускаете PROD
|
||||
docker-compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
### Или через git (рекомендуется):
|
||||
|
||||
```bash
|
||||
# В DEV папке
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/aiform_dev
|
||||
git add .
|
||||
git commit -m "feat: Описание"
|
||||
git push aiform_dev main
|
||||
|
||||
# В PROD папке
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
git pull aiform_prod main
|
||||
docker-compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Важно
|
||||
|
||||
1. **DEV и PROD — это разные папки:**
|
||||
- DEV: `/aiform_dev/`
|
||||
- PROD: `/ticket_form/`
|
||||
|
||||
2. **Изменения в DEV не попадают в PROD автоматически** — нужно копировать/пушить через git
|
||||
|
||||
3. **У каждого окружения свой `.env` файл:**
|
||||
- DEV: `aiform_dev/backend/.env`
|
||||
- PROD: `ticket_form/.env`
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Полезные команды
|
||||
|
||||
```bash
|
||||
# Проверить статус DEV
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/aiform_dev
|
||||
docker-compose -f docker-compose.dev.yml ps
|
||||
|
||||
# Проверить статус PROD
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
|
||||
# Логи DEV
|
||||
docker logs aiform_frontend_dev -f
|
||||
docker logs aiform_backend_dev -f
|
||||
|
||||
# Логи PROD
|
||||
docker logs ticket_form_frontend_prod -f
|
||||
docker logs ticket_form_backend -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Автор:** AI Assistant + Фёдор
|
||||
**Дата:** 2 января 2025
|
||||
|
||||
203
DEPLOYMENT.md
Normal file
203
DEPLOYMENT.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# 🚀 Руководство по деплою: DEV → PROD
|
||||
|
||||
## 📍 Текущая структура
|
||||
|
||||
- **DEV:** http://147.45.146.17:5177/ (папка `aiform_dev/`)
|
||||
- **PROD:** https://aiform.clientright.ru/ (домен продакшна)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Быстрый перенос изменений (1 команда)
|
||||
|
||||
```bash
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
./deploy-to-prod.sh
|
||||
```
|
||||
|
||||
Этот скрипт:
|
||||
1. ✅ Проверит незакоммиченные изменения
|
||||
2. ✅ Отправит код в git репозитории (dev и prod)
|
||||
3. ✅ Пересоберёт PROD контейнеры
|
||||
4. ✅ Перезапустит PROD окружение
|
||||
|
||||
---
|
||||
|
||||
## 📝 Пошаговый процесс (вручную)
|
||||
|
||||
### Шаг 1: Сохранить изменения в git
|
||||
|
||||
```bash
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
|
||||
# Проверить что изменилось
|
||||
git status
|
||||
|
||||
# Добавить изменения
|
||||
git add .
|
||||
|
||||
# Закоммитить
|
||||
git commit -m "feat: Описание изменений"
|
||||
|
||||
# Отправить в dev репозиторий
|
||||
git push aiform_dev main # или master
|
||||
```
|
||||
|
||||
### Шаг 2: Отправить в prod репозиторий
|
||||
|
||||
```bash
|
||||
# Отправить в prod
|
||||
git push aiform_prod main # или master
|
||||
```
|
||||
|
||||
### Шаг 3: Обновить PROD контейнеры
|
||||
|
||||
```bash
|
||||
# Пересобрать
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
|
||||
# Перезапустить
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Про .env файл
|
||||
|
||||
### Почему один .env, а не два?
|
||||
|
||||
**✅ Преимущества одного .env:**
|
||||
- Проще поддерживать (один файл вместо двух)
|
||||
- Меньше путаницы
|
||||
- Режим переключается через переменную `APP_ENV` в docker-compose
|
||||
|
||||
**Как это работает:**
|
||||
|
||||
В `docker-compose.dev.yml`:
|
||||
```yaml
|
||||
environment:
|
||||
- APP_ENV=development # Переопределяет значение из .env
|
||||
- DEBUG=true
|
||||
```
|
||||
|
||||
В `docker-compose.prod.yml`:
|
||||
```yaml
|
||||
environment:
|
||||
- APP_ENV=production # Переопределяет значение из .env
|
||||
- DEBUG=false
|
||||
```
|
||||
|
||||
**Ваш `.env` файл остаётся один**, но docker-compose переопределяет нужные переменные для каждого окружения.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Структура репозиториев
|
||||
|
||||
```
|
||||
Gitea (http://147.45.146.17:3002/negodiy):
|
||||
├─ aiform_dev → DEV версия (http://147.45.146.17:5177/)
|
||||
├─ aiform_prod → PROD версия (https://aiform.clientright.ru/)
|
||||
└─ erv-platform → Основной репозиторий
|
||||
```
|
||||
|
||||
**Локальные папки:**
|
||||
- `/var/www/.../aiform_dev/` → DEV окружение
|
||||
- `/var/www/.../ticket_form/` → Основной проект (может быть и DEV и PROD)
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Типичный workflow
|
||||
|
||||
### 1. Разработка в DEV
|
||||
|
||||
```bash
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/aiform_dev
|
||||
# или
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
|
||||
# Вносите изменения
|
||||
# Тестируете на http://147.45.146.17:5177/
|
||||
```
|
||||
|
||||
### 2. Когда готово → деплой в PROD
|
||||
|
||||
```bash
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
|
||||
# Вариант 1: Автоматический (рекомендуется)
|
||||
./deploy-to-prod.sh
|
||||
|
||||
# Вариант 2: Вручную
|
||||
git add .
|
||||
git commit -m "feat: Описание"
|
||||
git push aiform_prod main
|
||||
docker-compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
### 3. Проверка PROD
|
||||
|
||||
```bash
|
||||
# Проверить статус
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
|
||||
# Проверить логи
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# Открыть в браузере
|
||||
# https://aiform.clientright.ru/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Важные моменты
|
||||
|
||||
1. **Всегда тестируйте в DEV перед деплоем в PROD**
|
||||
2. **Проверяйте `.env` файл** — убедитесь что там правильные настройки
|
||||
3. **В PROD `APP_ENV=production` и `DEBUG=false`** (устанавливается через docker-compose)
|
||||
4. **Не коммитьте `.env`** — он в `.gitignore`
|
||||
5. **После деплоя проверяйте логи** — `docker-compose -f docker-compose.prod.yml logs`
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Откат изменений (если что-то пошло не так)
|
||||
|
||||
```bash
|
||||
# Откатить к предыдущему коммиту
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
git log --oneline -5 # Найти нужный коммит
|
||||
git checkout <commit-hash>
|
||||
git push aiform_prod main --force
|
||||
|
||||
# Пересобрать
|
||||
docker-compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 Полезные команды
|
||||
|
||||
```bash
|
||||
# Статус контейнеров
|
||||
docker ps | grep aiform
|
||||
|
||||
# Логи DEV
|
||||
docker logs aiform_frontend_dev -f
|
||||
docker logs aiform_backend_dev -f
|
||||
|
||||
# Логи PROD
|
||||
docker logs ticket_form_frontend_prod -f
|
||||
docker logs ticket_form_backend_prod -f
|
||||
|
||||
# Перезапуск PROD
|
||||
docker-compose -f docker-compose.prod.yml restart
|
||||
|
||||
# Полная пересборка PROD
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker-compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Автор:** AI Assistant + Фёдор
|
||||
**Дата:** 2 января 2025
|
||||
|
||||
23
DOCKER-COMPOSE-README.md
Normal file
23
DOCKER-COMPOSE-README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Docker Compose в этом каталоге
|
||||
|
||||
**Для сайта miniapp.clientright.ru используется один compose — в корне репозитория:**
|
||||
|
||||
```
|
||||
/var/www/fastuser/data/www/miniapp.clientright.ru/docker-compose.yml
|
||||
```
|
||||
|
||||
Он поднимает: `miniapp_frontend` (5179), `miniapp_backend` (8205), `miniapp_redis` (6383).
|
||||
Запуск из корня: `docker compose up -d`.
|
||||
|
||||
---
|
||||
|
||||
Файлы в **aiform_prod/**:
|
||||
|
||||
| Файл | Назначение | Порты |
|
||||
|------|------------|--------|
|
||||
| `docker-compose.yml` | Старый стек (ticket_form_*), не для miniapp.clientright.ru | 5175, host |
|
||||
| `docker-compose.prod.yml` | Другой прод (miniapp_front/back на 4176), не для miniapp.clientright.ru | 4176 |
|
||||
| `docker-compose.dev.yml` | Дев aiform (aiform_frontend_dev, aiform_backend_dev) | 5177, 8201 |
|
||||
| `docker-compose.full.yml` | Полный стек ERV (postgres, redis, pgadmin и т.д.) | 8100, 5173, … |
|
||||
|
||||
Их можно не поднимать для работы miniapp.clientright.ru. Оставлены для истории/других окружений.
|
||||
264
ENVIRONMENTS.md
Normal file
264
ENVIRONMENTS.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# 🚀 Руководство по DEV и PROD окружениям
|
||||
|
||||
## 📋 Обзор
|
||||
|
||||
Проект поддерживает два отдельных окружения:
|
||||
- **DEV** (Development) — для разработки и тестирования
|
||||
- **PROD** (Production) — для продакшна
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Структура файлов
|
||||
|
||||
```
|
||||
ticket_form/
|
||||
├─ docker-compose.dev.yml ← Конфигурация для разработки
|
||||
├─ docker-compose.prod.yml ← Конфигурация для продакшна
|
||||
├─ .env.dev ← Переменные окружения для DEV
|
||||
├─ .env.prod ← Переменные окружения для PROD
|
||||
├─ .env.example ← Шаблон переменных окружения
|
||||
├─ start-dev.sh ← Скрипт запуска DEV
|
||||
├─ start-prod.sh ← Скрипт запуска PROD
|
||||
└─ ENVIRONMENTS.md ← Эта документация
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Быстрый старт
|
||||
|
||||
### 1. Первоначальная настройка
|
||||
|
||||
```bash
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
|
||||
# Создаём .env файлы из шаблона
|
||||
cp .env.example .env.dev
|
||||
cp .env.example .env.prod
|
||||
|
||||
# Редактируем .env.dev (для разработки)
|
||||
nano .env.dev
|
||||
# Установите: APP_ENV=development, DEBUG=true
|
||||
|
||||
# Редактируем .env.prod (для продакшна)
|
||||
nano .env.prod
|
||||
# Установите: APP_ENV=production, DEBUG=false
|
||||
# Проверьте все URL и API ключи
|
||||
```
|
||||
|
||||
### 2. Запуск DEV окружения
|
||||
|
||||
```bash
|
||||
# Вариант 1: Используя скрипт (рекомендуется)
|
||||
./start-dev.sh
|
||||
|
||||
# Вариант 2: Вручную
|
||||
docker-compose -f docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
**Доступ:**
|
||||
- Frontend: http://localhost:5175
|
||||
- Backend: http://localhost:8200
|
||||
- API Docs: http://localhost:8200/docs
|
||||
|
||||
### 3. Запуск PROD окружения
|
||||
|
||||
```bash
|
||||
# Вариант 1: Используя скрипт (рекомендуется)
|
||||
./start-prod.sh
|
||||
|
||||
# Вариант 2: Вручную
|
||||
docker-compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
**Доступ:**
|
||||
- Frontend: http://localhost:5176
|
||||
- Backend: http://localhost:8200
|
||||
- API Docs: http://localhost:8200/docs
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Различия между DEV и PROD
|
||||
|
||||
| Параметр | DEV | PROD |
|
||||
|----------|-----|------|
|
||||
| **Порты** | 5175 (frontend), 8200 (backend) | 5176 (frontend), 8200 (backend) |
|
||||
| **Контейнеры** | `*_dev` | `*_prod` |
|
||||
| **PostgreSQL** | Локальный контейнер (порт 5433) | Внешний (147.45.189.234:5432) |
|
||||
| **Redis** | Локальный контейнер (порт 6380) | Системный (localhost:6379) |
|
||||
| **Debug** | ✅ Включен | ❌ Выключен |
|
||||
| **Логи** | DEBUG уровень | INFO уровень |
|
||||
| **Hot Reload** | ✅ Включен | ❌ Выключен |
|
||||
| **Build** | Dev режим | Production оптимизация |
|
||||
| **Healthcheck** | ❌ Нет | ✅ Есть |
|
||||
|
||||
---
|
||||
|
||||
## 📝 Управление окружениями
|
||||
|
||||
### Остановка
|
||||
|
||||
```bash
|
||||
# Остановить DEV
|
||||
docker-compose -f docker-compose.dev.yml down
|
||||
|
||||
# Остановить PROD
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
```
|
||||
|
||||
### Просмотр логов
|
||||
|
||||
```bash
|
||||
# Логи DEV
|
||||
docker-compose -f docker-compose.dev.yml logs -f
|
||||
|
||||
# Логи PROD
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# Логи конкретного сервиса
|
||||
docker-compose -f docker-compose.dev.yml logs -f ticket_form_backend_dev
|
||||
```
|
||||
|
||||
### Перезапуск
|
||||
|
||||
```bash
|
||||
# Перезапуск DEV
|
||||
docker-compose -f docker-compose.dev.yml restart
|
||||
|
||||
# Перезапуск PROD
|
||||
docker-compose -f docker-compose.prod.yml restart
|
||||
```
|
||||
|
||||
### Пересборка
|
||||
|
||||
```bash
|
||||
# Пересборка DEV
|
||||
docker-compose -f docker-compose.dev.yml up -d --build
|
||||
|
||||
# Пересборка PROD
|
||||
docker-compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Переменные окружения
|
||||
|
||||
### Основные переменные
|
||||
|
||||
| Переменная | DEV значение | PROD значение |
|
||||
|------------|--------------|---------------|
|
||||
| `APP_ENV` | `development` | `production` |
|
||||
| `DEBUG` | `true` | `false` |
|
||||
| `LOG_LEVEL` | `DEBUG` | `INFO` |
|
||||
| `VITE_API_URL` | `http://localhost:8200` | `https://aiform.clientright.ru/api` |
|
||||
| `NODE_ENV` | `development` | `production` |
|
||||
|
||||
### Базы данных
|
||||
|
||||
**DEV:**
|
||||
- PostgreSQL: `ticket_form_postgres_dev` (контейнер, порт 5433)
|
||||
- Redis: `ticket_form_redis_dev` (контейнер, порт 6380)
|
||||
|
||||
**PROD:**
|
||||
- PostgreSQL: `147.45.189.234:5432` (внешний)
|
||||
- Redis: `localhost:6379` (системный)
|
||||
- MySQL: `localhost:3306` (системный)
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Отладка
|
||||
|
||||
### Проверка статуса
|
||||
|
||||
```bash
|
||||
# Статус DEV контейнеров
|
||||
docker-compose -f docker-compose.dev.yml ps
|
||||
|
||||
# Статус PROD контейнеров
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
|
||||
# Все контейнеры проекта
|
||||
docker ps | grep ticket_form
|
||||
```
|
||||
|
||||
### Проверка подключений
|
||||
|
||||
```bash
|
||||
# Проверка backend health
|
||||
curl http://localhost:8200/health
|
||||
|
||||
# Проверка frontend
|
||||
curl http://localhost:5175
|
||||
|
||||
# Проверка PostgreSQL (DEV)
|
||||
docker exec -it ticket_form_postgres_dev psql -U erv_user -d erv_db_dev
|
||||
|
||||
# Проверка Redis (DEV)
|
||||
docker exec -it ticket_form_redis_dev redis-cli -a redis_dev_pass ping
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Git репозитории
|
||||
|
||||
### Структура репозиториев
|
||||
|
||||
- **`erv-platform`** (origin) — основной репозиторий
|
||||
- **`aiform_prod`** — production версия
|
||||
- **`aiform_dev`** — development версия (в папке `aiform_dev/`)
|
||||
|
||||
### Работа с Git
|
||||
|
||||
```bash
|
||||
# Push в основной репозиторий
|
||||
git push origin main
|
||||
|
||||
# Push в prod репозиторий
|
||||
git push aiform_prod main
|
||||
|
||||
# Push в оба
|
||||
git push origin main && git push aiform_prod main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Важные замечания
|
||||
|
||||
1. **Никогда не коммитьте `.env.dev` и `.env.prod`** — они в `.gitignore`
|
||||
2. **Всегда проверяйте `.env.prod`** перед деплоем в продакшн
|
||||
3. **DEV и PROD могут работать одновременно** на разных портах
|
||||
4. **В PROD используйте внешние БД** — не создавайте локальные контейнеры
|
||||
5. **Healthcheck в PROD** — проверяйте статус регулярно
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Миграция с текущей структуры
|
||||
|
||||
Если у вас уже запущены контейнеры со старыми именами:
|
||||
|
||||
```bash
|
||||
# Остановите старые контейнеры
|
||||
docker stop ticket_form_frontend ticket_form_backend ticket_form_frontend_prod
|
||||
|
||||
# Удалите старые контейнеры (опционально)
|
||||
docker rm ticket_form_frontend ticket_form_backend ticket_form_frontend_prod
|
||||
|
||||
# Запустите новые через скрипты
|
||||
./start-dev.sh
|
||||
./start-prod.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 Поддержка
|
||||
|
||||
При проблемах:
|
||||
1. Проверьте логи: `docker-compose -f docker-compose.*.yml logs`
|
||||
2. Проверьте статус: `docker-compose -f docker-compose.*.yml ps`
|
||||
3. Проверьте `.env` файлы на корректность
|
||||
4. Убедитесь, что порты не заняты: `netstat -tulpn | grep -E "5175|5176|8200"`
|
||||
|
||||
---
|
||||
|
||||
**Автор:** AI Assistant + Фёдор
|
||||
**Дата:** 2 января 2025
|
||||
|
||||
99
GIT_STATUS.md
Normal file
99
GIT_STATUS.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# 📊 Статус Git репозитория DEV
|
||||
|
||||
**Дата проверки:** 2 января 2025
|
||||
|
||||
---
|
||||
|
||||
## 📅 Последний коммит
|
||||
|
||||
**Дата:** 29 декабря 2025, 10:59:21
|
||||
**Автор:** Fedor (fedor@clientright.ru)
|
||||
**Сообщение:** `feat: Add SMS debug code modal for dev environment`
|
||||
**Хеш:** `f7d27388a0b62380e4f1bdeba3c997f50ff10587`
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Незакоммиченные изменения
|
||||
|
||||
**Всего изменено файлов:** 9
|
||||
|
||||
### Backend (4 файла):
|
||||
- `backend/app/api/n8n_proxy.py` - изменён
|
||||
- `backend/app/api/sms.py` - изменён
|
||||
- `backend/app/config.py` - изменён
|
||||
- `backend/app/services/sms_service.py` - изменён
|
||||
|
||||
### Frontend (5 файлов):
|
||||
- `frontend/src/components/form/Step1Phone.tsx` - изменён
|
||||
- `frontend/src/components/form/Step3Payment.tsx` - изменён
|
||||
- `frontend/src/components/form/generateConfirmationFormHTML.ts` - изменён
|
||||
- `frontend/src/pages/ClaimForm.tsx` - изменён
|
||||
- `frontend/vite.config.ts` - изменён
|
||||
|
||||
**Статистика изменений:**
|
||||
- Добавлено: ~242 строки
|
||||
- Удалено: ~81 строка
|
||||
- Чистое изменение: +161 строка
|
||||
|
||||
---
|
||||
|
||||
## 📤 Статус с remote
|
||||
|
||||
**Ветка:** `main`
|
||||
**Remote:** `origin/main`
|
||||
**Статус:** Есть локальные изменения, которые не запушены в remote
|
||||
|
||||
**Не запушенные изменения:**
|
||||
- `backend/app/api/n8n_proxy.py`
|
||||
- `backend/app/api/sms.py`
|
||||
- `backend/app/config.py`
|
||||
- `backend/app/services/sms_service.py`
|
||||
|
||||
---
|
||||
|
||||
## 🔄 История коммитов (последние 5)
|
||||
|
||||
1. **2025-12-29** - `feat: Add SMS debug code modal for dev environment`
|
||||
2. **2025-12-29** - `Add docker-compose.dev.yml for dev environment (ports 5177, 8201)`
|
||||
3. **2025-12-29** - `docs: Move session log to root`
|
||||
4. **2025-12-29** - `Add session log 2025-12-29`
|
||||
5. **2025-12-29** - `Production fixes: n8n workflow auto-restart, user-friendly messages, fixed navigation buttons`
|
||||
|
||||
---
|
||||
|
||||
## 💡 Рекомендации
|
||||
|
||||
### 1. Закоммитить изменения
|
||||
|
||||
```bash
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/aiform_dev
|
||||
|
||||
# Посмотреть что изменилось
|
||||
git diff
|
||||
|
||||
# Добавить все изменения
|
||||
git add .
|
||||
|
||||
# Закоммитить
|
||||
git commit -m "feat: Описание изменений"
|
||||
```
|
||||
|
||||
### 2. Запушить в remote
|
||||
|
||||
```bash
|
||||
# Отправить в dev репозиторий
|
||||
git push origin main
|
||||
|
||||
# Или если remote называется aiform_dev
|
||||
git push aiform_dev main
|
||||
```
|
||||
|
||||
### 3. Перенести в PROD (если нужно)
|
||||
|
||||
После коммита и пуша, можно перенести изменения в PROD папку.
|
||||
|
||||
---
|
||||
|
||||
**Автор:** AI Assistant + Фёдор
|
||||
**Дата:** 2 января 2025
|
||||
|
||||
224
README.md
224
README.md
@@ -1,173 +1,163 @@
|
||||
# 🚀 Ticket Form Intake Platform
|
||||
# AiForm — платформа приёма обращений о защите прав потребителя
|
||||
|
||||
**Платформа цифровой приёмки обращений для other.clientright.ru**
|
||||
|
||||
- **Backend**: Python FastAPI (async)
|
||||
- **Frontend**: React 18 + TypeScript
|
||||
- **Database**: PostgreSQL + MySQL + Redis
|
||||
- **Queue**: RabbitMQ
|
||||
- **Storage**: S3 Timeweb Cloud
|
||||
Веб-форма и Telegram Mini App для подачи обращений о защите прав потребителя. Интеграция с CRM (vTiger), n8n, SMS-верификация и авторизация через Telegram.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Быстрый старт
|
||||
## Назначение
|
||||
|
||||
### 📍 **Визуальный доступ:**
|
||||
|
||||
После запуска доступны по адресам:
|
||||
|
||||
```
|
||||
Frontend (форма):
|
||||
http://147.45.146.17:5175/
|
||||
|
||||
Backend API:
|
||||
http://147.45.146.17:8200/
|
||||
|
||||
API Документация (Swagger UI):
|
||||
http://147.45.146.17:8200/docs ← Интерактивная!
|
||||
|
||||
Gitea (Git репозиторий):
|
||||
http://147.45.146.17:3002/
|
||||
```
|
||||
- **Веб:** форма на https://aiform.clientright.ru — описание проблемы, загрузка документов, подтверждение заявления.
|
||||
- **Telegram Mini App:** тот же функционал внутри бота (@klientprav_bot) с авторизацией по Telegram (без SMS при первом заходе).
|
||||
- Черновики, восстановление сессии, статусы заявок (черновик, в работе, готово).
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Установка и запуск
|
||||
## Стек
|
||||
|
||||
### **Backend (FastAPI):**
|
||||
| Компонент | Технология |
|
||||
|-----------|------------|
|
||||
| **Backend** | Python 3.11, FastAPI (async) |
|
||||
| **Frontend** | React 18, TypeScript, Vite, Ant Design |
|
||||
| **БД** | PostgreSQL, MySQL (полисы/CRM), Redis (сессии) |
|
||||
| **Очереди** | RabbitMQ |
|
||||
| **Хранилище** | S3 (Timeweb Cloud) |
|
||||
| **Автоматизация** | n8n (воркфлоу, вебхуки) |
|
||||
| **Интеграции** | Telegram Mini App SDK, SMS (сервис провайдера) |
|
||||
|
||||
---
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Локальная разработка
|
||||
|
||||
**Backend:**
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Создаём виртуальное окружение
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Устанавливаем зависимости
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Запускаем сервер
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8200
|
||||
```
|
||||
|
||||
### **Frontend (React):**
|
||||
|
||||
**Frontend:**
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Устанавливаем зависимости
|
||||
npm install
|
||||
|
||||
# Запускаем dev сервер
|
||||
npm run dev -- --host 0.0.0.0 --port 5175
|
||||
```
|
||||
|
||||
---
|
||||
Форма: http://localhost:5175
|
||||
API: http://localhost:8200
|
||||
Swagger: http://localhost:8200/docs
|
||||
|
||||
## 📊 Архитектура
|
||||
### Продакшн (Docker)
|
||||
|
||||
### **Поток данных:**
|
||||
```bash
|
||||
# Сборка и запуск
|
||||
docker-compose -f docker-compose.prod.yml up -d --build
|
||||
|
||||
```
|
||||
React (5175) → FastAPI (8200) → [Redis, RabbitMQ, PostgreSQL]
|
||||
↓
|
||||
OCR Service (8001)
|
||||
OpenRouter AI
|
||||
FlightAware API
|
||||
↓
|
||||
PHP Bridge → Vtiger CRM
|
||||
# Или скрипт деплоя (git push + пересборка)
|
||||
./deploy-to-prod.sh
|
||||
```
|
||||
|
||||
### **Что НЕ трогаем:**
|
||||
- **Frontend (prod):** порт 5176 → внутри 3000
|
||||
- **Backend (prod):** network_mode: host, порт 8200
|
||||
|
||||
✅ CRM Vtiger (работает как работала)
|
||||
✅ MySQL полисы (только READ)
|
||||
✅ Существующий PHP код
|
||||
Подробнее: [DEPLOYMENT.md](DEPLOYMENT.md).
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Базы данных
|
||||
## Telegram Mini App
|
||||
|
||||
| База | Назначение | Хост |
|
||||
|------|------------|------|
|
||||
| PostgreSQL | Логи, метрики, новые данные | 147.45.189.234:5432 |
|
||||
| MySQL | Проверка полисов (READ) | localhost:3306 |
|
||||
| Redis | Кеш, Rate Limiting | localhost:6379 |
|
||||
- Открытие формы из бота → загрузка aiform.clientright.ru в WebView.
|
||||
- Фронт определяет контекст (URL/referrer/user-agent) и подгружает `telegram-web-app.js` только в Mini App.
|
||||
- При наличии `initData` вызывается `POST /api/v1/tg/auth`; бэкенд проверяет подпись, дергает n8n, создаёт сессию в Redis и возвращает `session_token`, `unified_id`, `has_drafts`.
|
||||
- В Mini App: компактный скин, заявки «В работе» скрыты, кнопка «Выход» закрывает приложение. В вебе для заявок «В работе» — кнопка «Просмотреть в Telegram» (ссылка на @klientprav_bot).
|
||||
|
||||
Подробнее: [docs/TELEGRAM_MINIAPP_FLOW.md](docs/TELEGRAM_MINIAPP_FLOW.md).
|
||||
|
||||
---
|
||||
|
||||
## 📁 Структура проекта
|
||||
## Основные API (v1)
|
||||
|
||||
| Метод | Путь | Назначение |
|
||||
|-------|------|------------|
|
||||
| POST | `/api/v1/tg/auth` | Авторизация по Telegram initData |
|
||||
| POST | `/api/v1/session/verify` | Проверка сессии по session_token |
|
||||
| POST | `/api/v1/session/logout` | Выход, удаление сессии из Redis |
|
||||
| POST | `/api/v1/sms/send` | Отправка SMS-кода |
|
||||
| POST | `/api/v1/sms/verify` | Проверка SMS-кода |
|
||||
| GET | `/api/v1/claims/drafts/list` | Список черновиков (по unified_id / phone / session_id) |
|
||||
| GET | `/api/v1/claims/drafts/{claim_id}` | Данные черновика |
|
||||
| DELETE | `/api/v1/claims/drafts/{claim_id}` | Удаление черновика (не для in_work) |
|
||||
| POST | `/api/v1/claims/wizard` | Получение плана (wizard) по описанию |
|
||||
| POST | `/api/v1/claims/approve` | Подтверждение заявления (отправка в обработку) |
|
||||
| POST | `/api/v1/documents/upload` | Загрузка документа |
|
||||
| GET | `/api/v1/events/claim-plan/{session_token}` | SSE: данные заявления (claim:plan) |
|
||||
|
||||
Полный список — в Swagger: `/docs`.
|
||||
|
||||
---
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
ticket_form/
|
||||
├─ backend/ ← Python FastAPI
|
||||
│ ├─ app/
|
||||
│ │ ├─ main.py
|
||||
│ │ ├─ api/
|
||||
│ │ ├─ services/
|
||||
│ │ └─ models/
|
||||
│ └─ requirements.txt
|
||||
│
|
||||
├─ frontend/ ← React TypeScript
|
||||
│ ├─ src/
|
||||
│ │ ├─ components/
|
||||
│ │ ├─ pages/
|
||||
│ │ └─ api/
|
||||
│ └─ package.json
|
||||
│
|
||||
└─ .env ← Конфигурация
|
||||
├── backend/ # FastAPI
|
||||
│ ├── app/
|
||||
│ │ ├── main.py
|
||||
│ │ ├── config.py
|
||||
│ │ ├── api/ # Роутеры (claims, sms, session, telegram_auth, n8n_proxy, documents, …)
|
||||
│ │ └── services/
|
||||
│ ├── requirements.txt
|
||||
│ └── Dockerfile
|
||||
├── frontend/ # React + Vite
|
||||
│ ├── src/
|
||||
│ │ ├── pages/ # ClaimForm.tsx — основная форма
|
||||
│ │ ├── components/form/ # Step1Phone, StepDraftSelection, StepWizardPlan, …
|
||||
│ │ └── ...
|
||||
│ ├── package.json
|
||||
│ └── Dockerfile.prod
|
||||
├── docker-compose.prod.yml
|
||||
├── deploy-to-prod.sh
|
||||
├── .env # Не в git: TELEGRAM_BOT_TOKEN, N8N_*, БД, Redis, S3
|
||||
├── README.md # Этот файл
|
||||
├── DEPLOYMENT.md # Деплой DEV → PROD
|
||||
├── ENVIRONMENTS.md # Переменные окружения
|
||||
└── docs/
|
||||
├── TELEGRAM_MINIAPP_FLOW.md
|
||||
└── ... # Доп. документация по n8n, OCR, CRM
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API Endpoints
|
||||
## Окружения и конфиг
|
||||
|
||||
### **Документы:**
|
||||
- `POST /api/v1/documents/upload` - Загрузка в S3
|
||||
- `POST /api/v1/documents/scan` - OCR + Vision
|
||||
- **DEV:** форма на 5177, бэкенд на 8201 (см. docker-compose.dev.yml при наличии).
|
||||
- **PROD:** https://aiform.clientright.ru (frontend за nginx, backend на 8200).
|
||||
|
||||
### **Рейсы:**
|
||||
- `GET /api/v1/flights/check` - Проверка статуса
|
||||
|
||||
### **Обращения:**
|
||||
- `POST /api/v1/claims/submit` - Создание обращения
|
||||
|
||||
### **Полисы:**
|
||||
- `GET /api/v1/policies/verify` - Проверка полиса
|
||||
Переменные окружения: [ENVIRONMENTS.md](ENVIRONMENTS.md). Критично: `TELEGRAM_BOT_TOKEN`, `N8N_TG_AUTH_WEBHOOK`, БД, Redis, S3.
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Отладка
|
||||
## Git
|
||||
|
||||
### **Логи:**
|
||||
```bash
|
||||
# FastAPI
|
||||
tail -f backend/logs/app.log
|
||||
|
||||
# PostgreSQL логи
|
||||
SELECT * FROM logs ORDER BY created_at DESC LIMIT 50;
|
||||
```
|
||||
- **Репозиторий (prod):** http://147.45.146.17:3002/negodiy/aiform_prod.git
|
||||
- Клонирование: `git clone http://147.45.146.17:3002/negodiy/aiform_prod.git`
|
||||
- После изменений: `git add . && git commit -m "feat: описание" && git push`
|
||||
- Деплой на сервер: `./deploy-to-prod.sh` (при необходимости).
|
||||
|
||||
---
|
||||
|
||||
## 📝 Git
|
||||
## Документация
|
||||
|
||||
```bash
|
||||
# Репозиторий
|
||||
http://147.45.146.17:3002/negodiy/erv-platform
|
||||
|
||||
# Клонирование
|
||||
git clone http://147.45.146.17:3002/negodiy/erv-platform.git
|
||||
|
||||
# Push изменений
|
||||
git add .
|
||||
git commit -m "Your message"
|
||||
git push origin main
|
||||
```
|
||||
| Файл | Содержание |
|
||||
|------|------------|
|
||||
| [DEPLOYMENT.md](DEPLOYMENT.md) | Деплой, скрипты, .env |
|
||||
| [ENVIRONMENTS.md](ENVIRONMENTS.md) | Переменные окружения |
|
||||
| [docs/TELEGRAM_MINIAPP_FLOW.md](docs/TELEGRAM_MINIAPP_FLOW.md) | Поток Telegram Mini App и tg/auth |
|
||||
|
||||
---
|
||||
|
||||
**Автор**: AI Assistant + Фёдор
|
||||
**Дата**: 24.10.2025
|
||||
|
||||
|
||||
**Автор:** Фёдор + AI Assistant
|
||||
**Обновлено:** январь 2026
|
||||
|
||||
94
README_ENVIRONMENTS.md
Normal file
94
README_ENVIRONMENTS.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 🚀 Быстрый старт: DEV и PROD окружения
|
||||
|
||||
## 📦 Что создано
|
||||
|
||||
✅ `docker-compose.dev.yml` - конфигурация для разработки
|
||||
✅ `docker-compose.prod.yml` - конфигурация для продакшна
|
||||
✅ `start-dev.sh` - скрипт запуска DEV
|
||||
✅ `start-prod.sh` - скрипт запуска PROD
|
||||
✅ `.env.example` - шаблон переменных окружения
|
||||
✅ `ENVIRONMENTS.md` - полная документация
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Быстрый старт (3 шага)
|
||||
|
||||
### Шаг 1: Создайте .env файлы
|
||||
|
||||
```bash
|
||||
cd /var/www/fastuser/data/www/crm.clientright.ru/ticket_form
|
||||
|
||||
# Создаём из шаблона
|
||||
cp .env.example .env.dev
|
||||
cp .env.example .env.prod
|
||||
|
||||
# Редактируем DEV
|
||||
nano .env.dev
|
||||
# Установите: APP_ENV=development, DEBUG=true
|
||||
|
||||
# Редактируем PROD
|
||||
nano .env.prod
|
||||
# Установите: APP_ENV=production, DEBUG=false
|
||||
# Проверьте все URL и ключи!
|
||||
```
|
||||
|
||||
### Шаг 2: Запустите DEV
|
||||
|
||||
```bash
|
||||
./start-dev.sh
|
||||
```
|
||||
|
||||
**Доступ:** http://localhost:5175
|
||||
|
||||
### Шаг 3: Запустите PROD (когда готово)
|
||||
|
||||
```bash
|
||||
./start-prod.sh
|
||||
```
|
||||
|
||||
**Доступ:** http://localhost:5176
|
||||
|
||||
---
|
||||
|
||||
## 📊 Основные команды
|
||||
|
||||
```bash
|
||||
# Остановить DEV
|
||||
docker-compose -f docker-compose.dev.yml down
|
||||
|
||||
# Остановить PROD
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
|
||||
# Логи DEV
|
||||
docker-compose -f docker-compose.dev.yml logs -f
|
||||
|
||||
# Логи PROD
|
||||
docker-compose -f docker-compose.prod.yml logs -f
|
||||
|
||||
# Статус
|
||||
docker-compose -f docker-compose.dev.yml ps
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Различия
|
||||
|
||||
| | DEV | PROD |
|
||||
|---|---|---|
|
||||
| **Порты** | 5175, 8200 | 5176, 8200 |
|
||||
| **PostgreSQL** | Локальный контейнер | Внешний (147.45.189.234) |
|
||||
| **Redis** | Локальный контейнер | Системный (localhost) |
|
||||
| **Debug** | ✅ Включен | ❌ Выключен |
|
||||
| **Hot Reload** | ✅ Да | ❌ Нет |
|
||||
|
||||
---
|
||||
|
||||
## 📖 Полная документация
|
||||
|
||||
Смотрите `ENVIRONMENTS.md` для детальной информации.
|
||||
|
||||
---
|
||||
|
||||
**Всё готово к работе!** 🎉
|
||||
|
||||
@@ -14,8 +14,8 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
|
||||
# Открываем порт
|
||||
EXPOSE 8200
|
||||
EXPOSE 4200
|
||||
|
||||
# Запускаем приложение
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8200"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "4200"]
|
||||
|
||||
|
||||
256
backend/app/api/auth2.py
Normal file
256
backend/app/api/auth2.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
Alternative auth endpoint (tg/max/sms) without touching existing flow.
|
||||
|
||||
/api/v1/auth2/login:
|
||||
- platform=tg|max|sms
|
||||
- Validates init_data for TG/MAX and calls n8n webhook
|
||||
- For SMS: verifies code, calls n8n contact webhook, creates session
|
||||
- Returns greeting message
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Literal, Any, Dict
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..services.sms_service import sms_service
|
||||
from ..services.telegram_auth import extract_telegram_user, TelegramAuthError
|
||||
from ..services.max_auth import extract_max_user, MaxAuthError
|
||||
from ..config import settings
|
||||
from . import n8n_proxy
|
||||
from . import session as session_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth2", tags=["auth2"])
|
||||
|
||||
|
||||
class Auth2LoginRequest(BaseModel):
|
||||
platform: Literal["tg", "max", "sms"]
|
||||
init_data: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
code: Optional[str] = None
|
||||
session_token: Optional[str] = None
|
||||
form_id: str = "ticket_form"
|
||||
|
||||
|
||||
class Auth2LoginResponse(BaseModel):
|
||||
success: bool
|
||||
greeting: str
|
||||
session_token: Optional[str] = None
|
||||
unified_id: Optional[str] = None
|
||||
contact_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
has_drafts: Optional[bool] = None
|
||||
need_contact: Optional[bool] = None
|
||||
avatar_url: Optional[str] = None
|
||||
|
||||
|
||||
def _generate_session_token() -> str:
|
||||
import uuid
|
||||
return f"sess-{uuid.uuid4()}"
|
||||
|
||||
|
||||
@router.post("/login", response_model=Auth2LoginResponse)
|
||||
async def login(request: Auth2LoginRequest):
|
||||
platform = request.platform
|
||||
logger.info("[AUTH2] login: platform=%s", platform)
|
||||
|
||||
if platform == "tg":
|
||||
if not request.init_data:
|
||||
raise HTTPException(status_code=400, detail="init_data обязателен для tg")
|
||||
try:
|
||||
tg_user = extract_telegram_user(request.init_data)
|
||||
except TelegramAuthError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
session_token = request.session_token or _generate_session_token()
|
||||
n8n_payload = {
|
||||
"telegram_user_id": tg_user["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": request.form_id,
|
||||
"init_data": request.init_data,
|
||||
}
|
||||
|
||||
class _DummyRequest:
|
||||
def __init__(self, payload: Dict[str, Any]):
|
||||
self._payload = payload
|
||||
async def json(self):
|
||||
return self._payload
|
||||
|
||||
n8n_response = await n8n_proxy.proxy_telegram_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
|
||||
n8n_data = jsonable_encoder(n8n_response)
|
||||
_result = n8n_data.get("result")
|
||||
_result_dict = _result if isinstance(_result, dict) else {}
|
||||
|
||||
_raw_nc = n8n_data.get("need_contact") or _result_dict.get("need_contact") or n8n_data.get("needContact") or _result_dict.get("needContact")
|
||||
need_contact = _raw_nc is True or _raw_nc == 1 or (isinstance(_raw_nc, str) and str(_raw_nc).strip().lower() in ("true", "1"))
|
||||
if need_contact:
|
||||
logger.info("[AUTH2] TG: n8n need_contact — возвращаем need_contact=true")
|
||||
return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True)
|
||||
|
||||
unified_id = n8n_data.get("unified_id") or _result_dict.get("unified_id") or n8n_data.get("unifiedId")
|
||||
contact_id = n8n_data.get("contact_id") or _result_dict.get("contact_id") or n8n_data.get("contactId")
|
||||
phone = n8n_data.get("phone") or _result_dict.get("phone")
|
||||
has_drafts = n8n_data.get("has_drafts")
|
||||
|
||||
if not unified_id:
|
||||
logger.info("[AUTH2] TG: n8n не вернул unified_id — возвращаем need_contact=true")
|
||||
return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True)
|
||||
|
||||
await session_api.create_session(session_api.SessionCreateRequest(
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
phone=phone or "",
|
||||
contact_id=contact_id or "",
|
||||
ttl_hours=24,
|
||||
chat_id=str(tg_user["telegram_user_id"]) if tg_user.get("telegram_user_id") is not None else None,
|
||||
))
|
||||
|
||||
first_name = tg_user.get("first_name") or ""
|
||||
greeting = f"Привет, {first_name}!" if first_name else "Привет!"
|
||||
|
||||
return Auth2LoginResponse(
|
||||
success=True,
|
||||
greeting=greeting,
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
contact_id=contact_id,
|
||||
phone=phone,
|
||||
has_drafts=has_drafts,
|
||||
avatar_url=tg_user.get("photo_url") or None,
|
||||
)
|
||||
|
||||
if platform == "max":
|
||||
if not request.init_data:
|
||||
raise HTTPException(status_code=400, detail="init_data обязателен для max")
|
||||
try:
|
||||
max_user = extract_max_user(request.init_data)
|
||||
except MaxAuthError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
session_token = request.session_token or _generate_session_token()
|
||||
n8n_payload = {
|
||||
"max_user_id": max_user["max_user_id"],
|
||||
"username": max_user.get("username"),
|
||||
"first_name": max_user.get("first_name"),
|
||||
"last_name": max_user.get("last_name"),
|
||||
"session_token": session_token,
|
||||
"form_id": request.form_id,
|
||||
"init_data": request.init_data,
|
||||
}
|
||||
|
||||
class _DummyRequest:
|
||||
def __init__(self, payload: Dict[str, Any]):
|
||||
self._payload = payload
|
||||
async def json(self):
|
||||
return self._payload
|
||||
|
||||
n8n_response = await n8n_proxy.proxy_max_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
|
||||
n8n_data = jsonable_encoder(n8n_response)
|
||||
_result = n8n_data.get("result")
|
||||
_result_dict = _result if isinstance(_result, dict) else {}
|
||||
|
||||
_raw_nc = n8n_data.get("need_contact") or _result_dict.get("need_contact") or n8n_data.get("needContact") or _result_dict.get("needContact")
|
||||
need_contact = _raw_nc is True or _raw_nc == 1 or (isinstance(_raw_nc, str) and str(_raw_nc).strip().lower() in ("true", "1"))
|
||||
if need_contact:
|
||||
logger.info("[AUTH2] MAX: n8n need_contact — возвращаем need_contact=true")
|
||||
return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True)
|
||||
|
||||
unified_id = n8n_data.get("unified_id") or _result_dict.get("unified_id") or n8n_data.get("unifiedId")
|
||||
contact_id = n8n_data.get("contact_id") or _result_dict.get("contact_id") or n8n_data.get("contactId")
|
||||
phone = n8n_data.get("phone") or _result_dict.get("phone")
|
||||
has_drafts = n8n_data.get("has_drafts")
|
||||
|
||||
if not unified_id:
|
||||
logger.info("[AUTH2] MAX: n8n не вернул unified_id — возвращаем need_contact=true")
|
||||
return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True)
|
||||
|
||||
await session_api.create_session(session_api.SessionCreateRequest(
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
phone=phone or "",
|
||||
contact_id=contact_id or "",
|
||||
ttl_hours=24,
|
||||
chat_id=str(max_user["max_user_id"]) if max_user.get("max_user_id") is not None else None,
|
||||
))
|
||||
|
||||
first_name = max_user.get("first_name") or ""
|
||||
greeting = f"Привет, {first_name}!" if first_name else "Привет!"
|
||||
|
||||
return Auth2LoginResponse(
|
||||
success=True,
|
||||
greeting=greeting,
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
contact_id=contact_id,
|
||||
phone=phone,
|
||||
has_drafts=has_drafts,
|
||||
avatar_url=max_user.get("photo_url") or None,
|
||||
)
|
||||
|
||||
if platform == "sms":
|
||||
phone = (request.phone or "").strip()
|
||||
code = (request.code or "").strip()
|
||||
if not phone or not code:
|
||||
raise HTTPException(status_code=400, detail="phone и code обязательны для sms")
|
||||
|
||||
is_valid = await sms_service.verify_code(phone, code)
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail="Неверный код или код истек")
|
||||
|
||||
class _DummyRequest:
|
||||
def __init__(self, payload: Dict[str, Any]):
|
||||
self._payload = payload
|
||||
async def json(self):
|
||||
return self._payload
|
||||
|
||||
n8n_payload = {
|
||||
"phone": phone,
|
||||
"session_id": request.session_token or "",
|
||||
"form_id": request.form_id,
|
||||
}
|
||||
|
||||
n8n_response = await n8n_proxy.proxy_create_contact(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
|
||||
n8n_data = jsonable_encoder(n8n_response)
|
||||
if isinstance(n8n_data, list) and n8n_data:
|
||||
n8n_data = n8n_data[0]
|
||||
|
||||
if not n8n_data or not isinstance(n8n_data, dict) or not n8n_data.get("success"):
|
||||
raise HTTPException(status_code=500, detail="Ошибка создания контакта в n8n")
|
||||
|
||||
result = n8n_data.get("result") or n8n_data
|
||||
unified_id = result.get("unified_id")
|
||||
contact_id = result.get("contact_id")
|
||||
phone_res = result.get("phone") or phone
|
||||
has_drafts = result.get("has_drafts")
|
||||
session_token = result.get("session") or request.session_token or _generate_session_token()
|
||||
|
||||
if not unified_id:
|
||||
raise HTTPException(status_code=500, detail="n8n не вернул unified_id")
|
||||
|
||||
await session_api.create_session(session_api.SessionCreateRequest(
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
phone=phone_res or "",
|
||||
contact_id=contact_id or "",
|
||||
ttl_hours=24,
|
||||
))
|
||||
|
||||
return Auth2LoginResponse(
|
||||
success=True,
|
||||
greeting="Привет!",
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
contact_id=contact_id,
|
||||
phone=phone_res,
|
||||
has_drafts=has_drafts,
|
||||
avatar_url=None,
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=400, detail="Неподдерживаемая платформа")
|
||||
264
backend/app/api/auth_universal.py
Normal file
264
backend/app/api/auth_universal.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
Универсальный auth: один endpoint для TG и MAX.
|
||||
Принимает channel (tg|max) и init_data, валидирует, дергает N8N_AUTH_WEBHOOK,
|
||||
пишет сессию в Redis по ключу session:{channel}:{channel_user_id} и session:{session_token}.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Optional, Any, Dict, Union
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import settings
|
||||
from ..services.telegram_auth import extract_telegram_user, TelegramAuthError
|
||||
from ..services.max_auth import extract_max_user, MaxAuthError
|
||||
from . import session as session_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth-universal"])
|
||||
|
||||
|
||||
class AuthUniversalRequest(BaseModel):
|
||||
channel: str # tg | max
|
||||
init_data: str
|
||||
|
||||
|
||||
class AuthUniversalResponse(BaseModel):
|
||||
success: bool
|
||||
need_contact: Optional[bool] = None
|
||||
message: Optional[str] = None
|
||||
session_token: Optional[str] = None
|
||||
unified_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
contact_id: Optional[str] = None
|
||||
has_drafts: Optional[bool] = None
|
||||
need_profile_confirm: Optional[bool] = None
|
||||
profile_needs_attention: Optional[bool] = None
|
||||
|
||||
|
||||
def _to_bool(v: Any) -> Optional[bool]:
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, bool):
|
||||
return v
|
||||
if isinstance(v, (int, float)):
|
||||
if v == 1:
|
||||
return True
|
||||
if v == 0:
|
||||
return False
|
||||
if isinstance(v, str):
|
||||
s = v.strip().lower()
|
||||
if s in ("1", "true", "yes", "y", "да"):
|
||||
return True
|
||||
if s in ("0", "false", "no", "n", "нет", ""):
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
@router.post("", response_model=AuthUniversalResponse)
|
||||
async def auth_universal(request: AuthUniversalRequest):
|
||||
"""
|
||||
Универсальная авторизация: channel (tg|max) + init_data.
|
||||
Валидируем init_data, получаем channel_user_id, вызываем N8N_AUTH_WEBHOOK,
|
||||
при успехе пишем сессию в Redis по session:{channel}:{channel_user_id}.
|
||||
"""
|
||||
logger.info("[AUTH] POST /api/v1/auth вызван: channel=%s", request.channel)
|
||||
channel = (request.channel or "").strip().lower()
|
||||
if channel not in ("tg", "telegram", "max"):
|
||||
channel = "telegram" if channel.startswith("tg") else "max"
|
||||
# В n8n и Redis всегда передаём telegram, не tg
|
||||
if channel == "tg":
|
||||
channel = "telegram"
|
||||
|
||||
init_data = (request.init_data or "").strip()
|
||||
if not init_data:
|
||||
raise HTTPException(status_code=400, detail="init_data обязателен")
|
||||
|
||||
logger.debug("[AUTH] init_data length=%s", len(init_data))
|
||||
|
||||
# 1) Извлечь channel_user_id из init_data
|
||||
channel_user_id: Optional[str] = None
|
||||
if channel == "telegram":
|
||||
try:
|
||||
user = extract_telegram_user(init_data)
|
||||
channel_user_id = user.get("telegram_user_id")
|
||||
except TelegramAuthError as e:
|
||||
logger.warning("[TG] Ошибка валидации init_data: %s", e)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
else:
|
||||
try:
|
||||
user = extract_max_user(init_data)
|
||||
channel_user_id = user.get("max_user_id")
|
||||
except MaxAuthError as e:
|
||||
logger.warning("[MAX] Ошибка валидации init_data: %s", e)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
if not channel_user_id:
|
||||
raise HTTPException(status_code=400, detail="Не удалось получить channel_user_id из init_data")
|
||||
|
||||
# URL из settings или напрямую из env (если в config нет поля n8n_auth_webhook)
|
||||
webhook_url = (getattr(settings, "n8n_auth_webhook", None) or os.environ.get("N8N_AUTH_WEBHOOK") or "").strip()
|
||||
if not webhook_url:
|
||||
logger.error("N8N_AUTH_WEBHOOK не задан в .env")
|
||||
raise HTTPException(status_code=503, detail="Сервис авторизации не настроен")
|
||||
|
||||
# 2) Вызвать n8n
|
||||
payload = {
|
||||
"channel": channel,
|
||||
"channel_user_id": channel_user_id,
|
||||
"init_data": init_data,
|
||||
}
|
||||
# При мультиботе (Telegram или MAX) передаём bot_id (из extract_telegram_user / extract_max_user)
|
||||
if user.get("bot_id"):
|
||||
payload["bot_id"] = user["bot_id"]
|
||||
logger.info("[AUTH] Вызов N8N_AUTH_WEBHOOK: channel=%s, channel_user_id=%s", channel, channel_user_id)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
webhook_url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.error("[AUTH] Таймаут N8N_AUTH_WEBHOOK")
|
||||
raise HTTPException(status_code=504, detail="Таймаут сервиса авторизации")
|
||||
except Exception as e:
|
||||
logger.exception("[AUTH] Ошибка вызова N8N_AUTH_WEBHOOK: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Ошибка сервиса авторизации")
|
||||
|
||||
# Лог: что пришло от n8n (сырой ответ)
|
||||
try:
|
||||
_body = response.text or ""
|
||||
logger.info("[AUTH] n8n ответ: status=%s, body_len=%s, body_preview=%s", response.status_code, len(_body), _body[:500] if _body else "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
raw = response.json()
|
||||
logger.info("[AUTH] raw type=%s, is_list=%s, len=%s", type(raw).__name__, isinstance(raw, list), len(raw) if isinstance(raw, (list, dict)) else 0)
|
||||
if isinstance(raw, list) and len(raw) > 0:
|
||||
logger.info("[AUTH] raw[0] keys=%s", list(raw[0].keys()) if isinstance(raw[0], dict) else type(raw[0]).__name__)
|
||||
|
||||
# n8n может вернуть: массив [{ json: { ... } }] или массив объектов напрямую [{ success, unified_id, ... }]
|
||||
if isinstance(raw, list) and len(raw) > 0 and isinstance(raw[0], dict):
|
||||
first = raw[0]
|
||||
if "json" in first:
|
||||
data = first["json"]
|
||||
logger.info("[AUTH] парсинг: взяли first['json'], data keys=%s", list(data.keys()) if isinstance(data, dict) else "?")
|
||||
elif "success" in first or "unified_id" in first:
|
||||
data = first
|
||||
logger.info("[AUTH] парсинг: взяли first как data, keys=%s", list(data.keys()))
|
||||
else:
|
||||
data = {}
|
||||
logger.warning("[AUTH] парсинг: first без json/success/unified_id, data={}")
|
||||
elif isinstance(raw, dict):
|
||||
# n8n Respond to Webhook может вернуть { "json": { success, phone, ... } }
|
||||
if "json" in raw and isinstance(raw.get("json"), dict):
|
||||
data = raw["json"]
|
||||
logger.info("[AUTH] парсинг: raw — dict с json, data keys=%s", list(data.keys()))
|
||||
else:
|
||||
data = raw
|
||||
logger.info("[AUTH] парсинг: raw — dict, keys=%s", list(data.keys()))
|
||||
else:
|
||||
data = {}
|
||||
logger.warning("[AUTH] парсинг: неизвестный формат raw, data={}")
|
||||
except Exception as e:
|
||||
logger.warning("[AUTH] Ответ n8n не JSON: %s", (response.text or "")[:300])
|
||||
raise HTTPException(status_code=502, detail="Некорректный ответ сервиса авторизации")
|
||||
|
||||
logger.info("[AUTH] data: success=%s, need_contact=%s, unified_id=%s", data.get("success"), data.get("need_contact"), data.get("unified_id"))
|
||||
|
||||
# Флаг «профиль требует внимания»: приходит из n8n, прокидываем в сессию и на фронт
|
||||
need_profile_confirm = _to_bool(
|
||||
data.get("need_profile_confirm")
|
||||
if "need_profile_confirm" in data
|
||||
else data.get("needProfileConfirm")
|
||||
)
|
||||
profile_needs_attention = _to_bool(
|
||||
data.get("profile_needs_attention")
|
||||
if "profile_needs_attention" in data
|
||||
else data.get("profileNeedsAttention")
|
||||
)
|
||||
if profile_needs_attention is None:
|
||||
profile_needs_attention = need_profile_confirm
|
||||
|
||||
# 3) need_contact — только если n8n явно вернул need_contact (закрыть приложение и попросить контакт в чате)
|
||||
need_contact = (
|
||||
data.get("need_contact") is True
|
||||
or data.get("need_contact") == 1
|
||||
or (isinstance(data.get("need_contact"), str) and data.get("need_contact", "").strip().lower() in ("true", "1"))
|
||||
)
|
||||
if need_contact:
|
||||
logger.info("[AUTH] ответ: need_contact=true → закрыть приложение")
|
||||
return AuthUniversalResponse(
|
||||
success=False,
|
||||
need_contact=True,
|
||||
message=(data.get("message") or "Пользователь не найден. Поделитесь контактом в чате с ботом."),
|
||||
)
|
||||
if data.get("success") is False:
|
||||
# Ошибка/неуспех без требования контакта — не закрываем приложение, показываем сообщение
|
||||
msg = data.get("message") or "Ошибка авторизации."
|
||||
logger.info("[AUTH] ответ: success=false, need_contact=false → показать ошибку: message=%s", msg)
|
||||
logger.debug("[AUTH] полный data при success=false: %s", data)
|
||||
return AuthUniversalResponse(
|
||||
success=False,
|
||||
need_contact=False,
|
||||
message=msg,
|
||||
)
|
||||
|
||||
# 4) Успех: unified_id и т.д.
|
||||
unified_id = data.get("unified_id")
|
||||
if not unified_id and isinstance(data.get("result"), dict):
|
||||
unified_id = (data.get("result") or {}).get("unified_id")
|
||||
if not unified_id:
|
||||
logger.warning("[AUTH] n8n не вернул unified_id: %s", data)
|
||||
logger.info("[AUTH] ответ: нет unified_id → need_contact=true, закрыть приложение")
|
||||
return AuthUniversalResponse(success=False, need_contact=True, message="Контакт не найден.")
|
||||
|
||||
# 5) Записать сессию в Redis по session:{channel}:{channel_user_id} и session:{session_token}
|
||||
_phone = data.get("phone") or ((data.get("result") or {}).get("phone") if isinstance(data.get("result"), dict) else None)
|
||||
_contact_id = data.get("contact_id") or ((data.get("result") or {}).get("contact_id") if isinstance(data.get("result"), dict) else None)
|
||||
if _phone is not None and not isinstance(_phone, str):
|
||||
_phone = str(_phone).strip() or None
|
||||
elif isinstance(_phone, str):
|
||||
_phone = _phone.strip() or None
|
||||
session_data = {
|
||||
"unified_id": unified_id,
|
||||
"phone": _phone,
|
||||
"contact_id": _contact_id,
|
||||
"has_drafts": data.get("has_drafts", False) or (data.get("result") or {}).get("has_drafts", False) if isinstance(data.get("result"), dict) else False,
|
||||
"chat_id": channel_user_id,
|
||||
"need_profile_confirm": need_profile_confirm,
|
||||
"profile_needs_attention": profile_needs_attention,
|
||||
}
|
||||
logger.info("[AUTH] session_data: unified_id=%s, phone=%s", unified_id, session_data.get("phone"))
|
||||
try:
|
||||
await session_api.set_session_by_channel_user(channel, channel_user_id, session_data)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("[AUTH] Ошибка записи сессии в Redis: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Ошибка сохранения сессии")
|
||||
|
||||
session_token = str(uuid.uuid4())
|
||||
try:
|
||||
await session_api.set_session_by_token(session_token, session_data)
|
||||
except Exception as e:
|
||||
logger.warning("[AUTH] Двойная запись session_token в Redis: %s", e)
|
||||
|
||||
logger.info("[AUTH] ответ: success=true, session_token=%s..., unified_id=%s", session_token[:8] if session_token else "", unified_id)
|
||||
return AuthUniversalResponse(
|
||||
success=True,
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
phone=session_data.get("phone"),
|
||||
contact_id=session_data.get("contact_id"),
|
||||
has_drafts=session_data.get("has_drafts", False),
|
||||
need_profile_confirm=need_profile_confirm,
|
||||
profile_needs_attention=profile_needs_attention,
|
||||
)
|
||||
57
backend/app/api/banks.py
Normal file
57
backend/app/api/banks.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
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 (BANK_IP в .env или nspk_banks_api_url).
|
||||
"""
|
||||
external_api_url = (getattr(settings, "bank_ip", None) or getattr(settings, "bank_api_url", None) or "").strip() or "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
|
||||
try:
|
||||
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)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -13,16 +13,21 @@ import uuid
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
import os
|
||||
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"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
N8N_TICKET_FORM_FINAL_WEBHOOK = "https://n8n.clientright.pro/webhook/ecc93306-fadc-489a-afdb-d3e981013df3"
|
||||
|
||||
def _get_ticket_form_webhook() -> str:
|
||||
"""URL webhook n8n для wizard и create. Менять в .env: N8N_TICKET_FORM_FINAL_WEBHOOK"""
|
||||
return (getattr(settings, "n8n_ticket_form_final_webhook", None) or "").strip() or "https://n8n.clientright.pro/webhook/ecc93306-fadc-489a-afdb-d3e981013df3"
|
||||
|
||||
|
||||
@router.post("/wizard")
|
||||
@@ -58,16 +63,32 @@ async def submit_wizard(request: Request):
|
||||
},
|
||||
)
|
||||
|
||||
webhook_url = _get_ticket_form_webhook()
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.post(
|
||||
N8N_TICKET_FORM_FINAL_WEBHOOK,
|
||||
webhook_url,
|
||||
data=data,
|
||||
files=files or None,
|
||||
)
|
||||
|
||||
text = response.text or ""
|
||||
|
||||
logger.info(
|
||||
"n8n wizard response: status=%s, body_length=%s, body_preview=%s",
|
||||
response.status_code,
|
||||
len(text),
|
||||
text[:1500] if len(text) > 1500 else text,
|
||||
extra={"claim_id": data.get("claim_id"), "session_id": data.get("session_id")},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
logger.info(
|
||||
"n8n wizard response (parsed): keys=%s",
|
||||
list(parsed.keys()) if isinstance(parsed, dict) else type(parsed).__name__,
|
||||
extra={"session_id": data.get("session_id")},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(
|
||||
"✅ TicketForm wizard webhook OK",
|
||||
extra={"response_preview": text[:500]},
|
||||
@@ -120,9 +141,10 @@ async def create_claim(request: Request):
|
||||
)
|
||||
|
||||
# Проксируем запрос к n8n
|
||||
webhook_url = _get_ticket_form_webhook()
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
N8N_TICKET_FORM_FINAL_WEBHOOK,
|
||||
webhook_url,
|
||||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
@@ -241,7 +263,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 +290,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
|
||||
@@ -372,8 +394,23 @@ async def list_drafts(
|
||||
# Категория проблемы
|
||||
category = ai_analysis.get('category') or wizard_plan.get('category') or None
|
||||
|
||||
# Подробное описание (для превью)
|
||||
problem_text = payload.get('problem_description', '')
|
||||
# Направление (для иконки плитки)
|
||||
direction = payload.get('direction') or wizard_plan.get('direction') or category
|
||||
|
||||
# facts_short из AI Agent (краткие факты — заголовок плитки)
|
||||
ai_agent1_facts = payload.get('ai_agent1_facts') or {}
|
||||
ai_analysis_facts = (payload.get('ai_analysis') or {}).get('facts_short')
|
||||
facts_short = ai_agent1_facts.get('facts_short') or ai_analysis_facts
|
||||
if facts_short and len(facts_short) > 200:
|
||||
facts_short = facts_short[:200].rstrip() + '…'
|
||||
|
||||
# Подробное описание (для превью); n8n может сохранять в description/chatInput
|
||||
problem_text = (
|
||||
payload.get('problem_description')
|
||||
or payload.get('description')
|
||||
or payload.get('chatInput')
|
||||
or ''
|
||||
)
|
||||
|
||||
# Считаем документы
|
||||
documents_meta = payload.get('documents_meta') or []
|
||||
@@ -392,10 +429,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,
|
||||
@@ -416,6 +454,8 @@ async def list_drafts(
|
||||
# Полное описание
|
||||
"problem_description": problem_text[:500] if problem_text else None,
|
||||
"category": category,
|
||||
"direction": direction,
|
||||
"facts_short": facts_short,
|
||||
"wizard_plan": payload.get('wizard_plan') is not None,
|
||||
"wizard_answers": payload.get('answers') is not None,
|
||||
"has_documents": documents_uploaded > 0,
|
||||
@@ -443,11 +483,13 @@ async def list_drafts(
|
||||
@router.get("/drafts/{claim_id}")
|
||||
async def get_draft(claim_id: str):
|
||||
"""
|
||||
Получить полные данные черновика по claim_id
|
||||
|
||||
Возвращает все данные формы для продолжения заполнения
|
||||
Получить полные данные черновика по claim_id.
|
||||
Поддерживаются форматы: голый UUID, claim_id_<uuid> (из MAX startapp).
|
||||
"""
|
||||
try:
|
||||
# Формат из MAX диплинка: claim_id_<uuid> — извлекаем UUID
|
||||
if claim_id.startswith("claim_id_"):
|
||||
claim_id = claim_id[9:]
|
||||
logger.info(f"🔍 Загрузка черновика: claim_id={claim_id}")
|
||||
|
||||
# Ищем черновик по claim_id (может быть в payload->>'claim_id' или id = UUID)
|
||||
@@ -498,10 +540,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 +676,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,
|
||||
@@ -622,11 +698,11 @@ async def get_draft(claim_id: str):
|
||||
@router.delete("/drafts/{claim_id}")
|
||||
async def delete_draft(claim_id: str):
|
||||
"""
|
||||
Удалить черновик по claim_id
|
||||
|
||||
Удаляет черновики с любым статусом (кроме submitted/completed)
|
||||
Удалить черновик по claim_id. Поддерживается формат claim_id_<uuid>.
|
||||
"""
|
||||
try:
|
||||
if claim_id.startswith("claim_id_"):
|
||||
claim_id = claim_id[9:]
|
||||
query = """
|
||||
DELETE FROM clpr_claims
|
||||
WHERE (payload->>'claim_id' = $1 OR id::text = $1)
|
||||
@@ -832,15 +908,14 @@ async def get_claim(claim_id: str):
|
||||
@router.get("/wizard/load/{claim_id}")
|
||||
async def load_wizard_data(claim_id: str):
|
||||
"""
|
||||
Загрузить данные визарда из PostgreSQL по claim_id
|
||||
|
||||
Используется после получения claim_id из ocr_events.
|
||||
Возвращает полные данные для построения формы (wizard_plan, problem_description и т.д.)
|
||||
Загрузить данные визарда по claim_id. Поддерживается формат claim_id_<uuid>.
|
||||
"""
|
||||
try:
|
||||
if claim_id.startswith("claim_id_"):
|
||||
claim_id = claim_id[9:]
|
||||
logger.info(f"🔍 Загрузка данных визарда для claim_id={claim_id}")
|
||||
|
||||
# Ищем заявку по claim_id (может быть UUID или строка CLM-...)
|
||||
# Ищем заявку по claim_id (UUID или CLM-...)
|
||||
query = """
|
||||
SELECT
|
||||
id,
|
||||
@@ -908,48 +983,127 @@ 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):
|
||||
# Актуальный webhook для описания проблемы (n8n.clientright.ru). Старый aiform_description на .pro больше не используем.
|
||||
DESCRIPTION_WEBHOOK_DEFAULT = "https://n8n.clientright.ru/webhook/ticket_form_description"
|
||||
|
||||
DEBUG_LOG_PATH = "/app/logs/debug-2a4d38.log"
|
||||
|
||||
|
||||
def _debug_log(hy: str, msg: str, data: dict):
|
||||
try:
|
||||
import time
|
||||
line = json.dumps({
|
||||
"sessionId": "2a4d38",
|
||||
"hypothesisId": hy,
|
||||
"location": "claims.py:publish_ticket_form_description",
|
||||
"message": msg,
|
||||
"data": data,
|
||||
"timestamp": int(time.time() * 1000),
|
||||
}, ensure_ascii=False) + "\n"
|
||||
with open(DEBUG_LOG_PATH, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _get_description_webhook_url() -> str:
|
||||
"""URL webhook для описания проблемы: только env N8N_DESCRIPTION_WEBHOOK или константа (старый .pro не используем)."""
|
||||
url = (os.environ.get("N8N_DESCRIPTION_WEBHOOK") or "").strip()
|
||||
if url:
|
||||
return url
|
||||
return DESCRIPTION_WEBHOOK_DEFAULT
|
||||
|
||||
|
||||
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)")
|
||||
description_webhook_url = _get_description_webhook_url()
|
||||
if not description_webhook_url:
|
||||
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 workflow_data:
|
||||
is_active = workflow_data.get("active", False)
|
||||
if not is_active:
|
||||
logger.warning(f"⚠️ Workflow НЕ активен! Активирую и перезапускаю...")
|
||||
# Workflow выключен — нужно его ВКЛЮЧИТЬ
|
||||
else:
|
||||
logger.info(
|
||||
f"⚠️ Workflow активен, но нет подписчиков. Перезапускаю workflow..."
|
||||
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(
|
||||
description_webhook_url,
|
||||
json=webhook_payload, # Отправляем в формате массива
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
# Устанавливаем lock на MIN_RESTART_INTERVAL секунд
|
||||
await redis_service.set(lock_key, "1", expire=MIN_RESTART_INTERVAL)
|
||||
|
||||
# Перезапускаем
|
||||
success = await restart_workflow()
|
||||
|
||||
if success:
|
||||
logger.info("✅ Workflow успешно перезапущен")
|
||||
if response.status_code == 200:
|
||||
sent_count += 1
|
||||
logger.info(
|
||||
f"✅ Буферированное сообщение отправлено: "
|
||||
f"session_id={buffered_message.get('session_id', 'unknown')}"
|
||||
)
|
||||
# НЕ возвращаем в буфер - успешно отправили
|
||||
else:
|
||||
logger.error("❌ Не удалось перезапустить workflow")
|
||||
else:
|
||||
logger.warning("⚠️ Не удалось проверить статус workflow, пропускаем перезапуск")
|
||||
# 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:
|
||||
logger.exception(f"❌ Ошибка при проверке/перезапуске workflow: {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} возвращено в буфер"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"❌ Ошибка при отправке буфера: {e}")
|
||||
|
||||
|
||||
@router.post("/description")
|
||||
@@ -958,114 +1112,201 @@ async def publish_ticket_form_description(
|
||||
background_tasks: BackgroundTasks
|
||||
):
|
||||
"""
|
||||
Публикует свободное описание проблемы в Redis канал ticket_form:description
|
||||
(слушается воркфлоу в n8n)
|
||||
Отправляет описание проблемы в n8n через webhook. URL: N8N_DESCRIPTION_WEBHOOK из env или константа (n8n.clientright.ru).
|
||||
"""
|
||||
# #region agent log
|
||||
_debug_log("H1_H4", "POST /description handler entered", {"session_id": getattr(payload, "session_id", None)})
|
||||
# #endregion
|
||||
try:
|
||||
description_webhook_url = _get_description_webhook_url()
|
||||
# #region agent log
|
||||
_debug_log("H3_H5", "description webhook URL resolved", {"url": description_webhook_url[:80] if description_webhook_url else "", "env_N8N": (os.environ.get("N8N_DESCRIPTION_WEBHOOK") or "")[:80]})
|
||||
# #endregion
|
||||
if not description_webhook_url:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="N8N description webhook не настроен"
|
||||
)
|
||||
|
||||
# Если unified_id не передан — подставляем из сессии в Redis (tg/max auth создают сессию с unified_id)
|
||||
unified_id = payload.unified_id
|
||||
contact_id = payload.contact_id
|
||||
phone = payload.phone
|
||||
if not unified_id and payload.session_id:
|
||||
try:
|
||||
session_key = f"session:{payload.session_id}"
|
||||
session_raw = await redis_service.client.get(session_key)
|
||||
if session_raw:
|
||||
session_data = json.loads(session_raw)
|
||||
unified_id = unified_id or session_data.get("unified_id")
|
||||
contact_id = contact_id or session_data.get("contact_id")
|
||||
phone = phone or session_data.get("phone")
|
||||
if unified_id:
|
||||
logger.info("📝 unified_id/contact_id/phone подставлены из сессии Redis: session_key=%s", session_key)
|
||||
except Exception as e:
|
||||
logger.warning("Не удалось прочитать сессию из Redis для подстановки unified_id: %s", e)
|
||||
|
||||
# Формируем данные в формате, который ожидает 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
|
||||
"phone": payload.phone,
|
||||
"phone": phone,
|
||||
"email": payload.email,
|
||||
"unified_id": payload.unified_id, # ✅ Unified ID пользователя
|
||||
"contact_id": payload.contact_id, # ✅ Contact ID пользователя
|
||||
"unified_id": unified_id, # из запроса или из сессии Redis
|
||||
"contact_id": contact_id,
|
||||
"description": payload.problem_description.strip(),
|
||||
"source": payload.source,
|
||||
"entry_channel": (payload.entry_channel or "web").strip() or "web", # telegram | max | web — для роутинга в n8n
|
||||
"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",
|
||||
"📝 TicketForm description received → webhook=%s",
|
||||
description_webhook_url[:80] + ("..." if len(description_webhook_url) > 80 else ""),
|
||||
extra={
|
||||
"session_id": payload.session_id,
|
||||
"claim_id": payload.claim_id or "not_set",
|
||||
"phone": payload.phone,
|
||||
"unified_id": payload.unified_id or "not_set",
|
||||
"contact_id": payload.contact_id or "not_set",
|
||||
"description_length": len(payload.problem_description),
|
||||
"channel": channel,
|
||||
},
|
||||
)
|
||||
|
||||
# Retry-логика: пытаемся отправить в n8n webhook
|
||||
max_attempts = 3
|
||||
initial_delay = 1 # секунды
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
logger.info(
|
||||
"📡 Publishing to Redis channel",
|
||||
extra={
|
||||
"channel": channel,
|
||||
"event_type": event["type"],
|
||||
"event_keys": list(event.keys()),
|
||||
"json_length": len(event_json),
|
||||
},
|
||||
f"🔄 Попытка {attempt}/{max_attempts}: отправка в n8n webhook",
|
||||
extra={"session_id": payload.session_id}
|
||||
)
|
||||
|
||||
subscribers_count = await redis_service.publish(channel, event_json)
|
||||
|
||||
# #region agent log
|
||||
_debug_log("H2_H4", "about to POST to n8n webhook", {"attempt": attempt, "url_short": description_webhook_url[:60] if description_webhook_url else ""})
|
||||
# #endregion
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
description_webhook_url,
|
||||
json=webhook_payload, # Отправляем в формате массива
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
# #region agent log
|
||||
_debug_log("H4", "n8n webhook response", {"status": response.status_code, "url_short": description_webhook_url[:60] if description_webhook_url else ""})
|
||||
# #endregion
|
||||
if response.status_code == 200:
|
||||
response_body = response.text or ""
|
||||
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],
|
||||
},
|
||||
"✅ Описание успешно отправлено в n8n webhook (попытка %s), ответ n8n (length=%s): %s",
|
||||
attempt,
|
||||
len(response_body),
|
||||
response_body[:2000] if len(response_body) > 2000 else response_body,
|
||||
extra={"session_id": payload.session_id},
|
||||
)
|
||||
|
||||
if subscribers_count == 0:
|
||||
try:
|
||||
parsed_n8n = json.loads(response_body)
|
||||
logger.info(
|
||||
"n8n description response (parsed): keys=%s",
|
||||
list(parsed_n8n.keys()) if isinstance(parsed_n8n, dict) else type(parsed_n8n).__name__,
|
||||
extra={"session_id": payload.session_id},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# После описания фронт подписывается на SSE — логируем, на что именно
|
||||
logger.info(
|
||||
"📡 После описания в n8n клиент подпишется на: "
|
||||
"channel_ocr=ocr_events:%s (GET /api/v1/events/%s), "
|
||||
"channel_plan=claim:plan:%s (GET /api/v1/claim-plan/%s)",
|
||||
payload.session_id, payload.session_id, payload.session_id, payload.session_id,
|
||||
extra={"session_id": payload.session_id},
|
||||
)
|
||||
# Успешно отправили - возвращаем успех
|
||||
return {
|
||||
"success": True,
|
||||
"event": message,
|
||||
"attempt": attempt,
|
||||
}
|
||||
else:
|
||||
# HTTP ошибка (не 200)
|
||||
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..."
|
||||
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
|
||||
)
|
||||
|
||||
# Если это не последняя попытка - ждём перед следующей
|
||||
if attempt < max_attempts:
|
||||
wait_time = initial_delay * (2 ** (attempt - 1)) # Экспоненциальный backoff
|
||||
logger.info(f"⏳ Жду {wait_time} секунд перед следующей попыткой...")
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
# Все попытки исчерпаны - сохраняем в буфер
|
||||
logger.error(
|
||||
f"❌ Все {max_attempts} попытки исчерпаны, сохраняю в буфер",
|
||||
extra={"session_id": payload.session_id}
|
||||
)
|
||||
|
||||
# Сохраняем сообщение в буфер для последующей отправки
|
||||
buffer_message = {
|
||||
"session_id": payload.session_id,
|
||||
"claim_id": payload.claim_id,
|
||||
"event": event,
|
||||
"channel": channel,
|
||||
"message": message, # Сохраняем message для последующей отправки
|
||||
"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)
|
||||
# Запускаем фоновую задачу для отправки из буфера
|
||||
background_tasks.add_task(_send_buffered_messages_to_webhook)
|
||||
|
||||
# Дополнительная проверка: логируем полный event для отладки
|
||||
logger.debug(
|
||||
"🔍 Full event data published",
|
||||
extra={
|
||||
"channel": channel,
|
||||
"event": event,
|
||||
},
|
||||
)
|
||||
# Формируем ответ с информацией о подписчиках
|
||||
response_data = {
|
||||
"success": True,
|
||||
"channel": channel,
|
||||
"subscribers_count": subscribers_count,
|
||||
"event": event,
|
||||
}
|
||||
|
||||
# Если подписчиков нет - сообщаем что обработка займёт больше времени
|
||||
if subscribers_count == 0:
|
||||
buffer_size = await redis_service.buffer_size("description")
|
||||
response_data["warning"] = (
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
213
backend/app/api/consultations.py
Normal file
213
backend/app/api/consultations.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Консультации: тикеты из CRM (MySQL) через N8N_TICKET_FORM_CONSULTATION_WEBHOOK.
|
||||
|
||||
GET/POST /api/v1/consultations — верификация сессии, вызов webhook с тем же payload,
|
||||
что и у других хуков (session_token, unified_id, contact_id, phone, chat_id, entry_channel, form_id).
|
||||
Ответ webhook возвращается клиенту (список тикетов и т.д.).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings
|
||||
from app.api.session import SessionVerifyRequest, verify_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/consultations", tags=["consultations"])
|
||||
|
||||
|
||||
class ConsultationsPostBody(BaseModel):
|
||||
"""Тело запроса: session_token обязателен для идентификации."""
|
||||
session_token: str = Field(..., description="Токен сессии")
|
||||
entry_channel: Optional[str] = Field("web", description="Канал входа: telegram | max | web")
|
||||
|
||||
|
||||
class TicketDetailBody(BaseModel):
|
||||
"""Тело запроса «подробнее по тикету»."""
|
||||
session_token: str = Field(..., description="Токен сессии")
|
||||
ticket_id: Any = Field(..., description="ID тикета в CRM (ticketid)")
|
||||
entry_channel: Optional[str] = Field("web", description="Канал входа")
|
||||
|
||||
|
||||
def _get_consultation_webhook_url() -> str:
|
||||
url = (getattr(settings, "n8n_ticket_form_consultation_webhook", None) or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="N8N_TICKET_FORM_CONSULTATION_WEBHOOK не настроен",
|
||||
)
|
||||
return url
|
||||
|
||||
|
||||
def _get_podrobnee_webhook_url() -> str:
|
||||
url = (getattr(settings, "n8n_ticket_form_podrobnee_webhook", None) or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="N8N_TICKET_FORM_PODROBNEE_WEBHOOK не настроен",
|
||||
)
|
||||
return url
|
||||
|
||||
|
||||
async def _call_consultation_webhook(
|
||||
session_token: str,
|
||||
entry_channel: str = "web",
|
||||
) -> dict:
|
||||
"""
|
||||
Верифицировать сессию, собрать payload как у других хуков, POST в webhook, вернуть ответ.
|
||||
"""
|
||||
verify_res = await verify_session(SessionVerifyRequest(session_token=session_token))
|
||||
if not getattr(verify_res, "valid", False):
|
||||
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
|
||||
|
||||
unified_id = getattr(verify_res, "unified_id", None)
|
||||
if not unified_id:
|
||||
raise HTTPException(status_code=401, detail="Сессия не содержит unified_id")
|
||||
|
||||
contact_id = getattr(verify_res, "contact_id", None)
|
||||
phone = getattr(verify_res, "phone", None)
|
||||
chat_id = getattr(verify_res, "chat_id", None)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"form_id": "ticket_form",
|
||||
"session_token": session_token,
|
||||
"unified_id": unified_id,
|
||||
"entry_channel": (entry_channel or "web").strip() or "web",
|
||||
}
|
||||
if contact_id is not None:
|
||||
payload["contact_id"] = contact_id
|
||||
if phone is not None:
|
||||
payload["phone"] = phone
|
||||
if chat_id is not None and str(chat_id).strip():
|
||||
payload["chat_id"] = str(chat_id).strip()
|
||||
|
||||
webhook_url = _get_consultation_webhook_url()
|
||||
logger.info("Consultation webhook: POST %s, keys=%s", webhook_url[:60], list(payload.keys()))
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.post(
|
||||
webhook_url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Таймаут вызова N8N_TICKET_FORM_CONSULTATION_WEBHOOK")
|
||||
raise HTTPException(status_code=504, detail="Сервис консультаций временно недоступен")
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка вызова N8N_TICKET_FORM_CONSULTATION_WEBHOOK: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Сервис консультаций временно недоступен")
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"Consultation webhook вернул %s: %s",
|
||||
response.status_code,
|
||||
response.text[:500],
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Сервис консультаций вернул ошибку",
|
||||
)
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except Exception:
|
||||
return {"raw": response.text or ""}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_consultations(
|
||||
session_token: Optional[str] = Query(None, description="Токен сессии"),
|
||||
entry_channel: Optional[str] = Query("web", description="Канал входа: telegram | max | web"),
|
||||
):
|
||||
"""
|
||||
Получить данные консультаций (тикеты из CRM) через n8n webhook.
|
||||
Передаётся тот же payload, что и на другие хуки: session_token, unified_id, contact_id, phone, chat_id, entry_channel, form_id.
|
||||
"""
|
||||
if not session_token or not str(session_token).strip():
|
||||
raise HTTPException(status_code=400, detail="Укажите session_token")
|
||||
return await _call_consultation_webhook(
|
||||
session_token=str(session_token).strip(),
|
||||
entry_channel=entry_channel or "web",
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def post_consultations(body: ConsultationsPostBody):
|
||||
"""То же по телу запроса."""
|
||||
return await _call_consultation_webhook(
|
||||
session_token=body.session_token.strip(),
|
||||
entry_channel=body.entry_channel or "web",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ticket-detail")
|
||||
async def get_ticket_detail(body: TicketDetailBody):
|
||||
"""
|
||||
Подробнее по тикету: верификация сессии, вызов N8N_TICKET_FORM_PODROBNEE_WEBHOOK
|
||||
с payload (session_token, unified_id, contact_id, phone, ticket_id, entry_channel, form_id).
|
||||
Ответ вебхука возвращается клиенту как есть (HTML в поле html/body или весь JSON).
|
||||
"""
|
||||
session_token = str(body.session_token or "").strip()
|
||||
if not session_token:
|
||||
raise HTTPException(status_code=400, detail="Укажите session_token")
|
||||
ticket_id = body.ticket_id
|
||||
if ticket_id is None or (isinstance(ticket_id, str) and not str(ticket_id).strip()):
|
||||
raise HTTPException(status_code=400, detail="Укажите ticket_id")
|
||||
|
||||
verify_res = await verify_session(SessionVerifyRequest(session_token=session_token))
|
||||
if not getattr(verify_res, "valid", False):
|
||||
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
|
||||
unified_id = getattr(verify_res, "unified_id", None)
|
||||
if not unified_id:
|
||||
raise HTTPException(status_code=401, detail="Сессия не содержит unified_id")
|
||||
|
||||
contact_id = getattr(verify_res, "contact_id", None)
|
||||
phone = getattr(verify_res, "phone", None)
|
||||
chat_id = getattr(verify_res, "chat_id", None)
|
||||
entry_channel = (body.entry_channel or "web").strip() or "web"
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"form_id": "ticket_form",
|
||||
"session_token": session_token,
|
||||
"unified_id": unified_id,
|
||||
"ticket_id": int(ticket_id) if isinstance(ticket_id, str) and ticket_id.isdigit() else ticket_id,
|
||||
"entry_channel": entry_channel,
|
||||
}
|
||||
if contact_id is not None:
|
||||
payload["contact_id"] = contact_id
|
||||
if phone is not None:
|
||||
payload["phone"] = phone
|
||||
if chat_id is not None and str(chat_id).strip():
|
||||
payload["chat_id"] = str(chat_id).strip()
|
||||
|
||||
webhook_url = _get_podrobnee_webhook_url()
|
||||
logger.info("Podrobnee webhook: POST %s, ticket_id=%s", webhook_url[:60], payload.get("ticket_id"))
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
webhook_url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Таймаут вызова N8N_TICKET_FORM_PODROBNEE_WEBHOOK")
|
||||
raise HTTPException(status_code=504, detail="Сервис временно недоступен")
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка вызова N8N_TICKET_FORM_PODROBNEE_WEBHOOK: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Сервис временно недоступен")
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning("Podrobnee webhook вернул %s: %s", response.status_code, response.text[:500])
|
||||
raise HTTPException(status_code=502, detail="Сервис вернул ошибку")
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except Exception:
|
||||
return {"html": response.text or "", "raw": True}
|
||||
89
backend/app/api/debug_session.py
Normal file
89
backend/app/api/debug_session.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import base64
|
||||
import json
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
WEBHOOK_DEBUG_URL = "https://n8n.clientright.ru/webhook/test"
|
||||
|
||||
router = APIRouter(prefix="/api/v1/debug", tags=["debug"])
|
||||
|
||||
|
||||
@router.post("/forward-to-webhook")
|
||||
async def forward_to_webhook(request: Request):
|
||||
"""
|
||||
Прокси: принимает JSON body и пересылает на n8n webhook (обход CORS с debug-webapp).
|
||||
Сначала POST; если n8n вернёт 404 (webhook только GET) — повторяем GET с ?data=base64(body).
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
r = await client.post(WEBHOOK_DEBUG_URL, json=body)
|
||||
if r.status_code == 404 and "POST" in (r.text or ""):
|
||||
b64 = base64.urlsafe_b64encode(json.dumps(body, ensure_ascii=False).encode()).decode().rstrip("=")
|
||||
r = await client.get(f"{WEBHOOK_DEBUG_URL}?data={quote_plus(b64)}")
|
||||
ct = r.headers.get("content-type", "")
|
||||
if "application/json" in ct:
|
||||
try:
|
||||
content = r.json()
|
||||
except Exception:
|
||||
content = {"status": r.status_code, "text": (r.text or "")[:500]}
|
||||
else:
|
||||
content = {"status": r.status_code, "text": (r.text or "")[:500]}
|
||||
return JSONResponse(status_code=r.status_code, content=content)
|
||||
|
||||
|
||||
@router.get("/set_session_redirect", response_class=HTMLResponse)
|
||||
async def set_session_redirect(request: Request, session_token: str = "", claim_id: str = "", redirect_to: str = "/hello"):
|
||||
"""
|
||||
Temporary helper: returns an HTML page that sets localStorage.session_token and redirects to /hello?claim_id=...
|
||||
Use for manual testing: open this URL in a browser on the target origin.
|
||||
"""
|
||||
# Ensure values are safe for embedding
|
||||
js_session = session_token.replace('"', '\\"')
|
||||
target_claim = quote_plus(claim_id) if claim_id else ""
|
||||
# sanitize redirect_to - allow only absolute path starting with '/'
|
||||
if not redirect_to.startswith('/'):
|
||||
redirect_to = '/hello'
|
||||
if target_claim:
|
||||
# append query param correctly
|
||||
if '?' in redirect_to:
|
||||
redirect_url = f"{redirect_to}&claim_id={target_claim}"
|
||||
else:
|
||||
redirect_url = f"{redirect_to}?claim_id={target_claim}"
|
||||
else:
|
||||
redirect_url = redirect_to
|
||||
|
||||
html = f"""<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Set session and redirect</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
try {{
|
||||
const token = "{js_session}";
|
||||
if (token && token.length>0) {{
|
||||
localStorage.setItem('session_token', token);
|
||||
console.log('Set localStorage.session_token:', token);
|
||||
}} else {{
|
||||
console.log('No session_token provided');
|
||||
}}
|
||||
// give localStorage a tick then redirect
|
||||
setTimeout(() => {{
|
||||
window.location.href = "{redirect_url}";
|
||||
}}, 200);
|
||||
}} catch (e) {{
|
||||
document.body.innerText = 'Error: ' + e;
|
||||
}}
|
||||
</script>
|
||||
<p>Setting session and redirecting...</p>
|
||||
<p>If you are not redirected, click <a id="go" href="{redirect_url}">here</a>.</p>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(content=html, status_code=200)
|
||||
|
||||
@@ -491,6 +491,32 @@ async def skip_document(
|
||||
},
|
||||
)
|
||||
|
||||
# Сохраняем documents_skipped в БД, чтобы при следующем заходе состояние не обнулялось
|
||||
claim_id_clean = claim_id.replace("claim_id_", "", 1) if claim_id.startswith("claim_id_") else claim_id
|
||||
try:
|
||||
row = await db.fetch_one(
|
||||
"SELECT id, payload FROM clpr_claims WHERE (payload->>'claim_id' = $1 OR id::text = $1) ORDER BY updated_at DESC LIMIT 1",
|
||||
claim_id_clean,
|
||||
)
|
||||
if row:
|
||||
payload_raw = row.get("payload") or {}
|
||||
payload = json.loads(payload_raw) if isinstance(payload_raw, str) else (payload_raw if isinstance(payload_raw, dict) else {})
|
||||
skipped = list(payload.get("documents_skipped") or [])
|
||||
if document_type not in skipped:
|
||||
skipped.append(document_type)
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE clpr_claims
|
||||
SET payload = jsonb_set(COALESCE(payload, '{}'::jsonb), '{documents_skipped}', $1::jsonb)
|
||||
WHERE (payload->>'claim_id' = $2 OR id::text = $2)
|
||||
""",
|
||||
json.dumps(skipped),
|
||||
claim_id_clean,
|
||||
)
|
||||
logger.info("✅ documents_skipped сохранён в БД для claim_id=%s", claim_id_clean)
|
||||
except Exception as e:
|
||||
logger.warning("⚠️ Не удалось сохранить documents_skipped в БД: %s", e)
|
||||
|
||||
# Парсим ответ от n8n
|
||||
try:
|
||||
n8n_response = json.loads(response_text)
|
||||
|
||||
132
backend/app/api/documents_draft_open.py
Normal file
132
backend/app/api/documents_draft_open.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Documents draft-open endpoint
|
||||
|
||||
This file provides a single, isolated endpoint to fetch the documents list
|
||||
and minimal claim metadata for a given claim_id. It is implemented as a
|
||||
separate router to avoid touching existing document/claim routes.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import RedirectResponse
|
||||
from ..config import settings
|
||||
import logging
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
from ..services.database import db
|
||||
|
||||
router = APIRouter(prefix="/api/v1/documents-draft", tags=["DocumentsDraft"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/open/{claim_id}")
|
||||
async def open_documents_draft(claim_id: str):
|
||||
"""
|
||||
Return minimal draft info focused on documents for the given claim_id.
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": True,
|
||||
"claim_id": "...",
|
||||
"session_token": "...",
|
||||
"status_code": "...",
|
||||
"documents_required": [...],
|
||||
"documents_meta": [...],
|
||||
"documents_count": 3,
|
||||
"created_at": "...",
|
||||
"updated_at": "..."
|
||||
}
|
||||
"""
|
||||
try:
|
||||
query = """
|
||||
SELECT
|
||||
id,
|
||||
payload->>'claim_id' AS claim_id,
|
||||
session_token,
|
||||
status_code,
|
||||
payload->'documents_required' AS documents_required,
|
||||
payload->'documents_meta' AS documents_meta,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM clpr_claims
|
||||
WHERE (payload->>'claim_id' = $1 OR id::text = $1)
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
row = await db.fetch_one(query, claim_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail=f"Draft not found: {claim_id}")
|
||||
|
||||
# Normalize JSONB fields which may be strings
|
||||
def parse_json_field(val: Any):
|
||||
if val is None:
|
||||
return []
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
return json.loads(val)
|
||||
except Exception:
|
||||
return []
|
||||
return val if isinstance(val, list) else []
|
||||
|
||||
documents_required = parse_json_field(row.get("documents_required"))
|
||||
documents_meta = parse_json_field(row.get("documents_meta"))
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"claim_id": row.get("claim_id") or str(row.get("id")),
|
||||
"session_token": row.get("session_token"),
|
||||
"status_code": row.get("status_code"),
|
||||
"documents_required": documents_required,
|
||||
"documents_meta": documents_meta,
|
||||
"documents_count": len(documents_required),
|
||||
"created_at": row.get("created_at").isoformat() if row.get("created_at") else None,
|
||||
"updated_at": row.get("updated_at").isoformat() if row.get("updated_at") else None,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("Failed to open documents draft")
|
||||
raise HTTPException(status_code=500, detail=f"Error opening documents draft: {str(e)}")
|
||||
|
||||
|
||||
|
||||
@router.get("/open/launch/{claim_id}")
|
||||
async def launch_documents_draft(
|
||||
claim_id: str,
|
||||
target: str = Query("miniapp", description="Where to open: 'miniapp' or 'max'"),
|
||||
bot_name: str | None = Query(None, description="MAX bot name (required if target=max)"),
|
||||
):
|
||||
"""
|
||||
Convenience launcher:
|
||||
- target=miniapp (default) -> redirects to our miniapp URL with claim_id
|
||||
https://miniapp.clientright.ru/hello?claim_id=...
|
||||
- target=max -> redirects to MAX deep link:
|
||||
https://max.ru/{bot_name}?startapp={claim_id}
|
||||
This endpoint only redirects; it does not change persisted data.
|
||||
"""
|
||||
try:
|
||||
# ensure claim exists
|
||||
query = "SELECT 1 FROM clpr_claims WHERE (payload->>'claim_id' = $1 OR id::text = $1) LIMIT 1"
|
||||
row = await db.fetch_one(query, claim_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail=f"Draft not found: {claim_id}")
|
||||
|
||||
if target == "max":
|
||||
bot = bot_name or getattr(settings, "MAX_BOT_NAME", None)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=400, detail="bot_name is required when target=max")
|
||||
# claim_id is UUID with allowed chars (hex + hyphens) - OK for startapp
|
||||
url = f"https://max.ru/{bot}?startapp={claim_id}"
|
||||
return RedirectResponse(url)
|
||||
else:
|
||||
# default: open miniapp directly (hosted at /hello)
|
||||
url = f"https://miniapp.clientright.ru/hello?claim_id={claim_id}"
|
||||
return RedirectResponse(url)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("Failed to launch documents draft")
|
||||
raise HTTPException(status_code=500, detail=f"Error launching documents draft: {str(e)}")
|
||||
|
||||
@@ -9,12 +9,108 @@ from pydantic import BaseModel
|
||||
from typing import Dict, Any
|
||||
from app.services.redis_service import redis_service
|
||||
from app.services.database import db
|
||||
from app.config import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["Events"])
|
||||
|
||||
# Типы для единого отображения на фронте: тип + текст (+ data для consumer_complaint)
|
||||
DISPLAY_EVENT_TYPES = ("trash_message", "out_of_scope", "consumer_consultation", "consumer_complaint")
|
||||
|
||||
|
||||
def _normalize_display_event(actual_event: dict) -> dict:
|
||||
"""
|
||||
Приводит событие к формату { event_type, message [, data] } для единого отображения.
|
||||
event_type — один из: trash_message (красный), out_of_scope (жёлтый),
|
||||
consumer_consultation (синий), consumer_complaint (зелёный).
|
||||
"""
|
||||
raw_type = actual_event.get("event_type") or actual_event.get("type")
|
||||
payload = actual_event.get("payload") or actual_event.get("data") or {}
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
payload = json.loads(payload) if payload else {}
|
||||
except Exception:
|
||||
payload = {}
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
msg = (actual_event.get("message") or payload.get("message") or "").strip() or "Ответ получен"
|
||||
|
||||
# Если n8n уже прислал один из четырёх типов — не перезаписываем, отдаём как есть (синий/зелёный не превращаем в жёлтый)
|
||||
if raw_type in DISPLAY_EVENT_TYPES:
|
||||
return {
|
||||
"event_type": raw_type,
|
||||
"message": msg or "Ответ получен",
|
||||
"data": actual_event.get("data", {}),
|
||||
"suggested_actions": (actual_event.get("suggested_actions") or payload.get("suggested_actions")) if raw_type == "out_of_scope" else None,
|
||||
}
|
||||
|
||||
if raw_type == "trash_message" or payload.get("intent") == "trash":
|
||||
return {
|
||||
"event_type": "trash_message",
|
||||
"message": msg or "К сожалению, это обращение не по тематике.",
|
||||
"data": actual_event.get("data", {}),
|
||||
}
|
||||
if raw_type == "out_of_scope":
|
||||
return {
|
||||
"event_type": "out_of_scope",
|
||||
"message": msg or "К сожалению, мы не можем помочь с этим вопросом.",
|
||||
"data": actual_event.get("data", {}),
|
||||
"suggested_actions": actual_event.get("suggested_actions") or payload.get("suggested_actions"),
|
||||
}
|
||||
if raw_type == "consumer_intent":
|
||||
intent = payload.get("intent") or actual_event.get("intent")
|
||||
if intent == "consultation":
|
||||
return {
|
||||
"event_type": "consumer_consultation",
|
||||
"message": msg or "Понял. Это похоже на консультацию.",
|
||||
"data": {},
|
||||
}
|
||||
return {
|
||||
"event_type": "consumer_complaint",
|
||||
"message": msg or "Обращение принято.",
|
||||
"data": actual_event.get("data", {}),
|
||||
}
|
||||
if raw_type == "documents_list_ready":
|
||||
return {
|
||||
"event_type": "consumer_complaint",
|
||||
"message": msg or "Подготовлен список документов.",
|
||||
"data": {
|
||||
**actual_event.get("data", {}),
|
||||
"documents_required": actual_event.get("documents_required"),
|
||||
"claim_id": actual_event.get("claim_id"),
|
||||
},
|
||||
}
|
||||
if raw_type in ("wizard_ready", "wizard_plan_ready", "claim_plan_ready"):
|
||||
return {
|
||||
"event_type": "consumer_complaint",
|
||||
"message": msg or "План готов.",
|
||||
"data": actual_event.get("data", actual_event),
|
||||
}
|
||||
if raw_type == "ocr_status" and actual_event.get("status") == "ready":
|
||||
return {
|
||||
"event_type": "consumer_complaint",
|
||||
"message": msg or "Данные подтверждены.",
|
||||
"data": actual_event.get("data", {}),
|
||||
}
|
||||
# Если есть текст сообщения, но тип неизвестен — считаем out_of_scope, чтобы фронт точно показал ответ
|
||||
if msg and msg.strip() and raw_type not in (
|
||||
"documents_list_ready", "document_uploaded", "document_ocr_completed",
|
||||
"ocr_status", "claim_ready", "claim_plan_ready", "claim_plan_error",
|
||||
):
|
||||
return {
|
||||
"event_type": "out_of_scope",
|
||||
"message": msg.strip(),
|
||||
"data": actual_event.get("data", {}),
|
||||
"suggested_actions": actual_event.get("suggested_actions"),
|
||||
}
|
||||
# Остальные события — прозрачно, только дополняем message
|
||||
out = dict(actual_event)
|
||||
if "message" not in out or not out.get("message"):
|
||||
out["message"] = msg
|
||||
return out
|
||||
|
||||
|
||||
class EventPublish(BaseModel):
|
||||
"""Модель для публикации события"""
|
||||
@@ -84,7 +180,10 @@ async def stream_events(task_id: str):
|
||||
Returns:
|
||||
StreamingResponse с событиями
|
||||
"""
|
||||
logger.info(f"🚀 SSE connection requested for session_token: {task_id}")
|
||||
logger.info(
|
||||
"🚀 SSE connection requested for session_token: %s → channel=ocr_events:%s (Redis %s:%s)",
|
||||
task_id, task_id, settings.redis_host, settings.redis_port,
|
||||
)
|
||||
|
||||
async def event_generator():
|
||||
"""Генератор событий из Redis Pub/Sub"""
|
||||
@@ -95,7 +194,10 @@ async def stream_events(task_id: str):
|
||||
pubsub = redis_service.client.pubsub()
|
||||
await pubsub.subscribe(channel)
|
||||
|
||||
logger.info(f"📡 Client subscribed to {channel}")
|
||||
logger.info(
|
||||
"📡 Subscribed to channel=%s on Redis %s:%s (проверка: redis-cli -h %s PUBSUB NUMSUB %s)",
|
||||
channel, settings.redis_host, settings.redis_port, settings.redis_host, channel,
|
||||
)
|
||||
|
||||
# Отправляем начальное событие
|
||||
yield f"data: {json.dumps({'status': 'connected', 'message': 'Подключено к событиям'})}\n\n"
|
||||
@@ -298,10 +400,14 @@ async def stream_events(task_id: str):
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error loading form_draft from PostgreSQL: {e}")
|
||||
|
||||
# Единый формат для фронта: событие с полями event_type и message (и data при необходимости)
|
||||
raw_event_type = actual_event.get("event_type")
|
||||
raw_status = actual_event.get("status")
|
||||
actual_event = _normalize_display_event(actual_event)
|
||||
# Отправляем событие клиенту (плоский формат)
|
||||
event_json = json.dumps(actual_event, ensure_ascii=False, default=str)
|
||||
event_type_sent = actual_event.get('event_type', 'unknown')
|
||||
event_status = actual_event.get('status', 'unknown')
|
||||
event_type_sent = actual_event.get("event_type", "unknown")
|
||||
event_status = actual_event.get("status") or (actual_event.get("data") or {}).get("status") or "unknown"
|
||||
# Логируем размер и наличие данных
|
||||
data_info = actual_event.get('data', {})
|
||||
has_form_draft = 'form_draft' in data_info if isinstance(data_info, dict) else False
|
||||
@@ -310,18 +416,21 @@ async def stream_events(task_id: str):
|
||||
|
||||
# Если обработка завершена - закрываем соединение
|
||||
# НЕ закрываем для documents_list_ready и document_ocr_completed (ждём ещё события)
|
||||
if event_status in ['completed', 'error'] and event_type_sent not in ['documents_list_ready', 'document_ocr_completed', 'document_uploaded']:
|
||||
if event_status in ['completed', 'error'] and (raw_event_type or event_type_sent) not in ['documents_list_ready', 'document_ocr_completed', 'document_uploaded']:
|
||||
logger.info(f"✅ Task {task_id} finished, closing SSE")
|
||||
break
|
||||
|
||||
# Закрываем для финальных событий
|
||||
# Закрываем для финальных событий (raw_event_type до нормализации)
|
||||
if raw_event_type in ['claim_ready', 'claim_plan_ready', 'wizard_ready', 'wizard_plan_ready']:
|
||||
logger.info(f"✅ Final event {raw_event_type} sent, closing SSE")
|
||||
break
|
||||
if event_type_sent in ['claim_ready', 'claim_plan_ready']:
|
||||
logger.info(f"✅ Final event {event_type_sent} sent, closing SSE")
|
||||
break
|
||||
|
||||
# Закрываем для ocr_status ready (форма заявления готова)
|
||||
if event_type_sent == 'ocr_status' and event_status == 'ready':
|
||||
logger.info(f"✅ OCR ready event sent, closing SSE")
|
||||
if raw_event_type == "ocr_status" and raw_status == "ready":
|
||||
logger.info("✅ OCR ready event sent, closing SSE")
|
||||
break
|
||||
else:
|
||||
logger.info(f"⏰ Timeout waiting for message on {channel}")
|
||||
@@ -369,7 +478,10 @@ async def stream_claim_plan(session_token: str):
|
||||
}
|
||||
}
|
||||
"""
|
||||
logger.info(f"🚀 Claim plan SSE connection requested for session_token: {session_token}")
|
||||
logger.info(
|
||||
"🚀 Claim plan SSE: session_token=%s → channel=claim:plan:%s (Redis %s:%s)",
|
||||
session_token, session_token, settings.redis_host, settings.redis_port,
|
||||
)
|
||||
|
||||
async def claim_plan_generator():
|
||||
"""Генератор событий из Redis Pub/Sub для claim:plan канала"""
|
||||
@@ -379,7 +491,10 @@ async def stream_claim_plan(session_token: str):
|
||||
pubsub = redis_service.client.pubsub()
|
||||
await pubsub.subscribe(channel)
|
||||
|
||||
logger.info(f"📡 Client subscribed to {channel}")
|
||||
logger.info(
|
||||
"📡 Subscribed to channel=%s on Redis %s:%s (PUBSUB NUMSUB %s)",
|
||||
channel, settings.redis_host, settings.redis_port, channel,
|
||||
)
|
||||
|
||||
# Отправляем начальное событие
|
||||
yield f"data: {json.dumps({'status': 'connected', 'message': 'Ожидание данных заявления...'})}\n\n"
|
||||
|
||||
156
backend/app/api/max_auth.py
Normal file
156
backend/app/api/max_auth.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
MAX Mini App (WebApp) auth endpoint.
|
||||
|
||||
/api/v1/max/auth:
|
||||
- Принимает init_data от MAX Bridge (window.WebApp.initData)
|
||||
- Валидирует init_data и извлекает данные пользователя MAX
|
||||
- Проксирует max_user_id в n8n для получения unified_id/контакта
|
||||
- Создаёт сессию в Redis (аналогично Telegram — без SMS)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..services.max_auth import extract_max_user, MaxAuthError
|
||||
from ..config import settings
|
||||
from . import n8n_proxy
|
||||
from . import session as session_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/max", tags=["MAX"])
|
||||
|
||||
|
||||
class MaxAuthRequest(BaseModel):
|
||||
init_data: str
|
||||
session_token: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
|
||||
|
||||
class MaxAuthResponse(BaseModel):
|
||||
success: bool
|
||||
session_token: Optional[str] = None
|
||||
unified_id: Optional[str] = None
|
||||
contact_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
has_drafts: Optional[bool] = None
|
||||
need_contact: Optional[bool] = None
|
||||
|
||||
|
||||
def _generate_session_token() -> str:
|
||||
import uuid
|
||||
return f"sess-{uuid.uuid4()}"
|
||||
|
||||
|
||||
@router.post("/auth", response_model=MaxAuthResponse)
|
||||
async def max_auth(request: MaxAuthRequest):
|
||||
"""
|
||||
Авторизация пользователя через MAX WebApp (Mini App).
|
||||
"""
|
||||
init_data = request.init_data or ""
|
||||
phone = (request.phone or "").strip()
|
||||
logger.info(
|
||||
"[MAX] POST /api/v1/max/auth: init_data длина=%s, phone=%s, session_token=%s",
|
||||
len(init_data),
|
||||
bool(phone),
|
||||
bool(request.session_token),
|
||||
)
|
||||
if not init_data:
|
||||
logger.warning("[MAX] init_data пустой")
|
||||
raise HTTPException(status_code=400, detail="init_data обязателен")
|
||||
|
||||
bot_configured = bool((getattr(settings, "max_bot_token", None) or "").strip())
|
||||
webhook_configured = bool((getattr(settings, "n8n_max_auth_webhook", None) or "").strip())
|
||||
logger.info("[MAX] Конфиг: MAX_BOT_TOKEN=%s, N8N_MAX_AUTH_WEBHOOK=%s", bot_configured, webhook_configured)
|
||||
|
||||
try:
|
||||
max_user = extract_max_user(request.init_data)
|
||||
except MaxAuthError as e:
|
||||
logger.warning("[MAX] Ошибка валидации initData: %s", e)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
max_user_id = max_user["max_user_id"]
|
||||
logger.info("[MAX] MAX user валиден: id=%s, username=%s", max_user_id, max_user.get("username"))
|
||||
|
||||
session_token = request.session_token or _generate_session_token()
|
||||
|
||||
n8n_payload = {
|
||||
"max_user_id": max_user_id,
|
||||
"username": max_user.get("username"),
|
||||
"first_name": max_user.get("first_name"),
|
||||
"last_name": max_user.get("last_name"),
|
||||
"session_token": session_token,
|
||||
"form_id": "ticket_form",
|
||||
"init_data": request.init_data,
|
||||
}
|
||||
if phone:
|
||||
n8n_payload["phone"] = phone
|
||||
|
||||
logger.info("[MAX] Валидация OK → вызов n8n webhook (max_user_id=%s)", max_user_id)
|
||||
try:
|
||||
class _DummyRequest:
|
||||
def __init__(self, payload: dict):
|
||||
self._payload = payload
|
||||
async def json(self):
|
||||
return self._payload
|
||||
|
||||
n8n_response = await n8n_proxy.proxy_max_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
|
||||
n8n_data = jsonable_encoder(n8n_response)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("[MAX] Ошибка вызова n8n MAX auth webhook: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка обращения к n8n: {str(e)}")
|
||||
|
||||
logger.info("[MAX] n8n ответ (ключи): %s", list(n8n_data.keys()) if isinstance(n8n_data, dict) else type(n8n_data).__name__)
|
||||
_result = n8n_data.get("result")
|
||||
_result_dict = _result if isinstance(_result, dict) else {}
|
||||
_raw = (
|
||||
n8n_data.get("need_contact")
|
||||
or _result_dict.get("need_contact")
|
||||
or n8n_data.get("needContact")
|
||||
or _result_dict.get("needContact")
|
||||
)
|
||||
need_contact = _raw is True or _raw == 1 or (isinstance(_raw, str) and str(_raw).strip().lower() in ("true", "1"))
|
||||
if need_contact:
|
||||
logger.info("[MAX] n8n: need_contact=true — возвращаем need_contact, фронт закроет приложение")
|
||||
return MaxAuthResponse(success=False, need_contact=True)
|
||||
|
||||
unified_id = n8n_data.get("unified_id") or _result_dict.get("unified_id") or n8n_data.get("unifiedId")
|
||||
contact_id = n8n_data.get("contact_id") or _result_dict.get("contact_id") or n8n_data.get("contactId")
|
||||
phone_res = n8n_data.get("phone") or _result_dict.get("phone")
|
||||
has_drafts = n8n_data.get("has_drafts") or _result_dict.get("has_drafts")
|
||||
|
||||
if not unified_id:
|
||||
logger.info("[MAX] n8n не вернул unified_id (юзер не в базе) — возвращаем need_contact=true. Ответ: %s", n8n_data)
|
||||
return MaxAuthResponse(success=False, need_contact=True)
|
||||
|
||||
session_request = session_api.SessionCreateRequest(
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
phone=phone_res or phone or "",
|
||||
contact_id=contact_id or "",
|
||||
ttl_hours=24,
|
||||
chat_id=str(max_user_id) if max_user_id else None,
|
||||
)
|
||||
|
||||
try:
|
||||
await session_api.create_session(session_request)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("[MAX] Ошибка создания сессии в Redis")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка создания сессии: {str(e)}")
|
||||
|
||||
return MaxAuthResponse(
|
||||
success=True,
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
contact_id=contact_id,
|
||||
phone=phone_res or phone,
|
||||
has_drafts=has_drafts,
|
||||
)
|
||||
@@ -75,4 +75,5 @@ class TicketFormDescriptionRequest(BaseModel):
|
||||
problem_description: str = Field(..., min_length=10, description="Свободное описание ситуации")
|
||||
source: str = Field("ticket_form", description="Источник события")
|
||||
channel: Optional[str] = Field(None, description="Переопределение Redis канала (опционально)")
|
||||
entry_channel: Optional[str] = Field(None, description="Канал входа: telegram | max | web — для роутинга в n8n")
|
||||
|
||||
|
||||
@@ -15,11 +15,13 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/n8n", tags=["n8n-proxy"])
|
||||
|
||||
|
||||
# URL webhooks из .env (будут добавлены)
|
||||
N8N_POLICY_CHECK_WEBHOOK = getattr(settings, 'n8n_policy_check_webhook', None)
|
||||
N8N_FILE_UPLOAD_WEBHOOK = getattr(settings, 'n8n_file_upload_webhook', None)
|
||||
N8N_CREATE_CONTACT_WEBHOOK = getattr(settings, 'n8n_create_contact_webhook', 'https://n8n.clientright.pro/webhook/511fde97-88bb-4fb4-bea5-cafdc364be27')
|
||||
N8N_CREATE_CLAIM_WEBHOOK = getattr(settings, 'n8n_create_claim_webhook', 'https://n8n.clientright.pro/webhook/d5bf4ca6-9e44-44b9-9714-3186ea703e7d')
|
||||
# URL webhooks - берём из settings (defaults в config.py)
|
||||
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
|
||||
N8N_MAX_AUTH_WEBHOOK = getattr(settings, "n8n_max_auth_webhook", None) or None
|
||||
|
||||
|
||||
@router.post("/policy/check")
|
||||
@@ -124,7 +126,9 @@ async def proxy_create_contact(request: Request):
|
||||
logger.error("⏱️ N8N webhook timeout")
|
||||
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
logger.error(f"❌ Error proxying to n8n: {e}")
|
||||
logger.error(f"❌ Traceback: {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка создания контакта: {str(e)}")
|
||||
|
||||
|
||||
@@ -217,6 +221,120 @@ 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("/max/auth")
|
||||
async def proxy_max_auth(request: Request):
|
||||
"""
|
||||
Проксирует авторизацию MAX WebApp в n8n webhook.
|
||||
Используется /api/v1/max/auth: backend валидирует initData, затем вызывает этот роут.
|
||||
"""
|
||||
if not N8N_MAX_AUTH_WEBHOOK:
|
||||
logger.error("[MAX] N8N_MAX_AUTH_WEBHOOK не задан в .env")
|
||||
raise HTTPException(status_code=500, detail="N8N MAX auth webhook не настроен")
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
logger.info(
|
||||
"[MAX] Proxy → n8n: max_user_id=%s, session_token=%s",
|
||||
body.get("max_user_id", "unknown"),
|
||||
body.get("session_token", "unknown"),
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
N8N_MAX_AUTH_WEBHOOK,
|
||||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
response_text = response.text or ""
|
||||
logger.info("[MAX] n8n webhook ответ: status=%s, len=%s", response.status_code, len(response_text))
|
||||
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error("[MAX] Парсинг JSON: %s. Response: %s", e, response_text[:500])
|
||||
raise HTTPException(status_code=500, detail="Ошибка парсинга ответа n8n")
|
||||
|
||||
logger.error("[MAX] n8n вернул %s: %s", response.status_code, response_text[:500])
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=f"N8N MAX auth error: {response_text}",
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.error("[MAX] Таймаут n8n MAX auth webhook")
|
||||
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n (MAX auth)")
|
||||
except Exception as e:
|
||||
logger.exception("[MAX] Ошибка вызова n8n MAX auth: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка авторизации MAX: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/claim/create")
|
||||
async def proxy_create_claim(request: Request):
|
||||
"""
|
||||
|
||||
322
backend/app/api/profile.py
Normal file
322
backend/app/api/profile.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
Профиль пользователя: контактные данные из CRM через n8n webhook.
|
||||
|
||||
GET/POST /api/v1/profile/contact — возвращает массив контактных данных по unified_id.
|
||||
GET /api/v1/profile/dadata/address — подсказки адресов через DaData (FORMA_DADATA_* в .env).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/profile", tags=["profile"])
|
||||
|
||||
|
||||
async def _resolve_profile_identity(
|
||||
session_token: Optional[str] = None,
|
||||
unified_id: Optional[str] = None,
|
||||
channel: Optional[str] = None,
|
||||
channel_user_id: Optional[str] = None,
|
||||
entry_channel: Optional[str] = None,
|
||||
chat_id: Optional[str] = None,
|
||||
) -> Tuple[str, Optional[str], Optional[str], Optional[str]]:
|
||||
"""Возвращает (unified_id, contact_id, phone, chat_id). При ошибке — HTTPException(401/400)."""
|
||||
contact_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
|
||||
if not unified_id and channel and channel_user_id:
|
||||
try:
|
||||
from app.api.session import get_session_by_channel_user
|
||||
session_data = await get_session_by_channel_user(channel.strip(), str(channel_user_id).strip())
|
||||
if session_data:
|
||||
unified_id = session_data.get("unified_id")
|
||||
contact_id = session_data.get("contact_id")
|
||||
phone = session_data.get("phone")
|
||||
if chat_id is None:
|
||||
chat_id = session_data.get("chat_id")
|
||||
except Exception as e:
|
||||
logger.warning("Ошибка чтения сессии по channel: %s", e)
|
||||
if not unified_id:
|
||||
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
|
||||
|
||||
if not unified_id and session_token:
|
||||
try:
|
||||
from app.api.session import SessionVerifyRequest, verify_session
|
||||
verify_res = await verify_session(SessionVerifyRequest(session_token=session_token))
|
||||
if getattr(verify_res, "valid", False):
|
||||
unified_id = getattr(verify_res, "unified_id", None)
|
||||
contact_id = getattr(verify_res, "contact_id", None)
|
||||
phone = getattr(verify_res, "phone", None)
|
||||
if chat_id is None:
|
||||
chat_id = getattr(verify_res, "chat_id", None)
|
||||
if not unified_id:
|
||||
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("Ошибка верификации сессии для профиля: %s", e)
|
||||
raise HTTPException(status_code=401, detail="Сессия недействительна")
|
||||
|
||||
if not unified_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Укажите session_token, (channel + channel_user_id) или unified_id",
|
||||
)
|
||||
return unified_id, contact_id, phone, chat_id
|
||||
|
||||
|
||||
class ProfileContactRequest(BaseModel):
|
||||
"""Запрос контактных данных: session_token, (channel + channel_user_id) или unified_id."""
|
||||
session_token: Optional[str] = Field(None, description="Токен сессии (unified_id подставится из Redis)")
|
||||
unified_id: Optional[str] = Field(None, description="Unified ID пользователя в CRM")
|
||||
channel: Optional[str] = Field(None, description="Канал: tg | max (для поиска сессии в Redis)")
|
||||
channel_user_id: Optional[str] = Field(None, description="ID пользователя в канале (tg/max)")
|
||||
entry_channel: Optional[str] = Field(None, description="Канал входа: telegram | max | web")
|
||||
chat_id: Optional[str] = Field(None, description="Telegram user id или Max user id (для передачи в n8n)")
|
||||
|
||||
|
||||
class ProfileContactUpdateRequest(BaseModel):
|
||||
"""Обновление контакта: session_token обязателен; остальные поля — редактируемые (все обязательны на фронте, кроме phone)."""
|
||||
session_token: str = Field(..., description="Токен сессии")
|
||||
entry_channel: Optional[str] = Field("web", description="Канал входа: telegram | max | web")
|
||||
last_name: str = Field("", description="Фамилия")
|
||||
first_name: str = Field("", description="Имя")
|
||||
middle_name: str = Field("", description="Отчество")
|
||||
birth_date: str = Field("", description="Дата рождения")
|
||||
birth_place: str = Field("", description="Место рождения")
|
||||
inn: str = Field("", description="ИНН")
|
||||
email: str = Field("", description="Email")
|
||||
registration_address: str = Field("", description="Адрес регистрации")
|
||||
mailing_address: str = Field("", description="Почтовый адрес")
|
||||
bank_for_compensation: str = Field("", description="Банк для возмещения")
|
||||
phone: Optional[str] = Field(None, description="Телефон (read-only на фронте, передаётся в n8n)")
|
||||
|
||||
|
||||
DADATA_SUGGEST_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
|
||||
|
||||
|
||||
@router.get("/dadata/address")
|
||||
async def get_dadata_address_suggestions(
|
||||
query: str = Query(..., min_length=1, description="Строка поиска адреса"),
|
||||
count: int = Query(10, ge=1, le=20),
|
||||
):
|
||||
"""
|
||||
Подсказки адресов через DaData (FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET в .env).
|
||||
Возвращает список { value, unrestricted_value } для подстановки в форму профиля.
|
||||
"""
|
||||
api_key = (getattr(settings, "forma_dadata_api_key", None) or "").strip()
|
||||
secret = (getattr(settings, "forma_dadata_secret", None) or "").strip()
|
||||
if not api_key or not secret:
|
||||
raise HTTPException(status_code=503, detail="DaData не настроен (FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET)")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.post(
|
||||
DADATA_SUGGEST_URL,
|
||||
json={"query": query.strip(), "count": count},
|
||||
headers={
|
||||
"Authorization": f"Token {api_key}",
|
||||
"X-Secret": secret,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
logger.warning("DaData address suggest вернул %s: %s", response.status_code, response.text[:300])
|
||||
return {"suggestions": []}
|
||||
data = response.json()
|
||||
suggestions = data.get("suggestions") or []
|
||||
return {"suggestions": [{"value": s.get("value", ""), "unrestricted_value": s.get("unrestricted_value", "")} for s in suggestions]}
|
||||
except httpx.TimeoutException:
|
||||
return {"suggestions": []}
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка DaData suggest: %s", e)
|
||||
return {"suggestions": []}
|
||||
|
||||
|
||||
@router.get("/contact")
|
||||
async def get_profile_contact(
|
||||
session_token: Optional[str] = Query(None, description="Токен сессии"),
|
||||
unified_id: Optional[str] = Query(None, description="Unified ID"),
|
||||
channel: Optional[str] = Query(None, description="Канал: tg | max"),
|
||||
channel_user_id: Optional[str] = Query(None, description="ID пользователя в канале"),
|
||||
entry_channel: Optional[str] = Query(None, description="Канал: telegram | max | web"),
|
||||
chat_id: Optional[str] = Query(None, description="Telegram/Max user id"),
|
||||
):
|
||||
"""
|
||||
Получить контактные данные из CRM через n8n webhook.
|
||||
Передайте session_token, (channel + channel_user_id) или unified_id.
|
||||
"""
|
||||
return await _fetch_contact(
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
channel=channel,
|
||||
channel_user_id=channel_user_id,
|
||||
entry_channel=entry_channel,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/contact")
|
||||
async def post_profile_contact(body: ProfileContactRequest):
|
||||
"""То же по телу запроса."""
|
||||
return await _fetch_contact(
|
||||
session_token=body.session_token,
|
||||
unified_id=body.unified_id,
|
||||
channel=body.channel,
|
||||
channel_user_id=body.channel_user_id,
|
||||
entry_channel=body.entry_channel,
|
||||
chat_id=body.chat_id,
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_contact(
|
||||
session_token: Optional[str] = None,
|
||||
unified_id: Optional[str] = None,
|
||||
channel: Optional[str] = None,
|
||||
channel_user_id: Optional[str] = None,
|
||||
entry_channel: Optional[str] = None,
|
||||
chat_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
webhook_url = getattr(settings, "n8n_contact_webhook", None) or ""
|
||||
if not webhook_url:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="N8N_CONTACT_WEBHOOK не настроен",
|
||||
)
|
||||
|
||||
unified_id, contact_id, phone, chat_id = await _resolve_profile_identity(
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
channel=channel,
|
||||
channel_user_id=channel_user_id,
|
||||
entry_channel=entry_channel,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
|
||||
payload: dict = {
|
||||
"unified_id": unified_id,
|
||||
"entry_channel": (entry_channel or "web").strip() or "web",
|
||||
}
|
||||
if session_token:
|
||||
payload["session_token"] = session_token
|
||||
if contact_id is not None:
|
||||
payload["contact_id"] = contact_id
|
||||
if phone is not None:
|
||||
payload["phone"] = phone
|
||||
if chat_id is not None and str(chat_id).strip():
|
||||
payload["chat_id"] = str(chat_id).strip()
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.post(
|
||||
webhook_url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка вызова N8N_CONTACT_WEBHOOK: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Сервис контактов временно недоступен")
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning("N8N contact webhook вернул %s: %s", response.status_code, response.text[:500])
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Сервис контактов вернул ошибку",
|
||||
)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except Exception:
|
||||
data = response.text or ""
|
||||
|
||||
if isinstance(data, list):
|
||||
return {"items": data if data else []}
|
||||
if isinstance(data, dict):
|
||||
if "items" in data and isinstance(data["items"], list):
|
||||
return {"items": data["items"]}
|
||||
if "contact" in data:
|
||||
c = data["contact"]
|
||||
return {"items": c if isinstance(c, list) else [c] if c else []}
|
||||
if "data" in data and isinstance(data["data"], list):
|
||||
return {"items": data["data"]}
|
||||
if data and isinstance(data, dict):
|
||||
return {"items": [data]}
|
||||
return {"items": []}
|
||||
return {"items": []}
|
||||
|
||||
|
||||
@router.post("/contact/update")
|
||||
async def post_profile_contact_update(body: ProfileContactUpdateRequest):
|
||||
"""
|
||||
Обновить контакт в CRM через N8N_PROFILE_UPDATE_WEBHOOK.
|
||||
Вызывается с фронта при verification="0". Сессия проверяется по session_token.
|
||||
"""
|
||||
webhook_url = (getattr(settings, "n8n_profile_update_webhook", None) or "").strip()
|
||||
if not webhook_url:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="N8N_PROFILE_UPDATE_WEBHOOK не настроен",
|
||||
)
|
||||
|
||||
unified_id, contact_id, phone, chat_id = await _resolve_profile_identity(
|
||||
session_token=body.session_token,
|
||||
entry_channel=body.entry_channel,
|
||||
chat_id=None,
|
||||
)
|
||||
|
||||
payload: dict = {
|
||||
"unified_id": unified_id,
|
||||
"entry_channel": (body.entry_channel or "web").strip() or "web",
|
||||
"session_token": body.session_token,
|
||||
"last_name": (body.last_name or "").strip(),
|
||||
"first_name": (body.first_name or "").strip(),
|
||||
"middle_name": (body.middle_name or "").strip(),
|
||||
"birth_date": (body.birth_date or "").strip(),
|
||||
"birth_place": (body.birth_place or "").strip(),
|
||||
"inn": (body.inn or "").strip(),
|
||||
"email": (body.email or "").strip(),
|
||||
"registration_address": (body.registration_address or "").strip(),
|
||||
"mailing_address": (body.mailing_address or "").strip(),
|
||||
"bank_for_compensation": (body.bank_for_compensation or "").strip(),
|
||||
}
|
||||
if contact_id is not None:
|
||||
payload["contact_id"] = contact_id
|
||||
if body.phone is not None and str(body.phone).strip():
|
||||
payload["phone"] = str(body.phone).strip()
|
||||
elif phone is not None:
|
||||
payload["phone"] = phone
|
||||
if chat_id is not None and str(chat_id).strip():
|
||||
payload["chat_id"] = str(chat_id).strip()
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.post(
|
||||
webhook_url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка вызова N8N_PROFILE_UPDATE_WEBHOOK: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Не удалось сохранить профиль, попробуйте позже")
|
||||
|
||||
if response.status_code < 200 or response.status_code >= 300:
|
||||
logger.warning("N8N profile update webhook вернул %s: %s", response.status_code, response.text[:500])
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Не удалось сохранить профиль, попробуйте позже",
|
||||
)
|
||||
|
||||
result: dict = {"success": True}
|
||||
try:
|
||||
data = response.json()
|
||||
if isinstance(data, dict) and data:
|
||||
result.update(data)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
@@ -2,7 +2,8 @@
|
||||
Session management API endpoints
|
||||
|
||||
Обеспечивает управление сессиями пользователей через Redis:
|
||||
- Верификация существующей сессии
|
||||
- Верификация по session_token или по (channel, channel_user_id)
|
||||
- Ключ Redis: session:{channel}:{channel_user_id} для универсального auth
|
||||
- Logout (удаление сессии)
|
||||
"""
|
||||
|
||||
@@ -15,6 +16,8 @@ from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import redis.asyncio as redis
|
||||
|
||||
from ..services.redis_service import redis_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/session", tags=["session"])
|
||||
@@ -22,13 +25,103 @@ router = APIRouter(prefix="/api/v1/session", tags=["session"])
|
||||
# Redis connection (используем существующее подключение)
|
||||
redis_client: Optional[redis.Redis] = None
|
||||
|
||||
# TTL для сессии по channel+channel_user_id (секунды). 0 = без TTL.
|
||||
SESSION_BY_CHANNEL_TTL_HOURS = 24
|
||||
|
||||
def init_redis(redis_conn: redis.Redis):
|
||||
"""Initialize Redis connection"""
|
||||
|
||||
def init_redis(redis_conn: Optional[redis.Redis]):
|
||||
"""Initialize Redis connection (локальный Redis для сессий). None при shutdown."""
|
||||
global redis_client
|
||||
redis_client = redis_conn
|
||||
|
||||
|
||||
def _session_key_by_channel(channel: str, channel_user_id: str) -> str:
|
||||
"""Ключ Redis для сессии по каналу и id пользователя в канале."""
|
||||
return f"session:{channel}:{channel_user_id}"
|
||||
|
||||
|
||||
async def set_session_by_channel_user(
|
||||
channel: str,
|
||||
channel_user_id: str,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
"""
|
||||
Записать сессию в Redis по ключу session:{channel}:{channel_user_id}.
|
||||
data: unified_id, phone, contact_id, chat_id, has_drafts, ...
|
||||
"""
|
||||
if not redis_client:
|
||||
raise HTTPException(status_code=500, detail="Redis connection not initialized")
|
||||
key = _session_key_by_channel(channel, channel_user_id)
|
||||
payload = {
|
||||
"unified_id": data.get("unified_id") or "",
|
||||
"phone": data.get("phone") or "",
|
||||
"contact_id": data.get("contact_id") or "",
|
||||
"chat_id": str(channel_user_id),
|
||||
"has_drafts": data.get("has_drafts", False),
|
||||
"verified_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
ttl = SESSION_BY_CHANNEL_TTL_HOURS * 3600 if SESSION_BY_CHANNEL_TTL_HOURS else None
|
||||
body = json.dumps(payload)
|
||||
if ttl:
|
||||
await redis_client.setex(key, ttl, body)
|
||||
else:
|
||||
await redis_client.set(key, body)
|
||||
# Дублируем сессию в внешний Redis, чтобы n8n мог читать по тем же ключам
|
||||
try:
|
||||
if redis_service.client:
|
||||
if ttl:
|
||||
await redis_service.client.setex(key, ttl, body)
|
||||
else:
|
||||
await redis_service.client.set(key, body)
|
||||
except Exception as e:
|
||||
logger.warning("Не удалось продублировать сессию в внешний Redis (channel): %s", e)
|
||||
logger.info("Сессия записана: %s, unified_id=%s", key, payload.get("unified_id"))
|
||||
|
||||
|
||||
async def get_session_by_channel_user(channel: str, channel_user_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Прочитать сессию из Redis по channel и channel_user_id. Если нет — None."""
|
||||
if not redis_client:
|
||||
return None
|
||||
key = _session_key_by_channel(channel, channel_user_id)
|
||||
raw = await redis_client.get(key)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
async def set_session_by_token(session_token: str, data: Dict[str, Any]) -> None:
|
||||
"""Записать сессию в Redis по ключу session:{session_token} (для совместимости с profile/claims)."""
|
||||
if not redis_client:
|
||||
return
|
||||
key = f"session:{session_token}"
|
||||
payload = {
|
||||
"unified_id": data.get("unified_id") or "",
|
||||
"phone": data.get("phone") or "",
|
||||
"contact_id": data.get("contact_id") or "",
|
||||
"chat_id": data.get("chat_id") or "",
|
||||
"has_drafts": data.get("has_drafts", False),
|
||||
"verified_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
ttl = SESSION_BY_CHANNEL_TTL_HOURS * 3600 if SESSION_BY_CHANNEL_TTL_HOURS else None
|
||||
body = json.dumps(payload)
|
||||
if ttl:
|
||||
await redis_client.setex(key, ttl, body)
|
||||
else:
|
||||
await redis_client.set(key, body)
|
||||
# Дублируем сессию по токену в внешний Redis для доступа из n8n
|
||||
try:
|
||||
if redis_service.client:
|
||||
if ttl:
|
||||
await redis_service.client.setex(key, ttl, body)
|
||||
else:
|
||||
await redis_service.client.set(key, body)
|
||||
except Exception as e:
|
||||
logger.warning("Не удалось продублировать сессию в внешний Redis (token): %s", e)
|
||||
|
||||
|
||||
class SessionVerifyRequest(BaseModel):
|
||||
session_token: str
|
||||
|
||||
@@ -39,10 +132,16 @@ class SessionVerifyResponse(BaseModel):
|
||||
unified_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
contact_id: Optional[str] = None
|
||||
chat_id: Optional[str] = None # telegram_user_id или max_user_id
|
||||
verified_at: Optional[str] = None
|
||||
expires_in_seconds: Optional[int] = None
|
||||
|
||||
|
||||
class SessionVerifyByChannelRequest(BaseModel):
|
||||
channel: str # tg | max
|
||||
channel_user_id: str
|
||||
|
||||
|
||||
class SessionLogoutRequest(BaseModel):
|
||||
session_token: str
|
||||
|
||||
@@ -92,6 +191,7 @@ async def verify_session(request: SessionVerifyRequest):
|
||||
unified_id=session_data.get('unified_id'),
|
||||
phone=session_data.get('phone'),
|
||||
contact_id=session_data.get('contact_id'),
|
||||
chat_id=session_data.get('chat_id'),
|
||||
verified_at=session_data.get('verified_at'),
|
||||
expires_in_seconds=ttl if ttl > 0 else None
|
||||
)
|
||||
@@ -143,20 +243,47 @@ async def logout_session(request: SessionLogoutRequest):
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка при выходе: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/verify-by-channel", response_model=SessionVerifyResponse)
|
||||
async def verify_session_by_channel(request: SessionVerifyByChannelRequest):
|
||||
"""
|
||||
Проверить сессию по channel и channel_user_id (ключ Redis: session:{channel}:{channel_user_id}).
|
||||
Используется, когда клиент не хранит session_token и передаёт channel + channel_user_id.
|
||||
"""
|
||||
try:
|
||||
data = await get_session_by_channel_user(request.channel, request.channel_user_id)
|
||||
if not data:
|
||||
return SessionVerifyResponse(success=True, valid=False)
|
||||
ttl = await redis_client.ttl(_session_key_by_channel(request.channel, request.channel_user_id)) if redis_client else 0
|
||||
return SessionVerifyResponse(
|
||||
success=True,
|
||||
valid=True,
|
||||
unified_id=data.get("unified_id"),
|
||||
phone=data.get("phone"),
|
||||
contact_id=data.get("contact_id"),
|
||||
chat_id=data.get("chat_id"),
|
||||
verified_at=data.get("verified_at"),
|
||||
expires_in_seconds=ttl if ttl > 0 else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка verify-by-channel: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Ошибка проверки сессии")
|
||||
|
||||
|
||||
class SessionCreateRequest(BaseModel):
|
||||
session_token: str
|
||||
unified_id: str
|
||||
phone: str
|
||||
contact_id: str
|
||||
ttl_hours: int = 24
|
||||
chat_id: Optional[str] = None # telegram_user_id или max_user_id для передачи в n8n как chat_id
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_session(request: SessionCreateRequest):
|
||||
"""
|
||||
Создать новую сессию (вызывается после успешной SMS верификации)
|
||||
Создать новую сессию (вызывается после успешной SMS верификации или TG/MAX auth)
|
||||
|
||||
Обычно вызывается из Step1Phone после получения данных от n8n.
|
||||
Обычно вызывается из Step1Phone после получения данных от n8n или из auth2/tg/max auth.
|
||||
"""
|
||||
try:
|
||||
if not redis_client:
|
||||
@@ -171,6 +298,8 @@ async def create_session(request: SessionCreateRequest):
|
||||
'verified_at': datetime.utcnow().isoformat(),
|
||||
'expires_at': (datetime.utcnow() + timedelta(hours=request.ttl_hours)).isoformat()
|
||||
}
|
||||
if request.chat_id is not None:
|
||||
session_data['chat_id'] = str(request.chat_id).strip()
|
||||
|
||||
# Сохраняем в Redis с TTL
|
||||
await redis_client.setex(
|
||||
|
||||
@@ -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 if sms_service.enabled else None # Показываем код только в 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,
|
||||
|
||||
699
backend/app/api/support.py
Normal file
699
backend/app/api/support.py
Normal file
@@ -0,0 +1,699 @@
|
||||
"""
|
||||
Support API: диалог поддержки (треды + сообщения).
|
||||
POST /api/v1/support — multipart, создание/поиск треда, запись сообщения user, прокси в n8n.
|
||||
GET /api/v1/support/thread — получить тред и сообщения.
|
||||
GET /api/v1/support/stream — SSE: один канал на юзера, события из Postgres NOTIFY.
|
||||
POST /api/v1/support/incoming — webhook для n8n: добавить сообщение от поддержки.
|
||||
GET /api/v1/support/limits — лимиты вложений.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
import asyncpg
|
||||
import httpx
|
||||
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from ..config import settings
|
||||
from ..services.database import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/support", tags=["support"])
|
||||
|
||||
# Реестр SSE по unified_id: кому пушить при NOTIFY (один канал support_events)
|
||||
_support_stream_registry: Dict[str, Set[asyncio.Queue]] = {}
|
||||
_support_notify_inbox: asyncio.Queue = asyncio.Queue()
|
||||
_SUPPORT_EVENTS_CHANNEL = "support_events"
|
||||
|
||||
|
||||
def _get_support_webhook() -> str:
|
||||
url = (getattr(settings, "n8n_support_webhook", None) or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="N8N_SUPPORT_WEBHOOK не настроен",
|
||||
)
|
||||
return url
|
||||
|
||||
|
||||
async def _resolve_session(
|
||||
session_token: Optional[str] = None,
|
||||
channel: Optional[str] = None,
|
||||
channel_user_id: Optional[str] = None,
|
||||
) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
|
||||
"""Возвращает (unified_id, phone, email, session_id)."""
|
||||
unified_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
session_id: Optional[str] = session_token
|
||||
|
||||
if channel and channel_user_id:
|
||||
try:
|
||||
from .session import get_session_by_channel_user
|
||||
data = await get_session_by_channel_user(channel.strip(), str(channel_user_id).strip())
|
||||
if data:
|
||||
unified_id = data.get("unified_id")
|
||||
phone = data.get("phone")
|
||||
if session_token is None:
|
||||
session_id = data.get("session_token")
|
||||
except Exception as e:
|
||||
logger.warning("Ошибка чтения сессии по channel: %s", e)
|
||||
|
||||
if not unified_id and session_token:
|
||||
try:
|
||||
from .session import SessionVerifyRequest, verify_session
|
||||
res = await verify_session(SessionVerifyRequest(session_token=session_token))
|
||||
if getattr(res, "valid", False):
|
||||
unified_id = getattr(res, "unified_id", None)
|
||||
phone = getattr(res, "phone", None)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("Ошибка верификации сессии для support: %s", e)
|
||||
raise HTTPException(status_code=401, detail="Сессия недействительна")
|
||||
|
||||
if not unified_id:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Укажите session_token или (channel + channel_user_id)",
|
||||
)
|
||||
|
||||
return (unified_id, phone, None, session_id or session_token)
|
||||
|
||||
|
||||
def _check_attachment_limits(
|
||||
files: List[Tuple[str, bytes, Optional[str]]],
|
||||
) -> None:
|
||||
"""Проверяет лимиты вложений; 0 или пусто = не проверять."""
|
||||
max_count = getattr(settings, "support_attachments_max_count", 0) or 0
|
||||
max_bytes = settings.support_attachments_max_size_bytes
|
||||
allowed = (getattr(settings, "support_attachments_allowed_types", None) or "").strip()
|
||||
|
||||
if max_count > 0 and len(files) > max_count:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Слишком много файлов: максимум {max_count}",
|
||||
)
|
||||
|
||||
if max_bytes > 0:
|
||||
for name, content, _ in files:
|
||||
if len(content) > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Файл {name} превышает допустимый размер ({max_bytes // (1024*1024)} МБ)",
|
||||
)
|
||||
|
||||
if allowed:
|
||||
allowed_list = [x.strip().lower() for x in allowed.split(",") if x.strip()]
|
||||
for filename, content, mime in files:
|
||||
mime = (mime or "").strip().lower()
|
||||
ext = ""
|
||||
if "." in filename:
|
||||
ext = "." + filename.rsplit(".", 1)[-1].lower()
|
||||
ok = False
|
||||
for a in allowed_list:
|
||||
if a.startswith("."):
|
||||
if ext == a:
|
||||
ok = True
|
||||
break
|
||||
elif "/" in a:
|
||||
if mime == a or (a.endswith("/*") and mime.split("/")[0] == a.split("/")[0]):
|
||||
ok = True
|
||||
break
|
||||
else:
|
||||
if ext == f".{a}" or mime == a:
|
||||
ok = True
|
||||
break
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Тип файла «{filename}» не разрешён. Допустимые: {allowed}",
|
||||
)
|
||||
|
||||
|
||||
def _uuid_str(val: Any) -> str:
|
||||
if val is None:
|
||||
return ""
|
||||
return str(val)
|
||||
|
||||
|
||||
async def _get_or_create_thread(unified_id: str, claim_id: Optional[str], source: str) -> str:
|
||||
"""Найти тред по (unified_id, claim_id) или создать. Возвращает thread_id (UUID str)."""
|
||||
if claim_id and claim_id.strip():
|
||||
row = await db.fetch_one(
|
||||
"SELECT id FROM clpr_support_threads WHERE unified_id = $1 AND claim_id = $2",
|
||||
unified_id,
|
||||
claim_id.strip(),
|
||||
)
|
||||
else:
|
||||
row = await db.fetch_one(
|
||||
"SELECT id FROM clpr_support_threads WHERE unified_id = $1 AND claim_id IS NULL",
|
||||
unified_id,
|
||||
)
|
||||
|
||||
if row:
|
||||
return _uuid_str(row["id"])
|
||||
|
||||
thread_id = uuid.uuid4()
|
||||
await db.execute(
|
||||
"INSERT INTO clpr_support_threads (id, unified_id, claim_id, source) VALUES ($1, $2, $3, $4)",
|
||||
thread_id,
|
||||
unified_id,
|
||||
claim_id.strip() if claim_id and claim_id.strip() else None,
|
||||
source or "bar",
|
||||
)
|
||||
return str(thread_id)
|
||||
|
||||
|
||||
def _support_notify_callback(conn: Any, pid: int, channel: str, payload: str) -> None:
|
||||
"""Вызывается asyncpg при NOTIFY support_events. Кладём payload во inbox."""
|
||||
try:
|
||||
_support_notify_inbox.put_nowait(payload)
|
||||
except Exception as e:
|
||||
logger.warning("Support notify inbox put: %s", e)
|
||||
|
||||
|
||||
async def _run_support_listener() -> None:
|
||||
"""
|
||||
Один подписчик на Postgres NOTIFY support_events.
|
||||
Держит соединение, слушает канал, раскидывает по unified_id в реестр.
|
||||
"""
|
||||
conn: Optional[asyncpg.Connection] = None
|
||||
try:
|
||||
conn = await asyncpg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
database=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
)
|
||||
await conn.execute("LISTEN " + _SUPPORT_EVENTS_CHANNEL)
|
||||
conn.add_listener(_SUPPORT_EVENTS_CHANNEL, _support_notify_callback)
|
||||
logger.info("Support LISTEN %s started", _SUPPORT_EVENTS_CHANNEL)
|
||||
while True:
|
||||
payload = await _support_notify_inbox.get()
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
u_id = data.get("unified_id")
|
||||
if not u_id:
|
||||
continue
|
||||
queues = _support_stream_registry.get(u_id)
|
||||
if not queues:
|
||||
continue
|
||||
for q in list(queues):
|
||||
try:
|
||||
q.put_nowait(data)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("Support stream put: %s", e)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning("Support notify payload not JSON: %s", e)
|
||||
except Exception as e:
|
||||
logger.exception("Support listener dispatch: %s", e)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Support listener cancelled")
|
||||
except Exception as e:
|
||||
logger.exception("Support listener error: %s", e)
|
||||
finally:
|
||||
if conn and not conn.is_closed():
|
||||
await conn.close()
|
||||
logger.info("Support LISTEN stopped")
|
||||
|
||||
|
||||
@router.get("/limits")
|
||||
async def get_support_limits():
|
||||
"""Лимиты вложений (0/пусто = без ограничений)."""
|
||||
max_count = getattr(settings, "support_attachments_max_count", 0) or 0
|
||||
max_bytes = settings.support_attachments_max_size_bytes
|
||||
allowed = (getattr(settings, "support_attachments_allowed_types", None) or "").strip()
|
||||
unlimited = max_count == 0 and max_bytes == 0 and not allowed
|
||||
return {
|
||||
"max_count": max_count,
|
||||
"max_size_per_file": max_bytes,
|
||||
"allowed_types": allowed,
|
||||
"unlimited": unlimited,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/threads")
|
||||
async def get_support_threads(
|
||||
session_token: Optional[str] = Query(None),
|
||||
channel: Optional[str] = Query(None),
|
||||
channel_user_id: Optional[str] = Query(None),
|
||||
):
|
||||
"""
|
||||
Список всех тредов пользователя для экрана «Мои обращения».
|
||||
Сессия: session_token или channel + channel_user_id.
|
||||
"""
|
||||
unified_id, _, _, _ = await _resolve_session(
|
||||
session_token=session_token, channel=channel, channel_user_id=channel_user_id
|
||||
)
|
||||
|
||||
rows = await db.fetch_all(
|
||||
"""
|
||||
SELECT
|
||||
t.id,
|
||||
t.claim_id,
|
||||
t.source,
|
||||
t.ticket_id,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
(SELECT m.body FROM clpr_support_messages m WHERE m.thread_id = t.id ORDER BY m.created_at DESC LIMIT 1) AS last_body,
|
||||
(SELECT m.created_at FROM clpr_support_messages m WHERE m.thread_id = t.id ORDER BY m.created_at DESC LIMIT 1) AS last_at,
|
||||
(SELECT COUNT(*)::int FROM clpr_support_messages m WHERE m.thread_id = t.id) AS messages_count,
|
||||
(SELECT COUNT(*)::int FROM clpr_support_messages m
|
||||
WHERE m.thread_id = t.id AND m.direction = 'support'
|
||||
AND m.created_at > COALESCE(
|
||||
(SELECT r.last_read_at FROM clpr_support_reads r WHERE r.unified_id = t.unified_id AND r.thread_id = t.id),
|
||||
'1970-01-01'::timestamptz
|
||||
)) AS unread_count
|
||||
FROM clpr_support_threads t
|
||||
WHERE t.unified_id = $1
|
||||
ORDER BY COALESCE(
|
||||
(SELECT m.created_at FROM clpr_support_messages m WHERE m.thread_id = t.id ORDER BY m.created_at DESC LIMIT 1),
|
||||
t.updated_at,
|
||||
t.created_at
|
||||
) DESC
|
||||
""",
|
||||
unified_id,
|
||||
)
|
||||
threads = []
|
||||
for r in rows:
|
||||
last_at = r.get("last_at")
|
||||
if hasattr(last_at, "isoformat"):
|
||||
last_at = last_at.isoformat()
|
||||
elif last_at is not None:
|
||||
last_at = str(last_at)
|
||||
threads.append({
|
||||
"thread_id": _uuid_str(r["id"]),
|
||||
"claim_id": str(r["claim_id"]).strip() if r.get("claim_id") else None,
|
||||
"source": str(r.get("source") or "bar"),
|
||||
"ticket_id": str(r["ticket_id"]) if r.get("ticket_id") else None,
|
||||
"created_at": r["created_at"].isoformat() if hasattr(r.get("created_at"), "isoformat") else str(r.get("created_at") or ""),
|
||||
"updated_at": r["updated_at"].isoformat() if hasattr(r.get("updated_at"), "isoformat") else str(r.get("updated_at") or ""),
|
||||
"last_body": (r.get("last_body") or "")[:200] if r.get("last_body") else None,
|
||||
"last_at": last_at,
|
||||
"messages_count": r.get("messages_count") or 0,
|
||||
"unread_count": r.get("unread_count") or 0,
|
||||
})
|
||||
return {"threads": threads}
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
async def get_support_unread_count(
|
||||
session_token: Optional[str] = Query(None),
|
||||
channel: Optional[str] = Query(None),
|
||||
channel_user_id: Optional[str] = Query(None),
|
||||
):
|
||||
"""Суммарное число непрочитанных сообщений от поддержки (для бейджа в баре)."""
|
||||
unified_id, _, _, _ = await _resolve_session(
|
||||
session_token=session_token, channel=channel, channel_user_id=channel_user_id
|
||||
)
|
||||
row = await db.fetch_one(
|
||||
"""
|
||||
SELECT COALESCE(SUM(cnt), 0)::int AS total
|
||||
FROM (
|
||||
SELECT COUNT(*)::int AS cnt
|
||||
FROM clpr_support_threads t
|
||||
JOIN clpr_support_messages m ON m.thread_id = t.id AND m.direction = 'support'
|
||||
WHERE t.unified_id = $1
|
||||
AND m.created_at > COALESCE(
|
||||
(SELECT r.last_read_at FROM clpr_support_reads r WHERE r.unified_id = t.unified_id AND r.thread_id = t.id),
|
||||
'1970-01-01'::timestamptz
|
||||
)
|
||||
GROUP BY t.id
|
||||
) s
|
||||
""",
|
||||
unified_id,
|
||||
)
|
||||
total = (row and row.get("total")) or 0
|
||||
return {"unread_count": total}
|
||||
|
||||
|
||||
@router.post("/read")
|
||||
async def mark_support_thread_read(
|
||||
request: Request,
|
||||
session_token: Optional[str] = Query(None),
|
||||
channel: Optional[str] = Query(None),
|
||||
channel_user_id: Optional[str] = Query(None),
|
||||
):
|
||||
"""
|
||||
Отметить тред как прочитанный (пользователь открыл чат).
|
||||
Тело JSON: { "thread_id": "..." } или query thread_id= / claim_id=.
|
||||
"""
|
||||
unified_id, _, _, _ = await _resolve_session(
|
||||
session_token=session_token, channel=channel, channel_user_id=channel_user_id
|
||||
)
|
||||
thread_id = request.query_params.get("thread_id")
|
||||
claim_id = request.query_params.get("claim_id")
|
||||
if not thread_id:
|
||||
try:
|
||||
body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {}
|
||||
except Exception:
|
||||
body = {}
|
||||
thread_id = body.get("thread_id")
|
||||
if not thread_id:
|
||||
claim_id = claim_id or body.get("claim_id")
|
||||
if claim_id and not thread_id:
|
||||
cid = str(claim_id).strip()
|
||||
if cid:
|
||||
row = await db.fetch_one(
|
||||
"SELECT id FROM clpr_support_threads WHERE unified_id = $1 AND claim_id = $2",
|
||||
unified_id,
|
||||
cid,
|
||||
)
|
||||
else:
|
||||
row = await db.fetch_one(
|
||||
"SELECT id FROM clpr_support_threads WHERE unified_id = $1 AND claim_id IS NULL",
|
||||
unified_id,
|
||||
)
|
||||
if row:
|
||||
thread_id = str(row["id"])
|
||||
if not thread_id:
|
||||
raise HTTPException(status_code=400, detail="thread_id или claim_id обязателен")
|
||||
try:
|
||||
thread_uuid = uuid.UUID(thread_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Некорректный thread_id")
|
||||
# Проверяем, что тред принадлежит пользователю
|
||||
row = await db.fetch_one(
|
||||
"SELECT id FROM clpr_support_threads WHERE id = $1 AND unified_id = $2",
|
||||
thread_uuid,
|
||||
unified_id,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Тред не найден")
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO clpr_support_reads (unified_id, thread_id, last_read_at)
|
||||
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (unified_id, thread_id) DO UPDATE SET last_read_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
unified_id,
|
||||
thread_uuid,
|
||||
)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/thread")
|
||||
async def get_support_thread(
|
||||
claim_id: Optional[str] = Query(None),
|
||||
session_token: Optional[str] = Query(None),
|
||||
channel: Optional[str] = Query(None),
|
||||
channel_user_id: Optional[str] = Query(None),
|
||||
):
|
||||
"""
|
||||
Получить тред поддержки и сообщения. Query: claim_id (опционально).
|
||||
Сессия: session_token или channel + channel_user_id.
|
||||
"""
|
||||
unified_id, _, _, _ = await _resolve_session(
|
||||
session_token=session_token, channel=channel, channel_user_id=channel_user_id
|
||||
)
|
||||
|
||||
cid = claim_id.strip() if claim_id and str(claim_id).strip() else None
|
||||
if cid:
|
||||
row = await db.fetch_one(
|
||||
"SELECT id, ticket_id FROM clpr_support_threads WHERE unified_id = $1 AND claim_id = $2",
|
||||
unified_id,
|
||||
cid,
|
||||
)
|
||||
else:
|
||||
row = await db.fetch_one(
|
||||
"SELECT id, ticket_id FROM clpr_support_threads WHERE unified_id = $1 AND claim_id IS NULL",
|
||||
unified_id,
|
||||
)
|
||||
|
||||
if not row:
|
||||
return {"thread_id": None, "messages": [], "ticket_id": None}
|
||||
|
||||
thread_id = _uuid_str(row["id"])
|
||||
ticket_id = row.get("ticket_id")
|
||||
if ticket_id is not None:
|
||||
ticket_id = str(ticket_id)
|
||||
|
||||
rows = await db.fetch_all(
|
||||
"SELECT id, direction, body, attachments, created_at FROM clpr_support_messages WHERE thread_id = $1 ORDER BY created_at ASC",
|
||||
row["id"],
|
||||
)
|
||||
messages = []
|
||||
for r in rows:
|
||||
att = r.get("attachments")
|
||||
if att is not None and not isinstance(att, list):
|
||||
try:
|
||||
att = json.loads(att) if isinstance(att, str) else att
|
||||
except Exception:
|
||||
att = []
|
||||
messages.append({
|
||||
"id": _uuid_str(r["id"]),
|
||||
"direction": r["direction"],
|
||||
"body": r["body"] or "",
|
||||
"attachments": att or [],
|
||||
"created_at": r["created_at"].isoformat() if hasattr(r["created_at"], "isoformat") else str(r["created_at"]),
|
||||
})
|
||||
|
||||
return {
|
||||
"thread_id": thread_id,
|
||||
"messages": messages,
|
||||
"ticket_id": ticket_id,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def support_stream(
|
||||
session_token: Optional[str] = Query(None),
|
||||
channel: Optional[str] = Query(None),
|
||||
channel_user_id: Optional[str] = Query(None),
|
||||
):
|
||||
"""
|
||||
SSE: один поток на пользователя по unified_id. События приходят из Postgres NOTIFY (триггер на clpr_support_messages).
|
||||
Query: session_token или channel + channel_user_id.
|
||||
"""
|
||||
unified_id, _, _, _ = await _resolve_session(
|
||||
session_token=session_token, channel=channel, channel_user_id=channel_user_id
|
||||
)
|
||||
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=64)
|
||||
if unified_id not in _support_stream_registry:
|
||||
_support_stream_registry[unified_id] = set()
|
||||
_support_stream_registry[unified_id].add(queue)
|
||||
|
||||
async def event_gen():
|
||||
try:
|
||||
yield f"data: {json.dumps({'event': 'connected', 'unified_id': unified_id}, ensure_ascii=False)}\n\n"
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||
# Формат как в SupportChat: id, direction, body, attachments, created_at
|
||||
created_at = msg.get("created_at")
|
||||
if hasattr(created_at, "isoformat"):
|
||||
created_at = created_at.isoformat()
|
||||
elif created_at is not None:
|
||||
created_at = str(created_at)
|
||||
event = {
|
||||
"event": "support_message",
|
||||
"message": {
|
||||
"id": str(msg.get("message_id", "")),
|
||||
"direction": msg.get("direction", "support"),
|
||||
"body": msg.get("body", ""),
|
||||
"attachments": json.loads(msg.get("attachments", "[]")) if isinstance(msg.get("attachments"), str) else (msg.get("attachments") or []),
|
||||
"created_at": created_at,
|
||||
},
|
||||
"thread_id": str(msg.get("thread_id", "")),
|
||||
}
|
||||
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
except asyncio.TimeoutError:
|
||||
yield ": keepalive\n\n"
|
||||
finally:
|
||||
_support_stream_registry.get(unified_id, set()).discard(queue)
|
||||
if unified_id in _support_stream_registry and not _support_stream_registry[unified_id]:
|
||||
del _support_stream_registry[unified_id]
|
||||
|
||||
return StreamingResponse(
|
||||
event_gen(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def submit_support(request: Request):
|
||||
"""
|
||||
Отправить сообщение в поддержку. Multipart: message, subject?, claim_id?, source, thread_id?,
|
||||
session_token (или channel + channel_user_id), файлы. Создаёт/находит тред, пишет сообщение, проксирует в n8n.
|
||||
"""
|
||||
form = await request.form()
|
||||
message = form.get("message")
|
||||
if not message or not str(message).strip():
|
||||
raise HTTPException(status_code=400, detail="Поле message обязательно")
|
||||
message = str(message).strip()
|
||||
|
||||
subject = form.get("subject")
|
||||
subject = str(subject).strip() if subject else None
|
||||
claim_id = form.get("claim_id")
|
||||
claim_id = str(claim_id).strip() if claim_id else None
|
||||
source = form.get("source")
|
||||
source = str(source).strip() if source else "bar"
|
||||
thread_id_param = form.get("thread_id")
|
||||
thread_id_param = str(thread_id_param).strip() if thread_id_param else None
|
||||
session_token = form.get("session_token")
|
||||
session_token = str(session_token).strip() if session_token else None
|
||||
channel = form.get("channel")
|
||||
channel = str(channel).strip() if channel else None
|
||||
channel_user_id = form.get("channel_user_id")
|
||||
channel_user_id = str(channel_user_id).strip() if channel_user_id else None
|
||||
|
||||
file_items: List[Tuple[str, bytes, Optional[str]]] = []
|
||||
for key, value in form.multi_items():
|
||||
if hasattr(value, "read") and hasattr(value, "filename"):
|
||||
content = await value.read()
|
||||
file_items.append((value.filename or key, content, getattr(value, "content_type", None)))
|
||||
|
||||
_check_attachment_limits(file_items)
|
||||
|
||||
unified_id, phone, email, session_id = await _resolve_session(
|
||||
session_token=session_token,
|
||||
channel=channel,
|
||||
channel_user_id=channel_user_id,
|
||||
)
|
||||
|
||||
thread_id = await _get_or_create_thread(unified_id, claim_id or None, source)
|
||||
|
||||
attachments_json = json.dumps([{"filename": fn} for fn, _, _ in file_items])
|
||||
|
||||
message_id = uuid.uuid4()
|
||||
await db.execute(
|
||||
"INSERT INTO clpr_support_messages (id, thread_id, direction, body, attachments) VALUES ($1, $2, 'user', $3, $4)",
|
||||
message_id,
|
||||
uuid.UUID(thread_id),
|
||||
message,
|
||||
attachments_json,
|
||||
)
|
||||
|
||||
row = await db.fetch_one("SELECT ticket_id FROM clpr_support_threads WHERE id = $1", uuid.UUID(thread_id))
|
||||
ticket_id = str(row["ticket_id"]) if row and row.get("ticket_id") else None
|
||||
|
||||
webhook_url = _get_support_webhook()
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
data: Dict[str, str] = {
|
||||
"message": message,
|
||||
"source": source or "bar",
|
||||
"unified_id": unified_id or "",
|
||||
"phone": (phone or "").strip(),
|
||||
"email": (email or "").strip(),
|
||||
"session_id": (session_id or "").strip(),
|
||||
"timestamp": timestamp,
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
if subject:
|
||||
data["subject"] = subject
|
||||
if claim_id:
|
||||
data["claim_id"] = claim_id
|
||||
if ticket_id:
|
||||
data["ticket_id"] = ticket_id
|
||||
|
||||
files_for_upload: Dict[str, Tuple[str, bytes, Optional[str]]] = {}
|
||||
for i, (filename, content, content_type) in enumerate(file_items):
|
||||
key = f"attachments[{i}]" if len(file_items) > 1 else "attachments"
|
||||
if key in files_for_upload:
|
||||
key = f"attachments[{i}]"
|
||||
files_for_upload[key] = (filename, content, content_type or "application/octet-stream")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
webhook_url,
|
||||
data=data,
|
||||
files=files_for_upload or None,
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Таймаут вызова N8N support webhook")
|
||||
raise HTTPException(status_code=504, detail="Таймаут подключения к сервису поддержки")
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка вызова N8N support webhook: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Сервис поддержки временно недоступен")
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning("N8N support webhook вернул %s: %s", response.status_code, response.text[:500])
|
||||
raise HTTPException(status_code=502, detail="Сервис поддержки вернул ошибку")
|
||||
|
||||
try:
|
||||
resp_json = response.json()
|
||||
if isinstance(resp_json, dict) and resp_json.get("ticket_id"):
|
||||
tid = str(resp_json.get("ticket_id")).strip()
|
||||
if tid:
|
||||
await db.execute(
|
||||
"UPDATE clpr_support_threads SET ticket_id = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2",
|
||||
tid,
|
||||
uuid.UUID(thread_id),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"thread_id": thread_id,
|
||||
"message_id": str(message_id),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/incoming")
|
||||
async def support_incoming(
|
||||
request: Request,
|
||||
x_support_incoming_secret: Optional[str] = Header(None, alias="X-Support-Incoming-Secret"),
|
||||
):
|
||||
"""
|
||||
Webhook для n8n: добавить сообщение от поддержки в тред.
|
||||
Тело: JSON { "thread_id" или "ticket_id", "body", "attachments?" }.
|
||||
Заголовок X-Support-Incoming-Secret должен совпадать с SUPPORT_INCOMING_SECRET (если задан).
|
||||
"""
|
||||
secret = (getattr(settings, "support_incoming_secret", None) or "").strip()
|
||||
if secret:
|
||||
header_secret = x_support_incoming_secret or request.query_params.get("secret") or ""
|
||||
if header_secret.strip() != secret:
|
||||
raise HTTPException(status_code=403, detail="Invalid secret")
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="JSON body required")
|
||||
|
||||
thread_id = body.get("thread_id")
|
||||
ticket_id = body.get("ticket_id")
|
||||
msg_body = (body.get("body") or "").strip()
|
||||
attachments = body.get("attachments")
|
||||
if isinstance(attachments, list):
|
||||
attachments = json.dumps(attachments)
|
||||
else:
|
||||
attachments = "[]"
|
||||
|
||||
if not thread_id and not ticket_id:
|
||||
raise HTTPException(status_code=400, detail="thread_id or ticket_id required")
|
||||
|
||||
if ticket_id and not thread_id:
|
||||
row = await db.fetch_one("SELECT id FROM clpr_support_threads WHERE ticket_id = $1", str(ticket_id))
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Thread not found by ticket_id")
|
||||
thread_id = str(row["id"])
|
||||
|
||||
try:
|
||||
thread_uuid = uuid.UUID(thread_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid thread_id")
|
||||
|
||||
msg_id = uuid.uuid4()
|
||||
await db.execute(
|
||||
"INSERT INTO clpr_support_messages (id, thread_id, direction, body, attachments) VALUES ($1, $2, 'support', $3, $4)",
|
||||
msg_id,
|
||||
thread_uuid,
|
||||
msg_body,
|
||||
attachments,
|
||||
)
|
||||
logger.info("Support incoming message added: thread_id=%s", thread_id)
|
||||
return {"success": True, "message_id": str(msg_id)}
|
||||
176
backend/app/api/telegram_auth.py
Normal file
176
backend/app/api/telegram_auth.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
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: Optional[str] = None
|
||||
unified_id: Optional[str] = None
|
||||
contact_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
has_drafts: Optional[bool] = None
|
||||
need_contact: 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 для отладки (ключи и need_contact/unified_id)
|
||||
logger.info("[TG] n8n ответ (ключи): %s", list(n8n_data.keys()) if isinstance(n8n_data, dict) else type(n8n_data).__name__)
|
||||
_result = n8n_data.get("result")
|
||||
_result_dict = _result if isinstance(_result, dict) else {}
|
||||
if _result_dict:
|
||||
logger.info("[TG] n8n result ключи: %s", list(_result_dict.keys()))
|
||||
|
||||
# Если n8n вернул need_contact — пользователя нет в базе, мини-апп должен закрыться
|
||||
_raw = (
|
||||
n8n_data.get("need_contact")
|
||||
or _result_dict.get("need_contact")
|
||||
or n8n_data.get("needContact")
|
||||
or _result_dict.get("needContact")
|
||||
)
|
||||
need_contact = _raw is True or _raw == 1 or (isinstance(_raw, str) and str(_raw).strip().lower() in ("true", "1"))
|
||||
if need_contact:
|
||||
logger.info("[TG] n8n: need_contact=true — возвращаем need_contact, фронт закроет приложение")
|
||||
return TelegramAuthResponse(success=False, need_contact=True)
|
||||
|
||||
# Ожидаем от n8n как минимум unified_id
|
||||
unified_id = n8n_data.get("unified_id") or _result_dict.get("unified_id") or n8n_data.get("unifiedId")
|
||||
contact_id = n8n_data.get("contact_id") or _result_dict.get("contact_id") or n8n_data.get("contactId")
|
||||
phone = n8n_data.get("phone") or _result_dict.get("phone")
|
||||
has_drafts = n8n_data.get("has_drafts") or _result_dict.get("has_drafts")
|
||||
|
||||
# Нет unified_id = пользователь не найден в базе → тоже возвращаем need_contact, чтобы фронт закрыл мини-апп
|
||||
if not unified_id:
|
||||
logger.info("[TG] n8n не вернул unified_id (пользователь не в базе) — возвращаем need_contact=true. Ответ n8n: %s", n8n_data)
|
||||
return TelegramAuthResponse(success=False, need_contact=True)
|
||||
|
||||
# 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,
|
||||
chat_id=str(telegram_user_id) if telegram_user_id else None,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
"""
|
||||
Конфигурация приложения
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
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):
|
||||
# ============================================
|
||||
@@ -57,7 +63,7 @@ class Settings(BaseSettings):
|
||||
return f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
|
||||
|
||||
# ============================================
|
||||
# REDIS
|
||||
# REDIS (внешний — события, буферы, SMS и т.д.)
|
||||
# ============================================
|
||||
redis_host: str = "localhost"
|
||||
redis_port: int = 6379
|
||||
@@ -65,13 +71,26 @@ class Settings(BaseSettings):
|
||||
redis_db: int = 0
|
||||
redis_prefix: str = "ticket_form:"
|
||||
|
||||
# Redis для сессий (локальный в Docker — miniapp_redis; снаружи — localhost:6383 или свой)
|
||||
redis_session_host: str = "localhost"
|
||||
redis_session_port: int = 6383
|
||||
redis_session_password: str = ""
|
||||
redis_session_db: int = 0
|
||||
|
||||
@property
|
||||
def redis_url(self) -> str:
|
||||
"""Формирует URL для подключения к Redis"""
|
||||
"""Формирует URL для подключения к Redis (внешний)"""
|
||||
if self.redis_password:
|
||||
return f"redis://:{self.redis_password}@{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||
return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||
|
||||
@property
|
||||
def redis_session_url(self) -> str:
|
||||
"""URL для локального Redis сессий"""
|
||||
if self.redis_session_password:
|
||||
return f"redis://:{self.redis_session_password}@{self.redis_session_host}:{self.redis_session_port}/{self.redis_session_db}"
|
||||
return f"redis://{self.redis_session_host}:{self.redis_session_port}/{self.redis_session_db}"
|
||||
|
||||
# ============================================
|
||||
# RABBITMQ
|
||||
# ============================================
|
||||
@@ -120,9 +139,17 @@ class Settings(BaseSettings):
|
||||
aviationstack_base_url: str = "http://api.aviationstack.com/v1"
|
||||
|
||||
# ============================================
|
||||
# NSPK BANKS API
|
||||
# NSPK BANKS API (и альтернативный BANK_IP из .env)
|
||||
# ============================================
|
||||
nspk_banks_api_url: str = "https://qr.nspk.ru/proxyapp/c2bmembers.json"
|
||||
bank_ip: str = "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
|
||||
bank_api_url: str = "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
|
||||
|
||||
# ============================================
|
||||
# DADATA (подсказки адресов в форме профиля)
|
||||
# ============================================
|
||||
forma_dadata_api_key: str = "" # FORMA_DADATA_API_KEY
|
||||
forma_dadata_secret: str = "" # FORMA_DADATA_SECRET
|
||||
|
||||
# ============================================
|
||||
# SMS SERVICE (SigmaSMS)
|
||||
@@ -177,8 +204,73 @@ class Settings(BaseSettings):
|
||||
n8n_api_key: str = "" # Нужно задать в .env
|
||||
n8n_policy_check_webhook: str = ""
|
||||
n8n_file_upload_webhook: str = ""
|
||||
n8n_create_contact_webhook: str = ""
|
||||
n8n_create_claim_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.ru/webhook/ticket_form_description" # Webhook для описания проблемы (переопределяется через N8N_DESCRIPTION_WEBHOOK в .env)
|
||||
# Консультации: тикеты из CRM (MySQL) — тот же payload, что и у других хуков
|
||||
n8n_ticket_form_consultation_webhook: str = "" # N8N_TICKET_FORM_CONSULTATION_WEBHOOK в .env
|
||||
# Подробнее по тикету: session + ticket_id → ответ вебхука (HTML/JSON)
|
||||
n8n_ticket_form_podrobnee_webhook: str = "" # N8N_TICKET_FORM_PODROBNEE_WEBHOOK в .env
|
||||
n8n_project_form_podrobnee_webhook: str = "" # N8N_PROJECT_FORM_PODROBNEE_WEBHOOK — детали дела/проекта из CRM по project_id
|
||||
# Wizard и финальная отправка заявки (create) — один webhook, меняется через .env
|
||||
n8n_ticket_form_final_webhook: str = "https://n8n.clientright.pro/webhook/ecc93306-fadc-489a-afdb-d3e981013df3"
|
||||
n8n_tg_auth_webhook: str = "" # Webhook для авторизации пользователей Telegram WebApp (Mini App)
|
||||
|
||||
# Контактные данные из CRM для раздела «Профиль» (массив или пусто)
|
||||
n8n_contact_webhook: str = "" # N8N_CONTACT_WEBHOOK в .env
|
||||
n8n_profile_update_webhook: str = "" # N8N_PROFILE_UPDATE_WEBHOOK в .env — обновление профиля (verification=0)
|
||||
|
||||
# ============================================
|
||||
# TELEGRAM BOT
|
||||
# ============================================
|
||||
telegram_bot_token: str = "" # Токен бота для проверки initData WebApp
|
||||
|
||||
def get_telegram_bot_tokens(self) -> List[tuple]:
|
||||
"""Список (bot_id, token) для проверки подписи Telegram initData. Один токен — [('default', token)]."""
|
||||
token = (self.telegram_bot_token or "").strip()
|
||||
if token:
|
||||
return [("default", token)]
|
||||
return []
|
||||
|
||||
# ============================================
|
||||
# MAX (мессенджер) — Mini App auth
|
||||
# ============================================
|
||||
max_bot_token: str = "" # Токен бота MAX (один бот)
|
||||
max_bot_tokens: str = "" # Мультибот: JSON {"bot_id": "token", ...}. Если задан — используется вместо max_bot_token.
|
||||
|
||||
def get_max_bot_tokens(self) -> List[tuple]:
|
||||
"""Список (bot_id, token) для проверки подписи MAX initData. Из MAX_BOT_TOKENS (JSON) или [('default', MAX_BOT_TOKEN)]."""
|
||||
s = (self.max_bot_tokens or os.environ.get("MAX_BOT_TOKENS") or "").strip()
|
||||
if s:
|
||||
try:
|
||||
d = json.loads(s)
|
||||
out = [(k, str(v).strip()) for k, v in d.items() if v and str(v).strip()]
|
||||
if out:
|
||||
return out
|
||||
except Exception:
|
||||
pass
|
||||
token = (self.max_bot_token or os.environ.get("MAX_BOT_TOKEN") or "").strip()
|
||||
if token:
|
||||
return [("default", token)]
|
||||
return []
|
||||
|
||||
n8n_max_auth_webhook: str = "" # Webhook n8n: max_user_id → unified_id, contact_id, has_drafts
|
||||
n8n_auth_webhook: str = "" # Универсальный auth: channel + channel_user_id + init_data → unified_id, phone, contact_id, has_drafts
|
||||
|
||||
# ============================================
|
||||
# ПОДДЕРЖКА (чат, треды, n8n webhook)
|
||||
# ============================================
|
||||
n8n_support_webhook: str = "" # N8N_SUPPORT_WEBHOOK — URL webhook n8n (multipart). Обязателен для отправки сообщений.
|
||||
support_attachments_max_count: int = 0 # 0 = без ограничений
|
||||
support_attachments_max_size_mb: int = 0 # 0 = без ограничений
|
||||
support_attachments_allowed_types: str = "" # пусто = любые (например: .pdf,.jpg,image/*)
|
||||
support_incoming_secret: str = "" # Секрет для POST /api/v1/support/incoming (n8n → backend)
|
||||
|
||||
@property
|
||||
def support_attachments_max_size_bytes(self) -> int:
|
||||
if self.support_attachments_max_size_mb <= 0:
|
||||
return 0
|
||||
return self.support_attachments_max_size_mb * 1024 * 1024
|
||||
|
||||
# ============================================
|
||||
# LOGGING
|
||||
@@ -192,9 +284,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()
|
||||
|
||||
@@ -2,25 +2,114 @@
|
||||
Ticket Form Intake Platform - FastAPI Backend
|
||||
"""
|
||||
from fastapi import FastAPI, Request
|
||||
import json
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from .config import settings
|
||||
import redis.asyncio as redis
|
||||
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, max_auth, auth2, auth_universal, documents_draft_open, profile, support
|
||||
from .api import debug_session
|
||||
|
||||
# Настройка логирования
|
||||
# Настройка логирования (уровень из config: LOG_LEVEL=DEBUG для отладки)
|
||||
import sys
|
||||
_level = getattr(logging, (getattr(get_settings(), "log_level", None) or "INFO").upper(), logging.INFO)
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
level=_level,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
stream=sys.stdout,
|
||||
)
|
||||
# Применяем уровень ко всем логгерам приложения
|
||||
logging.getLogger("app").setLevel(_level)
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("Backend log level: %s", logging.getLevelName(_level))
|
||||
|
||||
DEBUG_SESSION_ID = "2a4d38"
|
||||
# В прод-контейнере гарантированно доступен /app/logs (volume ./backend/logs:/app/logs)
|
||||
DEBUG_LOG_PATH = "/app/logs/cursor-debug-2a4d38.log"
|
||||
|
||||
|
||||
def _debug_write(
|
||||
*,
|
||||
hypothesis_id: str,
|
||||
run_id: str,
|
||||
location: str,
|
||||
message: str,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
"""
|
||||
NDJSON debug log for Cursor Debug Mode.
|
||||
IMPORTANT: do not log secrets/PII (tokens, tg hash, full init_data, phone, etc).
|
||||
"""
|
||||
try:
|
||||
ts = int(time.time() * 1000)
|
||||
entry = {
|
||||
"sessionId": DEBUG_SESSION_ID,
|
||||
"id": f"log_{ts}_{uuid.uuid4().hex[:8]}",
|
||||
"timestamp": ts,
|
||||
"location": location,
|
||||
"message": message,
|
||||
"data": data,
|
||||
"runId": run_id,
|
||||
"hypothesisId": hypothesis_id,
|
||||
}
|
||||
with open(DEBUG_LOG_PATH, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
except Exception:
|
||||
# Never break prod request handling due to debug logging
|
||||
return
|
||||
|
||||
|
||||
def _extract_client_bundle_info(payload: Dict[str, Any]) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
"""
|
||||
Returns (moduleUrl, scriptSrc, build) from the last 'boot' entry if present.
|
||||
"""
|
||||
logs = payload.get("logs") or []
|
||||
if not isinstance(logs, list):
|
||||
return (None, None, None)
|
||||
for entry in reversed(logs):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("event") != "boot":
|
||||
continue
|
||||
data = entry.get("data") if isinstance(entry.get("data"), dict) else {}
|
||||
module_url = data.get("moduleUrl") if isinstance(data.get("moduleUrl"), str) else None
|
||||
script_src = data.get("scriptSrc") if isinstance(data.get("scriptSrc"), str) else None
|
||||
build = data.get("build") if isinstance(data.get("build"), str) else None
|
||||
return (module_url, script_src, build)
|
||||
return (None, None, None)
|
||||
|
||||
|
||||
def _extract_last_window_error(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
logs = payload.get("logs") or []
|
||||
if not isinstance(logs, list):
|
||||
return {}
|
||||
for entry in reversed(logs):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("event") != "window_error":
|
||||
continue
|
||||
data = entry.get("data") if isinstance(entry.get("data"), dict) else {}
|
||||
# Keep only safe fields
|
||||
return {
|
||||
"message": data.get("message"),
|
||||
"filename": data.get("filename"),
|
||||
"lineno": data.get("lineno"),
|
||||
"colno": data.get("colno"),
|
||||
"hasStack": bool(data.get("stack")),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -38,13 +127,24 @@ async def lifespan(app: FastAPI):
|
||||
logger.warning(f"⚠️ PostgreSQL not available: {e}")
|
||||
|
||||
try:
|
||||
# Подключаем Redis
|
||||
# Подключаем внешний Redis (события, буферы, SMS и т.д.)
|
||||
await redis_service.connect()
|
||||
# Инициализируем session API с Redis connection
|
||||
session.init_redis(redis_service.client)
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Redis not available: {e}")
|
||||
|
||||
try:
|
||||
# Подключаем локальный Redis для сессий (отдельно от внешнего)
|
||||
session_redis = await redis.from_url(
|
||||
settings.redis_session_url,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
)
|
||||
await session_redis.ping()
|
||||
session.init_redis(session_redis)
|
||||
logger.info(f"✅ Session Redis connected: {settings.redis_session_host}:{settings.redis_session_port}")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Session Redis not available: {e}")
|
||||
|
||||
try:
|
||||
# Подключаем RabbitMQ
|
||||
await rabbitmq_service.connect()
|
||||
@@ -69,15 +169,32 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ S3 storage not available: {e}")
|
||||
|
||||
# Postgres LISTEN support_events для доставки сообщений поддержки в реальном времени (SSE)
|
||||
support_listener_task = None
|
||||
try:
|
||||
support_listener_task = asyncio.create_task(support._run_support_listener())
|
||||
logger.info("✅ Support NOTIFY listener task started")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Support listener not started: {e}")
|
||||
|
||||
logger.info("✅ Ticket Form Intake Platform started successfully!")
|
||||
|
||||
yield
|
||||
|
||||
# SHUTDOWN
|
||||
logger.info("🛑 Shutting down Ticket Form Intake Platform...")
|
||||
if support_listener_task and not support_listener_task.done():
|
||||
support_listener_task.cancel()
|
||||
try:
|
||||
await support_listener_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
await db.disconnect()
|
||||
await redis_service.disconnect()
|
||||
if session.redis_client:
|
||||
await session.redis_client.close()
|
||||
session.init_redis(None)
|
||||
await rabbitmq_service.disconnect()
|
||||
await policy_service.close()
|
||||
await crm_mysql_service.close()
|
||||
@@ -93,14 +210,43 @@ 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)
|
||||
|
||||
|
||||
# Temporary middleware for capturing incoming init_data / startapp / claim_id for debugging.
|
||||
@app.middleware("http")
|
||||
async def capture_initdata_middleware(request, call_next):
|
||||
try:
|
||||
# Check query string first
|
||||
qs = str(request.url.query or "")
|
||||
if qs and ("claim_id" in qs or "startapp" in qs or "start_param" in qs):
|
||||
logger.info("[CAPTURE Q] %s %s QUERY: %s", request.method, request.url.path, qs)
|
||||
|
||||
# Check JSON body for known keys
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
body = await request.body()
|
||||
if body:
|
||||
text = body.decode(errors="ignore")
|
||||
if any(k in text for k in ("init_data", "startapp", "start_param", "claim_id")):
|
||||
# Log truncated body (limit 10k chars)
|
||||
snippet = text if len(text) <= 10000 else (text[:10000] + "...[truncated]")
|
||||
logger.info("[CAPTURE B] %s %s BODY: %s", request.method, request.url.path, snippet)
|
||||
except Exception:
|
||||
logger.exception("❌ Error in capture_initdata_middleware")
|
||||
return await call_next(request)
|
||||
|
||||
# API Routes
|
||||
app.include_router(sms.router)
|
||||
@@ -112,6 +258,15 @@ 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.include_router(max_auth.router) # 📱 MAX Mini App auth
|
||||
app.include_router(auth2.router) # 🆕 Alt auth endpoint (tg/max/sms)
|
||||
app.include_router(auth_universal.router) # Универсальный auth: channel + init_data → N8N_AUTH_WEBHOOK, Redis session:{channel}:{channel_user_id}
|
||||
app.include_router(profile.router) # 👤 Профиль: контакты из CRM через N8N_CONTACT_WEBHOOK
|
||||
app.include_router(support.router) # 📞 Поддержка: форма из бара и карточек жалоб → n8n
|
||||
app.include_router(documents_draft_open.router) # 🆕 Documents draft-open (isolated)
|
||||
app.include_router(debug_session.router) # 🔧 Debug helpers (set session + redirect)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@@ -210,6 +365,71 @@ async def get_client_ip(request: Request):
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/v1/utils/client-log")
|
||||
async def client_log(request: Request):
|
||||
"""
|
||||
Принимает клиентские логи (для отладки webview/miniapp) и пишет в backend-логи.
|
||||
Формат: { reason, client: {...}, logs: [...] }
|
||||
"""
|
||||
client_host = request.client.host if request.client else None
|
||||
ua = request.headers.get("user-agent", "")
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
payload = {"error": "invalid_json"}
|
||||
|
||||
# Cursor debug-mode evidence (sanitized)
|
||||
try:
|
||||
if isinstance(payload, dict):
|
||||
reason = payload.get("reason")
|
||||
client = payload.get("client") if isinstance(payload.get("client"), dict) else {}
|
||||
pathname = client.get("pathname") if isinstance(client.get("pathname"), str) else None
|
||||
origin = client.get("origin") if isinstance(client.get("origin"), str) else None
|
||||
logs = payload.get("logs") if isinstance(payload.get("logs"), list) else []
|
||||
|
||||
module_url, script_src, build = _extract_client_bundle_info(payload)
|
||||
last_err = _extract_last_window_error(payload)
|
||||
first_err_file = None
|
||||
last_err_file = None
|
||||
if isinstance(logs, list):
|
||||
for e in logs:
|
||||
if isinstance(e, dict) and e.get("event") == "window_error":
|
||||
d = e.get("data") if isinstance(e.get("data"), dict) else {}
|
||||
fn = d.get("filename")
|
||||
if isinstance(fn, str):
|
||||
if first_err_file is None:
|
||||
first_err_file = fn
|
||||
last_err_file = fn
|
||||
|
||||
_debug_write(
|
||||
hypothesis_id="H1",
|
||||
run_id="pre-fix",
|
||||
location="backend/app/main.py:client_log",
|
||||
message="client_log_received",
|
||||
data={
|
||||
"ip": client_host,
|
||||
"uaPrefix": ua[:80] if isinstance(ua, str) else "",
|
||||
"reason": reason,
|
||||
"origin": origin,
|
||||
"pathname": pathname,
|
||||
"logsCount": len(logs) if isinstance(logs, list) else None,
|
||||
"boot": {"moduleUrl": module_url, "scriptSrc": script_src, "build": build},
|
||||
"windowErrorLast": last_err,
|
||||
"windowErrorFiles": {"first": first_err_file, "last": last_err_file},
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ограничим размер вывода, но оставим самое важное
|
||||
try:
|
||||
s = json.dumps(payload, ensure_ascii=False)[:20000]
|
||||
except Exception:
|
||||
s = str(payload)[:20000]
|
||||
logger.warning(f"📱 CLIENT_LOG ip={client_host} ua={ua} payload={s}")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@app.get("/api/v1/info")
|
||||
async def info():
|
||||
"""Информация о платформе"""
|
||||
|
||||
120
backend/app/services/max_auth.py
Normal file
120
backend/app/services/max_auth.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
MAX WebApp (Mini App) auth helper.
|
||||
|
||||
Валидация initData от MAX Bridge — тот же алгоритм, что и у Telegram:
|
||||
secret_key = HMAC_SHA256("WebAppData", BOT_TOKEN), data_check_string без hash, сравнение с hash.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MaxAuthError(Exception):
|
||||
"""Ошибка проверки подлинности MAX initData."""
|
||||
|
||||
|
||||
def _parse_init_data(init_data: str) -> Dict[str, Any]:
|
||||
"""Разбирает строку initData (query string) в словарь."""
|
||||
data: Dict[str, Any] = {}
|
||||
for key, value in parse_qsl(init_data, keep_blank_values=True):
|
||||
data[key] = value
|
||||
return data
|
||||
|
||||
|
||||
def _verify_with_token(parsed: Dict[str, Any], data_check_string: str, received_hash: str, bot_token: str) -> bool:
|
||||
"""Проверяет подпись initData одним MAX ботом. Возвращает True, если подпись верна."""
|
||||
secret_key = hmac.new(
|
||||
key="WebAppData".encode("utf-8"),
|
||||
msg=bot_token.encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).digest()
|
||||
calculated_hash = hmac.new(
|
||||
key=secret_key,
|
||||
msg=data_check_string.encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(calculated_hash, received_hash)
|
||||
|
||||
|
||||
def verify_max_init_data(init_data: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Проверяет подпись initData по правилам MAX (аналогично Telegram).
|
||||
|
||||
Поддерживает один бот (MAX_BOT_TOKEN) или несколько (MAX_BOT_TOKENS — JSON).
|
||||
Перебирает токены, пока один не подойдёт; в результат добавляется ключ bot_id.
|
||||
|
||||
- secret_key = HMAC_SHA256("WebAppData", BOT_TOKEN)
|
||||
- data_check_string: пары key=value без hash, сортировка по key, разделитель \n
|
||||
- hex(HMAC_SHA256(secret_key, data_check_string)) === hash из initData
|
||||
"""
|
||||
if not init_data:
|
||||
logger.warning("[MAX] verify_max_init_data: init_data пустой")
|
||||
raise MaxAuthError("init_data is empty")
|
||||
|
||||
tokens_list = settings.get_max_bot_tokens()
|
||||
if not tokens_list:
|
||||
logger.warning("[MAX] Ни MAX_BOT_TOKEN, ни MAX_BOT_TOKENS не заданы в .env")
|
||||
raise MaxAuthError("MAX bot token is not configured")
|
||||
|
||||
parsed = _parse_init_data(init_data)
|
||||
logger.info("[MAX] initData распарсен, ключи: %s", list(parsed.keys()))
|
||||
|
||||
received_hash = parsed.pop("hash", None)
|
||||
if not received_hash:
|
||||
logger.warning("[MAX] В initData отсутствует поле hash")
|
||||
raise MaxAuthError("Missing hash in init_data")
|
||||
|
||||
data_check_items = [f"{k}={parsed[k]}" for k in sorted(parsed.keys())]
|
||||
data_check_string = "\n".join(data_check_items)
|
||||
|
||||
for bot_id, bot_token in tokens_list:
|
||||
if _verify_with_token(parsed, data_check_string, received_hash, bot_token):
|
||||
parsed["bot_id"] = bot_id
|
||||
logger.info("[MAX] Подпись MAX initData проверена, bot_id=%s", bot_id)
|
||||
return parsed
|
||||
|
||||
logger.warning("[MAX] Подпись initData не совпадает ни с одним из токенов MAX ботов")
|
||||
raise MaxAuthError("Invalid init_data hash")
|
||||
|
||||
|
||||
def extract_max_user(init_data: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Валидирует initData и возвращает данные пользователя MAX.
|
||||
|
||||
В поле `user` — JSON с id, first_name, last_name, username, language_code, photo_url и т.д.
|
||||
"""
|
||||
parsed = verify_max_init_data(init_data)
|
||||
|
||||
user_raw = parsed.get("user")
|
||||
if not user_raw:
|
||||
logger.warning("[MAX] В initData отсутствует поле user")
|
||||
raise MaxAuthError("No user field in init_data")
|
||||
|
||||
try:
|
||||
user_obj = json.loads(user_raw)
|
||||
except Exception as e:
|
||||
raise MaxAuthError(f"Failed to parse user JSON: {e}") from e
|
||||
|
||||
if "id" not in user_obj:
|
||||
raise MaxAuthError("MAX user.id is missing")
|
||||
|
||||
result = {
|
||||
"max_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"),
|
||||
"photo_url": user_obj.get("photo_url"),
|
||||
"raw": user_obj,
|
||||
}
|
||||
if "bot_id" in parsed:
|
||||
result["bot_id"] = parsed["bot_id"]
|
||||
return result
|
||||
@@ -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,37 +64,64 @@ 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}...")
|
||||
try:
|
||||
deactivate_response = await client.post(
|
||||
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/deactivate",
|
||||
headers=headers
|
||||
headers=headers,
|
||||
timeout=15.0 # Отдельный таймаут для деактивации
|
||||
)
|
||||
|
||||
if deactivate_response.status_code not in [200, 404]:
|
||||
if deactivate_response.status_code in [200, 404]:
|
||||
logger.info("✅ Workflow деактивирован")
|
||||
else:
|
||||
logger.warning(
|
||||
f"⚠️ Неожиданный статус при деактивации: "
|
||||
f"{deactivate_response.status_code}"
|
||||
f"{deactivate_response.status_code} - {deactivate_response.text[:200]}"
|
||||
)
|
||||
else:
|
||||
logger.info("✅ Workflow деактивирован")
|
||||
# Продолжаем даже если деактивация не удалась - возможно 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}...")
|
||||
try:
|
||||
activate_response = await client.post(
|
||||
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/activate",
|
||||
headers=headers
|
||||
headers=headers,
|
||||
timeout=15.0 # Отдельный таймаут для активации
|
||||
)
|
||||
|
||||
if activate_response.status_code == 200:
|
||||
logger.info("✅ Workflow активирован")
|
||||
|
||||
# Дополнительная задержка для инициализации trigger node
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# После успешного перезапуска отправляем сообщения из буфера
|
||||
await _send_buffered_messages()
|
||||
|
||||
@@ -104,9 +132,18 @@ async def restart_workflow() -> bool:
|
||||
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}")
|
||||
logger.error(f"❌ Ошибка при активации workflow: {e}")
|
||||
return False
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("⏱️ Общий таймаут при перезапуске workflow")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Неожиданная ошибка при перезапуске workflow: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -65,11 +65,11 @@ class SMSService:
|
||||
logger.warning("SMS отправка отключена в конфигурации")
|
||||
return False
|
||||
|
||||
# DEBUG MODE: Не отправляем реальные SMS, экономим бюджет
|
||||
# 🔧 DEV MODE: Не отправляем реальные SMS в development, экономим бюджет
|
||||
if settings.debug or settings.app_env == "development":
|
||||
logger.info(f"🔧 DEBUG MODE: SMS to {phone} not sent (saving money!)")
|
||||
logger.info(f"🔧 DEV MODE: SMS to {phone} not sent (saving money!)")
|
||||
logger.info(f"📱 Message would be: {message}")
|
||||
return True
|
||||
return True # Возвращаем True чтобы код сохранился в Redis для проверки
|
||||
|
||||
try:
|
||||
# Получаем актуальный токен
|
||||
|
||||
132
backend/app/services/telegram_auth.py
Normal file
132
backend/app/services/telegram_auth.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Telegram WebApp (Mini App) auth helper.
|
||||
|
||||
В этом модуле:
|
||||
- Парсим и валидируем initData от Telegram WebApp
|
||||
- Проверяем подпись по токену бота из настроек
|
||||
- Возвращаем разобранные данные пользователя Telegram
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelegramAuthError(Exception):
|
||||
"""Ошибка проверки подлинности Telegram initData."""
|
||||
|
||||
|
||||
def _parse_init_data(init_data: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Разбирает строку initData в словарь.
|
||||
|
||||
Формат initData — это query string, см. Telegram WebApp docs.
|
||||
"""
|
||||
data: Dict[str, Any] = {}
|
||||
for key, value in parse_qsl(init_data, keep_blank_values=True):
|
||||
data[key] = value
|
||||
return data
|
||||
|
||||
|
||||
def verify_telegram_init_data(init_data: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Проверяет подпись initData согласно Telegram WebApp правилам.
|
||||
|
||||
Алгоритм из официальной документации:
|
||||
- Берём токен бота: BOT_TOKEN
|
||||
- Вычисляем secret_key = HMAC_SHA256("WebAppData", BOT_TOKEN)
|
||||
- Собираем data_check_string: строки "<key>=<value>" по всем полям, кроме 'hash',
|
||||
отсортированные по key, соединённые '\n'
|
||||
- Считаем хэш: HMAC_SHA256(secret_key, data_check_string)
|
||||
- Сравниваем с полем 'hash' из initData (hex)
|
||||
"""
|
||||
if not init_data:
|
||||
logger.warning("[TG] verify_telegram_init_data: init_data пустой")
|
||||
raise TelegramAuthError("init_data is empty")
|
||||
|
||||
bot_token = (getattr(settings, "telegram_bot_token", None) or "").strip()
|
||||
if not bot_token:
|
||||
logger.warning("[TG] verify_telegram_init_data: TELEGRAM_BOT_TOKEN не задан в .env")
|
||||
raise TelegramAuthError("Telegram bot token is not configured")
|
||||
|
||||
parsed = _parse_init_data(init_data)
|
||||
logger.info("[TG] initData распарсен, ключи: %s", list(parsed.keys()))
|
||||
|
||||
received_hash = parsed.pop("hash", None)
|
||||
if not received_hash:
|
||||
logger.warning("[TG] В initData отсутствует поле hash")
|
||||
raise TelegramAuthError("Missing hash in init_data")
|
||||
|
||||
# Формируем data_check_string
|
||||
data_check_items = []
|
||||
for key in sorted(parsed.keys()):
|
||||
value = parsed[key]
|
||||
data_check_items.append(f"{key}={value}")
|
||||
data_check_string = "\n".join(data_check_items)
|
||||
|
||||
# secret_key = HMAC_SHA256("WebAppData", BOT_TOKEN)
|
||||
secret_key = hmac.new(
|
||||
key="WebAppData".encode("utf-8"),
|
||||
msg=bot_token.encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
# HMAC_SHA256(secret_key, data_check_string)
|
||||
calculated_hash = hmac.new(
|
||||
key=secret_key,
|
||||
msg=data_check_string.encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(calculated_hash, received_hash):
|
||||
logger.warning("[TG] Подпись initData не совпадает (неверный токен бота или поддельные данные)")
|
||||
raise TelegramAuthError("Invalid init_data hash")
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
def extract_telegram_user(init_data: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Валидирует initData и возвращает данные пользователя Telegram.
|
||||
|
||||
В field `user` лежит JSON-строка с полями:
|
||||
{
|
||||
"id": 123456789,
|
||||
"first_name": "...",
|
||||
"last_name": "...",
|
||||
"username": "...",
|
||||
...
|
||||
}
|
||||
"""
|
||||
import json
|
||||
|
||||
parsed = verify_telegram_init_data(init_data)
|
||||
|
||||
user_raw = parsed.get("user")
|
||||
if not user_raw:
|
||||
logger.warning("[TG] В initData отсутствует поле user")
|
||||
raise TelegramAuthError("No user field in init_data")
|
||||
|
||||
try:
|
||||
user_obj = json.loads(user_raw)
|
||||
except Exception as e:
|
||||
raise TelegramAuthError(f"Failed to parse user JSON: {e}") from e
|
||||
|
||||
if "id" not in user_obj:
|
||||
raise TelegramAuthError("Telegram user.id is missing")
|
||||
|
||||
return {
|
||||
"telegram_user_id": str(user_obj.get("id")),
|
||||
"username": user_obj.get("username"),
|
||||
"first_name": user_obj.get("first_name"),
|
||||
"last_name": user_obj.get("last_name"),
|
||||
"language_code": user_obj.get("language_code"),
|
||||
"raw": user_obj,
|
||||
}
|
||||
|
||||
32
backend/db/migrations/003_support_threads_messages.sql
Normal file
32
backend/db/migrations/003_support_threads_messages.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- Треды и сообщения поддержки (диалог). Префикс таблиц: clpr_
|
||||
-- Один тред на (unified_id, claim_id или null); сообщения user/support
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clpr_support_threads (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
unified_id VARCHAR(255) NOT NULL,
|
||||
claim_id VARCHAR(255),
|
||||
source VARCHAR(50) NOT NULL DEFAULT 'bar',
|
||||
ticket_id VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_clpr_support_threads_unified_claim ON clpr_support_threads(unified_id, claim_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_clpr_support_threads_ticket ON clpr_support_threads(ticket_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clpr_support_messages (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
thread_id UUID NOT NULL REFERENCES clpr_support_threads(id) ON DELETE CASCADE,
|
||||
direction VARCHAR(20) NOT NULL CHECK (direction IN ('user', 'support')),
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
attachments JSONB DEFAULT '[]',
|
||||
external_id VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_clpr_support_messages_thread ON clpr_support_messages(thread_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_clpr_support_messages_created ON clpr_support_messages(thread_id, created_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_clpr_support_messages_external ON clpr_support_messages(thread_id, external_id) WHERE external_id IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE clpr_support_threads IS 'Треды обращений в поддержку: один на пользователя (бар) или по claim_id';
|
||||
COMMENT ON TABLE clpr_support_messages IS 'Сообщения в треде: user — от пользователя, support — от оператора';
|
||||
37
backend/db/migrations/004_support_notify_trigger.sql
Normal file
37
backend/db/migrations/004_support_notify_trigger.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- NOTIFY при INSERT в clpr_support_messages для доставки в реальном времени (SSE).
|
||||
-- Один канал support_events, в payload — unified_id и данные сообщения.
|
||||
-- Таблицы поддержки с префиксом clpr_
|
||||
|
||||
CREATE OR REPLACE FUNCTION support_messages_notify()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
u_id VARCHAR(255);
|
||||
payload TEXT;
|
||||
BEGIN
|
||||
SELECT unified_id INTO u_id FROM clpr_support_threads WHERE id = NEW.thread_id;
|
||||
IF u_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
payload := json_build_object(
|
||||
'unified_id', u_id,
|
||||
'thread_id', NEW.thread_id,
|
||||
'message_id', NEW.id,
|
||||
'direction', NEW.direction,
|
||||
'body', NEW.body,
|
||||
'attachments', COALESCE(NEW.attachments::TEXT, '[]'),
|
||||
'created_at', NEW.created_at
|
||||
)::TEXT;
|
||||
PERFORM pg_notify('support_events', payload);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS after_support_message_insert ON clpr_support_messages;
|
||||
CREATE TRIGGER after_support_message_insert
|
||||
AFTER INSERT ON clpr_support_messages
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE support_messages_notify();
|
||||
|
||||
COMMENT ON FUNCTION support_messages_notify() IS 'NOTIFY support_events при новом сообщении для SSE';
|
||||
13
backend/db/migrations/005_support_reads.sql
Normal file
13
backend/db/migrations/005_support_reads.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Отметки «прочитано» по тредам: когда пользователь последний раз видел тред.
|
||||
-- Непрочитанные = сообщения от support с created_at > last_read_at.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clpr_support_reads (
|
||||
unified_id VARCHAR(255) NOT NULL,
|
||||
thread_id UUID NOT NULL REFERENCES clpr_support_threads(id) ON DELETE CASCADE,
|
||||
last_read_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (unified_id, thread_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_clpr_support_reads_unified ON clpr_support_reads(unified_id);
|
||||
|
||||
COMMENT ON TABLE clpr_support_reads IS 'Когда пользователь последний раз «прочитал» тред (открыл чат)';
|
||||
86
deploy-to-prod.sh
Executable file
86
deploy-to-prod.sh
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# Скрипт переноса изменений из DEV в PROD
|
||||
# ============================================
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "🚀 Перенос изменений из DEV в PROD"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
|
||||
# Проверка что мы в правильной директории
|
||||
if [ ! -f "docker-compose.dev.yml" ]; then
|
||||
echo "❌ Ошибка: запустите скрипт из корня проекта ticket_form"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. Проверка изменений в git
|
||||
echo "📊 Проверяю изменения в git..."
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "⚠️ Есть незакоммиченные изменения!"
|
||||
echo ""
|
||||
git status --short
|
||||
echo ""
|
||||
read -p "Закоммитить изменения перед деплоем? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "💾 Коммичу изменения..."
|
||||
git add -A
|
||||
git commit -m "chore: Изменения перед деплоем в prod $(date +%Y-%m-%d)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Push в dev репозиторий
|
||||
echo ""
|
||||
echo "📤 Отправляю изменения в DEV репозиторий..."
|
||||
if git remote | grep -q "aiform_dev"; then
|
||||
git push aiform_dev main 2>/dev/null || git push aiform_dev master 2>/dev/null || echo "⚠️ Не удалось запушить в aiform_dev"
|
||||
fi
|
||||
|
||||
# 3. Push в prod репозиторий
|
||||
echo ""
|
||||
echo "📤 Отправляю изменения в PROD репозиторий..."
|
||||
if git remote | grep -q "aiform_prod"; then
|
||||
git push aiform_prod main 2>/dev/null || git push aiform_prod master 2>/dev/null || echo "⚠️ Не удалось запушить в aiform_prod"
|
||||
else
|
||||
echo "⚠️ Remote 'aiform_prod' не найден. Добавьте:"
|
||||
echo " git remote add aiform_prod http://147.45.146.17:3002/negodiy/aiform_prod.git"
|
||||
fi
|
||||
|
||||
# 4. Пересборка prod контейнеров
|
||||
echo ""
|
||||
echo "🔨 Пересобираю PROD контейнеры..."
|
||||
docker-compose -f docker-compose.prod.yml build --no-cache
|
||||
|
||||
# 5. Перезапуск prod
|
||||
echo ""
|
||||
echo "🔄 Перезапускаю PROD окружение..."
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
# 6. Проверка статуса
|
||||
echo ""
|
||||
echo "⏳ Жду запуска (5 сек)..."
|
||||
sleep 5
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "✅ Деплой завершён!"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "📍 PROD доступен:"
|
||||
echo " Frontend: http://localhost:5176"
|
||||
echo " Backend: http://localhost:8200"
|
||||
echo " Production: https://aiform.clientright.ru"
|
||||
echo ""
|
||||
echo "📊 Статус контейнеров:"
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
echo ""
|
||||
echo "📋 Логи (последние 20 строк):"
|
||||
docker-compose -f docker-compose.prod.yml logs --tail=20
|
||||
echo ""
|
||||
|
||||
39
docker-compose.dev.yml
Normal file
39
docker-compose.dev.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
aiform_frontend_dev:
|
||||
container_name: aiform_frontend_dev
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "5177:3000"
|
||||
environment:
|
||||
- VITE_API_URL=http://localhost:8201
|
||||
- NODE_ENV=development
|
||||
volumes:
|
||||
- ./frontend/src:/app/src:ro
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- aiform-dev-network
|
||||
restart: unless-stopped
|
||||
|
||||
aiform_backend_dev:
|
||||
container_name: aiform_backend_dev
|
||||
build: ./backend
|
||||
ports:
|
||||
- "8201:8200"
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
environment:
|
||||
- APP_ENV=development
|
||||
- DEBUG=true
|
||||
networks:
|
||||
- aiform-dev-network
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
aiform-dev-network:
|
||||
driver: bridge
|
||||
|
||||
62
docker-compose.prod.yml
Normal file
62
docker-compose.prod.yml
Normal file
@@ -0,0 +1,62 @@
|
||||
version: '3.8'
|
||||
|
||||
# ============================================
|
||||
# PRODUCTION ENVIRONMENT
|
||||
# Запуск: docker-compose -f docker-compose.prod.yml up -d
|
||||
# ============================================
|
||||
|
||||
services:
|
||||
ticket_form_frontend_prod:
|
||||
container_name: miniapp_front
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.prod
|
||||
ports:
|
||||
- "4176: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: miniapp_back
|
||||
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:4200/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
|
||||
|
||||
68
docs/BROWSERLESS_CURL_EXAMPLE.sh
Normal file
68
docs/BROWSERLESS_CURL_EXAMPLE.sh
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# Пример curl запроса для Browserless (HTML → PDF)
|
||||
# Используйте этот запрос в HTTP Request ноде n8n
|
||||
# ============================================================================
|
||||
|
||||
# ВАРИАНТ 1: С data URL (HTML в base64)
|
||||
curl -X POST http://147.45.146.17:3000/pdf \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{
|
||||
"url": "data:text/html;base64,PCFET0NUWVBFIGh0bWw+PGh0bWw+PGJvZHk+PGgxPlRlc3Q8L2gxPjwvYm9keT48L2h0bWw+",
|
||||
"options": {
|
||||
"format": "A4",
|
||||
"printBackground": true,
|
||||
"margin": {
|
||||
"top": "20mm",
|
||||
"right": "15mm",
|
||||
"bottom": "20mm",
|
||||
"left": "15mm"
|
||||
}
|
||||
}
|
||||
}'
|
||||
|
||||
# ============================================================================
|
||||
# ВАРИАНТ 2: С прямым HTML (если Browserless поддерживает)
|
||||
# ============================================================================
|
||||
# curl -X POST http://147.45.146.17:3000/pdf \
|
||||
# -H "Content-Type: application/json" \
|
||||
# -H "Authorization: Bearer YOUR_TOKEN" \
|
||||
# -d '{
|
||||
# "html": "<!DOCTYPE html><html><body><h1>Test</h1></body></html>",
|
||||
# "options": {
|
||||
# "format": "A4",
|
||||
# "printBackground": true,
|
||||
# "margin": {
|
||||
# "top": "20mm",
|
||||
# "right": "15mm",
|
||||
# "bottom": "20mm",
|
||||
# "left": "15mm"
|
||||
# }
|
||||
# }
|
||||
# }'
|
||||
|
||||
# ============================================================================
|
||||
# НАСТРОЙКА В HTTP REQUEST НОДЕ:
|
||||
# ============================================================================
|
||||
# Method: POST
|
||||
# URL: http://147.45.146.17:3000/pdf
|
||||
# Headers:
|
||||
# Content-Type: application/json
|
||||
# Authorization: Bearer YOUR_TOKEN (если требуется)
|
||||
# Body (JSON):
|
||||
# {
|
||||
# "url": "data:text/html;base64,{{ $json.html_base64_encoded }}",
|
||||
# "options": {
|
||||
# "format": "A4",
|
||||
# "printBackground": true,
|
||||
# "margin": {
|
||||
# "top": "20mm",
|
||||
# "right": "15mm",
|
||||
# "bottom": "20mm",
|
||||
# "left": "15mm"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# Response Format: Binary
|
||||
# ============================================================================
|
||||
163
docs/N8N_BROWSERLESS_FUNCTION_GUIDE.md
Normal file
163
docs/N8N_BROWSERLESS_FUNCTION_GUIDE.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# Настройка HTTP Request для Browserless Function API
|
||||
|
||||
## Готовые настройки для HTTP Request ноды
|
||||
|
||||
### Method
|
||||
`POST`
|
||||
|
||||
### URL
|
||||
```
|
||||
http://147.45.146.17:3000/function?token=9ahhnpjkchxtcho9
|
||||
```
|
||||
|
||||
### Headers
|
||||
```json
|
||||
{
|
||||
"Content-Type": "application/javascript"
|
||||
}
|
||||
```
|
||||
|
||||
### Body (Raw)
|
||||
**Content Type:** `application/javascript`
|
||||
|
||||
**Body:**
|
||||
```javascript
|
||||
export default async function ({ page }) {
|
||||
const html = `{{ $json.html }}`;
|
||||
|
||||
if (!html) {
|
||||
throw new Error('❌ HTML не передан');
|
||||
}
|
||||
|
||||
// универсальный sleep
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
await page.setViewport({ width: 1240, height: 1754 });
|
||||
|
||||
// Загружаем HTML напрямую
|
||||
await page.setContent(html, {
|
||||
waitUntil: ['load', 'domcontentloaded', 'networkidle0'],
|
||||
});
|
||||
|
||||
// Даём браузеру применить стили
|
||||
await sleep(300);
|
||||
|
||||
const pdfBuffer = await page.pdf({
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '15mm',
|
||||
bottom: '20mm',
|
||||
left: '15mm',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
pdf_base64: pdfBuffer.toString('base64'),
|
||||
size_bytes: pdfBuffer.length,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Options
|
||||
- **Timeout:** `40000` (40 секунд)
|
||||
|
||||
### Response Format
|
||||
`JSON` (Browserless вернёт JSON с `pdf_base64`)
|
||||
|
||||
---
|
||||
|
||||
## Вариант с html_base64
|
||||
|
||||
Если у вас HTML в base64, используйте этот вариант:
|
||||
|
||||
```javascript
|
||||
export default async function ({ page }) {
|
||||
// Получаем HTML из base64
|
||||
const htmlBase64 = `{{ $json.html_base64 }}`;
|
||||
const html = Buffer.from(htmlBase64, 'base64').toString('utf8');
|
||||
|
||||
if (!html) {
|
||||
throw new Error('❌ HTML не передан');
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
await page.setViewport({ width: 1240, height: 1754 });
|
||||
|
||||
await page.setContent(html, {
|
||||
waitUntil: ['load', 'domcontentloaded', 'networkidle0'],
|
||||
});
|
||||
|
||||
await sleep(300);
|
||||
|
||||
const pdfBuffer = await page.pdf({
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '15mm',
|
||||
bottom: '20mm',
|
||||
left: '15mm',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
pdf_base64: pdfBuffer.toString('base64'),
|
||||
size_bytes: pdfBuffer.length,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Полный Workflow
|
||||
|
||||
```
|
||||
[Code: Process Flights Data] ← Генерирует HTML
|
||||
↓
|
||||
[HTTP Request: Browserless Function] ← Используйте настройки выше
|
||||
↓
|
||||
[Code: Extract PDF Base64] ← Если нужно обработать ответ
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Node: Extract PDF Base64 (опционально)
|
||||
|
||||
Если Browserless уже вернул `pdf_base64` в JSON, можно просто передать дальше:
|
||||
|
||||
```javascript
|
||||
const response = $input.first().json;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
pdf_base64: response.pdf_base64,
|
||||
pdf_size_bytes: response.size_bytes,
|
||||
pdf_size_mb: (response.size_bytes / (1024 * 1024)).toFixed(2),
|
||||
status: response.status,
|
||||
success: true
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Преимущества этого подхода
|
||||
|
||||
✅ **Прямая работа с HTML** - не нужно конвертировать в data URL
|
||||
✅ **Полный контроль** - можете добавить любую логику в функцию
|
||||
✅ **Готовый base64** - Browserless сразу возвращает base64 PDF
|
||||
✅ **Надёжность** - sleep даёт время браузеру применить стили
|
||||
|
||||
---
|
||||
|
||||
## Отладка
|
||||
|
||||
Если получаете ошибки:
|
||||
- **"HTML не передан"** → Проверьте, что предыдущая нода вернула `html` или `html_base64`
|
||||
- **Timeout** → Увеличьте timeout в Options до 60000 (60 секунд)
|
||||
- **Пустой PDF** → Увеличьте sleep до 500-1000ms
|
||||
29
docs/N8N_BROWSERLESS_FUNCTION_SETUP.json
Normal file
29
docs/N8N_BROWSERLESS_FUNCTION_SETUP.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "http://147.45.146.17:3000/function?token=9ahhnpjkchxtcho9",
|
||||
"sendHeaders": true,
|
||||
"headerParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Content-Type",
|
||||
"value": "application/javascript"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sendBody": true,
|
||||
"contentType": "raw",
|
||||
"rawContentType": "application/javascript",
|
||||
"body": "export default async function ({ page }) {\n const html = `{{ $json.html }}`;\n\n if (!html) {\n throw new Error('❌ HTML не передан');\n }\n\n // универсальный sleep\n const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));\n\n await page.setViewport({ width: 1240, height: 1754 });\n\n // Загружаем HTML напрямую\n await page.setContent(html, {\n waitUntil: ['load', 'domcontentloaded', 'networkidle0'],\n });\n\n // Даём браузеру применить стили\n await sleep(300);\n\n const pdfBuffer = await page.pdf({\n format: 'A4',\n printBackground: true,\n margin: {\n top: '20mm',\n right: '15mm',\n bottom: '20mm',\n left: '15mm',\n },\n });\n\n return {\n status: 'success',\n pdf_base64: pdfBuffer.toString('base64'),\n size_bytes: pdfBuffer.length,\n };\n}",
|
||||
"options": {
|
||||
"timeout": 40000
|
||||
}
|
||||
},
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"name": "Browserless: HTML to PDF"
|
||||
}
|
||||
]
|
||||
}
|
||||
135
docs/N8N_BROWSERLESS_HTTP_REQUEST_SETUP.md
Normal file
135
docs/N8N_BROWSERLESS_HTTP_REQUEST_SETUP.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Настройка HTTP Request ноды для Browserless
|
||||
|
||||
## Готовый запрос для вставки
|
||||
|
||||
### Вариант 1: С использованием html_base64 из предыдущей ноды
|
||||
|
||||
**Method:** `POST`
|
||||
|
||||
**URL:** `http://147.45.146.17:3000/pdf`
|
||||
|
||||
**Headers:**
|
||||
```json
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer YOUR_TOKEN"
|
||||
}
|
||||
```
|
||||
*Примечание: Если токен не требуется, уберите строку Authorization*
|
||||
|
||||
**Body (JSON):**
|
||||
```json
|
||||
{
|
||||
"url": "data:text/html;base64,{{ $json.html_base64 }}",
|
||||
"options": {
|
||||
"format": "A4",
|
||||
"printBackground": true,
|
||||
"margin": {
|
||||
"top": "20mm",
|
||||
"right": "15mm",
|
||||
"bottom": "20mm",
|
||||
"left": "15mm"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response Format:** `Binary`
|
||||
|
||||
---
|
||||
|
||||
### Вариант 2: Если у вас HTML в строке (не base64)
|
||||
|
||||
**Method:** `POST`
|
||||
|
||||
**URL:** `http://147.45.146.17:3000/pdf`
|
||||
|
||||
**Headers:**
|
||||
```json
|
||||
{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer YOUR_TOKEN"
|
||||
}
|
||||
```
|
||||
|
||||
**Body (JSON):**
|
||||
```json
|
||||
{
|
||||
"html": "{{ $json.html }}",
|
||||
"options": {
|
||||
"format": "A4",
|
||||
"printBackground": true,
|
||||
"margin": {
|
||||
"top": "20mm",
|
||||
"right": "15mm",
|
||||
"bottom": "20mm",
|
||||
"left": "15mm"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response Format:** `Binary`
|
||||
|
||||
---
|
||||
|
||||
## Полный workflow
|
||||
|
||||
```
|
||||
[Code: Process Flights Data] ← Генерирует HTML
|
||||
↓
|
||||
[Code: HTML to Base64] ← Конвертирует HTML в base64 (если нужно)
|
||||
↓
|
||||
[HTTP Request: Browserless PDF] ← Используйте настройки выше
|
||||
↓
|
||||
[Code: Extract Base64 PDF] ← Конвертирует binary в base64
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Node: HTML to Base64 (если нужно)
|
||||
|
||||
Если у вас HTML в строке, а нужен base64 для data URL:
|
||||
|
||||
```javascript
|
||||
const html = $json.html;
|
||||
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
|
||||
|
||||
return [{
|
||||
json: {
|
||||
html_base64: htmlBase64,
|
||||
html: html
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Node: Extract Base64 PDF (после HTTP Request)
|
||||
|
||||
```javascript
|
||||
const pdfBinary = $binary.data;
|
||||
const base64 = Buffer.isBuffer(pdfBinary)
|
||||
? pdfBinary.toString('base64')
|
||||
: Buffer.from(pdfBinary).toString('base64');
|
||||
|
||||
const sizeBytes = Buffer.from(base64, 'base64').length;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
pdf_base64: base64,
|
||||
pdf_size_bytes: sizeBytes,
|
||||
pdf_size_mb: (sizeBytes / (1024 * 1024)).toFixed(2),
|
||||
success: true
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Отладка
|
||||
|
||||
Если получаете ошибку:
|
||||
- **"Bad or missing authentication"** → Проверьте токен или уберите Authorization header
|
||||
- **"Not Found"** → Проверьте URL эндпоинта
|
||||
- **Пустой ответ** → Проверьте формат HTML и data URL
|
||||
698
docs/N8N_CODE_PROCESS_FLIGHTS_DATA.js
Normal file
698
docs/N8N_CODE_PROCESS_FLIGHTS_DATA.js
Normal file
@@ -0,0 +1,698 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: Обработка данных о рейсах из FlightAware и FlightRadar24
|
||||
// ============================================================================
|
||||
// Объединяет данные из двух источников и формирует красивый HTML для PDF
|
||||
// ============================================================================
|
||||
|
||||
// ==== ПОЛУЧЕНИЕ ВХОДНЫХ ДАННЫХ ====
|
||||
// Ожидаемая структура: массив с двумя элементами
|
||||
// [0] - данные из FlightAware (body.flights[])
|
||||
// [1] - данные из FlightRadar24 (body.data[])
|
||||
const inputItems = $input.all();
|
||||
|
||||
if (!inputItems || inputItems.length === 0) {
|
||||
return [{
|
||||
json: {
|
||||
error: 'Нет входных данных',
|
||||
html: '<html><body><h1>Ошибка: данные не получены</h1></body></html>',
|
||||
flights: [],
|
||||
sources: { flightaware: false, flightradar24: false }
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
// ==== ИЗВЛЕЧЕНИЕ ДАННЫХ ИЗ ИСТОЧНИКОВ ====
|
||||
let flightAwareData = [];
|
||||
let flightRadar24Data = [];
|
||||
|
||||
try {
|
||||
// Первый элемент - FlightAware
|
||||
const faItem = inputItems[0];
|
||||
if (faItem && faItem.json && faItem.json.body && faItem.json.body.flights) {
|
||||
flightAwareData = Array.isArray(faItem.json.body.flights)
|
||||
? faItem.json.body.flights
|
||||
: [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('⚠️ Ошибка извлечения FlightAware:', e.message);
|
||||
}
|
||||
|
||||
try {
|
||||
// Второй элемент - FlightRadar24
|
||||
const fr24Item = inputItems[1];
|
||||
if (fr24Item && fr24Item.json && fr24Item.json.body && fr24Item.json.body.data) {
|
||||
flightRadar24Data = Array.isArray(fr24Item.json.body.data)
|
||||
? fr24Item.json.body.data
|
||||
: [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('⚠️ Ошибка извлечения FlightRadar24:', e.message);
|
||||
}
|
||||
|
||||
// ==== УТИЛИТЫ ====
|
||||
const safeStr = (v) => (v == null ? '' : String(v));
|
||||
const safeDate = (v) => {
|
||||
if (!v) return '—';
|
||||
try {
|
||||
const d = new Date(v);
|
||||
return d.toLocaleString('ru-RU', {
|
||||
timeZone: 'UTC',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return v;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
if (!seconds) return '—';
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}ч ${minutes}м`;
|
||||
};
|
||||
|
||||
const formatDistance = (km) => {
|
||||
if (!km) return '—';
|
||||
return `${Number(km).toFixed(2)} км`;
|
||||
};
|
||||
|
||||
// ==== ОБЪЕДИНЕНИЕ ДАННЫХ ПО REGISTRATION ====
|
||||
// Создаём карту для быстрого поиска
|
||||
const flightsMap = new Map();
|
||||
|
||||
// Добавляем данные из FlightAware
|
||||
flightAwareData.forEach(flight => {
|
||||
const reg = safeStr(flight.registration).trim();
|
||||
if (!reg) return;
|
||||
|
||||
if (!flightsMap.has(reg)) {
|
||||
flightsMap.set(reg, {
|
||||
registration: reg,
|
||||
flightNumber: safeStr(flight.flight_number),
|
||||
ident: safeStr(flight.ident),
|
||||
identIata: safeStr(flight.ident_iata),
|
||||
aircraftType: safeStr(flight.aircraft_type),
|
||||
flightAware: flight,
|
||||
flightRadar24: null
|
||||
});
|
||||
} else {
|
||||
flightsMap.get(reg).flightAware = flight;
|
||||
}
|
||||
});
|
||||
|
||||
// Добавляем данные из FlightRadar24
|
||||
flightRadar24Data.forEach(flight => {
|
||||
const reg = safeStr(flight.reg).trim();
|
||||
if (!reg) return;
|
||||
|
||||
if (!flightsMap.has(reg)) {
|
||||
flightsMap.set(reg, {
|
||||
registration: reg,
|
||||
flightNumber: safeStr(flight.flight),
|
||||
ident: safeStr(flight.callsign),
|
||||
identIata: safeStr(flight.flight),
|
||||
aircraftType: safeStr(flight.type),
|
||||
flightAware: null,
|
||||
flightRadar24: flight
|
||||
});
|
||||
} else {
|
||||
flightsMap.get(reg).flightRadar24 = flight;
|
||||
}
|
||||
});
|
||||
|
||||
// Преобразуем Map в массив
|
||||
const mergedFlights = Array.from(flightsMap.values());
|
||||
|
||||
// ==== ГЕНЕРАЦИЯ HTML ====
|
||||
const generateFlightCard = (flight) => {
|
||||
const fa = flight.flightAware;
|
||||
const fr24 = flight.flightRadar24;
|
||||
|
||||
let html = `
|
||||
<div class="flight-card">
|
||||
<div class="flight-header">
|
||||
<h2>Рейс ${flight.flightNumber || flight.ident || 'N/A'}</h2>
|
||||
<span class="registration">${flight.registration}</span>
|
||||
</div>
|
||||
|
||||
<div class="flight-info">
|
||||
<div class="info-row">
|
||||
<span class="label">Тип самолёта:</span>
|
||||
<span class="value">${flight.aircraftType || '—'}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Идентификатор:</span>
|
||||
<span class="value">${flight.ident || '—'} (${flight.identIata || '—'})</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Данные из FlightAware
|
||||
if (fa) {
|
||||
html += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightaware">FlightAware</span>
|
||||
</div>
|
||||
<div class="source-content">
|
||||
<div class="route-info">
|
||||
<div class="route-item">
|
||||
<span class="route-label">Откуда:</span>
|
||||
<span class="route-value">${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')})</span>
|
||||
</div>
|
||||
<div class="route-item">
|
||||
<span class="route-label">Куда:</span>
|
||||
<span class="route-value">${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline">
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Плановый вылет:</span>
|
||||
<span class="timeline-value">${safeDate(fa.scheduled_out)}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Фактический вылет:</span>
|
||||
<span class="timeline-value">${safeDate(fa.actual_out)}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Взлёт:</span>
|
||||
<span class="timeline-value">${safeDate(fa.actual_off)} ${fa.actual_runway_off ? `(ВПП ${fa.actual_runway_off})` : ''}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Посадка:</span>
|
||||
<span class="timeline-value">${safeDate(fa.actual_on)} ${fa.actual_runway_on ? `(ВПП ${fa.actual_runway_on})` : ''}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Фактический прилёт:</span>
|
||||
<span class="timeline-value">${safeDate(fa.actual_in)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-info">
|
||||
<div class="status-item">
|
||||
<span class="status-label">Статус:</span>
|
||||
<span class="status-value">${safeStr(fa.status || '—')}</span>
|
||||
</div>
|
||||
${fa.departure_delay !== null && fa.departure_delay !== undefined ? `
|
||||
<div class="status-item">
|
||||
<span class="status-label">Задержка вылета:</span>
|
||||
<span class="status-value ${fa.departure_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.departure_delay > 0 ? '+' : ''}${Math.floor(fa.departure_delay / 60)} мин</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${fa.arrival_delay !== null && fa.arrival_delay !== undefined ? `
|
||||
<div class="status-item">
|
||||
<span class="status-label">Задержка прилёта:</span>
|
||||
<span class="status-value ${fa.arrival_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.arrival_delay > 0 ? '+' : ''}${Math.floor(fa.arrival_delay / 60)} мин</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${fa.gate_origin ? `
|
||||
<div class="status-item">
|
||||
<span class="status-label">Гейт вылета:</span>
|
||||
<span class="status-value">${fa.gate_origin}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${fa.gate_destination ? `
|
||||
<div class="status-item">
|
||||
<span class="status-label">Гейт прилёта:</span>
|
||||
<span class="status-value">${fa.gate_destination}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${fa.baggage_claim ? `
|
||||
<div class="status-item">
|
||||
<span class="status-label">Выдача багажа:</span>
|
||||
<span class="status-value">${fa.baggage_claim}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
html += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightaware">FlightAware</span>
|
||||
<span class="source-missing">Данные не получены</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Данные из FlightRadar24
|
||||
if (fr24) {
|
||||
html += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightradar24">FlightRadar24</span>
|
||||
</div>
|
||||
<div class="source-content">
|
||||
<div class="route-info">
|
||||
<div class="route-item">
|
||||
<span class="route-label">Откуда:</span>
|
||||
<span class="route-value">${safeStr(fr24.orig_iata || '—')} (${safeStr(fr24.orig_icao || '—')})</span>
|
||||
</div>
|
||||
<div class="route-item">
|
||||
<span class="route-label">Куда:</span>
|
||||
<span class="route-value">${safeStr(fr24.dest_iata || '—')} (${safeStr(fr24.dest_icao || '—')})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline">
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Взлёт:</span>
|
||||
<span class="timeline-value">${safeDate(fr24.datetime_takeoff)} ${fr24.runway_takeoff ? `(ВПП ${fr24.runway_takeoff})` : ''}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Посадка:</span>
|
||||
<span class="timeline-value">${safeDate(fr24.datetime_landed)} ${fr24.runway_landed ? `(ВПП ${fr24.runway_landed})` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-info">
|
||||
<div class="status-item">
|
||||
<span class="status-label">Время полёта:</span>
|
||||
<span class="status-value">${formatDuration(fr24.flight_time)}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Фактическое расстояние:</span>
|
||||
<span class="status-value">${formatDistance(fr24.actual_distance)}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Кратчайшее расстояние:</span>
|
||||
<span class="status-value">${formatDistance(fr24.circle_distance)}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Статус полёта:</span>
|
||||
<span class="status-value">${fr24.flight_ended ? 'Завершён' : 'В процессе'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
html += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightradar24">FlightRadar24</span>
|
||||
<span class="source-missing">Данные не получены</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
return html;
|
||||
};
|
||||
|
||||
// ==== ГЕНЕРАЦИЯ ПОЛНОГО HTML ДОКУМЕНТА ====
|
||||
const generateFullHTML = (flights) => {
|
||||
const now = new Date();
|
||||
const reportDate = now.toLocaleString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
let flightsHTML = '';
|
||||
if (flights.length === 0) {
|
||||
flightsHTML = '<div class="no-data">Данные о рейсах не найдены</div>';
|
||||
} else {
|
||||
flightsHTML = flights.map(flight => generateFlightCard(flight)).join('');
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Отчёт о рейсах</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
border-bottom: 3px solid #2563eb;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #1e40af;
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sources-info {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-top: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.source-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.source-tag.available {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.source-tag.unavailable {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.flight-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 25px;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.flight-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.flight-header h2 {
|
||||
font-size: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.registration {
|
||||
background: rgba(255,255,255,0.2);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.flight-info {
|
||||
padding: 15px 20px;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-row .label {
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
width: 150px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-row .value {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.source-section {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.source-section:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.source-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.source-badge.source-flightaware {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.source-badge.source-flightradar24 {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.source-missing {
|
||||
color: #ef4444;
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.source-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.route-info {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: #f9fafb;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.route-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.route-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.route-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.timeline-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.timeline-label {
|
||||
font-weight: 500;
|
||||
color: #4b5563;
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.timeline-value {
|
||||
color: #111827;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
padding: 15px;
|
||||
background: #f9fafb;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.delay-negative {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.delay-positive {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #6b7280;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
background: white;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
box-shadow: none;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.flight-card {
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Отчёт о рейсах</h1>
|
||||
<div class="header-meta">
|
||||
<div>Дата формирования: ${reportDate}</div>
|
||||
<div class="sources-info">
|
||||
<span class="source-tag ${flightAwareData.length > 0 ? 'available' : 'unavailable'}">
|
||||
FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
|
||||
</span>
|
||||
<span class="source-tag ${flightRadar24Data.length > 0 ? 'available' : 'unavailable'}">
|
||||
FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flights-container">
|
||||
${flightsHTML}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
|
||||
// ==== ФОРМИРОВАНИЕ РЕЗУЛЬТАТА ====
|
||||
const html = generateFullHTML(mergedFlights);
|
||||
|
||||
// ==== ПОДГОТОВКА ДАННЫХ ДЛЯ КОНВЕРТАЦИИ В BASE64 PDF ====
|
||||
// Эти данные будут использованы в следующей HTTP Request ноде
|
||||
// для конвертации HTML в PDF и получения base64
|
||||
|
||||
// Настройки сервиса конвертации (замените на ваши)
|
||||
const PDF_SERVICE_URL = 'https://api.htmlpdfapi.com/v1/pdf'; // Или другой сервис
|
||||
const PDF_API_KEY = 'YOUR_API_KEY'; // ⚠️ ЗАМЕНИТЕ на ваш API ключ
|
||||
|
||||
// Подготовка запроса для HTTP Request ноды
|
||||
const pdfRequestData = {
|
||||
method: 'POST',
|
||||
url: PDF_SERVICE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${PDF_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
html: html,
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '15mm',
|
||||
bottom: '20mm',
|
||||
left: '15mm'
|
||||
}
|
||||
},
|
||||
base64: true // Запрашиваем base64 напрямую
|
||||
})
|
||||
};
|
||||
|
||||
return [{
|
||||
json: {
|
||||
html: html,
|
||||
flights: mergedFlights,
|
||||
flights_count: mergedFlights.length,
|
||||
sources: {
|
||||
flightaware: {
|
||||
available: flightAwareData.length > 0,
|
||||
count: flightAwareData.length
|
||||
},
|
||||
flightradar24: {
|
||||
available: flightRadar24Data.length > 0,
|
||||
count: flightRadar24Data.length
|
||||
}
|
||||
},
|
||||
generated_at: new Date().toISOString(),
|
||||
|
||||
// Данные для конвертации в PDF (используйте в следующей HTTP Request ноде)
|
||||
pdf_request: pdfRequestData,
|
||||
pdf_request_method: pdfRequestData.method,
|
||||
pdf_request_url: pdfRequestData.url,
|
||||
pdf_request_headers: pdfRequestData.headers,
|
||||
pdf_request_body: pdfRequestData.body
|
||||
}
|
||||
}];
|
||||
51
docs/N8N_CODE_PROFILE_CONTACT_RESPONSE.js
Normal file
51
docs/N8N_CODE_PROFILE_CONTACT_RESPONSE.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// ========================================
|
||||
// Code Node: Формирование JSON для ответа N8N_CONTACT_WEBHOOK (профиль)
|
||||
// Данные берутся из ноды select_user1 (SQL/запрос контакта).
|
||||
// Выход этой ноды подаётся в "Respond to Webhook" как Response Body.
|
||||
// ========================================
|
||||
//
|
||||
// Вход из ноды select_user1 (массив строк или один item на строку):
|
||||
// contactid, firstname, lastname, email, mobile, phone, birthday, mailingstreet,
|
||||
// middle_name, birthplace, inn, verification, bank
|
||||
//
|
||||
// Выход для вебхука: { "items": [ { ...поля в snake_case... } ] } или { "items": [] }
|
||||
// ========================================
|
||||
|
||||
// Данные из ноды select_user1
|
||||
const rawItems = $('select_user1').all();
|
||||
let rows = [];
|
||||
if (rawItems.length === 1 && Array.isArray(rawItems[0].json)) {
|
||||
rows = rawItems[0].json;
|
||||
} else if (rawItems.length === 1 && Array.isArray(rawItems[0].json?.items)) {
|
||||
rows = rawItems[0].json.items;
|
||||
} else if (rawItems.length === 1 && rawItems[0].json && !Array.isArray(rawItems[0].json)) {
|
||||
rows = [rawItems[0].json];
|
||||
} else {
|
||||
rows = rawItems.map(i => i.json).filter(Boolean);
|
||||
}
|
||||
|
||||
function mapRow(r) {
|
||||
const v = (key) => {
|
||||
const x = r[key];
|
||||
return x !== undefined && x !== null && String(x).trim() !== '' ? String(x).trim() : '';
|
||||
};
|
||||
return {
|
||||
contact_id: r.contactid ?? r.contact_id ?? '',
|
||||
last_name: v('lastname') || v('last_name'),
|
||||
first_name: v('firstname') || v('first_name'),
|
||||
middle_name: v('middle_name') || v('middleName'),
|
||||
birth_date: v('birthday') || v('birth_date') || v('birthDate'),
|
||||
birth_place: v('birthplace') || v('birth_place') || v('birthPlace'),
|
||||
inn: v('inn'),
|
||||
email: v('email'),
|
||||
registration_address: v('mailingstreet') || v('registration_address') || v('address'),
|
||||
mailing_address: v('mailing_address') || v('postal_address'),
|
||||
bank_for_compensation: v('bank') || v('bank_for_compensation'),
|
||||
phone: v('mobile') || v('phone') || v('mobile_phone'),
|
||||
};
|
||||
}
|
||||
|
||||
const items = rows.map(mapRow);
|
||||
|
||||
// Один выходной item с телом ответа для Respond to Webhook
|
||||
return [{ json: { items } }];
|
||||
110
docs/N8N_EXTRACT_BASE64_FROM_RESPONSE.js
Normal file
110
docs/N8N_EXTRACT_BASE64_FROM_RESPONSE.js
Normal file
@@ -0,0 +1,110 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: Извлечение Base64 PDF из ответа HTTP Request
|
||||
// ============================================================================
|
||||
// Используйте этот код ПОСЛЕ HTTP Request ноды, которая конвертировала HTML в PDF
|
||||
// ============================================================================
|
||||
|
||||
const response = $input.first();
|
||||
|
||||
if (!response) {
|
||||
throw new Error('Ответ от HTTP Request не получен');
|
||||
}
|
||||
|
||||
let base64 = null;
|
||||
let pdfSize = 0;
|
||||
|
||||
// ==== ВАРИАНТ 1: Сервис вернул base64 в JSON ====
|
||||
if (response.json) {
|
||||
// htmlpdfapi.com возвращает: { pdf: "base64..." }
|
||||
if (response.json.pdf) {
|
||||
base64 = response.json.pdf;
|
||||
pdfSize = Math.floor(base64.length * 0.75); // Примерный размер
|
||||
}
|
||||
// api2pdf.com возвращает: { Pdf: "base64..." }
|
||||
else if (response.json.Pdf) {
|
||||
base64 = response.json.Pdf;
|
||||
pdfSize = Math.floor(base64.length * 0.75);
|
||||
}
|
||||
// pdfshift.io возвращает: { pdf: "base64..." }
|
||||
else if (response.json.pdf) {
|
||||
base64 = response.json.pdf;
|
||||
pdfSize = Math.floor(base64.length * 0.75);
|
||||
}
|
||||
// Если base64 в другом поле
|
||||
else if (response.json.base64) {
|
||||
base64 = response.json.base64;
|
||||
pdfSize = Math.floor(base64.length * 0.75);
|
||||
}
|
||||
// Если base64 в body
|
||||
else if (response.json.body && typeof response.json.body === 'string') {
|
||||
base64 = response.json.body;
|
||||
pdfSize = Math.floor(base64.length * 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
// ==== ВАРИАНТ 2: Сервис вернул binary PDF ====
|
||||
if (!base64 && response.binary && response.binary.data) {
|
||||
const pdfBinary = response.binary.data;
|
||||
|
||||
// Конвертируем binary в base64
|
||||
if (Buffer.isBuffer(pdfBinary)) {
|
||||
base64 = pdfBinary.toString('base64');
|
||||
pdfSize = pdfBinary.length;
|
||||
} else if (typeof pdfBinary === 'string') {
|
||||
// Если уже base64 строка
|
||||
base64 = pdfBinary;
|
||||
pdfSize = Buffer.from(base64, 'base64').length;
|
||||
} else {
|
||||
// Пытаемся преобразовать
|
||||
const buffer = Buffer.from(pdfBinary);
|
||||
base64 = buffer.toString('base64');
|
||||
pdfSize = buffer.length;
|
||||
}
|
||||
}
|
||||
|
||||
// ==== ВАРИАНТ 3: PDF в текстовом формате (base64 строка) ====
|
||||
if (!base64 && response.json && typeof response.json === 'string') {
|
||||
base64 = response.json;
|
||||
pdfSize = Buffer.from(base64, 'base64').length;
|
||||
}
|
||||
|
||||
// ==== ПРОВЕРКА РЕЗУЛЬТАТА ====
|
||||
if (!base64) {
|
||||
console.error('❌ Не удалось извлечь base64. Структура ответа:', Object.keys(response));
|
||||
throw new Error('Не удалось извлечь base64 PDF из ответа. Проверьте формат ответа сервиса.');
|
||||
}
|
||||
|
||||
// Проверяем, что это действительно base64
|
||||
if (!/^[A-Za-z0-9+/=]+$/.test(base64)) {
|
||||
throw new Error('Извлечённые данные не являются валидным base64');
|
||||
}
|
||||
|
||||
const pdfSizeMB = (pdfSize / (1024 * 1024)).toFixed(2);
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
const filename = `flights-report-${timestamp}.pdf`;
|
||||
|
||||
console.log('✅ Base64 PDF извлечён успешно');
|
||||
console.log('📊 Размер PDF:', pdfSizeMB, 'MB');
|
||||
|
||||
// ==== ВОЗВРАТ РЕЗУЛЬТАТА ====
|
||||
return [{
|
||||
json: {
|
||||
pdf_base64: base64,
|
||||
pdf_size_bytes: pdfSize,
|
||||
pdf_size_mb: pdfSizeMB,
|
||||
filename: filename,
|
||||
success: true,
|
||||
generated_at: new Date().toISOString()
|
||||
}
|
||||
}];
|
||||
|
||||
// ============================================================================
|
||||
// ИСПОЛЬЗОВАНИЕ РЕЗУЛЬТАТА:
|
||||
// ============================================================================
|
||||
// Теперь у вас есть base64 PDF в поле pdf_base64
|
||||
// Вы можете:
|
||||
// 1. Сохранить в файл
|
||||
// 2. Отправить по email
|
||||
// 3. Загрузить в S3/Nextcloud
|
||||
// 4. Вернуть в API response
|
||||
// ============================================================================
|
||||
99
docs/N8N_FLIGHTS_BROWSERLESS_COMPLETE.js
Normal file
99
docs/N8N_FLIGHTS_BROWSERLESS_COMPLETE.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: HTML → PDF через Browserless (полная версия)
|
||||
// ============================================================================
|
||||
// Используйте этот код ПОСЛЕ ноды, которая вернула HTML или html_base64
|
||||
// ============================================================================
|
||||
|
||||
// Получаем HTML из предыдущей ноды
|
||||
let html = null;
|
||||
|
||||
if ($json.html) {
|
||||
html = $json.html;
|
||||
} else if ($json.html_base64) {
|
||||
html = Buffer.from($json.html_base64, 'base64').toString('utf8');
|
||||
} else if ($json.body?.html) {
|
||||
html = $json.body.html;
|
||||
} else if ($binary && $binary.data) {
|
||||
html = $binary.data.toString('utf8');
|
||||
} else {
|
||||
throw new Error('HTML не найден. Проверьте, что предыдущая нода вернула html или html_base64');
|
||||
}
|
||||
|
||||
console.log('📄 HTML получен, длина:', html.length);
|
||||
|
||||
// ================== НАСТРОЙКИ BROWSERLESS ==================
|
||||
const BROWSERLESS_URL = 'http://147.45.146.17:3000';
|
||||
// ⚠️ ВАЖНО: Если Browserless требует токен, замените на ваш токен
|
||||
// Если токен не требуется, оставьте пустую строку или удалите Authorization header
|
||||
const BROWSERLESS_TOKEN = ''; // Замените на ваш токен, если требуется
|
||||
|
||||
// Конвертируем HTML в data URL для передачи в Browserless
|
||||
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
|
||||
const dataUrl = `data:text/html;base64,${htmlBase64}`;
|
||||
|
||||
// Формируем headers
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
// Добавляем токен, если он указан
|
||||
if (BROWSERLESS_TOKEN) {
|
||||
headers['Authorization'] = `Bearer ${BROWSERLESS_TOKEN}`;
|
||||
}
|
||||
|
||||
// ================== ПОДГОТОВКА ЗАПРОСА ==================
|
||||
return [{
|
||||
json: {
|
||||
// Данные для HTTP Request ноды
|
||||
method: 'POST',
|
||||
url: `${BROWSERLESS_URL}/pdf`,
|
||||
headers: headers,
|
||||
|
||||
// Тело запроса - передаём HTML через data URL
|
||||
body: JSON.stringify({
|
||||
url: dataUrl,
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '15mm',
|
||||
bottom: '20mm',
|
||||
left: '15mm'
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Метаданные для отладки
|
||||
html_length: html.length,
|
||||
data_url_length: dataUrl.length,
|
||||
browserless_url: BROWSERLESS_URL
|
||||
}
|
||||
}];
|
||||
|
||||
// ============================================================================
|
||||
// ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ:
|
||||
// ============================================================================
|
||||
// 1. Замените BROWSERLESS_TOKEN на ваш токен (если требуется)
|
||||
// 2. Добавьте HTTP Request ноду после этого Code Node
|
||||
// 3. В HTTP Request ноде настройте:
|
||||
// - Method: {{ $json.method }}
|
||||
// - URL: {{ $json.url }}
|
||||
// - Headers: {{ $json.headers }}
|
||||
// - Body: {{ $json.body }}
|
||||
// - Response Format: Binary (Browserless возвращает PDF как binary)
|
||||
// 4. После HTTP Request добавьте Code Node для конвертации binary в base64:
|
||||
//
|
||||
// const pdfBinary = $binary.data;
|
||||
// const base64 = Buffer.isBuffer(pdfBinary)
|
||||
// ? pdfBinary.toString('base64')
|
||||
// : Buffer.from(pdfBinary).toString('base64');
|
||||
//
|
||||
// return [{
|
||||
// json: {
|
||||
// pdf_base64: base64,
|
||||
// pdf_size_bytes: Buffer.from(base64, 'base64').length,
|
||||
// success: true
|
||||
// }
|
||||
// }];
|
||||
// ============================================================================
|
||||
99
docs/N8N_FLIGHTS_BROWSERLESS_PDF.js
Normal file
99
docs/N8N_FLIGHTS_BROWSERLESS_PDF.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: HTML → PDF через Browserless
|
||||
// ============================================================================
|
||||
// Используйте этот код ПОСЛЕ ноды, которая вернула HTML или html_base64
|
||||
// Подготавливает запрос для HTTP Request ноды к Browserless
|
||||
// ============================================================================
|
||||
|
||||
// Получаем HTML из предыдущей ноды
|
||||
let html = null;
|
||||
|
||||
// Вариант 1: HTML уже есть в json.html
|
||||
if ($json.html) {
|
||||
html = $json.html;
|
||||
}
|
||||
// Вариант 2: HTML в base64
|
||||
else if ($json.html_base64) {
|
||||
html = Buffer.from($json.html_base64, 'base64').toString('utf8');
|
||||
}
|
||||
// Вариант 3: HTML в другом поле
|
||||
else if ($json.body?.html) {
|
||||
html = $json.body.html;
|
||||
}
|
||||
// Вариант 4: Пытаемся получить из binary
|
||||
else if ($binary && $binary.data) {
|
||||
html = $binary.data.toString('utf8');
|
||||
}
|
||||
else {
|
||||
throw new Error('HTML не найден. Проверьте, что предыдущая нода вернула html или html_base64');
|
||||
}
|
||||
|
||||
console.log('📄 HTML получен, длина:', html.length);
|
||||
|
||||
// ================== НАСТРОЙКИ BROWSERLESS ==================
|
||||
const BROWSERLESS_URL = 'http://147.45.146.17:3000';
|
||||
const BROWSERLESS_TOKEN = 'YOUR_TOKEN'; // ⚠️ ЗАМЕНИТЕ на ваш токен Browserless
|
||||
|
||||
// ================== ВАРИАНТ 1: Использование data URL ==================
|
||||
// Browserless может принимать HTML через data URL
|
||||
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
|
||||
const dataUrl = `data:text/html;base64,${htmlBase64}`;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
// Данные для HTTP Request ноды
|
||||
method: 'POST',
|
||||
url: `${BROWSERLESS_URL}/pdf`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${BROWSERLESS_TOKEN}` // Если требуется токен
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: dataUrl, // Передаём HTML через data URL
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '15mm',
|
||||
bottom: '20mm',
|
||||
left: '15mm'
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Альтернативный вариант (если Browserless поддерживает прямой HTML)
|
||||
body_alternative: JSON.stringify({
|
||||
html: html, // Прямая передача HTML (если поддерживается)
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '15mm',
|
||||
bottom: '20mm',
|
||||
left: '15mm'
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Метаданные
|
||||
html_length: html.length,
|
||||
data_url_length: dataUrl.length
|
||||
}
|
||||
}];
|
||||
|
||||
// ============================================================================
|
||||
// ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ:
|
||||
// ============================================================================
|
||||
// 1. Замените YOUR_TOKEN на ваш реальный токен Browserless (если требуется)
|
||||
// 2. Добавьте HTTP Request ноду после этого Code Node
|
||||
// 3. В HTTP Request ноде настройте:
|
||||
// - Method: {{ $json.method }}
|
||||
// - URL: {{ $json.url }}
|
||||
// - Headers: {{ $json.headers }}
|
||||
// - Body: {{ $json.body }}
|
||||
// - Response Format: Binary (или JSON, если Browserless возвращает base64)
|
||||
// 4. После HTTP Request добавьте Code Node для извлечения base64 из ответа
|
||||
// (используйте N8N_EXTRACT_BASE64_FROM_RESPONSE.js)
|
||||
// ============================================================================
|
||||
124
docs/N8N_FLIGHTS_BROWSERLESS_PDF_V2.js
Normal file
124
docs/N8N_FLIGHTS_BROWSERLESS_PDF_V2.js
Normal file
@@ -0,0 +1,124 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: HTML → PDF через Browserless (вариант с прямым HTML)
|
||||
// ============================================================================
|
||||
// Альтернативный вариант - передача HTML напрямую в body
|
||||
// ============================================================================
|
||||
|
||||
// Получаем HTML из предыдущей ноды
|
||||
let html = null;
|
||||
|
||||
if ($json.html) {
|
||||
html = $json.html;
|
||||
} else if ($json.html_base64) {
|
||||
html = Buffer.from($json.html_base64, 'base64').toString('utf8');
|
||||
} else if ($json.body?.html) {
|
||||
html = $json.body.html;
|
||||
} else if ($binary && $binary.data) {
|
||||
html = $binary.data.toString('utf8');
|
||||
} else {
|
||||
throw new Error('HTML не найден');
|
||||
}
|
||||
|
||||
console.log('📄 HTML получен, длина:', html.length);
|
||||
|
||||
// ================== НАСТРОЙКИ ==================
|
||||
const BROWSERLESS_URL = 'http://147.45.146.17:3000';
|
||||
const BROWSERLESS_TOKEN = 'YOUR_TOKEN'; // ⚠️ ЗАМЕНИТЕ на ваш токен
|
||||
|
||||
// ================== ВАРИАНТ: Использование /screenshot или /pdf ==================
|
||||
// Browserless может иметь разные эндпоинты
|
||||
|
||||
// Вариант A: POST /pdf с HTML в body
|
||||
const requestA = {
|
||||
method: 'POST',
|
||||
url: `${BROWSERLESS_URL}/pdf`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${BROWSERLESS_TOKEN}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
html: html,
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Вариант B: POST /pdf с data URL
|
||||
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
|
||||
const dataUrl = `data:text/html;base64,${htmlBase64}`;
|
||||
|
||||
const requestB = {
|
||||
method: 'POST',
|
||||
url: `${BROWSERLESS_URL}/pdf`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${BROWSERLESS_TOKEN}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: dataUrl,
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// Вариант C: POST /screenshot (если /pdf не работает)
|
||||
const requestC = {
|
||||
method: 'POST',
|
||||
url: `${BROWSERLESS_URL}/screenshot`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${BROWSERLESS_TOKEN}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: dataUrl,
|
||||
options: {
|
||||
type: 'pdf',
|
||||
format: 'A4',
|
||||
printBackground: true
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
return [{
|
||||
json: {
|
||||
// Используйте один из вариантов ниже
|
||||
// Попробуйте сначала вариант A, если не работает - B, затем C
|
||||
|
||||
// === ВАРИАНТ A: Прямой HTML ===
|
||||
method_a: requestA.method,
|
||||
url_a: requestA.url,
|
||||
headers_a: requestA.headers,
|
||||
body_a: requestA.body,
|
||||
|
||||
// === ВАРИАНТ B: Data URL ===
|
||||
method_b: requestB.method,
|
||||
url_b: requestB.url,
|
||||
headers_b: requestB.headers,
|
||||
body_b: requestB.body,
|
||||
|
||||
// === ВАРИАНТ C: Screenshot (PDF) ===
|
||||
method_c: requestC.method,
|
||||
url_c: requestC.url,
|
||||
headers_c: requestC.headers,
|
||||
body_c: requestC.body,
|
||||
|
||||
// Метаданные
|
||||
html_length: html.length,
|
||||
instruction: 'Попробуйте сначала вариант A в HTTP Request ноде'
|
||||
}
|
||||
}];
|
||||
|
||||
// ============================================================================
|
||||
// ОТЛАДКА:
|
||||
// ============================================================================
|
||||
// Если получаете ошибку аутентификации:
|
||||
// 1. Проверьте, нужен ли токен для вашего Browserless
|
||||
// 2. Если токен не требуется, уберите строку Authorization из headers
|
||||
// 3. Проверьте документацию Browserless: https://docs.browserless.io
|
||||
// ============================================================================
|
||||
112
docs/N8N_FLIGHTS_COMPLETE_WORKFLOW.md
Normal file
112
docs/N8N_FLIGHTS_COMPLETE_WORKFLOW.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Полный Workflow: HTML → Base64 PDF
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
[HTTP Request: FlightAware]
|
||||
↓
|
||||
[HTTP Request: FlightRadar24]
|
||||
↓
|
||||
[Code: Process Flights Data] ← Генерирует HTML + подготавливает запрос для PDF
|
||||
↓
|
||||
[HTTP Request: Convert to PDF] ← Конвертирует HTML в base64 PDF
|
||||
↓
|
||||
[Code: Extract Base64 PDF] ← Извлекает base64 из ответа
|
||||
↓
|
||||
[Использование base64 PDF]
|
||||
```
|
||||
|
||||
## Настройка нод
|
||||
|
||||
### 1. Code: Process Flights Data
|
||||
|
||||
**Код:** Используйте обновлённый `N8N_CODE_PROCESS_FLIGHTS_DATA.js`
|
||||
|
||||
**Выходные данные:**
|
||||
```json
|
||||
{
|
||||
"html": "<!DOCTYPE html>...",
|
||||
"flights": [...],
|
||||
"pdf_request_method": "POST",
|
||||
"pdf_request_url": "https://api.htmlpdfapi.com/v1/pdf",
|
||||
"pdf_request_headers": {...},
|
||||
"pdf_request_body": "{...}"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. HTTP Request: Convert to PDF
|
||||
|
||||
**Название:** `HTTP Request: Convert to PDF`
|
||||
|
||||
**Настройка:**
|
||||
- **Method:** `{{ $json.pdf_request_method }}`
|
||||
- **URL:** `{{ $json.pdf_request_url }}`
|
||||
- **Authentication:** None (или по необходимости)
|
||||
- **Headers:**
|
||||
```json
|
||||
{{ $json.pdf_request_headers }}
|
||||
```
|
||||
- **Body:**
|
||||
```json
|
||||
{{ $json.pdf_request_body }}
|
||||
```
|
||||
- **Response Format:** `JSON`
|
||||
|
||||
### 3. Code: Extract Base64 PDF
|
||||
|
||||
**Название:** `Code: Extract Base64 PDF`
|
||||
|
||||
**Код:** Используйте `N8N_EXTRACT_BASE64_FROM_RESPONSE.js`
|
||||
|
||||
**Выходные данные:**
|
||||
```json
|
||||
{
|
||||
"pdf_base64": "JVBERi0xLjQKJeLjz9MK...",
|
||||
"pdf_size_mb": "0.12",
|
||||
"filename": "flights-report-2026-01-16.pdf",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
## Альтернатива: Использование Convert to File
|
||||
|
||||
Если вы хотите использовать ноду **Convert to File** для создания HTML файла, а затем конвертировать его в PDF:
|
||||
|
||||
### Вариант A: HTML файл → PDF через сервис
|
||||
|
||||
```
|
||||
[Code: Process Flights Data]
|
||||
↓
|
||||
[Convert to File] ← Operation: "html", Put Output File in Field: {{ $json.html }}
|
||||
↓
|
||||
[HTTP Request: Convert to PDF] ← Отправьте binary HTML файл в сервис конвертации
|
||||
↓
|
||||
[Code: Extract Base64 PDF]
|
||||
```
|
||||
|
||||
### Вариант B: Прямая конвертация HTML → Base64 PDF
|
||||
|
||||
Пропустите ноду Convert to File и используйте HTML напрямую:
|
||||
|
||||
```
|
||||
[Code: Process Flights Data]
|
||||
↓
|
||||
[HTTP Request: Convert to PDF] ← Используйте {{ $json.html }} в body
|
||||
↓
|
||||
[Code: Extract Base64 PDF]
|
||||
```
|
||||
|
||||
## Настройка API ключа
|
||||
|
||||
В файле `N8N_CODE_PROCESS_FLIGHTS_DATA.js` найдите строку:
|
||||
```javascript
|
||||
const PDF_API_KEY = 'YOUR_API_KEY';
|
||||
```
|
||||
|
||||
Замените `YOUR_API_KEY` на ваш реальный API ключ от сервиса конвертации.
|
||||
|
||||
## Популярные сервисы
|
||||
|
||||
1. **htmlpdfapi.com** - 100 PDF/месяц бесплатно
|
||||
2. **pdfshift.io** - 100 PDF/месяц бесплатно
|
||||
3. **api2pdf.com** - 50 PDF/месяц бесплатно
|
||||
132
docs/N8N_FLIGHTS_HTML_TO_PDF_BROWSER.js
Normal file
132
docs/N8N_FLIGHTS_HTML_TO_PDF_BROWSER.js
Normal file
@@ -0,0 +1,132 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: HTML → PDF через браузер (Puppeteer/Playwright)
|
||||
// ============================================================================
|
||||
// Используйте этот код ПОСЛЕ ноды, которая вернула HTML или html_base64
|
||||
// Подготавливает команду для Execute Command ноды с puppeteer
|
||||
// ============================================================================
|
||||
|
||||
// Получаем HTML из предыдущей ноды
|
||||
let html = null;
|
||||
|
||||
// Вариант 1: HTML уже есть в json.html
|
||||
if ($json.html) {
|
||||
html = $json.html;
|
||||
}
|
||||
// Вариант 2: HTML в base64
|
||||
else if ($json.html_base64) {
|
||||
html = Buffer.from($json.html_base64, 'base64').toString('utf8');
|
||||
}
|
||||
// Вариант 3: HTML в другом поле
|
||||
else if ($json.body?.html) {
|
||||
html = $json.body.html;
|
||||
}
|
||||
// Вариант 4: Пытаемся получить из binary
|
||||
else if ($binary && $binary.data) {
|
||||
html = $binary.data.toString('utf8');
|
||||
}
|
||||
else {
|
||||
throw new Error('HTML не найден. Проверьте, что предыдущая нода вернула html или html_base64');
|
||||
}
|
||||
|
||||
console.log('📄 HTML получен, длина:', html.length);
|
||||
|
||||
// ================== ВАРИАНТ 1: Execute Command с Puppeteer ==================
|
||||
// Требует: npm install puppeteer в контейнере n8n
|
||||
// Команда для Execute Command ноды:
|
||||
|
||||
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
|
||||
const timestamp = Date.now();
|
||||
const htmlFile = `/tmp/flights-${timestamp}.html`;
|
||||
const pdfFile = `/tmp/flights-${timestamp}.pdf`;
|
||||
|
||||
// Команда для Execute Command ноды:
|
||||
const command = `node -e "
|
||||
const puppeteer = require('puppeteer');
|
||||
const fs = require('fs');
|
||||
const html = Buffer.from('${htmlBase64}', 'base64').toString('utf8');
|
||||
(async () => {
|
||||
const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] });
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(html, { waitUntil: 'networkidle0' });
|
||||
await page.pdf({
|
||||
path: '${pdfFile}',
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
|
||||
});
|
||||
await browser.close();
|
||||
const pdfBuffer = fs.readFileSync('${pdfFile}');
|
||||
const base64 = pdfBuffer.toString('base64');
|
||||
console.log(base64);
|
||||
fs.unlinkSync('${pdfFile}');
|
||||
})();
|
||||
"`;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
// Команда для Execute Command ноды
|
||||
command: command,
|
||||
|
||||
// Или используйте этот вариант (проще):
|
||||
html_file: htmlFile,
|
||||
pdf_file: pdfFile,
|
||||
html_base64: htmlBase64,
|
||||
|
||||
// Инструкция
|
||||
instruction: 'Используйте Execute Command ноду с одной из команд ниже'
|
||||
}
|
||||
}];
|
||||
|
||||
// ================== ВАРИАНТ 2: HTTP Request к сервису с браузером ==================
|
||||
// Раскомментируйте, если используете внешний сервис (Gotenberg, Browserless, etc.)
|
||||
|
||||
/*
|
||||
const PDF_SERVICE_URL = 'https://api.gotenberg.dev/forms/chromium/convert/html';
|
||||
// Или Browserless: 'https://chrome.browserless.io/pdf'
|
||||
|
||||
return [{
|
||||
json: {
|
||||
method: 'POST',
|
||||
url: PDF_SERVICE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
body: {
|
||||
files: [{
|
||||
name: 'index.html',
|
||||
content: html
|
||||
}],
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '15mm',
|
||||
bottom: '20mm',
|
||||
left: '15mm'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}];
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ:
|
||||
// ============================================================================
|
||||
// ВАРИАНТ 1: Execute Command (если puppeteer установлен)
|
||||
// 1. Установите puppeteer в контейнере n8n:
|
||||
// docker exec -it <n8n_container> npm install puppeteer
|
||||
// 2. Добавьте Execute Command ноду после этого Code Node
|
||||
// 3. В команде используйте: {{ $json.command }}
|
||||
// 4. После Execute Command добавьте Code Node для извлечения base64 из вывода
|
||||
//
|
||||
// ВАРИАНТ 2: HTTP Request к Gotenberg (self-hosted браузер)
|
||||
// 1. Запустите Gotenberg: docker run -p 3000:3000 gotenberg/gotenberg:7
|
||||
// 2. Используйте код выше (раскомментируйте)
|
||||
// 3. Добавьте HTTP Request ноду
|
||||
//
|
||||
// ВАРИАНТ 3: HTTP Request к Browserless (cloud сервис)
|
||||
// 1. Зарегистрируйтесь на browserless.io
|
||||
// 2. Используйте их API для конвертации
|
||||
// ============================================================================
|
||||
96
docs/N8N_FLIGHTS_HTML_TO_PDF_EXAMPLE.js
Normal file
96
docs/N8N_FLIGHTS_HTML_TO_PDF_EXAMPLE.js
Normal file
@@ -0,0 +1,96 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: Конвертация HTML в Base64 PDF
|
||||
// ============================================================================
|
||||
// Используйте этот код после "Code: Process Flights Data"
|
||||
// для подготовки данных для конвертации в PDF и получения base64
|
||||
// ============================================================================
|
||||
|
||||
// Получаем HTML из предыдущей ноды
|
||||
const processedData = $('Code: Process Flights Data').first().json;
|
||||
|
||||
if (!processedData || !processedData.html) {
|
||||
throw new Error('HTML не получен из предыдущей ноды');
|
||||
}
|
||||
|
||||
const html = processedData.html;
|
||||
|
||||
// ==== ВАРИАНТ 1: HTTP Request к сервису, который возвращает base64 PDF ====
|
||||
// Используйте этот вариант с HTTP Request нодой после этого Code Node
|
||||
// Сервисы, которые поддерживают base64:
|
||||
// - htmlpdfapi.com
|
||||
// - pdfshift.io
|
||||
// - api2pdf.com
|
||||
// - и другие
|
||||
|
||||
return [{
|
||||
json: {
|
||||
method: 'POST',
|
||||
url: 'https://api.htmlpdfapi.com/v1/pdf', // Замените на ваш сервис
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer YOUR_API_KEY' // Замените на ваш API ключ
|
||||
},
|
||||
body: JSON.stringify({
|
||||
html: html,
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '15mm',
|
||||
bottom: '20mm',
|
||||
left: '15mm'
|
||||
}
|
||||
},
|
||||
// Если сервис поддерживает прямое возвращение base64
|
||||
base64: true
|
||||
})
|
||||
}
|
||||
}];
|
||||
|
||||
// ==== ВАРИАНТ 2: Если сервис возвращает binary, конвертируем в base64 ====
|
||||
// Используйте этот код в Code Node ПОСЛЕ HTTP Request ноды
|
||||
// (когда получили PDF в binary формате)
|
||||
/*
|
||||
const pdfBinary = $binary.data; // Получаем binary данные из HTTP Request
|
||||
|
||||
// Конвертируем binary в base64
|
||||
const base64 = pdfBinary.toString('base64');
|
||||
|
||||
return [{
|
||||
json: {
|
||||
pdf_base64: base64,
|
||||
pdf_size_bytes: pdfBinary.length,
|
||||
pdf_size_mb: (pdfBinary.length / (1024 * 1024)).toFixed(2),
|
||||
flights_count: processedData.flights_count,
|
||||
generated_at: processedData.generated_at,
|
||||
filename: `flights-report-${new Date().toISOString().split('T')[0]}.pdf`
|
||||
}
|
||||
}];
|
||||
*/
|
||||
|
||||
// ==== ВАРИАНТ 3: Использование Execute Command с wkhtmltopdf ====
|
||||
// Если у вас установлен wkhtmltopdf на сервере n8n
|
||||
// Раскомментируйте и используйте в Execute Command ноде
|
||||
/*
|
||||
// Сохраняем HTML во временный файл
|
||||
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
|
||||
const timestamp = Date.now();
|
||||
const htmlFile = `/tmp/flights-${timestamp}.html`;
|
||||
const pdfFile = `/tmp/flights-${timestamp}.pdf`;
|
||||
|
||||
// Команда для Execute Command ноды:
|
||||
// echo '{{ $json.html_base64 }}' | base64 -d > {{ $json.html_file }} && \
|
||||
// wkhtmltopdf --page-size A4 --margin-top 20mm --margin-right 15mm --margin-bottom 20mm --margin-left 15mm \
|
||||
// --print-media-type {{ $json.html_file }} {{ $json.pdf_file }} && \
|
||||
// cat {{ $json.pdf_file }} | base64 && \
|
||||
// rm -f {{ $json.html_file }} {{ $json.pdf_file }}
|
||||
|
||||
return [{
|
||||
json: {
|
||||
html_base64: htmlBase64,
|
||||
html_file: htmlFile,
|
||||
pdf_file: pdfFile
|
||||
}
|
||||
}];
|
||||
*/
|
||||
35
docs/N8N_FLIGHTS_IMPROVED_FORMATTING.md
Normal file
35
docs/N8N_FLIGHTS_IMPROVED_FORMATTING.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Улучшенное форматирование PDF отчёта
|
||||
|
||||
## Что изменено
|
||||
|
||||
### Уменьшены отступы:
|
||||
- **Padding секций:** с `20px` → `12px 18px`
|
||||
- **Margin между элементами:** с `20px` → `12px`
|
||||
- **Padding карточек:** с `20px` → `14px 18px`
|
||||
- **Отступы в timeline:** с `10px` → `6px`
|
||||
|
||||
### Уменьшены размеры шрифтов:
|
||||
- **Заголовки:** с `24px` → `20px`
|
||||
- **Основной текст:** с `14px` → `13px`
|
||||
- **Метки:** с `12px` → `11px`
|
||||
|
||||
### Более компактная компоновка:
|
||||
- **Route info:** уменьшен gap с `15px` → `12px`
|
||||
- **Status info:** уменьшен gap с `15px` → `10px`, minmax с `200px` → `180px`
|
||||
- **Timeline:** уменьшена ширина label с `180px` → `160px`
|
||||
|
||||
### Общие улучшения:
|
||||
- Уменьшен `line-height` с `1.6` → `1.4` для более плотного текста
|
||||
- Уменьшены отступы body с `20px` → `15px`
|
||||
- Уменьшены отступы container с `30px` → `20px`
|
||||
|
||||
## Результат
|
||||
|
||||
✅ **Более компактное отображение** - данные расположены ближе друг к другу
|
||||
✅ **Меньше разрывов** - плавные переходы между секциями
|
||||
✅ **Лучшая читаемость** - оптимальный баланс между компактностью и читаемостью
|
||||
✅ **Экономия места** - больше информации на странице
|
||||
|
||||
## Как применить
|
||||
|
||||
Скопируйте обновлённый код из `N8N_FLIGHTS_TO_BASE64.js` в вашу Code Node "причесываем данные".
|
||||
72
docs/N8N_FLIGHTS_PDF_BASE64_COMPLETE.js
Normal file
72
docs/N8N_FLIGHTS_PDF_BASE64_COMPLETE.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: Полный цикл - HTML → Base64 PDF (всё в одном)
|
||||
// ============================================================================
|
||||
// Этот код делает всё: получает HTML, отправляет на конвертацию, получает base64
|
||||
// Требует настройки HTTP Request ноды или внешнего сервиса
|
||||
// ============================================================================
|
||||
|
||||
// Получаем HTML из предыдущей ноды "Code: Process Flights Data"
|
||||
const processedData = $('Code: Process Flights Data').first().json;
|
||||
|
||||
if (!processedData || !processedData.html) {
|
||||
throw new Error('HTML не получен из предыдущей ноды');
|
||||
}
|
||||
|
||||
const html = processedData.html;
|
||||
|
||||
// ==== НАСТРОЙКИ ====
|
||||
// Замените на ваши параметры
|
||||
const PDF_SERVICE_URL = 'https://api.htmlpdfapi.com/v1/pdf'; // Или другой сервис
|
||||
const PDF_API_KEY = 'YOUR_API_KEY'; // Замените на ваш ключ
|
||||
|
||||
// ==== ПОДГОТОВКА ЗАПРОСА ДЛЯ HTTP REQUEST ====
|
||||
// Этот код подготавливает данные для HTTP Request ноды
|
||||
// После этого Code Node добавьте HTTP Request ноду и используйте эти данные
|
||||
|
||||
return [{
|
||||
json: {
|
||||
// Данные для HTTP Request ноды
|
||||
http_method: 'POST',
|
||||
http_url: PDF_SERVICE_URL,
|
||||
http_headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${PDF_API_KEY}`
|
||||
},
|
||||
http_body: JSON.stringify({
|
||||
html: html,
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '15mm',
|
||||
bottom: '20mm',
|
||||
left: '15mm'
|
||||
}
|
||||
},
|
||||
base64: true // Запрашиваем base64 напрямую
|
||||
}),
|
||||
|
||||
// Метаданные
|
||||
html_length: html.length,
|
||||
flights_count: processedData.flights_count,
|
||||
generated_at: processedData.generated_at,
|
||||
|
||||
// Инструкция для следующей ноды
|
||||
next_step: 'HTTP Request → Code: Extract Base64 PDF'
|
||||
}
|
||||
}];
|
||||
|
||||
// ============================================================================
|
||||
// ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ:
|
||||
// ============================================================================
|
||||
// 1. Этот Code Node подготавливает запрос
|
||||
// 2. Добавьте HTTP Request ноду после этого Code Node
|
||||
// 3. В HTTP Request ноде используйте:
|
||||
// - Method: {{ $json.http_method }}
|
||||
// - URL: {{ $json.http_url }}
|
||||
// - Headers: {{ $json.http_headers }}
|
||||
// - Body: {{ $json.http_body }}
|
||||
// 4. После HTTP Request добавьте Code Node с кодом из N8N_FLIGHTS_PDF_BASE64_FULL.js
|
||||
// для извлечения base64 из ответа
|
||||
// ============================================================================
|
||||
81
docs/N8N_FLIGHTS_PDF_BASE64_FULL.js
Normal file
81
docs/N8N_FLIGHTS_PDF_BASE64_FULL.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: Полная обработка - HTML → Base64 PDF
|
||||
// ============================================================================
|
||||
// Этот код обрабатывает ответ от HTTP Request и возвращает base64 PDF
|
||||
// Используйте ПОСЛЕ HTTP Request ноды, которая конвертирует HTML в PDF
|
||||
// ============================================================================
|
||||
|
||||
// Получаем данные из HTTP Request ноды
|
||||
const httpResponse = $input.first();
|
||||
|
||||
if (!httpResponse) {
|
||||
throw new Error('Ответ от HTTP Request не получен');
|
||||
}
|
||||
|
||||
// ==== ВАРИАНТ 1: Сервис вернул base64 напрямую в JSON ====
|
||||
if (httpResponse.json && httpResponse.json.pdf) {
|
||||
const base64 = httpResponse.json.pdf;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
pdf_base64: base64,
|
||||
pdf_size_bytes: Math.floor(base64.length * 0.75), // Примерный размер
|
||||
pdf_size_mb: (Math.floor(base64.length * 0.75) / (1024 * 1024)).toFixed(2),
|
||||
success: true,
|
||||
source: 'json_response'
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
// ==== ВАРИАНТ 2: Сервис вернул binary данные ====
|
||||
if (httpResponse.binary && httpResponse.binary.data) {
|
||||
const pdfBinary = httpResponse.binary.data;
|
||||
|
||||
// Конвертируем binary в base64
|
||||
// В n8n binary.data может быть Buffer или строка
|
||||
let base64;
|
||||
if (Buffer.isBuffer(pdfBinary)) {
|
||||
base64 = pdfBinary.toString('base64');
|
||||
} else if (typeof pdfBinary === 'string') {
|
||||
// Если уже base64 строка
|
||||
base64 = pdfBinary;
|
||||
} else {
|
||||
// Пытаемся преобразовать
|
||||
base64 = Buffer.from(pdfBinary).toString('base64');
|
||||
}
|
||||
|
||||
const sizeBytes = Buffer.from(base64, 'base64').length;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
pdf_base64: base64,
|
||||
pdf_size_bytes: sizeBytes,
|
||||
pdf_size_mb: (sizeBytes / (1024 * 1024)).toFixed(2),
|
||||
success: true,
|
||||
source: 'binary_response'
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
// ==== ВАРИАНТ 3: Сервис вернул base64 в поле body или data ====
|
||||
if (httpResponse.json) {
|
||||
const body = httpResponse.json.body || httpResponse.json.data || httpResponse.json;
|
||||
|
||||
if (body.pdf || body.base64 || body.content) {
|
||||
const base64 = body.pdf || body.base64 || body.content;
|
||||
const sizeBytes = Buffer.from(base64, 'base64').length;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
pdf_base64: base64,
|
||||
pdf_size_bytes: sizeBytes,
|
||||
pdf_size_mb: (sizeBytes / (1024 * 1024)).toFixed(2),
|
||||
success: true,
|
||||
source: 'body_field'
|
||||
}
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
// ==== ОШИБКА: Не удалось извлечь PDF ====
|
||||
throw new Error('Не удалось извлечь PDF из ответа. Структура ответа: ' + JSON.stringify(Object.keys(httpResponse), null, 2));
|
||||
65
docs/N8N_FLIGHTS_PREPARE_REQUEST_DATA.js
Normal file
65
docs/N8N_FLIGHTS_PREPARE_REQUEST_DATA.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: Подготовка данных запроса рейса
|
||||
// ============================================================================
|
||||
// Используйте эту ноду ПЕРЕД "причесываем данные"
|
||||
// Она безопасно получает данные из ноды "запрос рейса" и передаёт их дальше
|
||||
// ============================================================================
|
||||
|
||||
// Получаем данные из ноды "запрос рейса"
|
||||
let requestData = {
|
||||
flight_number: null,
|
||||
departure_date_local: null,
|
||||
arrival_date_local: null
|
||||
};
|
||||
|
||||
try {
|
||||
const requestNode = $('запрос рейса');
|
||||
if (requestNode && requestNode.first()) {
|
||||
const requestJson = requestNode.first().json;
|
||||
if (requestJson) {
|
||||
requestData = {
|
||||
flight_number: requestJson.flight_number || requestJson.ident || requestJson.flight || null,
|
||||
departure_date_local: requestJson.departure_date_local || null,
|
||||
arrival_date_local: requestJson.arrival_date_local || null
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('⚠️ Не удалось получить данные из ноды "запрос рейса":', e.message);
|
||||
}
|
||||
|
||||
// Получаем данные из входных элементов (fallback)
|
||||
const inputItems = $input.all();
|
||||
inputItems.forEach(item => {
|
||||
if (item.json) {
|
||||
if (!requestData.flight_number && item.json.flight_number) {
|
||||
requestData.flight_number = item.json.flight_number;
|
||||
}
|
||||
if (!requestData.departure_date_local && item.json.departure_date_local) {
|
||||
requestData.departure_date_local = item.json.departure_date_local;
|
||||
}
|
||||
if (!requestData.arrival_date_local && item.json.arrival_date_local) {
|
||||
requestData.arrival_date_local = item.json.arrival_date_local;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Передаём данные дальше вместе с входными данными
|
||||
const outputItems = inputItems.map(item => ({
|
||||
...item,
|
||||
json: {
|
||||
...item.json,
|
||||
// Добавляем данные запроса
|
||||
request_flight_number: requestData.flight_number,
|
||||
request_departure_date: requestData.departure_date_local,
|
||||
request_arrival_date: requestData.arrival_date_local
|
||||
}
|
||||
}));
|
||||
|
||||
return outputItems.length > 0 ? outputItems : [{
|
||||
json: {
|
||||
request_flight_number: requestData.flight_number,
|
||||
request_departure_date: requestData.departure_date_local,
|
||||
request_arrival_date: requestData.arrival_date_local
|
||||
}
|
||||
}];
|
||||
193
docs/N8N_FLIGHTS_PROCESSING_GUIDE.md
Normal file
193
docs/N8N_FLIGHTS_PROCESSING_GUIDE.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Обработка данных о рейсах в n8n
|
||||
|
||||
## Описание
|
||||
|
||||
Код для обработки данных о рейсах из двух источников (FlightAware и FlightRadar24), объединения их и генерации красивого HTML для последующей конвертации в PDF.
|
||||
|
||||
## Структура входных данных
|
||||
|
||||
Workflow должен получать данные в следующем формате:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"body": {
|
||||
"flights": [
|
||||
{
|
||||
"ident": "CES747",
|
||||
"registration": "B-1308",
|
||||
"origin": { "code_iata": "KMG", "name": "Kunming Changshui Int'l" },
|
||||
"destination": { "code_iata": "PVG", "name": "Shanghai Pudong Int'l" },
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"body": {
|
||||
"data": [
|
||||
{
|
||||
"flight": "MU747",
|
||||
"reg": "B-1308",
|
||||
"orig_iata": "KMG",
|
||||
"dest_iata": "PVG",
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Установка в n8n
|
||||
|
||||
### Шаг 1: Добавить Code Node
|
||||
|
||||
1. В вашем workflow после получения данных из FlightAware и FlightRadar24
|
||||
2. Добавьте ноду **Code** (JavaScript)
|
||||
3. Назовите её: `Code: Process Flights Data`
|
||||
|
||||
### Шаг 2: Вставить код
|
||||
|
||||
Скопируйте содержимое файла `N8N_CODE_PROCESS_FLIGHTS_DATA.js` в Code Node.
|
||||
|
||||
### Шаг 3: Настройка выхода
|
||||
|
||||
Code Node вернёт объект с полями:
|
||||
- `html` - готовый HTML для конвертации в PDF
|
||||
- `flights` - массив объединённых данных о рейсах
|
||||
- `flights_count` - количество рейсов
|
||||
- `sources` - информация о доступности источников
|
||||
- `generated_at` - время генерации
|
||||
|
||||
## Конвертация HTML в Base64 PDF
|
||||
|
||||
### Вариант 1: HTTP Request → Base64 PDF (Рекомендуется)
|
||||
|
||||
**Шаг 1:** После Code Node добавьте Code Node с кодом из `N8N_FLIGHTS_PDF_BASE64_COMPLETE.js`
|
||||
- Этот код подготавливает запрос для HTTP Request
|
||||
|
||||
**Шаг 2:** Добавьте HTTP Request ноду:
|
||||
- Method: `POST`
|
||||
- URL: `{{ $json.http_url }}` (например, `https://api.htmlpdfapi.com/v1/pdf`)
|
||||
- Headers: `{{ $json.http_headers }}`
|
||||
- Body: `{{ $json.http_body }}`
|
||||
- Response Format: `JSON` или `Binary` (в зависимости от сервиса)
|
||||
|
||||
**Шаг 3:** После HTTP Request добавьте Code Node с кодом из `N8N_FLIGHTS_PDF_BASE64_FULL.js`
|
||||
- Этот код извлекает base64 из ответа сервиса
|
||||
|
||||
**Результат:** В выходных данных будет поле `pdf_base64` с готовым PDF в формате base64
|
||||
|
||||
### Вариант 2: Прямой запрос к сервису
|
||||
|
||||
Используйте код из `N8N_FLIGHTS_HTML_TO_PDF_EXAMPLE.js` для подготовки запроса к сервису конвертации.
|
||||
|
||||
**Популярные сервисы:**
|
||||
- **htmlpdfapi.com** - возвращает base64 в JSON
|
||||
- **pdfshift.io** - поддерживает base64
|
||||
- **api2pdf.com** - возвращает base64
|
||||
- **gotenberg.dev** - бесплатный self-hosted вариант
|
||||
|
||||
### Вариант 3: Execute Command с wkhtmltopdf
|
||||
|
||||
Если на сервере n8n установлен `wkhtmltopdf`:
|
||||
|
||||
1. Сохраните HTML во временный файл
|
||||
2. Выполните команду:
|
||||
```bash
|
||||
wkhtmltopdf --page-size A4 \
|
||||
--margin-top 20mm --margin-right 15mm \
|
||||
--margin-bottom 20mm --margin-left 15mm \
|
||||
--print-media-type input.html output.pdf && \
|
||||
cat output.pdf | base64
|
||||
```
|
||||
3. Получите base64 из вывода команды
|
||||
|
||||
### Использование base64 PDF
|
||||
|
||||
После получения base64 вы можете:
|
||||
- Сохранить в файл
|
||||
- Отправить по email
|
||||
- Загрузить в S3/Nextcloud
|
||||
- Вернуть в API response
|
||||
- Использовать в других workflow
|
||||
|
||||
## Особенности обработки
|
||||
|
||||
### Объединение данных
|
||||
|
||||
Данные объединяются по полю `registration` (номер самолёта):
|
||||
- FlightAware: `flight.registration`
|
||||
- FlightRadar24: `flight.reg`
|
||||
|
||||
Если для рейса есть данные только из одного источника, они всё равно будут отображены.
|
||||
|
||||
### Обработка отсутствующих данных
|
||||
|
||||
- Если данные из источника отсутствуют, показывается сообщение "Данные не получены"
|
||||
- Пустые значения отображаются как "—"
|
||||
- Даты форматируются в читаемый формат
|
||||
|
||||
### Форматирование
|
||||
|
||||
HTML включает:
|
||||
- Красивый дизайн с градиентами и карточками
|
||||
- Адаптивную вёрстку
|
||||
- Стили для печати (media queries для print)
|
||||
- Цветовую индикацию источников данных
|
||||
- Информацию о задержках (зелёный/красный)
|
||||
|
||||
## Пример workflow
|
||||
|
||||
```
|
||||
HTTP Request (FlightAware)
|
||||
↓
|
||||
HTTP Request (FlightRadar24)
|
||||
↓
|
||||
Code: Process Flights Data ← Вставить код отсюда
|
||||
↓
|
||||
HTML/CSS to PDF (или HTTP Request для конвертации)
|
||||
↓
|
||||
Save File / Send Email / etc.
|
||||
```
|
||||
|
||||
## Отладка
|
||||
|
||||
Если данные не обрабатываются:
|
||||
|
||||
1. Проверьте структуру входных данных через `console.log`:
|
||||
```javascript
|
||||
console.log('FlightAware:', JSON.stringify(flightAwareData, null, 2));
|
||||
console.log('FlightRadar24:', JSON.stringify(flightRadar24Data, null, 2));
|
||||
```
|
||||
|
||||
2. Убедитесь, что данные приходят в правильном порядке:
|
||||
- Первый элемент = FlightAware
|
||||
- Второй элемент = FlightRadar24
|
||||
|
||||
3. Проверьте наличие полей `body.flights` и `body.data`
|
||||
|
||||
## Дополнительные возможности
|
||||
|
||||
### Кастомизация HTML
|
||||
|
||||
Вы можете изменить стили в функции `generateFullHTML()`:
|
||||
- Цвета
|
||||
- Шрифты
|
||||
- Размеры
|
||||
- Расположение элементов
|
||||
|
||||
### Добавление дополнительных полей
|
||||
|
||||
В функции `generateFlightCard()` можно добавить отображение дополнительных полей из API.
|
||||
|
||||
### Фильтрация рейсов
|
||||
|
||||
Перед генерацией HTML можно отфильтровать рейсы:
|
||||
```javascript
|
||||
const filteredFlights = mergedFlights.filter(flight => {
|
||||
// Ваша логика фильтрации
|
||||
return flight.flightAware || flight.flightRadar24;
|
||||
});
|
||||
```
|
||||
236
docs/N8N_FLIGHTS_QUICK_START.md
Normal file
236
docs/N8N_FLIGHTS_QUICK_START.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# Быстрый старт: HTML → Base64 PDF в n8n
|
||||
|
||||
## Проблема
|
||||
У вас есть HTML в формате:
|
||||
```json
|
||||
{
|
||||
"html": "<!DOCTYPE html>..."
|
||||
}
|
||||
```
|
||||
|
||||
Нужно получить base64 PDF.
|
||||
|
||||
## Решение: 3 ноды
|
||||
|
||||
### Шаг 1: Code Node - Подготовка запроса
|
||||
|
||||
**Название:** `Code: Prepare PDF Request`
|
||||
|
||||
**Код:** Скопируйте из `N8N_HTML_TO_BASE64_PDF_SIMPLE.js`
|
||||
|
||||
**Важно:**
|
||||
- Замените `YOUR_API_KEY` на ваш реальный API ключ
|
||||
- Выберите сервис конвертации (htmlpdfapi.com, pdfshift.io и т.д.)
|
||||
|
||||
**Выходные данные:**
|
||||
```json
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "https://api.htmlpdfapi.com/v1/pdf",
|
||||
"headers": {...},
|
||||
"body": "{...}"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Шаг 2: HTTP Request - Конвертация
|
||||
|
||||
**Название:** `HTTP Request: Convert to PDF`
|
||||
|
||||
**Настройка:**
|
||||
- **Method:** `{{ $json.method }}`
|
||||
- **URL:** `{{ $json.url }}`
|
||||
- **Authentication:** None (или Basic, если требуется)
|
||||
- **Headers:**
|
||||
```json
|
||||
{{ $json.headers }}
|
||||
```
|
||||
- **Body:**
|
||||
```json
|
||||
{{ $json.body }}
|
||||
```
|
||||
- **Response Format:** `JSON` (или `Binary`, если сервис возвращает binary)
|
||||
|
||||
**Что делает:** Отправляет HTML в сервис конвертации и получает PDF
|
||||
|
||||
---
|
||||
|
||||
### Шаг 3: Code Node - Извлечение Base64
|
||||
|
||||
**Название:** `Code: Extract Base64 PDF`
|
||||
|
||||
**Код:** Скопируйте из `N8N_EXTRACT_BASE64_FROM_RESPONSE.js`
|
||||
|
||||
**Выходные данные:**
|
||||
```json
|
||||
{
|
||||
"pdf_base64": "JVBERi0xLjQKJeLjz9MK...",
|
||||
"pdf_size_bytes": 123456,
|
||||
"pdf_size_mb": "0.12",
|
||||
"filename": "flights-report-2026-01-16.pdf",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Готово!
|
||||
|
||||
Теперь у вас есть base64 PDF в поле `pdf_base64`.
|
||||
|
||||
## Что дальше?
|
||||
|
||||
### Вариант A: Сохранить в файл
|
||||
|
||||
Добавьте Code Node:
|
||||
```javascript
|
||||
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
|
||||
const pdfBuffer = Buffer.from(base64, 'base64');
|
||||
|
||||
return [{
|
||||
binary: {
|
||||
data: pdfBuffer,
|
||||
fileName: $('Code: Extract Base64 PDF').first().json.filename,
|
||||
mimeType: 'application/pdf'
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
Затем используйте ноду **Write Binary File** или **Save to S3**.
|
||||
|
||||
### Вариант B: Вернуть в API
|
||||
|
||||
Добавьте Code Node перед Response:
|
||||
```javascript
|
||||
const pdfData = $('Code: Extract Base64 PDF').first().json;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
success: true,
|
||||
pdf_base64: pdfData.pdf_base64,
|
||||
pdf_size_mb: pdfData.pdf_size_mb,
|
||||
filename: pdfData.filename
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
### Вариант C: Отправить по Email
|
||||
|
||||
Добавьте Code Node:
|
||||
```javascript
|
||||
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
|
||||
const pdfBuffer = Buffer.from(base64, 'base64');
|
||||
const filename = $('Code: Extract Base64 PDF').first().json.filename;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Отчёт о рейсах',
|
||||
text: 'Во вложении отчёт о рейсах.',
|
||||
attachments: [{
|
||||
filename: filename,
|
||||
content: pdfBuffer,
|
||||
contentType: 'application/pdf'
|
||||
}]
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
Затем используйте ноду **Email Send**.
|
||||
|
||||
---
|
||||
|
||||
## Популярные сервисы конвертации
|
||||
|
||||
### 1. htmlpdfapi.com (рекомендуется)
|
||||
- **Бесплатно:** 100 PDF/месяц
|
||||
- **Платно:** от $9/месяц
|
||||
- **URL:** https://htmlpdfapi.com
|
||||
- **Возвращает:** `{ pdf: "base64..." }`
|
||||
|
||||
### 2. pdfshift.io
|
||||
- **Бесплатно:** 100 PDF/месяц
|
||||
- **Платно:** от $9/месяц
|
||||
- **URL:** https://pdfshift.io
|
||||
- **Возвращает:** binary или base64
|
||||
|
||||
### 3. api2pdf.com
|
||||
- **Бесплатно:** 50 PDF/месяц
|
||||
- **Платно:** от $9/месяц
|
||||
- **URL:** https://www.api2pdf.com
|
||||
- **Возвращает:** `{ Pdf: "base64..." }`
|
||||
|
||||
### 4. Self-hosted: Gotenberg
|
||||
- **Бесплатно:** полностью
|
||||
- **Требует:** Docker
|
||||
- **URL:** https://gotenberg.dev
|
||||
- **Возвращает:** binary PDF
|
||||
|
||||
---
|
||||
|
||||
## Отладка
|
||||
|
||||
### Проверка HTML
|
||||
В Code Node добавьте:
|
||||
```javascript
|
||||
console.log('HTML length:', html.length);
|
||||
console.log('HTML preview:', html.substring(0, 200));
|
||||
```
|
||||
|
||||
### Проверка ответа сервиса
|
||||
После HTTP Request добавьте Code Node:
|
||||
```javascript
|
||||
const response = $input.first();
|
||||
console.log('Response keys:', Object.keys(response));
|
||||
console.log('Response json keys:', response.json ? Object.keys(response.json) : 'no json');
|
||||
console.log('Response binary:', response.binary ? 'yes' : 'no');
|
||||
```
|
||||
|
||||
### Проверка base64
|
||||
После извлечения base64:
|
||||
```javascript
|
||||
const base64 = $json.pdf_base64;
|
||||
console.log('Base64 length:', base64.length);
|
||||
console.log('Base64 preview:', base64.substring(0, 50));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Частые проблемы
|
||||
|
||||
### Проблема: "HTML не найден"
|
||||
**Решение:** Проверьте, что HTML приходит в поле `html`. Если нет, измените первую строку в `N8N_HTML_TO_BASE64_PDF_SIMPLE.js`:
|
||||
```javascript
|
||||
const html = $json.html || $json.body?.html || $json.data?.html || $json;
|
||||
```
|
||||
|
||||
### Проблема: "Не удалось извлечь base64"
|
||||
**Решение:**
|
||||
1. Проверьте формат ответа сервиса
|
||||
2. Добавьте логирование в `N8N_EXTRACT_BASE64_FROM_RESPONSE.js`
|
||||
3. Убедитесь, что сервис действительно вернул PDF
|
||||
|
||||
### Проблема: PDF пустой или повреждён
|
||||
**Решение:**
|
||||
1. Проверьте, что HTML валидный
|
||||
2. Убедитесь, что CSS включён в HTML (inline styles)
|
||||
3. Проверьте, что сервис поддерживает все используемые CSS свойства
|
||||
|
||||
---
|
||||
|
||||
## Готовый Workflow
|
||||
|
||||
```
|
||||
[Ваша нода с HTML]
|
||||
↓
|
||||
Code: Prepare PDF Request
|
||||
↓
|
||||
HTTP Request: Convert to PDF
|
||||
↓
|
||||
Code: Extract Base64 PDF
|
||||
↓
|
||||
[Использование base64]
|
||||
```
|
||||
|
||||
Всё готово! 🎉
|
||||
103
docs/N8N_FLIGHTS_REQUESTED_INFO.md
Normal file
103
docs/N8N_FLIGHTS_REQUESTED_INFO.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Отображение запрошенных рейсов без данных
|
||||
|
||||
## Проблема
|
||||
|
||||
Когда данных о рейсе нет, нужно показывать, по какому рейсу и запросу информация отсутствует.
|
||||
|
||||
## Решение
|
||||
|
||||
Код автоматически извлекает информацию о запрошенных рейсах и показывает их даже если данных нет.
|
||||
|
||||
## Способы передачи информации о запрошенных рейсах
|
||||
|
||||
### Вариант 1: Прямая передача (рекомендуется)
|
||||
|
||||
В предыдущей ноде (перед Code Node) добавьте информацию о запрошенных рейсах:
|
||||
|
||||
```javascript
|
||||
// В Code Node перед "причесываем данные"
|
||||
return [{
|
||||
json: {
|
||||
// Ваши данные
|
||||
...existingData,
|
||||
|
||||
// Информация о запрошенных рейсах
|
||||
requested_flights: ['MU747', 'CES747'], // Массив номеров рейсов
|
||||
// ИЛИ
|
||||
flight_number: 'MU747', // Один рейс
|
||||
// ИЛИ
|
||||
flight_numbers: ['MU747', 'CES747'] // Альтернативный формат
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
### Вариант 2: Автоматическое извлечение
|
||||
|
||||
Код автоматически пытается извлечь информацию о рейсах из:
|
||||
- URL запросов (параметры `ident`, `flight_number`, `flight`, `callsign`)
|
||||
- Query параметров
|
||||
- Body запросов
|
||||
- Прямых полей в JSON
|
||||
|
||||
## Что отображается
|
||||
|
||||
Если рейс был запрошен, но данных нет, показывается карточка:
|
||||
|
||||
```
|
||||
┌─ Рейс MU747 ───────────────┐
|
||||
│ Запрошен │
|
||||
│ │
|
||||
│ Запрошенный рейс: MU747 │
|
||||
│ │
|
||||
│ [FlightAware] │
|
||||
│ ✗ Данные не получены │
|
||||
│ │
|
||||
│ [FlightRadar24] │
|
||||
│ ✗ Данные не получены │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
## Пример использования
|
||||
|
||||
### В предыдущей ноде (HTTP Request или Code Node):
|
||||
|
||||
```javascript
|
||||
// После запросов к FlightAware и FlightRadar24
|
||||
return [{
|
||||
json: {
|
||||
data: [
|
||||
{ body: { flights: [...] } }, // FlightAware ответ
|
||||
{ body: { data: [...] } } // FlightRadar24 ответ
|
||||
],
|
||||
// Добавляем информацию о запрошенных рейсах
|
||||
requested_flights: ['MU747', 'CES747']
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
### Или в отдельной ноде перед обработкой:
|
||||
|
||||
```javascript
|
||||
// Code Node: Prepare Request Info
|
||||
const flightNumbers = ['MU747', 'CES747']; // Из вашего запроса
|
||||
|
||||
return [{
|
||||
json: {
|
||||
requested_flights: flightNumbers,
|
||||
// Другие данные...
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
## Преимущества
|
||||
|
||||
✅ **Прозрачность** - видно, какие рейсы запрашивались
|
||||
✅ **Отладка** - легко понять, почему данных нет
|
||||
✅ **Информативность** - пользователь видит, что запрос был выполнен
|
||||
✅ **Автоматика** - код пытается извлечь информацию автоматически
|
||||
|
||||
## Если данные всё равно не показываются
|
||||
|
||||
1. Проверьте, что передаёте `requested_flights` в предыдущей ноде
|
||||
2. Убедитесь, что формат правильный: массив строк или объект с полем `flight_number`
|
||||
3. Проверьте логи в Code Node - там будут сообщения о найденных запрошенных рейсах
|
||||
370
docs/N8N_FLIGHTS_SIMPLE_BINARY.js
Normal file
370
docs/N8N_FLIGHTS_SIMPLE_BINARY.js
Normal file
@@ -0,0 +1,370 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: Отчёт о рейсах (HTML → Binary + Base64 PDF)
|
||||
// ============================================================================
|
||||
// Упрощённая версия с возвратом binary HTML и подготовкой для PDF конвертации
|
||||
// ============================================================================
|
||||
|
||||
const inputItems = $input.all();
|
||||
|
||||
// ================== FALLBACK ==================
|
||||
if (!inputItems || inputItems.length === 0) {
|
||||
const html = '<!DOCTYPE html><html><body><h1>Ошибка: данные не получены</h1></body></html>';
|
||||
return [{
|
||||
binary: {
|
||||
data: Buffer.from(html, 'utf8'),
|
||||
mimeType: 'text/html',
|
||||
fileName: 'flights-report.html'
|
||||
},
|
||||
json: {
|
||||
html: html,
|
||||
flights_count: 0,
|
||||
error: 'Нет входных данных'
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
// ================== ИЗВЛЕЧЕНИЕ ДАННЫХ ==================
|
||||
let flightAwareData = [];
|
||||
let flightRadar24Data = [];
|
||||
|
||||
try {
|
||||
const fa = inputItems[0]?.json?.body?.flights;
|
||||
if (Array.isArray(fa)) flightAwareData = fa;
|
||||
} catch (e) {
|
||||
console.log('⚠️ Ошибка извлечения FlightAware:', e.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const fr = inputItems[1]?.json?.body?.data;
|
||||
if (Array.isArray(fr)) flightRadar24Data = fr;
|
||||
} catch (e) {
|
||||
console.log('⚠️ Ошибка извлечения FlightRadar24:', e.message);
|
||||
}
|
||||
|
||||
// ================== УТИЛИТЫ ==================
|
||||
const safeStr = v => (v == null ? '' : String(v));
|
||||
const safeDate = v => {
|
||||
if (!v) return '—';
|
||||
try {
|
||||
const d = new Date(v);
|
||||
return isNaN(d.getTime()) ? '—' : d.toLocaleString('ru-RU', {
|
||||
timeZone: 'UTC',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = s => !s ? '—' : `${Math.floor(s / 3600)}ч ${Math.floor((s % 3600) / 60)}м`;
|
||||
const formatDistance = km => !km ? '—' : `${Number(km).toFixed(2)} км`;
|
||||
|
||||
// ================== MERGE ПО REGISTRATION ==================
|
||||
const flightsMap = new Map();
|
||||
|
||||
flightAwareData.forEach(f => {
|
||||
const reg = safeStr(f.registration).trim();
|
||||
if (!reg) return;
|
||||
if (!flightsMap.has(reg)) {
|
||||
flightsMap.set(reg, {
|
||||
registration: reg,
|
||||
flightNumber: safeStr(f.flight_number),
|
||||
ident: safeStr(f.ident),
|
||||
identIata: safeStr(f.ident_iata),
|
||||
aircraftType: safeStr(f.aircraft_type),
|
||||
fa: f,
|
||||
fr: null
|
||||
});
|
||||
} else {
|
||||
flightsMap.get(reg).fa = f;
|
||||
}
|
||||
});
|
||||
|
||||
flightRadar24Data.forEach(f => {
|
||||
const reg = safeStr(f.reg).trim();
|
||||
if (!reg) return;
|
||||
if (!flightsMap.has(reg)) {
|
||||
flightsMap.set(reg, {
|
||||
registration: reg,
|
||||
flightNumber: safeStr(f.flight),
|
||||
ident: safeStr(f.callsign),
|
||||
identIata: safeStr(f.flight),
|
||||
aircraftType: safeStr(f.type),
|
||||
fa: null,
|
||||
fr: f
|
||||
});
|
||||
} else {
|
||||
flightsMap.get(reg).fr = f;
|
||||
}
|
||||
});
|
||||
|
||||
const flights = Array.from(flightsMap.values());
|
||||
|
||||
// ================== HTML GENERATION ==================
|
||||
const generateFlightCard = f => {
|
||||
const fa = f.fa;
|
||||
const fr = f.fr;
|
||||
|
||||
let card = `
|
||||
<div class="flight-card">
|
||||
<div class="flight-header">
|
||||
<h2>Рейс ${f.flightNumber || f.ident || '—'}</h2>
|
||||
<span class="registration">${f.registration}</span>
|
||||
</div>
|
||||
<div class="flight-info">
|
||||
<div class="info-row">
|
||||
<span class="label">Тип самолёта:</span>
|
||||
<span class="value">${f.aircraftType || '—'}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Идентификатор:</span>
|
||||
<span class="value">${f.ident || '—'} (${f.identIata || '—'})</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
if (fa) {
|
||||
card += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightaware">FlightAware</span>
|
||||
</div>
|
||||
<div class="source-content">
|
||||
<div class="route-info">
|
||||
<div class="route-item">
|
||||
<span class="route-label">Откуда:</span>
|
||||
<span class="route-value">${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')})</span>
|
||||
</div>
|
||||
<div class="route-item">
|
||||
<span class="route-label">Куда:</span>
|
||||
<span class="route-value">${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Вылет:</span>
|
||||
<span class="timeline-value">${safeDate(fa.actual_out)}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Прилёт:</span>
|
||||
<span class="timeline-value">${safeDate(fa.actual_in)}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Статус:</span>
|
||||
<span class="timeline-value">${safeStr(fa.status || '—')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
card += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightaware">FlightAware</span>
|
||||
<span class="source-missing">Данные не получены</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (fr) {
|
||||
card += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightradar24">FlightRadar24</span>
|
||||
</div>
|
||||
<div class="source-content">
|
||||
<div class="route-info">
|
||||
<div class="route-item">
|
||||
<span class="route-label">Откуда:</span>
|
||||
<span class="route-value">${safeStr(fr.orig_iata || '—')} (${safeStr(fr.orig_icao || '—')})</span>
|
||||
</div>
|
||||
<div class="route-item">
|
||||
<span class="route-label">Куда:</span>
|
||||
<span class="route-value">${safeStr(fr.dest_iata || '—')} (${safeStr(fr.dest_icao || '—')})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-info">
|
||||
<div class="status-item">
|
||||
<span class="status-label">Время полёта:</span>
|
||||
<span class="status-value">${formatDuration(fr.flight_time)}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Расстояние:</span>
|
||||
<span class="status-value">${formatDistance(fr.actual_distance)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
card += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightradar24">FlightRadar24</span>
|
||||
<span class="source-missing">Данные не получены</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
card += `</div>`;
|
||||
return card;
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const reportDate = now.toLocaleString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Отчёт о рейсах</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; line-height: 1.6; color: #333; background: #f5f5f5; padding: 20px; }
|
||||
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
.header { border-bottom: 3px solid #2563eb; padding-bottom: 20px; margin-bottom: 30px; }
|
||||
.header h1 { color: #1e40af; font-size: 28px; margin-bottom: 10px; }
|
||||
.header-meta { color: #666; font-size: 14px; }
|
||||
.sources-info { display: flex; gap: 15px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.source-tag { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }
|
||||
.source-tag.available { background: #d1fae5; color: #065f46; }
|
||||
.source-tag.unavailable { background: #fee2e2; color: #991b1b; }
|
||||
.flight-card { border: 1px solid #e5e7eb; border-radius: 8px; margin-bottom: 25px; overflow: hidden; background: white; }
|
||||
.flight-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.flight-header h2 { font-size: 24px; margin: 0; }
|
||||
.registration { background: rgba(255,255,255,0.2); padding: 6px 12px; border-radius: 4px; font-weight: 600; font-size: 14px; }
|
||||
.flight-info { padding: 15px 20px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; }
|
||||
.info-row { display: flex; margin-bottom: 8px; }
|
||||
.info-row .label { font-weight: 600; color: #4b5563; width: 150px; flex-shrink: 0; }
|
||||
.info-row .value { color: #111827; }
|
||||
.source-section { border-top: 1px solid #e5e7eb; padding: 20px; }
|
||||
.source-section:first-of-type { border-top: none; }
|
||||
.source-header { display: flex; align-items: center; gap: 10px; margin-bottom: 15px; }
|
||||
.source-badge { display: inline-block; padding: 6px 14px; border-radius: 6px; font-size: 13px; font-weight: 600; color: white; }
|
||||
.source-badge.source-flightaware { background: #3b82f6; }
|
||||
.source-badge.source-flightradar24 { background: #10b981; }
|
||||
.source-missing { color: #ef4444; font-size: 13px; font-style: italic; }
|
||||
.source-content { margin-left: 0; }
|
||||
.route-info { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px; padding: 15px; background: #f9fafb; border-radius: 6px; }
|
||||
.route-item { display: flex; flex-direction: column; }
|
||||
.route-label { font-size: 12px; color: #6b7280; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.route-value { font-size: 16px; font-weight: 600; color: #111827; }
|
||||
.timeline { margin-bottom: 20px; }
|
||||
.timeline-item { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }
|
||||
.timeline-item:last-child { border-bottom: none; }
|
||||
.timeline-label { font-weight: 500; color: #4b5563; width: 180px; flex-shrink: 0; }
|
||||
.timeline-value { color: #111827; text-align: right; }
|
||||
.status-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; padding: 15px; background: #f9fafb; border-radius: 6px; }
|
||||
.status-item { display: flex; flex-direction: column; }
|
||||
.status-label { font-size: 12px; color: #6b7280; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.status-value { font-size: 14px; font-weight: 600; color: #111827; }
|
||||
.no-data { text-align: center; padding: 60px 20px; color: #6b7280; font-size: 18px; }
|
||||
@media print { body { background: white; padding: 0; } .container { box-shadow: none; padding: 20px; } .flight-card { page-break-inside: avoid; margin-bottom: 20px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Отчёт о рейсах</h1>
|
||||
<div class="header-meta">
|
||||
<div>Дата формирования: ${reportDate}</div>
|
||||
<div class="sources-info">
|
||||
<span class="source-tag ${flightAwareData.length > 0 ? 'available' : 'unavailable'}">
|
||||
FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
|
||||
</span>
|
||||
<span class="source-tag ${flightRadar24Data.length > 0 ? 'available' : 'unavailable'}">
|
||||
FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flights-container">
|
||||
${flights.length ? flights.map(generateFlightCard).join('') : '<div class="no-data">Данные о рейсах не найдены</div>'}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// ================== ПОДГОТОВКА ДАННЫХ ДЛЯ PDF КОНВЕРТАЦИИ ==================
|
||||
// Настройки сервиса (замените на ваши)
|
||||
const PDF_SERVICE_URL = 'https://api.htmlpdfapi.com/v1/pdf';
|
||||
const PDF_API_KEY = 'YOUR_API_KEY'; // ⚠️ ЗАМЕНИТЕ на ваш API ключ
|
||||
|
||||
// ================== RETURN ==================
|
||||
return [{
|
||||
// Binary HTML файл (для использования в Convert to File ноде или сохранения)
|
||||
binary: {
|
||||
data: Buffer.from(html, 'utf8'),
|
||||
mimeType: 'text/html',
|
||||
fileName: `flights-report-${now.toISOString().split('T')[0]}.html`
|
||||
},
|
||||
|
||||
// JSON данные
|
||||
json: {
|
||||
// HTML строка (для конвертации в PDF через HTTP Request)
|
||||
html: html,
|
||||
|
||||
// Метаданные
|
||||
flights_count: flights.length,
|
||||
generated_at: now.toISOString(),
|
||||
sources: {
|
||||
flightaware: { available: flightAwareData.length > 0, count: flightAwareData.length },
|
||||
flightradar24: { available: flightRadar24Data.length > 0, count: flightRadar24Data.length }
|
||||
},
|
||||
|
||||
// Данные для конвертации в base64 PDF (используйте в следующей HTTP Request ноде)
|
||||
pdf_request: {
|
||||
method: 'POST',
|
||||
url: PDF_SERVICE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${PDF_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
html: html,
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
|
||||
},
|
||||
base64: true
|
||||
})
|
||||
},
|
||||
|
||||
// Удобные поля для HTTP Request ноды
|
||||
pdf_request_method: 'POST',
|
||||
pdf_request_url: PDF_SERVICE_URL,
|
||||
pdf_request_headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${PDF_API_KEY}`
|
||||
},
|
||||
pdf_request_body: JSON.stringify({
|
||||
html: html,
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
|
||||
},
|
||||
base64: true
|
||||
})
|
||||
}
|
||||
}];
|
||||
|
||||
// ============================================================================
|
||||
// ИСПОЛЬЗОВАНИЕ:
|
||||
// ============================================================================
|
||||
// 1. Binary HTML можно использовать в ноде "Convert to File" или сохранить
|
||||
// 2. JSON.html можно использовать для конвертации в PDF через HTTP Request
|
||||
// 3. JSON.pdf_request_* поля готовы для использования в HTTP Request ноде
|
||||
// 4. После HTTP Request используйте N8N_EXTRACT_BASE64_FROM_RESPONSE.js
|
||||
// для извлечения base64 PDF из ответа
|
||||
// ============================================================================
|
||||
630
docs/N8N_FLIGHTS_TO_BASE64.js
Normal file
630
docs/N8N_FLIGHTS_TO_BASE64.js
Normal file
@@ -0,0 +1,630 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: Обработка данных о рейсах → Base64 HTML
|
||||
// ============================================================================
|
||||
// Вход: [{ data: [{ body: { flights: [...] }}, { body: { data: [...] }}] }]
|
||||
// Выход: base64 HTML
|
||||
// ============================================================================
|
||||
|
||||
const inputItems = $input.all();
|
||||
|
||||
// ================== FALLBACK ==================
|
||||
if (!inputItems || inputItems.length === 0) {
|
||||
const html = '<!DOCTYPE html><html><body><h1>Ошибка: данные не получены</h1></body></html>';
|
||||
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
|
||||
|
||||
return [{
|
||||
json: {
|
||||
html_base64: htmlBase64,
|
||||
html: html,
|
||||
flights_count: 0,
|
||||
error: 'Нет входных данных'
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
// ================== ИЗВЛЕЧЕНИЕ ДАННЫХ ==================
|
||||
// Новая структура: [{ data: [{ body: { flights: [...] }}, { error: {...} }, { flight_number, ... }] }]
|
||||
let flightAwareData = [];
|
||||
let flightRadar24Data = [];
|
||||
let requestData = null; // Данные из ноды "запрос рейса"
|
||||
let flightRadar24Error = null; // Ошибка от FlightRadar24
|
||||
|
||||
try {
|
||||
const firstItem = inputItems[0];
|
||||
if (firstItem && firstItem.json && firstItem.json.data && Array.isArray(firstItem.json.data)) {
|
||||
// Первый элемент массива data - FlightAware
|
||||
if (firstItem.json.data[0] && firstItem.json.data[0].body) {
|
||||
if (firstItem.json.data[0].body.flights) {
|
||||
flightAwareData = Array.isArray(firstItem.json.data[0].body.flights)
|
||||
? firstItem.json.data[0].body.flights
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
// Второй элемент массива data - FlightRadar24 (может быть ошибка)
|
||||
if (firstItem.json.data[1]) {
|
||||
// Проверяем, есть ли ошибка
|
||||
if (firstItem.json.data[1].error) {
|
||||
flightRadar24Error = firstItem.json.data[1].error;
|
||||
console.log('⚠️ Ошибка FlightRadar24:', flightRadar24Error.message);
|
||||
flightRadar24Data = [];
|
||||
} else if (firstItem.json.data[1].body && firstItem.json.data[1].body.data) {
|
||||
flightRadar24Data = Array.isArray(firstItem.json.data[1].body.data)
|
||||
? firstItem.json.data[1].body.data
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
// Третий элемент массива data - данные из ноды "запрос рейса"
|
||||
if (firstItem.json.data[2] && firstItem.json.data[2].flight_number) {
|
||||
requestData = {
|
||||
flight_number: firstItem.json.data[2].flight_number,
|
||||
departure_date_local: firstItem.json.data[2].departure_date_local || null,
|
||||
arrival_date_local: firstItem.json.data[2].arrival_date_local || null
|
||||
};
|
||||
console.log('✅ Данные запроса получены:', requestData);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('⚠️ Ошибка извлечения данных:', e.message);
|
||||
}
|
||||
|
||||
// ================== УТИЛИТЫ ==================
|
||||
const safeStr = v => (v == null ? '' : String(v));
|
||||
const safeDate = v => {
|
||||
if (!v) return '—';
|
||||
try {
|
||||
const d = new Date(v);
|
||||
return isNaN(d.getTime()) ? '—' : d.toLocaleString('ru-RU', {
|
||||
timeZone: 'UTC',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = s => !s ? '—' : `${Math.floor(s / 3600)}ч ${Math.floor((s % 3600) / 60)}м`;
|
||||
const formatDistance = km => !km ? '—' : `${Number(km).toFixed(2)} км`;
|
||||
|
||||
// ================== MERGE ПО REGISTRATION ==================
|
||||
const flightsMap = new Map();
|
||||
|
||||
// Добавляем данные из FlightAware
|
||||
flightAwareData.forEach(f => {
|
||||
const reg = safeStr(f.registration).trim();
|
||||
if (!reg) return;
|
||||
if (!flightsMap.has(reg)) {
|
||||
flightsMap.set(reg, {
|
||||
registration: reg,
|
||||
flightNumber: safeStr(f.flight_number),
|
||||
ident: safeStr(f.ident),
|
||||
identIata: safeStr(f.ident_iata),
|
||||
aircraftType: safeStr(f.aircraft_type),
|
||||
fa: f,
|
||||
fr: null
|
||||
});
|
||||
} else {
|
||||
flightsMap.get(reg).fa = f;
|
||||
}
|
||||
});
|
||||
|
||||
// Добавляем данные из FlightRadar24
|
||||
flightRadar24Data.forEach(f => {
|
||||
const reg = safeStr(f.reg).trim();
|
||||
if (!reg) return;
|
||||
if (!flightsMap.has(reg)) {
|
||||
flightsMap.set(reg, {
|
||||
registration: reg,
|
||||
flightNumber: safeStr(f.flight),
|
||||
ident: safeStr(f.callsign),
|
||||
identIata: safeStr(f.flight),
|
||||
aircraftType: safeStr(f.type),
|
||||
fa: null,
|
||||
fr: f
|
||||
});
|
||||
} else {
|
||||
flightsMap.get(reg).fr = f;
|
||||
}
|
||||
});
|
||||
|
||||
// ================== ДОБАВЛЕНИЕ ЗАПРОШЕННЫХ РЕЙСОВ БЕЗ ДАННЫХ ==================
|
||||
// Если есть информация о запрошенных рейсах, но нет данных - добавляем их
|
||||
// Пытаемся извлечь из предыдущих нод (HTTP Request) или получить из входных данных
|
||||
const allInputItems = $input.all();
|
||||
const firstItemForRequest = inputItems[0]; // Используем уже определённую переменную из блока выше
|
||||
|
||||
// Ищем информацию о запрошенных рейсах
|
||||
let requestedFlightNumbers = new Set();
|
||||
|
||||
// ВАРИАНТ 1: Получение данных из ноды "запрос рейса"
|
||||
// Используем данные, извлечённые выше из data[2]
|
||||
let requestFlightNumber = null;
|
||||
let requestDepartureDate = null;
|
||||
let requestArrivalDate = null;
|
||||
|
||||
if (requestData) {
|
||||
requestFlightNumber = requestData.flight_number;
|
||||
requestDepartureDate = requestData.departure_date_local;
|
||||
requestArrivalDate = requestData.arrival_date_local;
|
||||
|
||||
if (requestFlightNumber) {
|
||||
requestedFlightNumbers.add(String(requestFlightNumber));
|
||||
}
|
||||
}
|
||||
|
||||
// Дополнительно ищем в других местах (fallback)
|
||||
allInputItems.forEach(item => {
|
||||
if (item.json) {
|
||||
// Прямые поля из ноды "запрос рейса"
|
||||
if (item.json.flight_number && (item.json.departure_date_local || item.json.arrival_date_local)) {
|
||||
if (!requestFlightNumber) {
|
||||
requestFlightNumber = item.json.flight_number || item.json.ident || item.json.flight;
|
||||
requestDepartureDate = item.json.departure_date_local || null;
|
||||
requestArrivalDate = item.json.arrival_date_local || null;
|
||||
|
||||
if (requestFlightNumber) {
|
||||
requestedFlightNumbers.add(String(requestFlightNumber));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Данные, переданные из предыдущей ноды
|
||||
if (item.json.request_flight_number) {
|
||||
if (!requestFlightNumber) {
|
||||
requestFlightNumber = item.json.request_flight_number;
|
||||
requestDepartureDate = item.json.request_departure_date || null;
|
||||
requestArrivalDate = item.json.request_arrival_date || null;
|
||||
|
||||
if (requestFlightNumber) {
|
||||
requestedFlightNumbers.add(String(requestFlightNumber));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ВАРИАНТ 2: Прямая передача из предыдущей ноды
|
||||
if (firstItemForRequest && firstItemForRequest.json) {
|
||||
// Массив запрошенных рейсов
|
||||
if (firstItemForRequest.json.requested_flights && Array.isArray(firstItemForRequest.json.requested_flights)) {
|
||||
firstItemForRequest.json.requested_flights.forEach(flight => {
|
||||
const flightNum = typeof flight === 'string' ? flight : (flight.flight_number || flight.ident || flight);
|
||||
if (flightNum) {
|
||||
requestedFlightNumbers.add(flightNum);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Один рейс
|
||||
if (firstItemForRequest.json.flight_number || firstItemForRequest.json.ident || firstItemForRequest.json.flight) {
|
||||
const flightNum = firstItemForRequest.json.flight_number || firstItemForRequest.json.ident || firstItemForRequest.json.flight;
|
||||
requestedFlightNumbers.add(flightNum);
|
||||
}
|
||||
|
||||
// Массив flight_numbers
|
||||
if (firstItemForRequest.json.flight_numbers && Array.isArray(firstItemForRequest.json.flight_numbers)) {
|
||||
firstItemForRequest.json.flight_numbers.forEach(flightNum => {
|
||||
if (flightNum) requestedFlightNumbers.add(String(flightNum));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ВАРИАНТ 3: Извлечение из всех входных элементов
|
||||
allInputItems.forEach(item => {
|
||||
if (item.json) {
|
||||
if (item.json.flight_number) {
|
||||
requestedFlightNumbers.add(String(item.json.flight_number));
|
||||
}
|
||||
if (item.json.ident) {
|
||||
requestedFlightNumbers.add(String(item.json.ident));
|
||||
}
|
||||
if (item.json.flight) {
|
||||
requestedFlightNumbers.add(String(item.json.flight));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ВАРИАНТ 2: Извлечение из URL и параметров запросов
|
||||
allInputItems.forEach(item => {
|
||||
// Из URL запроса
|
||||
if (item.json && item.json.url) {
|
||||
const url = item.json.url;
|
||||
const flightMatch = url.match(/(?:ident|flight_number|flight|callsign)=([^&]+)/i);
|
||||
if (flightMatch) {
|
||||
requestedFlightNumbers.add(flightMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Из query параметров
|
||||
if (item.json && item.json.query) {
|
||||
const query = item.json.query;
|
||||
const flightNum = query.ident || query.flight_number || query.flight || query.callsign;
|
||||
if (flightNum) {
|
||||
requestedFlightNumbers.add(flightNum);
|
||||
}
|
||||
}
|
||||
|
||||
// Из body запроса
|
||||
if (item.json && item.json.body) {
|
||||
const body = item.json.body;
|
||||
const flightNum = body.ident || body.flight_number || body.flight || body.callsign;
|
||||
if (flightNum) {
|
||||
requestedFlightNumbers.add(flightNum);
|
||||
}
|
||||
}
|
||||
|
||||
// Прямо из json
|
||||
if (item.json) {
|
||||
const flightNum = item.json.ident || item.json.flight_number || item.json.flight || item.json.callsign;
|
||||
if (flightNum) {
|
||||
requestedFlightNumbers.add(flightNum);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Добавляем запрошенные рейсы, для которых нет данных
|
||||
requestedFlightNumbers.forEach(flightNum => {
|
||||
// Проверяем, есть ли уже этот рейс в flightsMap
|
||||
let found = false;
|
||||
flightsMap.forEach((flight, reg) => {
|
||||
if (flight.flightNumber === flightNum || flight.ident === flightNum || flight.identIata === flightNum) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Если не найден - добавляем как запрошенный без данных
|
||||
if (!found) {
|
||||
flightsMap.set(`REQUESTED-${flightNum}`, {
|
||||
registration: '—',
|
||||
flightNumber: flightNum,
|
||||
ident: flightNum,
|
||||
identIata: flightNum,
|
||||
aircraftType: '—',
|
||||
fa: null,
|
||||
fr: null,
|
||||
isRequested: true // Флаг, что это запрошенный рейс без данных
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const flights = Array.from(flightsMap.values());
|
||||
|
||||
// ================== HTML GENERATION ==================
|
||||
// Делаем flightRadar24Error доступным в функции generateFlightCard
|
||||
const generateFlightCard = (f, fr24ErrorParam = null) => {
|
||||
const fa = f.fa;
|
||||
const fr = f.fr;
|
||||
const fr24Error = fr24ErrorParam; // Локальная переменная для использования в функции
|
||||
|
||||
// Если это запрошенный рейс без данных
|
||||
if (f.isRequested && !fa && !fr) {
|
||||
// Используем данные, полученные ранее из ноды "запрос рейса"
|
||||
let requestInfo = '';
|
||||
|
||||
// Проверяем, соответствует ли этот рейс запрошенному
|
||||
const matchesRequest = requestFlightNumber && (
|
||||
String(f.flightNumber) === String(requestFlightNumber) ||
|
||||
String(f.ident) === String(requestFlightNumber)
|
||||
);
|
||||
|
||||
if (matchesRequest) {
|
||||
if (requestDepartureDate) {
|
||||
requestInfo += `<div class="info-row"><span class="label">Дата вылета (запрос):</span><span class="value">${requestDepartureDate}</span></div>`;
|
||||
}
|
||||
if (requestArrivalDate) {
|
||||
requestInfo += `<div class="info-row"><span class="label">Дата прилёта (запрос):</span><span class="value">${requestArrivalDate}</span></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="flight-card">
|
||||
<div class="flight-header">
|
||||
<h2>Рейс ${f.flightNumber || f.ident || '—'}</h2>
|
||||
<span class="registration">Запрошен</span>
|
||||
</div>
|
||||
<div class="flight-info">
|
||||
<div class="info-row">
|
||||
<span class="label">Запрошенный рейс:</span>
|
||||
<span class="value">${f.flightNumber || f.ident || '—'}</span>
|
||||
</div>
|
||||
${requestInfo}
|
||||
</div>
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightaware">FlightAware</span>
|
||||
<span class="source-missing">Данные не получены</span>
|
||||
</div>
|
||||
<div class="source-content">
|
||||
<div style="padding: 10px; color: #666; font-size: 13px;">
|
||||
По запросу рейса <strong>${f.flightNumber || f.ident || '—'}</strong>${requestDepartureDate ? ` на ${requestDepartureDate}` : ''} данные не найдены.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightradar24">FlightRadar24</span>
|
||||
<span class="source-missing">Данные не получены</span>
|
||||
</div>
|
||||
<div class="source-content">
|
||||
<div style="padding: 10px; color: #666; font-size: 13px;">
|
||||
По запросу рейса <strong>${f.flightNumber || f.ident || '—'}</strong>${requestDepartureDate ? ` на ${requestDepartureDate}` : ''} данные не найдены.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let card = `
|
||||
<div class="flight-card">
|
||||
<div class="flight-header">
|
||||
<h2>Рейс ${f.flightNumber || f.ident || '—'}</h2>
|
||||
<span class="registration">${f.registration || '—'}</span>
|
||||
</div>
|
||||
<div class="flight-info">
|
||||
<div class="info-row">
|
||||
<span class="label">Тип самолёта:</span>
|
||||
<span class="value">${f.aircraftType || '—'}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Идентификатор:</span>
|
||||
<span class="value">${f.ident || '—'} (${f.identIata || '—'})</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Данные из FlightAware
|
||||
if (fa) {
|
||||
card += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightaware">FlightAware</span>
|
||||
</div>
|
||||
<div class="source-content">
|
||||
<div class="route-info">
|
||||
<div class="route-item">
|
||||
<span class="route-label">Откуда:</span>
|
||||
<span class="route-value">${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')})</span>
|
||||
</div>
|
||||
<div class="route-item">
|
||||
<span class="route-label">Куда:</span>
|
||||
<span class="route-value">${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Плановый вылет:</span>
|
||||
<span class="timeline-value">${safeDate(fa.scheduled_out)}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Фактический вылет:</span>
|
||||
<span class="timeline-value">${safeDate(fa.actual_out)}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Взлёт:</span>
|
||||
<span class="timeline-value">${safeDate(fa.actual_off)} ${fa.actual_runway_off ? `(ВПП ${fa.actual_runway_off})` : ''}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Посадка:</span>
|
||||
<span class="timeline-value">${safeDate(fa.actual_on)} ${fa.actual_runway_on ? `(ВПП ${fa.actual_runway_on})` : ''}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Фактический прилёт:</span>
|
||||
<span class="timeline-value">${safeDate(fa.actual_in)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-info">
|
||||
<div class="status-item">
|
||||
<span class="status-label">Статус:</span>
|
||||
<span class="status-value">${safeStr(fa.status || '—')}</span>
|
||||
</div>
|
||||
${fa.departure_delay !== null && fa.departure_delay !== undefined ? `
|
||||
<div class="status-item">
|
||||
<span class="status-label">Задержка вылета:</span>
|
||||
<span class="status-value ${fa.departure_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.departure_delay > 0 ? '+' : ''}${Math.floor(fa.departure_delay / 60)} мин</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${fa.arrival_delay !== null && fa.arrival_delay !== undefined ? `
|
||||
<div class="status-item">
|
||||
<span class="status-label">Задержка прилёта:</span>
|
||||
<span class="status-value ${fa.arrival_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.arrival_delay > 0 ? '+' : ''}${Math.floor(fa.arrival_delay / 60)} мин</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${fa.gate_origin ? `
|
||||
<div class="status-item">
|
||||
<span class="status-label">Гейт вылета:</span>
|
||||
<span class="status-value">${fa.gate_origin}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${fa.gate_destination ? `
|
||||
<div class="status-item">
|
||||
<span class="status-label">Гейт прилёта:</span>
|
||||
<span class="status-value">${fa.gate_destination}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${fa.baggage_claim ? `
|
||||
<div class="status-item">
|
||||
<span class="status-label">Выдача багажа:</span>
|
||||
<span class="status-value">${fa.baggage_claim}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
card += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightaware">FlightAware</span>
|
||||
<span class="source-missing">Данные не получены</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Данные из FlightRadar24
|
||||
if (fr) {
|
||||
card += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightradar24">FlightRadar24</span>
|
||||
</div>
|
||||
<div class="source-content">
|
||||
<div class="route-info">
|
||||
<div class="route-item">
|
||||
<span class="route-label">Откуда:</span>
|
||||
<span class="route-value">${safeStr(fr.orig_iata || '—')} (${safeStr(fr.orig_icao || '—')})</span>
|
||||
</div>
|
||||
<div class="route-item">
|
||||
<span class="route-label">Куда:</span>
|
||||
<span class="route-value">${safeStr(fr.dest_iata || '—')} (${safeStr(fr.dest_icao || '—')})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Взлёт:</span>
|
||||
<span class="timeline-value">${safeDate(fr.datetime_takeoff)} ${fr.runway_takeoff ? `(ВПП ${fr.runway_takeoff})` : ''}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Посадка:</span>
|
||||
<span class="timeline-value">${safeDate(fr.datetime_landed)} ${fr.runway_landed ? `(ВПП ${fr.runway_landed})` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-info">
|
||||
<div class="status-item">
|
||||
<span class="status-label">Время полёта:</span>
|
||||
<span class="status-value">${formatDuration(fr.flight_time)}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Фактическое расстояние:</span>
|
||||
<span class="status-value">${formatDistance(fr.actual_distance)}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Кратчайшее расстояние:</span>
|
||||
<span class="status-value">${formatDistance(fr.circle_distance)}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">Статус полёта:</span>
|
||||
<span class="status-value">${fr.flight_ended ? 'Завершён' : 'В процессе'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
card += `
|
||||
<div class="source-section">
|
||||
<div class="source-header">
|
||||
<span class="source-badge source-flightradar24">FlightRadar24</span>
|
||||
<span class="source-missing">Данные не получены</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
card += `</div>`;
|
||||
return card;
|
||||
};
|
||||
|
||||
// Генерация полного HTML
|
||||
const now = new Date();
|
||||
const reportDate = now.toLocaleString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Отчёт о рейсах</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.4; color: #333; background: #f5f5f5; padding: 15px; }
|
||||
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
.header { border-bottom: 3px solid #2563eb; padding-bottom: 8px; margin-bottom: 8px; }
|
||||
.header h1 { color: #1e40af; font-size: 24px; margin-bottom: 4px; }
|
||||
.header-meta { color: #666; font-size: 13px; }
|
||||
.sources-info { display: flex; gap: 10px; margin-top: 4px; flex-wrap: wrap; }
|
||||
.source-tag { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 500; }
|
||||
.source-tag.available { background: #d1fae5; color: #065f46; }
|
||||
.source-tag.unavailable { background: #fee2e2; color: #991b1b; }
|
||||
.flight-card { border: 1px solid #e5e7eb; border-radius: 8px; margin-bottom: 18px; overflow: hidden; background: white; }
|
||||
.flight-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 18px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.flight-header h2 { font-size: 20px; margin: 0; }
|
||||
.registration { background: rgba(255,255,255,0.2); padding: 4px 10px; border-radius: 4px; font-weight: 600; font-size: 13px; }
|
||||
.flight-info { padding: 12px 18px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; }
|
||||
.info-row { display: flex; margin-bottom: 6px; }
|
||||
.info-row:last-child { margin-bottom: 0; }
|
||||
.info-row .label { font-weight: 600; color: #4b5563; width: 140px; flex-shrink: 0; font-size: 13px; }
|
||||
.info-row .value { color: #111827; font-size: 13px; }
|
||||
.source-section { border-top: 1px solid #e5e7eb; padding: 12px 18px; }
|
||||
.source-section:first-of-type { border-top: none; }
|
||||
.source-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||
.source-badge { display: inline-block; padding: 5px 12px; border-radius: 5px; font-size: 12px; font-weight: 600; color: white; }
|
||||
.source-badge.source-flightaware { background: #3b82f6; }
|
||||
.source-badge.source-flightradar24 { background: #10b981; }
|
||||
.source-missing { color: #ef4444; font-size: 12px; font-style: italic; }
|
||||
.source-content { margin-left: 0; }
|
||||
.route-info { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; padding: 12px; background: #f9fafb; border-radius: 6px; }
|
||||
.route-item { display: flex; flex-direction: column; }
|
||||
.route-label { font-size: 11px; color: #6b7280; margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.route-value { font-size: 14px; font-weight: 600; color: #111827; }
|
||||
.timeline { margin-bottom: 12px; }
|
||||
.timeline-item { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #e5e7eb; }
|
||||
.timeline-item:last-child { border-bottom: none; }
|
||||
.timeline-label { font-weight: 500; color: #4b5563; width: 160px; flex-shrink: 0; font-size: 12px; }
|
||||
.timeline-value { color: #111827; text-align: right; font-size: 12px; }
|
||||
.status-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; padding: 12px; background: #f9fafb; border-radius: 6px; }
|
||||
.status-item { display: flex; flex-direction: column; }
|
||||
.status-label { font-size: 11px; color: #6b7280; margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.status-value { font-size: 13px; font-weight: 600; color: #111827; }
|
||||
.delay-negative { color: #10b981; }
|
||||
.delay-positive { color: #ef4444; }
|
||||
.no-data { text-align: center; padding: 40px 20px; color: #6b7280; font-size: 16px; }
|
||||
@media print { body { background: white; padding: 0; } .container { box-shadow: none; padding: 15px; } .flight-card { page-break-inside: avoid; margin-bottom: 15px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Отчёт о рейсах</h1>
|
||||
<div class="header-meta">
|
||||
<div>Дата формирования: ${reportDate}</div>
|
||||
<div class="sources-info">
|
||||
<span class="source-tag ${flightAwareData.length > 0 ? 'available' : 'unavailable'}">
|
||||
FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
|
||||
</span>
|
||||
<span class="source-tag ${flightRadar24Data.length > 0 ? 'available' : 'unavailable'}">
|
||||
FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flights-container">
|
||||
${flights.length ? flights.map(f => generateFlightCard(f, flightRadar24Error)).join('') : '<div class="no-data">Данные о рейсах не найдены</div>'}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// ================== HTML → BASE64 ==================
|
||||
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
|
||||
|
||||
// ================== RETURN ==================
|
||||
return [{
|
||||
json: {
|
||||
html_base64: htmlBase64,
|
||||
html: html,
|
||||
flights_count: flights.length,
|
||||
sources: {
|
||||
flightaware: { available: flightAwareData.length > 0, count: flightAwareData.length },
|
||||
flightradar24: { available: flightRadar24Data.length > 0, count: flightRadar24Data.length }
|
||||
},
|
||||
generated_at: now.toISOString()
|
||||
}
|
||||
}];
|
||||
320
docs/N8N_FLIGHTS_WORKFLOW_EXAMPLE.md
Normal file
320
docs/N8N_FLIGHTS_WORKFLOW_EXAMPLE.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# Пример Workflow для обработки рейсов с Base64 PDF
|
||||
|
||||
## Структура Workflow
|
||||
|
||||
```
|
||||
HTTP Request (FlightAware)
|
||||
↓
|
||||
HTTP Request (FlightRadar24)
|
||||
↓
|
||||
Code: Process Flights Data ← N8N_CODE_PROCESS_FLIGHTS_DATA.js
|
||||
↓
|
||||
Code: Prepare PDF Request ← N8N_FLIGHTS_PDF_BASE64_COMPLETE.js
|
||||
↓
|
||||
HTTP Request (Convert to PDF) ← Внешний сервис конвертации
|
||||
↓
|
||||
Code: Extract Base64 PDF ← N8N_FLIGHTS_PDF_BASE64_FULL.js
|
||||
↓
|
||||
[Использование base64 PDF]
|
||||
├─→ Save File
|
||||
├─→ Send Email
|
||||
├─→ Upload to S3
|
||||
└─→ Return in API Response
|
||||
```
|
||||
|
||||
## Детальная настройка нод
|
||||
|
||||
### 1. HTTP Request: FlightAware
|
||||
- **Method:** GET/POST (в зависимости от API)
|
||||
- **URL:** `https://flightaware.com/api/...`
|
||||
- **Authentication:** По необходимости
|
||||
|
||||
### 2. HTTP Request: FlightRadar24
|
||||
- **Method:** GET/POST (в зависимости от API)
|
||||
- **URL:** `https://flightradar24.com/api/...`
|
||||
- **Authentication:** По необходимости
|
||||
|
||||
### 3. Code: Process Flights Data
|
||||
**Код:** Скопируйте из `N8N_CODE_PROCESS_FLIGHTS_DATA.js`
|
||||
|
||||
**Входные данные:**
|
||||
- Два элемента из предыдущих HTTP Request нод
|
||||
|
||||
**Выходные данные:**
|
||||
```json
|
||||
{
|
||||
"html": "<!DOCTYPE html>...",
|
||||
"flights": [...],
|
||||
"flights_count": 2,
|
||||
"sources": {...},
|
||||
"generated_at": "2026-01-14T..."
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Code: Prepare PDF Request
|
||||
**Код:** Скопируйте из `N8N_FLIGHTS_PDF_BASE64_COMPLETE.js`
|
||||
|
||||
**Настройка:**
|
||||
- Замените `PDF_SERVICE_URL` на URL вашего сервиса
|
||||
- Замените `PDF_API_KEY` на ваш API ключ
|
||||
|
||||
**Выходные данные:**
|
||||
```json
|
||||
{
|
||||
"http_method": "POST",
|
||||
"http_url": "https://api.htmlpdfapi.com/v1/pdf",
|
||||
"http_headers": {...},
|
||||
"http_body": "{...}",
|
||||
"html_length": 12345,
|
||||
"flights_count": 2
|
||||
}
|
||||
```
|
||||
|
||||
### 5. HTTP Request: Convert to PDF
|
||||
**Настройка:**
|
||||
- **Method:** `{{ $json.http_method }}`
|
||||
- **URL:** `{{ $json.http_url }}`
|
||||
- **Authentication:** По необходимости (через Headers)
|
||||
- **Headers:**
|
||||
```json
|
||||
{{ $json.http_headers }}
|
||||
```
|
||||
- **Body:**
|
||||
```json
|
||||
{{ $json.http_body }}
|
||||
```
|
||||
- **Response Format:** `JSON` или `Binary` (зависит от сервиса)
|
||||
|
||||
**Пример для htmlpdfapi.com:**
|
||||
```json
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "https://api.htmlpdfapi.com/v1/pdf",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer YOUR_API_KEY"
|
||||
},
|
||||
"body": {
|
||||
"html": "{{ $('Code: Process Flights Data').first().json.html }}",
|
||||
"options": {
|
||||
"format": "A4",
|
||||
"printBackground": true
|
||||
},
|
||||
"base64": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Code: Extract Base64 PDF
|
||||
**Код:** Скопируйте из `N8N_FLIGHTS_PDF_BASE64_FULL.js`
|
||||
|
||||
**Входные данные:**
|
||||
- Ответ от HTTP Request ноды (JSON или Binary)
|
||||
|
||||
**Выходные данные:**
|
||||
```json
|
||||
{
|
||||
"pdf_base64": "JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9MZW5ndGg...",
|
||||
"pdf_size_bytes": 123456,
|
||||
"pdf_size_mb": "0.12",
|
||||
"success": true,
|
||||
"source": "json_response"
|
||||
}
|
||||
```
|
||||
|
||||
## Использование base64 PDF
|
||||
|
||||
### Вариант A: Сохранение в файл
|
||||
|
||||
**Code Node:**
|
||||
```javascript
|
||||
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
|
||||
const filename = `flights-report-${new Date().toISOString().split('T')[0]}.pdf`;
|
||||
|
||||
// Конвертируем base64 в binary
|
||||
const pdfBuffer = Buffer.from(base64, 'base64');
|
||||
|
||||
return [{
|
||||
binary: {
|
||||
data: pdfBuffer,
|
||||
fileName: filename,
|
||||
mimeType: 'application/pdf'
|
||||
},
|
||||
json: {
|
||||
filename: filename,
|
||||
size_bytes: pdfBuffer.length
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
Затем используйте ноду **Write Binary File** или **Save to S3**.
|
||||
|
||||
### Вариант B: Отправка по Email
|
||||
|
||||
**Code Node:**
|
||||
```javascript
|
||||
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
|
||||
const pdfBuffer = Buffer.from(base64, 'base64');
|
||||
|
||||
return [{
|
||||
json: {
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Отчёт о рейсах',
|
||||
text: 'Во вложении отчёт о рейсах.',
|
||||
attachments: [{
|
||||
filename: 'flights-report.pdf',
|
||||
content: pdfBuffer,
|
||||
contentType: 'application/pdf'
|
||||
}]
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
Затем используйте ноду **Email Send**.
|
||||
|
||||
### Вариант C: Возврат в API Response
|
||||
|
||||
**Code Node (перед Response нодой):**
|
||||
```javascript
|
||||
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
|
||||
const processedData = $('Code: Process Flights Data').first().json;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
success: true,
|
||||
flights_count: processedData.flights_count,
|
||||
pdf_base64: base64,
|
||||
pdf_size_mb: $('Code: Extract Base64 PDF').first().json.pdf_size_mb,
|
||||
generated_at: processedData.generated_at
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
### Вариант D: Загрузка в S3/Nextcloud
|
||||
|
||||
**Code Node:**
|
||||
```javascript
|
||||
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
|
||||
const pdfBuffer = Buffer.from(base64, 'base64');
|
||||
const filename = `flights-report-${new Date().toISOString().split('T')[0]}.pdf`;
|
||||
|
||||
return [{
|
||||
binary: {
|
||||
data: pdfBuffer,
|
||||
fileName: filename,
|
||||
mimeType: 'application/pdf'
|
||||
},
|
||||
json: {
|
||||
bucket: 'your-bucket',
|
||||
key: `reports/${filename}`,
|
||||
contentType: 'application/pdf'
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
Затем используйте ноду **S3 Upload** или **Nextcloud Upload**.
|
||||
|
||||
## Альтернативные сервисы конвертации
|
||||
|
||||
### 1. htmlpdfapi.com
|
||||
```javascript
|
||||
{
|
||||
"url": "https://api.htmlpdfapi.com/v1/pdf",
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer YOUR_API_KEY"
|
||||
},
|
||||
"body": {
|
||||
"html": "{{ HTML }}",
|
||||
"base64": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. pdfshift.io
|
||||
```javascript
|
||||
{
|
||||
"url": "https://api.pdfshift.io/v3/convert/pdf",
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Basic " + Buffer.from("api:YOUR_API_KEY").toString("base64")
|
||||
},
|
||||
"body": {
|
||||
"source": "{{ HTML }}",
|
||||
"format": "A4"
|
||||
}
|
||||
}
|
||||
// Ответ содержит base64 в поле "pdf"
|
||||
```
|
||||
|
||||
### 3. api2pdf.com
|
||||
```javascript
|
||||
{
|
||||
"url": "https://v2.api2pdf.com/chrome/html",
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"Authorization": "YOUR_API_KEY",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"body": {
|
||||
"html": "{{ HTML }}",
|
||||
"inlinePdf": true,
|
||||
"fileName": "flights-report.pdf"
|
||||
}
|
||||
}
|
||||
// Ответ содержит base64 в поле "pdf"
|
||||
```
|
||||
|
||||
### 4. Self-hosted: Gotenberg
|
||||
```javascript
|
||||
{
|
||||
"url": "http://your-gotenberg-server:3000/forms/chromium/convert/html",
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"Content-Type": "multipart/form-data"
|
||||
},
|
||||
"body": {
|
||||
"files": [{
|
||||
"name": "index.html",
|
||||
"content": "{{ HTML }}"
|
||||
}]
|
||||
}
|
||||
}
|
||||
// Ответ - binary PDF, конвертируем в base64
|
||||
```
|
||||
|
||||
## Отладка
|
||||
|
||||
### Проверка HTML
|
||||
```javascript
|
||||
const html = $('Code: Process Flights Data').first().json.html;
|
||||
console.log('HTML length:', html.length);
|
||||
console.log('HTML preview:', html.substring(0, 500));
|
||||
```
|
||||
|
||||
### Проверка base64
|
||||
```javascript
|
||||
const base64 = $('Code: Extract Base64 PDF').first().json.pdf_base64;
|
||||
console.log('Base64 length:', base64.length);
|
||||
console.log('Base64 preview:', base64.substring(0, 100));
|
||||
```
|
||||
|
||||
### Проверка размера PDF
|
||||
```javascript
|
||||
const data = $('Code: Extract Base64 PDF').first().json;
|
||||
console.log('PDF size:', data.pdf_size_mb, 'MB');
|
||||
console.log('PDF size bytes:', data.pdf_size_bytes);
|
||||
```
|
||||
|
||||
## Обработка ошибок
|
||||
|
||||
Добавьте IF Node после HTTP Request для проверки успешности:
|
||||
|
||||
```javascript
|
||||
// IF Node: Check PDF Conversion Success
|
||||
{{ $json.success === true }}
|
||||
```
|
||||
|
||||
Если ошибка - отправьте уведомление или сохраните HTML для ручной конвертации.
|
||||
140
docs/N8N_FLIGHTS_WORKING_SOLUTION.md
Normal file
140
docs/N8N_FLIGHTS_WORKING_SOLUTION.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# ✅ Рабочее решение: Обработка данных о рейсах → PDF
|
||||
|
||||
## Структура Workflow
|
||||
|
||||
```
|
||||
[Входные данные: FlightAware + FlightRadar24]
|
||||
↓
|
||||
[Code: причесываем данные] ← Генерирует HTML и конвертирует в base64
|
||||
↓
|
||||
[HTTP Request: Browserless PDF] ← Конвертирует HTML в PDF через браузер
|
||||
↓
|
||||
[Результат: PDF binary]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Нода 1: Code - "причесываем данные"
|
||||
|
||||
**Тип:** Code (JavaScript)
|
||||
|
||||
**Код:** См. файл `N8N_FLIGHTS_TO_BASE64.js`
|
||||
|
||||
**Что делает:**
|
||||
1. Извлекает данные из структуры `[{ data: [{ body: { flights: [...] }}, { body: { data: [...] }}] }]`
|
||||
2. Объединяет рейсы по `registration` (номер самолёта)
|
||||
3. Генерирует красивый HTML с CSS
|
||||
4. Конвертирует HTML в base64
|
||||
|
||||
**Выходные данные:**
|
||||
```json
|
||||
{
|
||||
"html_base64": "PCFET0NUWVBFIGh0bWw+...",
|
||||
"html": "<!DOCTYPE html>...",
|
||||
"flights_count": 2,
|
||||
"sources": {
|
||||
"flightaware": { "available": true, "count": 2 },
|
||||
"flightradar24": { "available": true, "count": 2 }
|
||||
},
|
||||
"generated_at": "2026-01-16T07:23:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Нода 2: HTTP Request - "Browserless PDF"
|
||||
|
||||
**Тип:** HTTP Request
|
||||
|
||||
**Настройки:**
|
||||
|
||||
- **Method:** `POST`
|
||||
- **URL:** `http://147.45.146.17:3000/pdf?token=9ahhnpjkchxtcho9`
|
||||
- **Send Body:** ✅ Да
|
||||
- **Specify Body:** `JSON`
|
||||
- **JSON Body:**
|
||||
```json
|
||||
{
|
||||
"url": "data:text/html;base64, {{ $json.html_base64 }}",
|
||||
"options": {
|
||||
"format": "A4",
|
||||
"printBackground": true,
|
||||
"margin": {
|
||||
"top": "20mm",
|
||||
"right": "15mm",
|
||||
"bottom": "20mm",
|
||||
"left": "20mm"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response Format:** `Binary` (или `JSON`, если Browserless возвращает JSON)
|
||||
|
||||
---
|
||||
|
||||
## Результат
|
||||
|
||||
HTTP Request нода вернёт PDF в binary формате, который можно:
|
||||
- Сохранить в файл
|
||||
- Отправить по email
|
||||
- Загрузить в S3/Nextcloud
|
||||
- Конвертировать в base64 для API response
|
||||
|
||||
---
|
||||
|
||||
## Конвертация Binary PDF → Base64 (опционально)
|
||||
|
||||
Если нужен base64 PDF, добавьте Code Node после HTTP Request:
|
||||
|
||||
```javascript
|
||||
const pdfBinary = $binary.data;
|
||||
const base64 = Buffer.isBuffer(pdfBinary)
|
||||
? pdfBinary.toString('base64')
|
||||
: Buffer.from(pdfBinary).toString('base64');
|
||||
|
||||
const sizeBytes = Buffer.from(base64, 'base64').length;
|
||||
|
||||
return [{
|
||||
json: {
|
||||
pdf_base64: base64,
|
||||
pdf_size_bytes: sizeBytes,
|
||||
pdf_size_mb: (sizeBytes / (1024 * 1024)).toFixed(2),
|
||||
success: true
|
||||
}
|
||||
}];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Преимущества решения
|
||||
|
||||
✅ **Простота** - всего 2 ноды
|
||||
✅ **Надёжность** - Browserless использует реальный браузер
|
||||
✅ **Качество** - PDF с правильным форматированием и стилями
|
||||
✅ **Гибкость** - можно легко изменить параметры PDF (формат, отступы)
|
||||
|
||||
---
|
||||
|
||||
## Отладка
|
||||
|
||||
Если что-то не работает:
|
||||
|
||||
1. **Проверьте HTML** - в Code Node добавьте:
|
||||
```javascript
|
||||
console.log('HTML length:', html.length);
|
||||
console.log('HTML preview:', html.substring(0, 200));
|
||||
```
|
||||
|
||||
2. **Проверьте base64** - в Code Node добавьте:
|
||||
```javascript
|
||||
console.log('Base64 length:', htmlBase64.length);
|
||||
```
|
||||
|
||||
3. **Проверьте ответ Browserless** - в HTTP Request включите "Always Output Data" и проверьте ответ
|
||||
|
||||
---
|
||||
|
||||
## Готово! 🎉
|
||||
|
||||
Workflow работает и генерирует красивые PDF отчёты о рейсах!
|
||||
53
docs/N8N_FLIGHTS_WORKING_WORKFLOW.json
Normal file
53
docs/N8N_FLIGHTS_WORKING_WORKFLOW.json
Normal file
File diff suppressed because one or more lines are too long
102
docs/N8N_HTML_TO_BASE64_PDF_SIMPLE.js
Normal file
102
docs/N8N_HTML_TO_BASE64_PDF_SIMPLE.js
Normal file
@@ -0,0 +1,102 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: HTML → Base64 PDF (простой вариант)
|
||||
// ============================================================================
|
||||
// Используйте этот код ПОСЛЕ ноды, которая вернула HTML
|
||||
// Этот код подготовит запрос для HTTP Request ноды
|
||||
// ============================================================================
|
||||
|
||||
// Получаем HTML из предыдущей ноды
|
||||
// Если HTML пришёл в поле "html", используем его
|
||||
const html = $json.html || $json.body?.html || $json;
|
||||
|
||||
if (!html || typeof html !== 'string') {
|
||||
throw new Error('HTML не найден в входных данных. Проверьте структуру данных.');
|
||||
}
|
||||
|
||||
console.log('📄 HTML получен, длина:', html.length);
|
||||
|
||||
// ==== НАСТРОЙКИ СЕРВИСА КОНВЕРТАЦИИ ====
|
||||
// Выберите один из вариантов ниже и раскомментируйте его
|
||||
|
||||
// ==== ВАРИАНТ 1: htmlpdfapi.com (рекомендуется) ====
|
||||
// Бесплатный план: 100 PDF в месяц
|
||||
// URL: https://htmlpdfapi.com
|
||||
return [{
|
||||
json: {
|
||||
// Данные для HTTP Request ноды
|
||||
method: 'POST',
|
||||
url: 'https://api.htmlpdfapi.com/v1/pdf',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer YOUR_API_KEY' // ⚠️ ЗАМЕНИТЕ на ваш API ключ
|
||||
},
|
||||
body: JSON.stringify({
|
||||
html: html,
|
||||
options: {
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '20mm',
|
||||
right: '15mm',
|
||||
bottom: '20mm',
|
||||
left: '15mm'
|
||||
}
|
||||
},
|
||||
base64: true // Запрашиваем base64 напрямую
|
||||
})
|
||||
}
|
||||
}];
|
||||
|
||||
// ==== ВАРИАНТ 2: pdfshift.io ====
|
||||
// Раскомментируйте, если используете pdfshift.io
|
||||
/*
|
||||
return [{
|
||||
json: {
|
||||
method: 'POST',
|
||||
url: 'https://api.pdfshift.io/v3/convert/pdf',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Basic ' + Buffer.from('api:YOUR_API_KEY').toString('base64')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source: html,
|
||||
format: 'A4',
|
||||
margin: '20mm'
|
||||
})
|
||||
}
|
||||
}];
|
||||
*/
|
||||
|
||||
// ==== ВАРИАНТ 3: api2pdf.com ====
|
||||
// Раскомментируйте, если используете api2pdf.com
|
||||
/*
|
||||
return [{
|
||||
json: {
|
||||
method: 'POST',
|
||||
url: 'https://v2.api2pdf.com/chrome/html',
|
||||
headers: {
|
||||
'Authorization': 'YOUR_API_KEY',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
html: html,
|
||||
inlinePdf: true,
|
||||
fileName: 'flights-report.pdf'
|
||||
})
|
||||
}
|
||||
}];
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// ИНСТРУКЦИЯ:
|
||||
// ============================================================================
|
||||
// 1. Этот Code Node подготавливает запрос
|
||||
// 2. Добавьте HTTP Request ноду после этого Code Node
|
||||
// 3. В HTTP Request ноде настройте:
|
||||
// - Method: {{ $json.method }}
|
||||
// - URL: {{ $json.url }}
|
||||
// - Headers: {{ $json.headers }}
|
||||
// - Body: {{ $json.body }}
|
||||
// 4. После HTTP Request добавьте Code Node с кодом из N8N_EXTRACT_BASE64_FROM_RESPONSE.js
|
||||
// для извлечения base64 из ответа
|
||||
// ============================================================================
|
||||
62
docs/N8N_PARSE_INIT_DATA.js
Normal file
62
docs/N8N_PARSE_INIT_DATA.js
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* n8n Code node: парсинг сырого init_data из Telegram WebApp
|
||||
*
|
||||
* Вход: объект с полем init_data (строка query string от Telegram).
|
||||
* Выход: тот же объект + поля init_data_parsed и user_decoded.
|
||||
*
|
||||
* Подключение: после Webhook — в Code передаётся $input.item.json.
|
||||
* init_data должен быть в $json.init_data (как шлёт наш бэкенд).
|
||||
*/
|
||||
|
||||
const item = $input.first().json;
|
||||
|
||||
// Сырая строка init_data (query string)
|
||||
const rawInitData = item.init_data || item.body?.init_data || '';
|
||||
|
||||
if (!rawInitData) {
|
||||
return [{ json: { ...item, init_data_error: 'init_data отсутствует' } }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит query string в объект (значения URL-декодированы)
|
||||
*/
|
||||
function parseQueryString(qs) {
|
||||
const result = {};
|
||||
const pairs = qs.split('&');
|
||||
for (const pair of pairs) {
|
||||
const [key, value] = pair.split('=').map(s => decodeURIComponent(s || ''));
|
||||
if (key) result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const parsed = parseQueryString(rawInitData);
|
||||
|
||||
// user приходит как URL-encoded JSON строка
|
||||
let userDecoded = null;
|
||||
if (parsed.user) {
|
||||
try {
|
||||
userDecoded = JSON.parse(parsed.user);
|
||||
} catch (e) {
|
||||
userDecoded = { _parse_error: String(e), raw: parsed.user };
|
||||
}
|
||||
}
|
||||
|
||||
return [{
|
||||
json: {
|
||||
...item,
|
||||
init_data_parsed: {
|
||||
query_id: parsed.query_id || null,
|
||||
auth_date: parsed.auth_date ? parseInt(parsed.auth_date, 10) : null,
|
||||
hash: parsed.hash || null,
|
||||
signature: parsed.signature || null,
|
||||
user_raw: parsed.user || null,
|
||||
},
|
||||
user_decoded: userDecoded,
|
||||
// удобные поля для маппинга в CRM
|
||||
telegram_user_id: userDecoded?.id ?? null,
|
||||
telegram_username: userDecoded?.username ?? null,
|
||||
telegram_first_name: userDecoded?.first_name ?? null,
|
||||
telegram_last_name: userDecoded?.last_name ?? null,
|
||||
},
|
||||
}];
|
||||
31
docs/N8N_PROFILE_CONTACT_WEBHOOK_RESPONSE.md
Normal file
31
docs/N8N_PROFILE_CONTACT_WEBHOOK_RESPONSE.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Профиль: ответ N8N_CONTACT_WEBHOOK из SQL
|
||||
|
||||
## Цепочка в n8n
|
||||
|
||||
1. **Webhook** (POST) — получает от бэкенда `unified_id`, `entry_channel`, `chat_id`, `session_token`, `contact_id`, `phone`.
|
||||
2. **SQL** — по `unified_id`/`contact_id` выбирает контакт из БД. Возвращает массив строк в формате:
|
||||
- `contactid`, `firstname`, `lastname`, `email`, `mobile`, `phone`, `birthday`, `mailingstreet`, `middle_name`, `birthplace`, `inn`, `verification`, `bank`
|
||||
3. **Code** — преобразует строки в JSON для ответа вебхука (см. `N8N_CODE_PROFILE_CONTACT_RESPONSE.js`).
|
||||
4. **Respond to Webhook** — отдаёт ответ клиенту (тело = вывод Code).
|
||||
|
||||
## Формат ответа
|
||||
|
||||
- **Ничего не нашли:** вернуть **HTTP 200** и тело `{ "items": [] }`.
|
||||
- **Нашли контакт(ы):** **HTTP 200** и тело `{ "items": [ { ...поля в snake_case... } ] }`.
|
||||
|
||||
Поля контакта (уже в формате мини-апа после Code):
|
||||
|
||||
- `last_name`, `first_name`, `middle_name`
|
||||
- `birth_date`, `birth_place`
|
||||
- `inn`, `email`, `phone`
|
||||
- `registration_address` (в SQL: `mailingstreet` — адрес регистрации)
|
||||
- `mailing_address`, `bank_for_compensation`
|
||||
|
||||
## Подстановка Code-ноды
|
||||
|
||||
- Скопировать код из `aiform_prod/docs/N8N_CODE_PROFILE_CONTACT_RESPONSE.js` в ноду **Code**.
|
||||
- Вход Code — вывод SQL (один item с массивом в `json` или несколько items по одному контакту).
|
||||
- Выход Code — один item с `{ "items": [ ... ] }`.
|
||||
- В **Respond to Webhook** указать: ответить телом из предыдущей ноды (всё из Code), чтобы в ответ ушёл именно `{ "items": [...] }`.
|
||||
|
||||
Если SQL не нашёл строк — перед Code добавьте условие (IF): при пустом результате отдавать в Respond to Webhook тело `{ "items": [] }` и статус 200.
|
||||
147
docs/N8N_WORKFLOW_STUCK_FIX.md
Normal file
147
docs/N8N_WORKFLOW_STUCK_FIX.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# 🔧 Решение проблемы зависших n8n workflow
|
||||
|
||||
## 🐛 Проблема
|
||||
|
||||
Workflow в n8n зависает и не может быть перезапущен даже через интерфейс. Redis Trigger node теряет соединение и не переподключается автоматически.
|
||||
|
||||
## ✅ Что сделано
|
||||
|
||||
### 1. Улучшена логика перезапуска workflow
|
||||
|
||||
**Файл:** `backend/app/services/n8n_service.py`
|
||||
|
||||
**Изменения:**
|
||||
- ✅ Увеличены таймауты с 10 до 30 секунд (общий) и 15 секунд (для отдельных операций)
|
||||
- ✅ Добавлена обработка таймаутов при деактивации (продолжаем даже если деактивация зависла)
|
||||
- ✅ Увеличена задержка между деактивацией и активацией (3 секунды вместо 2)
|
||||
- ✅ Добавлена дополнительная задержка после активации для инициализации trigger node
|
||||
- ✅ Улучшено логирование ошибок с полным traceback
|
||||
|
||||
### 2. Улучшена проверка и перезапуск в фоне
|
||||
|
||||
**Файл:** `backend/app/api/claims.py`
|
||||
|
||||
**Изменения:**
|
||||
- ✅ Добавлены повторные попытки перезапуска (до 2 попыток)
|
||||
- ✅ Добавлена проверка подписчиков после перезапуска
|
||||
- ✅ Улучшено логирование процесса перезапуска
|
||||
|
||||
## 🚀 Как это работает
|
||||
|
||||
1. **При публикации сообщения в Redis:**
|
||||
- Проверяется количество подписчиков
|
||||
- Если подписчиков нет → сообщение сохраняется в буфер
|
||||
- Запускается фоновая задача перезапуска workflow
|
||||
|
||||
2. **Процесс перезапуска:**
|
||||
- Проверяется Redis lock (защита от частых перезапусков)
|
||||
- Проверяется статус workflow через API
|
||||
- Деактивируется workflow (даже если завис)
|
||||
- Ждёт 3 секунды
|
||||
- Активирует workflow
|
||||
- Ждёт 2 секунды для инициализации
|
||||
- Проверяет подписчиков
|
||||
- Отправляет сообщения из буфера
|
||||
|
||||
3. **Повторные попытки:**
|
||||
- Если первая попытка не удалась → повтор через 5 секунд
|
||||
- Максимум 2 попытки
|
||||
|
||||
## 📊 Мониторинг
|
||||
|
||||
### Проверка подписчиков вручную:
|
||||
|
||||
```bash
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PUBSUB NUMSUB "ticket_form:description"
|
||||
```
|
||||
|
||||
### Проверка статуса workflow:
|
||||
|
||||
```bash
|
||||
curl -H "X-N8N-API-KEY: ..." "https://n8n.clientright.pro/api/v1/workflows/b4K4u851b4JFivyD" | jq '.active'
|
||||
```
|
||||
|
||||
### Логи backend:
|
||||
|
||||
```bash
|
||||
tail -f /var/www/fastuser/data/www/crm.clientright.ru/ticket_form/backend.log | grep -i "workflow\|redis\|subscriber"
|
||||
```
|
||||
|
||||
## 🛠️ Если проблема повторится
|
||||
|
||||
### Вариант 1: Перезапуск через API (автоматически)
|
||||
|
||||
Код теперь автоматически пытается перезапустить workflow при обнаружении проблемы.
|
||||
|
||||
### Вариант 2: Ручной перезапуск через API
|
||||
|
||||
```bash
|
||||
# Деактивировать
|
||||
curl -X POST -H "X-N8N-API-KEY: ..." \
|
||||
"https://n8n.clientright.pro/api/v1/workflows/b4K4u851b4JFivyD/deactivate"
|
||||
|
||||
# Подождать 5 секунд
|
||||
sleep 5
|
||||
|
||||
# Активировать
|
||||
curl -X POST -H "X-N8N-API-KEY: ..." \
|
||||
"https://n8n.clientright.pro/api/v1/workflows/b4K4u851b4JFivyD/activate"
|
||||
```
|
||||
|
||||
### Вариант 3: Перезапуск n8n (крайний случай)
|
||||
|
||||
Если workflow всё ещё завис, может потребоваться перезапуск самого n8n:
|
||||
|
||||
```bash
|
||||
# Если n8n в Docker
|
||||
docker restart <n8n_container>
|
||||
|
||||
# Если n8n как системный сервис
|
||||
systemctl restart n8n
|
||||
```
|
||||
|
||||
## 🔍 Диагностика
|
||||
|
||||
### Проверка что workflow активен но не слушает:
|
||||
|
||||
```bash
|
||||
# 1. Проверить статус workflow
|
||||
curl -H "X-N8N-API-KEY: ..." \
|
||||
"https://n8n.clientright.pro/api/v1/workflows/b4K4u851b4JFivyD" | jq '{active: .active, updatedAt: .updatedAt}'
|
||||
|
||||
# 2. Проверить подписчиков
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a "..." PUBSUB NUMSUB "ticket_form:description"
|
||||
|
||||
# 3. Если active=true но подписчиков 0 → workflow завис
|
||||
```
|
||||
|
||||
### Проверка Redis соединений:
|
||||
|
||||
```bash
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a "..." CLIENT LIST | grep "sub=1"
|
||||
```
|
||||
|
||||
## 📝 Рекомендации на будущее
|
||||
|
||||
1. **Мониторинг:**
|
||||
- Настроить автоматический мониторинг подписчиков (cron каждые 5 минут)
|
||||
- Алерты при отсутствии подписчиков более 10 минут
|
||||
|
||||
2. **Автоматический перезапуск n8n:**
|
||||
- Настроить health check для n8n
|
||||
- Автоматический перезапуск при обнаружении проблем
|
||||
|
||||
3. **Логирование:**
|
||||
- Включить детальное логирование в n8n
|
||||
- Мониторинг логов на ошибки Redis соединений
|
||||
|
||||
4. **Настройка Redis:**
|
||||
- Увеличить `tcp-keepalive` для стабильности соединений
|
||||
- Настроить `timeout` для неактивных соединений
|
||||
|
||||
## 🔗 Связанные файлы
|
||||
|
||||
- `backend/app/services/n8n_service.py` - логика перезапуска workflow
|
||||
- `backend/app/api/claims.py` - проверка подписчиков и запуск перезапуска
|
||||
- `docs/N8N_REDIS_TRIGGER_TROUBLESHOOTING.md` - общая диагностика Redis Trigger
|
||||
- `docs/N8N_MEMORY_ISSUES.md` - проблемы с памятью в n8n
|
||||
95
docs/PROFILE_AND_N8N_CONTACT_WEBHOOK.md
Normal file
95
docs/PROFILE_AND_N8N_CONTACT_WEBHOOK.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Профиль пользователя и контакт-вебхук (N8N_CONTACT_WEBHOOK)
|
||||
|
||||
Описание изменений: раздел «Профиль» в мини-апе, передача `chat_id` в n8n, формат ответа вебхука и Code-нода для формирования JSON из SQL.
|
||||
|
||||
---
|
||||
|
||||
## 1. Раздел «Профиль» в мини-апе
|
||||
|
||||
- **Роут:** `/profile` (фронт), кнопка «Профиль» в нижней панели ведёт на него без перезагрузки.
|
||||
- **API:** `GET/POST /api/v1/profile/contact` — по `session_token` (или `unified_id`) запрашивает контактные данные из CRM через n8n-вебхук `N8N_CONTACT_WEBHOOK`.
|
||||
- **Фронт:** страница `Profile.tsx` показывает поля: фамилия, имя, отчество, дата/место рождения, ИНН, email, адрес регистрации, почтовый адрес, банк для возмещения, мобильный телефон. Поддерживаются snake_case и camelCase из ответа.
|
||||
|
||||
---
|
||||
|
||||
## 2. Конфиг и бэкенд
|
||||
|
||||
- **config.py:** добавлена настройка `n8n_contact_webhook` из переменной окружения `N8N_CONTACT_WEBHOOK`.
|
||||
- **main.py:** подключён роутер `profile`.
|
||||
- **profile.py:** реализованы `GET/POST /api/v1/profile/contact`, верификация сессии по `session_token`, сборка тела запроса к вебхуку и нормализация ответа n8n в формат `{ "items": [...] }`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Передача chat_id (Telegram / Max user id)
|
||||
|
||||
- **Сессия (session.py):**
|
||||
- В `SessionCreateRequest` добавлено опциональное поле `chat_id`.
|
||||
- При создании сессии в Redis сохраняется `chat_id`, если передан.
|
||||
- В `SessionVerifyResponse` и в ответе `verify_session` возвращается `chat_id`.
|
||||
|
||||
- **Где передаётся chat_id при создании сессии:**
|
||||
- **auth2 (TG):** `chat_id = str(tg_user["telegram_user_id"])`.
|
||||
- **auth2 (MAX):** `chat_id = str(max_user["max_user_id"])`.
|
||||
- **telegram_auth:** `chat_id = str(telegram_user_id)`.
|
||||
- **max_auth:** `chat_id = str(max_user_id)`.
|
||||
- SMS-флоу: `chat_id` не передаётся.
|
||||
|
||||
- **Профиль (profile.py):**
|
||||
- В запрос к API добавлен параметр `chat_id` (query/body).
|
||||
- При верификации сессии `chat_id` подставляется из сессии, если не передан явно.
|
||||
- В теле POST на `N8N_CONTACT_WEBHOOK` всегда добавляется поле `chat_id` (строка), когда оно известно.
|
||||
|
||||
- **Фронт (Profile.tsx):**
|
||||
- При запросе профиля передаётся `chat_id`: из `Telegram.WebApp.initDataUnsafe?.user?.id` или из `WebApp.initDataUnsafe?.user?.id` (MAX).
|
||||
|
||||
---
|
||||
|
||||
## 4. Формат запроса на N8N_CONTACT_WEBHOOK
|
||||
|
||||
**Тело POST от бэкенда к n8n:**
|
||||
|
||||
- `unified_id` (str) — идентификатор в CRM
|
||||
- `entry_channel` (str) — `"telegram"` | `"max"` | `"web"`
|
||||
- `chat_id` (str, опционально) — Telegram user id или Max user id
|
||||
- `session_token`, `contact_id`, `phone` (опционально)
|
||||
|
||||
---
|
||||
|
||||
## 5. Формат ответа из n8n (как возвращать и как маппится)
|
||||
|
||||
**Ничего не нашли:** HTTP 200, тело: `[]` или `{ "items": [] }`.
|
||||
|
||||
**Нашли контакт(ы):** HTTP 200, тело одно из:
|
||||
|
||||
- массив `[{...}, ...]` → нормализуется в `{ "items": [...] }`;
|
||||
- `{ "items": [...] }` — без изменений;
|
||||
- `{ "contact": {...} }` / `{ "contact": [...] }` → в `items`;
|
||||
- `{ "data": [...] }` → в `items`;
|
||||
- один объект `{...}` → `{ "items": [{...}] }`;
|
||||
- пустой объект `{}` → `{ "items": [] }`.
|
||||
|
||||
**Поля контакта** (snake_case или camelCase):
|
||||
`last_name`, `first_name`, `middle_name`, `birth_date`, `birth_place`, `inn`, `email`, `registration_address`, `mailing_address`, `bank_for_compensation`, `phone`.
|
||||
|
||||
Подробности и маппинг полей описаны в докстринге модуля `backend/app/api/profile.py`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Code-нода n8n для ответа вебхука из SQL
|
||||
|
||||
- **Файл:** `docs/N8N_CODE_PROFILE_CONTACT_RESPONSE.js`
|
||||
- **Назначение:** после ноды **select_user1** (SQL) формирует JSON для ответа вебхука.
|
||||
- **Вход:** данные из ноды `select_user1` (массив строк с полями contactid, firstname, lastname, email, mobile, phone, birthday, mailingstreet, middle_name, birthplace, inn, bank и т.д.).
|
||||
- **Выход:** один item с `{ "items": [ {...}, ... ] }` в формате полей для мини-апа (snake_case). Пустой результат → `{ "items": [] }`.
|
||||
- **Маппинг:** mailingstreet → registration_address, birthday → birth_date, birthplace → birth_place, bank → bank_for_compensation, mobile/phone → phone и т.д.
|
||||
|
||||
Инструкция по цепочке Webhook → SQL → Code → Respond to Webhook: `docs/N8N_PROFILE_CONTACT_WEBHOOK_RESPONSE.md`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Прочие изменения (в рамках той же задачи)
|
||||
|
||||
- События SSE: единый формат `event_type` + `message`, цвета по типу (trash_message, out_of_scope, consumer_consultation, consumer_complaint), не показывать «Подключено к событиям» как ответ, не перезаписывать consumer_consultation в out_of_scope.
|
||||
- Кнопка «Домой» — программная навигация на главную.
|
||||
- Закрытие приложения при `need_contact` от вебхука (повторный вызов close, fallback без initData).
|
||||
- Передача в контакт-хук: unified_id, entry_channel, session_token, contact_id, phone, chat_id.
|
||||
36
docs/SUPPORT_FEATURE_SUMMARY.md
Normal file
36
docs/SUPPORT_FEATURE_SUMMARY.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Поддержка: чат, список тикетов, прочитано/непрочитано, SSE
|
||||
|
||||
## Что сделано
|
||||
|
||||
### БД (таблицы с префиксом `clpr_`)
|
||||
- **clpr_support_threads** — треды обращений (unified_id, claim_id, source, ticket_id).
|
||||
- **clpr_support_messages** — сообщения (thread_id, direction user/support, body, attachments).
|
||||
- **clpr_support_reads** — когда пользователь последний раз «прочитал» тред (unified_id, thread_id, last_read_at).
|
||||
- Триггер на INSERT в clpr_support_messages → NOTIFY `support_events` (payload: unified_id, thread_id, сообщение) для доставки в реальном времени.
|
||||
|
||||
### Backend API
|
||||
- **POST /api/v1/support** — отправить сообщение (multipart), создание/поиск треда, прокси в n8n.
|
||||
- **GET /api/v1/support/threads** — список всех тредов пользователя с unread_count.
|
||||
- **GET /api/v1/support/thread** — один тред и сообщения (по claim_id или бар).
|
||||
- **GET /api/v1/support/stream** — SSE: один поток на пользователя, события из Postgres NOTIFY.
|
||||
- **GET /api/v1/support/unread-count** — суммарное число непрочитанных (бейдж в баре).
|
||||
- **POST /api/v1/support/read** — отметить тред прочитанным (thread_id или claim_id).
|
||||
- **POST /api/v1/support/incoming** — webhook для n8n: добавить ответ оператора в тред.
|
||||
- **GET /api/v1/support/limits** — лимиты вложений.
|
||||
|
||||
При старте приложения запускается задача LISTEN на канал `support_events`; при NOTIFY события раскидываются по реестру стримов (unified_id → SSE).
|
||||
|
||||
### Frontend
|
||||
- **Страница «Поддержка»** — первый экран: список обращений (тикетов) с бейджем непрочитанных; кнопка «Новое обращение»; по клику — чат выбранного треда.
|
||||
- **SupportChat** — чат с SSE (новые сообщения от поддержки без перезагрузки); при открытии чата вызывается POST /read.
|
||||
- **Нижний бар** — на иконке «Поддержка» бейдж с общим числом непрочитанных.
|
||||
|
||||
### Документация
|
||||
- **docs/SUPPORT_N8N_WEBHOOK.md** — переменные окружения, API, миграции, тест SSE, прочитано/непрочитано и сценарии в n8n.
|
||||
|
||||
## Миграции
|
||||
- 003_support_threads_messages.sql — создание clpr_support_threads, clpr_support_messages.
|
||||
- 004_support_notify_trigger.sql — триггер NOTIFY support_events.
|
||||
- 005_support_reads.sql — таблица clpr_support_reads.
|
||||
|
||||
Креды Postgres из .env. Применение: из корня aiform_prod подставить POSTGRES_* и выполнить psql -f для каждой миграции.
|
||||
80
docs/SUPPORT_N8N_WEBHOOK.md
Normal file
80
docs/SUPPORT_N8N_WEBHOOK.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Поддержка: webhook n8n, диалог (треды), лимиты вложений
|
||||
|
||||
Функционал «Поддержка» реализован как диалог: треды и сообщения хранятся в БД. **Таблицы с префиксом `clpr_`:** `clpr_support_threads`, `clpr_support_messages`. Исходящие сообщения пользователя проксируются в n8n; входящие ответы оператора приходят в backend через webhook POST /api/v1/support/incoming (из n8n при ответе в CRM).
|
||||
|
||||
Подключение к PostgreSQL: креды берутся из `.env` — `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`.
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
В `.env` задаются:
|
||||
|
||||
| Переменная | Описание |
|
||||
|------------|----------|
|
||||
| `N8N_SUPPORT_WEBHOOK` | URL webhook n8n (multipart). Обязателен. |
|
||||
| `SUPPORT_ATTACHMENTS_MAX_COUNT` | Макс. количество файлов (0 = без ограничений). |
|
||||
| `SUPPORT_ATTACHMENTS_MAX_SIZE_MB` | Макс. размер одного файла в МБ (0 = без ограничений). |
|
||||
| `SUPPORT_ATTACHMENTS_ALLOWED_TYPES` | Допустимые типы (пусто = любые). |
|
||||
| `SUPPORT_INCOMING_SECRET` | Секрет для POST /api/v1/support/incoming (заголовок `X-Support-Incoming-Secret` или query `secret`). Если задан — только n8n с этим секретом может слать ответы в тред. |
|
||||
|
||||
Значение **0** или **пустая строка** для лимитов означает «без ограничений».
|
||||
|
||||
## Формат запроса от backend к n8n
|
||||
|
||||
Backend отправляет на `N8N_SUPPORT_WEBHOOK` **POST multipart/form-data**:
|
||||
|
||||
- **Поля:** `message`, `subject`, `claim_id`, `source`, `unified_id`, `phone`, `email`, `session_id`, `timestamp`, **`thread_id`** (UUID треда), **`ticket_id`** (если тред уже привязан к тикету в CRM).
|
||||
- **Файлы:** `attachments[0]`, … или `attachments`.
|
||||
|
||||
Ответ n8n может содержать **`ticket_id`** — backend сохранит его в `clpr_support_threads` для последующих сообщений и для входящего webhook.
|
||||
|
||||
## API backend
|
||||
|
||||
- **POST /api/v1/support** — multipart: message, subject?, claim_id?, source, **thread_id?**, session_token (или channel+channel_user_id), файлы. Создаёт/находит тред по (unified_id, claim_id), записывает сообщение (user), проксирует в n8n. Ответ: `{ "success": true, "thread_id": "...", "message_id": "..." }`.
|
||||
- **GET /api/v1/support/threads** — список всех тредов пользователя. В каждом элементе есть **`unread_count`** (число непрочитанных сообщений от поддержки). Ответ: `{ "threads": [{ "thread_id", "claim_id" | null, "source", "ticket_id", "created_at", "updated_at", "last_body", "last_at", "messages_count", "unread_count" }] }`.
|
||||
- **GET /api/v1/support/unread-count** — суммарное число непрочитанных по всем тредам (для бейджа в баре). Ответ: `{ "unread_count": number }`.
|
||||
- **POST /api/v1/support/read** — отметить тред как прочитанный (пользователь открыл чат). Query или body: `thread_id` или `claim_id`. Обновляет `clpr_support_reads`.
|
||||
- **GET /api/v1/support/thread** — query: `claim_id?`, `session_token` (или `channel` + `channel_user_id`). Возвращает один тред и сообщения: `{ "thread_id": "...", "messages": [...], "ticket_id": "..." }`. Если треда нет — `thread_id: null`, `messages: []`.
|
||||
- **POST /api/v1/support/incoming** — для n8n: добавить сообщение от поддержки в тред. Тело JSON: `{ "thread_id" или "ticket_id", "body", "attachments?": [] }`. Заголовок **`X-Support-Incoming-Secret`** или query **`secret`** должен совпадать с `SUPPORT_INCOMING_SECRET` (если задан). По `ticket_id` backend находит thread_id и вставляет сообщение с direction=support.
|
||||
- **GET /api/v1/support/limits** — лимиты вложений из env.
|
||||
- **GET /api/v1/support/stream** — SSE: один поток на пользователя (query `session_token` или `channel` + `channel_user_id`). Новые сообщения от поддержки приходят в реальном времени через Postgres NOTIFY (триггер на `clpr_support_messages`). События: `connected`, `support_message` (в теле — `thread_id`, `message`: id, direction, body, attachments, created_at).
|
||||
|
||||
## Доставка в реальном времени (Postgres NOTIFY)
|
||||
|
||||
При INSERT в `clpr_support_messages` срабатывает триггер, который делает `NOTIFY support_events` с payload (unified_id, thread_id, сообщение). Backend при старте подписывается на канал `support_events` одним LISTEN-соединением и раскидывает события по реестру стримов (unified_id → очереди SSE).
|
||||
|
||||
**Прочитано/непрочитано:** таблица `clpr_support_reads` (unified_id, thread_id, last_read_at). Пользователь «прочитал» тред, когда открывает чат — фронт вызывает POST /read. Непрочитанные = сообщения от support с created_at > last_read_at. По этим данным можно в n8n/CRM строить сценарии напоминаний (push, повторная отправка), если пользователь долго не читает.
|
||||
|
||||
**Миграции** (таблицы с префиксом `clpr_`): `003` — треды и сообщения; `004` — триггер NOTIFY; `005_support_reads.sql` — отметки прочтения. Применять к БД вручную. Креды Postgres — из `.env`:
|
||||
|
||||
```bash
|
||||
# из корня aiform_prod, креды из .env
|
||||
export $(grep -E '^POSTGRES_' .env | xargs)
|
||||
psql -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f backend/db/migrations/003_support_threads_messages.sql
|
||||
psql -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f backend/db/migrations/004_support_notify_trigger.sql
|
||||
psql -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f backend/db/migrations/005_support_reads.sql
|
||||
```
|
||||
|
||||
Если в БД уже есть таблицы без префикса (`support_threads`, `support_messages`), их нужно переименовать в `clpr_support_threads` и `clpr_support_messages` перед применением 004, либо пересоздать схему (миграция 003 с префиксом создаёт таблицы с `IF NOT EXISTS`).
|
||||
|
||||
## n8n
|
||||
|
||||
1. **Webhook приёма обращений** — multipart, при первом сообщении создаёт тикет в CRM, в ответе возвращает `ticket_id`. При последующих (есть thread_id/ticket_id) — добавляет комментарий к тикету.
|
||||
2. **Вызов нашего incoming** — когда оператор ответил в CRM, workflow n8n должен вызвать **POST https://.../api/v1/support/incoming** с заголовком `X-Support-Incoming-Secret: <SUPPORT_INCOMING_SECRET>` и телом `{ "thread_id": "..." или "ticket_id": "...", "body": "текст ответа" }`, чтобы сообщение появилось в чате мини-аппа.
|
||||
|
||||
---
|
||||
|
||||
## Как тестировать SSE (ответы в реальном времени)
|
||||
|
||||
1. **В мини-аппе:** зайти в поддержку (бар → «Поддержка» или страница /support), авторизоваться, отправить первое сообщение (или открыть уже существующий тред). Оставить чат открытым.
|
||||
2. **Узнать `thread_id`:** в DevTools → Network найти запрос `GET .../api/v1/support/thread` и в ответе скопировать `thread_id`, либо после отправки сообщения — ответ `POST .../api/v1/support` содержит `thread_id`.
|
||||
3. **Имитация ответа поддержки:** вызвать incoming (как будет делать n8n):
|
||||
|
||||
```bash
|
||||
# Подставить THREAD_ID и секрет из .env (SUPPORT_INCOMING_SECRET). Если секрет пустой — заголовок можно не передавать.
|
||||
curl -s -X POST 'https://miniapp.clientright.ru/api/v1/support/incoming' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-Support-Incoming-Secret: ВАШ_SUPPORT_INCOMING_SECRET' \
|
||||
-d '{"thread_id":"THREAD_ID","body":"Тестовый ответ от поддержки"}'
|
||||
```
|
||||
|
||||
4. **Ожидание:** в открытом чате в мини-аппе в течение 1–2 секунд должно появиться новое сообщение **без перезагрузки и без повторного запроса** (доставка по SSE). Если сообщение появляется только после обновления страницы — проверить, что фронт пересобран с SSE (`docker compose build frontend && docker compose up -d frontend`) и что в Network есть запрос к `/api/v1/support/stream` со статусом pending (длинное соединение).
|
||||
122
docs/TELEGRAM_MINIAPP_FLOW.md
Normal file
122
docs/TELEGRAM_MINIAPP_FLOW.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Как срабатывает Telegram Mini App (по шагам)
|
||||
|
||||
Ты в Telegram нажимаешь кнопку «Открыть мини-апп» → открывается **aiform.clientright.ru**. Ниже — что происходит дальше и где.
|
||||
|
||||
---
|
||||
|
||||
## 1. Где открывается страница
|
||||
|
||||
- **Кто:** Telegram (клиент на телефоне/десктопе).
|
||||
- **Что:** Открывает aiform.clientright.ru **в своём встроенном браузере (WebView)** как Mini App.
|
||||
- **Важно:** В этом режиме Telegram сам подставляет в страницу свой скрипт и объект `window.Telegram.WebApp` с полем **initData** (подпись пользователя и данные). В обычном браузере по прямой ссылке этого объекта нет.
|
||||
|
||||
---
|
||||
|
||||
## 2. Загрузка фронта (aiform.clientright.ru)
|
||||
|
||||
- Загружается твой SPA (React): главная страница — форма заявки **ClaimForm**.
|
||||
- Рендерится первый экран формы (шаг 0).
|
||||
- Сразу при монтировании компонента запускается **useEffect** с функцией `tryTelegramAuth()` (в `ClaimForm.tsx`).
|
||||
|
||||
**Где в коде:** `frontend/src/pages/ClaimForm.tsx`, блок «Telegram Mini App: попытка авторизоваться через initData при первом заходе».
|
||||
|
||||
---
|
||||
|
||||
## 3. Проверка: это Mini App или обычный сайт?
|
||||
|
||||
Фронт делает:
|
||||
|
||||
1. Смотрит, есть ли `window.Telegram?.WebApp?.initData`.
|
||||
2. Если нет — ждёт 300 ms (на случай асинхронной подгрузки скрипта Telegram) и проверяет снова.
|
||||
3. Если после этого **нет** `initData` → в консоль пишется «Telegram WebApp не обнаружен», авторизация по Telegram **не вызывается**, форма ведёт себя как обычный веб-сайт (SMS, сессия из localStorage и т.д.).
|
||||
4. Если **есть** `initData`:
|
||||
- Проверяет, есть ли уже в **localStorage** ключ `session_token`.
|
||||
- Если **есть** → считаем, что пользователь уже залогинен, tg/auth не вызываем, дальше работает обычное восстановление сессии.
|
||||
- Если **нет** → идём в шаг 4.
|
||||
|
||||
**Итого:** срабатывание tg/auth **только** когда:
|
||||
- страница открыта **из Telegram** (есть `initData`),
|
||||
- и в localStorage **нет** сохранённого `session_token`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Запрос на бэкенд: POST /api/v1/tg/auth
|
||||
|
||||
- **Кто:** фронт (ClaimForm).
|
||||
- **Куда:** на тот же домен aiform.clientright.ru → запрос уходит на твой backend (через nginx/proxy на порт 8200).
|
||||
- **URL:** `POST /api/v1/tg/auth`.
|
||||
- **Тело:** `{ "init_data": "<строка initData от Telegram>" }`.
|
||||
|
||||
**Где в коде:** `ClaimForm.tsx` — `fetch('/api/v1/tg/auth', { method: 'POST', body: JSON.stringify({ init_data: webApp.initData }) })`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Обработка на бэкенде (tg/auth)
|
||||
|
||||
- **Где:** `backend/app/api/telegram_auth.py`, эндпоинт `POST /api/v1/tg/auth`.
|
||||
|
||||
Последовательно:
|
||||
|
||||
1. **Валидация initData** (`backend/app/services/telegram_auth.py`):
|
||||
- Проверка подписи через **TELEGRAM_BOT_TOKEN** из `.env`.
|
||||
- Если токена нет или подпись не совпадает → ответ **400** (или 500), фронт пишет «Telegram auth failed» и ведёт себя как обычный сайт.
|
||||
|
||||
2. **Извлечение пользователя Telegram:** из initData достаются `id`, `username`, `first_name`, `last_name`.
|
||||
|
||||
3. **Запрос в n8n:**
|
||||
- Бэкенд дергает **N8N_TG_AUTH_WEBHOOK** (URL из `.env`).
|
||||
- Передаёт: `telegram_user_id`, `username`, `first_name`, `last_name`, `session_token`, `form_id`.
|
||||
- Ожидает в ответе минимум **unified_id** (и при необходимости contact_id, phone, has_drafts).
|
||||
|
||||
4. **Создание сессии в Redis:**
|
||||
- По `session_token` + `unified_id` (+ phone, contact_id) создаётся запись сессии (как после SMS-логина).
|
||||
|
||||
5. **Ответ фронту:**
|
||||
`{ success: true, session_token, unified_id, contact_id?, phone?, has_drafts? }`.
|
||||
|
||||
Если на любом шаге ошибка (нет токена, n8n не вернул unified_id и т.д.) — бэкенд отдаёт ошибку, фронт считает tg/auth неуспешным и продолжает как обычный веб.
|
||||
|
||||
---
|
||||
|
||||
## 6. Что делает фронт после успешного ответа
|
||||
|
||||
- Сохраняет **session_token** в **localStorage** и в `sessionIdRef`.
|
||||
- Обновляет состояние формы: `unified_id`, `phone`, `contact_id`, `session_id`.
|
||||
- Ставит **isPhoneVerified = true** (шаг «телефон» считаем пройденным).
|
||||
- Если в ответе **has_drafts === true** → показывает экран выбора черновиков.
|
||||
- Если **has_drafts** нет или false → переводит на **шаг 1** (описание проблемы).
|
||||
|
||||
Дальше пользователь идёт по форме как обычно: описание → черновик/визард → подтверждение → оплата и т.д., но уже без ввода телефона и SMS, потому что он «залогинен» через Telegram.
|
||||
|
||||
---
|
||||
|
||||
## Сводка: где что срабатывает
|
||||
|
||||
| Шаг | Где | Что происходит |
|
||||
|-----|-----|----------------|
|
||||
| 1 | Telegram | Открывает aiform.clientright.ru в WebView, подставляет WebApp и initData |
|
||||
| 2 | Браузер (WebView) | Загружается SPA, монтируется ClaimForm |
|
||||
| 3 | ClaimForm.tsx (фронт) | Проверка: есть ли Telegram.WebApp.initData и нет ли session_token в localStorage |
|
||||
| 4 | ClaimForm.tsx (фронт) | POST /api/v1/tg/auth с init_data |
|
||||
| 5 | telegram_auth.py (бэкенд) | Валидация initData, запрос в n8n, создание сессии в Redis |
|
||||
| 6 | ClaimForm.tsx (фронт) | Сохранение session_token, переход на шаг черновиков или описание |
|
||||
|
||||
---
|
||||
|
||||
## Если открыть aiform.clientright.ru не из Telegram
|
||||
|
||||
- В обычном браузере (Chrome, Safari по прямой ссылке) **нет** `window.Telegram.WebApp`.
|
||||
- Фронт пишет в консоль «Telegram WebApp не обнаружен» и **не вызывает** /api/v1/tg/auth.
|
||||
- Работает обычный сценарий: ввод телефона → SMS → сессия и т.д.
|
||||
|
||||
---
|
||||
|
||||
## Что должно быть настроено
|
||||
|
||||
1. **В Telegram:** у бота должна быть кнопка/меню, открывающее Mini App с URL **https://aiform.clientright.ru** (или с путём на эту форму).
|
||||
2. **Backend .env:**
|
||||
- **TELEGRAM_BOT_TOKEN** — токен этого же бота (для проверки initData).
|
||||
- **N8N_TG_AUTH_WEBHOOK** — URL webhook в n8n, который по telegram_user_id возвращает unified_id (и при необходимости contact_id, phone, has_drafts).
|
||||
3. **n8n:** workflow по этому webhook принимает JSON с telegram_user_id и т.д. и отдаёт JSON с полем **unified_id** (обязательно).
|
||||
|
||||
Если что-то из этого не настроено, цепочка обрывается на шаге 5 (бэкенд/n8n), и пользователь остаётся в «обычном» режиме формы без авторизации через Telegram.
|
||||
71
docs/n8n_CODE_CRM_NORMALIZE.js
Normal file
71
docs/n8n_CODE_CRM_NORMALIZE.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* n8n Code node: нормализация ответа CRM (projects_json + tickets_json)
|
||||
* в массив элементов с метками для фронта (type_code, payload.source, status_code по projectstatus).
|
||||
*
|
||||
* Вход: один элемент с полями projects_json[], tickets_json[] (и опционально contactid, unified_id, mobile).
|
||||
* Выход: один элемент { crm_items: [...] } — массив готовых объектов для склейки с черновиками из Postgres.
|
||||
*/
|
||||
|
||||
const input = $input.first().json;
|
||||
const projects = input.projects_json || [];
|
||||
const tickets = input.tickets_json || [];
|
||||
|
||||
const normalized = [];
|
||||
|
||||
// Проекты из CRM → один элемент на проект (карточка «В работе» / «Решены» / «Отклонены»)
|
||||
for (const p of projects) {
|
||||
const projectstatus = (p.projectstatus || '').toString().toLowerCase();
|
||||
let status_code = 'active';
|
||||
if (projectstatus.includes('завершено') || projectstatus === 'completed') status_code = 'completed';
|
||||
else if (projectstatus.includes('отклонен')) status_code = 'rejected';
|
||||
|
||||
normalized.push({
|
||||
id: `crm_project_${p.projectid}`,
|
||||
claim_id: null,
|
||||
type_code: 'external_case',
|
||||
payload: {
|
||||
source: 'CRM',
|
||||
projectid: p.projectid,
|
||||
projectstatus: p.projectstatus,
|
||||
},
|
||||
status_code,
|
||||
channel: 'crm',
|
||||
problem_title: p.projectname || '',
|
||||
problem_description: '',
|
||||
created_at: p.createdtime || null,
|
||||
updated_at: p.createdtime || null,
|
||||
documents_total: 0,
|
||||
documents_uploaded: 0,
|
||||
unified_id: input.unified_id || null,
|
||||
contact_id: input.contactid != null ? String(input.contactid) : null,
|
||||
phone: input.mobile || input.phone || null,
|
||||
});
|
||||
}
|
||||
|
||||
// Тикеты из CRM → один элемент на тикет (карточка «Консультации»)
|
||||
for (const t of tickets) {
|
||||
normalized.push({
|
||||
id: `crm_ticket_${t.ticketid}`,
|
||||
claim_id: null,
|
||||
type_code: 'consultation',
|
||||
payload: {
|
||||
source: 'CRM',
|
||||
ticketid: t.ticketid,
|
||||
ticket_no: t.ticket_no,
|
||||
},
|
||||
status_code: 'active',
|
||||
channel: 'crm',
|
||||
problem_title: t.title || t.ticket_no || '',
|
||||
problem_description: '',
|
||||
created_at: t.createdtime || null,
|
||||
updated_at: t.createdtime || null,
|
||||
documents_total: 0,
|
||||
documents_uploaded: 0,
|
||||
unified_id: input.unified_id || null,
|
||||
contact_id: input.contactid != null ? String(input.contactid) : null,
|
||||
phone: input.mobile || input.phone || null,
|
||||
});
|
||||
}
|
||||
|
||||
// Отдаём один элемент с массивом crm_items (далее в workflow склеиваешь с data из Postgres)
|
||||
return [{ json: { crm_items: normalized } }];
|
||||
29
docs/n8n_CODE_FLATTEN_DATA.js
Normal file
29
docs/n8n_CODE_FLATTEN_DATA.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* n8n Code node: развернуть data в плоский список.
|
||||
* Если в data попал объект вида { "crm_items": [...] }, он заменяется на сами элементы crm_items.
|
||||
*
|
||||
* Вход: один элемент с полем data (массив), где часть элементов могут быть { crm_items: [...] }.
|
||||
* Выход: один элемент { data: [...] } — плоский массив только карточек (заявки Postgres + элементы CRM).
|
||||
*/
|
||||
|
||||
const input = $input.first().json;
|
||||
let data = input.data;
|
||||
if (data == null) data = input.items || input.drafts || [];
|
||||
if (!Array.isArray(data)) data = [data];
|
||||
|
||||
const flattened = [];
|
||||
for (const item of data) {
|
||||
if (
|
||||
item &&
|
||||
typeof item === 'object' &&
|
||||
item.crm_items &&
|
||||
Array.isArray(item.crm_items) &&
|
||||
Object.keys(item).length === 1
|
||||
) {
|
||||
flattened.push(...item.crm_items);
|
||||
} else {
|
||||
flattened.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return [{ json: { ...input, data: flattened } }];
|
||||
@@ -1,36 +1,16 @@
|
||||
# React Frontend Dockerfile (PRODUCTION BUILD)
|
||||
# Продакшен: сборка + отдача dist (без dev-сервера).
|
||||
# После правок в коде: docker compose build frontend && docker compose up -d frontend
|
||||
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
# Устанавливаем рабочую директорию
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем package.json
|
||||
COPY package*.json ./
|
||||
|
||||
# Устанавливаем зависимости
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# Копируем исходный код
|
||||
COPY . .
|
||||
RUN node -r ./scripts/crypto-polyfill.cjs ./node_modules/vite/bin/vite.js build
|
||||
|
||||
# Собираем production build
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:18-alpine
|
||||
|
||||
# Устанавливаем serve глобально
|
||||
RUN npm install -g serve
|
||||
|
||||
# Копируем собранное приложение из builder stage
|
||||
COPY --from=builder /app/dist /app/dist
|
||||
|
||||
# Устанавливаем рабочую директорию
|
||||
WORKDIR /app
|
||||
|
||||
# Открываем порт
|
||||
RUN npm install -g serve
|
||||
COPY --from=builder /app/dist ./dist
|
||||
EXPOSE 3000
|
||||
|
||||
# Запускаем serve для раздачи статических файлов
|
||||
CMD ["serve", "-s", "dist", "-l", "3000"]
|
||||
|
||||
|
||||
@@ -3,8 +3,19 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Clientright — защита прав потребителей</title>
|
||||
<!-- Подключаем только скрипт текущей платформы, иначе в MAX приходят события Telegram → UnsupportedEvent -->
|
||||
<script>
|
||||
(function() {
|
||||
var u = window.location.href || '';
|
||||
if (u.indexOf('tgWebAppData') !== -1 || u.indexOf('tgWebAppVersion') !== -1) {
|
||||
var s = document.createElement('script'); s.src = 'https://telegram.org/js/telegram-web-app.js'; document.head.appendChild(s);
|
||||
} else {
|
||||
var s = document.createElement('script'); s.src = 'https://st.max.ru/js/max-web-app.js'; document.head.appendChild(s);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
15
frontend/package-lock.json
generated
15
frontend/package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"imask": "^7.6.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.3.5",
|
||||
@@ -3562,6 +3563,14 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.575.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz",
|
||||
"integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -7725,6 +7734,12 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"lucide-react": {
|
||||
"version": "0.575.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz",
|
||||
"integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==",
|
||||
"requires": {}
|
||||
},
|
||||
"math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
||||
@@ -6,40 +6,40 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "node -r ./scripts/crypto-polyfill.cjs ./node_modules/vite/bin/vite.js build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"type-check": "tsc --noEmit",
|
||||
"start": "serve -s dist -l 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"antd": "^5.21.6",
|
||||
"@ant-design/icons": "^5.5.1",
|
||||
"axios": "^1.7.7",
|
||||
"@tanstack/react-query": "^5.59.16",
|
||||
"zustand": "^5.0.1",
|
||||
"antd": "^5.21.6",
|
||||
"axios": "^1.7.7",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"imask": "^7.6.1",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"serve": "^14.2.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"browser-image-compression": "^2.0.2"
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"serve": "^14.2.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"zustand": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"eslint": "^9.13.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.11.0",
|
||||
"@typescript-eslint/parser": "^8.11.0",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"eslint": "^9.13.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.13"
|
||||
"eslint-plugin-react-refresh": "^0.4.13",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Clientright — защита прав потребителей</title>
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
18
frontend/scripts/crypto-polyfill.cjs
Normal file
18
frontend/scripts/crypto-polyfill.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Полифилл crypto.getRandomValues для Node 16 (нужен Vite при сборке).
|
||||
* Запуск: node -r ./scripts/crypto-polyfill.cjs node_modules/vite/bin/vite.js build
|
||||
*/
|
||||
const crypto = require('node:crypto');
|
||||
function getRandomValues(buffer) {
|
||||
if (!buffer) return buffer;
|
||||
const bytes = crypto.randomBytes(buffer.length);
|
||||
buffer.set(bytes);
|
||||
return buffer;
|
||||
}
|
||||
if (typeof crypto.getRandomValues !== 'function') {
|
||||
crypto.getRandomValues = getRandomValues;
|
||||
}
|
||||
if (typeof globalThis !== 'undefined') {
|
||||
globalThis.crypto = globalThis.crypto || {};
|
||||
globalThis.crypto.getRandomValues = getRandomValues;
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
@@ -27,8 +29,10 @@
|
||||
flex: 1;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.card {
|
||||
|
||||
@@ -1,12 +1,111 @@
|
||||
import ClaimForm from './pages/ClaimForm'
|
||||
import './App.css'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import ClaimForm from './pages/ClaimForm';
|
||||
import HelloAuth from './pages/HelloAuth';
|
||||
import Profile from './pages/Profile';
|
||||
import Support from './pages/Support';
|
||||
import Consultations from './pages/Consultations';
|
||||
import BottomBar from './components/BottomBar';
|
||||
import { DraftsProvider } from './context/DraftsContext';
|
||||
import './App.css';
|
||||
import { miniappLog, miniappSendLogs } from './utils/miniappLogger';
|
||||
|
||||
function App() {
|
||||
const [pathname, setPathname] = useState<string>(() => {
|
||||
const p = window.location.pathname || '';
|
||||
if (p !== '/hello' && !p.startsWith('/hello')) return '/hello';
|
||||
return p;
|
||||
});
|
||||
const [avatarUrl, setAvatarUrl] = useState<string>(() => localStorage.getItem('user_avatar_url') || '');
|
||||
const [profileNeedsAttention, setProfileNeedsAttention] = useState<boolean>(false);
|
||||
const lastRouteTsRef = useRef<number>(Date.now());
|
||||
const lastPathRef = useRef<string>(pathname);
|
||||
|
||||
useEffect(() => {
|
||||
const path = window.location.pathname || '/';
|
||||
if (path !== '/hello' && !path.startsWith('/hello')) {
|
||||
window.history.replaceState({}, '', '/hello' + (window.location.search || '') + (window.location.hash || ''));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onPopState = () => setPathname(window.location.pathname || '');
|
||||
window.addEventListener('popstate', onPopState);
|
||||
return () => window.removeEventListener('popstate', onPopState);
|
||||
}, []);
|
||||
|
||||
// Логируем смену маршрута + ловим быстрый возврат на /hello (симптом бага)
|
||||
useEffect(() => {
|
||||
const now = Date.now();
|
||||
const prev = lastPathRef.current;
|
||||
lastPathRef.current = pathname;
|
||||
lastRouteTsRef.current = now;
|
||||
miniappLog('route', { prev, next: pathname });
|
||||
|
||||
if (pathname.startsWith('/hello') && !prev.startsWith('/hello')) {
|
||||
// Вернулись на /hello: отправим дамп, чтобы поймать “ложится”
|
||||
void miniappSendLogs('returned_to_hello');
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
// Ловим клики в первые 2с после смены маршрута (ghost click / попадание в бар)
|
||||
useEffect(() => {
|
||||
const onClickCapture = (e: MouseEvent) => {
|
||||
const dt = Date.now() - lastRouteTsRef.current;
|
||||
if (dt > 2000) return;
|
||||
const t = e.target as HTMLElement | null;
|
||||
const inBar = !!t?.closest?.('.app-bottom-bar');
|
||||
miniappLog('click_capture', {
|
||||
dtFromRouteMs: dt,
|
||||
inBottomBar: inBar,
|
||||
tag: t?.tagName,
|
||||
id: t?.id,
|
||||
class: t?.className,
|
||||
x: (e as MouseEvent).clientX,
|
||||
y: (e as MouseEvent).clientY,
|
||||
});
|
||||
};
|
||||
window.addEventListener('click', onClickCapture, true);
|
||||
return () => window.removeEventListener('click', onClickCapture, true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setAvatarUrl(localStorage.getItem('user_avatar_url') || '');
|
||||
}, [pathname]);
|
||||
|
||||
const isNewClaimPage = pathname === '/new';
|
||||
|
||||
const navigateTo = useCallback((path: string) => {
|
||||
window.history.pushState({}, '', path);
|
||||
setPathname(path);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DraftsProvider>
|
||||
<div className="App">
|
||||
<ClaimForm />
|
||||
{pathname === '/profile' ? (
|
||||
<Profile onNavigate={navigateTo} />
|
||||
) : pathname === '/support' ? (
|
||||
<Support onNavigate={navigateTo} />
|
||||
) : pathname === '/consultations' ? (
|
||||
<Consultations onNavigate={navigateTo} />
|
||||
) : pathname.startsWith('/hello') ? (
|
||||
<HelloAuth
|
||||
onAvatarChange={setAvatarUrl}
|
||||
onNavigate={navigateTo}
|
||||
onProfileNeedsAttentionChange={setProfileNeedsAttention}
|
||||
/>
|
||||
) : (
|
||||
<ClaimForm forceNewClaim={isNewClaimPage} onNavigate={navigateTo} />
|
||||
)}
|
||||
<BottomBar
|
||||
currentPath={pathname}
|
||||
avatarUrl={avatarUrl || undefined}
|
||||
profileNeedsAttention={profileNeedsAttention}
|
||||
onNavigate={navigateTo}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
</DraftsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
||||
122
frontend/src/components/BottomBar.css
Normal file
122
frontend/src/components/BottomBar.css
Normal file
@@ -0,0 +1,122 @@
|
||||
.app-bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 64px;
|
||||
height: calc(64px + env(safe-area-inset-bottom, 0));
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
padding-left: env(safe-area-inset-left, 0);
|
||||
padding-right: env(safe-area-inset-right, 0);
|
||||
background: #ffffff;
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
z-index: 100;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.app-bottom-bar--hidden {
|
||||
transform: translateY(120%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.app-bar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.app-bar-item:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.app-bar-item:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.app-bar-item:disabled:hover {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.app-bar-item--active {
|
||||
color: #2563EB;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-bar-item--active:hover {
|
||||
color: #2563EB;
|
||||
}
|
||||
|
||||
.app-bar-item--exit:hover {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.app-bar-item-icon-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-bar-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.app-bar-profile-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -6px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
background: #dc2626;
|
||||
border: 1.5px solid #fff;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.app-bar-support-badge {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 50%);
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
background: #dc2626;
|
||||
border: 1.5px solid #fff;
|
||||
border-radius: 9px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
286
frontend/src/components/BottomBar.tsx
Normal file
286
frontend/src/components/BottomBar.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Home, Headphones, User, LogOut, ArrowLeft } from 'lucide-react';
|
||||
import './BottomBar.css';
|
||||
import { miniappLog } from '../utils/miniappLogger';
|
||||
|
||||
function getSessionToken(): string | null {
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
const s = sessionStorage.getItem('session_token');
|
||||
if (s) return s;
|
||||
}
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
return localStorage.getItem('session_token');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface BottomBarProps {
|
||||
currentPath: string;
|
||||
avatarUrl?: string;
|
||||
profileNeedsAttention?: boolean;
|
||||
onNavigate?: (path: string) => void;
|
||||
}
|
||||
|
||||
export default function BottomBar({ currentPath, avatarUrl, profileNeedsAttention, onNavigate }: BottomBarProps) {
|
||||
const isHome = currentPath.startsWith('/hello');
|
||||
const isProfile = currentPath === '/profile';
|
||||
const isSupport = currentPath === '/support';
|
||||
const [backEnabled, setBackEnabled] = useState(false);
|
||||
const [supportUnreadCount, setSupportUnreadCount] = useState(0);
|
||||
const [keyboardOpen, setKeyboardOpen] = useState(false);
|
||||
const [inputFocused, setInputFocused] = useState(false);
|
||||
const [supportChatMode, setSupportChatMode] = useState(false);
|
||||
|
||||
// Непрочитанные в поддержке — для бейджа на иконке
|
||||
useEffect(() => {
|
||||
const token = getSessionToken();
|
||||
if (!token) {
|
||||
setSupportUnreadCount(0);
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.set('session_token', token);
|
||||
fetch(`/api/v1/support/unread-count?${params.toString()}`)
|
||||
.then((res) => (res.ok ? res.json() : { unread_count: 0 }))
|
||||
.then((data) => setSupportUnreadCount(data.unread_count ?? 0))
|
||||
.catch(() => setSupportUnreadCount(0));
|
||||
}, [currentPath]);
|
||||
|
||||
// В некоторых webview бывает «ghost click» сразу после навигации — даём бару чуть устояться.
|
||||
// На /support кнопка «Назад» включена — возврат из чата в список или из списка в «Мои обращения».
|
||||
useEffect(() => {
|
||||
if (isHome || isProfile) {
|
||||
setBackEnabled(false);
|
||||
return;
|
||||
}
|
||||
setBackEnabled(false);
|
||||
const t = window.setTimeout(() => setBackEnabled(true), 1200);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [isHome, isProfile, currentPath]);
|
||||
|
||||
// Если открыта клавиатура — прячем нижний бар, чтобы он не перекрывал поле ввода
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
const update = () => {
|
||||
const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
|
||||
setKeyboardOpen(inset > 80);
|
||||
};
|
||||
update();
|
||||
vv.addEventListener('resize', update);
|
||||
vv.addEventListener('scroll', update);
|
||||
return () => {
|
||||
vv.removeEventListener('resize', update);
|
||||
vv.removeEventListener('scroll', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Универсально для любых WebView: если в фокусе поле ввода, нижний бар скрываем.
|
||||
useEffect(() => {
|
||||
const isEditable = (el: EventTarget | null): boolean => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea' || el.isContentEditable;
|
||||
};
|
||||
|
||||
const handleFocusIn = (e: FocusEvent) => {
|
||||
if (isEditable(e.target)) setInputFocused(true);
|
||||
};
|
||||
|
||||
const handleFocusOut = () => {
|
||||
window.setTimeout(() => {
|
||||
const active = document.activeElement;
|
||||
setInputFocused(isEditable(active));
|
||||
}, 30);
|
||||
};
|
||||
|
||||
window.addEventListener('focusin', handleFocusIn);
|
||||
window.addEventListener('focusout', handleFocusOut);
|
||||
return () => {
|
||||
window.removeEventListener('focusin', handleFocusIn);
|
||||
window.removeEventListener('focusout', handleFocusOut);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onSupportChatMode = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ active?: boolean }>).detail;
|
||||
setSupportChatMode(!!detail?.active);
|
||||
};
|
||||
window.addEventListener('miniapp:supportChatMode', onSupportChatMode as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('miniapp:supportChatMode', onSupportChatMode as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleBack = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
miniappLog('bottom_bar_back_click', { backEnabled, currentPath });
|
||||
if (!backEnabled) return;
|
||||
window.dispatchEvent(new CustomEvent('miniapp:goBack'));
|
||||
};
|
||||
|
||||
const handleExit = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const tgWebApp = (window as any).Telegram?.WebApp;
|
||||
const tgInitData = typeof tgWebApp?.initData === 'string' ? tgWebApp.initData : '';
|
||||
const hasTgContext =
|
||||
tgInitData.length > 0 ||
|
||||
window.location.href.includes('tgWebAppData') ||
|
||||
navigator.userAgent.includes('Telegram');
|
||||
|
||||
const maxWebApp = (window as any).WebApp;
|
||||
const maxInitData = typeof maxWebApp?.initData === 'string' ? maxWebApp.initData : '';
|
||||
const maxStartParam = maxWebApp?.initDataUnsafe?.start_param;
|
||||
const hasMaxContext =
|
||||
maxInitData.length > 0 ||
|
||||
(typeof maxStartParam === 'string' && maxStartParam.length > 0);
|
||||
|
||||
// Если пользователь не поделился контактом, initData может быть пустым — всё равно пробуем close по наличию WebApp
|
||||
const hasTgWebApp = !!tgWebApp && typeof tgWebApp.close === 'function';
|
||||
const hasMaxWebApp = !!maxWebApp && (typeof maxWebApp.close === 'function' || typeof maxWebApp.postEvent === 'function');
|
||||
|
||||
miniappLog('bottom_bar_exit_click', {
|
||||
currentPath,
|
||||
hasTgContext,
|
||||
hasMaxContext,
|
||||
tgInitDataLen: tgInitData.length,
|
||||
maxInitDataLen: maxInitData.length,
|
||||
hasTgClose: hasTgWebApp,
|
||||
hasMaxClose: hasMaxWebApp,
|
||||
});
|
||||
|
||||
// ВАЖНО: выбираем платформу по контексту (URL/UA/initData). Если оба есть — приоритет у того, у кого есть initData.
|
||||
if (hasTgContext && hasTgWebApp && !hasMaxContext) {
|
||||
try {
|
||||
miniappLog('bottom_bar_exit_close', { platform: 'tg' });
|
||||
tgWebApp.close();
|
||||
return;
|
||||
} catch (err) {
|
||||
miniappLog('bottom_bar_exit_error', { platform: 'tg', error: String(err) });
|
||||
}
|
||||
}
|
||||
if (hasMaxContext && hasMaxWebApp) {
|
||||
try {
|
||||
if (typeof maxWebApp.close === 'function') {
|
||||
miniappLog('bottom_bar_exit_close', { platform: 'max' });
|
||||
maxWebApp.close();
|
||||
return;
|
||||
}
|
||||
if (typeof maxWebApp.postEvent === 'function') {
|
||||
miniappLog('bottom_bar_exit_close', { platform: 'max', method: 'postEvent' });
|
||||
maxWebApp.postEvent('web_app_close');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
miniappLog('bottom_bar_exit_error', { platform: 'max', error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// Когда контакт не дан, initData может быть пустым — пробуем закрыть по наличию объекта WebApp (без требования initData)
|
||||
if (hasTgWebApp && !hasMaxWebApp) {
|
||||
try {
|
||||
miniappLog('bottom_bar_exit_close', { platform: 'tg_no_init', note: 'close without initData' });
|
||||
tgWebApp.close();
|
||||
return;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (hasMaxWebApp && !hasTgWebApp) {
|
||||
try {
|
||||
if (typeof maxWebApp.close === 'function') {
|
||||
miniappLog('bottom_bar_exit_close', { platform: 'max_no_init', note: 'close without initData' });
|
||||
maxWebApp.close();
|
||||
return;
|
||||
}
|
||||
if (typeof maxWebApp.postEvent === 'function') {
|
||||
maxWebApp.postEvent('web_app_close');
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Fallback: переход на главную
|
||||
miniappLog('bottom_bar_exit_fallback', {});
|
||||
window.location.href = '/hello';
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={`app-bottom-bar${keyboardOpen || inputFocused || supportChatMode ? ' app-bottom-bar--hidden' : ''}`}
|
||||
aria-label="Навигация"
|
||||
>
|
||||
{!isHome && !isProfile && (
|
||||
<button
|
||||
type="button"
|
||||
className="app-bar-item"
|
||||
onClick={handleBack}
|
||||
disabled={!backEnabled}
|
||||
aria-label="Назад"
|
||||
>
|
||||
<ArrowLeft size={24} strokeWidth={1.8} />
|
||||
<span>Назад</span>
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href="/hello"
|
||||
className={`app-bar-item ${isHome ? 'app-bar-item--active' : ''}`}
|
||||
onClick={(e) => {
|
||||
if (onNavigate && !isHome) {
|
||||
e.preventDefault();
|
||||
onNavigate('/hello');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Home size={24} strokeWidth={1.8} />
|
||||
<span>Домой</span>
|
||||
</a>
|
||||
<a
|
||||
href="/profile"
|
||||
className={`app-bar-item ${isProfile ? 'app-bar-item--active' : ''}`}
|
||||
onClick={(e) => {
|
||||
if (onNavigate && !isProfile) {
|
||||
e.preventDefault();
|
||||
onNavigate('/profile');
|
||||
}
|
||||
}}
|
||||
aria-label={profileNeedsAttention ? 'Профиль — требуется подтверждение данных' : 'Профиль'}
|
||||
>
|
||||
<span className="app-bar-item-icon-wrap">
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="" className="app-bar-avatar" />
|
||||
) : (
|
||||
<User size={24} strokeWidth={1.8} />
|
||||
)}
|
||||
{profileNeedsAttention && <span className="app-bar-profile-badge" aria-hidden>!</span>}
|
||||
</span>
|
||||
<span>Профиль</span>
|
||||
</a>
|
||||
<a
|
||||
href="/support"
|
||||
className={`app-bar-item ${isSupport ? 'app-bar-item--active' : ''}`}
|
||||
onClick={(e) => {
|
||||
if (onNavigate && currentPath !== '/support') {
|
||||
e.preventDefault();
|
||||
onNavigate('/support');
|
||||
}
|
||||
}}
|
||||
aria-label={supportUnreadCount > 0 ? `Поддержка: ${supportUnreadCount} непрочитанных` : 'Поддержка'}
|
||||
>
|
||||
<span className="app-bar-item-icon-wrap">
|
||||
<Headphones size={24} strokeWidth={1.8} />
|
||||
{supportUnreadCount > 0 && (
|
||||
<span className="app-bar-support-badge" aria-hidden>
|
||||
{supportUnreadCount > 99 ? '99+' : supportUnreadCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>Поддержка</span>
|
||||
</a>
|
||||
<button type="button" className="app-bar-item app-bar-item--exit" onClick={handleExit} aria-label="Выход">
|
||||
<LogOut size={24} strokeWidth={1.8} />
|
||||
<span>Выход</span>
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
479
frontend/src/components/SupportChat.tsx
Normal file
479
frontend/src/components/SupportChat.tsx
Normal file
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* SupportChat — диалог поддержки: список сообщений + ввод.
|
||||
* Новые ответы приходят по SSE (Postgres NOTIFY), один канал на пользователя.
|
||||
* Если треда ещё нет — показывается форма первого сообщения; после отправки — чат.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Form, Input, message, Spin, Typography } from 'antd';
|
||||
import { Paperclip, X } from 'lucide-react';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export interface SupportMessage {
|
||||
id: string;
|
||||
direction: 'user' | 'support';
|
||||
body: string;
|
||||
attachments: Array<{ filename?: string; url?: string }>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SupportThreadResponse {
|
||||
thread_id: string | null;
|
||||
messages: SupportMessage[];
|
||||
ticket_id: string | null;
|
||||
}
|
||||
|
||||
export interface SupportChatProps {
|
||||
claimId?: string;
|
||||
source?: 'bar' | 'complaint_card';
|
||||
compact?: boolean;
|
||||
onSuccess?: () => void;
|
||||
hideClaimLabel?: boolean;
|
||||
}
|
||||
|
||||
function getSessionToken(): string | null {
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
const s = sessionStorage.getItem('session_token');
|
||||
if (s) return s;
|
||||
}
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
return localStorage.getItem('session_token');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildThreadUrl(claimId?: string): string {
|
||||
const token = getSessionToken();
|
||||
const params = new URLSearchParams();
|
||||
if (token) params.set('session_token', token);
|
||||
if (claimId) params.set('claim_id', claimId);
|
||||
return `/api/v1/support/thread?${params.toString()}`;
|
||||
}
|
||||
|
||||
function buildStreamUrl(): string {
|
||||
const token = getSessionToken();
|
||||
if (!token) return '';
|
||||
const params = new URLSearchParams();
|
||||
params.set('session_token', token);
|
||||
return `/api/v1/support/stream?${params.toString()}`;
|
||||
}
|
||||
|
||||
export default function SupportChat({
|
||||
claimId,
|
||||
source = 'bar',
|
||||
compact = false,
|
||||
onSuccess,
|
||||
hideClaimLabel = false,
|
||||
}: SupportChatProps) {
|
||||
const [threadId, setThreadId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<SupportMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [fileInputKey, setFileInputKey] = useState(0);
|
||||
const [keyboardInset, setKeyboardInset] = useState(0);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputBarRef = useRef<HTMLDivElement>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const threadIdRef = useRef<string | null>(null);
|
||||
threadIdRef.current = threadId;
|
||||
|
||||
// При фокусе: в TG/MAX запрашиваем expand(); затем прокручиваем поле ввода в видимую зону (над клавиатурой)
|
||||
const scrollInputIntoView = useCallback(() => {
|
||||
const win = typeof window !== 'undefined' ? window : null;
|
||||
const tg = (win as unknown as { Telegram?: { WebApp?: { expand?: () => void } } })?.Telegram?.WebApp;
|
||||
const max = (win as unknown as { WebApp?: { expand?: () => void } })?.WebApp;
|
||||
if (tg?.expand) tg.expand();
|
||||
if (max?.expand) max.expand();
|
||||
|
||||
const scroll = () => inputBarRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
const t1 = window.setTimeout(scroll, 350);
|
||||
const t2 = window.setTimeout(scroll, 700);
|
||||
return () => {
|
||||
window.clearTimeout(t1);
|
||||
window.clearTimeout(t2);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const markRead = useCallback((tid: string) => {
|
||||
const token = getSessionToken();
|
||||
if (!token) return;
|
||||
const params = new URLSearchParams();
|
||||
params.set('session_token', token);
|
||||
params.set('thread_id', tid);
|
||||
fetch(`/api/v1/support/read?${params.toString()}`, { method: 'POST' }).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const fetchThread = useCallback(async () => {
|
||||
const token = getSessionToken();
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await fetch(buildThreadUrl(claimId));
|
||||
if (!res.ok) return;
|
||||
const data: SupportThreadResponse = await res.json();
|
||||
setThreadId(data.thread_id || null);
|
||||
setMessages(data.messages || []);
|
||||
if (data.thread_id) markRead(data.thread_id);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [claimId, markRead]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchThread();
|
||||
}, [fetchThread]);
|
||||
|
||||
// SSE: один поток на пользователя, новые сообщения от поддержки приходят по Postgres NOTIFY
|
||||
useEffect(() => {
|
||||
const url = buildStreamUrl();
|
||||
if (!url) return;
|
||||
const es = new EventSource(url);
|
||||
eventSourceRef.current = es;
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data || '{}');
|
||||
if (data.event !== 'support_message' || !data.message || !data.thread_id) return;
|
||||
if (data.thread_id !== threadIdRef.current) return;
|
||||
const msg = data.message as SupportMessage;
|
||||
const created_at =
|
||||
typeof msg.created_at === 'string'
|
||||
? msg.created_at
|
||||
: (msg.created_at as unknown as { isoformat?: () => string })?.isoformat?.() ?? new Date().toISOString();
|
||||
setMessages((prev) => {
|
||||
if (prev.some((m) => m.id === msg.id)) return prev;
|
||||
return [...prev, { ...msg, created_at, attachments: msg.attachments || [] }];
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
};
|
||||
return () => {
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
const update = () => {
|
||||
const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
|
||||
setKeyboardInset(inset);
|
||||
};
|
||||
update();
|
||||
vv.addEventListener('resize', update);
|
||||
vv.addEventListener('scroll', update);
|
||||
return () => {
|
||||
vv.removeEventListener('resize', update);
|
||||
vv.removeEventListener('scroll', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSend = async () => {
|
||||
const values = await form.getFieldsValue();
|
||||
const text = (values.message || '').trim();
|
||||
if (!text) return;
|
||||
|
||||
const token = getSessionToken();
|
||||
if (!token) return;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('message', text);
|
||||
fd.append('source', source);
|
||||
fd.append('session_token', token);
|
||||
if (claimId) fd.append('claim_id', claimId);
|
||||
if (threadId) fd.append('thread_id', threadId);
|
||||
files.forEach((file, i) => {
|
||||
fd.append(`attachments[${i}]`, file, file.name);
|
||||
});
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/support', { method: 'POST', body: fd });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
const detail = err.detail || res.statusText;
|
||||
if (res.status === 503) {
|
||||
message.error('Сервис поддержки временно недоступен. Попробуйте позже.');
|
||||
} else {
|
||||
message.error(typeof detail === 'string' ? detail : 'Не удалось отправить сообщение.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.thread_id) setThreadId(data.thread_id);
|
||||
await fetchThread();
|
||||
form.setFieldValue('message', '');
|
||||
setFiles([]);
|
||||
setFileInputKey((k) => k + 1);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('Ошибка соединения. Попробуйте ещё раз.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFirstMessage = async () => {
|
||||
const values = await form.validateFields().catch(() => null);
|
||||
if (!values?.message?.trim()) return;
|
||||
|
||||
const token = getSessionToken();
|
||||
if (!token) return;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('message', values.message.trim());
|
||||
if (values.subject?.trim()) fd.append('subject', values.subject.trim());
|
||||
fd.append('source', source);
|
||||
fd.append('session_token', token);
|
||||
if (claimId) fd.append('claim_id', claimId);
|
||||
files.forEach((file, i) => {
|
||||
fd.append(`attachments[${i}]`, file, file.name);
|
||||
});
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/support', { method: 'POST', body: fd });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
const detail = err.detail || res.statusText;
|
||||
if (res.status === 503) {
|
||||
message.error('Сервис поддержки временно недоступен. Попробуйте позже.');
|
||||
} else {
|
||||
message.error(typeof detail === 'string' ? detail : 'Не удалось отправить обращение.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.thread_id) setThreadId(data.thread_id);
|
||||
await fetchThread();
|
||||
form.resetFields();
|
||||
setFiles([]);
|
||||
setFileInputKey((k) => k + 1);
|
||||
onSuccess?.();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('Ошибка соединения. Попробуйте ещё раз.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = Array.from(e.target.files || []);
|
||||
setFiles((prev) => [...prev, ...selected]);
|
||||
setFileInputKey((k) => k + 1);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showChat = threadId && messages.length > 0;
|
||||
|
||||
if (!showChat) {
|
||||
return (
|
||||
<div
|
||||
className={compact ? 'support-chat support-chat--compact' : 'support-chat'}
|
||||
style={{ paddingBottom: keyboardInset ? keyboardInset + 8 : 8 }}
|
||||
>
|
||||
{claimId && !hideClaimLabel && (
|
||||
<p style={{ marginBottom: 12, color: '#666', fontSize: 13 }}>По обращению №{claimId}</p>
|
||||
)}
|
||||
<Form form={form} layout="vertical" onFinish={handleFirstMessage}>
|
||||
<Form.Item
|
||||
name="message"
|
||||
label="Сообщение"
|
||||
rules={[{ required: true, message: 'Введите текст' }]}
|
||||
>
|
||||
<TextArea rows={compact ? 3 : 5} placeholder="Опишите вопрос..." maxLength={5000} showCount onFocus={scrollInputIntoView} />
|
||||
</Form.Item>
|
||||
<Form.Item name="subject" label="Тема (необязательно)">
|
||||
<Input placeholder="Краткая тема" maxLength={200} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Прикрепить файлы">
|
||||
<input
|
||||
key={fileInputKey}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
id="support-chat-files"
|
||||
onChange={addFile}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
icon={<Paperclip size={16} style={{ verticalAlign: 'middle', marginRight: 6 }} />}
|
||||
onClick={() => document.getElementById('support-chat-files')?.click()}
|
||||
>
|
||||
Прикрепить
|
||||
</Button>
|
||||
{files.length > 0 && (
|
||||
<ul style={{ marginTop: 8, paddingLeft: 20 }}>
|
||||
{files.map((f, i) => (
|
||||
<li key={i} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Удалить"
|
||||
onClick={() => setFiles((p) => p.filter((_, j) => j !== i))}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={submitting} block={compact}>
|
||||
Отправить
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={compact ? 'support-chat support-chat--compact' : 'support-chat'} style={{ display: 'flex', flexDirection: 'column', minHeight: compact ? 320 : 400 }}>
|
||||
{claimId && !hideClaimLabel && (
|
||||
<p style={{ marginBottom: 8, color: '#666', fontSize: 13 }}>По обращению №{claimId}</p>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '12px 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
paddingBottom: keyboardInset ? keyboardInset + 8 : 8,
|
||||
}}
|
||||
>
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
style={{
|
||||
alignSelf: msg.direction === 'user' ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '85%',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 12,
|
||||
background: msg.direction === 'user' ? '#e3f2fd' : '#f5f5f5',
|
||||
border: `1px solid ${msg.direction === 'user' ? '#90caf9' : '#e0e0e0'}`,
|
||||
}}
|
||||
>
|
||||
<Typography.Text style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{msg.body}
|
||||
</Typography.Text>
|
||||
{msg.attachments?.length > 0 && (
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: '#666' }}>
|
||||
{msg.attachments.map((a, i) => (
|
||||
<div key={i}>{a.filename || a.url || 'Файл'}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 4, fontSize: 11, color: '#999' }}>
|
||||
{new Date(msg.created_at).toLocaleString('ru-RU')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<Form form={form} onFinish={handleSend} style={{ flexShrink: 0 }}>
|
||||
<div
|
||||
ref={inputBarRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
paddingTop: 8,
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
boxShadow: '0 -2px 8px rgba(0,0,0,0.04)',
|
||||
paddingBottom: keyboardInset ? keyboardInset : 0,
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
|
||||
<Form.Item name="message" style={{ flex: 1, marginBottom: 0 }}>
|
||||
<TextArea
|
||||
placeholder="Сообщение..."
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
maxLength={5000}
|
||||
onFocus={scrollInputIntoView}
|
||||
onPressEnter={(e) => {
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault();
|
||||
form.submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<input
|
||||
key={fileInputKey}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
id="support-chat-files-chat"
|
||||
onChange={addFile}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
icon={<Paperclip size={18} />}
|
||||
size="large"
|
||||
onClick={() => document.getElementById('support-chat-files-chat')?.click()}
|
||||
/>
|
||||
<Button type="primary" htmlType="submit" loading={submitting} size="large">
|
||||
Отправить
|
||||
</Button>
|
||||
</div>
|
||||
{files.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{files.map((f, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: '2px 8px',
|
||||
background: '#f0f0f0',
|
||||
borderRadius: 4,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{f.name}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Удалить"
|
||||
onClick={() => setFiles((p) => p.filter((_, j) => j !== i))}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
214
frontend/src/components/SupportForm.tsx
Normal file
214
frontend/src/components/SupportForm.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* SupportForm — форма обращения в поддержку (переиспользуется на странице /support и в модалке карточки жалобы).
|
||||
* Отправка: POST /api/v1/support (multipart). Лимиты вложений опционально из GET /api/v1/support/limits.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Form, Input, message as antMessage } from 'antd';
|
||||
import { Paperclip, X } from 'lucide-react';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export interface SupportLimits {
|
||||
max_count: number;
|
||||
max_size_per_file: number;
|
||||
allowed_types: string;
|
||||
unlimited: boolean;
|
||||
}
|
||||
|
||||
export interface SupportFormProps {
|
||||
/** Привязка к обращению (из карточки жалобы) */
|
||||
claimId?: string;
|
||||
/** bar | complaint_card */
|
||||
source?: 'bar' | 'complaint_card';
|
||||
/** После успешной отправки */
|
||||
onSuccess?: () => void;
|
||||
/** Компактный вид (модалка) */
|
||||
compact?: boolean;
|
||||
/** Скрыть заголовок «По обращению №…» когда передан claimId */
|
||||
hideClaimLabel?: boolean;
|
||||
}
|
||||
|
||||
function getSessionToken(): string | null {
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
const s = sessionStorage.getItem('session_token');
|
||||
if (s) return s;
|
||||
}
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
return localStorage.getItem('session_token');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function SupportForm({
|
||||
claimId,
|
||||
source = 'bar',
|
||||
onSuccess,
|
||||
compact = false,
|
||||
hideClaimLabel = false,
|
||||
}: SupportFormProps) {
|
||||
const [form] = Form.useForm();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [limits, setLimits] = useState<SupportLimits | null>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [fileInputKey, setFileInputKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/support/limits')
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data: SupportLimits | null) => {
|
||||
if (data) setLimits(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const canAddFile = (): boolean => {
|
||||
if (!limits || limits.unlimited) return true;
|
||||
return files.length < limits.max_count;
|
||||
};
|
||||
|
||||
const isFileSizeOk = (file: File): boolean => {
|
||||
if (!limits || limits.unlimited || limits.max_size_per_file <= 0) return true;
|
||||
return file.size <= limits.max_size_per_file;
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = Array.from(e.target.files || []);
|
||||
if (!limits?.unlimited && limits && limits.max_count > 0) {
|
||||
const remaining = limits.max_count - files.length;
|
||||
if (selected.length > remaining) {
|
||||
antMessage.warning(`Можно прикрепить не более ${limits.max_count} файлов`);
|
||||
setFileInputKey((k) => k + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ok: File[] = [];
|
||||
for (const f of selected) {
|
||||
if (!isFileSizeOk(f)) {
|
||||
antMessage.warning(`Файл «${f.name}» превышает допустимый размер`);
|
||||
continue;
|
||||
}
|
||||
ok.push(f);
|
||||
}
|
||||
setFiles((prev) => [...prev, ...ok].slice(0, limits?.unlimited ? 999 : (limits?.max_count || 999)));
|
||||
setFileInputKey((k) => k + 1);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const values = await form.validateFields().catch(() => null);
|
||||
if (!values || !values.message?.trim()) return;
|
||||
|
||||
const token = getSessionToken();
|
||||
if (!token) {
|
||||
antMessage.error('Сессия не найдена. Войдите снова.');
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('message', values.message.trim());
|
||||
if (values.subject?.trim()) fd.append('subject', values.subject.trim());
|
||||
fd.append('source', source);
|
||||
fd.append('session_token', token);
|
||||
if (claimId) fd.append('claim_id', claimId);
|
||||
|
||||
files.forEach((file, i) => {
|
||||
fd.append(`attachments[${i}]`, file, file.name);
|
||||
});
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/support', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || res.statusText || 'Ошибка отправки');
|
||||
}
|
||||
antMessage.success('Запрос отправлен! Мы свяжемся с вами в ближайшее время.');
|
||||
form.resetFields();
|
||||
setFiles([]);
|
||||
setFileInputKey((k) => k + 1);
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
antMessage.error(err instanceof Error ? err.message : 'Не удалось отправить запрос. Попробуйте позже.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const limitHint =
|
||||
limits && !limits.unlimited
|
||||
? `Макс. ${limits.max_count || '—'} файл(ов)${limits.max_size_per_file ? `, до ${Math.round(limits.max_size_per_file / 1024 / 1024)} МБ каждый` : ''}${limits.allowed_types ? `. Типы: ${limits.allowed_types}` : ''}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={compact ? 'support-form support-form--compact' : 'support-form'}>
|
||||
{claimId && !hideClaimLabel && (
|
||||
<p style={{ marginBottom: 12, color: '#666', fontSize: 13 }}>По обращению №{claimId}</p>
|
||||
)}
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
name="message"
|
||||
label="Сообщение"
|
||||
rules={[{ required: true, message: 'Введите текст обращения' }]}
|
||||
>
|
||||
<TextArea rows={compact ? 3 : 5} placeholder="Опишите вопрос или проблему..." maxLength={5000} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item name="subject" label="Тема (необязательно)">
|
||||
<Input placeholder="Краткая тема" maxLength={200} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Прикрепить файлы">
|
||||
{limitHint && <p style={{ fontSize: 12, color: '#888', marginBottom: 8 }}>{limitHint}</p>}
|
||||
<input
|
||||
key={fileInputKey}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
id="support-attachments-input"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<label htmlFor="support-attachments-input">
|
||||
<Button
|
||||
type="button"
|
||||
icon={<Paperclip size={16} style={{ verticalAlign: 'middle', marginRight: 6 }} />}
|
||||
disabled={!canAddFile()}
|
||||
onClick={() => document.getElementById('support-attachments-input')?.click()}
|
||||
>
|
||||
Прикрепить файлы
|
||||
</Button>
|
||||
</label>
|
||||
{files.length > 0 && (
|
||||
<ul style={{ marginTop: 8, paddingLeft: 20 }}>
|
||||
{files.map((f, i) => (
|
||||
<li key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Удалить"
|
||||
onClick={() => removeFile(i)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={submitting} block={compact}>
|
||||
Отправить
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Form, Input, Button, message, Space } from 'antd';
|
||||
import { PhoneOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||
import { Form, Input, Button, message, Space, Modal } from 'antd';
|
||||
import { PhoneOutlined, SafetyOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
|
||||
interface Props {
|
||||
formData: any;
|
||||
@@ -23,6 +23,8 @@ export default function Step1Phone({
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [verifyLoading, setVerifyLoading] = useState(false);
|
||||
const [debugCode, setDebugCode] = useState<string | null>(null);
|
||||
const [showDebugModal, setShowDebugModal] = useState(false);
|
||||
|
||||
const sendCode = async () => {
|
||||
try {
|
||||
@@ -49,7 +51,13 @@ export default function Step1Phone({
|
||||
message.success('Код отправлен на ваш телефон');
|
||||
setCodeSent(true);
|
||||
updateFormData({ phone });
|
||||
// DEBUG код не показываем в продакшене
|
||||
|
||||
// 🔧 DEV MODE: показываем debug код в модалке (только в development)
|
||||
// В production debug_code не приходит с сервера, поэтому модалка не покажется
|
||||
if (result.debug_code && import.meta.env.MODE === 'development') {
|
||||
setDebugCode(result.debug_code);
|
||||
setShowDebugModal(true);
|
||||
}
|
||||
} else {
|
||||
addDebugEvent?.('sms', 'error', `❌ Ошибка SMS: ${result.detail}`, { error: result.detail });
|
||||
message.error(result.detail || 'Ошибка отправки кода');
|
||||
@@ -334,7 +342,60 @@ export default function Step1Phone({
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
{/* DEV MODE секция удалена для продакшена */}
|
||||
{/* 🔧 DEV MODE: Модалка с SMS кодом (только в development) */}
|
||||
{import.meta.env.MODE === 'development' && (
|
||||
<Modal
|
||||
title="🔧 DEV MODE - SMS Код"
|
||||
open={showDebugModal}
|
||||
onCancel={() => setShowDebugModal(false)}
|
||||
footer={[
|
||||
<Button
|
||||
key="copy"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
if (debugCode) {
|
||||
// Fallback для HTTP (clipboard API требует HTTPS)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(debugCode);
|
||||
} else {
|
||||
// Fallback: копируем через textarea
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = debugCode;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
message.success('Код скопирован!');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Скопировать
|
||||
</Button>,
|
||||
<Button key="close" type="primary" onClick={() => setShowDebugModal(false)}>
|
||||
Закрыть
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<p style={{ marginBottom: 16, color: '#666' }}>
|
||||
Это DEV режим. SMS не отправляется реально.
|
||||
</p>
|
||||
<div style={{
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
fontFamily: 'monospace',
|
||||
background: '#f5f5f5',
|
||||
padding: '16px 32px',
|
||||
borderRadius: 8,
|
||||
display: 'inline-block',
|
||||
letterSpacing: 8
|
||||
}}>
|
||||
{debugCode}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,23 @@ export default function Step3Payment({
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const banksData: Bank[] = await response.json();
|
||||
// Наш API возвращает формат: [{"bankId":"...","bankName":"..."}]
|
||||
let banksData: Bank[] = await response.json();
|
||||
|
||||
// Преобразуем формат нашего 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) => a.bankname.localeCompare(b.bankname, 'ru'));
|
||||
banksData.sort((a, b) => {
|
||||
const nameA = (a.bankname || '').toString();
|
||||
const nameB = (b.bankname || '').toString();
|
||||
return nameA.localeCompare(nameB, 'ru');
|
||||
});
|
||||
|
||||
setBanks(banksData);
|
||||
addDebugEvent?.('banks', 'success', `✅ Загружено ${banksData.length} банков`, { count: banksData.length });
|
||||
@@ -62,29 +76,31 @@ export default function Step3Payment({
|
||||
// Если есть сохранённый bankName или bankId - восстанавливаем значения
|
||||
if (formData.bankName) {
|
||||
const foundBank = banksData.find(b =>
|
||||
b && b.bankname && (
|
||||
b.bankname.toLowerCase() === formData.bankName.toLowerCase() ||
|
||||
b.bankname.toLowerCase().includes(formData.bankName.toLowerCase())
|
||||
)
|
||||
);
|
||||
if (foundBank) {
|
||||
if (foundBank && foundBank.bankname) {
|
||||
updateFormData({
|
||||
bankId: foundBank.bankid,
|
||||
bankId: foundBank.bankid || '',
|
||||
bankName: foundBank.bankname
|
||||
});
|
||||
form.setFieldsValue({
|
||||
bankId: foundBank.bankid,
|
||||
bankId: foundBank.bankid || '',
|
||||
bankName: foundBank.bankname
|
||||
});
|
||||
}
|
||||
} else if (formData.bankId) {
|
||||
// Если есть только bankId, находим по ID
|
||||
const foundBank = banksData.find(b => b.bankid === formData.bankId);
|
||||
if (foundBank) {
|
||||
const foundBank = banksData.find(b => b && b.bankid === formData.bankId);
|
||||
if (foundBank && foundBank.bankname) {
|
||||
updateFormData({
|
||||
bankId: foundBank.bankid,
|
||||
bankId: foundBank.bankid || '',
|
||||
bankName: foundBank.bankname
|
||||
});
|
||||
form.setFieldsValue({
|
||||
bankId: foundBank.bankid,
|
||||
bankId: foundBank.bankid || '',
|
||||
bankName: foundBank.bankname
|
||||
});
|
||||
}
|
||||
@@ -414,7 +430,7 @@ export default function Step3Payment({
|
||||
return Promise.resolve();
|
||||
}
|
||||
const foundBank = banks.find(b =>
|
||||
b.bankname.toLowerCase() === value.toLowerCase()
|
||||
b && b.bankname && b.bankname.toLowerCase() === value.toLowerCase()
|
||||
);
|
||||
if (!foundBank) {
|
||||
return Promise.reject(new Error('Выберите банк из списка'));
|
||||
@@ -429,7 +445,9 @@ export default function Step3Payment({
|
||||
size="large"
|
||||
loading={banksLoading}
|
||||
notFoundContent={banksLoading ? "Загрузка..." : "Банк не найден. Попробуйте ввести другое название"}
|
||||
options={banks.map((bank) => ({
|
||||
options={banks
|
||||
.filter(bank => bank && bank.bankname)
|
||||
.map((bank) => ({
|
||||
value: bank.bankname,
|
||||
label: bank.bankname,
|
||||
}))}
|
||||
@@ -439,28 +457,28 @@ export default function Step3Payment({
|
||||
}}
|
||||
onSelect={(value) => {
|
||||
// При выборе из списка находим банк и сохраняем оба поля
|
||||
const selectedBank = banks.find(b => b.bankname === value);
|
||||
if (selectedBank) {
|
||||
const selectedBank = banks.find(b => b && b.bankname && b.bankname === value);
|
||||
if (selectedBank && selectedBank.bankname) {
|
||||
updateFormData({
|
||||
bankId: selectedBank.bankid,
|
||||
bankId: selectedBank.bankid || '',
|
||||
bankName: selectedBank.bankname
|
||||
});
|
||||
// Устанавливаем bankId в скрытое поле
|
||||
form.setFieldsValue({ bankId: selectedBank.bankid });
|
||||
form.setFieldsValue({ bankId: selectedBank.bankid || '' });
|
||||
}
|
||||
}}
|
||||
onChange={(value) => {
|
||||
// При вводе текста ищем точное совпадение по названию
|
||||
if (typeof value === 'string') {
|
||||
const foundBank = banks.find(b =>
|
||||
b.bankname.toLowerCase() === value.toLowerCase()
|
||||
b && b.bankname && b.bankname.toLowerCase() === value.toLowerCase()
|
||||
);
|
||||
if (foundBank) {
|
||||
if (foundBank && foundBank.bankname) {
|
||||
updateFormData({
|
||||
bankId: foundBank.bankid,
|
||||
bankId: foundBank.bankid || '',
|
||||
bankName: foundBank.bankname
|
||||
});
|
||||
form.setFieldsValue({ bankId: foundBank.bankid });
|
||||
form.setFieldsValue({ bankId: foundBank.bankid || '' });
|
||||
} else if (value === '') {
|
||||
// Если поле очищено, очищаем и bankId
|
||||
updateFormData({ bankId: undefined, bankName: undefined });
|
||||
|
||||
@@ -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]);
|
||||
|
||||
37
frontend/src/components/form/StepComplaintsDashboard.css
Normal file
37
frontend/src/components/form/StepComplaintsDashboard.css
Normal file
@@ -0,0 +1,37 @@
|
||||
/* Карточки дашборда — в стиле экрана hello: тень и подъём при наведении, одинаковая высота */
|
||||
.dashboard-tile {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.06);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
cursor: pointer;
|
||||
min-height: 88px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dashboard-tile:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 22px 36px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.dashboard-tile .ant-card-body {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
/* чтобы все плитки в ряду были одной высоты */
|
||||
.dashboard-tile-row .ant-col {
|
||||
display: flex;
|
||||
}
|
||||
.dashboard-tile-row .ant-col .dashboard-tile {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* заголовок плитки — фиксированная высота под 2 строки, чтобы «Приняты к работе» не делал карточку выше */
|
||||
.dashboard-tile-title {
|
||||
min-height: 2.5em;
|
||||
line-height: 1.25;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
330
frontend/src/components/form/StepComplaintsDashboard.tsx
Normal file
330
frontend/src/components/form/StepComplaintsDashboard.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* StepComplaintsDashboard.tsx
|
||||
*
|
||||
* Экран «Мои обращения»: плитки по статусам + кнопка «Подать жалобу».
|
||||
* Показывается после нажатия «Мои обращения» на приветственном экране.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, Row, Col, Typography, Spin } from 'antd';
|
||||
import { Clock, Briefcase, CheckCircle, XCircle, FileSearch, PlusCircle, MessageCircle } from 'lucide-react';
|
||||
|
||||
import './StepComplaintsDashboard.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// Признак элемента из CRM (проект/тикет)
|
||||
function isFromCrm(d: DraftItem): boolean {
|
||||
const p = (d as any).payload;
|
||||
return (d as any).type_code === 'external_case' || p?.source === 'CRM' || (p && 'projectid' in p);
|
||||
}
|
||||
|
||||
// Тикет из CRM (для карточки «Консультации»)
|
||||
function isCrmTicket(d: DraftItem): boolean {
|
||||
if (!isFromCrm(d)) return false;
|
||||
const p = (d as any).payload;
|
||||
return p?.ticketid != null || p?.ticket_no != null || (d as any).type_code === 'consultation';
|
||||
}
|
||||
|
||||
// Статус CRM: resolved | rejected | in_work (по status_code или payload.projectstatus)
|
||||
function getCrmStatus(d: DraftItem): 'resolved' | 'rejected' | 'in_work' {
|
||||
const code = ((d as any).status_code || '').toLowerCase();
|
||||
const p = (d as any).payload;
|
||||
const projectStatus = (p?.projectstatus || p?.status || '').toString().toLowerCase();
|
||||
if (code === 'completed' || code === 'submitted' || projectStatus.includes('завершено') || projectStatus === 'completed') return 'resolved';
|
||||
if (code === 'rejected' || projectStatus.includes('отклонен')) return 'rejected';
|
||||
return 'in_work';
|
||||
}
|
||||
|
||||
interface DraftItem {
|
||||
claim_id?: string;
|
||||
id?: string;
|
||||
status_code?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
type_code?: string;
|
||||
}
|
||||
|
||||
interface Counts {
|
||||
consultations: number;
|
||||
pending: number;
|
||||
inWork: number;
|
||||
resolved: number;
|
||||
rejected: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Правила: Консультации = тикеты из CRM; В работе = проекты из CRM не завершено + черновики; Решены/Отклонены = из CRM; В ожидании = все из Postgres
|
||||
function countByStatus(drafts: DraftItem[]): Counts {
|
||||
let consultations = 0;
|
||||
let pending = 0;
|
||||
let inWork = 0;
|
||||
let resolved = 0;
|
||||
let rejected = 0;
|
||||
for (const d of drafts) {
|
||||
if (isFromCrm(d)) {
|
||||
if (isCrmTicket(d)) consultations += 1;
|
||||
const crmStatus = getCrmStatus(d);
|
||||
if (crmStatus === 'resolved') resolved += 1;
|
||||
else if (crmStatus === 'rejected') rejected += 1;
|
||||
else inWork += 1;
|
||||
} else {
|
||||
// Всё из Postgres → «В ожидании»
|
||||
pending += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
consultations,
|
||||
pending,
|
||||
inWork,
|
||||
resolved,
|
||||
rejected,
|
||||
total: drafts.length,
|
||||
};
|
||||
}
|
||||
|
||||
export type DraftsListFilter = 'all' | 'pending' | 'in_work' | 'resolved' | 'rejected';
|
||||
|
||||
interface StepComplaintsDashboardProps {
|
||||
unified_id?: string;
|
||||
phone?: string;
|
||||
session_id?: string;
|
||||
/** Канал входа: telegram | max | web */
|
||||
entry_channel?: string;
|
||||
/** Список обращений от родителя (один запрос в n8n) — если передан, свой запрос не делаем */
|
||||
drafts?: DraftItem[];
|
||||
loading?: boolean;
|
||||
onGoToList: (filter: DraftsListFilter) => void;
|
||||
onNewClaim: () => void;
|
||||
onNavigate?: (path: string) => void;
|
||||
}
|
||||
|
||||
export default function StepComplaintsDashboard({
|
||||
unified_id,
|
||||
phone,
|
||||
session_id,
|
||||
entry_channel,
|
||||
drafts: draftsFromProps,
|
||||
loading: loadingFromProps,
|
||||
onGoToList,
|
||||
onNewClaim,
|
||||
onNavigate,
|
||||
}: StepComplaintsDashboardProps) {
|
||||
const [counts, setCounts] = useState<Counts>({ consultations: 0, pending: 0, inWork: 0, resolved: 0, rejected: 0, total: 0 });
|
||||
const [localLoading, setLocalLoading] = useState(true);
|
||||
|
||||
const loading = loadingFromProps ?? localLoading;
|
||||
|
||||
useEffect(() => {
|
||||
if (draftsFromProps !== undefined) {
|
||||
setCounts(countByStatus(Array.isArray(draftsFromProps) ? draftsFromProps : []));
|
||||
setLocalLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!unified_id && !phone && !session_id) {
|
||||
setLocalLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const params = new URLSearchParams();
|
||||
if (unified_id) params.append('unified_id', unified_id);
|
||||
if (phone) params.append('phone', phone);
|
||||
if (session_id) params.append('session_id', session_id);
|
||||
params.append('entry_channel', (entry_channel || 'web').trim() || 'web');
|
||||
fetch(`/api/v1/claims/drafts/list?${params.toString()}`)
|
||||
.then((res) => (res.ok ? res.json() : Promise.reject(new Error('Не удалось загрузить список'))))
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setCounts(countByStatus(data.drafts || []));
|
||||
})
|
||||
.catch(() => { if (!cancelled) setCounts((c) => ({ ...c, consultations: 0, pending: 0, inWork: 0, resolved: 0, rejected: 0, total: 0 })); })
|
||||
.finally(() => { if (!cancelled) setLocalLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [draftsFromProps, unified_id, phone, session_id, entry_channel]);
|
||||
|
||||
const tiles = [
|
||||
{
|
||||
key: 'pending' as const,
|
||||
title: 'В ожидании',
|
||||
count: counts.pending,
|
||||
label: counts.pending === 1 ? '1 дело' : counts.pending < 5 ? `${counts.pending} дела` : `${counts.pending} дел`,
|
||||
color: '#3B82F6',
|
||||
bg: '#EFF6FF',
|
||||
icon: Clock,
|
||||
},
|
||||
{
|
||||
key: 'in_work' as const,
|
||||
title: 'Приняты к работе',
|
||||
count: counts.inWork,
|
||||
label: counts.inWork === 1 ? '1 дело' : counts.inWork < 5 ? `${counts.inWork} дела` : `${counts.inWork} дел`,
|
||||
color: '#EA580C',
|
||||
bg: '#FFF7ED',
|
||||
icon: Briefcase,
|
||||
},
|
||||
{
|
||||
key: 'resolved' as const,
|
||||
title: 'Решены',
|
||||
count: counts.resolved,
|
||||
label: counts.resolved === 1 ? '1 дело' : counts.resolved < 5 ? `${counts.resolved} дела` : `${counts.resolved} дел`,
|
||||
color: '#16A34A',
|
||||
bg: '#F0FDF4',
|
||||
icon: CheckCircle,
|
||||
},
|
||||
{
|
||||
key: 'rejected' as const,
|
||||
title: 'Отклонены',
|
||||
count: counts.rejected,
|
||||
label: counts.rejected === 1 ? '1 дело' : counts.rejected < 5 ? `${counts.rejected} дела` : `${counts.rejected} дел`,
|
||||
color: '#DC2626',
|
||||
bg: '#FEF2F2',
|
||||
icon: XCircle,
|
||||
},
|
||||
];
|
||||
|
||||
const handleTileClick = (key: DraftsListFilter) => {
|
||||
onGoToList(key);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px', paddingBottom: 24 }}>
|
||||
<Title level={2} style={{ marginBottom: 4, color: '#111827', fontSize: 22 }}>
|
||||
Мои обращения
|
||||
</Title>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 20 }}>
|
||||
Выберите категорию
|
||||
</Text>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '48px 0' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Плитка «Консультации» — в самом верху; данные из CRM по вебхуку */}
|
||||
{onNavigate && (
|
||||
<Card
|
||||
size="small"
|
||||
className="dashboard-tile"
|
||||
style={{ background: '#F5F3FF', marginBottom: 12 }}
|
||||
onClick={() => onNavigate('/consultations')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#8B5CF6',
|
||||
}}
|
||||
>
|
||||
<MessageCircle size={24} strokeWidth={1.8} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text strong style={{ display: 'block', color: '#111827', fontSize: 14 }}>
|
||||
Консультации
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{counts.consultations === 0
|
||||
? 'Тикеты из CRM'
|
||||
: counts.consultations === 1
|
||||
? '1 тикет'
|
||||
: counts.consultations < 5
|
||||
? `${counts.consultations} тикета`
|
||||
: `${counts.consultations} тикетов`}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }} className="dashboard-tile-row">
|
||||
{tiles.map((t) => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<Col xs={12} key={t.key}>
|
||||
<Card
|
||||
size="small"
|
||||
className="dashboard-tile"
|
||||
style={{ background: t.bg }}
|
||||
onClick={() => handleTileClick(t.key)}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: t.color,
|
||||
}}
|
||||
>
|
||||
<Icon size={24} strokeWidth={1.8} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text strong style={{ display: 'block', color: '#111827', fontSize: 14 }} className="dashboard-tile-title">
|
||||
{t.title}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{t.label}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
className="dashboard-tile"
|
||||
style={{ background: '#F9FAFB', marginBottom: 12 }}
|
||||
onClick={() => handleTileClick('all' as const)}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#6366F1',
|
||||
}}
|
||||
>
|
||||
<FileSearch size={24} strokeWidth={1.8} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text strong style={{ display: 'block', color: '#111827', fontSize: 14 }}>
|
||||
Все обращения
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{counts.total === 1 ? '1 дело всего' : counts.total < 5 ? `${counts.total} дела всего` : `${counts.total} дел всего`}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
icon={<PlusCircle size={20} style={{ verticalAlign: 'middle', marginRight: 8 }} />}
|
||||
onClick={onNewClaim}
|
||||
style={{ height: 48, fontSize: 16, borderRadius: 12 }}
|
||||
>
|
||||
Подать жалобу
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ interface Props {
|
||||
export default function StepDescription({
|
||||
formData,
|
||||
updateFormData,
|
||||
onPrev,
|
||||
onPrev: _onPrev,
|
||||
onNext,
|
||||
}: Props) {
|
||||
const [form] = Form.useForm();
|
||||
@@ -75,14 +75,18 @@ export default function StepDescription({
|
||||
return;
|
||||
}
|
||||
|
||||
const entryChannel =
|
||||
(typeof window !== 'undefined' && (window as any).Telegram?.WebApp?.initData) ? 'telegram'
|
||||
: (typeof window !== 'undefined' && (window as any).WebApp?.initData) ? 'max'
|
||||
: 'web';
|
||||
|
||||
console.log('📝 Отправка описания проблемы на сервер:', {
|
||||
session_id: formData.session_id,
|
||||
phone: formData.phone,
|
||||
email: formData.email,
|
||||
unified_id: formData.unified_id,
|
||||
contact_id: formData.contact_id,
|
||||
entry_channel: entryChannel,
|
||||
description_length: safeDescription.length,
|
||||
description_preview: safeDescription.substring(0, 100),
|
||||
});
|
||||
|
||||
const response = await fetch('/api/v1/claims/description', {
|
||||
@@ -92,9 +96,10 @@ export default function StepDescription({
|
||||
session_id: formData.session_id,
|
||||
phone: formData.phone,
|
||||
email: formData.email,
|
||||
unified_id: formData.unified_id, // ✅ Unified ID пользователя
|
||||
contact_id: formData.contact_id, // ✅ Contact ID пользователя
|
||||
unified_id: formData.unified_id,
|
||||
contact_id: formData.contact_id,
|
||||
problem_description: safeDescription,
|
||||
entry_channel: entryChannel, // telegram | max | web — для роутинга в n8n
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -114,6 +119,10 @@ export default function StepDescription({
|
||||
|
||||
const responseData = await response.json();
|
||||
console.log('✅ Описание успешно отправлено:', responseData);
|
||||
console.log('📥 Ответ n8n (description):', responseData);
|
||||
if (responseData && typeof responseData === 'object') {
|
||||
console.log('📥 Ключи ответа n8n:', Object.keys(responseData));
|
||||
}
|
||||
|
||||
message.success('Описание отправлено, подбираем рекомендации...');
|
||||
updateFormData({
|
||||
@@ -135,13 +144,9 @@ export default function StepDescription({
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Button onClick={onPrev} size="large">
|
||||
← Назад
|
||||
</Button>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 24,
|
||||
marginTop: 0,
|
||||
padding: 24,
|
||||
background: '#f6f8fa',
|
||||
borderRadius: 8,
|
||||
|
||||
12
frontend/src/components/form/StepDraftSelection.css
Normal file
12
frontend/src/components/form/StepDraftSelection.css
Normal file
@@ -0,0 +1,12 @@
|
||||
/* Карточки списка обращений — как на hello: тень и подъём при наведении */
|
||||
.draft-list-card {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.06);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.draft-list-card:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 22px 36px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
@@ -14,11 +14,10 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, List, Typography, Space, Empty, Popconfirm, message, Spin, Tag, Alert, Progress, Tooltip } from 'antd';
|
||||
import { Button, Card, Modal, Typography, Space, Empty, message, Spin, Tooltip } from 'antd';
|
||||
import {
|
||||
FileTextOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
ClockCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
@@ -26,10 +25,57 @@ import {
|
||||
UploadOutlined,
|
||||
FileSearchOutlined,
|
||||
MobileOutlined,
|
||||
ExclamationCircleOutlined
|
||||
ExclamationCircleOutlined,
|
||||
FolderOpenOutlined
|
||||
} from '@ant-design/icons';
|
||||
import './StepDraftSelection.css';
|
||||
import {
|
||||
Package,
|
||||
Wrench,
|
||||
Wallet,
|
||||
ShoppingCart,
|
||||
Truck,
|
||||
Plane,
|
||||
GraduationCap,
|
||||
Wifi,
|
||||
Home,
|
||||
Hammer,
|
||||
HeartPulse,
|
||||
Car,
|
||||
Building,
|
||||
Shield,
|
||||
Ticket,
|
||||
Headphones,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import SupportChat from '../SupportChat';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// Иконки по направлениям (категориям) для плиток
|
||||
const DIRECTION_ICONS: Record<string, LucideIcon> = {
|
||||
'товары': Package,
|
||||
'услуги': Wrench,
|
||||
'финансы и платежи': Wallet,
|
||||
'интернет-торговля и маркетплейсы': ShoppingCart,
|
||||
'доставка и логистика': Truck,
|
||||
'туризм и путешествия': Plane,
|
||||
'образование и онлайн-курсы': GraduationCap,
|
||||
'связь и интернет': Wifi,
|
||||
'жкх и коммунальные услуги': Home,
|
||||
'строительство и ремонт': Hammer,
|
||||
'медицина и платные клиники': HeartPulse,
|
||||
'транспорт и перевозки': Car,
|
||||
'недвижимость и аренда': Building,
|
||||
'страхование': Shield,
|
||||
'развлечения и мероприятия': Ticket,
|
||||
};
|
||||
|
||||
function getDirectionIcon(directionOrCategory: string | undefined): LucideIcon | null {
|
||||
if (!directionOrCategory || typeof directionOrCategory !== 'string') return null;
|
||||
const key = directionOrCategory.trim().toLowerCase();
|
||||
return DIRECTION_ICONS[key] || null;
|
||||
}
|
||||
|
||||
// Форматирование даты
|
||||
const formatDate = (dateStr: string) => {
|
||||
@@ -46,6 +92,58 @@ const formatDate = (dateStr: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Короткая дата для карточек списка: "12 апреля 2024"
|
||||
const formatDateShort = (dateStr: string) => {
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const day = date.getDate();
|
||||
const month = date.toLocaleDateString('ru-RU', { month: 'long' });
|
||||
const year = date.getFullYear();
|
||||
return `${day} ${month} ${year}`;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
// Маппинг status_code → категория дашборда (как в StepComplaintsDashboard)
|
||||
const PENDING_CODES = ['draft', 'draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready', 'awaiting_sms'];
|
||||
const IN_WORK_CODE = 'in_work';
|
||||
const RESOLVED_CODES = ['completed', 'submitted'];
|
||||
const REJECTED_CODE = 'rejected';
|
||||
|
||||
function getDraftCategory(statusCode: string): 'pending' | 'in_work' | 'resolved' | 'rejected' {
|
||||
const code = (statusCode || '').toLowerCase();
|
||||
if (code === IN_WORK_CODE) return 'in_work';
|
||||
if (code === REJECTED_CODE) return 'rejected';
|
||||
if (RESOLVED_CODES.includes(code)) return 'resolved';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
/** Признак элемента из CRM (проект/тикет) — как в StepComplaintsDashboard */
|
||||
function isFromCrm(d: { payload?: Record<string, unknown>; type_code?: string }): boolean {
|
||||
const p = d.payload;
|
||||
return d.type_code === 'external_case' || (p as any)?.source === 'CRM' || (p && 'projectid' in (p || {}));
|
||||
}
|
||||
|
||||
/** Категория для фильтра и плитки: Postgres по status_code, CRM по status_code (active→in_work, completed→resolved, rejected→rejected) */
|
||||
function getItemCategory(draft: { status_code?: string; payload?: Record<string, unknown>; type_code?: string }): 'pending' | 'in_work' | 'resolved' | 'rejected' {
|
||||
if (isFromCrm(draft)) {
|
||||
const code = (draft.status_code || '').toLowerCase();
|
||||
if (code === 'completed' || (draft.payload as any)?.projectstatus === 'completed') return 'resolved';
|
||||
if (code === 'rejected') return 'rejected';
|
||||
return 'in_work'; // active и всё остальное (тикеты, черновики CRM и т.д.)
|
||||
}
|
||||
return getDraftCategory(draft.status_code || '');
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<'all' | 'pending' | 'in_work' | 'resolved' | 'rejected', string> = {
|
||||
all: 'Все обращения',
|
||||
pending: 'В ожидании',
|
||||
in_work: 'Приняты к работе',
|
||||
resolved: 'Решены',
|
||||
rejected: 'Отклонены',
|
||||
};
|
||||
|
||||
// Относительное время
|
||||
const getRelativeTime = (dateStr: string) => {
|
||||
try {
|
||||
@@ -83,6 +181,8 @@ interface Draft {
|
||||
problem_title?: string; // Краткое описание (заголовок)
|
||||
problem_description?: string;
|
||||
category?: string; // Категория проблемы
|
||||
direction?: string; // Направление (для иконки плитки)
|
||||
facts_short?: string; // Краткие факты от AI — заголовок плитки
|
||||
wizard_plan: boolean;
|
||||
wizard_answers: boolean;
|
||||
has_documents: boolean;
|
||||
@@ -96,13 +196,29 @@ interface Draft {
|
||||
is_legacy?: boolean; // Старый формат без documents_required
|
||||
}
|
||||
|
||||
/** Фильтр списка по категории (с дашборда) */
|
||||
export type DraftsListFilter = 'all' | 'pending' | 'in_work' | 'resolved' | 'rejected';
|
||||
|
||||
interface Props {
|
||||
phone?: string;
|
||||
session_id?: string;
|
||||
unified_id?: string;
|
||||
isTelegramMiniApp?: boolean;
|
||||
entry_channel?: string;
|
||||
/** Список обращений от родителя (один запрос в n8n) — если передан, свой запрос не делаем */
|
||||
drafts?: Draft[] | any[];
|
||||
loading?: boolean;
|
||||
/** Вызов после удаления черновика, чтобы родитель перезапросил список */
|
||||
onRefreshDrafts?: () => void;
|
||||
/** ID черновика, открытого для просмотра описания (управляется из ClaimForm, чтобы не терять при пересчёте steps) */
|
||||
draftDetailClaimId?: string | null;
|
||||
/** Показывать только обращения этой категории (с дашборда) */
|
||||
categoryFilter?: DraftsListFilter;
|
||||
onOpenDraftDetail?: (claimId: string) => void;
|
||||
onCloseDraftDetail?: () => void;
|
||||
onSelectDraft: (claimId: string) => void;
|
||||
onNewClaim: () => void;
|
||||
onRestartDraft?: (claimId: string, description: string) => void; // Для legacy черновиков
|
||||
onRestartDraft?: (claimId: string, description: string) => void;
|
||||
}
|
||||
|
||||
// === Конфиг статусов ===
|
||||
@@ -162,6 +278,27 @@ const STATUS_CONFIG: Record<string, {
|
||||
description: 'Заявка на рассмотрении',
|
||||
action: 'Просмотреть',
|
||||
},
|
||||
active: {
|
||||
color: 'cyan',
|
||||
icon: <FileSearchOutlined />,
|
||||
label: 'В работе',
|
||||
description: 'Дело из CRM',
|
||||
action: 'Просмотреть',
|
||||
},
|
||||
completed: {
|
||||
color: 'green',
|
||||
icon: <CheckCircleOutlined />,
|
||||
label: 'Решено',
|
||||
description: 'Дело завершено',
|
||||
action: 'Просмотреть',
|
||||
},
|
||||
rejected: {
|
||||
color: 'red',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
label: 'Отклонено',
|
||||
description: 'Дело отклонено',
|
||||
action: 'Просмотреть',
|
||||
},
|
||||
legacy: {
|
||||
color: 'warning',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
@@ -171,71 +308,81 @@ const STATUS_CONFIG: Record<string, {
|
||||
},
|
||||
};
|
||||
|
||||
function processDraftsFromApi(raw: any[]): Draft[] {
|
||||
return (raw || []).map((draft: Draft) => {
|
||||
const isNewFlowStatus = ['draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready'].includes(draft.status_code || '');
|
||||
const isLegacy = !isNewFlowStatus && draft.wizard_plan && draft.status_code === 'draft';
|
||||
return { ...draft, is_legacy: isLegacy };
|
||||
});
|
||||
}
|
||||
|
||||
export default function StepDraftSelection({
|
||||
phone,
|
||||
session_id,
|
||||
unified_id,
|
||||
isTelegramMiniApp,
|
||||
entry_channel,
|
||||
drafts: draftsFromProps,
|
||||
loading: loadingFromProps,
|
||||
onRefreshDrafts,
|
||||
draftDetailClaimId = null,
|
||||
categoryFilter = 'all',
|
||||
onOpenDraftDetail,
|
||||
onCloseDraftDetail,
|
||||
onSelectDraft,
|
||||
onNewClaim,
|
||||
onRestartDraft,
|
||||
}: Props) {
|
||||
const [drafts, setDrafts] = useState<Draft[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [localDrafts, setLocalDrafts] = useState<Draft[]>([]);
|
||||
const [localLoading, setLocalLoading] = useState(true);
|
||||
const drafts = draftsFromProps !== undefined ? processDraftsFromApi(Array.isArray(draftsFromProps) ? draftsFromProps : []) : localDrafts;
|
||||
const loading = loadingFromProps !== undefined ? loadingFromProps : localLoading;
|
||||
const [supportModalClaimId, setSupportModalClaimId] = useState<string | null>(null);
|
||||
|
||||
/** Список отфильтрован по категории с дашборда (учёт и Postgres, и CRM) */
|
||||
const filteredDrafts =
|
||||
categoryFilter === 'all'
|
||||
? drafts
|
||||
: drafts.filter((d) => getItemCategory(d) === categoryFilter);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
/** Полный payload черновика с API GET /drafts/{claim_id} для экрана описания */
|
||||
const [detailDraftPayload, setDetailDraftPayload] = useState<{ claimId: string; payload: Record<string, unknown> } | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
/** Черновик для экрана описания: из пропа draftDetailClaimId + список drafts */
|
||||
const selectedDraft = draftDetailClaimId
|
||||
? (drafts.find((d) => (d.claim_id || d.id) === draftDetailClaimId) ?? null)
|
||||
: null;
|
||||
|
||||
const loadDrafts = async () => {
|
||||
if (draftsFromProps !== undefined) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
setLocalLoading(true);
|
||||
if (!unified_id && !phone && !session_id) {
|
||||
setLocalLoading(false);
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (unified_id) {
|
||||
params.append('unified_id', unified_id);
|
||||
console.log('🔍 StepDraftSelection: загружаем черновики по unified_id:', unified_id);
|
||||
} else if (phone) {
|
||||
params.append('phone', phone);
|
||||
console.log('🔍 StepDraftSelection: загружаем черновики по phone:', phone);
|
||||
} else if (session_id) {
|
||||
params.append('session_id', session_id);
|
||||
console.log('🔍 StepDraftSelection: загружаем черновики по session_id:', session_id);
|
||||
}
|
||||
|
||||
const url = `/api/v1/claims/drafts/list?${params.toString()}`;
|
||||
console.log('🔍 StepDraftSelection: запрос:', url);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось загрузить черновики');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('🔍 StepDraftSelection: ответ API:', data);
|
||||
|
||||
// Определяем legacy черновики (без documents_required в payload)
|
||||
const processedDrafts = (data.drafts || []).map((draft: Draft) => {
|
||||
// Legacy только если:
|
||||
// 1. Статус 'draft' (старый формат) ИЛИ
|
||||
// 2. Нет новых статусов (draft_new, draft_docs_progress, draft_docs_complete, draft_claim_ready)
|
||||
// И есть wizard_plan (старый формат)
|
||||
const isNewFlowStatus = ['draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready'].includes(draft.status_code || '');
|
||||
const isLegacy = !isNewFlowStatus && draft.wizard_plan && draft.status_code === 'draft';
|
||||
return {
|
||||
...draft,
|
||||
is_legacy: isLegacy,
|
||||
};
|
||||
});
|
||||
|
||||
setDrafts(processedDrafts);
|
||||
if (unified_id) params.append('unified_id', unified_id);
|
||||
if (phone) params.append('phone', phone);
|
||||
if (session_id) params.append('session_id', session_id);
|
||||
params.append('entry_channel', (entry_channel || 'web').trim() || 'web');
|
||||
const res = await fetch(`/api/v1/claims/drafts/list?${params.toString()}`);
|
||||
if (!res.ok) throw new Error('Не удалось загрузить черновики');
|
||||
const data = await res.json();
|
||||
setLocalDrafts(processDraftsFromApi(data.drafts || []));
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки черновиков:', error);
|
||||
message.error('Не удалось загрузить список черновиков');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLocalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (draftsFromProps !== undefined) return;
|
||||
loadDrafts();
|
||||
}, [phone, session_id, unified_id]);
|
||||
}, [phone, unified_id, entry_channel, draftsFromProps]);
|
||||
|
||||
const handleDelete = async (claimId: string) => {
|
||||
try {
|
||||
@@ -249,7 +396,7 @@ export default function StepDraftSelection({
|
||||
}
|
||||
|
||||
message.success('Черновик удален');
|
||||
await loadDrafts();
|
||||
if (onRefreshDrafts) await onRefreshDrafts(); else await loadDrafts();
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления черновика:', error);
|
||||
message.error('Не удалось удалить черновик');
|
||||
@@ -263,6 +410,10 @@ export default function StepDraftSelection({
|
||||
if (draft.is_legacy) {
|
||||
return STATUS_CONFIG.legacy;
|
||||
}
|
||||
if (isFromCrm(draft)) {
|
||||
const code = (draft.status_code || 'active').toLowerCase();
|
||||
return STATUS_CONFIG[code] || STATUS_CONFIG.active;
|
||||
}
|
||||
return STATUS_CONFIG[draft.status_code] || STATUS_CONFIG.draft;
|
||||
};
|
||||
|
||||
@@ -276,6 +427,38 @@ export default function StepDraftSelection({
|
||||
return { uploaded, skipped, total, percent };
|
||||
};
|
||||
|
||||
// Открыть экран полного описания (загрузка payload — в useEffect по draftDetailClaimId)
|
||||
const openDraftDetail = (draft: Draft) => {
|
||||
const draftId = draft.claim_id || draft.id;
|
||||
onOpenDraftDetail?.(draftId);
|
||||
setDetailDraftPayload(null);
|
||||
setDetailLoading(true);
|
||||
};
|
||||
|
||||
const closeDraftDetail = () => {
|
||||
onCloseDraftDetail?.();
|
||||
setDetailDraftPayload(null);
|
||||
};
|
||||
|
||||
// Загрузка payload при открытии по draftDetailClaimId (клик по карточке или восстановление после пересчёта steps)
|
||||
useEffect(() => {
|
||||
if (!draftDetailClaimId) return;
|
||||
if (detailDraftPayload?.claimId === draftDetailClaimId) return;
|
||||
setDetailLoading(true);
|
||||
setDetailDraftPayload(null);
|
||||
const claimId = draftDetailClaimId;
|
||||
fetch(`/api/v1/claims/drafts/${claimId}`)
|
||||
.then((res) => (res.ok ? res.json() : Promise.reject(new Error('Не удалось загрузить черновик'))))
|
||||
.then((data) => {
|
||||
const payload = data?.claim?.payload;
|
||||
if (payload && typeof payload === 'object') {
|
||||
setDetailDraftPayload({ claimId, payload });
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setDetailLoading(false));
|
||||
}, [draftDetailClaimId]);
|
||||
|
||||
// Обработка клика на черновик
|
||||
const handleDraftAction = (draft: Draft) => {
|
||||
const draftId = draft.claim_id || draft.id;
|
||||
@@ -291,6 +474,27 @@ export default function StepDraftSelection({
|
||||
|
||||
// Кнопка действия
|
||||
const getActionButton = (draft: Draft) => {
|
||||
// Для заявок "В работе"
|
||||
if (draft.status_code === 'in_work') {
|
||||
// ✅ В веб-версии показываем кнопку "Просмотреть в Telegram"
|
||||
if (!isTelegramMiniApp) {
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<FileSearchOutlined />}
|
||||
onClick={() => {
|
||||
// Открываем Telegram бота
|
||||
window.open('https://t.me/klientprav_bot', '_blank');
|
||||
}}
|
||||
>
|
||||
Просмотреть в Telegram
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
// ✅ В Telegram Mini App не показываем (но этот код не выполнится, т.к. заявки отфильтрованы)
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = getStatusConfig(draft);
|
||||
|
||||
return (
|
||||
@@ -304,270 +508,229 @@ export default function StepDraftSelection({
|
||||
);
|
||||
};
|
||||
|
||||
// Экран полного описания черновика (draftDetailClaimId открыт; selectedDraft может быть null пока список не подгрузился)
|
||||
if (draftDetailClaimId) {
|
||||
const draftId = draftDetailClaimId;
|
||||
const payload = detailDraftPayload?.claimId === draftId ? detailDraftPayload.payload : null;
|
||||
const fromPayload =
|
||||
(payload && (payload.problem_description ?? payload.description ?? payload.chatInput)) ?? '';
|
||||
const fromDraft = selectedDraft
|
||||
? (selectedDraft.problem_description ||
|
||||
selectedDraft.facts_short ||
|
||||
selectedDraft.problem_title ||
|
||||
'')
|
||||
: '';
|
||||
const fullText = String(fromPayload || fromDraft || '').trim();
|
||||
const displayText = fullText || 'Описание не сохранено';
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 800, margin: '0 auto', padding: '24px 0' }}>
|
||||
<div style={{ padding: '12px 16px', overflowY: 'auto', minHeight: 0, width: '100%', boxSizing: 'border-box' }}>
|
||||
<Card
|
||||
bodyStyle={{ padding: '16px 20px' }}
|
||||
style={{ borderRadius: 8, border: '1px solid #d9d9d9', background: '#fff', width: '100%', boxSizing: 'border-box' }}
|
||||
>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Title level={4} style={{ marginBottom: 8, color: '#111827' }}>
|
||||
Обращение
|
||||
</Title>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #d9d9d9',
|
||||
background: '#fff',
|
||||
border: '1px solid #e2e8f0',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
minHeight: 80,
|
||||
maxHeight: 320,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Title level={2} style={{ marginBottom: 8, color: '#1890ff' }}>
|
||||
📋 Ваши заявки
|
||||
</Title>
|
||||
<Paragraph type="secondary" style={{ fontSize: 14, marginBottom: 16 }}>
|
||||
Выберите заявку для продолжения или создайте новую.
|
||||
</Paragraph>
|
||||
{detailLoading && !fromDraft ? <Spin size="small" /> : displayText}
|
||||
</div>
|
||||
|
||||
{/* Кнопка создания новой заявки - всегда вверху */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{selectedDraft?.is_legacy && onRestartDraft ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onNewClaim}
|
||||
size="large"
|
||||
style={{ width: '100%' }}
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
onRestartDraft(draftId, selectedDraft.problem_description || '');
|
||||
closeDraftDetail();
|
||||
}}
|
||||
>
|
||||
Создать новую заявку
|
||||
Начать заново
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<FolderOpenOutlined />}
|
||||
onClick={() => {
|
||||
onSelectDraft(draftId);
|
||||
closeDraftDetail();
|
||||
}}
|
||||
>
|
||||
К документам
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="default"
|
||||
size="middle"
|
||||
icon={<Headphones size={16} style={{ verticalAlign: 'middle' }} />}
|
||||
onClick={() => setSupportModalClaimId(draftId)}
|
||||
>
|
||||
Написать в поддержку
|
||||
</Button>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
<Modal
|
||||
title="Написать в поддержку"
|
||||
open={supportModalClaimId === draftId}
|
||||
onCancel={() => setSupportModalClaimId(null)}
|
||||
footer={null}
|
||||
width={480}
|
||||
destroyOnClose
|
||||
mask={false}
|
||||
>
|
||||
<SupportChat
|
||||
claimId={draftId}
|
||||
source="complaint_card"
|
||||
compact
|
||||
onSuccess={() => {
|
||||
setSupportModalClaimId(null);
|
||||
message.success('Запрос отправлен.');
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Цвет точки статуса по категории (как на макете — зелёный для «Приняты к работе»)
|
||||
const statusDotColor: Record<string, string> = {
|
||||
pending: '#1890ff',
|
||||
in_work: '#52c41a',
|
||||
resolved: '#52c41a',
|
||||
rejected: '#ff4d4f',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px 16px', overflowY: 'auto', minHeight: 0, width: '100%', boxSizing: 'border-box' }}>
|
||||
{/* Шапка: заголовок + подзаголовок категории */}
|
||||
<div style={{ marginBottom: 16, padding: '16px 0 8px' }}>
|
||||
<Title level={3} style={{ margin: 0, color: '#111827', fontWeight: 700 }}>
|
||||
Мои обращения
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 14, marginTop: 4, display: 'block' }}>
|
||||
{CATEGORY_LABELS[categoryFilter]}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : drafts.length === 0 ? (
|
||||
) : filteredDrafts.length === 0 ? (
|
||||
<Empty
|
||||
description="У вас пока нет незавершенных заявок"
|
||||
description={categoryFilter === 'all' ? 'У вас пока нет обращений' : `Нет обращений в категории «${CATEGORY_LABELS[categoryFilter]}»`}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<List
|
||||
dataSource={drafts}
|
||||
renderItem={(draft) => {
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{filteredDrafts.map((draft) => {
|
||||
const config = getStatusConfig(draft);
|
||||
const docsProgress = getDocsProgress(draft);
|
||||
const tileTitle = draft.facts_short
|
||||
|| draft.problem_title
|
||||
|| (draft.problem_description
|
||||
? (draft.problem_description.length > 60 ? draft.problem_description.slice(0, 60).trim() + '…' : draft.problem_description)
|
||||
: 'Обращение');
|
||||
const category = getItemCategory(draft);
|
||||
const dotColor = statusDotColor[category] || '#8c8c8c';
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '16px',
|
||||
border: `1px solid ${draft.is_legacy ? '#faad14' : '#e8e8e8'}`,
|
||||
borderRadius: 12,
|
||||
marginBottom: 16,
|
||||
background: draft.is_legacy ? '#fffbe6' : '#fff',
|
||||
overflow: 'hidden',
|
||||
display: 'block', // Вертикальный layout
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
<Card
|
||||
key={draft.claim_id || draft.id}
|
||||
className="draft-list-card"
|
||||
hoverable
|
||||
style={{ background: '#fff', cursor: 'pointer' }}
|
||||
bodyStyle={{ padding: '14px 16px' }}
|
||||
onClick={() => openDraftDetail(draft)}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<div style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<Text strong style={{ fontSize: 15, color: '#111827', lineHeight: 1.35 }}>
|
||||
{tileTitle}
|
||||
</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: draft.is_legacy ? '#fff7e6' : '#f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 20,
|
||||
color: draft.is_legacy ? '#faad14' : '#595959',
|
||||
background: dotColor,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{config.icon}
|
||||
</div>
|
||||
}
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Tag color={config.color} style={{ margin: 0 }}>{config.label}</Tag>
|
||||
{draft.category && (
|
||||
<Tag color="purple" style={{ margin: 0 }}>{draft.category}</Tag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
{/* Заголовок - краткое описание проблемы */}
|
||||
{draft.problem_title && (
|
||||
<Text strong style={{
|
||||
fontSize: 15,
|
||||
color: '#1a1a1a',
|
||||
display: 'block',
|
||||
marginBottom: 4,
|
||||
}}>
|
||||
{draft.problem_title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Полное описание проблемы */}
|
||||
{draft.problem_description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
color: '#262626',
|
||||
background: '#f5f5f5',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
borderLeft: '4px solid #1890ff',
|
||||
marginTop: 4,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
title={draft.problem_description}
|
||||
>
|
||||
{draft.problem_description.length > 250
|
||||
? draft.problem_description.substring(0, 250) + '...'
|
||||
: draft.problem_description
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Время обновления */}
|
||||
<Space size="small">
|
||||
<ClockCircleOutlined style={{ color: '#8c8c8c' }} />
|
||||
<Tooltip title={formatDate(draft.updated_at)}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{getRelativeTime(draft.updated_at)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
|
||||
{/* Legacy предупреждение */}
|
||||
{draft.is_legacy && (
|
||||
<Alert
|
||||
message="Черновик в старом формате. Нажмите 'Начать заново'."
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ fontSize: 12, padding: '4px 8px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Список документов со статусами */}
|
||||
{draft.documents_list && draft.documents_list.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: 8,
|
||||
background: '#fafafa',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
<Text type="secondary" style={{ fontSize: 12, fontWeight: 500 }}>
|
||||
📄 Документы
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: '#1890ff', fontWeight: 500 }}>
|
||||
{draft.documents_uploaded || 0} / {draft.documents_total || 0}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 13, color: dotColor }}>{config.label}</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{draft.documents_list.map((doc, idx) => (
|
||||
<div key={idx} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
}}>
|
||||
{doc.uploaded ? (
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: 14 }} />
|
||||
) : (
|
||||
<span style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: '50%',
|
||||
border: `2px solid ${doc.required ? '#ff4d4f' : '#d9d9d9'}`,
|
||||
display: 'inline-block',
|
||||
}} />
|
||||
)}
|
||||
<span style={{
|
||||
color: doc.uploaded ? '#52c41a' : (doc.required ? '#262626' : '#8c8c8c'),
|
||||
textDecoration: doc.uploaded ? 'none' : 'none',
|
||||
}}>
|
||||
{doc.name}
|
||||
{doc.required && !doc.uploaded && <span style={{ color: '#ff4d4f' }}> *</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Прогрессбар (если нет списка) */}
|
||||
{(!draft.documents_list || draft.documents_list.length === 0) && docsProgress && docsProgress.total > 0 && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Progress
|
||||
percent={docsProgress.percent}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
strokeColor={{
|
||||
'0%': '#1890ff',
|
||||
'100%': '#52c41a',
|
||||
}}
|
||||
trailColor="#f0f0f0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Описание статуса */}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, lineHeight: 1.4 }}>
|
||||
{config.description}
|
||||
</Text>
|
||||
|
||||
{/* Кнопки действий */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
marginTop: 12,
|
||||
paddingTop: 12,
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
}}>
|
||||
{getActionButton(draft)}
|
||||
<Popconfirm
|
||||
title="Удалить заявку?"
|
||||
description="Это действие нельзя отменить"
|
||||
onConfirm={() => handleDelete(draft.claim_id || draft.id)}
|
||||
okText="Да, удалить"
|
||||
cancelText="Отмена"
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatDateShort(draft.updated_at)}
|
||||
</Text>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deletingId === (draft.claim_id || draft.id)}
|
||||
disabled={deletingId === (draft.claim_id || draft.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
type="link"
|
||||
size="small"
|
||||
style={{ padding: 0, height: 'auto', marginTop: 4 }}
|
||||
icon={<Headphones size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSupportModalClaimId(draft.claim_id || draft.id || '');
|
||||
}}
|
||||
/>
|
||||
>
|
||||
Поддержка
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<div style={{ textAlign: 'center', padding: '8px 0' }}>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={loadDrafts}
|
||||
onClick={() => onRefreshDrafts ? onRefreshDrafts() : loadDrafts()}
|
||||
loading={loading}
|
||||
>
|
||||
Обновить список
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="Написать в поддержку"
|
||||
open={!!supportModalClaimId}
|
||||
onCancel={() => setSupportModalClaimId(null)}
|
||||
footer={null}
|
||||
width={480}
|
||||
destroyOnClose
|
||||
mask={false}
|
||||
>
|
||||
{supportModalClaimId && (
|
||||
<SupportChat
|
||||
claimId={supportModalClaimId}
|
||||
source="complaint_card"
|
||||
compact
|
||||
onSuccess={() => {
|
||||
setSupportModalClaimId(null);
|
||||
message.success('Запрос отправлен.');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Card, Checkbox, Form, Input, Radio, Result, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd';
|
||||
import { Button, Card, Checkbox, Form, Input, Modal, Radio, Result, Row, Col, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd';
|
||||
import { LoadingOutlined, PlusOutlined, ThunderboltOutlined, InboxOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import { getDocTypeStyle, STATUS_UPLOADED, STATUS_NEEDED, STATUS_NOT_AVAILABLE, STATUS_OPTIONAL } from './documentsScreenMaps';
|
||||
import AiWorkingIllustration from '../../assets/ai-working.svg';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
|
||||
@@ -50,6 +51,8 @@ interface Props {
|
||||
updateFormData: (data: any) => void;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
backToDraftsList?: () => void; // ✅ Возврат к списку черновиков напрямую
|
||||
onNewClaim?: () => void; // ✅ Переход на форму нового обращения (шаг «Описание»)
|
||||
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
|
||||
}
|
||||
|
||||
@@ -92,6 +95,21 @@ const buildPrefillMap = (prefill?: Array<{ name: string; value: any }>) => {
|
||||
|
||||
const YES_VALUES = ['да', 'yes', 'true', '1'];
|
||||
|
||||
/** Единое событие от бэкенда: тип + текст (+ data для consumer_complaint) */
|
||||
type DisplayEventType = 'trash_message' | 'out_of_scope' | 'consumer_consultation' | 'consumer_complaint';
|
||||
interface ResponseEvent {
|
||||
event_type: DisplayEventType;
|
||||
message: string;
|
||||
data?: Record<string, any>;
|
||||
suggested_actions?: any[];
|
||||
}
|
||||
const DISPLAY_STYLE: Record<DisplayEventType, { bg: string; border: string; title: string }> = {
|
||||
trash_message: { bg: '#fff2f0', border: '#ffccc7', title: 'Не по тематике' },
|
||||
out_of_scope: { bg: '#fff7e6', border: '#ffd591', title: 'Вне нашей компетенции' },
|
||||
consumer_consultation: { bg: '#e6f7ff', border: '#91d5ff', title: 'Консультация' },
|
||||
consumer_complaint: { bg: '#f6ffed', border: '#b7eb8f', title: 'Обращение принято' },
|
||||
};
|
||||
|
||||
const isAffirmative = (value: any) => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
@@ -110,6 +128,8 @@ export default function StepWizardPlan({
|
||||
updateFormData,
|
||||
onNext,
|
||||
onPrev,
|
||||
backToDraftsList,
|
||||
onNewClaim,
|
||||
addDebugEvent,
|
||||
}: Props) {
|
||||
console.log('🔥 StepWizardPlan v1.4 - 2025-11-20 15:00 - Add unified_id and claim_id to wizard payload');
|
||||
@@ -120,6 +140,8 @@ export default function StepWizardPlan({
|
||||
const [isWaiting, setIsWaiting] = useState(!formData.wizardPlan);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [outOfScopeData, setOutOfScopeData] = useState<any>(null);
|
||||
/** Единое событие от бэка: тип + текст — одно окошко с цветом по типу */
|
||||
const [responseEvent, setResponseEvent] = useState<ResponseEvent | null>(null);
|
||||
const [plan, setPlan] = useState<any>(formData.wizardPlan || null);
|
||||
const [prefillMap, setPrefillMap] = useState<Record<string, any>>(
|
||||
formData.wizardPrefill || buildPrefillMap(formData.wizardPrefillArray)
|
||||
@@ -462,83 +484,59 @@ export default function StepWizardPlan({
|
||||
payload_preview: JSON.stringify(payload).substring(0, 200),
|
||||
});
|
||||
|
||||
// ❌ OUT OF SCOPE: Вопрос не связан с защитой прав потребителей
|
||||
if (eventType === 'out_of_scope') {
|
||||
debugLoggerRef.current?.('wizard', 'warning', '⚠️ Вопрос вне скоупа', {
|
||||
session_id: sessionId,
|
||||
message: payload.message,
|
||||
suggested_actions: payload.suggested_actions,
|
||||
});
|
||||
|
||||
setIsWaiting(false);
|
||||
setOutOfScopeData(payload); // Сохраняем полные данные
|
||||
setConnectionError(null); // Не используем connectionError
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
source.close();
|
||||
eventSourceRef.current = null;
|
||||
// Не показывать служебное сообщение подключения SSE как ответ пользователю
|
||||
if (payload.status === 'connected' && payload.message === 'Подключено к событиям') {
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ НОВЫЙ ФЛОУ: Обработка списка документов
|
||||
if (eventType === 'documents_list_ready') {
|
||||
const documentsRequired = payload.documents_required || [];
|
||||
|
||||
debugLoggerRef.current?.('wizard', 'success', '📋 Получен список документов!', {
|
||||
session_id: sessionId,
|
||||
documents_count: documentsRequired.length,
|
||||
documents: documentsRequired.map((d: any) => d.name),
|
||||
});
|
||||
|
||||
console.log('📋 documents_list_ready:', {
|
||||
claim_id: payload.claim_id,
|
||||
documents_required: documentsRequired,
|
||||
});
|
||||
|
||||
// Сохраняем в formData для нового флоу
|
||||
updateFormData({
|
||||
documents_required: documentsRequired,
|
||||
claim_id: payload.claim_id,
|
||||
wizardPlanStatus: 'documents_ready', // Новый статус
|
||||
});
|
||||
// Единый формат от бэка: event_type + message (тип и текст)
|
||||
const displayTypes: DisplayEventType[] = ['trash_message', 'out_of_scope', 'consumer_consultation', 'consumer_complaint'];
|
||||
let isDisplayEvent = payload.event_type && displayTypes.includes(payload.event_type as DisplayEventType) && payload.message != null;
|
||||
// Fallback: пришло только message без event_type — показываем как out_of_scope (но не служебное "Подключено к событиям")
|
||||
if (!isDisplayEvent && payload.message != null && String(payload.message).trim() && payload.message !== 'Подключено к событиям') {
|
||||
payload.event_type = payload.event_type || 'out_of_scope';
|
||||
payload.event_type = displayTypes.includes(payload.event_type as DisplayEventType) ? payload.event_type : 'out_of_scope';
|
||||
isDisplayEvent = true;
|
||||
}
|
||||
|
||||
if (isDisplayEvent) {
|
||||
const ev: ResponseEvent = {
|
||||
event_type: payload.event_type as DisplayEventType,
|
||||
message: payload.message || 'Ответ получен',
|
||||
data: payload.data,
|
||||
suggested_actions: payload.suggested_actions,
|
||||
};
|
||||
setResponseEvent(ev);
|
||||
setIsWaiting(false);
|
||||
setConnectionError(null);
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Пока показываем alert для теста, потом переход к StepDocumentsNew
|
||||
message.success(`Получен список документов: ${documentsRequired.length} шт.`);
|
||||
|
||||
// TODO: onNext() для перехода к StepDocumentsNew
|
||||
return;
|
||||
}
|
||||
|
||||
const wizardPayload = extractWizardPayload(payload);
|
||||
const hasWizardPlan = Boolean(wizardPayload);
|
||||
|
||||
if (eventType?.includes('wizard') || hasWizardPlan) {
|
||||
const wizardPlan = wizardPayload?.wizard_plan;
|
||||
const answersPrefill = wizardPayload?.answers_prefill;
|
||||
const coverageReport = wizardPayload?.coverage_report;
|
||||
|
||||
debugLoggerRef.current?.('wizard', 'success', '✨ Получен план вопросов', {
|
||||
// consumer_complaint с data: список документов или план — обновляем formData и при необходимости план
|
||||
if (ev.event_type === 'consumer_complaint' && ev.data) {
|
||||
const docs = ev.data.documents_required ?? payload.documents_required;
|
||||
if (docs && Array.isArray(docs)) {
|
||||
debugLoggerRef.current?.('wizard', 'success', '📋 Получен список документов!', {
|
||||
session_id: sessionId,
|
||||
questions: wizardPlan?.questions?.length || 0,
|
||||
documents_count: docs.length,
|
||||
});
|
||||
|
||||
updateFormData({
|
||||
documents_required: docs,
|
||||
claim_id: ev.data.claim_id || payload.claim_id,
|
||||
wizardPlanStatus: 'documents_ready',
|
||||
});
|
||||
message.success(`Получен список документов: ${docs.length} шт.`);
|
||||
}
|
||||
const wizardPlan = ev.data.wizard_plan ?? extractWizardPayload(payload)?.wizard_plan;
|
||||
if (wizardPlan) {
|
||||
const wizardPayload = extractWizardPayload(payload) || { wizard_plan: wizardPlan, answers_prefill: ev.data.answers_prefill, coverage_report: ev.data.coverage_report };
|
||||
const answersPrefill = wizardPayload.answers_prefill ?? ev.data.answers_prefill;
|
||||
const coverageReport = wizardPayload.coverage_report ?? ev.data.coverage_report;
|
||||
const prefill = buildPrefillMap(answersPrefill);
|
||||
setPlan(wizardPlan);
|
||||
setPrefillMap(prefill);
|
||||
setIsWaiting(false);
|
||||
setConnectionError(null);
|
||||
|
||||
updateFormData({
|
||||
wizardPlan: wizardPlan,
|
||||
wizardPrefill: prefill,
|
||||
@@ -546,11 +544,91 @@ export default function StepWizardPlan({
|
||||
wizardCoverageReport: coverageReport,
|
||||
wizardPlanStatus: 'ready',
|
||||
});
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
source.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Для trash и out_of_scope закрываем SSE
|
||||
if (ev.event_type === 'trash_message' || ev.event_type === 'out_of_scope') {
|
||||
source.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Обратная совместимость: старый формат без нормализации (out_of_scope, trash_message, documents_list_ready, wizard)
|
||||
if (eventType === 'out_of_scope') {
|
||||
setResponseEvent({
|
||||
event_type: 'out_of_scope',
|
||||
message: payload.message || 'К сожалению, мы не можем помочь с этим вопросом.',
|
||||
suggested_actions: payload.suggested_actions,
|
||||
});
|
||||
setOutOfScopeData(payload);
|
||||
setIsWaiting(false);
|
||||
setConnectionError(null);
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
|
||||
source.close();
|
||||
eventSourceRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (eventType === 'trash_message' || payload?.payload?.intent === 'trash') {
|
||||
const msg = payload?.payload?.message || payload?.message || 'К сожалению, это обращение не по тематике защиты прав потребителей.';
|
||||
setResponseEvent({
|
||||
event_type: 'trash_message',
|
||||
message: msg,
|
||||
suggested_actions: payload?.payload?.suggested_actions || payload?.suggested_actions,
|
||||
});
|
||||
setIsWaiting(false);
|
||||
setConnectionError(null);
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
|
||||
source.close();
|
||||
eventSourceRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (eventType === 'documents_list_ready') {
|
||||
const documentsRequired = payload.documents_required || [];
|
||||
setResponseEvent({
|
||||
event_type: 'consumer_complaint',
|
||||
message: `Подготовлен список документов: ${documentsRequired.length} шт.`,
|
||||
data: { documents_required: documentsRequired, claim_id: payload.claim_id },
|
||||
});
|
||||
updateFormData({
|
||||
documents_required: documentsRequired,
|
||||
claim_id: payload.claim_id,
|
||||
wizardPlanStatus: 'documents_ready',
|
||||
});
|
||||
setIsWaiting(false);
|
||||
setConnectionError(null);
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
|
||||
message.success(`Получен список документов: ${documentsRequired.length} шт.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const wizardPayload = extractWizardPayload(payload);
|
||||
const hasWizardPlan = Boolean(wizardPayload);
|
||||
if (eventType?.includes('wizard') || hasWizardPlan) {
|
||||
const wizardPlan = wizardPayload?.wizard_plan;
|
||||
const answersPrefill = wizardPayload?.answers_prefill;
|
||||
const coverageReport = wizardPayload?.coverage_report;
|
||||
setResponseEvent({
|
||||
event_type: 'consumer_complaint',
|
||||
message: payload.message || 'План готов.',
|
||||
data: { wizard_plan: wizardPlan, answers_prefill: answersPrefill, coverage_report: coverageReport },
|
||||
});
|
||||
const prefill = buildPrefillMap(answersPrefill);
|
||||
setPlan(wizardPlan);
|
||||
setPrefillMap(prefill);
|
||||
setIsWaiting(false);
|
||||
setConnectionError(null);
|
||||
updateFormData({
|
||||
wizardPlan: wizardPlan,
|
||||
wizardPrefill: prefill,
|
||||
wizardPrefillArray: answersPrefill,
|
||||
wizardCoverageReport: coverageReport,
|
||||
wizardPlanStatus: 'ready',
|
||||
});
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
|
||||
source.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
@@ -857,6 +935,11 @@ export default function StepWizardPlan({
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
console.log('📥 Ответ n8n (wizard):', parsed);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
console.log('📥 Ключи ответа n8n:', Object.keys(parsed));
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
message.error('Не удалось отправить данные визарда. Попробуйте ещё раз.');
|
||||
addDebugEvent?.('wizard', 'error', '❌ Ошибка отправки визарда в n8n', {
|
||||
@@ -1437,7 +1520,6 @@ export default function StepWizardPlan({
|
||||
})}
|
||||
|
||||
<Space style={{ marginTop: 24 }}>
|
||||
<Button onClick={onPrev}>← Назад</Button>
|
||||
<Button type="primary" htmlType="submit" loading={submitting}>
|
||||
Сохранить и продолжить →
|
||||
</Button>
|
||||
@@ -1454,7 +1536,6 @@ export default function StepWizardPlan({
|
||||
status="warning"
|
||||
title="Нет session_id"
|
||||
subTitle="Не удалось определить идентификатор сессии. Вернитесь на предыдущий шаг и попробуйте снова."
|
||||
extra={<Button onClick={onPrev}>Вернуться</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1585,8 +1666,10 @@ export default function StepWizardPlan({
|
||||
}
|
||||
}, [currentDocIndex, documentsRequired.length, uploadedDocs, skippedDocs, findFirstUnprocessedDoc, updateFormData]);
|
||||
|
||||
const [docChoice, setDocChoice] = useState<'upload' | 'none'>('upload'); // Выбор: загрузить или нет документа (по умолчанию - загрузить)
|
||||
const [currentUploadedFiles, setCurrentUploadedFiles] = useState<any[]>([]); // Массив загруженных файлов
|
||||
const [docChoice, setDocChoice] = useState<'upload' | 'none'>('upload');
|
||||
const [currentUploadedFiles, setCurrentUploadedFiles] = useState<any[]>([]);
|
||||
const [selectedDocIndex, setSelectedDocIndex] = useState<number | null>(null); // Плиточный стиль: какая плитка открыта в модалке
|
||||
const [customDocsModalOpen, setCustomDocsModalOpen] = useState(false); // Модалка «Свои документы»
|
||||
|
||||
// Текущий документ для загрузки
|
||||
const currentDoc = documentsRequired[currentDocIndex];
|
||||
@@ -2158,148 +2241,288 @@ export default function StepWizardPlan({
|
||||
}
|
||||
};
|
||||
|
||||
const showDocumentsOnly = hasNewFlowDocs && documentsRequired.length > 0;
|
||||
const stepContent = (
|
||||
<>
|
||||
{/* ✅ Экран «Загрузка документов» по дизайн-спецификации */}
|
||||
{hasNewFlowDocs && !allDocsProcessed && documentsRequired.length > 0 ? (
|
||||
<div style={{ background: '#f5f7fb', margin: '-1px -1px 0', borderRadius: '16px 16px 0 0', overflow: 'hidden', minHeight: 360 }}>
|
||||
{/* Шапка: градиент синий, заголовок */}
|
||||
<div style={{ background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)', padding: '16px 16px', textAlign: 'center' }}>
|
||||
<Typography.Text strong style={{ color: '#fff', fontSize: 18 }}>Загрузка документов</Typography.Text>
|
||||
</div>
|
||||
<div style={{ padding: '16px 16px 100px' }}>
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 80 }}>
|
||||
{documentsRequired.map((doc: any, index: number) => {
|
||||
const docId = doc.id || doc.name;
|
||||
const isUploaded = uploadedDocs.includes(docId);
|
||||
const isSkipped = skippedDocs.includes(docId);
|
||||
const fileCount = (formData.documents_uploaded || []).filter((d: any) => (d.type || d.id) === docId).length;
|
||||
const { Icon: DocIcon, color: docColor } = getDocTypeStyle(docId);
|
||||
const isSelected = selectedDocIndex === index;
|
||||
const status = isUploaded ? STATUS_UPLOADED : isSkipped ? STATUS_NOT_AVAILABLE : (doc.required ? STATUS_NEEDED : STATUS_OPTIONAL);
|
||||
const StatusIcon = status.Icon;
|
||||
const statusLabel = isUploaded ? (fileCount > 0 ? `${status.label} (${fileCount})` : status.label) : status.label;
|
||||
const tileBg = isUploaded ? '#ECFDF5' : isSkipped ? '#F3F4F6' : '#FFFBEB';
|
||||
const tileBorder = isSelected ? '#2563eb' : isUploaded ? '#22C55E' : isSkipped ? '#9ca3af' : '#F59E0B';
|
||||
return (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Button onClick={onPrev}>← Назад</Button>
|
||||
{plan && !hasNewFlowDocs && (
|
||||
<Button type="link" onClick={handleRefreshPlan}>
|
||||
Обновить рекомендации
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Col xs={12} key={docId}>
|
||||
<Card
|
||||
hoverable
|
||||
bordered
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: '1px solid #d9d9d9',
|
||||
background: '#fafafa',
|
||||
borderRadius: 18,
|
||||
border: `1px solid ${tileBorder}`,
|
||||
background: tileBg,
|
||||
boxShadow: isSelected ? '0 0 0 2px rgba(37,99,235,0.25)' : '0 2px 12px rgba(0,0,0,0.06)',
|
||||
height: '100%',
|
||||
}}
|
||||
bodyStyle={{ padding: 16, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 10 }}
|
||||
onClick={() => { setCurrentDocIndex(index); setDocChoice(isSkipped ? 'none' : 'upload'); setCurrentUploadedFiles([]); setSelectedDocIndex(index); }}
|
||||
>
|
||||
{/* ✅ НОВЫЙ ФЛОУ: Поэкранная загрузка документов */}
|
||||
{hasNewFlowDocs && !allDocsProcessed && currentDocIndex < documentsRequired.length && currentDoc ? (
|
||||
<div style={{ padding: '24px 0' }}>
|
||||
{/* Прогресс */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text type="secondary">Документ {currentDocIndex + 1} из {documentsRequired.length}</Text>
|
||||
<Text type="secondary">{Math.round(((uploadedDocs.length + skippedDocs.length) / documentsRequired.length) * 100)}% завершено</Text>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 14, background: `${docColor}18`, display: 'flex', alignItems: 'center', justifyContent: 'center', color: docColor }}>
|
||||
<DocIcon size={28} strokeWidth={1.8} />
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round(((uploadedDocs.length + skippedDocs.length) / documentsRequired.length) * 100)}
|
||||
showInfo={false}
|
||||
strokeColor="#595959"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Заголовок документа */}
|
||||
<Title level={4} style={{ marginBottom: 8 }}>
|
||||
📄 {currentDoc.name}
|
||||
{currentDoc.required && <Tag color="volcano" style={{ marginLeft: 8 }}>Важный</Tag>}
|
||||
</Title>
|
||||
|
||||
{currentDoc.hints && (
|
||||
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||
{currentDoc.hints}
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
{/* Радио-кнопки выбора */}
|
||||
<Radio.Group
|
||||
value={docChoice}
|
||||
onChange={(e) => {
|
||||
setDocChoice(e.target.value);
|
||||
if (e.target.value === 'none') {
|
||||
setCurrentUploadedFiles([]);
|
||||
}
|
||||
}}
|
||||
style={{ marginBottom: 16, display: 'block' }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Radio value="upload" style={{ fontSize: 16 }}>
|
||||
📎 Загрузить документ
|
||||
</Radio>
|
||||
<Radio value="none" style={{ fontSize: 16 }}>
|
||||
❌ У меня нет этого документа
|
||||
</Radio>
|
||||
<Text strong style={{ fontSize: 14, lineHeight: 1.3, minHeight: 40, display: 'block', color: '#111827' }}>{doc.name}</Text>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<Space size={6} style={{ fontSize: 12, color: status.color }}>
|
||||
<StatusIcon size={14} strokeWidth={2} />
|
||||
<span>{statusLabel}</span>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
|
||||
{/* Загрузка файлов — показываем только если выбрано "Загрузить" */}
|
||||
{docChoice === 'upload' && (
|
||||
<Dragger
|
||||
multiple={true}
|
||||
beforeUpload={() => false}
|
||||
fileList={currentUploadedFiles}
|
||||
onChange={({ fileList }) => handleFilesChange(fileList)}
|
||||
onRemove={(file) => {
|
||||
setCurrentUploadedFiles(prev => prev.filter(f => f.uid !== file.uid));
|
||||
return true;
|
||||
}}
|
||||
accept={currentDoc.accept?.map((ext: string) => `.${ext}`).join(',') || '.pdf,.jpg,.jpeg,.png'}
|
||||
disabled={submitting}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined style={{ color: '#595959', fontSize: 32 }} />
|
||||
</p>
|
||||
<p className="ant-upload-text">
|
||||
Перетащите файлы или нажмите для выбора
|
||||
</p>
|
||||
<p className="ant-upload-hint">
|
||||
📌 Можно загрузить несколько файлов (все страницы документа)
|
||||
<br />
|
||||
Форматы: {currentDoc.accept?.join(', ') || 'PDF, JPG, PNG'} (до 20 МБ каждый)
|
||||
</p>
|
||||
</Dragger>
|
||||
)}
|
||||
|
||||
{/* Предупреждение если "нет документа" для важного */}
|
||||
{docChoice === 'none' && currentDoc.required && (
|
||||
<div style={{
|
||||
padding: 12,
|
||||
background: '#fff7e6',
|
||||
border: '1px solid #ffd591',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16
|
||||
}}>
|
||||
<Text type="warning">
|
||||
⚠️ Этот документ важен для рассмотрения заявки. Постарайтесь найти его позже.
|
||||
</Text>
|
||||
{'subLabel' in status && isSkipped && <Text type="secondary" style={{ fontSize: 11 }}>{(status as { subLabel?: string }).subLabel}</Text>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопки */}
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button onClick={onPrev}>← К списку заявок</Button>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
{/* Плитка: произвольные группы документов (название от пользователя при одной группе) */}
|
||||
<Col xs={12} key="__custom_docs__">
|
||||
<Card
|
||||
hoverable
|
||||
bordered
|
||||
style={{
|
||||
borderRadius: 18,
|
||||
border: `1px solid #e5e7eb`,
|
||||
background: '#fff',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
|
||||
height: '100%',
|
||||
}}
|
||||
bodyStyle={{ padding: 16, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 10 }}
|
||||
onClick={() => setCustomDocsModalOpen(true)}
|
||||
>
|
||||
{(() => {
|
||||
const { Icon: CustomIcon, color: customColor } = getDocTypeStyle('__custom_docs__');
|
||||
const StatusIcon = customFileBlocks.length > 0 ? STATUS_UPLOADED.Icon : CustomIcon;
|
||||
const statusColor = customFileBlocks.length > 0 ? STATUS_UPLOADED.color : '#8c8c8c';
|
||||
const hasGroups = customFileBlocks.length > 0;
|
||||
const titleText = hasGroups && customFileBlocks.length === 1 && customFileBlocks[0].description?.trim()
|
||||
? (customFileBlocks[0].description.trim().length > 25 ? customFileBlocks[0].description.trim().slice(0, 22) + '…' : customFileBlocks[0].description.trim())
|
||||
: 'Свои документы';
|
||||
return (
|
||||
<>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 14, background: `${customColor}18`, display: 'flex', alignItems: 'center', justifyContent: 'center', color: customColor }}>
|
||||
<CustomIcon size={28} strokeWidth={1.8} />
|
||||
</div>
|
||||
<Text strong style={{ fontSize: 14, lineHeight: 1.3, minHeight: 40, display: 'block', color: '#111827' }}>{titleText}</Text>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<Space size={6} style={{ fontSize: 12, color: statusColor }}>
|
||||
<StatusIcon size={14} strokeWidth={2} />
|
||||
<span>{hasGroups ? `Загружено (${customFileBlocks.length} ${customFileBlocks.length === 1 ? 'группа' : 'группы'})` : 'Добавить'}</span>
|
||||
</Space>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
</Col>
|
||||
{/* Плитка «Добавить ещё группу» — серая до загрузки, цветная после */}
|
||||
<Col xs={12} key="__custom_docs_add__">
|
||||
<Card
|
||||
hoverable
|
||||
bordered
|
||||
style={{
|
||||
borderRadius: 18,
|
||||
border: '1px solid #e5e7eb',
|
||||
background: customFileBlocks.length > 0 ? '#f5f3ff' : '#fafafa',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
|
||||
height: '100%',
|
||||
}}
|
||||
bodyStyle={{ padding: 16, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center', gap: 8 }}
|
||||
onClick={() => setCustomDocsModalOpen(true)}
|
||||
>
|
||||
{(() => {
|
||||
const { Icon: AddIcon, color: addColor } = getDocTypeStyle('__custom_docs__');
|
||||
const isColored = customFileBlocks.length > 0;
|
||||
const iconColor = isColored ? addColor : '#9ca3af';
|
||||
const bgColor = isColored ? `${addColor}18` : '#f3f4f6';
|
||||
return (
|
||||
<>
|
||||
<div style={{ width: 48, height: 48, borderRadius: 14, background: bgColor, display: 'flex', alignItems: 'center', justifyContent: 'center', color: iconColor }}>
|
||||
<AddIcon size={26} strokeWidth={1.8} />
|
||||
</div>
|
||||
<Text style={{ fontSize: 13, color: isColored ? '#374151' : '#9ca3af', lineHeight: 1.3 }}>
|
||||
Добавить ещё группу
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
{/* Кнопка «Отправить» внизу экрана с плитками (bottom: 90px — выше футера) */}
|
||||
<div style={{ position: 'sticky', bottom: 90, left: 0, right: 0, padding: '24px 0 0', marginTop: 8 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleDocContinue}
|
||||
disabled={!canContinue || submitting}
|
||||
loading={submitting}
|
||||
size="large"
|
||||
block
|
||||
onClick={handleAllDocsComplete}
|
||||
disabled={!allDocsProcessed}
|
||||
title={!allDocsProcessed ? `Сначала отметьте все документы (${uploadedDocs.length + skippedDocs.length}/${documentsRequired.length})` : undefined}
|
||||
style={{
|
||||
background: allDocsProcessed ? 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)' : undefined,
|
||||
border: 'none',
|
||||
borderRadius: 28,
|
||||
height: 52,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{submitting ? 'Загружаем...' : 'Продолжить →'}
|
||||
Отправить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
title={currentDoc ? `📄 ${currentDoc.name}` : 'Документ'}
|
||||
open={selectedDocIndex !== null && !!documentsRequired[selectedDocIndex]}
|
||||
onCancel={() => setSelectedDocIndex(null)}
|
||||
footer={null}
|
||||
width={520}
|
||||
destroyOnClose
|
||||
>
|
||||
{selectedDocIndex !== null && documentsRequired[selectedDocIndex] && (() => {
|
||||
const doc = documentsRequired[selectedDocIndex];
|
||||
return (
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
{doc.hints && <Paragraph type="secondary" style={{ marginBottom: 16 }}>{doc.hints}</Paragraph>}
|
||||
<Radio.Group value={docChoice} onChange={(e) => { setDocChoice(e.target.value); if (e.target.value === 'none') setCurrentUploadedFiles([]); }} style={{ marginBottom: 16, display: 'block' }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Radio value="upload" style={{ fontSize: 15 }}>📎 Загрузить документ</Radio>
|
||||
<Radio value="none" style={{ fontSize: 15 }}>❌ У меня нет этого документа</Radio>
|
||||
</Space>
|
||||
|
||||
{/* Уже загруженные */}
|
||||
{uploadedDocs.length > 0 && (
|
||||
<div style={{ marginTop: 24, padding: 12, background: '#f6ffed', borderRadius: 8 }}>
|
||||
<Text strong>✅ Загружено:</Text>
|
||||
<ul style={{ margin: '8px 0 0 20px', padding: 0 }}>
|
||||
{/* Убираем дубликаты и используем уникальные ключи */}
|
||||
{Array.from(new Set(uploadedDocs)).map((docId, idx) => {
|
||||
const doc = documentsRequired.find((d: any) => d.id === docId);
|
||||
return <li key={`${docId}_${idx}`}>{doc?.name || docId}</li>;
|
||||
})}
|
||||
</ul>
|
||||
</Radio.Group>
|
||||
{docChoice === 'upload' && (
|
||||
<Dragger multiple beforeUpload={() => false} fileList={currentUploadedFiles} onChange={({ fileList }) => handleFilesChange(fileList)} onRemove={(file) => { setCurrentUploadedFiles(prev => prev.filter(f => f.uid !== file.uid)); return true; }} accept={doc.accept?.map((ext: string) => `.${ext}`).join(',') || '.pdf,.jpg,.jpeg,.png'} disabled={submitting} style={{ marginBottom: 16 }}>
|
||||
<p className="ant-upload-drag-icon"><InboxOutlined style={{ color: '#595959', fontSize: 32 }} /></p>
|
||||
<p className="ant-upload-text">Перетащите файлы или нажмите для выбора</p>
|
||||
<p className="ant-upload-hint">Форматы: {doc.accept?.join(', ') || 'PDF, JPG, PNG'} (до 20 МБ)</p>
|
||||
</Dragger>
|
||||
)}
|
||||
{docChoice === 'none' && doc.required && (
|
||||
<div style={{ padding: 12, background: '#fff7e6', borderRadius: 8, marginBottom: 16 }}>
|
||||
<Text type="warning">⚠️ Документ важен для рассмотрения. Постарайтесь найти его позже.</Text>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button onClick={() => setSelectedDocIndex(null)}>Отмена</Button>
|
||||
<Button type="primary" onClick={async () => { await handleDocContinue(); setSelectedDocIndex(null); }} disabled={!canContinue || submitting} loading={submitting}>{submitting ? 'Загружаем...' : 'Готово'}</Button>
|
||||
</div>
|
||||
) : hasNewFlowDocs && !allDocsProcessed && currentDocIndex >= documentsRequired.length ? (
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
{/* Модалка «Свои документы» — произвольные группы документов */}
|
||||
<Modal
|
||||
title="Дополнительные документы"
|
||||
open={customDocsModalOpen}
|
||||
onCancel={() => setCustomDocsModalOpen(false)}
|
||||
footer={null}
|
||||
width={560}
|
||||
destroyOnClose={false}
|
||||
>
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
{customFileBlocks.length === 0 && (
|
||||
<div style={{ marginBottom: 16, padding: 16, background: '#fafafa', borderRadius: 8 }}>
|
||||
<Paragraph style={{ marginBottom: 8 }}>
|
||||
<Text strong>Есть ещё документы, которые могут помочь?</Text>
|
||||
</Paragraph>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||
Добавьте группу документов с названием (например: «Переписка в мессенджере», «Скриншоты»).
|
||||
В каждой группе — своё название и файлы.
|
||||
</Paragraph>
|
||||
<Button type="dashed" icon={<PlusOutlined />} onClick={addCustomBlock} block size="large">
|
||||
Добавить группу документов
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{customFileBlocks.map((block, idx) => (
|
||||
<Card
|
||||
key={block.id}
|
||||
size="small"
|
||||
style={{ borderRadius: 8, border: '1px solid #d9d9d9', background: '#fff' }}
|
||||
title={<span><FileTextOutlined style={{ color: '#595959', marginRight: 8 }} />Группа документов #{idx + 1}</span>}
|
||||
extra={<Button type="link" danger size="small" onClick={() => removeCustomBlock(block.id)}>Удалить</Button>}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 4 }}>Название группы <Text type="danger">*</Text></Text>
|
||||
<Input
|
||||
placeholder="Например: Переписка в WhatsApp с менеджером"
|
||||
value={block.description}
|
||||
onChange={(e) => updateCustomBlock(block.id, { description: e.target.value })}
|
||||
maxLength={500}
|
||||
showCount
|
||||
style={{ marginBottom: 12 }}
|
||||
status={block.files.length > 0 && !block.description?.trim() ? 'error' : ''}
|
||||
/>
|
||||
{block.files.length > 0 && !block.description?.trim() && (
|
||||
<Text type="danger" style={{ fontSize: 12 }}>Укажите название группы</Text>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 4 }}>Категория (необязательно)</Text>
|
||||
<Select
|
||||
value={block.category}
|
||||
placeholder="Выберите или оставьте пустым"
|
||||
onChange={(value) => updateCustomBlock(block.id, { category: value })}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{customCategoryOptions.map((opt) => (
|
||||
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<Dragger
|
||||
multiple
|
||||
beforeUpload={() => false}
|
||||
fileList={block.files}
|
||||
onChange={({ fileList }) => updateCustomBlock(block.id, { files: fileList })}
|
||||
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.heic"
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><InboxOutlined style={{ color: '#595959', fontSize: 24 }} /></p>
|
||||
<p className="ant-upload-text">Перетащите файлы или нажмите для выбора</p>
|
||||
</Dragger>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
{customFileBlocks.length > 0 && (
|
||||
<Button type="dashed" onClick={addCustomBlock} icon={<PlusOutlined />} block style={{ marginTop: 12 }}>
|
||||
Добавить ещё группу
|
||||
</Button>
|
||||
)}
|
||||
<div style={{ marginTop: 16, textAlign: 'right' }}>
|
||||
<Button type="primary" onClick={() => setCustomDocsModalOpen(false)}>Готово</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
) : hasNewFlowDocs && !allDocsProcessed && currentDocIndex >= documentsRequired.length && documentsRequired.length > 0 ? (
|
||||
<div style={{ padding: '24px 0', textAlign: 'center' }}>
|
||||
<Text type="warning">
|
||||
⚠️ Ошибка: индекс документа ({currentDocIndex}) выходит за границы массива ({documentsRequired.length}).
|
||||
⚠️ Ошибка: индекс документа ({currentDocIndex}) выходит за границы ({documentsRequired.length}).
|
||||
<br />
|
||||
Загружено: {uploadedDocs.length}, пропущено: {skippedDocs.length}
|
||||
</Text>
|
||||
@@ -2391,15 +2614,52 @@ export default function StepWizardPlan({
|
||||
{/* ✅ Дополнительные документы */}
|
||||
{renderCustomUploads()}
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
||||
<Button type="primary" size="large" onClick={handleAllDocsComplete}>
|
||||
Продолжить →
|
||||
<div style={{ position: 'sticky', bottom: 90, left: 0, right: 0, padding: '20px 0', background: '#f5f7fb', marginTop: 24 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
onClick={handleAllDocsComplete}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)',
|
||||
border: 'none',
|
||||
borderRadius: 28,
|
||||
height: 52,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Отправить
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
</>
|
||||
);
|
||||
|
||||
return showDocumentsOnly ? (
|
||||
<div style={{ marginTop: 0 }}>{stepContent}</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
{plan && !hasNewFlowDocs && (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
|
||||
<Button type="link" onClick={handleRefreshPlan}>
|
||||
Обновить рекомендации
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: '1px solid #d9d9d9',
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
{stepContent}
|
||||
{(
|
||||
<>
|
||||
{/* СТАРЫЙ ФЛОУ: Ожидание визарда */}
|
||||
{!hasNewFlowDocs && isWaiting && !outOfScopeData && (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
@@ -2433,8 +2693,104 @@ export default function StepWizardPlan({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OUT OF SCOPE: Вопрос вне нашей компетенции */}
|
||||
{outOfScopeData && (
|
||||
{/* Единое окошко: тип + текст, цвет по event_type */}
|
||||
{responseEvent && (
|
||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||
<div style={{
|
||||
background: DISPLAY_STYLE[responseEvent.event_type].bg,
|
||||
border: `1px solid ${DISPLAY_STYLE[responseEvent.event_type].border}`,
|
||||
borderRadius: 12,
|
||||
padding: 24,
|
||||
maxWidth: 600,
|
||||
margin: '0 auto',
|
||||
}}>
|
||||
<Title level={4} style={{ marginBottom: 16 }}>
|
||||
{responseEvent.event_type === 'trash_message' && '❌ '}
|
||||
{responseEvent.event_type === 'out_of_scope' && '⚠️ '}
|
||||
{responseEvent.event_type === 'consumer_consultation' && 'ℹ️ '}
|
||||
{responseEvent.event_type === 'consumer_complaint' && '✅ '}
|
||||
{DISPLAY_STYLE[responseEvent.event_type].title}
|
||||
</Title>
|
||||
<Paragraph style={{ fontSize: 16, marginBottom: 16 }}>
|
||||
{responseEvent.message}
|
||||
</Paragraph>
|
||||
{responseEvent.suggested_actions && responseEvent.suggested_actions.length > 0 && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Paragraph strong style={{ marginBottom: 12 }}>Что можно сделать:</Paragraph>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{responseEvent.suggested_actions.map((action: any, index: number) => (
|
||||
<Card key={index} size="small" style={{ textAlign: 'left', background: '#fafafa' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{action.title}</div>
|
||||
<div style={{ color: '#666', fontSize: 14 }}>{action.description}</div>
|
||||
{action.actionType === 'external_link' && action.url && (
|
||||
<a href={action.url} target="_blank" rel="noopener noreferrer" style={{ marginTop: 8, display: 'inline-block' }}>
|
||||
{action.urlText || 'Перейти →'}
|
||||
</a>
|
||||
)}
|
||||
{action.actionType === 'contact_support' && (
|
||||
<Button
|
||||
type="link"
|
||||
style={{ marginTop: 8, padding: 0 }}
|
||||
onClick={async () => {
|
||||
const sessionToken = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null) || localStorage.getItem('session_token');
|
||||
if (!sessionToken) {
|
||||
message.error('Сессия не найдена. Войдите снова.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
message.loading('Отправляем запрос в поддержку...', 0);
|
||||
const fd = new FormData();
|
||||
fd.append('message', responseEvent.message || '');
|
||||
fd.append('source', 'complaint_card');
|
||||
fd.append('session_token', sessionToken);
|
||||
if (formData.claim_id) fd.append('claim_id', formData.claim_id);
|
||||
const res = await fetch('/api/v1/support', { method: 'POST', body: fd });
|
||||
message.destroy();
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || res.statusText);
|
||||
}
|
||||
message.success('Запрос отправлен! Мы свяжемся с вами в ближайшее время. Возвращаем на главную...');
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
} catch (error) {
|
||||
message.destroy();
|
||||
message.error(error instanceof Error ? error.message : 'Не удалось отправить запрос. Попробуйте позже.');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Связаться с поддержкой →
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
{(responseEvent.event_type === 'trash_message' || responseEvent.event_type === 'out_of_scope') && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setResponseEvent(null);
|
||||
setOutOfScopeData(null);
|
||||
if (onNewClaim) onNewClaim();
|
||||
else {
|
||||
updateFormData({ wizardPlan: null, wizardPlanStatus: null, problemDescription: '' });
|
||||
window.history.pushState({}, '', '/new');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Новое обращение
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OUT OF SCOPE (старый формат, если пришло без event_type/message) */}
|
||||
{!responseEvent && outOfScopeData && (
|
||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||
<div style={{
|
||||
background: '#fff7e6',
|
||||
@@ -2485,33 +2841,29 @@ export default function StepWizardPlan({
|
||||
type="link"
|
||||
style={{ marginTop: 8, padding: 0 }}
|
||||
onClick={async () => {
|
||||
const sessionToken = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null) || localStorage.getItem('session_token');
|
||||
if (!sessionToken) {
|
||||
message.error('Сессия не найдена. Войдите снова.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
message.loading('Отправляем запрос в поддержку...', 0);
|
||||
await fetch('https://n8n.clientright.pro/webhook/3ef6ff67-f3f2-418e-a300-86cb4659dbde', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: formData.session_id,
|
||||
phone: formData.phone,
|
||||
email: formData.email,
|
||||
unified_id: formData.unified_id,
|
||||
ticket_number: outOfScopeData.ticket_number,
|
||||
ticket: outOfScopeData.ticket,
|
||||
reason: outOfScopeData.reason,
|
||||
message: outOfScopeData.message,
|
||||
action: 'contact_support',
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
const fd = new FormData();
|
||||
fd.append('message', outOfScopeData.message || outOfScopeData.reason || '');
|
||||
fd.append('source', 'complaint_card');
|
||||
fd.append('session_token', sessionToken);
|
||||
if (formData.claim_id) fd.append('claim_id', formData.claim_id);
|
||||
const res = await fetch('/api/v1/support', { method: 'POST', body: fd });
|
||||
message.destroy();
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || res.statusText);
|
||||
}
|
||||
message.success('Запрос отправлен! Мы свяжемся с вами в ближайшее время. Возвращаем на главную...');
|
||||
// Возвращаемся на главную через перезагрузку
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
} catch (error) {
|
||||
message.destroy();
|
||||
message.error('Не удалось отправить запрос. Попробуйте позже.');
|
||||
message.error(error instanceof Error ? error.message : 'Не удалось отправить запрос. Попробуйте позже.');
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -2525,17 +2877,15 @@ export default function StepWizardPlan({
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Button onClick={onPrev} style={{ marginRight: 12 }}>
|
||||
← Изменить описание
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => {
|
||||
// Сбрасываем состояние и возвращаемся на первый экран
|
||||
updateFormData({
|
||||
wizardPlan: null,
|
||||
wizardPlanStatus: null,
|
||||
problemDescription: '',
|
||||
});
|
||||
window.location.href = '/';
|
||||
setOutOfScopeData(null);
|
||||
if (onNewClaim) {
|
||||
onNewClaim(); // переход на форму «Описание проблемы», без дашборда
|
||||
} else {
|
||||
updateFormData({ wizardPlan: null, wizardPlanStatus: null, problemDescription: '' });
|
||||
window.history.pushState({}, '', '/new');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
}}>
|
||||
Новое обращение
|
||||
</Button>
|
||||
@@ -2614,6 +2964,8 @@ export default function StepWizardPlan({
|
||||
{renderQuestions()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
44
frontend/src/components/form/documentsScreenMaps.tsx
Normal file
44
frontend/src/components/form/documentsScreenMaps.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Маппинг типов документов и статусов для экрана «Загрузка документов».
|
||||
* Спецификация: дизайн «Документы кейса», Lucide-иконки.
|
||||
*/
|
||||
import {
|
||||
FileSignature,
|
||||
Receipt,
|
||||
ClipboardList,
|
||||
MessagesSquare,
|
||||
FileWarning,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Clock3,
|
||||
Ban,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export const DOC_TYPE_MAP: Record<string, { Icon: LucideIcon; color: string }> = {
|
||||
contract: { Icon: FileSignature, color: '#1890ff' },
|
||||
payment: { Icon: Receipt, color: '#52c41a' },
|
||||
receipt: { Icon: Receipt, color: '#52c41a' },
|
||||
cheque: { Icon: Receipt, color: '#52c41a' },
|
||||
correspondence: { Icon: MessagesSquare, color: '#722ed1' },
|
||||
acts: { Icon: ClipboardList, color: '#fa8c16' },
|
||||
claim: { Icon: FileWarning, color: '#ff4d4f' },
|
||||
other: { Icon: FolderOpen, color: '#595959' },
|
||||
/** Плитка «Свои документы» — произвольные группы документов */
|
||||
__custom_docs__: { Icon: FolderPlus, color: '#722ed1' },
|
||||
};
|
||||
|
||||
export function getDocTypeStyle(docId: string): { Icon: LucideIcon; color: string } {
|
||||
const key = (docId || '').toLowerCase().replace(/\s+/g, '_');
|
||||
return DOC_TYPE_MAP[key] ?? { Icon: FileText, color: '#1890ff' };
|
||||
}
|
||||
|
||||
/** Цвета и иконки статусов по спецификации */
|
||||
export const STATUS_UPLOADED = { Icon: CheckCircle2, color: '#22C55E', label: 'Загружено' };
|
||||
export const STATUS_NEEDED = { Icon: AlertTriangle, color: '#F59E0B', label: 'Нужно' };
|
||||
export const STATUS_EXPECTED = { Icon: Clock3, color: '#F59E0B', label: 'Ожидаем завтра' };
|
||||
export const STATUS_NOT_AVAILABLE = { Icon: Ban, color: '#8c8c8c', label: 'Не будет', subLabel: 'Утеряно' };
|
||||
export const STATUS_OPTIONAL = { Icon: Clock3, color: '#8c8c8c', label: 'По желанию' };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user