Compare commits

...

32 Commits

Author SHA1 Message Date
Fedor
4b9665b27f config: N8N_PROJECT_FORM_PODROBNEE_WEBHOOK для деталей дела/проекта из CRM
- config.py: n8n_project_form_podrobnee_webhook (из .env)
- CHANGELOG_MINIAPP.md: описание переменной, эндпоинт пока не добавлен
2026-03-02 15:04:46 +03:00
Fedor
e630d03e67 Support chat mobile UX: fix keyboard overlap and improve composer.
Hide bottom navigation while typing and in support chat mode, adapt chat layout to visual viewport/keyboard insets, and enlarge the message composer so input remains visible and comfortable in TG/MAX mobile webviews.
2026-03-02 08:22:26 +03:00
Fedor
66a0065df8 Consultations, CRM dashboard, Back button in support and consultations
- Consultations: list from DraftsContext, ticket-detail webhook, response card
- Back button in bar on consultations and in support chat (miniapp:goBack)
- BottomBar: back enabled on /support; Support: goBack subscription
- n8n: CRM normalize (n8n_CODE_CRM_NORMALIZE), flatten data (n8n_CODE_FLATTEN_DATA)
- Dashboard: filter by category for CRM items, draft card width
- Backend: consultations.py, ticket-detail, n8n_ticket_form_podrobnee_webhook
- CHANGELOG_MINIAPP.md: section 2026-02-25
2026-03-01 10:49:38 +03:00
Fedor
c39b12630e Профиль: валидация, календарь, ИНН 12 цифр, email, DaData адреса, банки из BANK_IP, подсказка ИНН (ФНС)
- Backend: N8N_AUTH_WEBHOOK из env (fallback), банки из BANK_IP, эндпоинт
  /api/v1/profile/dadata/address для подсказок адресов (FORMA_DADATA_*).
- Config: bank_ip, bank_api_url, forma_dadata_api_key, forma_dadata_secret.
- Frontend Profile: DatePicker для даты рождения, ИНН 12 цифр + ссылка на ФНС,
  валидация email, чекбокс «Совпадает с адресом регистрации», AutoComplete
  адресов через DaData, Select банков из /api/v1/banks/nspk (bankId/bankName).

Подробности в CHANGELOG_PROFILE_VALIDATION.md.
2026-02-27 18:32:06 +03:00
Fedor
b5c31b43dd Banner: system banners zone carousel mobile layout remove Home from Profile 2026-02-27 15:56:40 +03:00
Fedor
f2e144e9ca 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 в логах.
2026-02-27 10:33:07 +03:00
Fedor
06b89d20e7 Support: маршрут /support на страницу чата поддержки
- App.tsx: добавлен импорт страницы Support и роутинг pathname === '/support' на компонент Support.
- При клике на иконку «Поддержка» в нижнем баре теперь открывается список обращений и чат, а не форма «Мои обращения».
2026-02-27 10:13:19 +03:00
Fedor
9c65b6a4ea 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; иначе профиль остаётся только для просмотра.
2026-02-27 08:34:27 +03:00
Fedor
62fc57f108 Auth: multibot TG MAX logging fix 500 2026-02-27 07:48:16 +03:00
Fedor
b3a7396d32 Support: chat, tickets list, SSE Postgres NOTIFY, read/unread 2026-02-25 23:18:45 +03:00
Fedor
d8fe0b605b Unified auth and sessions: POST /api/v1/auth, session by channel:id and token, need_contact fix, n8n parsing, TTL 24h 2026-02-24 16:17:59 +03:00
Fedor
6350f9015b Mini-app updates: UI TG MAX session nav logs 2026-02-23 11:31:52 +03:00
Fedor
4536210284 Draft detail and Back button 2026-02-21 22:08:30 +03:00
root
1887336aba docs: describe auth2 hello flow 2026-02-20 09:57:11 +03:00
root
8c3e993eb7 feat: add soft ui auth page 2026-02-20 09:31:13 +03:00
AI Assistant
a4cc4f9de6 docs: актуальное описание проекта AiForm 2026-02-19 10:36:41 +03:00
AI Assistant
2e45786e46 feat: Telegram Mini App integration and UX improvements
- Добавлена полная интеграция с Telegram Mini App (динамическая загрузка SDK)
- Отдельный компактный дизайн для Telegram Mini App
- Добавлен loader при инициализации (предотвращает мелькание SMS-авторизации)
- Улучшена навигация: кнопки "Назад" и "К списку заявок" теперь сохраняют авторизацию
- Telegram Mini App: кнопка "Выход" просто закрывает приложение
- Telegram Mini App: заявки "В работе" скрыты из списка
- Веб-версия: для заявок "В работе" добавлена кнопка "Просмотреть в Telegram" (ссылка на @klientprav_bot)
- Telegram Mini App: кнопки действий в черновиках расположены вертикально
- Веб-версия: убрано отображение номера телефона в приветствии
- Исправлена проблема с возвратом к списку черновиков (не требует повторной SMS-авторизации)
- Заблокировано удаление и редактирование заявок со статусом "В работе"
- Добавлена документация по Telegram Mini App интеграции
2026-01-29 16:12:48 +03:00
Fedor
73524465fd feat: Обновления после последнего коммита
Изменения в backend:
- Обновления в n8n_proxy.py
- Изменения в SMS API
- Обновления конфигурации
- Улучшения SMS сервиса

