Compare commits
32 Commits
b71f079699
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b9665b27f | ||
|
|
e630d03e67 | ||
|
|
66a0065df8 | ||
|
|
c39b12630e | ||
|
|
b5c31b43dd | ||
|
|
f2e144e9ca | ||
|
|
06b89d20e7 | ||
|
|
9c65b6a4ea | ||
|
|
62fc57f108 | ||
|
|
b3a7396d32 | ||
|
|
d8fe0b605b | ||
|
|
6350f9015b | ||
|
|
4536210284 | ||
|
|
1887336aba | ||
|
|
8c3e993eb7 | ||
|
|
a4cc4f9de6 | ||
|
|
2e45786e46 | ||
|
|
73524465fd | ||
|
|
f7d27388a0 | ||
|
|
56516fdd7d | ||
|
|
1a653f2154 | ||
|
|
df8c93f46b | ||
|
|
30774db18c | ||
|
|
080e7ec105 | ||
|
|
64385c430d | ||
|
|
02689e65db | ||
|
|
1d6c9d1f52 | ||
|
|
521831be5e | ||
|
|
2fb0921e4c | ||
|
|
3d3f5995af | ||
|
|
6f31ad0dda | ||
|
|
9c159eda21 |
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` для детальной информации.
|
||||
|
||||
---
|
||||
|
||||
**Всё готово к работе!** 🎉
|
||||
|
||||
192
SESSION_LOG_2025-11-22_DIALOG.md
Normal file
192
SESSION_LOG_2025-11-22_DIALOG.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# Лог диалога - 22 ноября 2025
|
||||
|
||||
## Хронология диалога
|
||||
|
||||
### Начало работы
|
||||
Пользователь начал работу с исправлениями в `ticket_form`, связанными с обработкой черновиков и прикреплением документов к проектам.
|
||||
|
||||
### 1. Проблема с извлечением данных из payload
|
||||
|
||||
**Проблема:** В `payload` данные вложены в `body` (`payload.body.wizard_plan`, `payload.body.answers`), а не в `payload` напрямую.
|
||||
|
||||
**Решение:**
|
||||
- Исправлено извлечение данных из `payload.body` для telegram-черновиков
|
||||
- Добавлен парсинг JSON-строк в `wizard_plan` и `answers`
|
||||
- Использование `claim.id` (UUID) как `claim_id`, если `claim_id` null
|
||||
- Логика перехода: если есть `wizard_plan` → переходим к StepWizardPlan (шаг 2)
|
||||
|
||||
**Файлы изменены:**
|
||||
- `ticket_form/frontend/src/pages/ClaimForm.tsx`
|
||||
|
||||
### 2. Ошибка при загрузке черновика
|
||||
|
||||
**Ошибка:** `ReferenceError: Cannot access 'claimId2' before initialization` в `ClaimForm.tsx:160:50`
|
||||
|
||||
**Причина:** Конфликт имён переменных - локальная переменная `claimId` конфликтовала с параметром функции.
|
||||
|
||||
**Решение:** Переименована локальная переменная `claimId` в `finalClaimId` внутри функции `loadDraft`.
|
||||
|
||||
**Файлы изменены:**
|
||||
- `ticket_form/frontend/src/pages/ClaimForm.tsx`
|
||||
|
||||
### 3. Работа с n8n workflow `b4K4u851b4JFivyD` (ticket_form:description)
|
||||
|
||||
**Задача:** Настроить ноду `claimsave` для сохранения первичного черновика жалобы после построения wizard plan.
|
||||
|
||||
**Требования:**
|
||||
1. Сохранить черновик сразу после первичного построения wizard plan
|
||||
2. Включить данные из агентов (агент1 и агент13)
|
||||
3. Учесть `session_token` и `unified_id`
|
||||
4. Сохранить: `wizard_plan`, `problem_description`, `answers_prefill`, `coverage_report`, AI agent outputs
|
||||
|
||||
**Документация создана:**
|
||||
- `ticket_form/docs/CLAIMSAVE_PRIMARY_DRAFT_FIX.md`
|
||||
- `ticket_form/docs/SQL_CLAIMSAVE_PRIMARY_DRAFT.sql`
|
||||
|
||||
### 4. Ошибка в n8n Code node (Code4)
|
||||
|
||||
**Ошибка:** `ReferenceError: session is not defined [line 34]`
|
||||
|
||||
**Проблема:** В коде использовалась переменная `session`, которая не была определена.
|
||||
|
||||
**Решение:** Исправлен код в `CODE4_FIXED.js`:
|
||||
- Заменено `const sessionToken = $('Redis Trigger').first().json.message.claim_id` на более надёжную логику
|
||||
- `sessionToken` теперь берётся из `Edit Fields11` или `Redis Trigger`, с fallback на временный ключ
|
||||
- `redisKey` теперь использует `sessionToken` вместо `claim_id`
|
||||
|
||||
**Файлы:**
|
||||
- `ticket_form/docs/CODE4_FIXED.js`
|
||||
|
||||
### 5. Исправление CreateWebContact ноды
|
||||
|
||||
**Задача:** Убрать генерацию `claim_id`, добавить `unified_id` из ноды `user_get`, убрать `voucher` и `event_type` из `redis_value`.
|
||||
|
||||
**Решение:** Обновлён код `CODE_CREATE_WEB_CONTACT_FINAL.js`:
|
||||
- Убрана генерация `claim_id`
|
||||
- Добавлен `unified_id` из ноды `user_get`
|
||||
- Убраны `voucher` и `event_type` из `sessionData`
|
||||
- `redis_key` использует `session_id`
|
||||
|
||||
**Файлы:**
|
||||
- `ticket_form/docs/CODE_CREATE_WEB_CONTACT_FIXED.js`
|
||||
|
||||
### 6. Ошибка "Не удалось определить номер обращения"
|
||||
|
||||
**Проблема:** При создании нового обращения появлялась ошибка "Не удалось определить номер обращения. Вернитесь на шаг с телефоном."
|
||||
|
||||
**Решение:** Принято решение использовать только `session_id` на ранних этапах, убрать зависимость от `claim_id`.
|
||||
|
||||
**Изменения:**
|
||||
- `ticket_form/frontend/src/components/form/StepDescription.tsx` - убрана проверка `claim_id`
|
||||
- `ticket_form/frontend/src/components/form/Step1Phone.tsx` - убран `claim_id` из сохраняемых данных
|
||||
- `ticket_form/frontend/src/components/form/StepWizardPlan.tsx` - изменён EventSource на использование `session_id`
|
||||
- `ticket_form/backend/app/api/claims.py` - обновлено логирование для опционального `claim_id`
|
||||
|
||||
### 7. Модификация api_attach_documents.php
|
||||
|
||||
**Задача:** Вернуть `project_name` в дополнение к `project_id`.
|
||||
|
||||
**Решение:** Обновлён `include/Webservices/CreateClientProject.php`:
|
||||
- Функция теперь возвращает `project_name` вместе с `project_id` и `is_new`
|
||||
- Добавлен SQL запрос для получения `project_name`, если проект найден (не новый)
|
||||
|
||||
**Файлы:**
|
||||
- `include/Webservices/CreateClientProject.php`
|
||||
|
||||
### 8. Обновление S3 пути для файлов
|
||||
|
||||
**Задача:** Изменить формат пути S3 на `/f9825c87-.../crm2/CRM_Active_Files/Documents/Project/{project_name}_{project_id}/{doc_id}__{slug}.{ext}`
|
||||
|
||||
**Решение:** Обновлён `CODE_FILES_RENAME_FIXED.js`:
|
||||
- Добавлено получение `project_id` и `project_name` из нескольких источников
|
||||
- Реализована санитизация `projectFolder` для удаления недопустимых символов
|
||||
- Обновлена генерация `slug` с приоритетом: `field_label` > `field_name` > `description`
|
||||
- Добавлен `field_label` в `renames` и `finalDocumentsMeta`
|
||||
|
||||
**Файлы:**
|
||||
- `ticket_form/docs/CODE_FILES_RENAME_FIXED.js`
|
||||
|
||||
### 9. Исправление slug для названий документов
|
||||
|
||||
**Задача:** Использовать название поля из формы визарда вместо generic "upload-contr".
|
||||
|
||||
**Решение:**
|
||||
- В `StepWizardPlan.tsx` добавлена отправка `uploads_field_labels[i]` (содержит `block.docLabel`)
|
||||
- В `CODE_FILES_RENAME_FIXED.js` обновлена генерация `slug` с использованием `field_label`
|
||||
|
||||
**Файлы:**
|
||||
- `ticket_form/frontend/src/components/form/StepWizardPlan.tsx`
|
||||
- `ticket_form/docs/CODE_FILES_RENAME_FIXED.js`
|
||||
|
||||
### 10. Ошибка "Multiple matching items" в Edit Fields13
|
||||
|
||||
**Ошибка:** `Multiple matching items for item [0] [item 0]` в ноде "Edit Fields13".
|
||||
|
||||
**Решение:** Обновлены выражения в "Edit Fields13":
|
||||
- Добавлен `.first()` для нод, возвращающих один item (`Edit Fields6`, `Code5`)
|
||||
- Исправлено обращение к `Split Out2` (используется `$json.to` вместо `$('Split Out2').item.json.to`)
|
||||
|
||||
### 11. Исправление CODE_MERGE_PROJECT_TO_SESSION
|
||||
|
||||
**Ошибка:** `TypeError: Cannot assign to read only property 'name' of object 'Error: Referenced node doesn't exist'`
|
||||
|
||||
**Решение:** Заменён оператор `||` для доступа к ноде на `try-catch` блоки для безопасной проверки существования ноды.
|
||||
|
||||
**Файлы:**
|
||||
- `ticket_form/docs/CODE_MERGE_PROJECT_TO_SESSION.js`
|
||||
|
||||
### 12. Финальные исправления и коммит
|
||||
|
||||
**Выполнено:**
|
||||
- Исправлена загрузка черновиков (упрощена логика перехода)
|
||||
- Убрано отображение `claim_id` в заголовке черновика
|
||||
- Обновлён формат пути S3 с `project_name`
|
||||
- Добавлен `field_label` в результат переименования файлов
|
||||
|
||||
**Git коммиты:**
|
||||
- `486f3619`: "Добавлен field_label в результат переименования файлов, исправлена загрузка черновиков, обновлен формат пути S3 с project_name"
|
||||
- `a20a4d0e`: "Добавлен лог сессии 2025-11-22"
|
||||
|
||||
## Итоговые изменения
|
||||
|
||||
### Frontend
|
||||
1. `ClaimForm.tsx` - исправлена загрузка черновиков, убрана зависимость от `claim_id`
|
||||
2. `StepDescription.tsx` - убрана проверка `claim_id`
|
||||
3. `Step1Phone.tsx` - убран `claim_id` из сохраняемых данных
|
||||
4. `StepWizardPlan.tsx` - добавлена отправка `uploads_field_labels`, изменён EventSource на `session_id`
|
||||
5. `StepDraftSelection.tsx` - убран `claim_id` из заголовка черновика
|
||||
|
||||
### Backend
|
||||
1. `claims.py` - обновлено логирование для опционального `claim_id`
|
||||
2. `CreateClientProject.php` - добавлен возврат `project_name`
|
||||
|
||||
### n8n Workflows
|
||||
1. Code4 - исправлена ошибка с `session is not defined`
|
||||
2. CreateWebContact - убрана генерация `claim_id`, добавлен `unified_id`
|
||||
3. CODE_FILES_RENAME_FIXED - обновлён формат пути S3, добавлен `field_label`
|
||||
4. CODE_MERGE_PROJECT_TO_SESSION - безопасная проверка существования ноды
|
||||
5. Edit Fields13 - исправлена ошибка "Multiple matching items"
|
||||
|
||||
### Документация
|
||||
1. `CLAIMSAVE_PRIMARY_DRAFT_FIX.md` - описание сохранения первичного черновика
|
||||
2. `SQL_CLAIMSAVE_PRIMARY_DRAFT.sql` - SQL запрос для сохранения черновика
|
||||
3. `CODE4_FIXED.js` - исправленный код для Code4
|
||||
4. `CODE_CREATE_WEB_CONTACT_FIXED.js` - исправленный код для CreateWebContact
|
||||
5. `CODE_FILES_RENAME_FIXED.js` - обновлённый код для переименования файлов
|
||||
6. `CODE_MERGE_PROJECT_TO_SESSION.js` - код для мержа данных проекта
|
||||
|
||||
## Статистика
|
||||
|
||||
- **Изменено файлов:** 212
|
||||
- **Добавлено строк:** +6706
|
||||
- **Удалено строк:** -125
|
||||
- **Git коммитов:** 2
|
||||
|
||||
## Важные замечания
|
||||
|
||||
1. На ранних этапах используется только `session_id`, `claim_id` генерируется позже в workflow
|
||||
2. `project_name` теперь используется в пути S3 для лучшей организации файлов
|
||||
3. `field_label` из формы визарда используется для генерации slug файлов
|
||||
4. Все ноды n8n должны безопасно обрабатывать отсутствие данных
|
||||
|
||||
|
||||
135
SESSION_LOG_2025-11-25.md
Normal file
135
SESSION_LOG_2025-11-25.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Лог сессии 25.11.2025
|
||||
|
||||
## Основные задачи
|
||||
|
||||
### 1. Передача unified_id и contact_id в описание проблемы
|
||||
|
||||
**Файлы:**
|
||||
- `backend/app/api/models.py` — добавлены поля `unified_id` и `contact_id` в `TicketFormDescriptionRequest`
|
||||
- `backend/app/api/claims.py` — добавлена передача `unified_id` и `contact_id` в Redis событие
|
||||
- `frontend/src/components/form/StepDescription.tsx` — добавлена передача `unified_id` и `contact_id` при отправке описания
|
||||
|
||||
**Результат:** При отправке описания проблемы теперь передаются `unified_id` и `contact_id` пользователя.
|
||||
|
||||
---
|
||||
|
||||
### 2. Структура таблиц CRM MySQL для контактов
|
||||
|
||||
**Основные таблицы:**
|
||||
- `vtiger_contactdetails` — основные данные (firstname, lastname, email, mobile, phone)
|
||||
- `vtiger_contactscf` — кастомные поля:
|
||||
- `cf_1157` — Отчество (middle_name)
|
||||
- `cf_1263` — Место рождения (birthplace)
|
||||
- `cf_1257` — ИНН (inn)
|
||||
- `cf_1849` — Реквизиты (requisites)
|
||||
- `cf_1580` — Код (code)
|
||||
- `vtiger_contactsubdetails` — дополнительные данные (birthday, homephone)
|
||||
- `vtiger_contactaddress` — адреса (mailingstreet, mailingcity, и т.д.)
|
||||
|
||||
**Создан файл:** `docs/SQL_SELECT_CONTACT_WITH_CUSTOM_FIELDS.sql` — правильный SQL запрос для получения всех данных контакта
|
||||
|
||||
---
|
||||
|
||||
### 3. Исправление Code Node: Мерж данных проекта в сессию
|
||||
|
||||
**Проблема:** Данные из `body.other` (sessionData) не сохранялись в Redis — терялись все данные пользователя.
|
||||
|
||||
**Причина:** К моменту выполнения Code Node структура данных менялась (`body_keys: ["success", "result"]`), и `body.other` был недоступен.
|
||||
|
||||
**Решение:** Добавлен fallback на получение `other` напрямую из Webhook:
|
||||
```javascript
|
||||
// ✅ Пробуем также достать other из Webhook напрямую
|
||||
if (!rawOther) {
|
||||
try {
|
||||
const webhookJson = $('Webhook').first()?.json;
|
||||
if (webhookJson?.body?.other) {
|
||||
rawOther = webhookJson.body.other;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
```
|
||||
|
||||
**Файл:** `docs/CODE_MERGE_PROJECT_TO_SESSION.js`
|
||||
|
||||
**Результат:** Теперь в Redis сохраняются ВСЕ данные:
|
||||
- session_id, phone, unified_id, contact_id
|
||||
- lastname, firstname, middle_name
|
||||
- birthday, birthplace, inn
|
||||
- mailingzip, mailingstreet, email, tg_id
|
||||
- description
|
||||
- claim_id, project_id, project_name
|
||||
- is_new_project, current_step
|
||||
|
||||
---
|
||||
|
||||
### 4. Генерация новой сессии для новой жалобы
|
||||
|
||||
**Проблема:** При создании новой жалобы использовалась та же сессия, что и для предыдущей.
|
||||
|
||||
**Решение:**
|
||||
- Добавлена функция `generateUUIDv4()` в `ClaimForm.tsx`
|
||||
- При создании новой жалобы генерируется новый `session_id`
|
||||
- `session_token` в localStorage (авторизация) остаётся прежним
|
||||
- `unified_id`, `phone`, `contact_id` сохраняются
|
||||
|
||||
**Файл:** `frontend/src/pages/ClaimForm.tsx`
|
||||
|
||||
---
|
||||
|
||||
## Созданные/обновлённые файлы
|
||||
|
||||
### Новые файлы:
|
||||
- `docs/SQL_SELECT_CONTACT_WITH_CUSTOM_FIELDS.sql` — SQL запрос для контактов с кастомными полями
|
||||
|
||||
### Обновлённые файлы:
|
||||
- `backend/app/api/models.py` — добавлены unified_id, contact_id
|
||||
- `backend/app/api/claims.py` — передача unified_id, contact_id в Redis
|
||||
- `frontend/src/components/form/StepDescription.tsx` — передача unified_id, contact_id
|
||||
- `frontend/src/pages/ClaimForm.tsx` — генерация новой сессии для новой жалобы
|
||||
- `docs/CODE_MERGE_PROJECT_TO_SESSION.js` — исправлен мерж данных в сессию
|
||||
|
||||
---
|
||||
|
||||
## Технические детали
|
||||
|
||||
### Redis канал для описания проблемы
|
||||
- Канал: `ticket_form:description`
|
||||
- Передаваемые данные: session_id, phone, email, unified_id, contact_id, problem_description
|
||||
|
||||
### Redis канал для подтверждения формы
|
||||
- Канал: `clientright:webform:approve`
|
||||
- Включает SMS код для верификации
|
||||
|
||||
### Структура сессии в Redis
|
||||
```json
|
||||
{
|
||||
"session_id": "sess_...",
|
||||
"phone": "79262306381",
|
||||
"unified_id": "usr_...",
|
||||
"contact_id": "320096",
|
||||
"lastname": "Коробков",
|
||||
"firstname": "Федор",
|
||||
"middle_name": "Владимирович",
|
||||
"birthday": "1981-09-18",
|
||||
"birthplace": "Москва",
|
||||
"inn": "123456789012",
|
||||
"mailingstreet": "...",
|
||||
"email": "help@clientright.ru",
|
||||
"tg_id": "295410106",
|
||||
"description": "...",
|
||||
"claim_id": "...",
|
||||
"project_id": "399171",
|
||||
"project_name": "Коробков_КлиентПрав",
|
||||
"is_new_project": false,
|
||||
"current_step": 2
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Статус
|
||||
✅ Все задачи выполнены
|
||||
✅ Backend пересобран и перезапущен
|
||||
✅ Frontend обновлён через HMR
|
||||
✅ Тестирование успешно
|
||||
|
||||
176
SESSION_LOG_2025-11-26_DOCUMENTS_FIX.md
Normal file
176
SESSION_LOG_2025-11-26_DOCUMENTS_FIX.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Лог сессии: Исправление загрузки документов и SQL запросов
|
||||
|
||||
**Дата:** 2025-11-26
|
||||
**Тема:** Исправление потери документов, дубликатов и правильного определения field_name
|
||||
|
||||
---
|
||||
|
||||
## Проблемы, которые были решены
|
||||
|
||||
### 1. Потеря документов при обновлении черновика
|
||||
**Проблема:** При обработке нового документа через SQL `claimsave_final` существующие документы терялись.
|
||||
|
||||
**Причина:**
|
||||
- SQL перезаписывал `documents_meta` вместо объединения
|
||||
- `documents_uploaded` мог быть перезаписан пустым массивом, если `jsonb_agg` возвращал NULL
|
||||
|
||||
**Решение:**
|
||||
- Исправлен SQL `SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql`:
|
||||
- `documents_meta` теперь объединяется с существующими
|
||||
- `documents_uploaded` всегда начинается с существующих документов
|
||||
- Добавлена проверка на пустой массив перед перезаписью
|
||||
|
||||
### 2. Дубликаты документов в documents_meta
|
||||
**Проблема:** В `documents_meta` были дубликаты (один и тот же `file_id` встречался несколько раз).
|
||||
|
||||
**Решение:**
|
||||
- Создан скрипт `fix_documents_meta_duplicates.py` для удаления дубликатов
|
||||
- Исправлена логика объединения в SQL
|
||||
|
||||
### 3. Неправильное определение типа документа
|
||||
**Проблема:** Чек определялся как `contract` вместо `payment`.
|
||||
|
||||
**Причина:**
|
||||
- SQL проверял `field_name` раньше, чем `field_label`
|
||||
- `field_name` был `uploads[0][0]` для всех документов
|
||||
|
||||
**Решение:**
|
||||
- Изменён порядок проверки в SQL: сначала `field_label`, потом `field_name`
|
||||
- Исправлен файл `SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql`
|
||||
|
||||
### 4. Все документы имели одинаковый field_name
|
||||
**Проблема:** В таблице `clpr_claim_documents` все документы имели `field_name: uploads[0][0]`, из-за чего второй документ перезаписывал первый.
|
||||
|
||||
**Причина:**
|
||||
- `group_index` (индекс документа в `documents_required`) не передавался с фронтенда
|
||||
- Код n8n использовал `group_index_num` из OCR, который всегда был `0`
|
||||
|
||||
**Решение:**
|
||||
- Фронтенд (`StepWizardPlan.tsx`): добавлена передача `group_index` в запрос
|
||||
- Бэкенд (`documents.py`): добавлено получение `group_index` из Form и передача в n8n
|
||||
- Код n8n (`N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js`): приоритет `group_index` из body над `group_index_num` из OCR
|
||||
- Создан скрипт `fix_claim_documents_field_names.py` для исправления существующих документов
|
||||
|
||||
### 5. SQL для claimsave перезаписывал documents_meta
|
||||
**Проблема:** SQL `claimsave` перезаписывал `documents_meta` вместо объединения.
|
||||
|
||||
**Решение:**
|
||||
- Исправлен файл `SQL_CLAIMSAVE_FIXED_NEW_FLOW.sql`:
|
||||
- `documents_meta` объединяется с существующими
|
||||
- Критичные поля удаляются из нового payload перед объединением
|
||||
- Затем устанавливаются отдельно через `jsonb_set`
|
||||
|
||||
### 6. Дубликаты в списке загруженных документов на фронтенде
|
||||
**Проблема:** React ошибка "Encountered two children with the same key, `contract`".
|
||||
|
||||
**Решение:**
|
||||
- Исправлен `StepWizardPlan.tsx`:
|
||||
- Убраны дубликаты при инициализации `uploadedDocs`
|
||||
- Проверка на дубликаты при добавлении нового документа
|
||||
- Использование `Array.from(new Set())` при рендеринге
|
||||
|
||||
---
|
||||
|
||||
## Созданные файлы
|
||||
|
||||
### SQL запросы
|
||||
- `docs/SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql` - SQL для сохранения документов с автоматическим созданием `documents_uploaded`
|
||||
- `docs/SQL_CLAIMSAVE_FIXED_NEW_FLOW.sql` - Исправленный SQL для `claimsave` с объединением `documents_meta`
|
||||
- `docs/SQL_FIX_DRAFT_BDDB6815.sql` - SQL для исправления конкретного черновика
|
||||
- `docs/SQL_FIX_CLAIM_DOCUMENTS_FIELD_NAMES.sql` - SQL для исправления `field_name` в таблице
|
||||
|
||||
### Код n8n
|
||||
- `docs/N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js` - Исправленный код для обработки загруженных файлов с поддержкой `group_index`
|
||||
|
||||
### Скрипты для исправления данных
|
||||
- `fix_draft_bddb6815_with_contract.py` - Скрипт для исправления черновика с учётом загруженных документов
|
||||
- `fix_documents_meta_duplicates.py` - Скрипт для удаления дубликатов из `documents_meta`
|
||||
- `fix_claim_documents_field_names.py` - Скрипт для исправления `field_name` в таблице `clpr_claim_documents`
|
||||
- `check_documents_detailed.py` - Скрипт для детальной проверки документов
|
||||
- `check_documents_mismatch.py` - Скрипт для проверки несоответствий между `documents_uploaded` и таблицей
|
||||
|
||||
---
|
||||
|
||||
## Изменённые файлы
|
||||
|
||||
### Backend
|
||||
- `backend/app/api/documents.py` - Добавлена передача `group_index` в n8n
|
||||
- `backend/app/api/claims.py` - Обновлена логика загрузки черновиков, добавлена поддержка `documents_required`
|
||||
- `backend/app/api/events.py` - Исправлены синтаксические ошибки (удалены дубликаты кода)
|
||||
- `backend/app/api/models.py` - Добавлены поля `unified_id` и `contact_id`
|
||||
|
||||
### Frontend
|
||||
- `frontend/src/pages/ClaimForm.tsx` - Обновлена логика загрузки черновиков, добавлена поддержка нового флоу
|
||||
- `frontend/src/components/form/StepWizardPlan.tsx` - Добавлена передача `group_index`, исправлены дубликаты в списке документов
|
||||
- `frontend/src/components/form/StepDraftSelection.tsx` - Обновлена логика определения legacy черновиков
|
||||
- `frontend/src/components/form/StepDescription.tsx` - Добавлена передача `unified_id` и `contact_id`
|
||||
|
||||
---
|
||||
|
||||
## Результаты
|
||||
|
||||
### Исправлено для черновика `bddb6815-8e17-4d54-a721-5e94382942c7`:
|
||||
- ✅ Удалены дубликаты из `documents_meta` (было 4, стало 3)
|
||||
- ✅ Исправлены типы документов в `documents_uploaded` (чек теперь `payment`, а не `contract`)
|
||||
- ✅ Исправлены `field_name` в таблице `clpr_claim_documents`:
|
||||
- `uploads[0][0]` - contract (договор)
|
||||
- `uploads[1][0]` - payment (чек)
|
||||
- `uploads[3][0]` - evidence_photo (фото доказательства)
|
||||
|
||||
### Текущее состояние:
|
||||
- `documents_required`: 4 документа
|
||||
- `documents_uploaded`: 2 документа (contract, payment)
|
||||
- `documents_meta`: 3 документа (без дубликатов)
|
||||
- `current_doc_index`: 2 (следующий документ - correspondence)
|
||||
- `status_code`: `draft_docs_progress`
|
||||
|
||||
---
|
||||
|
||||
## Что нужно сделать дальше
|
||||
|
||||
1. **Обновить код n8n:**
|
||||
- Заменить код в узле "Process Uploaded Files" на версию из `N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js`
|
||||
- Убедиться, что `group_index` передаётся из body
|
||||
|
||||
2. **Обновить SQL в n8n:**
|
||||
- Заменить SQL в узле "claimsave" на версию из `SQL_CLAIMSAVE_FIXED_NEW_FLOW.sql`
|
||||
- Заменить SQL в узле "claimsave_final" на версию из `SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql`
|
||||
|
||||
3. **Проверить работу:**
|
||||
- Загрузить новый документ через интерфейс
|
||||
- Убедиться, что он получает правильный `field_name` (например, `uploads[2][0]` для третьего документа)
|
||||
- Проверить, что документы не теряются при обновлении черновика
|
||||
|
||||
---
|
||||
|
||||
## Важные моменты
|
||||
|
||||
1. **Приоритет определения типа документа:**
|
||||
- Сначала проверяется `field_label` (более точный)
|
||||
- Потом проверяется `field_name` (fallback)
|
||||
|
||||
2. **Объединение документов:**
|
||||
- `documents_meta` всегда объединяется с существующими
|
||||
- `documents_uploaded` всегда начинается с существующих документов
|
||||
- Новые документы добавляются только если их нет в существующих
|
||||
|
||||
3. **field_name:**
|
||||
- Формат: `uploads[{group_index}][0]`
|
||||
- `group_index` = индекс документа в `documents_required` (0-based)
|
||||
- Передаётся с фронтенда через параметр `group_index`
|
||||
|
||||
---
|
||||
|
||||
## Команды для проверки
|
||||
|
||||
```bash
|
||||
# Проверить документы в черновике
|
||||
docker exec ticket_form_backend python3 /app/check_documents_detailed.py
|
||||
|
||||
# Проверить документы в таблице
|
||||
docker exec ticket_form_backend python3 /app/check_claim_documents_table.py
|
||||
|
||||
# Исправить field_name для существующих документов
|
||||
docker exec ticket_form_backend python3 /app/fix_claim_documents_field_names.py
|
||||
```
|
||||
|
||||
287
SESSION_LOG_2025-11-26_NEW_FLOW.md
Normal file
287
SESSION_LOG_2025-11-26_NEW_FLOW.md
Normal file
@@ -0,0 +1,287 @@
|
||||
# 📝 Лог сессии: Новая архитектура загрузки документов
|
||||
|
||||
**Дата:** 2025-11-26
|
||||
**Время:** ~13:00 MSK
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Цель сессии
|
||||
|
||||
Концептуальная переработка флоу подачи заявки:
|
||||
- **Проблема:** Визард генерируется слишком долго (2 минуты), анкета слишком длинная
|
||||
- **Решение:** Сразу запрашиваем документы, параллельно генерируем визард в бэке
|
||||
|
||||
---
|
||||
|
||||
## ✅ Что сделано
|
||||
|
||||
### 1. Документация архитектуры
|
||||
- **Файл:** `docs/NEW_FLOW_ARCHITECTURE.md`
|
||||
- Описан новый флоу: Description → Documents → Waiting → Claim Review → SMS
|
||||
- Определены статусы черновиков: `draft_new`, `draft_docs_progress`, `draft_docs_complete`, `draft_claim_ready`, `awaiting_sms`
|
||||
- Структура payload черновика с новыми полями
|
||||
|
||||
### 2. Frontend компоненты
|
||||
|
||||
#### StepDocumentsNew.tsx (НОВЫЙ)
|
||||
- Поэкранная загрузка документов (один документ на экран)
|
||||
- Критичные документы помечены предупреждением
|
||||
- Возможность пропустить любой документ
|
||||
- Прогресс-бар загрузки
|
||||
- Отображение уже загруженных документов
|
||||
|
||||
#### StepWaitingClaim.tsx (НОВЫЙ)
|
||||
- Экран ожидания формирования заявления
|
||||
- SSE подписка на события: `document_ocr_completed`, `claim_ready`
|
||||
- Шаги обработки: OCR → Анализ → Формирование → Готово
|
||||
- Таймер ожидания
|
||||
- Таймаут 5 минут с обработкой ошибок
|
||||
|
||||
#### StepDraftSelection.tsx (ОБНОВЛЁН)
|
||||
- Поддержка новых статусов черновиков
|
||||
- Визуальное отображение разных статусов (цвета, иконки, описания)
|
||||
- Прогресс документов (X из Y загружено)
|
||||
- Legacy черновики помечаются как "устаревший формат"
|
||||
- Разные действия для разных статусов
|
||||
|
||||
### 3. Backend API
|
||||
|
||||
#### documents.py (НОВЫЙ)
|
||||
- `POST /api/v1/documents/upload` — загрузка одного документа
|
||||
- `GET /api/v1/documents/status/{claim_id}` — статус обработки документов
|
||||
- `POST /api/v1/documents/generate-list` — запрос на генерацию списка документов
|
||||
- Интеграция с n8n webhook
|
||||
- Публикация событий в Redis
|
||||
|
||||
#### main.py (ОБНОВЛЁН)
|
||||
- Добавлен роутер `documents`
|
||||
|
||||
---
|
||||
|
||||
## 📁 Изменённые файлы
|
||||
|
||||
```
|
||||
ticket_form/
|
||||
├── docs/
|
||||
│ └── NEW_FLOW_ARCHITECTURE.md # НОВЫЙ
|
||||
├── frontend/src/components/form/
|
||||
│ ├── StepDocumentsNew.tsx # НОВЫЙ
|
||||
│ ├── StepWaitingClaim.tsx # НОВЫЙ
|
||||
│ └── StepDraftSelection.tsx # ОБНОВЛЁН
|
||||
├── backend/app/
|
||||
│ ├── api/
|
||||
│ │ └── documents.py # НОВЫЙ
|
||||
│ └── main.py # ОБНОВЛЁН
|
||||
└── SESSION_LOG_2025-11-26_NEW_FLOW.md # НОВЫЙ
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⏳ Что осталось сделать
|
||||
|
||||
### Frontend
|
||||
- [ ] Обновить `ClaimForm.tsx` — интегрировать новые компоненты в флоу
|
||||
- [ ] Обновить `StepDescription.tsx` — после описания переходить к документам (не к визарду)
|
||||
|
||||
### Backend
|
||||
- [ ] Эндпоинт получения списка документов из черновика
|
||||
- [ ] SSE события для прогресса OCR
|
||||
|
||||
### n8n
|
||||
- [ ] Воркфлоу: генерация списка документов (быстрый AI запрос)
|
||||
- [ ] Воркфлоу: OCR документа → заполнение полей визарда
|
||||
- [ ] Воркфлоу: формирование заявления после всех документов
|
||||
- [ ] Webhook: `/webhook/document-upload`
|
||||
|
||||
### Тестирование
|
||||
- [ ] Полный цикл с реальными данными
|
||||
- [ ] Обработка ошибок
|
||||
- [ ] Legacy черновики
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Технические детали
|
||||
|
||||
### Новые SSE события
|
||||
```javascript
|
||||
// Список документов готов
|
||||
{ event_type: "documents_list_ready", documents_required: [...] }
|
||||
|
||||
// Документ загружен (начало OCR)
|
||||
{ event_type: "document_uploaded", document_type: "contract", status: "processing" }
|
||||
|
||||
// OCR завершён
|
||||
{ event_type: "document_ocr_completed", document_type: "contract", ocr_data: {...} }
|
||||
|
||||
// Заявление готово
|
||||
{ event_type: "claim_ready", claim_data: {...} }
|
||||
```
|
||||
|
||||
### Статусы черновиков
|
||||
| Статус | Описание |
|
||||
|--------|----------|
|
||||
| `draft_new` | Только описание проблемы |
|
||||
| `draft_docs_progress` | Часть документов загружена |
|
||||
| `draft_docs_complete` | Все документы, ждём заявление |
|
||||
| `draft_claim_ready` | Заявление готово |
|
||||
| `awaiting_sms` | Ждёт SMS подтверждения |
|
||||
|
||||
### Legacy черновики
|
||||
- Определяются по отсутствию `documents_required` в payload
|
||||
- Показываются с пометкой "устаревший формат"
|
||||
- Кнопка "Начать заново" копирует description в новый черновик
|
||||
|
||||
---
|
||||
|
||||
## 📌 Примечания
|
||||
|
||||
1. **Ветка backup:** `backup-wizard-ui-2025-11-26` содержит состояние до изменений
|
||||
2. **n8n:** Webhook `/webhook/document-upload` нужно создать
|
||||
3. **Redis каналы:**
|
||||
- `ocr_events:{session_id}` — события для конкретного пользователя
|
||||
- `ticket_form:documents_list` — запрос на генерацию списка документов
|
||||
|
||||
|
||||
|
||||
**Дата:** 2025-11-26
|
||||
**Время:** ~13:00 MSK
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Цель сессии
|
||||
|
||||
Концептуальная переработка флоу подачи заявки:
|
||||
- **Проблема:** Визард генерируется слишком долго (2 минуты), анкета слишком длинная
|
||||
- **Решение:** Сразу запрашиваем документы, параллельно генерируем визард в бэке
|
||||
|
||||
---
|
||||
|
||||
## ✅ Что сделано
|
||||
|
||||
### 1. Документация архитектуры
|
||||
- **Файл:** `docs/NEW_FLOW_ARCHITECTURE.md`
|
||||
- Описан новый флоу: Description → Documents → Waiting → Claim Review → SMS
|
||||
- Определены статусы черновиков: `draft_new`, `draft_docs_progress`, `draft_docs_complete`, `draft_claim_ready`, `awaiting_sms`
|
||||
- Структура payload черновика с новыми полями
|
||||
|
||||
### 2. Frontend компоненты
|
||||
|
||||
#### StepDocumentsNew.tsx (НОВЫЙ)
|
||||
- Поэкранная загрузка документов (один документ на экран)
|
||||
- Критичные документы помечены предупреждением
|
||||
- Возможность пропустить любой документ
|
||||
- Прогресс-бар загрузки
|
||||
- Отображение уже загруженных документов
|
||||
|
||||
#### StepWaitingClaim.tsx (НОВЫЙ)
|
||||
- Экран ожидания формирования заявления
|
||||
- SSE подписка на события: `document_ocr_completed`, `claim_ready`
|
||||
- Шаги обработки: OCR → Анализ → Формирование → Готово
|
||||
- Таймер ожидания
|
||||
- Таймаут 5 минут с обработкой ошибок
|
||||
|
||||
#### StepDraftSelection.tsx (ОБНОВЛЁН)
|
||||
- Поддержка новых статусов черновиков
|
||||
- Визуальное отображение разных статусов (цвета, иконки, описания)
|
||||
- Прогресс документов (X из Y загружено)
|
||||
- Legacy черновики помечаются как "устаревший формат"
|
||||
- Разные действия для разных статусов
|
||||
|
||||
### 3. Backend API
|
||||
|
||||
#### documents.py (НОВЫЙ)
|
||||
- `POST /api/v1/documents/upload` — загрузка одного документа
|
||||
- `GET /api/v1/documents/status/{claim_id}` — статус обработки документов
|
||||
- `POST /api/v1/documents/generate-list` — запрос на генерацию списка документов
|
||||
- Интеграция с n8n webhook
|
||||
- Публикация событий в Redis
|
||||
|
||||
#### main.py (ОБНОВЛЁН)
|
||||
- Добавлен роутер `documents`
|
||||
|
||||
---
|
||||
|
||||
## 📁 Изменённые файлы
|
||||
|
||||
```
|
||||
ticket_form/
|
||||
├── docs/
|
||||
│ └── NEW_FLOW_ARCHITECTURE.md # НОВЫЙ
|
||||
├── frontend/src/components/form/
|
||||
│ ├── StepDocumentsNew.tsx # НОВЫЙ
|
||||
│ ├── StepWaitingClaim.tsx # НОВЫЙ
|
||||
│ └── StepDraftSelection.tsx # ОБНОВЛЁН
|
||||
├── backend/app/
|
||||
│ ├── api/
|
||||
│ │ └── documents.py # НОВЫЙ
|
||||
│ └── main.py # ОБНОВЛЁН
|
||||
└── SESSION_LOG_2025-11-26_NEW_FLOW.md # НОВЫЙ
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⏳ Что осталось сделать
|
||||
|
||||
### Frontend
|
||||
- [ ] Обновить `ClaimForm.tsx` — интегрировать новые компоненты в флоу
|
||||
- [ ] Обновить `StepDescription.tsx` — после описания переходить к документам (не к визарду)
|
||||
|
||||
### Backend
|
||||
- [ ] Эндпоинт получения списка документов из черновика
|
||||
- [ ] SSE события для прогресса OCR
|
||||
|
||||
### n8n
|
||||
- [ ] Воркфлоу: генерация списка документов (быстрый AI запрос)
|
||||
- [ ] Воркфлоу: OCR документа → заполнение полей визарда
|
||||
- [ ] Воркфлоу: формирование заявления после всех документов
|
||||
- [ ] Webhook: `/webhook/document-upload`
|
||||
|
||||
### Тестирование
|
||||
- [ ] Полный цикл с реальными данными
|
||||
- [ ] Обработка ошибок
|
||||
- [ ] Legacy черновики
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Технические детали
|
||||
|
||||
### Новые SSE события
|
||||
```javascript
|
||||
// Список документов готов
|
||||
{ event_type: "documents_list_ready", documents_required: [...] }
|
||||
|
||||
// Документ загружен (начало OCR)
|
||||
{ event_type: "document_uploaded", document_type: "contract", status: "processing" }
|
||||
|
||||
// OCR завершён
|
||||
{ event_type: "document_ocr_completed", document_type: "contract", ocr_data: {...} }
|
||||
|
||||
// Заявление готово
|
||||
{ event_type: "claim_ready", claim_data: {...} }
|
||||
```
|
||||
|
||||
### Статусы черновиков
|
||||
| Статус | Описание |
|
||||
|--------|----------|
|
||||
| `draft_new` | Только описание проблемы |
|
||||
| `draft_docs_progress` | Часть документов загружена |
|
||||
| `draft_docs_complete` | Все документы, ждём заявление |
|
||||
| `draft_claim_ready` | Заявление готово |
|
||||
| `awaiting_sms` | Ждёт SMS подтверждения |
|
||||
|
||||
### Legacy черновики
|
||||
- Определяются по отсутствию `documents_required` в payload
|
||||
- Показываются с пометкой "устаревший формат"
|
||||
- Кнопка "Начать заново" копирует description в новый черновик
|
||||
|
||||
---
|
||||
|
||||
## 📌 Примечания
|
||||
|
||||
1. **Ветка backup:** `backup-wizard-ui-2025-11-26` содержит состояние до изменений
|
||||
2. **n8n:** Webhook `/webhook/document-upload` нужно создать
|
||||
3. **Redis каналы:**
|
||||
- `ocr_events:{session_id}` — события для конкретного пользователя
|
||||
- `ticket_form:documents_list` — запрос на генерацию списка документов
|
||||
|
||||
|
||||
55
SESSION_LOG_2025-11-26_WIZARD_UI.md
Normal file
55
SESSION_LOG_2025-11-26_WIZARD_UI.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Сессия 26 ноября 2025 - Исправления UI Wizard
|
||||
|
||||
## Основные изменения
|
||||
|
||||
### 1. Исправлена ошибка Authentication failed в upload_documents_to_crm.php
|
||||
- **Проблема:** Race condition при параллельных запросах к webservice CRM
|
||||
- **Решение:** Добавлена функция `getWebserviceSession()` с retry механизмом (до 3 попыток) и случайной задержкой между попытками
|
||||
|
||||
### 2. Исправлен Wizard Plan - чекбоксы заменены на блоки загрузки
|
||||
- **Проблема:** Вопрос `docs_exist` показывал чекбоксы вместо полей загрузки файлов
|
||||
- **Решение:**
|
||||
- Скрыт вопрос `docs_exist` когда есть документы в плане
|
||||
- Добавлены блоки загрузки файлов под карточкой "Документы, которые понадобятся"
|
||||
|
||||
### 3. Чекбокс "У меня нет документа" перенесён под загрузку
|
||||
- **Было:** Чекбокс показывался отдельно сверху
|
||||
- **Стало:** Чекбокс внутри карточки, под Dragger (только для обязательных документов)
|
||||
|
||||
### 4. Блоки загрузки сразу развёрнуты
|
||||
- Добавлен useEffect с ref для автоматического создания блоков при загрузке плана
|
||||
- Используется `createdDocBlocksRef` чтобы избежать дублирования
|
||||
|
||||
### 5. Убраны лишние поля для предустановленных документов
|
||||
- Для документов из плана (contract, payment, correspondence и т.д.):
|
||||
- Нет поля "Уточните тип" (тип уже известен)
|
||||
- Нет кнопки "Удалить" для первого блока
|
||||
- Для дополнительных блоков - поля отображаются
|
||||
|
||||
### 6. Исправлено дублирование блоков
|
||||
- Убран дублирующий useEffect (для documentGroups)
|
||||
- Добавлен ref `createdDocBlocksRef` для отслеживания созданных блоков
|
||||
- Исправлена опечатка `React.useRef` → `useRef`
|
||||
|
||||
## Файлы изменены
|
||||
|
||||
1. `upload_documents_to_crm.php` - retry механизм для аутентификации
|
||||
2. `ticket_form/frontend/src/components/form/StepWizardPlan.tsx`:
|
||||
- Скрытие вопроса docs_exist
|
||||
- Блоки загрузки под информационной карточкой
|
||||
- Чекбокс под Dragger
|
||||
- Автосоздание блоков при загрузке
|
||||
- Улучшенная логика isPredefinedDoc
|
||||
|
||||
## Коммиты
|
||||
|
||||
1. `Добавлен retry механизм для webservice аутентификации (race condition fix)`
|
||||
2. `Заменены чекбоксы docs_exist на блоки загрузки файлов`
|
||||
3. `Исправлен JSX Fragment для блоков загрузки документов`
|
||||
4. `Чекбокс 'нет документа' перенесён под блок загрузки`
|
||||
5. `Блоки загрузки документов сразу развёрнуты при загрузке плана`
|
||||
6. `Убраны лишние поля для предустановленных документов`
|
||||
7. `Убран дублирующий useEffect для создания блоков документов`
|
||||
8. `Исправлено дублирование блоков документов (ref для отслеживания созданных)`
|
||||
9. `Исправлен React.useRef → useRef`
|
||||
|
||||
198
SESSION_LOG_2025-12-03.md
Normal file
198
SESSION_LOG_2025-12-03.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# Лог сессии 2025-12-03
|
||||
|
||||
## Задача 1: Получение cf_2624 из MySQL при загрузке черновика
|
||||
|
||||
### Проблема
|
||||
Пользователь заметил, что для `claim_id: "226564ce-d7cf-48ee-a820-690e8f5ec8e5"` доступно редактирование, хотя в CRM стоит галка "Данные подтверждены" (`cf_2624 = "1"`).
|
||||
|
||||
### Решение
|
||||
Вместо передачи `cf_2624` через события Redis, реализован прямой SQL запрос к MySQL БД vtiger CRM при загрузке черновика.
|
||||
|
||||
## Изменения
|
||||
|
||||
### 1. Добавлены credentials для MySQL CRM в `config.py`
|
||||
```python
|
||||
# MySQL CRM (vtiger CRM)
|
||||
mysql_crm_host: str = "localhost"
|
||||
mysql_crm_port: int = 3306
|
||||
mysql_crm_db: str = "ci20465_72new"
|
||||
mysql_crm_user: str = "ci20465_72new"
|
||||
mysql_crm_password: str = "EcY979Rn"
|
||||
```
|
||||
|
||||
### 2. Создан сервис `CrmMySQLService`
|
||||
**Файл:** `ticket_form/backend/app/services/crm_mysql_service.py`
|
||||
|
||||
- Подключение к MySQL БД vtiger CRM
|
||||
- Методы: `fetch_one()`, `fetch_all()`, `execute()`
|
||||
- Использует `aiomysql` для асинхронных запросов
|
||||
|
||||
### 3. Обновлён `main.py`
|
||||
- Добавлено подключение к MySQL CRM при старте
|
||||
- Добавлено закрытие соединения при остановке
|
||||
|
||||
### 4. Обновлён `claims.py` - метод `get_draft()`
|
||||
**Эндпоинт:** `GET /api/v1/claims/drafts/{claim_id}`
|
||||
|
||||
**Изменения:**
|
||||
- Убран webservice API (getchallenge → login → retrieve)
|
||||
- Добавлен прямой SQL запрос к MySQL для получения `cf_2624`
|
||||
- Получаем все данные контакта, включая `cf_2624`
|
||||
- Добавлено логирование для отладки
|
||||
|
||||
**SQL запрос:**
|
||||
```sql
|
||||
SELECT
|
||||
cd.contactid,
|
||||
cd.firstname,
|
||||
cd.lastname,
|
||||
cd.email,
|
||||
cd.mobile,
|
||||
ccf.cf_2624 AS cf_2624
|
||||
FROM vtiger_contactdetails cd
|
||||
LEFT JOIN vtiger_contactscf ccf ON ccf.contactid = cd.contactid
|
||||
LEFT JOIN vtiger_crmentity ce ON ce.crmid = cd.contactid
|
||||
WHERE cd.contactid = %s
|
||||
AND ce.deleted = 0
|
||||
LIMIT 1
|
||||
```
|
||||
|
||||
**Логика:**
|
||||
- Если `cf_2624 = "1"` → `contact_data_confirmed = True`, `contact_data_can_edit = False`
|
||||
- Если `cf_2624 = "0"` или `NULL` → `contact_data_confirmed = False`, `contact_data_can_edit = True`
|
||||
|
||||
### 5. Обновлены SQL файлы и документация
|
||||
- `N8N_POSTGRESQL_GET_CONTACT_DATA.sql` → `N8N_MYSQL_GET_CONTACT_DATA.sql`
|
||||
- Изменён синтаксис: `$1` → `?` (для n8n MySQL ноды)
|
||||
- Обновлена документация `BACKEND_GET_CONTACT_CF_2624_FROM_POSTGRESQL.md`
|
||||
- Создан `N8N_MYSQL_GET_CONTACT_DATA.md`
|
||||
|
||||
## Преимущества нового подхода
|
||||
|
||||
1. ✅ **Проще** - один SQL запрос вместо цепочки HTTP запросов
|
||||
2. ✅ **Быстрее** - прямой запрос к БД
|
||||
3. ✅ **Надёжнее** - не зависит от webservice API
|
||||
4. ✅ **Актуальнее** - всегда получаем свежие данные из БД
|
||||
|
||||
## Проблемы и решения
|
||||
|
||||
### Проблема 1: Файл crm_mysql_service.py отсутствовал в контейнере
|
||||
**Решение:** Пересобран контейнер через `docker-compose build ticket_form_backend`
|
||||
|
||||
### Проблема 2: MySQL не подключался из Docker контейнера
|
||||
**Ошибка:** `Can't connect to MySQL server on 'localhost'`
|
||||
|
||||
**Решение:**
|
||||
- Изменён `docker-compose.yml`: добавлен `network_mode: host`
|
||||
- Изменён `config.py`: `mysql_crm_host = "localhost"` (в режиме host работает)
|
||||
|
||||
**Результат:** `✅ MySQL CRM DB connected: localhost:3306/ci20465_72new`
|
||||
|
||||
### Проблема 3: contact_data_confirmed возвращал None
|
||||
**Причина:** Флаг не передавался в компонент `StepClaimConfirmation`
|
||||
|
||||
**Решение:**
|
||||
- Добавлен prop `contact_data_confirmed` в `StepClaimConfirmation`
|
||||
- Передача флага из `formData.contact_data_confirmed` в компонент
|
||||
- Исправлена логика получения флага (приоритет: props > claimPlanData > false)
|
||||
|
||||
## Проверка
|
||||
|
||||
**MySQL запрос:**
|
||||
```bash
|
||||
mysql -h localhost -u ci20465_72new -p'EcY979Rn' ci20465_72new \
|
||||
-e "SELECT contactid, cf_2624 FROM vtiger_contactscf WHERE contactid = '399542' LIMIT 1;"
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
```
|
||||
contactid cf_2624
|
||||
399542 1
|
||||
```
|
||||
|
||||
✅ В MySQL `cf_2624 = "1"` для `contact_id = "399542"` - данные подтверждены.
|
||||
|
||||
**API тест:**
|
||||
```bash
|
||||
curl "http://localhost:8200/api/v1/claims/drafts/226564ce-d7cf-48ee-a820-690e8f5ec8e5"
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
```json
|
||||
{
|
||||
"contact_data_confirmed": true,
|
||||
"contact_data_can_edit": false,
|
||||
"contact_data_from_crm": {
|
||||
"contactid": "399542",
|
||||
"cf_2624": "1",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Текущий статус
|
||||
|
||||
- ✅ Код обновлён
|
||||
- ✅ Бэкенд пересобран и перезапущен
|
||||
- ✅ MySQL CRM подключён
|
||||
- ✅ API возвращает правильные данные
|
||||
- ✅ Фронтенд получает `contact_data_confirmed` и блокирует поля
|
||||
- ✅ Поля формы блокируются (readonly) при `contact_data_confirmed = true`
|
||||
|
||||
## Блокировка полей
|
||||
|
||||
При `contact_data_confirmed = true` блокируются следующие поля:
|
||||
- `firstname` (Имя)
|
||||
- `lastname` (Фамилия)
|
||||
- `secondname` / `middle_name` (Отчество)
|
||||
- `inn` (ИНН)
|
||||
- `birthday` (Дата рождения)
|
||||
- `birthplace` / `birth_place` (Место рождения)
|
||||
- `address` / `mailingstreet` (Адрес)
|
||||
- `email` (E-mail)
|
||||
|
||||
Поля становятся `readonly` и отображаются с серым фоном.
|
||||
|
||||
---
|
||||
|
||||
## Задача 2: Выбор банка для СБП выплат
|
||||
|
||||
### Реализация
|
||||
- Динамическая загрузка списка банков из API `http://212.193.27.93/api/payouts/dictionaries/nspk-banks`
|
||||
- Добавлено в форму создания заявки (`Step3Payment.tsx`)
|
||||
- Добавлено в форму редактирования (`generateConfirmationFormHTML.ts`)
|
||||
- Используется `input` + `datalist` для автоподстановки
|
||||
|
||||
---
|
||||
|
||||
## Файлы изменены
|
||||
|
||||
### Backend:
|
||||
- `ticket_form/backend/app/config.py` - добавлены credentials для MySQL CRM
|
||||
- `ticket_form/backend/app/services/crm_mysql_service.py` - новый сервис
|
||||
- `ticket_form/backend/app/main.py` - подключение к MySQL CRM
|
||||
- `ticket_form/backend/app/api/claims.py` - прямой SQL запрос к MySQL
|
||||
- `ticket_form/docker-compose.yml` - добавлен `network_mode: host`
|
||||
|
||||
### Frontend:
|
||||
- `ticket_form/frontend/src/components/form/StepClaimConfirmation.tsx` - передача `contact_data_confirmed`
|
||||
- `ticket_form/frontend/src/pages/ClaimForm.tsx` - передача флага в компонент
|
||||
- `ticket_form/frontend/src/components/form/generateConfirmationFormHTML.ts` - блокировка полей
|
||||
|
||||
### Документация:
|
||||
- `ticket_form/docs/N8N_MYSQL_GET_CONTACT_DATA.sql` - SQL запрос для n8n
|
||||
- `ticket_form/docs/N8N_MYSQL_GET_CONTACT_DATA.md` - документация
|
||||
- `ticket_form/docs/BACKEND_GET_CONTACT_CF_2624_FROM_POSTGRESQL.md` - обновлена документация
|
||||
|
||||
---
|
||||
|
||||
## Коммиты
|
||||
|
||||
1. `e1142315` - feat: Получение cf_2624 из MySQL при загрузке черновика
|
||||
2. `a86120dd` - fix: передача contact_data_confirmed в StepClaimConfirmation для блокировки полей
|
||||
|
||||
---
|
||||
|
||||
**Время работы:** 2025-12-03 16:00-17:00
|
||||
**Статус:** ✅ Завершено успешно
|
||||
|
||||
105
SESSION_LOG_2025-12-29.md
Normal file
105
SESSION_LOG_2025-12-29.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Лог сессии 29 декабря 2025
|
||||
|
||||
## Основные задачи
|
||||
|
||||
### 1. Оптимизация мониторинга n8n workflow ✅
|
||||
|
||||
**Проблема:** Постоянный мониторинг workflow засорял логи n8n экзекушенами.
|
||||
|
||||
**Решение:**
|
||||
- Отключён постоянный мониторинг (`auto_restart_n8n_workflow.py`)
|
||||
- Реализована проверка workflow "по требованию" — при отправке формы пользователем
|
||||
- Если n8n не слушает Redis канал → сообщение буферизуется в Redis
|
||||
- В фоне запускается перезапуск workflow через n8n API
|
||||
- После перезапуска буферизованные сообщения отправляются повторно
|
||||
|
||||
**Изменённые файлы:**
|
||||
- `backend/app/services/n8n_service.py` (новый) — работа с n8n API
|
||||
- `backend/app/services/redis_service.py` — добавлены методы буферизации
|
||||
- `backend/app/api/claims.py` — интеграция проверки/перезапуска workflow
|
||||
- `backend/app/config.py` — добавлены настройки n8n_url, n8n_api_key
|
||||
- `backend/.env` — добавлен N8N_API_KEY
|
||||
|
||||
### 2. Синхронизация dev и prod ✅
|
||||
|
||||
**Проблема:** Dev и prod сильно разошлись, в проде появлялись DEV-секции.
|
||||
|
||||
**Решение:**
|
||||
- Скопированы файлы из работающего prod контейнера
|
||||
- Удалены все "DEV MODE" секции из frontend компонентов
|
||||
- Добавлен `terserOptions` в vite.config.ts для удаления console.log в проде
|
||||
- Создан `frontend/Dockerfile.prod` для правильной сборки
|
||||
|
||||
**Изменённые frontend файлы:**
|
||||
- `Step1Phone.tsx` — убраны DEV кнопки
|
||||
- `Step3Payment.tsx` — убран DEBUG код SMS
|
||||
- `StepDescription.tsx` — useMockWizard=false в проде
|
||||
- `StepDocumentUpload.tsx` — убраны DEV секции
|
||||
- `ClaimForm.tsx` — убран DebugPanel, исправлена навигация
|
||||
- `vite.config.ts` — drop_console в production
|
||||
|
||||
### 3. Обработка out_of_scope событий ✅
|
||||
|
||||
**Проблема:** Когда n8n возвращает `out_of_scope`, фронтенд не обрабатывал это.
|
||||
|
||||
**Решение:**
|
||||
- Добавлена обработка `event_type: "out_of_scope"` в `StepWizardPlan.tsx`
|
||||
- Показывается карточка с сообщением и suggested_actions
|
||||
- Кнопка "Связаться с поддержкой" отправляет webhook на n8n
|
||||
- После отправки — редирект на главную страницу
|
||||
|
||||
**Webhook:** `https://n8n.clientright.pro/webhook/3ef6ff67-f3f2-418e-a300-86cb4659dbde`
|
||||
|
||||
### 4. Исправление навигации ✅
|
||||
|
||||
**Проблема:** Обе кнопки "← Изменить описание" и "Новое обращение" вели на начальный экран.
|
||||
|
||||
**Решение:**
|
||||
- "← Изменить описание" → `onPrev()` → шаг описания проблемы
|
||||
- "Новое обращение" → `window.location.reload()` → начальный экран
|
||||
|
||||
**Изменённый файл:** `ClaimForm.tsx` — исправлен `onPrev` для `StepWizardPlan`
|
||||
|
||||
### 5. User-friendly сообщения ✅
|
||||
|
||||
**Проблема:** Технические ошибки показывались пользователям.
|
||||
|
||||
**Решение:**
|
||||
- Сообщение "План вопросов не получен..." → "Обработка занимает больше времени, чем обычно. Попробуйте ещё раз."
|
||||
|
||||
---
|
||||
|
||||
## Техническая информация
|
||||
|
||||
### N8N API
|
||||
- **URL:** https://n8n.clientright.pro
|
||||
- **Workflow ID:** b4K4u851b4JFivyD
|
||||
- **Header:** `X-N8N-API-KEY` (не Bearer!)
|
||||
|
||||
### Redis буферизация
|
||||
- **Ключ буфера:** `ticket_form:buffer:description`
|
||||
- **TTL:** 24 часа
|
||||
- **Методы:** `buffer_push()`, `buffer_get_all()`, `buffer_size()`
|
||||
|
||||
### Docker prod
|
||||
- **Frontend:** `ticket_form_frontend_prod` на порту 5176
|
||||
- **Backend:** `ticket_form_backend_prod` на порту 8200
|
||||
- **Dockerfile:** `frontend/Dockerfile.prod` (multi-stage build)
|
||||
|
||||
---
|
||||
|
||||
## Git
|
||||
|
||||
Все изменения запушены в:
|
||||
- **origin** (erv-platform): http://147.45.146.17:3002/negodiy/erv-platform.git
|
||||
- **aiform_prod** (новый): http://147.45.146.17:3002/negodiy/aiform_prod.git
|
||||
|
||||
**Commit:** `Production fixes: n8n workflow auto-restart, user-friendly messages, fixed navigation buttons`
|
||||
|
||||
---
|
||||
|
||||
## TODO на потом
|
||||
- [ ] Протестировать полный флоу с падением n8n workflow
|
||||
- [ ] Добавить алерты если workflow не поднимается после нескольких попыток
|
||||
- [ ] Логирование буферизованных сообщений для мониторинга
|
||||
|
||||
@@ -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)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Claims API Routes - Обработка заявок
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Request, Query
|
||||
from fastapi import APIRouter, HTTPException, Request, Query, BackgroundTasks
|
||||
from typing import Optional, List
|
||||
import httpx
|
||||
from .models import (
|
||||
@@ -13,14 +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
|
||||
# Убрали импорты из 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")
|
||||
@@ -56,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]},
|
||||
@@ -118,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"},
|
||||
)
|
||||
@@ -201,15 +225,19 @@ async def list_drafts(
|
||||
c.updated_at
|
||||
FROM clpr_claims c
|
||||
WHERE c.unified_id = $1
|
||||
-- ВРЕМЕННО: убираем все фильтры для диагностики
|
||||
-- TODO: вернуть фильтры после выяснения проблемы
|
||||
-- AND (c.is_confirmed IS NULL OR c.is_confirmed = false)
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT 20
|
||||
"""
|
||||
params = [unified_id]
|
||||
logger.info(f"🔍 Searching by unified_id: {unified_id}")
|
||||
elif phone:
|
||||
# Fallback: ищем через clpr_user_accounts и clpr_users
|
||||
# Fallback: ищем через clpr_user_accounts и clpr_users, ИЛИ напрямую по телефону в payload
|
||||
# Поддерживаем разные форматы телефона: 71234543212, +71234543212, 81234543212
|
||||
query = """
|
||||
SELECT
|
||||
SELECT DISTINCT
|
||||
c.id,
|
||||
c.payload->>'claim_id' as claim_id,
|
||||
c.session_token,
|
||||
@@ -219,19 +247,35 @@ async def list_drafts(
|
||||
c.created_at,
|
||||
c.updated_at
|
||||
FROM clpr_claims c
|
||||
WHERE c.unified_id = (
|
||||
SELECT u.unified_id
|
||||
FROM clpr_user_accounts ua
|
||||
JOIN clpr_users u ON u.id = ua.user_id
|
||||
WHERE ua.channel = 'web_form'
|
||||
AND ua.channel_user_id = $1
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE c.channel = 'web_form'
|
||||
AND (
|
||||
-- Вариант 1: Поиск через unified_id (если есть запись в clpr_user_accounts)
|
||||
c.unified_id = (
|
||||
SELECT u.unified_id
|
||||
FROM clpr_user_accounts ua
|
||||
JOIN clpr_users u ON u.id = ua.user_id
|
||||
WHERE ua.channel = 'web_form'
|
||||
AND (ua.channel_user_id = $1 OR ua.channel_user_id = $2 OR ua.channel_user_id = $3)
|
||||
LIMIT 1
|
||||
)
|
||||
-- Вариант 2: Прямой поиск по телефону в payload (в разных форматах)
|
||||
OR c.payload->>'phone' = $1
|
||||
OR c.payload->>'phone' = $2
|
||||
OR c.payload->>'phone' = $3
|
||||
)
|
||||
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
|
||||
"""
|
||||
params = [phone]
|
||||
logger.info(f"🔍 Searching by phone (fallback): {phone}")
|
||||
# Подготавливаем варианты телефона для поиска
|
||||
phone_variants = [
|
||||
phone, # Оригинальный формат
|
||||
f"+{phone}", # С плюсом
|
||||
phone.replace('7', '8', 1) if phone.startswith('7') else phone # С 8 вместо 7
|
||||
]
|
||||
params = phone_variants
|
||||
logger.info(f"🔍 Searching by phone (fallback): {phone}, variants: {phone_variants}")
|
||||
elif session_id:
|
||||
# Fallback: поиск по session_token
|
||||
query = """
|
||||
@@ -246,6 +290,8 @@ async def list_drafts(
|
||||
c.updated_at
|
||||
FROM clpr_claims c
|
||||
WHERE c.session_token = $1
|
||||
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
|
||||
"""
|
||||
@@ -258,9 +304,22 @@ async def list_drafts(
|
||||
# Простой тест: проверяем, что unified_id вообще есть в базе
|
||||
test_count = 0
|
||||
test_count_null = 0
|
||||
test_count_approved = 0
|
||||
test_count_confirmed = 0
|
||||
if unified_id:
|
||||
try:
|
||||
# Все заявления с этим unified_id
|
||||
test_count = await db.fetch_val("SELECT COUNT(*) FROM clpr_claims WHERE unified_id = $1", unified_id)
|
||||
# Заявления со статусом approved
|
||||
test_count_approved = await db.fetch_val("""
|
||||
SELECT COUNT(*) FROM clpr_claims
|
||||
WHERE unified_id = $1 AND status_code = 'approved'
|
||||
""", unified_id)
|
||||
# Заявления с is_confirmed = true
|
||||
test_count_confirmed = await db.fetch_val("""
|
||||
SELECT COUNT(*) FROM clpr_claims
|
||||
WHERE unified_id = $1 AND is_confirmed = true
|
||||
""", unified_id)
|
||||
# Также проверяем, сколько записей с NULL unified_id для этого пользователя (через phone)
|
||||
if phone:
|
||||
test_count_null = await db.fetch_val("""
|
||||
@@ -269,7 +328,7 @@ async def list_drafts(
|
||||
AND c.channel = 'web_form'
|
||||
AND c.payload->>'phone' = $1
|
||||
""", phone)
|
||||
logger.info(f"🔍 Test COUNT: unified_id={unified_id} → {test_count} records")
|
||||
logger.info(f"🔍 Test COUNT: unified_id={unified_id} → {test_count} total, {test_count_approved} approved, {test_count_confirmed} confirmed")
|
||||
if test_count_null > 0:
|
||||
logger.warning(f"⚠️ Found {test_count_null} records with NULL unified_id for phone={phone}")
|
||||
except Exception as e:
|
||||
@@ -284,10 +343,25 @@ async def list_drafts(
|
||||
logger.info(f"🔍 Test COUNT result: {test_count}")
|
||||
logger.info(f"🔍 Rows found: {len(rows)}")
|
||||
|
||||
# Если заявления есть, но не возвращаются - проверяем статусы
|
||||
if len(rows) == 0 and test_count > 0 and unified_id:
|
||||
logger.warning(f"⚠️ Заявления есть (test_count={test_count}), но запрос вернул 0 строк!")
|
||||
try:
|
||||
all_statuses = await db.fetch_all("""
|
||||
SELECT status_code, is_confirmed, channel, id
|
||||
FROM clpr_claims
|
||||
WHERE unified_id = $1
|
||||
""", unified_id)
|
||||
logger.warning(f"⚠️ Все заявления для unified_id: {[dict(r) for r in all_statuses]}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при проверке статусов: {e}")
|
||||
|
||||
# ВРЕМЕННО: возвращаем тестовые данные для отладки
|
||||
debug_info = {
|
||||
"unified_id": unified_id,
|
||||
"test_count": test_count,
|
||||
"test_count_approved": test_count_approved or 0,
|
||||
"test_count_confirmed": test_count_confirmed or 0,
|
||||
"test_count_null": test_count_null,
|
||||
"rows_found": len(rows),
|
||||
"query": query[:200] if len(query) > 200 else query,
|
||||
@@ -310,18 +384,86 @@ async def list_drafts(
|
||||
else:
|
||||
payload = {}
|
||||
|
||||
# Извлекаем данные из ai_analysis или wizard_plan
|
||||
ai_analysis = payload.get('ai_analysis') or {}
|
||||
wizard_plan = payload.get('wizard_plan') or {}
|
||||
|
||||
# Краткое описание проблемы (заголовок)
|
||||
problem_title = ai_analysis.get('problem') or payload.get('problem') or None
|
||||
|
||||
# Категория проблемы
|
||||
category = ai_analysis.get('category') or wizard_plan.get('category') or None
|
||||
|
||||
# Направление (для иконки плитки)
|
||||
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 []
|
||||
documents_required = payload.get('documents_required') or []
|
||||
|
||||
# Считаем загруженные (уникальные по field_label)
|
||||
uploaded_labels = set()
|
||||
for doc in documents_meta:
|
||||
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) if documents_required else 0
|
||||
|
||||
# Формируем список документов со статусами
|
||||
documents_list = []
|
||||
for doc_req in documents_required:
|
||||
# Пробуем разные поля для названия документа (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,
|
||||
})
|
||||
|
||||
drafts.append({
|
||||
"id": str(row['id']),
|
||||
"claim_id": row.get('claim_id'),
|
||||
"session_token": row.get('session_token'),
|
||||
"status_code": row.get('status_code'),
|
||||
"channel": row.get('channel'), # Добавляем канал в ответ
|
||||
"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,
|
||||
"problem_description": payload.get('problem_description', '')[:100] if payload.get('problem_description') else None,
|
||||
# Заголовок - краткое описание проблемы из AI
|
||||
"problem_title": problem_title[:150] if problem_title else None,
|
||||
# Полное описание
|
||||
"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": len(payload.get('documents_meta', [])) > 0 if payload.get('documents_meta') else False,
|
||||
"has_documents": documents_uploaded > 0,
|
||||
# Прогресс документов
|
||||
"documents_total": documents_total,
|
||||
"documents_uploaded": documents_uploaded,
|
||||
"documents_skipped": 0, # TODO: считать пропущенные
|
||||
"documents_list": documents_list, # Список со статусами
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -341,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)
|
||||
@@ -394,18 +538,154 @@ async def get_draft(claim_id: str):
|
||||
|
||||
logger.info(f"🔍 Загружен черновик: id={row.get('id')}, claim_id={final_claim_id}, channel={row.get('channel')}")
|
||||
|
||||
# 🔍 ОТЛАДКА: Логируем наличие 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),
|
||||
# нужно использовать отдельный connection через policy_service или создать новый MySQL connection
|
||||
unified_id = row.get('unified_id')
|
||||
contact_data_confirmed = False
|
||||
contact_data_can_edit = True
|
||||
contact_data_from_crm = None
|
||||
|
||||
# Получаем contact_id из payload
|
||||
contact_id = payload.get('contact_id') if isinstance(payload, dict) else None
|
||||
|
||||
# Преобразуем contact_id в строку, если он есть
|
||||
if contact_id:
|
||||
contact_id = str(contact_id).strip()
|
||||
logger.info(f"🔍 Получен contact_id из черновика: {contact_id} (type: {type(contact_id)})")
|
||||
|
||||
if contact_id:
|
||||
try:
|
||||
# ✅ Прямой SQL запрос к MySQL для получения cf_2624
|
||||
# Таблицы vtiger_* находятся в MySQL БД
|
||||
contact_query = """
|
||||
SELECT
|
||||
cd.contactid,
|
||||
cd.firstname,
|
||||
cd.lastname,
|
||||
cd.email,
|
||||
cd.mobile,
|
||||
cd.phone,
|
||||
cs.birthday,
|
||||
ca.mailingstreet,
|
||||
ca.mailingcity,
|
||||
ca.mailingstate,
|
||||
ca.mailingzip,
|
||||
ca.mailingcountry,
|
||||
ccf.cf_1157 AS middle_name,
|
||||
ccf.cf_1263 AS birthplace,
|
||||
ccf.cf_1257 AS inn,
|
||||
ccf.cf_1849 AS requisites,
|
||||
ccf.cf_1580 AS code,
|
||||
ccf.cf_1706 AS sms,
|
||||
ccf.cf_2624 AS cf_2624
|
||||
FROM vtiger_contactdetails cd
|
||||
LEFT JOIN vtiger_contactscf ccf ON ccf.contactid = cd.contactid
|
||||
LEFT JOIN vtiger_contactsubdetails cs ON cs.contactsubscriptionid = cd.contactid
|
||||
LEFT JOIN vtiger_contactaddress ca ON ca.contactaddressid = cd.contactid
|
||||
LEFT JOIN vtiger_crmentity ce ON ce.crmid = cd.contactid
|
||||
WHERE cd.contactid = %s
|
||||
AND ce.deleted = 0
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
contact_row = await crm_mysql_service.fetch_one(contact_query, contact_id)
|
||||
|
||||
if contact_row:
|
||||
# Формируем объект с данными контакта
|
||||
contact_data_from_crm = {
|
||||
"contactid": contact_row.get("contactid"),
|
||||
"firstname": contact_row.get("firstname"),
|
||||
"lastname": contact_row.get("lastname"),
|
||||
"email": contact_row.get("email"),
|
||||
"mobile": contact_row.get("mobile"),
|
||||
"phone": contact_row.get("phone"),
|
||||
"birthday": contact_row.get("birthday"),
|
||||
"mailingstreet": contact_row.get("mailingstreet"),
|
||||
"mailingcity": contact_row.get("mailingcity"),
|
||||
"mailingstate": contact_row.get("mailingstate"),
|
||||
"mailingzip": contact_row.get("mailingzip"),
|
||||
"mailingcountry": contact_row.get("mailingcountry"),
|
||||
"cf_1157": contact_row.get("middle_name"), # Отчество
|
||||
"cf_1263": contact_row.get("birthplace"), # Место рождения
|
||||
"cf_1257": contact_row.get("inn"), # ИНН
|
||||
"cf_1849": contact_row.get("requisites"), # Реквизиты
|
||||
"cf_1580": contact_row.get("code"), # Код
|
||||
"cf_1706": contact_row.get("sms"), # SMS
|
||||
"cf_2624": contact_row.get("cf_2624") or "0" # ✅ Данные подтверждены
|
||||
}
|
||||
|
||||
# ✅ Проверяем кастомное поле "Данные подтверждены" (cf_2624)
|
||||
confirmed_field = contact_data_from_crm.get("cf_2624", "0")
|
||||
contact_data_confirmed = confirmed_field == "1" or confirmed_field == "true" or confirmed_field is True
|
||||
contact_data_can_edit = not contact_data_confirmed
|
||||
|
||||
logger.info(
|
||||
f"🔒 Статус данных контакта из MySQL CRM: confirmed={contact_data_confirmed}, "
|
||||
f"field_value={confirmed_field}, contact_id={contact_id}"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"⚠️ Контакт не найден в MySQL CRM: contact_id={contact_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось загрузить данные контакта из MySQL CRM: {str(e)}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"claim": {
|
||||
"id": str(row['id']),
|
||||
"claim_id": final_claim_id, # ✅ Используем claim_id из payload, если его нет в row
|
||||
"claim_id": final_claim_id,
|
||||
"session_token": row.get('session_token'),
|
||||
"status_code": row.get('status_code'),
|
||||
"channel": row.get('channel'), # ✅ Добавляем channel для отладки
|
||||
"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,
|
||||
"contact_data_can_edit": contact_data_can_edit,
|
||||
"contact_data_from_crm": contact_data_from_crm # Данные из CRM (всегда загружаем, если есть contact_id)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
@@ -418,16 +698,15 @@ async def get_draft(claim_id: str):
|
||||
@router.delete("/drafts/{claim_id}")
|
||||
async def delete_draft(claim_id: str):
|
||||
"""
|
||||
Удалить черновик по claim_id
|
||||
|
||||
Удаляет только черновики (status_code = 'draft')
|
||||
Удалить черновик по 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
|
||||
AND status_code = 'draft'
|
||||
AND channel = 'web_form'
|
||||
WHERE (payload->>'claim_id' = $1 OR id::text = $1)
|
||||
AND status_code NOT IN ('submitted', 'completed', 'rejected')
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
@@ -464,8 +743,35 @@ async def publish_form_approval(request: Request):
|
||||
try:
|
||||
body = await request.json()
|
||||
|
||||
# Детальное логирование всего body для отладки
|
||||
logger.info(
|
||||
f"📥 Получен запрос на публикацию формы подтверждения",
|
||||
extra={
|
||||
"body_keys": list(body.keys()) if isinstance(body, dict) else "not_dict",
|
||||
"body_type": type(body).__name__,
|
||||
"sms_code_in_body": "sms_code" in body if isinstance(body, dict) else False,
|
||||
"sms_code_value": body.get("sms_code", "NOT_FOUND") if isinstance(body, dict) else "NOT_DICT",
|
||||
"contact_data_confirmed_in_body": "contact_data_confirmed" in body if isinstance(body, dict) else False,
|
||||
"cf_2624_in_body": "cf_2624" in body if isinstance(body, dict) else False,
|
||||
"bank_id_in_body": "bank_id" in body if isinstance(body, dict) else False,
|
||||
"bank_name_in_body": "bank_name" in body if isinstance(body, dict) else False,
|
||||
},
|
||||
)
|
||||
|
||||
claim_id = body.get("claim_id")
|
||||
session_token = body.get("session_token") or body.get("session_id")
|
||||
sms_code = body.get("sms_code", "")
|
||||
|
||||
# Логируем полученные данные для отладки
|
||||
logger.info(
|
||||
f"📥 Извлеченные данные из запроса",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"sms_code": sms_code if sms_code else "(пусто)",
|
||||
"sms_code_length": len(sms_code) if sms_code else 0,
|
||||
"has_sms_code": bool(sms_code),
|
||||
},
|
||||
)
|
||||
|
||||
if not claim_id:
|
||||
raise HTTPException(status_code=400, detail="claim_id обязателен")
|
||||
@@ -474,6 +780,27 @@ async def publish_form_approval(request: Request):
|
||||
import time
|
||||
idempotency_key = f"{claim_id}_{int(time.time() * 1000)}_{body.get('user_id', 'unknown')}"
|
||||
|
||||
# ✅ Получаем флаг подтверждения данных контакта и данные банка
|
||||
contact_data_confirmed = body.get("contact_data_confirmed", False)
|
||||
cf_2624 = body.get("cf_2624", "0")
|
||||
bank_id = body.get("bank_id", "")
|
||||
bank_name = body.get("bank_name", "")
|
||||
|
||||
# Логируем полученные значения для отладки
|
||||
logger.info(
|
||||
f"📥 Извлеченные дополнительные поля",
|
||||
extra={
|
||||
"contact_data_confirmed": contact_data_confirmed,
|
||||
"cf_2624": cf_2624,
|
||||
"bank_id": bank_id,
|
||||
"bank_name": bank_name,
|
||||
"has_contact_data_confirmed": "contact_data_confirmed" in body,
|
||||
"has_cf_2624": "cf_2624" in body,
|
||||
"has_bank_id": "bank_id" in body,
|
||||
"has_bank_name": "bank_name" in body,
|
||||
},
|
||||
)
|
||||
|
||||
# Формируем событие для Redis
|
||||
event_data = {
|
||||
"event_type": "form_approve",
|
||||
@@ -483,11 +810,19 @@ async def publish_form_approval(request: Request):
|
||||
"session_token": session_token,
|
||||
"unified_id": body.get("unified_id"),
|
||||
"phone": body.get("phone"),
|
||||
"sms_code": body.get("sms_code", ""), # SMS код для верификации
|
||||
"sms_code": sms_code, # SMS код для верификации
|
||||
"sms_verified": True,
|
||||
"idempotency_key": idempotency_key, # Для защиты от дублей в RabbitMQ
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
|
||||
# ✅ Флаг редактирования перс данных (cf_2624)
|
||||
"contact_data_confirmed": contact_data_confirmed,
|
||||
"cf_2624": cf_2624, # Значение для CRM (1 = подтверждено, 0 = не подтверждено)
|
||||
|
||||
# ✅ Данные банка для СБП выплаты
|
||||
"bank_id": bank_id,
|
||||
"bank_name": bank_name,
|
||||
|
||||
# Данные формы подтверждения
|
||||
"form_data": body.get("form_data", {}),
|
||||
"user": body.get("user", {}),
|
||||
@@ -501,14 +836,44 @@ async def publish_form_approval(request: Request):
|
||||
|
||||
# Публикуем в Redis канал clientright:webform:approve
|
||||
channel = "clientright:webform:approve"
|
||||
event_json = json.dumps(event_data, ensure_ascii=False)
|
||||
await redis_service.publish(channel, event_json)
|
||||
|
||||
# Логируем event_data перед сериализацией
|
||||
logger.info(
|
||||
f"📢 Form approval published to {channel}",
|
||||
f"📢 Формируем событие для Redis канала {channel}",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
"sms_code": sms_code if sms_code else "(пусто)",
|
||||
"has_sms_code": bool(sms_code),
|
||||
"sms_code_in_event_data": "sms_code" in event_data,
|
||||
"event_data_sms_code_value": event_data.get("sms_code", "NOT_FOUND"),
|
||||
"event_data_keys": list(event_data.keys()),
|
||||
"contact_data_confirmed_in_event": "contact_data_confirmed" in event_data,
|
||||
"cf_2624_in_event": "cf_2624" in event_data,
|
||||
"bank_id_in_event": "bank_id" in event_data,
|
||||
"bank_name_in_event": "bank_name" in event_data,
|
||||
},
|
||||
)
|
||||
|
||||
event_json = json.dumps(event_data, ensure_ascii=False)
|
||||
|
||||
# Логируем после сериализации
|
||||
logger.info(
|
||||
f"📢 JSON для публикации готов",
|
||||
extra={
|
||||
"json_length": len(event_json),
|
||||
"sms_code_in_json": '"sms_code"' in event_json,
|
||||
},
|
||||
)
|
||||
|
||||
await redis_service.publish(channel, event_json)
|
||||
|
||||
logger.info(
|
||||
f"✅ Form approval published to {channel}",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
"sms_code_included": bool(sms_code),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -543,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,
|
||||
@@ -619,42 +983,330 @@ async def load_wizard_data(claim_id: str):
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка при загрузке данных визарда: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/description")
|
||||
async def publish_ticket_form_description(payload: TicketFormDescriptionRequest):
|
||||
# Актуальный 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():
|
||||
"""
|
||||
Публикует свободное описание проблемы в Redis канал ticket_form:description
|
||||
(слушается воркфлоу в n8n)
|
||||
Отправляет все сообщения из буфера в n8n webhook (вместо Redis pub/sub)
|
||||
"""
|
||||
try:
|
||||
description_webhook_url = _get_description_webhook_url()
|
||||
if not description_webhook_url:
|
||||
logger.error("❌ N8N description webhook не настроен, не могу отправить из буфера")
|
||||
return
|
||||
|
||||
buffer_key = "description"
|
||||
messages = await redis_service.buffer_get_all(buffer_key)
|
||||
|
||||
if not messages:
|
||||
logger.info("📭 Буфер пуст, нечего отправлять")
|
||||
return
|
||||
|
||||
logger.info(f"📤 Отправляю {len(messages)} сообщений из буфера в n8n webhook...")
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
for buffered_message in messages:
|
||||
try:
|
||||
# Восстанавливаем формат для n8n: массив с channel и message
|
||||
channel = buffered_message.get("channel", f"{settings.redis_prefix}description")
|
||||
message_data = buffered_message.get("message", buffered_message.get("event", buffered_message))
|
||||
|
||||
webhook_payload = [
|
||||
{
|
||||
"channel": channel,
|
||||
"message": message_data
|
||||
}
|
||||
]
|
||||
|
||||
response = await client.post(
|
||||
description_webhook_url,
|
||||
json=webhook_payload, # Отправляем в формате массива
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
sent_count += 1
|
||||
logger.info(
|
||||
f"✅ Буферированное сообщение отправлено: "
|
||||
f"session_id={buffered_message.get('session_id', 'unknown')}"
|
||||
)
|
||||
# НЕ возвращаем в буфер - успешно отправили
|
||||
else:
|
||||
# HTTP ошибка - возвращаем в буфер
|
||||
failed_count += 1
|
||||
logger.warning(
|
||||
f"⚠️ n8n вернул ошибку {response.status_code}, "
|
||||
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}"
|
||||
)
|
||||
await redis_service.buffer_push(buffer_key, buffered_message)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
failed_count += 1
|
||||
logger.warning(
|
||||
f"⏱️ Таймаут при отправке из буфера, "
|
||||
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}"
|
||||
)
|
||||
await redis_service.buffer_push(buffer_key, buffered_message)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
failed_count += 1
|
||||
logger.error(
|
||||
f"🔌 Ошибка подключения к n8n: {e}, "
|
||||
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}"
|
||||
)
|
||||
await redis_service.buffer_push(buffer_key, buffered_message)
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logger.error(
|
||||
f"❌ Неожиданная ошибка при отправке из буфера: {e}, "
|
||||
f"возвращаю в буфер: session_id={buffered_message.get('session_id', 'unknown')}",
|
||||
exc_info=True
|
||||
)
|
||||
await redis_service.buffer_push(buffer_key, buffered_message)
|
||||
|
||||
logger.info(
|
||||
f"📊 Результат отправки буфера: {sent_count} отправлено, "
|
||||
f"{failed_count} возвращено в буфер"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"❌ Ошибка при отправке буфера: {e}")
|
||||
|
||||
|
||||
@router.post("/description")
|
||||
async def publish_ticket_form_description(
|
||||
payload: TicketFormDescriptionRequest,
|
||||
background_tasks: BackgroundTasks
|
||||
):
|
||||
"""
|
||||
Отправляет описание проблемы в 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": 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(),
|
||||
}
|
||||
|
||||
# n8n workflow ожидает массив с объектом, содержащим channel и message
|
||||
webhook_payload = [
|
||||
{
|
||||
"channel": channel,
|
||||
"message": message
|
||||
}
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"📝 TicketForm description received",
|
||||
extra={"session_id": payload.session_id, "claim_id": payload.claim_id or "not_set"},
|
||||
)
|
||||
await redis_service.publish(channel, json.dumps(event, ensure_ascii=False))
|
||||
logger.info(
|
||||
"📡 TicketForm description published",
|
||||
extra={"channel": channel, "session_id": payload.session_id},
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"channel": channel,
|
||||
"event": event,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception("❌ Failed to publish ticket form description")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Не удалось опубликовать описание: {e}"
|
||||
"📝 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",
|
||||
"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(
|
||||
f"🔄 Попытка {attempt}/{max_attempts}: отправка в n8n webhook",
|
||||
extra={"session_id": payload.session_id}
|
||||
)
|
||||
# #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(
|
||||
"✅ Описание успешно отправлено в 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},
|
||||
)
|
||||
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"⚠️ Попытка {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,
|
||||
"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}")
|
||||
|
||||
# Запускаем фоновую задачу для отправки из буфера
|
||||
background_tasks.add_task(_send_buffered_messages_to_webhook)
|
||||
|
||||
buffer_size = await redis_service.buffer_size("description")
|
||||
return {
|
||||
"success": True,
|
||||
"event": message,
|
||||
"buffered": True,
|
||||
"warning": (
|
||||
"Обработка вашего обращения займёт немного больше времени. "
|
||||
"Идёт автоматическое восстановление системы. "
|
||||
"Ваше сообщение сохранено и будет обработано в ближайшее время."
|
||||
),
|
||||
"buffer_size": buffer_size,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("❌ Failed to send ticket form description to n8n")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
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)
|
||||
|
||||
835
backend/app/api/documents.py
Normal file
835
backend/app/api/documents.py
Normal file
@@ -0,0 +1,835 @@
|
||||
"""
|
||||
Documents API Routes - Загрузка и обработка документов
|
||||
|
||||
Новый флоу: поэкранная загрузка документов
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Form, Request
|
||||
from typing import Optional, List
|
||||
import httpx
|
||||
import json
|
||||
import uuid
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from ..services.redis_service import redis_service
|
||||
from ..services.database import db
|
||||
from ..config import settings
|
||||
|
||||
router = APIRouter(prefix="/api/v1/documents", tags=["Documents"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# n8n webhook для загрузки документов
|
||||
N8N_DOCUMENT_UPLOAD_WEBHOOK = "https://n8n.clientright.pro/webhook/webform_document_upload"
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Получить реальный IP клиента (с учётом proxy заголовков)"""
|
||||
# Сначала проверяем заголовки от reverse proxy
|
||||
forwarded_for = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
||||
real_ip = request.headers.get("x-real-ip", "").strip()
|
||||
|
||||
# X-Forwarded-For имеет приоритет
|
||||
if forwarded_for and forwarded_for not in ("127.0.0.1", "192.168.0.1", "::1"):
|
||||
return forwarded_for
|
||||
if real_ip and real_ip not in ("127.0.0.1", "192.168.0.1", "::1"):
|
||||
return real_ip
|
||||
|
||||
# Fallback на request.client
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_document(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
claim_id: str = Form(...),
|
||||
session_id: str = Form(...),
|
||||
document_type: str = Form(...),
|
||||
document_name: Optional[str] = Form(None),
|
||||
document_description: Optional[str] = Form(None),
|
||||
group_index: Optional[str] = Form(None),
|
||||
unified_id: Optional[str] = Form(None),
|
||||
contact_id: Optional[str] = Form(None),
|
||||
phone: Optional[str] = Form(None),
|
||||
):
|
||||
"""
|
||||
Загрузка одного документа.
|
||||
|
||||
Принимает файл и метаданные, отправляет в n8n для:
|
||||
1. Сохранения в S3
|
||||
2. OCR обработки
|
||||
3. Обновления черновика в PostgreSQL
|
||||
|
||||
После успешной обработки n8n публикует событие document_ocr_completed в Redis.
|
||||
"""
|
||||
try:
|
||||
# Генерируем уникальный ID файла
|
||||
file_id = f"doc_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
logger.info(
|
||||
"📤 Document upload received",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"session_id": session_id,
|
||||
"document_type": document_type,
|
||||
"file_name": file.filename,
|
||||
"file_size": file.size if hasattr(file, 'size') else 'unknown',
|
||||
"content_type": file.content_type,
|
||||
},
|
||||
)
|
||||
|
||||
# Читаем содержимое файла
|
||||
file_content = await file.read()
|
||||
file_size = len(file_content)
|
||||
|
||||
# Получаем IP клиента
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
# Формируем данные в формате совместимом с существующим n8n воркфлоу
|
||||
form_data = {
|
||||
# Основные идентификаторы
|
||||
"form_id": "ticket_form",
|
||||
"stage": "document_upload",
|
||||
"session_id": session_id,
|
||||
"claim_id": claim_id,
|
||||
"client_ip": client_ip,
|
||||
|
||||
# Идентификаторы пользователя
|
||||
"unified_id": unified_id or "",
|
||||
"contact_id": contact_id or "",
|
||||
"phone": phone or "",
|
||||
|
||||
# Информация о документе
|
||||
"document_type": document_type,
|
||||
"file_id": file_id,
|
||||
"original_filename": file.filename,
|
||||
"content_type": file.content_type or "application/octet-stream",
|
||||
"file_size": str(file_size),
|
||||
"upload_timestamp": datetime.utcnow().isoformat(),
|
||||
|
||||
# Формат uploads_* для совместимости
|
||||
# ✅ Используем group_index для правильной индексации (по умолчанию 0)
|
||||
"uploads_field_names[{idx}]".format(idx=group_index or "0"): document_type,
|
||||
"uploads_field_labels[{idx}]".format(idx=group_index or "0"): document_name or document_type,
|
||||
"uploads_descriptions[{idx}]".format(idx=group_index or "0"): document_description or "",
|
||||
}
|
||||
|
||||
# ✅ Добавляем group_index в данные формы
|
||||
if group_index:
|
||||
form_data["group_index"] = group_index
|
||||
logger.info(f"📋 group_index передан в n8n: {group_index}")
|
||||
|
||||
# Файл для multipart (ключ uploads[group_index] для совместимости)
|
||||
idx = group_index or "0"
|
||||
files = {
|
||||
f"uploads[{idx}]": (file.filename, file_content, file.content_type or "application/octet-stream")
|
||||
}
|
||||
|
||||
# Отправляем в n8n
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.post(
|
||||
N8N_DOCUMENT_UPLOAD_WEBHOOK,
|
||||
data=form_data,
|
||||
files=files,
|
||||
)
|
||||
|
||||
response_text = response.text or ""
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
"✅ Document uploaded to n8n",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"document_type": document_type,
|
||||
"file_id": file_id,
|
||||
"response_preview": response_text[:200],
|
||||
},
|
||||
)
|
||||
|
||||
# Парсим ответ от n8n
|
||||
try:
|
||||
n8n_response = json.loads(response_text)
|
||||
except json.JSONDecodeError:
|
||||
n8n_response = {"raw": response_text}
|
||||
|
||||
# Публикуем событие в Redis для фронтенда
|
||||
event_data = {
|
||||
"event_type": "document_uploaded",
|
||||
"status": "processing",
|
||||
"claim_id": claim_id,
|
||||
"session_id": session_id,
|
||||
"document_type": document_type,
|
||||
"file_id": file_id,
|
||||
"original_filename": file.filename,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
await redis_service.publish(
|
||||
f"ocr_events:{session_id}",
|
||||
json.dumps(event_data, ensure_ascii=False)
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"file_id": file_id,
|
||||
"document_type": document_type,
|
||||
"ocr_status": "processing",
|
||||
"message": "Документ загружен и отправлен на обработку",
|
||||
"n8n_response": n8n_response,
|
||||
}
|
||||
|
||||
else:
|
||||
logger.error(
|
||||
"❌ n8n document upload error",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"body": response_text[:500],
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=f"Ошибка n8n: {response_text}",
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("⏱️ n8n document upload timeout")
|
||||
raise HTTPException(status_code=504, detail="Таймаут загрузки документа")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("❌ Document upload error")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ошибка загрузки документа: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload-multiple")
|
||||
async def upload_multiple_documents(
|
||||
request: Request,
|
||||
files: List[UploadFile] = File(...),
|
||||
claim_id: str = Form(...),
|
||||
session_id: str = Form(...),
|
||||
document_type: str = Form(...),
|
||||
document_name: Optional[str] = Form(None),
|
||||
document_description: Optional[str] = Form(None),
|
||||
unified_id: Optional[str] = Form(None),
|
||||
contact_id: Optional[str] = Form(None),
|
||||
phone: Optional[str] = Form(None),
|
||||
):
|
||||
"""
|
||||
Загрузка нескольких файлов для одного документа (например, несколько страниц паспорта).
|
||||
Все файлы отправляются одним запросом в n8n.
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"📤 Multiple documents upload received",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"session_id": session_id,
|
||||
"document_type": document_type,
|
||||
"files_count": len(files),
|
||||
"file_names": [f.filename for f in files],
|
||||
},
|
||||
)
|
||||
|
||||
# Получаем IP клиента
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
# Генерируем ID для каждого файла и читаем контент
|
||||
file_ids = []
|
||||
files_multipart = {}
|
||||
|
||||
for i, file in enumerate(files):
|
||||
file_id = f"doc_{uuid.uuid4().hex[:12]}"
|
||||
file_ids.append(file_id)
|
||||
|
||||
file_content = await file.read()
|
||||
files_multipart[f"uploads[{i}]"] = (
|
||||
file.filename,
|
||||
file_content,
|
||||
file.content_type or "application/octet-stream"
|
||||
)
|
||||
|
||||
# Формируем данные формы
|
||||
form_data = {
|
||||
# Основные идентификаторы
|
||||
"form_id": "ticket_form",
|
||||
"stage": "document_upload",
|
||||
"session_id": session_id,
|
||||
"claim_id": claim_id,
|
||||
"client_ip": client_ip,
|
||||
|
||||
# Идентификаторы пользователя
|
||||
"unified_id": unified_id or "",
|
||||
"contact_id": contact_id or "",
|
||||
"phone": phone or "",
|
||||
|
||||
# Информация о документе
|
||||
"document_type": document_type,
|
||||
"files_count": str(len(files)),
|
||||
"upload_timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
# ✅ Получаем group_index из Form (индекс документа в documents_required)
|
||||
form_params = await request.form()
|
||||
group_index = form_params.get("group_index")
|
||||
if group_index:
|
||||
form_data["group_index"] = group_index
|
||||
logger.info(f"📋 group_index передан в n8n: {group_index}")
|
||||
|
||||
# Добавляем информацию о каждом файле
|
||||
for i, (file, file_id) in enumerate(zip(files, file_ids)):
|
||||
form_data[f"file_ids[{i}]"] = file_id
|
||||
form_data[f"uploads_field_names[{i}]"] = document_type
|
||||
form_data[f"uploads_field_labels[{i}]"] = document_name or document_type
|
||||
form_data[f"uploads_descriptions[{i}]"] = document_description or ""
|
||||
form_data[f"original_filenames[{i}]"] = file.filename
|
||||
|
||||
# Отправляем в n8n одним запросом
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
response = await client.post(
|
||||
N8N_DOCUMENT_UPLOAD_WEBHOOK,
|
||||
data=form_data,
|
||||
files=files_multipart,
|
||||
)
|
||||
|
||||
response_text = response.text or ""
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
"✅ Multiple documents uploaded to n8n",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"document_type": document_type,
|
||||
"file_ids": file_ids,
|
||||
"files_count": len(files),
|
||||
},
|
||||
)
|
||||
|
||||
# Парсим ответ от n8n
|
||||
try:
|
||||
n8n_response = json.loads(response_text)
|
||||
except json.JSONDecodeError:
|
||||
n8n_response = {"raw": response_text}
|
||||
|
||||
# Публикуем событие в Redis
|
||||
event_data = {
|
||||
"event_type": "documents_uploaded",
|
||||
"status": "processing",
|
||||
"claim_id": claim_id,
|
||||
"session_id": session_id,
|
||||
"document_type": document_type,
|
||||
"file_ids": file_ids,
|
||||
"files_count": len(files),
|
||||
"original_filenames": [f.filename for f in files],
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
await redis_service.publish(
|
||||
f"ocr_events:{session_id}",
|
||||
json.dumps(event_data, ensure_ascii=False)
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"file_ids": file_ids,
|
||||
"files_count": len(files),
|
||||
"document_type": document_type,
|
||||
"ocr_status": "processing",
|
||||
"message": f"Загружено {len(files)} файл(ов)",
|
||||
"n8n_response": n8n_response,
|
||||
}
|
||||
|
||||
else:
|
||||
logger.error(
|
||||
"❌ n8n multiple upload error",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"body": response_text[:500],
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=f"Ошибка n8n: {response_text}",
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("⏱️ n8n multiple upload timeout")
|
||||
raise HTTPException(status_code=504, detail="Таймаут загрузки документов")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("❌ Multiple upload error")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ошибка загрузки документов: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status/{claim_id}")
|
||||
async def get_documents_status(claim_id: str):
|
||||
"""
|
||||
Получить статус обработки документов для заявки.
|
||||
|
||||
Возвращает:
|
||||
- Список загруженных документов и их OCR статус
|
||||
- Общий прогресс обработки
|
||||
"""
|
||||
try:
|
||||
# TODO: Запрос в PostgreSQL для получения статуса документов
|
||||
# Пока возвращаем mock данные
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"claim_id": claim_id,
|
||||
"documents": [],
|
||||
"ocr_progress": {
|
||||
"total": 0,
|
||||
"completed": 0,
|
||||
"processing": 0,
|
||||
"failed": 0,
|
||||
},
|
||||
"wizard_ready": False,
|
||||
"claim_ready": False,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("❌ Error getting documents status")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ошибка получения статуса: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
|
||||
async def skip_document(
|
||||
request: Request,
|
||||
claim_id: str = Form(...),
|
||||
session_id: str = Form(...),
|
||||
document_type: str = Form(...),
|
||||
document_name: Optional[str] = Form(None),
|
||||
group_index: Optional[str] = Form(None),
|
||||
unified_id: Optional[str] = Form(None),
|
||||
contact_id: Optional[str] = Form(None),
|
||||
phone: Optional[str] = Form(None),
|
||||
):
|
||||
"""
|
||||
Пропуск документа (пользователь указал, что документа нет).
|
||||
|
||||
Отправляет событие в n8n на тот же webhook, что и загрузка файлов,
|
||||
но с флагом skipped=true для обработки пропуска.
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
"⏭️ Document skip received",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"session_id": session_id,
|
||||
"document_type": document_type,
|
||||
"group_index": group_index,
|
||||
},
|
||||
)
|
||||
|
||||
# Получаем IP клиента
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
# Формируем данные в формате совместимом с существующим n8n воркфлоу
|
||||
form_data = {
|
||||
# Основные идентификаторы
|
||||
"form_id": "ticket_form",
|
||||
"stage": "document_skip",
|
||||
"session_id": session_id,
|
||||
"claim_id": claim_id,
|
||||
"client_ip": client_ip,
|
||||
|
||||
# Идентификаторы пользователя
|
||||
"unified_id": unified_id or "",
|
||||
"contact_id": contact_id or "",
|
||||
"phone": phone or "",
|
||||
|
||||
# Информация о документе
|
||||
"document_type": document_type,
|
||||
"document_name": document_name or document_type,
|
||||
"skipped": "true", # ✅ Флаг пропуска документа
|
||||
"action": "skip", # ✅ Действие: пропуск
|
||||
"skip_timestamp": datetime.utcnow().isoformat(),
|
||||
|
||||
# Формат uploads_* для совместимости (без файлов)
|
||||
# ✅ Используем group_index для правильной индексации (по умолчанию 0)
|
||||
"uploads_field_names[{idx}]".format(idx=group_index or "0"): document_type,
|
||||
"uploads_field_labels[{idx}]".format(idx=group_index or "0"): document_name or document_type,
|
||||
"uploads_descriptions[{idx}]".format(idx=group_index or "0"): "",
|
||||
"files_count": "0", # ✅ Нет файлов
|
||||
}
|
||||
|
||||
# ✅ Добавляем group_index в данные формы
|
||||
if group_index:
|
||||
form_data["group_index"] = group_index
|
||||
logger.info(f"📋 group_index передан в n8n: {group_index}")
|
||||
|
||||
# Отправляем в n8n на тот же webhook (без файлов)
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
N8N_DOCUMENT_UPLOAD_WEBHOOK,
|
||||
data=form_data,
|
||||
)
|
||||
|
||||
response_text = response.text or ""
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
"✅ Document skip sent to n8n",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"document_type": document_type,
|
||||
"response_preview": response_text[:200],
|
||||
},
|
||||
)
|
||||
|
||||
# Сохраняем 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)
|
||||
except json.JSONDecodeError:
|
||||
n8n_response = {"raw": response_text}
|
||||
|
||||
# Публикуем событие в Redis для фронтенда
|
||||
event_data = {
|
||||
"event_type": "document_skipped",
|
||||
"status": "skipped",
|
||||
"claim_id": claim_id,
|
||||
"session_id": session_id,
|
||||
"document_type": document_type,
|
||||
"document_name": document_name or document_type,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
await redis_service.publish(
|
||||
f"ocr_events:{session_id}",
|
||||
json.dumps(event_data, ensure_ascii=False)
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"document_type": document_type,
|
||||
"status": "skipped",
|
||||
"message": "Документ пропущен и сохранён",
|
||||
"n8n_response": n8n_response,
|
||||
}
|
||||
|
||||
else:
|
||||
logger.error(
|
||||
"❌ n8n document skip error",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"body": response_text[:500],
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=f"Ошибка n8n: {response_text}",
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("⏱️ n8n document skip timeout")
|
||||
raise HTTPException(status_code=504, detail="Таймаут отправки пропуска документа")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("❌ Document skip error")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка пропуска документа: {str(e)}")
|
||||
|
||||
|
||||
|
||||
@router.post("/generate-list")
|
||||
async def generate_documents_list(request: Request):
|
||||
"""
|
||||
Запрос на генерацию списка документов для проблемы.
|
||||
|
||||
Принимает описание проблемы, отправляет в n8n для быстрого AI-анализа.
|
||||
n8n публикует результат в Redis канал ocr_events:{session_id} с event_type=documents_list_ready.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
|
||||
session_id = body.get("session_id")
|
||||
problem_description = body.get("problem_description")
|
||||
|
||||
if not session_id or not problem_description:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="session_id и problem_description обязательны",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"📝 Generate documents list request",
|
||||
extra={
|
||||
"session_id": session_id,
|
||||
"description_length": len(problem_description),
|
||||
},
|
||||
)
|
||||
|
||||
# Публикуем событие в Redis для n8n
|
||||
event_data = {
|
||||
"type": "generate_documents_list",
|
||||
"session_id": session_id,
|
||||
"claim_id": body.get("claim_id"),
|
||||
"unified_id": body.get("unified_id"),
|
||||
"phone": body.get("phone"),
|
||||
"problem_description": problem_description,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
channel = f"{settings.redis_prefix}documents_list"
|
||||
|
||||
subscribers = await redis_service.publish(
|
||||
channel,
|
||||
json.dumps(event_data, ensure_ascii=False)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ Documents list request published",
|
||||
extra={
|
||||
"channel": channel,
|
||||
"subscribers": subscribers,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Запрос на генерацию списка документов отправлен",
|
||||
"channel": channel,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("❌ Error generating documents list")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ошибка генерации списка: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
def compute_documents_hash(doc_ids: List[str]) -> str:
|
||||
"""
|
||||
Вычисляет hash от списка document_id для проверки актуальности черновика.
|
||||
Должен совпадать с JS алгоритмом в n8n build_form_draft.
|
||||
"""
|
||||
import ctypes
|
||||
|
||||
sorted_ids = sorted([d for d in doc_ids if d])
|
||||
hash_input = ','.join(sorted_ids)
|
||||
|
||||
# djb2 hash — эмуляция JS поведения
|
||||
# В JS: (hash << 5) возвращает 32-битный signed int
|
||||
hash_val = 5381
|
||||
for char in hash_input:
|
||||
# ctypes.c_int32 эмулирует JS 32-битный signed int при сдвиге
|
||||
shifted = ctypes.c_int32(hash_val << 5).value
|
||||
hash_val = shifted + hash_val + ord(char)
|
||||
|
||||
# В JS: Math.abs(hash).toString(16).padStart(8, '0')
|
||||
return format(abs(hash_val), 'x').zfill(8)
|
||||
|
||||
|
||||
@router.post("/check-ocr-status")
|
||||
async def check_ocr_status(request: Request):
|
||||
"""
|
||||
Проверка статуса OCR обработки документов.
|
||||
|
||||
Вызывается при нажатии "Продолжить" после загрузки документов.
|
||||
|
||||
Логика:
|
||||
1. Проверяем наличие form_draft в payload
|
||||
2. Если черновик есть и documents_hash совпадает — возвращаем его
|
||||
3. Если черновика нет или он устарел — запускаем RAG workflow
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
|
||||
claim_id = body.get("claim_id")
|
||||
session_id = body.get("session_id")
|
||||
force_refresh = body.get("force_refresh", False) # Принудительное обновление
|
||||
|
||||
if not claim_id or not session_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="claim_id и session_id обязательны",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"🔍 Check OCR status request",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"session_id": session_id,
|
||||
"force_refresh": force_refresh,
|
||||
},
|
||||
)
|
||||
|
||||
# =====================================================
|
||||
# ШАГ 1: Проверяем наличие черновика в БД
|
||||
# =====================================================
|
||||
if not force_refresh:
|
||||
try:
|
||||
# Получаем form_draft и список документов
|
||||
claim_data = await db.fetch_one("""
|
||||
SELECT
|
||||
c.payload->'form_draft' AS form_draft,
|
||||
(
|
||||
SELECT array_agg(cd.id::text ORDER BY cd.id)
|
||||
FROM clpr_claim_documents cd
|
||||
WHERE cd.claim_id::uuid = c.id
|
||||
) AS document_ids
|
||||
FROM clpr_claims c
|
||||
WHERE c.id = $1::uuid
|
||||
""", claim_id)
|
||||
|
||||
if claim_data and claim_data.get('form_draft'):
|
||||
form_draft = claim_data['form_draft']
|
||||
# Если form_draft — строка, парсим JSON
|
||||
if isinstance(form_draft, str):
|
||||
form_draft = json.loads(form_draft)
|
||||
|
||||
saved_hash = form_draft.get('documents_hash', '')
|
||||
document_ids = claim_data.get('document_ids') or []
|
||||
current_hash = compute_documents_hash(document_ids)
|
||||
|
||||
logger.info(
|
||||
"📋 Draft check",
|
||||
extra={
|
||||
"saved_hash": saved_hash,
|
||||
"current_hash": current_hash,
|
||||
"docs_count": len(document_ids),
|
||||
},
|
||||
)
|
||||
|
||||
# ✅ Черновик актуален — возвращаем его!
|
||||
if saved_hash == current_hash:
|
||||
logger.info(
|
||||
"✅ Using cached form_draft",
|
||||
extra={
|
||||
"claim_id": claim_id,
|
||||
"hash": saved_hash,
|
||||
},
|
||||
)
|
||||
|
||||
# Публикуем событие что данные готовы
|
||||
event_data = {
|
||||
"event_type": "form_draft_ready",
|
||||
"status": "ready",
|
||||
"message": "Черновик формы готов",
|
||||
"claim_id": claim_id,
|
||||
"session_id": session_id,
|
||||
"form_draft": form_draft,
|
||||
"from_cache": True,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
await redis_service.publish(
|
||||
f"ocr_events:{session_id}",
|
||||
json.dumps(event_data, ensure_ascii=False)
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"status": "ready",
|
||||
"message": "Черновик формы готов (из кэша)",
|
||||
"from_cache": True,
|
||||
"form_draft": form_draft,
|
||||
"listen_channel": f"ocr_events:{session_id}",
|
||||
}
|
||||
else:
|
||||
logger.info(
|
||||
"🔄 Draft outdated, running RAG",
|
||||
extra={
|
||||
"reason": "documents_hash mismatch",
|
||||
"saved_hash": saved_hash,
|
||||
"current_hash": current_hash,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Draft check failed: {e}, proceeding with RAG")
|
||||
|
||||
# =====================================================
|
||||
# ШАГ 2: Черновика нет или устарел — запускаем RAG
|
||||
# =====================================================
|
||||
event_data = {
|
||||
"claim_id": claim_id,
|
||||
"session_token": session_id,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
channel = "clpr:check:ocr_status"
|
||||
|
||||
subscribers = await redis_service.publish(
|
||||
channel,
|
||||
json.dumps(event_data, ensure_ascii=False)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ OCR status check published (running RAG)",
|
||||
extra={
|
||||
"channel": channel,
|
||||
"subscribers": subscribers,
|
||||
"claim_id": claim_id,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"status": "processing",
|
||||
"message": "Запрос на обработку документов отправлен",
|
||||
"from_cache": False,
|
||||
"channel": channel,
|
||||
"listen_channel": f"ocr_events:{session_id}",
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("❌ Error checking OCR status")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Ошибка проверки статуса: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
router.add_api_route("/skip", skip_document, methods=["POST"], tags=["Documents"])
|
||||
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,11 +9,107 @@ 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()
|
||||
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"
|
||||
@@ -123,10 +225,18 @@ async def stream_events(task_id: str):
|
||||
# Формат уже плоский (от backend API или старых источников)
|
||||
actual_event = event
|
||||
|
||||
# ✅ Логируем полученное событие
|
||||
event_type = actual_event.get('event_type')
|
||||
logger.info(f"🔍 Processing event: event_type={event_type}, has claim_id={bool(actual_event.get('claim_id'))}")
|
||||
|
||||
# ✅ Обработка нового формата: documents_list_ready
|
||||
if event_type == 'documents_list_ready':
|
||||
logger.info(f"📋 Documents list received: {len(actual_event.get('documents_required', []))} documents")
|
||||
# Просто пропускаем дальше к yield
|
||||
|
||||
# ✅ Обработка формата от n8n: если пришёл объект с claim_id, но без event_type
|
||||
# Это значит, что n8n пушит минимальный payload для wizard_ready
|
||||
logger.info(f"🔍 Checking event: has event_type={bool(actual_event.get('event_type'))}, has claim_id={bool(actual_event.get('claim_id'))}")
|
||||
if not actual_event.get('event_type') and actual_event.get('claim_id'):
|
||||
elif not event_type and actual_event.get('claim_id'):
|
||||
logger.info(f"📦 Detected minimal wizard payload (no event_type), wrapping for claim_id={actual_event.get('claim_id')}")
|
||||
# Обёртываем в правильный формат
|
||||
actual_event = {
|
||||
@@ -207,15 +317,121 @@ async def stream_events(task_id: str):
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error loading wizard data from PostgreSQL: {e}")
|
||||
|
||||
# ✅ Обработка ocr_status ready: загружаем form_draft из PostgreSQL
|
||||
if actual_event.get('event_type') == 'ocr_status' and actual_event.get('status') == 'ready':
|
||||
claim_id = actual_event.get('claim_id') or actual_event.get('data', {}).get('claim_id')
|
||||
# ✅ Получаем cf_2624 из события (Данные подтверждены)
|
||||
cf_2624 = actual_event.get('cf_2624')
|
||||
|
||||
if claim_id:
|
||||
logger.info(f"🔍 OCR ready event received, loading form_draft for claim_id={claim_id}, cf_2624={cf_2624}")
|
||||
|
||||
try:
|
||||
# ✅ Если есть cf_2624 в событии - сохраняем в черновик
|
||||
if cf_2624 is not None:
|
||||
try:
|
||||
update_query = """
|
||||
UPDATE clpr_claims
|
||||
SET payload = jsonb_set(
|
||||
COALESCE(payload, '{}'::jsonb),
|
||||
'{cf_2624}',
|
||||
$1::jsonb
|
||||
)
|
||||
WHERE id::text = $2 OR payload->>'claim_id' = $2
|
||||
RETURNING id;
|
||||
"""
|
||||
await db.execute(update_query, json.dumps(cf_2624), claim_id)
|
||||
logger.info(f"✅ Сохранён cf_2624={cf_2624} в черновик claim_id={claim_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось сохранить cf_2624 в черновик: {e}")
|
||||
|
||||
# Загружаем form_draft и documents из PostgreSQL
|
||||
query = """
|
||||
SELECT
|
||||
c.id,
|
||||
c.payload->'form_draft' as form_draft,
|
||||
c.payload->'documents_required' as documents_required,
|
||||
c.payload->'documents_meta' as documents_meta,
|
||||
c.payload->>'cf_2624' as cf_2624
|
||||
FROM clpr_claims c
|
||||
WHERE c.id::text = $1 OR c.payload->>'claim_id' = $1
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
row = await db.fetch_one(query, claim_id)
|
||||
|
||||
if row:
|
||||
# Парсим JSONB поля (могут быть строками)
|
||||
form_draft_raw = row.get('form_draft')
|
||||
documents_required_raw = row.get('documents_required')
|
||||
documents_meta_raw = row.get('documents_meta')
|
||||
cf_2624_from_db = row.get('cf_2624') # ✅ Получаем cf_2624 из БД
|
||||
|
||||
# Парсим если строка
|
||||
def parse_json_field(val):
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
return json.loads(val)
|
||||
except:
|
||||
return val
|
||||
return val
|
||||
|
||||
form_draft = parse_json_field(form_draft_raw)
|
||||
documents_required = parse_json_field(documents_required_raw)
|
||||
documents_meta = parse_json_field(documents_meta_raw)
|
||||
|
||||
# Обогащаем событие данными из БД
|
||||
actual_event['data'] = {
|
||||
'claim_id': claim_id,
|
||||
'all_ready': True,
|
||||
'form_draft': form_draft,
|
||||
'documents_required': documents_required,
|
||||
'documents_meta': documents_meta,
|
||||
}
|
||||
|
||||
# ✅ Добавляем cf_2624 в событие (из БД или из события)
|
||||
actual_event['cf_2624'] = cf_2624_from_db or cf_2624 or "0"
|
||||
|
||||
logger.info(f"✅ Form draft loaded from PostgreSQL for claim_id={claim_id}, has_form_draft={form_draft is not None}, cf_2624={actual_event.get('cf_2624')}")
|
||||
else:
|
||||
logger.warning(f"⚠️ Claim not found in PostgreSQL: claim_id={claim_id}")
|
||||
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)
|
||||
logger.info(f"📤 Sending event to client: {actual_event.get('status', 'unknown')}")
|
||||
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") 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
|
||||
logger.info(f"📤 Sending event to client: type={event_type_sent}, status={event_status}, json_len={len(event_json)}, has_form_draft={has_form_draft}")
|
||||
yield f"data: {event_json}\n\n"
|
||||
|
||||
# Если обработка завершена - закрываем соединение
|
||||
if actual_event.get('status') in ['completed', 'error', 'success']:
|
||||
# НЕ закрываем для documents_list_ready и document_ocr_completed (ждём ещё события)
|
||||
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 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}")
|
||||
|
||||
@@ -262,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 канала"""
|
||||
@@ -272,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,
|
||||
)
|
||||
@@ -44,7 +44,8 @@ class ClaimCreateRequest(BaseModel):
|
||||
|
||||
# Шаг 3: Данные для выплаты
|
||||
payment_method: str = "sbp" # "sbp", "card", "bank_transfer"
|
||||
bank_name: Optional[str] = None
|
||||
bank_id: Optional[str] = None # ID банка из NSPK API (bankid)
|
||||
bank_name: Optional[str] = None # Название банка для отображения
|
||||
card_number: Optional[str] = None
|
||||
account_number: Optional[str] = None
|
||||
|
||||
@@ -69,7 +70,10 @@ class TicketFormDescriptionRequest(BaseModel):
|
||||
claim_id: Optional[str] = Field(None, description="ID заявки (если уже создана)")
|
||||
phone: Optional[str] = Field(None, description="Номер телефона заявителя")
|
||||
email: Optional[str] = Field(None, description="Email заявителя")
|
||||
unified_id: Optional[str] = Field(None, description="Unified ID пользователя из PostgreSQL")
|
||||
contact_id: Optional[str] = Field(None, description="Contact ID пользователя в CRM")
|
||||
problem_description: str = Field(..., min_length=10, description="Свободное описание ситуации")
|
||||
source: str = Field("ticket_form", description="Источник события")
|
||||
channel: Optional[str] = Field(None, description="Переопределение Redis канала (опционально)")
|
||||
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):
|
||||
# ============================================
|
||||
@@ -42,13 +48,22 @@ class Settings(BaseSettings):
|
||||
mysql_user: str = "root"
|
||||
mysql_password: str = ""
|
||||
|
||||
# ============================================
|
||||
# MYSQL CRM (vtiger CRM)
|
||||
# ============================================
|
||||
mysql_crm_host: str = "localhost" # В режиме network_mode: host используем localhost # Доступ к хосту из Docker контейнера
|
||||
mysql_crm_port: int = 3306
|
||||
mysql_crm_db: str = "ci20465_72new"
|
||||
mysql_crm_user: str = "ci20465_72new"
|
||||
mysql_crm_password: str = "EcY979Rn"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""Формирует URL для подключения к PostgreSQL"""
|
||||
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
|
||||
@@ -56,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
|
||||
# ============================================
|
||||
@@ -111,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)
|
||||
@@ -162,12 +198,79 @@ class Settings(BaseSettings):
|
||||
return self.cors_origins
|
||||
|
||||
# ============================================
|
||||
# N8N WEBHOOKS (скрыты от фронтенда)
|
||||
# N8N API & WEBHOOKS
|
||||
# ============================================
|
||||
n8n_url: str = "https://n8n.clientright.pro"
|
||||
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
|
||||
@@ -181,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,24 +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
|
||||
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
|
||||
@@ -37,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()
|
||||
@@ -56,23 +157,47 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ MySQL Policy DB not available: {e}")
|
||||
|
||||
try:
|
||||
# Подключаем MySQL CRM (vtiger)
|
||||
await crm_mysql_service.connect()
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ MySQL CRM DB not available: {e}")
|
||||
|
||||
try:
|
||||
# Подключаем S3 (для загрузки файлов)
|
||||
s3_service.connect()
|
||||
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()
|
||||
|
||||
logger.info("👋 Ticket Form Intake Platform stopped")
|
||||
|
||||
@@ -85,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)
|
||||
@@ -103,6 +257,16 @@ app.include_router(draft.router)
|
||||
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("/")
|
||||
@@ -201,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():
|
||||
"""Информация о платформе"""
|
||||
@@ -228,3 +457,4 @@ async def info():
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8200)
|
||||
|
||||
|
||||
118
backend/app/services/crm_mysql_service.py
Normal file
118
backend/app/services/crm_mysql_service.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
CRM MySQL Service - Подключение к MySQL БД vtiger CRM
|
||||
"""
|
||||
import aiomysql
|
||||
from typing import Optional, Dict, Any, List
|
||||
from ..config import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CrmMySQLService:
|
||||
"""Сервис для работы с MySQL БД vtiger CRM"""
|
||||
|
||||
def __init__(self):
|
||||
self.pool: Optional[aiomysql.Pool] = None
|
||||
|
||||
async def connect(self):
|
||||
"""Подключение к MySQL БД vtiger CRM"""
|
||||
try:
|
||||
self.pool = await aiomysql.create_pool(
|
||||
host=settings.mysql_crm_host,
|
||||
port=settings.mysql_crm_port,
|
||||
user=settings.mysql_crm_user,
|
||||
password=settings.mysql_crm_password,
|
||||
db=settings.mysql_crm_db,
|
||||
autocommit=True,
|
||||
minsize=1,
|
||||
maxsize=5
|
||||
)
|
||||
logger.info(f"✅ MySQL CRM DB connected: {settings.mysql_crm_host}:{settings.mysql_crm_port}/{settings.mysql_crm_db}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ MySQL CRM DB connection error: {e}")
|
||||
raise
|
||||
|
||||
async def fetch_one(self, query: str, *args) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Выполнить SQL запрос и вернуть одну запись
|
||||
|
||||
Args:
|
||||
query: SQL запрос с плейсхолдерами %s
|
||||
*args: Параметры для запроса
|
||||
|
||||
Returns:
|
||||
Dict с данными или None если не найдено
|
||||
"""
|
||||
if not self.pool:
|
||||
await self.connect()
|
||||
|
||||
try:
|
||||
async with self.pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(query, args)
|
||||
result = await cursor.fetchone()
|
||||
return dict(result) if result else None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error executing query: {e}")
|
||||
raise
|
||||
|
||||
async def fetch_all(self, query: str, *args) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Выполнить SQL запрос и вернуть все записи
|
||||
|
||||
Args:
|
||||
query: SQL запрос с плейсхолдерами %s
|
||||
*args: Параметры для запроса
|
||||
|
||||
Returns:
|
||||
List[Dict] с данными
|
||||
"""
|
||||
if not self.pool:
|
||||
await self.connect()
|
||||
|
||||
try:
|
||||
async with self.pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(query, args)
|
||||
results = await cursor.fetchall()
|
||||
return [dict(row) for row in results] if results else []
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error executing query: {e}")
|
||||
raise
|
||||
|
||||
async def execute(self, query: str, *args) -> int:
|
||||
"""
|
||||
Выполнить SQL запрос (INSERT, UPDATE, DELETE)
|
||||
|
||||
Args:
|
||||
query: SQL запрос с плейсхолдерами %s
|
||||
*args: Параметры для запроса
|
||||
|
||||
Returns:
|
||||
Количество затронутых строк
|
||||
"""
|
||||
if not self.pool:
|
||||
await self.connect()
|
||||
|
||||
try:
|
||||
async with self.pool.acquire() as conn:
|
||||
async with conn.cursor() as cursor:
|
||||
await cursor.execute(query, args)
|
||||
return cursor.rowcount
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error executing query: {e}")
|
||||
raise
|
||||
|
||||
async def close(self):
|
||||
"""Закрыть пул подключений"""
|
||||
if self.pool:
|
||||
self.pool.close()
|
||||
await self.pool.wait_closed()
|
||||
logger.info("MySQL CRM DB pool closed")
|
||||
|
||||
|
||||
# Глобальный экземпляр
|
||||
crm_mysql_service = CrmMySQLService()
|
||||
|
||||
|
||||
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
|
||||
216
backend/app/services/n8n_service.py
Normal file
216
backend/app/services/n8n_service.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Сервис для работы с n8n API
|
||||
"""
|
||||
import httpx
|
||||
import logging
|
||||
from typing import Optional
|
||||
from ..config import settings
|
||||
from ..services.redis_service import redis_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Workflow ID для ticket_form:description
|
||||
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]:
|
||||
"""
|
||||
Проверка статуса workflow через n8n API
|
||||
|
||||
Returns:
|
||||
dict с данными workflow или None при ошибке
|
||||
"""
|
||||
if not settings.n8n_api_key:
|
||||
logger.warning("⚠️ N8N_API_KEY не настроен")
|
||||
return None
|
||||
|
||||
headers = _get_headers()
|
||||
if not headers:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(
|
||||
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}",
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
logger.warning(f"⚠️ n8n API вернул статус {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при проверке статуса workflow: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def restart_workflow() -> bool:
|
||||
"""
|
||||
Перезапуск workflow через n8n API с улучшенной обработкой зависших состояний
|
||||
|
||||
Returns:
|
||||
True если успешно, False при ошибке
|
||||
"""
|
||||
if not settings.n8n_api_key:
|
||||
logger.error("❌ N8N_API_KEY не настроен! Не могу перезапустить workflow")
|
||||
return False
|
||||
|
||||
headers = _get_headers()
|
||||
if not headers:
|
||||
return False
|
||||
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
# Увеличиваем таймаут для обработки зависших 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,
|
||||
timeout=15.0 # Отдельный таймаут для деактивации
|
||||
)
|
||||
|
||||
if deactivate_response.status_code in [200, 404]:
|
||||
logger.info("✅ Workflow деактивирован")
|
||||
else:
|
||||
logger.warning(
|
||||
f"⚠️ Неожиданный статус при деактивации: "
|
||||
f"{deactivate_response.status_code} - {deactivate_response.text[:200]}"
|
||||
)
|
||||
# Продолжаем даже если деактивация не удалась - возможно workflow уже неактивен
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("⏱️ Таймаут при деактивации workflow (возможно завис)")
|
||||
# Продолжаем попытку активации - иногда помогает
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Ошибка при деактивации: {e}, продолжаю...")
|
||||
|
||||
# Задержка перед активацией (увеличена для стабильности)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# Шаг 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,
|
||||
timeout=15.0 # Отдельный таймаут для активации
|
||||
)
|
||||
|
||||
if activate_response.status_code == 200:
|
||||
logger.info("✅ Workflow активирован")
|
||||
|
||||
# Дополнительная задержка для инициализации trigger node
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# После успешного перезапуска отправляем сообщения из буфера
|
||||
await _send_buffered_messages()
|
||||
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
f"❌ Ошибка активации workflow: "
|
||||
f"{activate_response.status_code} - {activate_response.text[:200]}"
|
||||
)
|
||||
return False
|
||||
except httpx.TimeoutException:
|
||||
logger.error("⏱️ Таймаут при активации workflow - возможно n8n перегружен")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Ошибка при активации workflow: {e}")
|
||||
return False
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("⏱️ Общий таймаут при перезапуске workflow")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Неожиданная ошибка при перезапуске workflow: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
async def _send_buffered_messages():
|
||||
"""
|
||||
Отправить все сообщения из буфера после восстановления workflow
|
||||
"""
|
||||
try:
|
||||
buffer_key = "description" # Буфер для ticket_form:description
|
||||
messages = await redis_service.buffer_get_all(buffer_key)
|
||||
|
||||
if not messages:
|
||||
logger.info("📭 Буфер пуст, нечего отправлять")
|
||||
return
|
||||
|
||||
logger.info(f"📤 Отправляю {len(messages)} сообщений из буфера...")
|
||||
|
||||
import json
|
||||
channel = f"{settings.redis_prefix}description"
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for message in messages:
|
||||
try:
|
||||
event_json = json.dumps(message.get("event", message), ensure_ascii=False)
|
||||
subscribers = await redis_service.publish(channel, event_json)
|
||||
|
||||
if subscribers > 0:
|
||||
sent_count += 1
|
||||
logger.info(
|
||||
f"✅ Буферированное сообщение отправлено: "
|
||||
f"session_id={message.get('session_id', 'unknown')}, "
|
||||
f"subscribers={subscribers}"
|
||||
)
|
||||
else:
|
||||
failed_count += 1
|
||||
logger.warning(
|
||||
f"⚠️ Буферированное сообщение не доставлено "
|
||||
f"(подписчиков нет): session_id={message.get('session_id', 'unknown')}"
|
||||
)
|
||||
# Возвращаем обратно в буфер если не доставлено
|
||||
await redis_service.buffer_push(buffer_key, message)
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logger.error(f"❌ Ошибка отправки буферизованного сообщения: {e}")
|
||||
# Возвращаем обратно в буфер
|
||||
await redis_service.buffer_push(buffer_key, message)
|
||||
|
||||
logger.info(
|
||||
f"📊 Результат отправки буфера: {sent_count} отправлено, {failed_count} не доставлено"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"❌ Ошибка при отправке буферизованных сообщений: {e}")
|
||||
|
||||
|
||||
def _get_headers() -> Optional[dict]:
|
||||
"""Получить заголовки для n8n API"""
|
||||
if not settings.n8n_api_key:
|
||||
return None
|
||||
|
||||
api_key = settings.n8n_api_key
|
||||
|
||||
# Убираем "Bearer " если есть - n8n API использует X-N8N-API-KEY
|
||||
clean_key = api_key.replace("Bearer ", "").strip()
|
||||
|
||||
# n8n API принимает ключ в заголовке X-N8N-API-KEY
|
||||
return {"X-N8N-API-KEY": clean_key}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Redis Service для кеширования, rate limiting, сессий
|
||||
"""
|
||||
import redis.asyncio as redis
|
||||
from typing import Optional, Any
|
||||
from typing import Optional, Any, List
|
||||
import json
|
||||
from ..config import settings
|
||||
import logging
|
||||
@@ -54,9 +54,18 @@ class RedisService:
|
||||
async def publish(self, channel: str, message: str):
|
||||
"""Публикация сообщения в канал Redis Pub/Sub"""
|
||||
try:
|
||||
await self.client.publish(channel, message)
|
||||
subscribers_count = await self.client.publish(channel, message)
|
||||
logger.info(
|
||||
f"📢 Redis publish: channel={channel}, message_length={len(message)}, subscribers={subscribers_count}"
|
||||
)
|
||||
if subscribers_count == 0:
|
||||
logger.warning(
|
||||
f"⚠️ No subscribers on channel {channel}. Message published but no one is listening!"
|
||||
)
|
||||
return subscribers_count
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Redis publish error: {e}")
|
||||
raise
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
"""Удалить ключ"""
|
||||
@@ -147,6 +156,58 @@ class RedisService:
|
||||
"""Удалить из кеша"""
|
||||
await self.delete(f"cache:{cache_key}")
|
||||
|
||||
# ============================================
|
||||
# MESSAGE BUFFER (для буферизации сообщений при недоступности workflow)
|
||||
# ============================================
|
||||
|
||||
async def buffer_push(self, buffer_key: str, message: dict):
|
||||
"""
|
||||
Добавить сообщение в буфер (очередь)
|
||||
|
||||
Args:
|
||||
buffer_key: Имя буфера (например, "description")
|
||||
message: Сообщение для буферизации
|
||||
"""
|
||||
full_key = f"{settings.redis_prefix}buffer:{buffer_key}"
|
||||
await self.client.lpush(full_key, json.dumps(message))
|
||||
# Устанавливаем TTL на буфер (24 часа)
|
||||
await self.client.expire(full_key, 86400)
|
||||
|
||||
async def buffer_get_all(self, buffer_key: str) -> List[dict]:
|
||||
"""
|
||||
Получить все сообщения из буфера (и очистить буфер)
|
||||
|
||||
Args:
|
||||
buffer_key: Имя буфера
|
||||
|
||||
Returns:
|
||||
Список сообщений
|
||||
"""
|
||||
full_key = f"{settings.redis_prefix}buffer:{buffer_key}"
|
||||
|
||||
# Используем транзакцию для атомарности
|
||||
pipe = self.client.pipeline()
|
||||
pipe.lrange(full_key, 0, -1) # Получить все
|
||||
pipe.delete(full_key) # Удалить буфер
|
||||
results = await pipe.execute()
|
||||
|
||||
messages_data = results[0] if results else []
|
||||
|
||||
messages = []
|
||||
for msg_str in messages_data:
|
||||
try:
|
||||
messages.append(json.loads(msg_str))
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"⚠️ Не удалось распарсить сообщение из буфера: {msg_str}")
|
||||
|
||||
# Возвращаем в правильном порядке (FIFO - сначала старые)
|
||||
return list(reversed(messages))
|
||||
|
||||
async def buffer_size(self, buffer_key: str) -> int:
|
||||
"""Получить размер буфера"""
|
||||
full_key = f"{settings.redis_prefix}buffer:{buffer_key}"
|
||||
return await self.client.llen(full_key)
|
||||
|
||||
|
||||
# Глобальный экземпляр
|
||||
redis_service = RedisService()
|
||||
|
||||
@@ -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 'Когда пользователь последний раз «прочитал» тред (открыл чат)';
|
||||
68
check_claim_documents_table.py
Normal file
68
check_claim_documents_table.py
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Проверка документов в таблице clpr_claim_documents
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
|
||||
POSTGRES_HOST = "147.45.189.234"
|
||||
POSTGRES_PORT = 5432
|
||||
POSTGRES_DB = "default_db"
|
||||
POSTGRES_USER = "gen_user"
|
||||
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
|
||||
|
||||
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
|
||||
|
||||
async def check_documents_table():
|
||||
conn = await asyncpg.connect(
|
||||
host=POSTGRES_HOST,
|
||||
port=POSTGRES_PORT,
|
||||
database=POSTGRES_DB,
|
||||
user=POSTGRES_USER,
|
||||
password=POSTGRES_PASSWORD
|
||||
)
|
||||
|
||||
try:
|
||||
# Сначала находим UUID claim
|
||||
claim_row = await conn.fetchrow("""
|
||||
SELECT id FROM clpr_claims
|
||||
WHERE id::text = $1 OR payload->>'claim_id' = $1
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""", CLAIM_ID)
|
||||
|
||||
if not claim_row:
|
||||
print(f"❌ Черновик {CLAIM_ID} не найден!")
|
||||
return
|
||||
|
||||
claim_uuid = claim_row['id']
|
||||
|
||||
# Ищем документы по UUID (claim_id в таблице - text)
|
||||
rows = await conn.fetch("""
|
||||
SELECT
|
||||
ccd.id,
|
||||
ccd.claim_id,
|
||||
ccd.field_name,
|
||||
ccd.file_id,
|
||||
ccd.file_name,
|
||||
ccd.original_file_name,
|
||||
ccd.uploaded_at
|
||||
FROM clpr_claim_documents ccd
|
||||
WHERE ccd.claim_id = $1
|
||||
ORDER BY ccd.uploaded_at DESC
|
||||
""", str(claim_uuid))
|
||||
|
||||
print(f"📋 Найдено {len(rows)} документов в таблице clpr_claim_documents:")
|
||||
for i, row in enumerate(rows):
|
||||
print(f"\n {i+1}. field_name: {row['field_name']}")
|
||||
print(f" file_id: {row['file_id']}")
|
||||
print(f" file_name: {row['file_name']}")
|
||||
print(f" original_file_name: {row['original_file_name']}")
|
||||
print(f" uploaded_at: {row['uploaded_at']}")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(check_documents_table())
|
||||
|
||||
86
check_documents_detailed.py
Normal file
86
check_documents_detailed.py
Normal file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Детальная проверка документов в черновике
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import json
|
||||
|
||||
POSTGRES_HOST = "147.45.189.234"
|
||||
POSTGRES_PORT = 5432
|
||||
POSTGRES_DB = "default_db"
|
||||
POSTGRES_USER = "gen_user"
|
||||
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
|
||||
|
||||
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
|
||||
|
||||
async def check_documents_detailed():
|
||||
conn = await asyncpg.connect(
|
||||
host=POSTGRES_HOST,
|
||||
port=POSTGRES_PORT,
|
||||
database=POSTGRES_DB,
|
||||
user=POSTGRES_USER,
|
||||
password=POSTGRES_PASSWORD
|
||||
)
|
||||
|
||||
try:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT id, status_code, payload, updated_at
|
||||
FROM clpr_claims
|
||||
WHERE id::text = $1 OR payload->>'claim_id' = $1
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""", CLAIM_ID)
|
||||
|
||||
if not row:
|
||||
print(f"❌ Черновик {CLAIM_ID} не найден!")
|
||||
return
|
||||
|
||||
payload = row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload'])
|
||||
|
||||
print(f"📋 Статус: {row['status_code']}")
|
||||
print(f"📋 Обновлён: {row['updated_at']}")
|
||||
print(f"\n📋 documents_meta ({len(payload.get('documents_meta', []))} шт.):")
|
||||
for i, doc in enumerate(payload.get('documents_meta', [])):
|
||||
print(f" {i+1}. {doc.get('field_label', 'N/A')}")
|
||||
print(f" file_id: {doc.get('file_id', 'N/A')[:80]}...")
|
||||
print(f" field_name: {doc.get('field_name', 'N/A')}")
|
||||
|
||||
print(f"\n📋 documents_uploaded ({len(payload.get('documents_uploaded', []))} шт.):")
|
||||
for i, doc in enumerate(payload.get('documents_uploaded', [])):
|
||||
print(f" {i+1}. Тип: {doc.get('type', 'N/A')} / {doc.get('id', 'N/A')}")
|
||||
print(f" file_id: {doc.get('file_id', 'N/A')[:80]}...")
|
||||
print(f" original_file_name: {doc.get('original_file_name', 'N/A')}")
|
||||
|
||||
print(f"\n📋 documents_required ({len(payload.get('documents_required', []))} шт.):")
|
||||
for i, doc in enumerate(payload.get('documents_required', [])):
|
||||
print(f" {i+1}. {doc.get('name', 'N/A')} (id: {doc.get('id', 'N/A')})")
|
||||
|
||||
print(f"\n📋 current_doc_index: {payload.get('current_doc_index', 'N/A')}")
|
||||
|
||||
# Проверяем уникальность file_id
|
||||
print(f"\n🔍 Проверка уникальности file_id:")
|
||||
documents_meta = payload.get('documents_meta', [])
|
||||
file_ids_meta = [doc.get('file_id') for doc in documents_meta if doc.get('file_id')]
|
||||
unique_file_ids_meta = list(set(file_ids_meta))
|
||||
print(f" documents_meta: всего {len(file_ids_meta)}, уникальных {len(unique_file_ids_meta)}")
|
||||
if len(file_ids_meta) != len(unique_file_ids_meta):
|
||||
print(f" ⚠️ ЕСТЬ ДУБЛИКАТЫ!")
|
||||
from collections import Counter
|
||||
duplicates = [fid for fid, count in Counter(file_ids_meta).items() if count > 1]
|
||||
for dup in duplicates:
|
||||
print(f" - {dup[:80]}... (встречается {Counter(file_ids_meta)[dup]} раз)")
|
||||
|
||||
documents_uploaded = payload.get('documents_uploaded', [])
|
||||
file_ids_uploaded = [doc.get('file_id') for doc in documents_uploaded if doc.get('file_id')]
|
||||
unique_file_ids_uploaded = list(set(file_ids_uploaded))
|
||||
print(f" documents_uploaded: всего {len(file_ids_uploaded)}, уникальных {len(unique_file_ids_uploaded)}")
|
||||
if len(file_ids_uploaded) != len(unique_file_ids_uploaded):
|
||||
print(f" ⚠️ ЕСТЬ ДУБЛИКАТЫ!")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(check_documents_detailed())
|
||||
|
||||
118
check_documents_mismatch.py
Normal file
118
check_documents_mismatch.py
Normal file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Проверка несоответствия между documents_uploaded и clpr_claim_documents
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import json
|
||||
|
||||
POSTGRES_HOST = "147.45.189.234"
|
||||
POSTGRES_PORT = 5432
|
||||
POSTGRES_DB = "default_db"
|
||||
POSTGRES_USER = "gen_user"
|
||||
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
|
||||
|
||||
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
|
||||
|
||||
async def check_mismatch():
|
||||
conn = await asyncpg.connect(
|
||||
host=POSTGRES_HOST,
|
||||
port=POSTGRES_PORT,
|
||||
database=POSTGRES_DB,
|
||||
user=POSTGRES_USER,
|
||||
password=POSTGRES_PASSWORD
|
||||
)
|
||||
|
||||
try:
|
||||
# Находим UUID claim
|
||||
claim_row = await conn.fetchrow("""
|
||||
SELECT id FROM clpr_claims
|
||||
WHERE id::text = $1 OR payload->>'claim_id' = $1
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""", CLAIM_ID)
|
||||
|
||||
if not claim_row:
|
||||
print(f"❌ Черновик {CLAIM_ID} не найден!")
|
||||
return
|
||||
|
||||
claim_uuid = claim_row['id']
|
||||
|
||||
# Получаем payload
|
||||
payload_row = await conn.fetchrow("""
|
||||
SELECT payload FROM clpr_claims WHERE id = $1
|
||||
""", claim_uuid)
|
||||
|
||||
payload = payload_row['payload'] if isinstance(payload_row['payload'], dict) else json.loads(payload_row['payload'])
|
||||
|
||||
# Получаем документы из таблицы
|
||||
table_docs = await conn.fetch("""
|
||||
SELECT
|
||||
ccd.id,
|
||||
ccd.claim_id,
|
||||
ccd.field_name,
|
||||
ccd.file_id,
|
||||
ccd.file_name,
|
||||
ccd.original_file_name,
|
||||
ccd.uploaded_at
|
||||
FROM clpr_claim_documents ccd
|
||||
WHERE ccd.claim_id = $1
|
||||
ORDER BY ccd.uploaded_at DESC
|
||||
""", str(claim_uuid))
|
||||
|
||||
print(f"📋 Документы в таблице clpr_claim_documents ({len(table_docs)} шт.):")
|
||||
for i, doc in enumerate(table_docs):
|
||||
print(f" {i+1}. field_name: {doc['field_name']}")
|
||||
print(f" file_id: {doc['file_id']}")
|
||||
print(f" file_name: {doc['file_name']}")
|
||||
print(f" original_file_name: {doc['original_file_name']}")
|
||||
print(f" uploaded_at: {doc['uploaded_at']}")
|
||||
|
||||
print(f"\n📋 Документы в documents_uploaded ({len(payload.get('documents_uploaded', []))} шт.):")
|
||||
for i, doc in enumerate(payload.get('documents_uploaded', [])):
|
||||
print(f" {i+1}. Тип: {doc.get('type', 'N/A')} / {doc.get('id', 'N/A')}")
|
||||
print(f" file_id: {doc.get('file_id', 'N/A')}")
|
||||
print(f" original_file_name: {doc.get('original_file_name', 'N/A')}")
|
||||
|
||||
print(f"\n📋 Документы в documents_meta ({len(payload.get('documents_meta', []))} шт.):")
|
||||
for i, doc in enumerate(payload.get('documents_meta', [])):
|
||||
print(f" {i+1}. field_label: {doc.get('field_label', 'N/A')}")
|
||||
print(f" field_name: {doc.get('field_name', 'N/A')}")
|
||||
print(f" file_id: {doc.get('file_id', 'N/A')}")
|
||||
|
||||
# Проверяем, какие документы из documents_uploaded отсутствуют в таблице
|
||||
print(f"\n🔍 Проверка отсутствующих документов:")
|
||||
table_file_ids = {doc['file_id'] for doc in table_docs}
|
||||
uploaded_file_ids = {doc.get('file_id') for doc in payload.get('documents_uploaded', []) if doc.get('file_id')}
|
||||
|
||||
missing_in_table = uploaded_file_ids - table_file_ids
|
||||
if missing_in_table:
|
||||
print(f" ⚠️ В documents_uploaded есть, но нет в таблице ({len(missing_in_table)} шт.):")
|
||||
for file_id in missing_in_table:
|
||||
doc = next((d for d in payload.get('documents_uploaded', []) if d.get('file_id') == file_id), None)
|
||||
if doc:
|
||||
print(f" - {doc.get('type', 'N/A')}: {file_id[:80]}...")
|
||||
print(f" original_file_name: {doc.get('original_file_name', 'N/A')}")
|
||||
else:
|
||||
print(f" ✅ Все документы из documents_uploaded есть в таблице")
|
||||
|
||||
# Проверяем field_name
|
||||
print(f"\n🔍 Проверка field_name:")
|
||||
table_field_names = {doc['field_name'] for doc in table_docs}
|
||||
meta_field_names = {doc.get('field_name') for doc in payload.get('documents_meta', []) if doc.get('field_name')}
|
||||
|
||||
print(f" В таблице: {sorted(table_field_names)}")
|
||||
print(f" В documents_meta: {sorted(meta_field_names)}")
|
||||
|
||||
# Проверяем, есть ли конфликты по field_name
|
||||
if len(table_docs) < len(payload.get('documents_uploaded', [])):
|
||||
print(f"\n ⚠️ Возможная причина: несколько документов с одинаковым field_name")
|
||||
print(f" В таблице используется UNIQUE constraint на (claim_id, field_name)")
|
||||
print(f" Если два документа имеют одинаковый field_name, второй перезапишет первый")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(check_mismatch())
|
||||
|
||||
62
check_draft_documents.py
Normal file
62
check_draft_documents.py
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Проверка документов в черновике
|
||||
"""
|
||||
import asyncio
|
||||
import asyncpg
|
||||
import json
|
||||
|
||||
POSTGRES_HOST = "147.45.189.234"
|
||||
POSTGRES_PORT = 5432
|
||||
POSTGRES_DB = "default_db"
|
||||
POSTGRES_USER = "gen_user"
|
||||
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
|
||||
|
||||
CLAIM_ID = "bddb6815-8e17-4d54-a721-5e94382942c7"
|
||||
|
||||
async def check_documents():
|
||||
conn = await asyncpg.connect(
|
||||
host=POSTGRES_HOST,
|
||||
port=POSTGRES_PORT,
|
||||
database=POSTGRES_DB,
|
||||
user=POSTGRES_USER,
|
||||
password=POSTGRES_PASSWORD
|
||||
)
|
||||
|
||||
try:
|
||||
row = await conn.fetchrow("""
|
||||
SELECT id, status_code, payload
|
||||
FROM clpr_claims
|
||||
WHERE id::text = $1 OR payload->>'claim_id' = $1
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""", CLAIM_ID)
|
||||
|
||||
if not row:
|
||||
print(f"❌ Черновик {CLAIM_ID} не найден!")
|
||||
return
|
||||
|
||||
payload = row['payload'] if isinstance(row['payload'], dict) else json.loads(row['payload'])
|
||||
|
||||
print("📋 documents_meta:")
|
||||
for i, doc in enumerate(payload.get('documents_meta', [])):
|
||||
print(f" {i+1}. {doc.get('field_label', 'N/A')} - {doc.get('file_id', 'N/A')}")
|
||||
|
||||
print("\n📋 documents_uploaded:")
|
||||
for i, doc in enumerate(payload.get('documents_uploaded', [])):
|
||||
print(f" {i+1}. {doc.get('type', 'N/A')} / {doc.get('id', 'N/A')} - {doc.get('file_id', 'N/A')}")
|
||||
|
||||
print("\n📋 Все file_id в payload:")
|
||||
# Ищем все file_id в payload
|
||||
payload_str = json.dumps(payload, ensure_ascii=False)
|
||||
import re
|
||||
file_ids = re.findall(r'file_id["\']?\s*:\s*["\']([^"\']+)', payload_str)
|
||||
for file_id in set(file_ids):
|
||||
print(f" - {file_id}")
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(check_documents())
|
||||
|
||||
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
|
||||
|
||||
@@ -19,12 +19,9 @@ services:
|
||||
ticket_form_backend:
|
||||
container_name: ticket_form_backend
|
||||
build: ./backend
|
||||
ports:
|
||||
- "${TICKET_FORM_BACKEND_PORT:-8200}:8200"
|
||||
network_mode: host
|
||||
env_file:
|
||||
- .env
|
||||
networks:
|
||||
- ticket-form-network
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
|
||||
97
docs/BACKEND_GET_CONTACT_CF_2624_FROM_POSTGRESQL.md
Normal file
97
docs/BACKEND_GET_CONTACT_CF_2624_FROM_POSTGRESQL.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Получение cf_2624 из MySQL при загрузке черновика
|
||||
|
||||
## ✅ Упрощённый подход
|
||||
|
||||
Вместо передачи `cf_2624` через события Redis, просто делаем прямой SQL запрос к MySQL при загрузке черновика.
|
||||
|
||||
## Где это происходит
|
||||
|
||||
**Файл:** `ticket_form/backend/app/api/claims.py`
|
||||
**Эндпоинт:** `GET /api/v1/claims/drafts/{claim_id}`
|
||||
**Функция:** `get_draft()`
|
||||
|
||||
## Как работает
|
||||
|
||||
1. **Получаем `contact_id` из черновика:**
|
||||
```python
|
||||
contact_id = payload.get('contact_id')
|
||||
```
|
||||
|
||||
2. **Делаем SQL запрос к MySQL:**
|
||||
```sql
|
||||
SELECT
|
||||
cd.contactid,
|
||||
cd.firstname,
|
||||
cd.lastname,
|
||||
cd.email,
|
||||
cd.mobile,
|
||||
ccf.cf_2624 AS cf_2624
|
||||
FROM vtiger_contactdetails cd
|
||||
LEFT JOIN vtiger_contactscf ccf ON ccf.contactid = cd.contactid
|
||||
LEFT JOIN vtiger_crmentity ce ON ce.crmid = cd.contactid
|
||||
WHERE cd.contactid = %s
|
||||
AND ce.deleted = 0
|
||||
LIMIT 1
|
||||
```
|
||||
|
||||
3. **Используем `cf_2624` для блокировки полей:**
|
||||
```python
|
||||
contact_data_confirmed = (cf_2624 == "1")
|
||||
contact_data_can_edit = not contact_data_confirmed
|
||||
```
|
||||
|
||||
## Преимущества
|
||||
|
||||
1. ✅ **Проще** - один SQL запрос вместо цепочки событий
|
||||
2. ✅ **Быстрее** - прямой запрос к БД
|
||||
3. ✅ **Надёжнее** - не зависит от событий Redis
|
||||
4. ✅ **Актуальнее** - всегда получаем свежие данные из БД
|
||||
|
||||
## Что не нужно делать
|
||||
|
||||
- ❌ Передавать `cf_2624` через события Redis
|
||||
- ❌ Сохранять `cf_2624` в черновик при обработке событий
|
||||
- ❌ Использовать webservice API для получения `cf_2624`
|
||||
|
||||
## Проверка
|
||||
|
||||
1. ✅ При загрузке черновика делается SQL запрос к PostgreSQL
|
||||
2. ✅ Получаем `cf_2624` из таблицы `vtiger_contactscf`
|
||||
3. ✅ Используем для блокировки полей на фронтенде
|
||||
|
||||
---
|
||||
|
||||
## Реализация
|
||||
|
||||
### MySQL Connection для CRM
|
||||
|
||||
Создан отдельный сервис `CrmMySQLService` для подключения к MySQL БД vtiger CRM:
|
||||
|
||||
**Файл:** `ticket_form/backend/app/services/crm_mysql_service.py`
|
||||
|
||||
**Credentials (из config.php):**
|
||||
- Host: `localhost`
|
||||
- Port: `3306`
|
||||
- Database: `ci20465_72new`
|
||||
- User: `ci20465_72new`
|
||||
- Password: `EcY979Rn`
|
||||
|
||||
### Использование в коде
|
||||
|
||||
```python
|
||||
from ..services.crm_mysql_service import crm_mysql_service
|
||||
|
||||
# SQL запрос с MySQL синтаксисом (%s вместо $1)
|
||||
contact_query = """
|
||||
SELECT ... FROM vtiger_contactdetails cd
|
||||
WHERE cd.contactid = %s
|
||||
"""
|
||||
contact_row = await crm_mysql_service.fetch_one(contact_query, contact_id)
|
||||
```
|
||||
|
||||
### Отличия от PostgreSQL
|
||||
|
||||
- Параметры: `%s` вместо `$1`
|
||||
- Синтаксис JOIN: тот же
|
||||
- LIMIT: тот же
|
||||
|
||||
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
|
||||
# ============================================================================
|
||||
136
docs/CF_2624_IMPLEMENTATION_SUMMARY.md
Normal file
136
docs/CF_2624_IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Реализация проверки cf_2624 при формировании заявления
|
||||
|
||||
## ✅ Что сделано
|
||||
|
||||
### 1. Backend API (`/drafts/{claim_id}`)
|
||||
- ✅ Получает `cf_2624` из CRM через webservice `retrieve`
|
||||
- ✅ Преобразует в `contact_data_confirmed` (boolean)
|
||||
- ✅ Возвращает в ответе API вместе с `contact_data_from_crm`
|
||||
|
||||
**Файл:** `ticket_form/backend/app/api/claims.py` (строки 459-539)
|
||||
|
||||
### 2. Frontend - Загрузка черновика
|
||||
- ✅ Получает `contact_data_confirmed` из ответа API
|
||||
- ✅ Сохраняет в `formData`
|
||||
- ✅ Передаёт в `claimPlanData` для `StepClaimConfirmation`
|
||||
|
||||
**Файл:** `ticket_form/frontend/src/pages/ClaimForm.tsx` (строки 564-848)
|
||||
|
||||
### 3. Frontend - Форма подтверждения
|
||||
- ✅ `StepClaimConfirmation` получает `contact_data_confirmed` из `claimPlanData`
|
||||
- ✅ Передаёт в `generateConfirmationFormHTML`
|
||||
- ✅ Форма блокирует персональные данные если `contact_data_confirmed = true`
|
||||
|
||||
**Файлы:**
|
||||
- `ticket_form/frontend/src/components/form/StepClaimConfirmation.tsx` (строки 89-96)
|
||||
- `ticket_form/frontend/src/components/form/generateConfirmationFormHTML.ts` (строки 4, 293, 724-740, 840, 907-915)
|
||||
|
||||
### 4. CreateWebContact
|
||||
- ✅ Возвращает `cf_2624` в JSON ответе
|
||||
- ✅ Для новых контактов: `cf_2624 = "0"`
|
||||
- ✅ Для существующих: берёт значение из CRM
|
||||
|
||||
**Файл:** `include/Webservices/CreateWebContact.php`
|
||||
|
||||
---
|
||||
|
||||
## ⏳ Что нужно сделать
|
||||
|
||||
### 1. Обновить n8n workflow `6mxRJ2LLHmQXyaDz`
|
||||
|
||||
**После ноды `CreateWebContacКлиентправ`:**
|
||||
|
||||
Добавить ноду `Code: Extract Contact Data Confirmed`:
|
||||
|
||||
```javascript
|
||||
// Парсим результат CreateWebContact
|
||||
const rawResult = $node["CreateWebContacКлиентправ"].json.result;
|
||||
const contactData = JSON.parse(rawResult);
|
||||
|
||||
// Извлекаем cf_2624 (Данные подтверждены)
|
||||
const cf_2624 = contactData.cf_2624 || "0";
|
||||
const contact_data_confirmed = cf_2624 === "1";
|
||||
|
||||
return {
|
||||
contact_id: contactData.contact_id,
|
||||
is_new_contact: contactData.is_new,
|
||||
cf_2624: cf_2624,
|
||||
contact_data_confirmed: contact_data_confirmed,
|
||||
contact_data_can_edit: !contact_data_confirmed
|
||||
};
|
||||
```
|
||||
|
||||
**В ноде `Code in JavaScriptКлиентправ` (формирование ответа):**
|
||||
|
||||
Добавить в return:
|
||||
|
||||
```javascript
|
||||
const contactStatus = $('Code: Extract Contact Data Confirmed').first().json;
|
||||
|
||||
return {
|
||||
// ... существующие поля ...
|
||||
contact_data_confirmed: contactStatus.contact_data_confirmed || false,
|
||||
contact_data_can_edit: contactStatus.contact_data_can_edit !== false,
|
||||
cf_2624: contactStatus.cf_2624 || "0",
|
||||
// ... остальные поля ...
|
||||
};
|
||||
```
|
||||
|
||||
**См. подробности:** `ticket_form/docs/N8N_UPDATE_CF_2624_IN_RESPONSE.md`
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Логика работы
|
||||
|
||||
### Сценарий 1: Загрузка черновика
|
||||
1. Пользователь выбирает черновик
|
||||
2. Frontend вызывает `/api/v1/claims/drafts/{claim_id}`
|
||||
3. Backend получает `cf_2624` из CRM
|
||||
4. Backend возвращает `contact_data_confirmed = (cf_2624 === "1")`
|
||||
5. Frontend передаёт флаг в форму подтверждения
|
||||
6. Форма блокирует поля если `contact_data_confirmed = true`
|
||||
|
||||
### Сценарий 2: Новое заявление (через n8n)
|
||||
1. Пользователь вводит телефон
|
||||
2. n8n вызывает `CreateWebContact`
|
||||
3. `CreateWebContact` возвращает `cf_2624` в ответе
|
||||
4. n8n извлекает `cf_2624` и передаёт в ответе для фронтенда
|
||||
5. Frontend получает `contact_data_confirmed` из ответа n8n
|
||||
6. Форма блокирует поля если `contact_data_confirmed = true`
|
||||
|
||||
---
|
||||
|
||||
## 📋 Какие поля блокируются
|
||||
|
||||
Если `contact_data_confirmed = true`, блокируются следующие поля:
|
||||
- ✅ Фамилия (`lastname`)
|
||||
- ✅ Имя (`firstname`)
|
||||
- ✅ Отчество (`secondname`, `middle_name`)
|
||||
- ✅ ИНН (`inn`)
|
||||
- ✅ Дата рождения (`birthday`)
|
||||
- ✅ Место рождения (`birthplace`, `birth_place`)
|
||||
- ✅ Адрес (`mailingstreet`, `address`)
|
||||
- ✅ Email (`email`)
|
||||
|
||||
**Телефон (`mobile`) всегда только для чтения** (не зависит от флага)
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Проверка
|
||||
|
||||
1. ✅ Создать контакт в CRM → `cf_2624` должен быть "0"
|
||||
2. ✅ Загрузить черновик → поля должны быть редактируемыми
|
||||
3. ⏳ Установить `cf_2624 = "1"` в CRM
|
||||
4. ⏳ Загрузить черновик → поля должны быть заблокированы
|
||||
5. ⏳ Проверить предупреждение "⚠️ Данные подтверждены" в форме
|
||||
|
||||
---
|
||||
|
||||
## 📝 Документация
|
||||
|
||||
- `ticket_form/docs/CRM_CONTACT_DATA_CONFIRMED_FIELD.md` - Описание поля cf_2624
|
||||
- `ticket_form/docs/CREATE_WEB_CONTACT_RESPONSE_FORMAT.md` - Формат ответа CreateWebContact
|
||||
- `ticket_form/docs/N8N_UPDATE_CF_2624_IN_RESPONSE.md` - Обновление n8n workflow
|
||||
- `ticket_form/docs/CODE_CREATE_WEB_CONTACT_FINAL.js` - Код для n8n (обновлён)
|
||||
|
||||
|
||||
114
docs/CF_2624_IN_OCR_STATUS_EVENT.md
Normal file
114
docs/CF_2624_IN_OCR_STATUS_EVENT.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Добавление cf_2624 в событие ocr_status ready
|
||||
|
||||
## ✅ Да, правильно!
|
||||
|
||||
Событие `ocr_status` с `status: "ready"` должно содержать поле `cf_2624` и сохраняться в черновик.
|
||||
|
||||
## Формат события в Redis
|
||||
|
||||
**Канал:** `ocr_events:sess_5fc7cdd1-a848-4e92-aed4-3ee4bfb19b4c`
|
||||
|
||||
**Событие:**
|
||||
```json
|
||||
{
|
||||
"event_type": "ocr_status",
|
||||
"status": "ready",
|
||||
"claim_id": "ef853bac-f54b-46aa-adf8-f0c9c0cd76bc",
|
||||
"message": "Заявление сформировано",
|
||||
"timestamp": "2025-12-03T12:44:12.347Z",
|
||||
"cf_2624": "0"
|
||||
}
|
||||
```
|
||||
|
||||
## Что происходит
|
||||
|
||||
### 1. n8n workflow публикует событие
|
||||
|
||||
После сохранения черновика (после `claimsave`) n8n публикует событие в Redis канал `ocr_events:{session_id}` с полем `cf_2624`.
|
||||
|
||||
**Где добавить:** После ноды `claimsave`, перед публикацией в Redis.
|
||||
|
||||
**См. подробности:** `ticket_form/docs/N8N_ADD_CF_2624_TO_OCR_STATUS_EVENT.md`
|
||||
|
||||
---
|
||||
|
||||
### 2. Backend обрабатывает событие
|
||||
|
||||
Backend получает событие из Redis и:
|
||||
- ✅ Загружает `form_draft` из PostgreSQL
|
||||
- ✅ **Сохраняет `cf_2624` в черновик** (в `payload.cf_2624`)
|
||||
- ✅ Отправляет событие на фронтенд через SSE
|
||||
|
||||
**Файл:** `ticket_form/backend/app/api/events.py` (строки 218-273)
|
||||
|
||||
---
|
||||
|
||||
### 3. Сохранение в черновик
|
||||
|
||||
`cf_2624` сохраняется в таблицу `clpr_claims` в поле `payload.cf_2624`:
|
||||
|
||||
```sql
|
||||
UPDATE clpr_claims
|
||||
SET payload = jsonb_set(
|
||||
COALESCE(payload, '{}'::jsonb),
|
||||
'{cf_2624}',
|
||||
'"0"'::jsonb -- или '"1"'
|
||||
)
|
||||
WHERE id::text = $1 OR payload->>'claim_id' = $1;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Порядок работы
|
||||
|
||||
1. **n8n workflow:**
|
||||
- `CreateWebContacКлиентправ` → получает `cf_2624` из CRM
|
||||
- `claimsave` → сохраняет черновик
|
||||
- `Code: Prepare OCR Status Event` → формирует событие с `cf_2624`
|
||||
- `HTTP Request` или `Redis Publish` → публикует в `ocr_events:{session_id}`
|
||||
|
||||
2. **Backend:**
|
||||
- Получает событие из Redis
|
||||
- Сохраняет `cf_2624` в черновик
|
||||
- Загружает `form_draft` из PostgreSQL
|
||||
- Отправляет на фронтенд через SSE
|
||||
|
||||
3. **Фронтенд:**
|
||||
- Получает событие через SSE
|
||||
- Использует `cf_2624` для блокировки полей
|
||||
|
||||
---
|
||||
|
||||
## Проверка
|
||||
|
||||
1. ✅ Событие публикуется в `ocr_events:{session_id}` с `cf_2624`
|
||||
2. ✅ Backend сохраняет `cf_2624` в черновик (`payload.cf_2624`)
|
||||
3. ✅ При загрузке черновика `cf_2624` доступен в `payload.cf_2624`
|
||||
|
||||
---
|
||||
|
||||
## SQL для проверки
|
||||
|
||||
```sql
|
||||
-- Проверить, что cf_2624 сохранён в черновик
|
||||
SELECT
|
||||
id,
|
||||
payload->>'claim_id' as claim_id,
|
||||
payload->>'cf_2624' as cf_2624,
|
||||
updated_at
|
||||
FROM clpr_claims
|
||||
WHERE payload->>'claim_id' = 'ef853bac-f54b-46aa-adf8-f0c9c0cd76bc'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Итого
|
||||
|
||||
✅ **Да, правильно!** Событие `ocr_status` с `status: "ready"` должно содержать `cf_2624`, и это значение будет:
|
||||
- Публиковаться в Redis канал `ocr_events:{session_id}`
|
||||
- Сохраняться в черновик в `payload.cf_2624`
|
||||
- Использоваться для блокировки полей на фронтенде
|
||||
|
||||
|
||||
94
docs/CLAIM_226564ce_STATUS.md
Normal file
94
docs/CLAIM_226564ce_STATUS.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Статус заявки 226564ce-d7cf-48ee-a820-690e8f5ec8e5
|
||||
|
||||
## ✅ Общая информация
|
||||
|
||||
- **ID**: `226564ce-d7cf-48ee-a820-690e8f5ec8e5`
|
||||
- **Status**: `draft_docs_complete`
|
||||
- **Unified ID**: `usr_b1fbffa0-477b-4abb-95d6-8d6f849ddc71`
|
||||
- **Session Token**: `sess_c278abf8-1603-484d-af98-8b93843e5253`
|
||||
- **Phone**: `71234543212`
|
||||
- **Channel**: `web_form`
|
||||
- **Is Confirmed**: `false` (должна отображаться в списке)
|
||||
- **Created**: `2025-12-01 14:38:11`
|
||||
- **Updated**: `2025-12-01 20:06:18`
|
||||
- **Expires**: `2025-12-15 19:35:30`
|
||||
|
||||
## ✅ Документы
|
||||
|
||||
### documents_meta (2 записи)
|
||||
|
||||
1. **uploads[1][0]**
|
||||
- `field_label`: "Чек или подтверждение оплаты" ✅ (правильно, не "group-2")
|
||||
- `file_id`: `/f9825c87-4e3558f6-f9b6-405c-ad3d-d1535c49b61c/crm2/CRM_Active_Files/Documents/Project/ERV_3212_КлиентПрав_399543/e34f2f9e-e48d-47f4-9c2d-6957012c0800__chek-ili-podtverzhdenie-oplaty.pdf`
|
||||
- `file_name`: `e34f2f9e-e48d-47f4-9c2d-6957012c0800__chek-ili-podtverzhdenie-oplaty.pdf`
|
||||
- `uploaded_at`: `2025-12-01T14:15:54.122Z`
|
||||
|
||||
2. **uploads[0][0]**
|
||||
- `field_label`: "Договор или заказ" ✅ (правильно)
|
||||
- `file_id`: `/f9825c87-4e3558f6-f9b6-405c-ad3d-d1535c49b61c/crm2/CRM_Active_Files/Documents/Project/ERV_3212_КлиентПрав_399543/344deab2-1a3a-46ce-931b-5a29bb2c40a3__dogovor-ili-zakaz.pdf`
|
||||
- `file_name`: `344deab2-1a3a-46ce-931b-5a29bb2c40a3__dogovor-ili-zakaz.pdf`
|
||||
- `uploaded_at`: `2025-12-01T13:47:15.772Z`
|
||||
|
||||
### clpr_claim_documents (2 записи)
|
||||
|
||||
1. **uploads[1][0]**
|
||||
- `id`: `e34f2f9e-e48d-47f4-9c2d-6957012c0800`
|
||||
- `file_hash`: `3e1f1332a76b7f26df1628c49579f30a873de9170f3b8007b0bac5e4a439ca67` ✅
|
||||
|
||||
2. **uploads[0][0]**
|
||||
- `id`: `344deab2-1a3a-46ce-931b-5a29bb2c40a3`
|
||||
- `file_hash`: `83822e59662aa2037977dc5a8661d8a057ae6572e6f99936a31c6cdd7d66f1d9` ✅
|
||||
|
||||
## ✅ Проверки
|
||||
|
||||
- ✅ **Дубликатов нет** — все `field_name` уникальны
|
||||
- ✅ **field_label правильные** — не "group-2", а реальные названия
|
||||
- ✅ **Синхронизация** — `documents_meta` и `clpr_claim_documents` совпадают
|
||||
- ✅ **file_hash заполнен** — оба документа имеют хеш
|
||||
- ✅ **Заявка должна отображаться** — `is_confirmed = false`, `status_code != 'approved'`
|
||||
|
||||
## 📋 Payload структура
|
||||
|
||||
Заявка содержит следующие ключи в `payload`:
|
||||
- `body`
|
||||
- `email`
|
||||
- `phone`
|
||||
- `tg_id`
|
||||
- `answers`
|
||||
- `claim_id`
|
||||
- `applicant`
|
||||
- `contact_id`
|
||||
- `form_draft`
|
||||
- `ai_analysis`
|
||||
- `claim_ready`
|
||||
- `wizard_plan`
|
||||
- `wizard_ready`
|
||||
- `ai_agent13_rag`
|
||||
- `documents_meta` ✅
|
||||
- `ai_agent1_facts`
|
||||
- `answers_prefill`
|
||||
- `current_doc_index`
|
||||
- `documents_skipped`
|
||||
- `documents_required`
|
||||
- `documents_uploaded`
|
||||
- `problem_description`
|
||||
|
||||
## 🔍 Возможные проблемы с отображением
|
||||
|
||||
Если заявка не отображается или отображается неправильно, проверьте:
|
||||
|
||||
1. **API endpoint `/drafts/list`** — должен находить заявку по `unified_id`, `phone` или `session_token`
|
||||
2. **Фронтенд фильтрация** — возможно, фильтруется по `status_code`
|
||||
3. **Отображение `field_label`** — должно использовать `documents_meta[].field_label`, а не вычислять из `field_name`
|
||||
|
||||
## ✅ Вывод
|
||||
|
||||
**Заявка в порядке!** Все данные корректны:
|
||||
- ✅ Нет дубликатов в `documents_meta`
|
||||
- ✅ `field_label` правильные
|
||||
- ✅ Документы синхронизированы
|
||||
- ✅ `file_hash` заполнен
|
||||
- ✅ Заявка должна отображаться в списке
|
||||
|
||||
Если есть проблемы с отображением, они скорее всего на стороне фронтенда или API фильтрации.
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
// Парсим результат CreateWebContact
|
||||
const rawResult = $node["CreateWebContact"].json.result;
|
||||
|
||||
const contactData = JSON.parse(rawResult); // {"contact_id": "396625", "is_new": false}
|
||||
const contactData = JSON.parse(rawResult); // {"contact_id": "396625", "is_new": false, "cf_2624": "1"}
|
||||
|
||||
// ✅ Извлекаем cf_2624 (Данные подтверждены)
|
||||
// "1" = данные подтверждены, "0" = не подтверждены
|
||||
const cf_2624 = contactData.cf_2624 || "0";
|
||||
const contact_data_confirmed = cf_2624 === "1";
|
||||
|
||||
const phone = $('Edit Fields').first().json.phone;
|
||||
|
||||
@@ -18,6 +23,8 @@ const sessionData = {
|
||||
contact_id: contactData.contact_id, // ← распарсенный ID из CreateWebContact
|
||||
phone: phone,
|
||||
is_new_contact: contactData.is_new, // ← флаг нового контакта
|
||||
cf_2624: cf_2624, // ✅ Сохраняем cf_2624 в сессию
|
||||
contact_data_confirmed: contact_data_confirmed, // ✅ Сохраняем флаг подтверждения
|
||||
status: "draft",
|
||||
current_step: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
@@ -34,6 +41,10 @@ return {
|
||||
contact_id: contactData.contact_id,
|
||||
is_new_contact: contactData.is_new,
|
||||
phone: phone,
|
||||
// ✅ Флаги подтверждения данных контакта (из cf_2624)
|
||||
cf_2624: cf_2624,
|
||||
contact_data_confirmed: contact_data_confirmed,
|
||||
contact_data_can_edit: !contact_data_confirmed,
|
||||
redis_key: `session:${session_id}`, // ✅ Используем session_id для ключа Redis
|
||||
redis_value: JSON.stringify(sessionData),
|
||||
ttl: 604800
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// ========================================
|
||||
// Code Node: Мерж данных проекта в сессию
|
||||
// v2.0 - с расширенным логированием для отладки
|
||||
// ========================================
|
||||
|
||||
// 1. Берём первый item
|
||||
@@ -12,25 +13,62 @@ if (!inputItem || !inputItem.json) {
|
||||
// root — то, что реально пришло в эту ноду
|
||||
const root = inputItem.json;
|
||||
|
||||
// ✅ ОТЛАДКА: смотрим что пришло
|
||||
console.log('🔍 DEBUG: root keys:', Object.keys(root));
|
||||
console.log('🔍 DEBUG: root.body exists:', !!root.body);
|
||||
console.log('🔍 DEBUG: root.other exists:', !!root.other);
|
||||
|
||||
// 2. Универсально получаем body
|
||||
// - если нода стоит сразу после Webhook → данные лежат в root.body
|
||||
// - если кто-то выше уже отдал только body → root и есть body
|
||||
const body = root.body || root;
|
||||
|
||||
console.log('🔍 DEBUG: body keys:', Object.keys(body));
|
||||
console.log('🔍 DEBUG: body.other exists:', !!body.other);
|
||||
console.log('🔍 DEBUG: body.other type:', typeof body.other);
|
||||
|
||||
// 3. Парсим body.other (если есть) как сессию
|
||||
// ✅ ВАЖНО: Также проверяем root.other напрямую (если данные пришли не через body)
|
||||
let sessionData = {};
|
||||
const rawOther = body.other;
|
||||
let rawOther = body.other || root.other;
|
||||
|
||||
// ✅ Пробуем также достать other из Webhook напрямую
|
||||
if (!rawOther) {
|
||||
try {
|
||||
const webhookJson = $('Webhook').first()?.json;
|
||||
if (webhookJson?.body?.other) {
|
||||
rawOther = webhookJson.body.other;
|
||||
console.log('✅ Взяли other напрямую из Webhook');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('⚠️ Не удалось достать other из Webhook:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🔍 DEBUG: rawOther exists:', !!rawOther);
|
||||
console.log('🔍 DEBUG: rawOther type:', typeof rawOther);
|
||||
if (rawOther) {
|
||||
console.log('🔍 DEBUG: rawOther preview:', typeof rawOther === 'string' ? rawOther.substring(0, 200) : JSON.stringify(rawOther).substring(0, 200));
|
||||
}
|
||||
|
||||
if (rawOther) {
|
||||
if (typeof rawOther === 'string') {
|
||||
try {
|
||||
sessionData = JSON.parse(rawOther);
|
||||
console.log('✅ Распарсили other как JSON. Ключи:', Object.keys(sessionData));
|
||||
console.log('✅ sessionData.session_id:', sessionData.session_id);
|
||||
console.log('✅ sessionData.phone:', sessionData.phone);
|
||||
console.log('✅ sessionData.firstname:', sessionData.firstname);
|
||||
} catch (e) {
|
||||
throw new Error('Не смог распарсить body.other как JSON: ' + e.message + '. rawOther: ' + rawOther);
|
||||
throw new Error('Не смог распарсить other как JSON: ' + e.message + '. rawOther: ' + rawOther.substring(0, 500));
|
||||
}
|
||||
} else if (typeof rawOther === 'object') {
|
||||
sessionData = rawOther;
|
||||
console.log('✅ other уже объект. Ключи:', Object.keys(sessionData));
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ other отсутствует или пустой. Проверьте структуру данных!');
|
||||
console.log('⚠️ root:', JSON.stringify(root).substring(0, 500));
|
||||
}
|
||||
|
||||
// 4. Определяем claimId (основной путь)
|
||||
@@ -94,19 +132,75 @@ if (!projectResult || !projectResult.project_id) {
|
||||
}
|
||||
|
||||
// 8. Собираем обновлённую сессию
|
||||
// ✅ Используем spread оператор, но с фильтрацией undefined значений
|
||||
// Сначала создаём базовый объект из sessionData, фильтруя undefined
|
||||
const baseSession = Object.keys(sessionData).reduce((acc, key) => {
|
||||
if (sessionData[key] !== undefined && sessionData[key] !== null) {
|
||||
acc[key] = sessionData[key];
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
console.log('📦 baseSession после фильтрации:', Object.keys(baseSession));
|
||||
console.log('📦 baseSession sample:', {
|
||||
session_id: baseSession.session_id,
|
||||
phone: baseSession.phone,
|
||||
unified_id: baseSession.unified_id,
|
||||
contact_id: baseSession.contact_id,
|
||||
firstname: baseSession.firstname,
|
||||
lastname: baseSession.lastname,
|
||||
});
|
||||
|
||||
const updatedSession = {
|
||||
...sessionData, // всё, что было в other
|
||||
claim_id: claimId, // актуальный claim_id
|
||||
// ✅ Шаг 1: Все данные из sessionData (body.other) - базовая сессия
|
||||
...baseSession,
|
||||
|
||||
// ✅ Шаг 2: Дополняем данными из body (если их нет в sessionData)
|
||||
...(body.phone && !baseSession.phone ? { phone: body.phone } : {}),
|
||||
...(body.unified_id && !baseSession.unified_id ? { unified_id: body.unified_id } : {}),
|
||||
...(body.contact_id && !baseSession.contact_id ? { contact_id: body.contact_id } : {}),
|
||||
...(body.email && !baseSession.email ? { email: body.email } : {}),
|
||||
|
||||
// ✅ Шаг 3: Данные проекта (новые, всегда перезаписываем)
|
||||
claim_id: claimId, // актуальный claim_id (перезаписываем null из sessionData)
|
||||
project_id: projectResult.project_id, // id проекта из CRM
|
||||
project_name: projectResult.project_name || null, // название проекта из CRM (новое поле)
|
||||
project_name: projectResult.project_name || null, // название проекта из CRM
|
||||
is_new_project: projectResult.is_new, // флаг новый/старый
|
||||
current_step: 2, // двигаем визард на шаг 2
|
||||
|
||||
// ✅ Шаг 4: Данные анализа из body (приоритет body)
|
||||
problem: body.problem || baseSession.problem || null,
|
||||
last_analysis_output: body.output || baseSession.last_analysis_output || null,
|
||||
|
||||
// ✅ Шаг 5: Метаданные (всегда обновляем)
|
||||
updated_at: new Date().toISOString(),
|
||||
// опционально дотащим полезные поля из body:
|
||||
problem: body.problem ?? sessionData.problem,
|
||||
last_analysis_output: body.output ?? sessionData.last_analysis_output,
|
||||
};
|
||||
|
||||
// ✅ Логируем результат для отладки
|
||||
console.log('📦 sessionData keys:', Object.keys(sessionData));
|
||||
console.log('📦 sessionData sample:', {
|
||||
session_id: sessionData.session_id,
|
||||
phone: sessionData.phone,
|
||||
unified_id: sessionData.unified_id,
|
||||
contact_id: sessionData.contact_id,
|
||||
firstname: sessionData.firstname,
|
||||
lastname: sessionData.lastname,
|
||||
middle_name: sessionData.middle_name,
|
||||
});
|
||||
console.log('📦 updatedSession keys:', Object.keys(updatedSession));
|
||||
console.log('📦 updatedSession sample:', {
|
||||
session_id: updatedSession.session_id,
|
||||
phone: updatedSession.phone,
|
||||
unified_id: updatedSession.unified_id,
|
||||
contact_id: updatedSession.contact_id,
|
||||
firstname: updatedSession.firstname,
|
||||
lastname: updatedSession.lastname,
|
||||
middle_name: updatedSession.middle_name,
|
||||
claim_id: updatedSession.claim_id,
|
||||
project_id: updatedSession.project_id,
|
||||
});
|
||||
console.log('📦 updatedSession FULL:', JSON.stringify(updatedSession, null, 2));
|
||||
|
||||
// 9. Возвращаем один item для Redis SET
|
||||
return [
|
||||
{
|
||||
|
||||
56
docs/CREATE_WEB_CONTACT_RESPONSE_FORMAT.md
Normal file
56
docs/CREATE_WEB_CONTACT_RESPONSE_FORMAT.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Формат ответа CreateWebContact
|
||||
|
||||
## Обновление: добавлено поле cf_2624
|
||||
|
||||
### Старый формат:
|
||||
```json
|
||||
{
|
||||
"contact_id": "396625",
|
||||
"is_new": false
|
||||
}
|
||||
```
|
||||
|
||||
### Новый формат (с cf_2624):
|
||||
```json
|
||||
{
|
||||
"contact_id": "396625",
|
||||
"is_new": false,
|
||||
"cf_2624": "1"
|
||||
}
|
||||
```
|
||||
|
||||
## Описание полей:
|
||||
|
||||
- **contact_id** (string) - ID контакта в CRM
|
||||
- **is_new** (boolean) - `true` если контакт только что создан, `false` если найден существующий
|
||||
- **cf_2624** (string) - "Данные подтверждены":
|
||||
- `"1"` = "Да" (данные подтверждены)
|
||||
- `"0"` = "Нет" (данные не подтверждены)
|
||||
|
||||
## Использование в n8n:
|
||||
|
||||
```javascript
|
||||
// Парсим результат CreateWebContact
|
||||
const rawResult = $node["CreateWebContact"].json.result;
|
||||
const contactData = JSON.parse(rawResult);
|
||||
|
||||
// Получаем данные
|
||||
const contact_id = contactData.contact_id;
|
||||
const is_new = contactData.is_new;
|
||||
const data_confirmed = contactData.cf_2624 === "1"; // true/false
|
||||
|
||||
// Используем в дальнейшей логике
|
||||
if (data_confirmed) {
|
||||
// Данные подтверждены - блокируем редактирование
|
||||
}
|
||||
```
|
||||
|
||||
## Логика работы:
|
||||
|
||||
1. **Новый контакт** (`is_new: true`):
|
||||
- `cf_2624` всегда `"0"` (данные не подтверждены)
|
||||
|
||||
2. **Существующий контакт** (`is_new: false`):
|
||||
- `cf_2624` берётся из базы данных CRM
|
||||
- Если поле пустое → возвращается `"0"`
|
||||
|
||||
149
docs/CRM_CONTACT_DATA_CONFIRMED_FIELD.md
Normal file
149
docs/CRM_CONTACT_DATA_CONFIRMED_FIELD.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Добавление поля "Данные подтверждены" в CRM
|
||||
|
||||
## Шаг 1: Создание кастомного поля в CRM
|
||||
|
||||
1. Зайти в CRM → Настройки → Кастомные поля → Модуль "Контакты"
|
||||
2. Создать новое поле:
|
||||
- **Название:** "Данные подтверждены"
|
||||
- **Тип:** "Да/Нет" (Checkbox) или "Список" (Picklist) со значениями "Да"/"Нет"
|
||||
- **Код поля:** `cf_2624` ✅ (уже создано)
|
||||
- **По умолчанию:** "Нет" (false)
|
||||
|
||||
3. **ВАЖНО:** Записать номер поля (например, `cf_2624`)
|
||||
|
||||
---
|
||||
|
||||
## Шаг 2: Обновление backend для проверки поля в CRM
|
||||
|
||||
### Файл: `ticket_form/backend/app/api/claims.py`
|
||||
|
||||
В функции `get_draft()` вместо проверки PostgreSQL, проверяем поле в CRM:
|
||||
|
||||
```python
|
||||
# ✅ Проверяем флаг подтверждения данных контакта из CRM
|
||||
unified_id = row.get('unified_id')
|
||||
contact_data_confirmed = False
|
||||
contact_data_can_edit = True
|
||||
contact_data_confirmed_at = None
|
||||
contact_data_from_crm = None
|
||||
|
||||
if unified_id:
|
||||
# Получаем contact_id из payload
|
||||
contact_id = payload.get('contact_id') if isinstance(payload, dict) else None
|
||||
|
||||
if contact_id:
|
||||
try:
|
||||
# Получаем данные контакта из CRM
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
# 1. Get Challenge
|
||||
challenge_response = await client.get(
|
||||
f"{settings.crm_webservice_url}",
|
||||
params={"operation": "getchallenge", "username": "api"}
|
||||
)
|
||||
challenge_data = challenge_response.json()
|
||||
token = challenge_data.get("result", {}).get("token", "")
|
||||
|
||||
# 2. Login
|
||||
import hashlib
|
||||
salt = "4r9ANex8PT2IuRV"
|
||||
access_key = hashlib.md5((token + salt).encode()).hexdigest()
|
||||
|
||||
login_response = await client.post(
|
||||
f"{settings.crm_webservice_url}",
|
||||
data={
|
||||
"operation": "login",
|
||||
"username": "api",
|
||||
"accessKey": access_key
|
||||
}
|
||||
)
|
||||
login_data = login_response.json()
|
||||
session_name = login_data.get("result", {}).get("sessionName", "")
|
||||
|
||||
# 3. Retrieve Contact
|
||||
retrieve_response = await client.post(
|
||||
f"{settings.crm_webservice_url}",
|
||||
data={
|
||||
"operation": "retrieve",
|
||||
"sessionName": session_name,
|
||||
"id": f"12x{contact_id}"
|
||||
}
|
||||
)
|
||||
retrieve_data = retrieve_response.json()
|
||||
|
||||
if retrieve_data.get("success") and retrieve_data.get("result"):
|
||||
contact_data_from_crm = retrieve_data["result"]
|
||||
|
||||
# ✅ Проверяем кастомное поле "Данные подтверждены"
|
||||
confirmed_field = contact_data_from_crm.get("cf_2624", "0") # "1" = да, "0" = нет
|
||||
contact_data_confirmed = confirmed_field == "1" or confirmed_field == "true"
|
||||
contact_data_can_edit = not contact_data_confirmed
|
||||
|
||||
logger.info(
|
||||
f"🔒 Статус данных контакта из CRM: confirmed={contact_data_confirmed}, "
|
||||
f"field_value={confirmed_field}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось загрузить данные из CRM: {str(e)}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Шаг 3: Обновление n8n workflow для установки поля
|
||||
|
||||
### В workflow `6mxRJ2LLHmQXyaDz`
|
||||
|
||||
После подтверждения формы (после SMS-верификации) добавить ноду:
|
||||
|
||||
**Название:** `HTTP Request: Set Contact Data Confirmed`
|
||||
|
||||
**Метод:** POST
|
||||
|
||||
**URL:** `{{ $env.CRM_WEBSERVICE_URL }}`
|
||||
|
||||
**Body (form-data):**
|
||||
```
|
||||
operation: revise
|
||||
sessionName: {{ $('Login to CRM').json.sessionName }}
|
||||
id: 12x{{ JSON.parse($node['CreateWebContacКлиентправ'].json.result).contact_id }}
|
||||
cf_2624: 1
|
||||
```
|
||||
|
||||
**Где:**
|
||||
- `cf_2624` - поле "Данные подтверждены"
|
||||
- `1` = "Да" (данные подтверждены)
|
||||
|
||||
---
|
||||
|
||||
## Шаг 4: Обновление UpsertContact (если используется)
|
||||
|
||||
Если используется `UpsertContact.php`, добавить поддержку нового поля:
|
||||
|
||||
```php
|
||||
// В функции vtws_upsertcontact()
|
||||
if (!empty($data_confirmed)) {
|
||||
$params['cf_2624'] = $data_confirmed; // "1" или "0"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Преимущества подхода:
|
||||
|
||||
1. ✅ **CRM - источник истины** - все данные в одном месте
|
||||
2. ✅ **Нет синхронизации** - не нужно синхронизировать флаги между PostgreSQL и CRM
|
||||
3. ✅ **Простота** - один флаг в CRM, проверяем его напрямую
|
||||
4. ✅ **Видимость** - менеджеры видят статус в карточке контакта
|
||||
5. ✅ **Гибкость** - можно менять статус вручную в CRM
|
||||
|
||||
---
|
||||
|
||||
## Проверка:
|
||||
|
||||
1. ✅ Поле создано в CRM: `cf_2624`
|
||||
2. ⏳ Обновить код backend (использовать `cf_2624`)
|
||||
3. ⏳ Обновить n8n workflow (использовать `cf_2624`)
|
||||
4. ⏳ Протестировать:
|
||||
- Создать контакт → поле должно быть "Нет"
|
||||
- Подтвердить форму → поле должно стать "Да"
|
||||
- Загрузить черновик → поля должны быть заблокированы
|
||||
|
||||
217
docs/FRONTEND_UPDATE_CONTACT_DATA_CONFIRMED.md
Normal file
217
docs/FRONTEND_UPDATE_CONTACT_DATA_CONFIRMED.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# Обновление фронтенда: Блокировка редактирования подтверждённых данных
|
||||
|
||||
## Изменения
|
||||
|
||||
### 1. Step1Phone.tsx - Получение флага из n8n
|
||||
|
||||
**После получения ответа от n8n (после строки ~150):**
|
||||
|
||||
```typescript
|
||||
// ✅ Извлекаем флаг подтверждения данных
|
||||
const contact_data_confirmed = result.contact_data_confirmed || false;
|
||||
const contact_data_can_edit = result.contact_data_can_edit !== false; // По умолчанию true
|
||||
const contact_data_confirmed_at = result.contact_data_confirmed_at || null;
|
||||
|
||||
// Сохраняем в formData
|
||||
updateFormData({
|
||||
// ... существующие поля ...
|
||||
contact_data_confirmed: contact_data_confirmed,
|
||||
contact_data_can_edit: contact_data_can_edit,
|
||||
contact_data_confirmed_at: contact_data_confirmed_at,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. generateConfirmationFormHTML.ts - Блокировка полей
|
||||
|
||||
**Добавить параметр `contact_data_confirmed` в функцию:**
|
||||
|
||||
```typescript
|
||||
export function generateConfirmationFormHTML(
|
||||
data: any,
|
||||
contact_data_confirmed: boolean = false
|
||||
): string {
|
||||
// ... существующий код ...
|
||||
|
||||
// В функции createInputField добавить проверку:
|
||||
function createInputField(root: string, key: string, value: any, label: string, type: string = 'text') {
|
||||
const isReadOnly = contact_data_confirmed && (
|
||||
key === 'firstname' ||
|
||||
key === 'lastname' ||
|
||||
key === 'middle_name' ||
|
||||
key === 'inn' ||
|
||||
key === 'birthday' ||
|
||||
key === 'birthplace' ||
|
||||
key === 'mailingstreet' ||
|
||||
key === 'email'
|
||||
);
|
||||
|
||||
const readonlyAttr = isReadOnly ? 'readonly' : '';
|
||||
const readonlyClass = isReadOnly ? 'readonly-field' : '';
|
||||
|
||||
// ... остальной код с добавлением readonlyAttr и readonlyClass ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Добавить CSS для readonly полей:**
|
||||
|
||||
```css
|
||||
.readonly-field {
|
||||
background-color: #f5f5f5 !important;
|
||||
cursor: not-allowed !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. StepClaimConfirmation.tsx - Передача флага в форму
|
||||
|
||||
**В useEffect (после строки ~90):**
|
||||
|
||||
```typescript
|
||||
// Получаем флаг подтверждения из claimPlanData или formData
|
||||
const contact_data_confirmed =
|
||||
claimPlanData?.contact_data_confirmed ||
|
||||
claimPlanData?.propertyName?.meta?.contact_data_confirmed ||
|
||||
formData?.contact_data_confirmed ||
|
||||
false;
|
||||
|
||||
// Передаём в generateConfirmationFormHTML
|
||||
const html = generateConfirmationFormHTML(formData, contact_data_confirmed);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Добавить кнопку "Изменить данные" (опционально)
|
||||
|
||||
**В generateConfirmationFormHTML.ts:**
|
||||
|
||||
```typescript
|
||||
// После заголовка формы, если contact_data_confirmed = true
|
||||
if (contact_data_confirmed) {
|
||||
html += `
|
||||
<div style="margin-bottom: 16px; padding: 12px; background: #fff7e6; border: 1px solid #ffd591; border-radius: 4px;">
|
||||
<p style="margin: 0 0 8px 0; color: #ad6800;">
|
||||
<strong>⚠️ Данные подтверждены</strong>
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 14px; color: #ad6800;">
|
||||
Для изменения данных требуется подтверждение через SMS.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
id="btn-edit-data"
|
||||
style="margin-top: 8px; padding: 6px 16px; background: #fa8c16; color: white; border: none; border-radius: 4px; cursor: pointer;"
|
||||
>
|
||||
Изменить данные
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
**В JavaScript внутри формы:**
|
||||
|
||||
```javascript
|
||||
// Обработчик кнопки "Изменить данные"
|
||||
const editBtn = document.getElementById('btn-edit-data');
|
||||
if (editBtn) {
|
||||
editBtn.addEventListener('click', function() {
|
||||
// Отправляем сообщение родительскому окну
|
||||
window.parent.postMessage({
|
||||
type: 'request_edit_contact_data',
|
||||
eventData: {
|
||||
phone: state.user?.mobile || '',
|
||||
unified_id: state.meta?.unified_id || ''
|
||||
}
|
||||
}, '*');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Обработка запроса на изменение данных
|
||||
|
||||
**В StepClaimConfirmation.tsx:**
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
// ... существующие обработчики ...
|
||||
|
||||
if (event.data.type === 'request_edit_contact_data') {
|
||||
const { phone, unified_id } = event.data.eventData;
|
||||
|
||||
// Показываем модалку SMS для подтверждения
|
||||
setSmsModalVisible(true);
|
||||
setSmsCodeSent(false);
|
||||
sendSMSCode(phone);
|
||||
|
||||
// Сохраняем флаг, что это запрос на изменение данных
|
||||
setPendingFormData({
|
||||
...pendingFormData,
|
||||
is_edit_request: true,
|
||||
unified_id: unified_id
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, []);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. После SMS подтверждения - сброс флага
|
||||
|
||||
**В verifySMSCode (после успешной верификации):**
|
||||
|
||||
```typescript
|
||||
// Если это запрос на изменение данных
|
||||
if (pendingFormData?.is_edit_request) {
|
||||
// Отправляем запрос в n8n для сброса флага
|
||||
await fetch('/api/v1/claims/contact-data/reset-confirmed', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
unified_id: pendingFormData.unified_id,
|
||||
sms_code: code
|
||||
})
|
||||
});
|
||||
|
||||
// Обновляем флаг в formData
|
||||
updateFormData({
|
||||
contact_data_confirmed: false,
|
||||
contact_data_can_edit: true
|
||||
});
|
||||
|
||||
// Перезагружаем форму с разблокированными полями
|
||||
// (можно просто обновить страницу или пересоздать форму)
|
||||
window.location.reload();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Порядок реализации
|
||||
|
||||
1. ✅ Обновить Step1Phone для получения флага
|
||||
2. ✅ Обновить generateConfirmationFormHTML для блокировки полей
|
||||
3. ✅ Обновить StepClaimConfirmation для передачи флага
|
||||
4. ⏳ Добавить кнопку "Изменить данные" (опционально)
|
||||
5. ⏳ Реализовать механизм переподтверждения через SMS
|
||||
|
||||
---
|
||||
|
||||
## Тестирование
|
||||
|
||||
После обновления проверить:
|
||||
- ✅ Флаг получается из n8n
|
||||
- ✅ Поля блокируются при `contact_data_confirmed = true`
|
||||
- ✅ Данные из CRM загружаются и отображаются
|
||||
- ✅ Кнопка "Изменить данные" работает (если реализована)
|
||||
|
||||
210
docs/N8N_ADD_CF_2624_TO_OCR_STATUS_EVENT.md
Normal file
210
docs/N8N_ADD_CF_2624_TO_OCR_STATUS_EVENT.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Добавление cf_2624 в событие ocr_status ready
|
||||
|
||||
## Задача
|
||||
|
||||
После сохранения черновика (после `claimsave`) публиковать событие `ocr_status` с `status: "ready"` в Redis канал `ocr_events:{session_id}` с полем `cf_2624`.
|
||||
|
||||
## Формат события
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "ocr_status",
|
||||
"status": "ready",
|
||||
"claim_id": "ef853bac-f54b-46aa-adf8-f0c9c0cd76bc",
|
||||
"message": "Заявление сформировано",
|
||||
"timestamp": "2025-12-03T12:44:12.347Z",
|
||||
"cf_2624": "0"
|
||||
}
|
||||
```
|
||||
|
||||
## Где добавить в n8n workflow
|
||||
|
||||
### Вариант 1: После ноды `claimsave` (PostgreSQL)
|
||||
|
||||
**Название ноды:** `Code: Prepare OCR Status Event`
|
||||
|
||||
**Расположение:** После ноды `claimsave` (PostgreSQL), перед нодой публикации в Redis
|
||||
|
||||
**Код:**
|
||||
```javascript
|
||||
// Получаем результат из claimsave
|
||||
const claimResult = $input.first().json;
|
||||
const claim = claimResult.claim || claimResult;
|
||||
|
||||
// Получаем contact_id из claim
|
||||
const contact_id = claim.contact_id;
|
||||
|
||||
// ✅ Получаем cf_2624 из PostgreSQL (если есть нода Get Contact Data)
|
||||
let cf_2624 = "0"; // По умолчанию "0" (не подтверждено)
|
||||
|
||||
try {
|
||||
// Пытаемся получить из предыдущей ноды PostgreSQL: Get Contact Data
|
||||
const contactData = $('PostgreSQL: Get Contact Data')?.first()?.json;
|
||||
if (contactData && contactData.cf_2624) {
|
||||
cf_2624 = contactData.cf_2624;
|
||||
} else {
|
||||
// Альтернатива: получаем из CreateWebContact
|
||||
const createWebContactResult = $node["CreateWebContacКлиентправ"]?.json?.result || "";
|
||||
if (createWebContactResult) {
|
||||
const contactData = typeof createWebContactResult === 'string'
|
||||
? JSON.parse(createWebContactResult)
|
||||
: createWebContactResult;
|
||||
if (contactData.cf_2624) {
|
||||
cf_2624 = contactData.cf_2624;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Не удалось получить cf_2624, используем значение по умолчанию "0"');
|
||||
}
|
||||
|
||||
// Формируем событие для Redis
|
||||
const event = {
|
||||
event_type: 'ocr_status',
|
||||
status: 'ready',
|
||||
claim_id: claim.claim_id || claim.id,
|
||||
message: 'Заявление сформировано',
|
||||
timestamp: new Date().toISOString(),
|
||||
cf_2624: cf_2624 // ✅ Добавляем cf_2624
|
||||
};
|
||||
|
||||
console.log('📤 Подготовлено событие ocr_status ready:', {
|
||||
claim_id: event.claim_id,
|
||||
cf_2624: event.cf_2624,
|
||||
contact_id: contact_id
|
||||
});
|
||||
|
||||
return {
|
||||
json: {
|
||||
// Данные для публикации в Redis
|
||||
channel: `ocr_events:${claim.session_token || claim.session_id}`,
|
||||
message: JSON.stringify(event),
|
||||
|
||||
// Передаём дальше для следующих нод
|
||||
claim_id: event.claim_id,
|
||||
session_token: claim.session_token || claim.session_id,
|
||||
cf_2624: cf_2624
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Вариант 2: Прямо в ноде публикации (HTTP Request или Redis Publish)
|
||||
|
||||
**Если используется HTTP Request:**
|
||||
|
||||
**URL:** `{{ $env.BACKEND_URL }}/api/v1/events/{{ $json.session_token }}`
|
||||
|
||||
**Body (JSON):**
|
||||
```json
|
||||
{
|
||||
"event_type": "ocr_status",
|
||||
"status": "ready",
|
||||
"message": "Заявление сформировано",
|
||||
"data": {
|
||||
"claim_id": "{{ $json.claim_id }}",
|
||||
"cf_2624": "{{ $json.cf_2624 || '0' }}"
|
||||
},
|
||||
"timestamp": "{{ $now.toISO() }}"
|
||||
}
|
||||
```
|
||||
|
||||
**Если используется Redis Publish:**
|
||||
|
||||
**Channel:** `ocr_events:{{ $json.session_token }}`
|
||||
|
||||
**Message:**
|
||||
```javascript
|
||||
={{ JSON.stringify({
|
||||
event_type: 'ocr_status',
|
||||
status: 'ready',
|
||||
claim_id: $json.claim_id,
|
||||
message: 'Заявление сформировано',
|
||||
timestamp: new Date().toISOString(),
|
||||
cf_2624: $json.cf_2624 || '0'
|
||||
}) }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Порядок нод в workflow
|
||||
|
||||
1. **CreateWebContacКлиентправ** → получаем `contact_id` и `cf_2624`
|
||||
2. **PostgreSQL: Get Contact Data** (опционально) → получаем полные данные контакта включая `cf_2624`
|
||||
3. **claimsave** (PostgreSQL) → сохраняем черновик
|
||||
4. **Code: Prepare OCR Status Event** → формируем событие с `cf_2624`
|
||||
5. **HTTP Request** или **Redis Publish** → публикуем событие в `ocr_events:{session_id}`
|
||||
|
||||
---
|
||||
|
||||
## Сохранение в черновик
|
||||
|
||||
Событие с `cf_2624` будет:
|
||||
1. ✅ Публиковаться в Redis канал `ocr_events:{session_id}`
|
||||
2. ✅ Обрабатываться backend'ом (загружает `form_draft` из PostgreSQL)
|
||||
3. ⏳ **Нужно добавить:** Сохранение `cf_2624` в черновик при обработке события
|
||||
|
||||
### Обновление backend для сохранения cf_2624
|
||||
|
||||
В файле `ticket_form/backend/app/api/events.py` (строка 218-267):
|
||||
|
||||
После загрузки `form_draft` из PostgreSQL, если в событии есть `cf_2624`, нужно сохранить его в черновик:
|
||||
|
||||
```python
|
||||
# ✅ Обработка ocr_status ready: загружаем form_draft из PostgreSQL
|
||||
if actual_event.get('event_type') == 'ocr_status' and actual_event.get('status') == 'ready':
|
||||
claim_id = actual_event.get('claim_id') or actual_event.get('data', {}).get('claim_id')
|
||||
cf_2624 = actual_event.get('cf_2624') # ✅ Получаем cf_2624 из события
|
||||
|
||||
if claim_id:
|
||||
# ... существующий код загрузки form_draft ...
|
||||
|
||||
# ✅ Если есть cf_2624 в событии - сохраняем в черновик
|
||||
if cf_2624:
|
||||
try:
|
||||
update_query = """
|
||||
UPDATE clpr_claims
|
||||
SET payload = jsonb_set(
|
||||
payload,
|
||||
'{cf_2624}',
|
||||
$1::jsonb
|
||||
)
|
||||
WHERE id::text = $2
|
||||
RETURNING id;
|
||||
"""
|
||||
await db.execute(update_query, json.dumps(cf_2624), claim_id)
|
||||
logger.info(f"✅ Сохранён cf_2624={cf_2624} в черновик claim_id={claim_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Не удалось сохранить cf_2624: {e}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Проверка
|
||||
|
||||
1. ✅ Событие публикуется в `ocr_events:{session_id}` с `cf_2624`
|
||||
2. ⏳ Backend обрабатывает событие и сохраняет `cf_2624` в черновик
|
||||
3. ⏳ При загрузке черновика `cf_2624` доступен в `payload.cf_2624`
|
||||
|
||||
---
|
||||
|
||||
## Пример полного события
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "ocr_status",
|
||||
"status": "ready",
|
||||
"claim_id": "ef853bac-f54b-46aa-adf8-f0c9c0cd76bc",
|
||||
"message": "Заявление сформировано",
|
||||
"timestamp": "2025-12-03T12:44:12.347Z",
|
||||
"cf_2624": "0"
|
||||
}
|
||||
```
|
||||
|
||||
Это событие будет:
|
||||
- ✅ Публиковаться в Redis канал `ocr_events:sess_5fc7cdd1-a848-4e92-aed4-3ee4bfb19b4c`
|
||||
- ✅ Обрабатываться backend'ом
|
||||
- ✅ Сохраняться в черновик в поле `payload.cf_2624`
|
||||
|
||||
|
||||
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
|
||||
44
docs/N8N_CODE_CHECK_CONTACT_DATA_CONFIRMED.js
Normal file
44
docs/N8N_CODE_CHECK_CONTACT_DATA_CONFIRMED.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// ============================================================================
|
||||
// Code Node для n8n: Проверка подтверждения данных контакта
|
||||
// ============================================================================
|
||||
// Назначение: Проверить, подтверждены ли данные контакта пользователя
|
||||
// и нужно ли блокировать редактирование
|
||||
//
|
||||
// Использование: После получения unified_id, перед загрузкой данных формы
|
||||
// ============================================================================
|
||||
|
||||
// Получаем unified_id из предыдущих шагов
|
||||
const unified_id = $('user_get').first().json.unified_id ||
|
||||
$('Edit Fields').first().json.unified_id ||
|
||||
$json.unified_id;
|
||||
|
||||
if (!unified_id) {
|
||||
throw new Error('unified_id не найден');
|
||||
}
|
||||
|
||||
// Выполняем SQL запрос для проверки статуса
|
||||
// (это должно быть в PostgreSQL ноде, но для примера показываю логику)
|
||||
|
||||
// SQL запрос:
|
||||
// SELECT * FROM clpr_get_contact_data_status($1);
|
||||
// Параметр: unified_id
|
||||
|
||||
// Ожидаемый результат:
|
||||
// {
|
||||
// is_confirmed: true/false,
|
||||
// confirmed_at: "2025-12-02T14:30:00Z" или null,
|
||||
// can_edit: true/false
|
||||
// }
|
||||
|
||||
// Для Code Node (если нужно обработать результат):
|
||||
const status = $('PostgreSQL Check Status').first().json; // Предполагаем, что есть такая нода
|
||||
|
||||
return {
|
||||
unified_id: unified_id,
|
||||
is_confirmed: status.is_confirmed || false,
|
||||
confirmed_at: status.confirmed_at || null,
|
||||
can_edit: status.can_edit !== false, // По умолчанию можно редактировать
|
||||
// Флаг для фронтенда
|
||||
lock_editing: status.is_confirmed || false
|
||||
};
|
||||
|
||||
264
docs/N8N_CODE_IN_JAVASCRIPT_КЛИЕНТПРАВ_FULL.js
Normal file
264
docs/N8N_CODE_IN_JAVASCRIPT_КЛИЕНТПРАВ_FULL.js
Normal file
@@ -0,0 +1,264 @@
|
||||
// ========================================
|
||||
// Code Node: Code in JavaScriptКлиентправ
|
||||
// Формирование Response для фронтенда с поддержкой cf_2624
|
||||
// ========================================
|
||||
|
||||
// --- 1. Генерация UUIDv4 ---
|
||||
function generateUUIDv4() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : ((r & 0x3) | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
// --- 2. Парсим контакт из CreateWebContacКлиентправ ---
|
||||
const createWebContactNode = $node["CreateWebContacКлиентправ"] || $node["CreateWebContact"];
|
||||
const rawResult = createWebContactNode?.json?.result || "";
|
||||
|
||||
let contactData = {};
|
||||
try {
|
||||
contactData = typeof rawResult === 'string'
|
||||
? JSON.parse(rawResult)
|
||||
: rawResult;
|
||||
} catch (e) {
|
||||
console.error('❌ Ошибка парсинга CreateWebContact:', e);
|
||||
contactData = {};
|
||||
}
|
||||
|
||||
// ✅ Извлекаем cf_2624 (Данные подтверждены) из CreateWebContact
|
||||
// "1" = данные подтверждены, "0" = не подтверждены
|
||||
const cf_2624 = contactData.cf_2624 || "0";
|
||||
const contact_data_confirmed = cf_2624 === "1" || cf_2624 === "true" || cf_2624 === true;
|
||||
const contact_data_can_edit = !contact_data_confirmed;
|
||||
|
||||
console.log('🔒 Статус данных контакта из CreateWebContact:', {
|
||||
contact_id: contactData.contact_id,
|
||||
is_new: contactData.is_new,
|
||||
cf_2624: cf_2624,
|
||||
contact_data_confirmed: contact_data_confirmed,
|
||||
contact_data_can_edit: contact_data_can_edit
|
||||
});
|
||||
|
||||
// --- 2.1. Получаем полные данные контакта из PostgreSQL (если есть) ---
|
||||
let contactFromDB = null;
|
||||
try {
|
||||
// Пытаемся найти ноду PostgreSQL, которая получила данные контакта
|
||||
const possiblePostgresNodes = [
|
||||
'PostgreSQL: Get Contact Data',
|
||||
'Get Contact from DB',
|
||||
'PostgreSQL',
|
||||
'Get Contact Details'
|
||||
];
|
||||
|
||||
for (const nodeName of possiblePostgresNodes) {
|
||||
try {
|
||||
const node = $(nodeName)?.first();
|
||||
if (node && node.json) {
|
||||
// Проверяем, что это данные контакта (есть contactid)
|
||||
if (node.json.contactid || node.json.contact_id) {
|
||||
contactFromDB = node.json;
|
||||
console.log('✅ Получены данные контакта из PostgreSQL:', {
|
||||
contactid: contactFromDB.contactid || contactFromDB.contact_id,
|
||||
firstname: contactFromDB.firstname,
|
||||
lastname: contactFromDB.lastname
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Альтернативный способ: ищем по структуре данных
|
||||
if (!contactFromDB) {
|
||||
// Может быть в предыдущей ноде с результатом запроса
|
||||
const inputData = $input.all();
|
||||
for (const item of inputData) {
|
||||
if (item.json && (item.json.contactid || item.json.contact_id)) {
|
||||
contactFromDB = item.json;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Не удалось получить данные контакта из PostgreSQL:', e.message);
|
||||
}
|
||||
|
||||
// Если данные из БД получены - используем их для дополнения информации
|
||||
if (contactFromDB) {
|
||||
console.log('📋 Данные контакта из БД:', {
|
||||
contactid: contactFromDB.contactid,
|
||||
firstname: contactFromDB.firstname,
|
||||
lastname: contactFromDB.lastname,
|
||||
email: contactFromDB.email,
|
||||
mobile: contactFromDB.mobile,
|
||||
birthday: contactFromDB.birthday,
|
||||
mailingstreet: contactFromDB.mailingstreet,
|
||||
middle_name: contactFromDB.middle_name,
|
||||
birthplace: contactFromDB.birthplace,
|
||||
inn: contactFromDB.inn
|
||||
});
|
||||
}
|
||||
|
||||
// --- 3. Телефон из Edit Fields ---
|
||||
let phone = null;
|
||||
try {
|
||||
const editFields = $('Edit Fields')?.first();
|
||||
if (editFields && editFields.json) {
|
||||
phone = editFields.json.phone;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Не удалось получить phone из Edit Fields:', e.message);
|
||||
}
|
||||
|
||||
// --- 4. unified_id из user_get ---
|
||||
let unified_id = null;
|
||||
try {
|
||||
const possibleUserNodes = ['user_get', 'Find or Create User', 'PostgreSQL: Find User'];
|
||||
for (const nodeName of possibleUserNodes) {
|
||||
try {
|
||||
const node = $node[nodeName];
|
||||
if (node && node.json && node.json.unified_id) {
|
||||
unified_id = node.json.unified_id;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// Нода не существует или не выполнена - продолжаем поиск
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!unified_id) {
|
||||
console.warn('⚠️ unified_id не получен из ноды user_get. Проверьте, что нода выполнена.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Не удалось получить unified_id:', e.message);
|
||||
}
|
||||
|
||||
// --- 5. Генерируем session_id (если не получен из предыдущих нод) ---
|
||||
let session_id = null;
|
||||
|
||||
// Пытаемся получить session_id из предыдущих нод
|
||||
try {
|
||||
const possibleSessionNodes = [
|
||||
'Code in JavaScript1',
|
||||
'Code in JavaScript',
|
||||
'Set Session Data',
|
||||
'Create Session'
|
||||
];
|
||||
|
||||
for (const nodeName of possibleSessionNodes) {
|
||||
try {
|
||||
const node = $(nodeName)?.first();
|
||||
if (node && node.json) {
|
||||
if (node.json.session_id) {
|
||||
session_id = node.json.session_id;
|
||||
break;
|
||||
} else if (node.json.redis_value) {
|
||||
const parsed = JSON.parse(node.json.redis_value);
|
||||
if (parsed.session_id) {
|
||||
session_id = parsed.session_id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Пытаемся получить из Edit Fields
|
||||
if (!session_id) {
|
||||
try {
|
||||
const editFields = $('Edit Fields')?.first();
|
||||
if (editFields && editFields.json && editFields.json.session_id) {
|
||||
session_id = editFields.json.session_id;
|
||||
}
|
||||
} catch (e) {
|
||||
// Игнорируем
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Не удалось получить session_id из предыдущих нод:', e.message);
|
||||
}
|
||||
|
||||
// Если session_id не найден - генерируем новый
|
||||
if (!session_id) {
|
||||
session_id = 'sess_' + generateUUIDv4();
|
||||
console.log('✅ Сгенерирован новый session_id:', session_id);
|
||||
}
|
||||
|
||||
// --- 6. Формируем sessionData для Redis ---
|
||||
const sessionData = {
|
||||
session_id, // ← теперь сохраняем внутрь
|
||||
unified_id,
|
||||
contact_id: contactData.contact_id,
|
||||
phone,
|
||||
is_new_contact: contactData.is_new || contactData.is_new_contact || false,
|
||||
// ✅ Флаги подтверждения данных контакта (из cf_2624)
|
||||
cf_2624: cf_2624,
|
||||
contact_data_confirmed: contact_data_confirmed,
|
||||
contact_data_can_edit: contact_data_can_edit,
|
||||
// ✅ Данные контакта из PostgreSQL (если получены)
|
||||
contact_from_db: contactFromDB ? {
|
||||
contactid: contactFromDB.contactid || contactFromDB.contact_id,
|
||||
firstname: contactFromDB.firstname,
|
||||
lastname: contactFromDB.lastname,
|
||||
email: contactFromDB.email,
|
||||
mobile: contactFromDB.mobile,
|
||||
phone: contactFromDB.phone,
|
||||
birthday: contactFromDB.birthday,
|
||||
mailingstreet: contactFromDB.mailingstreet,
|
||||
mailingcity: contactFromDB.mailingcity,
|
||||
mailingstate: contactFromDB.mailingstate,
|
||||
mailingzip: contactFromDB.mailingzip,
|
||||
mailingcountry: contactFromDB.mailingcountry,
|
||||
middle_name: contactFromDB.middle_name,
|
||||
birthplace: contactFromDB.birthplace,
|
||||
inn: contactFromDB.inn,
|
||||
requisites: contactFromDB.requisites,
|
||||
code: contactFromDB.code,
|
||||
sms: contactFromDB.sms
|
||||
} : null,
|
||||
status: "draft",
|
||||
current_step: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
documents: {},
|
||||
email: contactFromDB?.email || null,
|
||||
bank_name: null
|
||||
};
|
||||
|
||||
// --- 7. Возвращаем результат в формате items ---
|
||||
const result = {
|
||||
json: {
|
||||
session: session_id,
|
||||
session_id,
|
||||
unified_id,
|
||||
contact_id: contactData.contact_id,
|
||||
is_new_contact: contactData.is_new || contactData.is_new_contact || false,
|
||||
phone,
|
||||
// ✅ Флаги подтверждения данных контакта (из cf_2624)
|
||||
cf_2624: cf_2624,
|
||||
contact_data_confirmed: contact_data_confirmed,
|
||||
contact_data_can_edit: contact_data_can_edit,
|
||||
redis_key: `session:${session_id}`,
|
||||
redis_value: JSON.stringify(sessionData),
|
||||
ttl: 604800
|
||||
}
|
||||
};
|
||||
|
||||
// Логируем финальный ответ для отладки
|
||||
console.log('✅ Сформирован ответ для фронтенда:', {
|
||||
session_id: result.json.session_id,
|
||||
has_unified_id: !!result.json.unified_id,
|
||||
has_contact_id: !!result.json.contact_id,
|
||||
contact_data_confirmed: result.json.contact_data_confirmed,
|
||||
cf_2624: result.json.cf_2624,
|
||||
is_new_contact: result.json.is_new_contact
|
||||
});
|
||||
|
||||
return [result];
|
||||
|
||||
113
docs/N8N_CODE_PREPARE_DOCUMENT_SKIP_SQL.js
Normal file
113
docs/N8N_CODE_PREPARE_DOCUMENT_SKIP_SQL.js
Normal file
@@ -0,0 +1,113 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: Подготовка параметров для SQL при пропуске документа
|
||||
// ============================================================================
|
||||
// Входные данные: массив с объектом [{ propertyName: {...}, body: {...} }]
|
||||
// Выходные данные: { $1: jsonb_payload, $2: claim_id_string }
|
||||
// ============================================================================
|
||||
|
||||
// Получаем входные данные
|
||||
const inputData = $input.all();
|
||||
|
||||
if (!inputData || inputData.length === 0) {
|
||||
return [{
|
||||
json: {
|
||||
error: "Нет входных данных",
|
||||
$1: null,
|
||||
$2: null
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
// Берём первый элемент
|
||||
// Если это массив - берём первый элемент массива
|
||||
// Если это объект - используем его напрямую
|
||||
let firstItem = inputData[0].json;
|
||||
|
||||
if (Array.isArray(firstItem)) {
|
||||
firstItem = firstItem[0];
|
||||
}
|
||||
|
||||
// Извлекаем данные
|
||||
const propertyName = firstItem.propertyName || {};
|
||||
const body = firstItem.body || {};
|
||||
|
||||
// Извлекаем claim_id (приоритет: body -> propertyName)
|
||||
const claim_id = body.claim_id || propertyName.claim_id || null;
|
||||
|
||||
if (!claim_id) {
|
||||
return [{
|
||||
json: {
|
||||
error: "claim_id не найден",
|
||||
$1: null,
|
||||
$2: null,
|
||||
debug: {
|
||||
body_keys: Object.keys(body),
|
||||
propertyName_keys: Object.keys(propertyName)
|
||||
}
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
// Формируем payload для $1 (jsonb)
|
||||
// SQL ищет данные в разных местах: p->>'document_type', p->'body'->>'document_type', p->'edit_fields_raw'->'body'->>'document_type'
|
||||
const payload = {
|
||||
// ✅ Основные идентификаторы (в корне для быстрого доступа)
|
||||
session_id: body.session_id || propertyName.session_id,
|
||||
claim_id: claim_id,
|
||||
unified_id: body.unified_id || propertyName.unified_id,
|
||||
contact_id: body.contact_id || propertyName.contact_id,
|
||||
phone: body.phone || propertyName.phone,
|
||||
|
||||
// ✅ Информация о пропущенном документе (в корне для быстрого доступа)
|
||||
document_type: body.document_type,
|
||||
document_name: body.document_name || body.document_type,
|
||||
group_index: body.group_index ? parseInt(body.group_index) : (body.group_index || null),
|
||||
|
||||
// ✅ Метаданные пропуска
|
||||
skipped: body.skipped,
|
||||
action: body.action,
|
||||
skip_timestamp: body.skip_timestamp || new Date().toISOString(),
|
||||
|
||||
// ✅ Данные из propertyName (для сохранения в payload)
|
||||
problem_description: propertyName.description || propertyName.problem_description,
|
||||
email: propertyName.email,
|
||||
|
||||
// ✅ Данные из body (для совместимости)
|
||||
form_id: body.form_id,
|
||||
stage: body.stage,
|
||||
client_ip: body.client_ip,
|
||||
|
||||
// ✅ Поля для совместимости с существующим SQL (SQL ищет данные здесь)
|
||||
body: {
|
||||
document_type: body.document_type,
|
||||
document_name: body.document_name || body.document_type,
|
||||
group_index: body.group_index ? parseInt(body.group_index) : (body.group_index || null),
|
||||
session_id: body.session_id,
|
||||
claim_id: claim_id,
|
||||
unified_id: body.unified_id,
|
||||
contact_id: body.contact_id,
|
||||
phone: body.phone
|
||||
},
|
||||
edit_fields_raw: {
|
||||
propertyName: propertyName,
|
||||
body: body
|
||||
},
|
||||
edit_fields_parsed: {
|
||||
propertyName: propertyName,
|
||||
body: body
|
||||
}
|
||||
};
|
||||
|
||||
// Возвращаем параметры для SQL
|
||||
return [{
|
||||
json: {
|
||||
$1: payload, // JSONB payload для SQL (будет передан как $1::jsonb)
|
||||
$2: claim_id, // TEXT claim_id для SQL (будет передан как $2::text)
|
||||
// Дополнительные поля для отладки
|
||||
claim_id: claim_id,
|
||||
document_type: body.document_type,
|
||||
document_name: body.document_name,
|
||||
group_index: body.group_index
|
||||
}
|
||||
}];
|
||||
|
||||
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
|
||||
}
|
||||
}];
|
||||
160
docs/N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js
Normal file
160
docs/N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js
Normal file
@@ -0,0 +1,160 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: Обработка загруженных файлов (ИСПРАВЛЕННАЯ ВЕРСИЯ)
|
||||
// ============================================================================
|
||||
// OCR возвращает объединённые документы: один файл на группу (group_index)
|
||||
// Структура: { data: [{ group_index_num: 0, files_count: 2, newfile: "...", ... }] }
|
||||
// Решение: обрабатываем каждый элемент из data как объединённый документ
|
||||
// ============================================================================
|
||||
|
||||
// ==== INPUT SHAPE SUPPORT ====
|
||||
// OCR возвращает: { data: [ ...объединённые документы... ] }
|
||||
const raw = $json;
|
||||
const items = Array.isArray(raw?.data) ? raw.data : (Array.isArray(raw) ? raw : []);
|
||||
|
||||
if (!items.length) {
|
||||
return [{
|
||||
json: {
|
||||
claim_id: null,
|
||||
payload_partial_json: { documents_meta: [], edit_fields_raw: null, edit_fields_parsed: null },
|
||||
filesRows: []
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
// ==== CLAIM_ID DISCOVERY ====
|
||||
let claim_id = $json.claim_id
|
||||
|| $items('Edit Fields6')?.[0]?.json?.propertyName?.case_id
|
||||
|| $('Edit Fields6').first().json.body.claim_id
|
||||
|| null;
|
||||
|
||||
// ==== UTILS ====
|
||||
const safeStr = (v) => (v == null ? '' : String(v));
|
||||
const nowIso = new Date().toISOString();
|
||||
const tryParseJSON = (x) => {
|
||||
if (x == null) return null;
|
||||
if (typeof x === 'object') return x;
|
||||
if (typeof x === 'string') { try { return JSON.parse(x); } catch { return null; } }
|
||||
return null;
|
||||
};
|
||||
|
||||
// ==== ПРЕДВАРИТЕЛЬНО СОБИРАЕМ uploads_field_labels ИЗ BODY ====
|
||||
const editRaw = $items('Edit Fields6')?.[0]?.json || null;
|
||||
const body = editRaw?.body || null;
|
||||
|
||||
let uploads_descriptions = [];
|
||||
let uploads_field_names = [];
|
||||
let uploads_field_labels = [];
|
||||
|
||||
if (body && typeof body === 'object') {
|
||||
const d = [];
|
||||
const f = [];
|
||||
const l = [];
|
||||
for (const k of Object.keys(body)) {
|
||||
const mD = k.match(/^uploads_descriptions\[(\d+)\]$/);
|
||||
const mF = k.match(/^uploads_field_names\[(\d+)\]$/);
|
||||
const mL = k.match(/^uploads_field_labels\[(\d+)\]$/);
|
||||
if (mD) d[Number(mD[1])] = safeStr(body[k]);
|
||||
if (mF) f[Number(mF[1])] = safeStr(body[k]);
|
||||
if (mL) l[Number(mL[1])] = safeStr(body[k]);
|
||||
}
|
||||
uploads_descriptions = d.filter(v => v !== undefined);
|
||||
uploads_field_names = f.filter(v => v !== undefined);
|
||||
uploads_field_labels = l.filter(v => v !== undefined);
|
||||
}
|
||||
|
||||
// ==== BUILD documents_meta + filesRows ====
|
||||
// OCR возвращает объединённые документы: один файл на group_index
|
||||
// Каждый элемент из data - это уже объединённый PDF (может содержать несколько страниц)
|
||||
const documents_meta = [];
|
||||
const filesRows = [];
|
||||
|
||||
for (const it of items) {
|
||||
// ✅ ПРИОРИТЕТ: Используем group_index из body (переданный с фронтенда)
|
||||
// Если его нет - используем group_index_num из OCR
|
||||
// Если и его нет - пытаемся определить по document_type из uploads_field_names
|
||||
let grp = null;
|
||||
|
||||
if (body && body.group_index !== undefined && body.group_index !== null) {
|
||||
grp = Number(body.group_index);
|
||||
} else if (it.group_index_num !== undefined && it.group_index_num !== null) {
|
||||
grp = Number(it.group_index_num);
|
||||
} else {
|
||||
// Fallback: пытаемся определить по document_type
|
||||
const doc_type = uploads_field_names[0] || uploads_field_labels[0] || '';
|
||||
// Ищем индекс в documents_required по типу документа
|
||||
// Это не идеально, но лучше чем всегда 0
|
||||
grp = 0; // По умолчанию 0, если не можем определить
|
||||
}
|
||||
|
||||
grp = grp || 0;
|
||||
const file_index = 0; // После объединения всегда один файл на группу
|
||||
|
||||
const field_name = `uploads[${grp}][${file_index}]`;
|
||||
|
||||
// ✅ ИСПРАВЛЕНО: uploads_field_labels содержит элементы с индексом 0 (текущий запрос),
|
||||
// а grp - это позиция в documents_required. Используем индекс 0 для массивов текущего запроса.
|
||||
const field_label = uploads_field_labels[0] || uploads_field_names[0] || uploads_descriptions[0] || `group-${grp}`;
|
||||
|
||||
// OCR уже объединил файлы, используем newfile (путь к объединённому файлу)
|
||||
const draft_key = safeStr(it.newfile || (it.folder && it.file_name ? `${it.folder}/${it.file_name}` : ''));
|
||||
const original_name = safeStr(it.file_name || `group_${grp}.pdf`);
|
||||
const description = safeStr(it.description || uploads_descriptions[0] || '');
|
||||
const prefix = safeStr(it.prefix || '');
|
||||
|
||||
// files_count показывает, сколько исходных файлов было объединено
|
||||
const files_count = Number(it.files_count) || 1;
|
||||
const pages = Number(it.pages) || null;
|
||||
|
||||
documents_meta.push({
|
||||
field_name,
|
||||
field_label,
|
||||
file_id: draft_key,
|
||||
file_name: original_name,
|
||||
original_file_name: original_name,
|
||||
uploaded_at: nowIso,
|
||||
files_count, // Информация: сколько файлов было объединено
|
||||
pages, // Информация: сколько страниц в объединённом PDF
|
||||
});
|
||||
|
||||
filesRows.push({
|
||||
claim_id,
|
||||
group_index: grp,
|
||||
file_index, // Всегда 0 для объединённого документа
|
||||
original_name,
|
||||
draft_key,
|
||||
mime: 'application/pdf',
|
||||
size_bytes: null,
|
||||
description,
|
||||
prefix,
|
||||
field_name,
|
||||
field_label,
|
||||
files_count, // Информация для отладки
|
||||
pages, // Информация для отладки
|
||||
});
|
||||
}
|
||||
|
||||
// ==== ПОДТЯГИВАЕМ ВСЁ ИЗ "Edit Fields" ====
|
||||
const propertyName = editRaw?.propertyName || null;
|
||||
const answers_parsed = body ? (tryParseJSON(body.answers) || null) : null;
|
||||
const wizard_plan_parsed = body ? (tryParseJSON(body.wizard_plan) || null) : null;
|
||||
|
||||
// ==== OUTPUT ====
|
||||
return [{
|
||||
json: {
|
||||
claim_id,
|
||||
payload_partial_json: {
|
||||
documents_meta,
|
||||
edit_fields_raw: editRaw || null,
|
||||
edit_fields_parsed: {
|
||||
propertyName,
|
||||
body,
|
||||
uploads_descriptions,
|
||||
uploads_field_names,
|
||||
uploads_field_labels,
|
||||
answers_parsed,
|
||||
wizard_plan_parsed,
|
||||
}
|
||||
},
|
||||
filesRows
|
||||
}
|
||||
}];
|
||||
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 } }];
|
||||
115
docs/N8N_CODE_PUSH_DOCUMENTS_LIST.js
Normal file
115
docs/N8N_CODE_PUSH_DOCUMENTS_LIST.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// ============================================================================
|
||||
// n8n Code Node: Пуш списка документов в Redis
|
||||
// ============================================================================
|
||||
// Расположение в workflow:
|
||||
// Redis Trigger (ticket_form:description)
|
||||
// → AI Agent (анализ проблемы)
|
||||
// → PostgreSQL (SQL_SAVE_DRAFT_NEW_FLOW.sql)
|
||||
// → [ЭТОТ CODE NODE]
|
||||
// → Redis Publish
|
||||
// ============================================================================
|
||||
|
||||
// Получаем результат из PostgreSQL
|
||||
const sqlResult = $input.first().json;
|
||||
|
||||
// claim содержит результат SQL запроса
|
||||
const claim = sqlResult.claim || sqlResult;
|
||||
|
||||
// Валидация
|
||||
if (!claim.session_token) {
|
||||
throw new Error('Нет session_token в результате SQL');
|
||||
}
|
||||
|
||||
if (!claim.documents_required || claim.documents_required.length === 0) {
|
||||
console.log('⚠️ Список документов пуст, но продолжаем');
|
||||
}
|
||||
|
||||
// Формируем событие для Redis
|
||||
const event = {
|
||||
event_type: 'documents_list_ready',
|
||||
status: 'ready',
|
||||
|
||||
// Идентификаторы
|
||||
claim_id: claim.claim_id,
|
||||
session_id: claim.session_token,
|
||||
|
||||
// ✅ Список документов для фронтенда
|
||||
documents_required: claim.documents_required || [],
|
||||
documents_count: claim.documents_count || 0,
|
||||
|
||||
// Метаданные
|
||||
timestamp: new Date().toISOString(),
|
||||
message: 'Список необходимых документов готов'
|
||||
};
|
||||
|
||||
// Логируем для отладки
|
||||
console.log('📤 Публикуем событие documents_list_ready:', {
|
||||
channel: `ocr_events:${claim.session_token}`,
|
||||
documents_count: event.documents_count,
|
||||
claim_id: event.claim_id
|
||||
});
|
||||
|
||||
// Возвращаем для Redis Publish node
|
||||
return {
|
||||
json: {
|
||||
// Канал Redis (ocr_events:{session_id})
|
||||
channel: `ocr_events:${claim.session_token}`,
|
||||
|
||||
// Данные события (будут JSON.stringify в Redis node)
|
||||
message: JSON.stringify(event),
|
||||
|
||||
// Дополнительно передаём для следующих нод
|
||||
claim_id: claim.claim_id,
|
||||
session_token: claim.session_token,
|
||||
documents_required: claim.documents_required
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Пример структуры documents_required:
|
||||
// ============================================================================
|
||||
// [
|
||||
// {
|
||||
// "id": "contract",
|
||||
// "name": "Договор или заказ",
|
||||
// "required": false,
|
||||
// "priority": 1,
|
||||
// "accept": ["pdf", "jpg", "png"],
|
||||
// "hints": "Поскольку договор не выслан, можно приложить публичную оферту"
|
||||
// },
|
||||
// {
|
||||
// "id": "payment",
|
||||
// "name": "Чек или подтверждение оплаты",
|
||||
// "required": false,
|
||||
// "priority": 1,
|
||||
// "accept": ["pdf", "jpg", "png"],
|
||||
// "hints": "Копия квитанции, чека или банковской выписки"
|
||||
// },
|
||||
// {
|
||||
// "id": "correspondence",
|
||||
// "name": "Переписка",
|
||||
// "required": true, // ⚠️ КРИТИЧНЫЙ документ
|
||||
// "priority": 2,
|
||||
// "accept": ["pdf", "jpg", "png"],
|
||||
// "hints": "Скриншоты переписки с организацией, претензии"
|
||||
// }
|
||||
// ]
|
||||
// ============================================================================
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Настройка Redis Publish node (следующая нода):
|
||||
// ============================================================================
|
||||
//
|
||||
// Operation: Publish
|
||||
// Channel: {{ $json.channel }}
|
||||
// Message: {{ $json.message }}
|
||||
//
|
||||
// Или через Execute Command:
|
||||
// Command: PUBLISH
|
||||
// Arguments:
|
||||
// - {{ $json.channel }}
|
||||
// - {{ $json.message }}
|
||||
// ============================================================================
|
||||
|
||||
51
docs/N8N_CODE_SET_CONTACT_DATA_CONFIRMED.js
Normal file
51
docs/N8N_CODE_SET_CONTACT_DATA_CONFIRMED.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// ============================================================================
|
||||
// Code Node для n8n: Установка флага подтверждения данных
|
||||
// ============================================================================
|
||||
// Назначение: Установить флаг contact_data_confirmed_at после подтверждения формы
|
||||
//
|
||||
// Использование: После успешного сохранения данных в CRM через claim_confirmed
|
||||
// ============================================================================
|
||||
|
||||
// Получаем unified_id
|
||||
const unified_id = $('user_get').first().json.unified_id ||
|
||||
$json.unified_id;
|
||||
|
||||
if (!unified_id) {
|
||||
throw new Error('unified_id не найден для установки флага подтверждения');
|
||||
}
|
||||
|
||||
// Получаем contact_id из CRM (если есть)
|
||||
const contact_id = $node['CreateWebContacКлиентправ']?.json?.result?.contact_id ||
|
||||
$json.contact_id ||
|
||||
null;
|
||||
|
||||
// Проверяем, есть ли данные в CRM (для автоматического подтверждения)
|
||||
// Если contact_id > 0, значит данные уже есть в CRM - подтверждаем автоматически
|
||||
const has_crm_data = contact_id && parseInt(contact_id) > 0;
|
||||
|
||||
// Формируем данные для PostgreSQL
|
||||
return {
|
||||
unified_id: unified_id,
|
||||
contact_id: contact_id,
|
||||
has_crm_data: has_crm_data,
|
||||
// Флаг для SQL функции
|
||||
should_confirm: true, // Всегда подтверждаем после сохранения формы
|
||||
confirmed_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SQL запрос для PostgreSQL ноды (после этого Code Node):
|
||||
// ============================================================================
|
||||
// SELECT clpr_set_contact_data_confirmed($1, $2::timestamptz);
|
||||
//
|
||||
// Параметры:
|
||||
// $1 = {{ $json.unified_id }}
|
||||
// $2 = {{ $json.confirmed_at }}
|
||||
//
|
||||
// ИЛИ для автоматического подтверждения существующих данных:
|
||||
// SELECT clpr_auto_confirm_if_crm_has_data($1, $2::integer);
|
||||
//
|
||||
// Параметры:
|
||||
// $1 = {{ $json.unified_id }}
|
||||
// $2 = {{ $json.contact_id }}
|
||||
|
||||
150
docs/N8N_DESCRIPTION_WORKFLOW.md
Normal file
150
docs/N8N_DESCRIPTION_WORKFLOW.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Настройка n8n Workflow для обработки описания проблемы
|
||||
|
||||
## Проблема
|
||||
|
||||
После отправки описания проблемы форма "тупит" на шаге рекомендаций. Это происходит потому, что n8n не обрабатывает событие из Redis канала.
|
||||
|
||||
## Текущий поток данных
|
||||
|
||||
1. **Frontend** отправляет описание на `/api/v1/claims/description`
|
||||
2. **Backend** публикует событие в Redis канал `ticket_form:description`
|
||||
3. **Frontend** подписывается на SSE `/api/v1/events/{session_id}` (слушает канал `ocr_events:{session_id}`)
|
||||
4. **n8n** должен:
|
||||
- Подписаться на канал `ticket_form:description` (или получить событие из него)
|
||||
- Обработать описание и сгенерировать `wizard_plan`
|
||||
- Опубликовать `wizard_plan` в канал `ocr_events:{session_id}` через POST `/api/v1/events/{session_id}`
|
||||
|
||||
## Структура события в Redis канале `ticket_form:description`
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ticket_form_description",
|
||||
"session_id": "sess_xxx",
|
||||
"claim_id": "claim_id_xxx" или null,
|
||||
"phone": "79262306381",
|
||||
"email": "user@example.com",
|
||||
"description": "Описание проблемы...",
|
||||
"source": "ticket_form",
|
||||
"timestamp": "2025-11-25T12:30:36.262855"
|
||||
}
|
||||
```
|
||||
|
||||
## Настройка n8n Workflow
|
||||
|
||||
### Шаг 1: Redis Subscribe Node
|
||||
|
||||
1. Добавьте **Redis Subscribe** node
|
||||
2. Настройте подключение к Redis:
|
||||
- Host: `crm.clientright.ru` (или IP вашего Redis)
|
||||
- Port: `6379`
|
||||
- Password: `CRM_Redis_Pass_2025_Secure!`
|
||||
3. Channel: `ticket_form:description`
|
||||
4. Output: `JSON`
|
||||
|
||||
### Шаг 2: Обработка описания
|
||||
|
||||
После получения события из Redis:
|
||||
|
||||
1. Извлеките `session_id` из события: `{{ $json.session_id }}`
|
||||
2. Извлеките `description` из события: `{{ $json.description }}`
|
||||
3. Обработайте описание (AI, RAG и т.д.)
|
||||
4. Сгенерируйте `wizard_plan`
|
||||
|
||||
### Шаг 3: Сохранение wizard_plan в PostgreSQL
|
||||
|
||||
Сохраните `wizard_plan` в таблицу `clpr_claims` используя SQL скрипт (например, `SQL_CLAIMSAVE_UPSERT_SIMPLE.sql`).
|
||||
|
||||
### Шаг 4: Публикация wizard_plan обратно в Redis
|
||||
|
||||
**ВАЖНО:** После генерации `wizard_plan` нужно опубликовать событие обратно в Redis канал `ocr_events:{session_id}`.
|
||||
|
||||
Используйте **HTTP Request** node:
|
||||
|
||||
- **Method:** POST
|
||||
- **URL:** `http://147.45.146.17:8200/api/v1/events/{{ $json.session_id }}`
|
||||
- **Headers:**
|
||||
```json
|
||||
{
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
```
|
||||
- **Body (JSON):**
|
||||
```json
|
||||
{
|
||||
"event_type": "wizard_ready",
|
||||
"status": "ready",
|
||||
"message": "Wizard plan готов",
|
||||
"data": {
|
||||
"claim_id": "{{ $json.claim_id }}",
|
||||
"wizard_plan": {{ $json.wizard_plan }},
|
||||
"answers_prefill": {{ $json.answers_prefill }},
|
||||
"coverage_report": {{ $json.coverage_report }}
|
||||
},
|
||||
"timestamp": "{{ $now.toISO() }}"
|
||||
}
|
||||
```
|
||||
|
||||
**Альтернатива:** Используйте **Redis Publish** node напрямую:
|
||||
|
||||
- Channel: `ocr_events:{{ $json.session_id }}`
|
||||
- Message (JSON):
|
||||
```json
|
||||
{
|
||||
"event_type": "wizard_ready",
|
||||
"status": "ready",
|
||||
"message": "Wizard plan готов",
|
||||
"data": {
|
||||
"claim_id": "{{ $json.claim_id }}",
|
||||
"wizard_plan": {{ $json.wizard_plan }},
|
||||
"answers_prefill": {{ $json.answers_prefill }},
|
||||
"coverage_report": {{ $json.coverage_report }}
|
||||
},
|
||||
"timestamp": "{{ $now.toISO() }}"
|
||||
}
|
||||
```
|
||||
|
||||
## Проверка работы
|
||||
|
||||
1. Откройте консоль браузера (F12)
|
||||
2. Отправьте описание проблемы
|
||||
3. Проверьте логи backend:
|
||||
```bash
|
||||
docker-compose logs -f ticket_form_backend | grep -E "📝|📡|description"
|
||||
```
|
||||
4. Проверьте, что событие опубликовано в Redis:
|
||||
```bash
|
||||
redis-cli -h crm.clientright.ru -p 6379 -a "CRM_Redis_Pass_2025_Secure!" PUBSUB CHANNELS "ticket_form:*"
|
||||
```
|
||||
5. Проверьте, что n8n получил событие (в логах n8n workflow)
|
||||
6. Проверьте, что n8n опубликовал `wizard_plan` обратно в канал `ocr_events:{session_id}`
|
||||
|
||||
## Типичные проблемы
|
||||
|
||||
### Проблема 1: n8n не получает события из Redis
|
||||
|
||||
**Решение:** Проверьте, что Redis Subscribe node правильно настроен и подключен к правильному каналу `ticket_form:description`.
|
||||
|
||||
### Проблема 2: Frontend не получает wizard_plan
|
||||
|
||||
**Решение:** Проверьте, что n8n публикует событие в правильный канал `ocr_events:{session_id}` (не `ocr_events:session_id`, а `ocr_events:{session_id}` где `{session_id}` - это значение из события).
|
||||
|
||||
### Проблема 3: Неправильный формат события
|
||||
|
||||
**Решение:** Убедитесь, что событие содержит поле `event_type: "wizard_ready"` и `status: "ready"`. Backend ожидает этот формат.
|
||||
|
||||
## Пример полного workflow в n8n
|
||||
|
||||
```
|
||||
Redis Subscribe (ticket_form:description)
|
||||
↓
|
||||
Code Node (обработка описания)
|
||||
↓
|
||||
AI/RAG Node (генерация wizard_plan)
|
||||
↓
|
||||
PostgreSQL Node (сохранение wizard_plan)
|
||||
↓
|
||||
HTTP Request Node (POST /api/v1/events/{session_id})
|
||||
или
|
||||
Redis Publish Node (ocr_events:{session_id})
|
||||
```
|
||||
|
||||
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
120
docs/N8N_FORM_APPROVAL_WORKFLOW.md
Normal file
120
docs/N8N_FORM_APPROVAL_WORKFLOW.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Настройка n8n Workflow для обработки подтвержденных форм
|
||||
|
||||
## Описание
|
||||
|
||||
После того, как пользователь подтвердил форму и прошел SMS-верификацию, данные публикуются в Redis канал `clientright:webform:approve`. n8n workflow должен:
|
||||
|
||||
1. Подписаться на Redis канал `clientright:webform:approve`
|
||||
2. Обработать данные формы
|
||||
3. Отметить форму как подтвержденную в PostgreSQL (чтобы она больше не показывалась в черновиках)
|
||||
|
||||
## Структура данных в Redis канале
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "form_approve",
|
||||
"status": "approved",
|
||||
"message": "Форма подтверждена после SMS-верификации",
|
||||
"claim_id": "0eb051ec-23a6-4e06-8b98-f02d20d35f68",
|
||||
"session_token": "sess_xxx",
|
||||
"unified_id": "usr_xxx",
|
||||
"phone": "79262306381",
|
||||
"sms_code": "123456",
|
||||
"sms_verified": true,
|
||||
"idempotency_key": "claim_id_timestamp_user_id",
|
||||
"timestamp": "2025-11-25T12:30:36.262855",
|
||||
"form_data": { /* данные формы */ },
|
||||
"user": { /* данные пользователя */ },
|
||||
"project": { /* данные проекта */ },
|
||||
"offenders": [ /* нарушители */ ],
|
||||
"meta": { /* метаданные */ }
|
||||
}
|
||||
```
|
||||
|
||||
## Настройка n8n Workflow
|
||||
|
||||
### Шаг 1: Redis Subscribe Node
|
||||
|
||||
1. Добавьте **Redis Subscribe** node
|
||||
2. Настройте подключение к Redis:
|
||||
- Host: `crm.clientright.ru` (или IP вашего Redis)
|
||||
- Port: `6379`
|
||||
- Password: `CRM_Redis_Pass_2025_Secure!`
|
||||
3. Channel: `clientright:webform:approve`
|
||||
4. Output: `JSON`
|
||||
|
||||
### Шаг 2: Обработка данных
|
||||
|
||||
После получения данных из Redis канала:
|
||||
|
||||
1. **Parse JSON** (если нужно)
|
||||
2. **Обработайте данные формы** (сохранение в CRM, отправка уведомлений и т.д.)
|
||||
3. **Отметьте форму как подтвержденную** (см. Шаг 3)
|
||||
|
||||
### Шаг 3: Отметка формы как подтвержденной
|
||||
|
||||
Используйте **PostgreSQL** node с SQL скриптом из `SQL_MARK_FORM_APPROVED.sql`:
|
||||
|
||||
```sql
|
||||
-- Используйте claim_id из данных Redis события
|
||||
WITH claim_lookup AS (
|
||||
SELECT
|
||||
c.id,
|
||||
c.payload,
|
||||
c.status_code,
|
||||
c.is_confirmed
|
||||
FROM clpr_claims c
|
||||
WHERE c.id::text = '{{ $json.claim_id }}'::text
|
||||
OR c.payload->>'claim_id' = '{{ $json.claim_id }}'::text
|
||||
ORDER BY
|
||||
CASE WHEN c.id::text = '{{ $json.claim_id }}'::text THEN 1 ELSE 2 END,
|
||||
c.updated_at DESC
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE clpr_claims c
|
||||
SET
|
||||
status_code = 'approved',
|
||||
is_confirmed = true,
|
||||
updated_at = now()
|
||||
FROM claim_lookup cl
|
||||
WHERE c.id = cl.id
|
||||
RETURNING
|
||||
c.id,
|
||||
c.payload->>'claim_id' AS claim_id,
|
||||
c.status_code,
|
||||
c.is_confirmed,
|
||||
c.updated_at;
|
||||
```
|
||||
|
||||
**Параметры:**
|
||||
- `{{ $json.claim_id }}` - claim_id из данных Redis события
|
||||
|
||||
**Результат:**
|
||||
- Форма помечается как `status_code = 'approved'`
|
||||
- Устанавливается `is_confirmed = true`
|
||||
- Форма больше не будет показываться в списке черновиков (`/api/v1/claims/drafts/list`)
|
||||
|
||||
## Проверка работы
|
||||
|
||||
После обработки события в n8n:
|
||||
|
||||
1. Проверьте, что запись в `clpr_claims` обновлена:
|
||||
```sql
|
||||
SELECT id, status_code, is_confirmed, updated_at
|
||||
FROM clpr_claims
|
||||
WHERE payload->>'claim_id' = 'YOUR_CLAIM_ID';
|
||||
```
|
||||
|
||||
2. Проверьте, что форма не показывается в черновиках:
|
||||
```bash
|
||||
curl "http://localhost:8200/api/v1/claims/drafts/list?unified_id=YOUR_UNIFIED_ID"
|
||||
```
|
||||
|
||||
## Важные поля из Redis события
|
||||
|
||||
- `claim_id` - ID заявки (используется для обновления статуса)
|
||||
- `sms_code` - SMS код, использованный для верификации (для аудита)
|
||||
- `form_data` - данные формы подтверждения
|
||||
- `user`, `project`, `offenders` - структурированные данные формы
|
||||
- `idempotency_key` - ключ для защиты от дублей (для будущей интеграции с RabbitMQ)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user