Изменения в frontend:
- Обновления Step1Phone компонента
- Изменения в Step3Payment
- Улучшения generateConfirmationFormHTML
- Обновления ClaimForm страницы
- Изменения в vite.config.ts

Статистика: +242 строки, -81 строка
2026-01-02 17:37:37 +03:00
Fedor
f7d27388a0 feat: Add SMS debug code modal for dev environment 2025-12-29 10:59:21 +03:00
Fedor
56516fdd7d Add docker-compose.dev.yml for dev environment (ports 5177, 8201) 2025-12-29 10:55:48 +03:00
AI Assistant
1a653f2154 docs: Move session log to root 2025-12-29 01:28:24 +03:00
AI Assistant
df8c93f46b Add session log 2025-12-29 2025-12-29 01:23:01 +03:00
AI Assistant
30774db18c Production fixes: n8n workflow auto-restart, user-friendly messages, fixed navigation buttons 2025-12-29 01:19:19 +03:00
AI Assistant
080e7ec105 feat: Получение cf_2624 из MySQL и блокировка полей при подтверждении данных
- Добавлен сервис CrmMySQLService для прямого подключения к MySQL CRM
- Обновлён метод get_draft() для получения cf_2624 напрямую из БД
- Реализована блокировка полей (readonly) при contact_data_confirmed = true
- Добавлен выбор банка для СБП выплат с динамической загрузкой из API
- Обновлена документация по работе с cf_2624 и MySQL
- Добавлен network_mode: host в docker-compose для доступа к MySQL
- Обновлены компоненты формы для поддержки блокировки полей
2025-12-04 12:22:23 +03:00
AI Assistant
64385c430d fix: Исправление логики загрузки документов и расчёта прогресса
- Исправлена ошибка порядка объявления allDocsProcessed (Cannot access before initialization)
- Исправлена логика поиска незагруженного документа: поиск с начала, если сохранённый индекс уже обработан
- Исправлен расчёт прогресса: теперь используется количество обработанных документов (uploadedDocs + skippedDocs), а не currentDocIndex
- Убрана синхронизация currentDocIndex из formData, которая перезаписывала правильный индекс
- Добавлена логика автоматического пропуска уже загруженных документов при открытии формы
- Добавлено подробное логирование для отладки состояния документов
- Исправлена логика определения завершённости: проверяется каждый документ из documentsRequired

Результат:
- Форма корректно показывает следующий незагруженный документ
- Прогресс правильно отображает процент обработанных документов (75% при 3 из 4)
- Система не требует повторной загрузки уже загруженных документов
2025-11-27 14:36:42 +03:00
AI Assistant
02689e65db fix: Исправление загрузки документов и SQL запросов
- Исправлена потеря документов при обновлении черновика (SQL объединяет вместо перезаписи)
- Исправлено определение типа документа (приоритет field_label над field_name)
- Исправлены дубликаты в documents_meta и documents_uploaded
- Добавлена передача group_index с фронтенда для правильного field_name
- Исправлены все документы в таблице clpr_claim_documents с правильными field_name
- Обновлены SQL запросы: claimsave и claimsave_final для нового флоу
- Добавлена поддержка multi-file upload для одного документа
- Исправлены дубликаты в списке загруженных документов на фронтенде

Файлы:
- SQL: SQL_CLAIMSAVE_FIXED_NEW_FLOW.sql, SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql
- n8n: N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js (поддержка group_index)
- Backend: documents.py (передача group_index в n8n)
- Frontend: StepWizardPlan.tsx (передача group_index, исправление дубликатов)
- Скрипты: fix_claim_documents_field_names.py, fix_documents_meta_duplicates.py

Результат: документы больше не теряются, имеют правильные типы и field_name
2025-11-26 19:54:51 +03:00
AI Assistant
1d6c9d1f52 feat: Add detailed logging for description endpoint and n8n workflow docs
Added:
- Detailed logging for /api/v1/claims/description endpoint
- Full event data logging for debugging
- Documentation for n8n workflow setup (N8N_DESCRIPTION_WORKFLOW.md)

The issue: Form hangs on recommendations step because n8n doesn't process description events.

Flow:
1. Frontend sends description to /api/v1/claims/description
2. Backend publishes to Redis channel ticket_form:description
3. Frontend subscribes to SSE /api/v1/events/{session_id} (listens to ocr_events:{session_id})
4. n8n must:
   - Subscribe to ticket_form:description channel
   - Process description and generate wizard_plan
   - Publish wizard_plan back to ocr_events:{session_id} channel

Files:
- backend/app/api/claims.py (enhanced logging)
- docs/N8N_DESCRIPTION_WORKFLOW.md (new documentation)
2025-11-25 17:42:31 +03:00
AI Assistant
521831be5e docs: Add n8n workflow documentation for form approval
Added documentation for n8n workflow setup:
- Redis channel subscription (clientright:webform:approve)
- Data structure description
- SQL script usage for marking forms as approved
- Verification steps

Files:
- docs/N8N_FORM_APPROVAL_WORKFLOW.md
2025-11-25 16:42:28 +03:00
AI Assistant
2fb0921e4c feat: Exclude approved forms from drafts list
Added filtering to exclude approved/confirmed forms from drafts list:
- Updated /drafts/list endpoint to filter out forms with status_code='approved' or is_confirmed=true
- Created SQL script for n8n to mark forms as approved after processing Redis channel data
- Forms marked as approved will no longer appear in drafts list

SQL script: SQL_MARK_FORM_APPROVED.sql
- Updates status_code to 'approved'
- Sets is_confirmed = true
- Uses claim_lookup CTE to find claim by id or payload->>'claim_id'

Files:
- backend/app/api/claims.py (updated drafts list queries)
- docs/SQL_MARK_FORM_APPROVED.sql (new SQL script for n8n)
2025-11-25 16:42:09 +03:00
AI Assistant
3d3f5995af fix: SMS code now properly included in Redis channel
SMS code is now successfully included in the Redis event data:
- Frontend sends SMS code in payload to backend
- Backend extracts SMS code from request body
- Backend includes SMS code in event_data before publishing to Redis
- Added comprehensive logging for debugging

The issue was that backend Docker image needed to be rebuilt after code changes.

Files:
- backend/app/api/claims.py (added detailed logging)
- frontend/src/components/form/StepClaimConfirmation.tsx (SMS code validation)
2025-11-25 15:55:06 +03:00
AI Assistant
6f31ad0dda debug: Add detailed logging for SMS code in backend
Added comprehensive logging to track SMS code:
- Log all keys in request body
- Log SMS code presence and value
- Log extracted SMS code before adding to event_data
- This will help identify why SMS code might not appear in Redis

Files:
- backend/app/api/claims.py
2025-11-25 15:38:07 +03:00
AI Assistant
9c159eda21 fix: Add protection to prevent saveFormData without SMS code
Added validation to ensure saveFormData is only called after SMS verification:
- Check if SMS code is provided
- Show error message if called without SMS code
- Prevent data from being sent to Redis without verification

This prevents accidental form submission without SMS approval.

Files:
- frontend/src/components/form/StepClaimConfirmation.tsx
2025-11-25 14:34:44 +03:00
206 changed files with 41091 additions and 1302 deletions

60
CHANGELOG_MINIAPP.md Normal file
View 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`.

View 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
View 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/роуты/эндпоинты не менялись — это отдельная ветка для новой архитектуры.
## Зачем
Чтобы развивать новую архитектуру входа и “кабинет” **параллельно** со старым флоу, без риска что-то сломать.

View 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; иначе профиль остаётся только для просмотра.

View 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 в логах.

View File

@@ -0,0 +1,4 @@
Support: маршрут /support на страницу чата поддержки
- App.tsx: добавлен импорт страницы Support и роутинг pathname === '/support' на компонент Support.
- При клике на иконку «Поддержка» в нижнем баре теперь открывается список обращений и чат, а не форма «Мои обращения».

137
CURRENT_SETUP.md Normal file
View File

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

203
DEPLOYMENT.md Normal file
View File

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

23
DOCKER-COMPOSE-README.md Normal file
View 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
View File

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

99
GIT_STATUS.md Normal file
View 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
View File

@@ -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
View File

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

View 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
View 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
✅ Тестирование успешно

View 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
```

View 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` — запрос на генерацию списка документов

View 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
View 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
View 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 не поднимается после нескольких попыток
- [ ] Логирование буферизованных сообщений для мониторинга

View File

@@ -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
View 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="Неподдерживаемая платформа")

View 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
View 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)}"
)

View File

@@ -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"},
"📝 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,
},
)
await redis_service.publish(channel, json.dumps(event, ensure_ascii=False))
logger.info(
"📡 TicketForm description published",
extra={"channel": channel, "session_id": payload.session_id},
# 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,
"channel": channel,
"event": event,
"event": message,
"buffered": True,
"warning": (
"Обработка вашего обращения займёт немного больше времени. "
"Идёт автоматическое восстановление системы. "
"Ваше сообщение сохранено и будет обработано в ближайшее время."
),
"buffer_size": buffer_size,
}
except HTTPException:
raise
except Exception as e:
logger.exception("❌ Failed to publish ticket form description")
logger.exception("❌ Failed to send ticket form description to n8n")
raise HTTPException(
status_code=500,
detail=f"Не удалось опубликовать описание: {e}"
detail=f"Не удалось отправить описание: {e}"
)

View 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}

View 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)

View 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"])

View 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)}")

View File

@@ -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
View 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,
)

View File

@@ -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")

View File

@@ -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
View 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

View File

@@ -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(

View File

@@ -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
View 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)}

View 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,
)

View File

@@ -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,26 +48,48 @@ 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
redis_password: str = "CRM_Redis_Pass_2025_Secure!"
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,13 +198,80 @@ 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()

View File

@@ -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,12 +127,23 @@ 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
@@ -56,11 +157,25 @@ 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!")
@@ -68,11 +183,21 @@ async def lifespan(app: FastAPI):
# 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)

View 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()

View 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

View 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}

View File

@@ -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:
"""Удалить ключ"""
@@ -146,6 +155,58 @@ class RedisService:
async def cache_delete(self, cache_key: str):
"""Удалить из кеша"""
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)
# Глобальный экземпляр

View File

@@ -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:
# Получаем актуальный токен

View File

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

View 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 — от оператора';

View 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';

View 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 'Когда пользователь последний раз «прочитал» тред (открыл чат)';

View 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())

View 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
View 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
View 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
View File

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

39
docker-compose.dev.yml Normal file
View 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
View File

@@ -0,0 +1,62 @@
version: '3.8'
# ============================================
# PRODUCTION ENVIRONMENT
# Запуск: docker-compose -f docker-compose.prod.yml up -d
# ============================================
services:
ticket_form_frontend_prod:
container_name: 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

View File

@@ -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:

View 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: тот же

View File

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

View File

@@ -0,0 +1,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 (обновлён)

View 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`
- Использоваться для блокировки полей на фронтенде

View 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 фильтрации.

View File

@@ -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

View File

@@ -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 [
{

View 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"`

View 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. ⏳ Протестировать:
- Создать контакт → поле должно быть "Нет"
- Подтвердить форму → поле должно стать "Да"
- Загрузить черновик → поля должны быть заблокированы

View 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 загружаются и отображаются
- ✅ Кнопка "Изменить данные" работает (если реализована)

View 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`

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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
};

View 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];

View 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
}
}];

View File

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

View File

@@ -0,0 +1,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
}
}];

View 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 } }];

View 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 }}
// ============================================================================

View 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 }}

View 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})
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,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