Compare commits

...

61 Commits

Author SHA1 Message Date
Fedor
2bb56342f4 Add OnlyOfficeTemplates module 2026-02-16 09:27:19 +03:00
Fedor
fd54177ada docs: авторизация ej.sudrf.ru через ЕСИА (Browserless + n8n)
- docs/BROWSERLESS_EJ_SUDRF_ESIA_LOGIN.md: использование browserless_login_esia.js
- Вход/выход, типы ошибок, шаг 2 (SMS), технические детали
2026-02-04 10:54:03 +03:00
Fedor
fd2e7cfb07 feat(n8n): авторизация ej.sudrf.ru через ЕСИА (Browserless)
- Скрипт browserless_login_esia.js для n8n: ГАС Правосудие → ЕСИА → до экрана SMS
- Галочка согласия (#iAgree), ожидание активации кнопки Войти, клик по button.esiaLogin
- Заполнение логина/пароля на ЕСИА: видимый input, keyboard.type + blur/change
- Куки через page.cookies() (совместимость с browserless /function)
- Вход: login/pass из body или fallback из n8n (JSON.stringify для спецсимволов)
- Возврат: status waiting_for_sms + cookies для второго шага (ввод SMS)
2026-02-04 10:52:19 +03:00
Fedor
d7982931cd feat: добавлен telegram_replay.php для публикации ответов поддержки в CRM
Новый endpoint для записи ответов поддержки как комментариев в CRM:
- Принимает JSON с полями: answer, contact_id, project_id (опц.), support_user_id (опц.), channel (опц.)
- Использует прямые INSERT запросы в vtiger_crmentity, vtiger_modcomments, vtiger_modcommentscf
- Обязательно создаёт запись в vtiger_modcommentscf (иначе комментарий не отображается)
- Устанавливает deleted=0 (иначе фильтруется при выборке)
- Полная проверка ошибок БД с детальным логированием
- Логи: logs/tg_replay_inbound.log

Исправлены проблемы:
- vtws_create падал без выброса исключения — заменён на прямой SQL
- Убраны несуществующие колонки (from_mailconverter, customer_email, from_mailroom)
- Добавлена обязательная запись в vtiger_modcommentscf
2026-02-03 14:02:12 +03:00
Fedor
ea0edafba5 Добавлена обработка бинарных данных и форматирование email для n8n workflow
- Добавлен рабочий код для подготовки бинарных данных и вложений для узла Send email
- Реализовано форматирование form_data в читаемый текст с разделами:
  * Личные данные (с автоматическим расчетом возраста, код документа, серия и номер)
  * Контактная информация
  * Страховщик (с телефоном страховой компании)
  * Банковские реквизиты (с получателем платежа)
  * Информация о страховом случае
  * Описание ситуации
- Добавлена документация по использованию и устранению неполадок
- Созданы альтернативные версии кода (простая версия, Function Node версия)
2025-12-26 14:51:58 +03:00
Fedor
1fdb244fd4 fix: исправлены ошибки clipboard и добавлена передача ticket_number в webhook поддержки
- Исправлена ошибка 'Cannot read properties of undefined (reading writeText)' в Step1Phone, StepClaimConfirmation, Step3Payment
  - Добавлена проверка на существование navigator.clipboard
  - Добавлен fallback для старых браузеров (document.execCommand)
  - Добавлена обработка ошибок с try/catch

- Добавлена передача ticket_number и ticket_id в webhook поддержки
  - При обработке события out_of_scope сохраняются claim_id, ticket_number, ticket_id из payload
  - Эти поля теперь передаются в webhook при отправке в поддержку

- Обновлён n8n код для обработки ошибок и out_of_scope
  - Добавлено явное сохранение ticket_number в data объекта события
  - ticket_number теперь гарантированно попадает в событие для Redis
2025-12-05 12:39:08 +03:00
Fedor
ab54530500 feat: добавлено детальное логирование статусов заявлений для диагностики
- Добавлено логирование всех заявлений с их статусами, если запрос вернул 0 строк
- Помогает понять, почему заявление не возвращается
2025-12-04 10:33:29 +03:00
Fedor
ffe30033da fix: временно убран фильтр is_confirmed для диагностики
- Убран фильтр is_confirmed для выяснения, почему заявление не возвращается
- После диагностики нужно вернуть фильтры с правильной логикой
2025-12-04 10:31:46 +03:00
Fedor
35ca11552d fix: убран фильтр по status_code='approved' - теперь approved заявления тоже возвращаются
- Заявления со статусом 'approved' теперь показываются в списке
- Исключаются только заявления с is_confirmed = true
- Исправляет проблему, когда заявление не возвращалось из-за статуса 'approved'
2025-12-04 10:30:36 +03:00
Fedor
b28bb4b636 fix: временно убраны фильтры для диагностики заявлений
- Убраны фильтры is_confirmed и status_code для диагностики
- Нужно выяснить, почему заявление не возвращается даже без фильтров
2025-12-04 10:27:35 +03:00
Fedor
37472d612a feat: добавлена диагностика статусов заявлений в debug
- Добавлены test_count_approved и test_count_confirmed для диагностики
- Показывает, сколько заявлений со статусом approved и is_confirmed = true
- Помогает понять, почему заявления не возвращаются в списке
2025-12-04 10:23:02 +03:00
Fedor
e0097a164d fix: улучшен поиск заявлений по телефону - поддержка разных форматов
- Поиск теперь работает по разным форматам телефона:
  * 71234543212 (оригинальный)
  * +71234543212 (с плюсом)
  * 81234543212 (с 8 вместо 7)
- Убрано условие unified_id IS NULL - теперь ищет в любом случае
- Поиск в clpr_user_accounts и напрямую в payload->>'phone'

Исправляет проблему поиска заявлений для контакта 71234543212
2025-12-04 10:18:04 +03:00
Fedor
6a783deba1 fix: улучшен поиск заявлений по телефону - добавлен fallback на прямой поиск по payload
- Теперь поиск работает двумя способами:
  1. Через unified_id (если есть запись в clpr_user_accounts)
  2. Напрямую по телефону в payload (если unified_id не установлен)

Исправляет проблему, когда заявления не находятся для контакта 71234543212
2025-12-04 10:15:41 +03:00
Fedor
920eb53660 fix: исправлена вставка номера телефона через копирование
- Установка значения напрямую в input элемент
- Триггер события input для синхронизации с формой
- Использование requestAnimationFrame вместо setTimeout для более надёжной работы

Теперь можно вставлять номер телефона через Ctrl+V / Cmd+V
2025-12-04 10:10:19 +03:00
Fedor
1f88e156b7 fix: исправлена проблема с label for атрибутом в createCheckbox
- Добавлена генерация безопасного id (убираются специальные символы)
- Добавлено экранирование id при использовании в атрибуте for
- Убеждаемся, что id и for всегда совпадают

Исправляет предупреждение: 'Incorrect use of <label for=FORM_ELEMENT>'
2025-12-04 09:45:40 +03:00
Fedor
003210dcfc fix: исправлены проблемы с формами и циклическими ссылками
- Исправлена проблема с циклическими ссылками в Step1Phone.tsx при onPaste (добавлен setTimeout)
- Добавлены name атрибуты во все поля формы для правильной работы форм и автозаполнения
- Исправлены проблемы с label/input связями (все label имеют правильные for/id)
- Все поля теперь имеют id и name атрибуты (исправляет предупреждения в консоли)

Исправленные функции:
- createField: добавлен name атрибут
- createReadonlyField: добавлен name атрибут
- createDateField: добавлен name атрибут
- createMoneyField: добавлен name атрибут
- createTextarea: добавлен name атрибут
- createBankSelect: добавлен name атрибут для основного поля и скрытого bank_id
- createCheckbox: добавлен name атрибут, проверена связь label/input
2025-12-04 09:21:12 +03:00
Fedor
346d9a77d2 feat: добавлена визуальная подсветка обязательных полей
- Добавлены звёздочки (*) рядом с обязательными полями
- Незаполненные обязательные поля подсвечиваются жёлтой рамкой
- Добавлен блок с предупреждением о незаполненных полях перед кнопкой отправки
- Улучшена валидация с визуальной обратной связью
- Пользователю теперь понятно, какие поля нужно заполнить
2025-12-03 18:59:58 +03:00
Fedor
38457394c1 fix: исправлена ошибка setSelectionRange для email полей и добавлено логирование
- Исправлена ошибка 'setSelectionRange not supported for email field' - добавлен try-catch и проверка типа поля
- Добавлено логирование наличия contact_data_confirmed, cf_2624, bank_id, bank_name в body запроса
- Это поможет отследить, передаются ли поля из frontend в backend
2025-12-03 18:51:33 +03:00
Fedor
e7915df634 fix: исправлено поле email для контрагента - добавлен type='email'
- Добавлен type='email' и inputmode='email' для полей email
- Это исправляет проблему, когда в поле email контрагента можно было вводить только цифры
- Обработчики событий уже были настроены правильно, но не хватало правильного типа поля
2025-12-03 18:47:08 +03:00
Fedor
b2433f38d8 feat: добавлено логирование для отладки передачи cf_2624 и bank_name
- Добавлено логирование извлечения contact_data_confirmed, cf_2624, bank_id, bank_name из body
- Добавлено логирование наличия этих полей в event_data перед отправкой в Redis
- Поможет отследить, передаются ли поля из frontend в backend
2025-12-03 18:44:07 +03:00
Fedor
8e116c76a4 feat: после отправки заявления сразу показывается сообщение об успехе
- Добавлен callback onSubmitted в StepClaimConfirmation
- После успешной отправки (SMS-верификации) сразу показывается сообщение об успехе
- Убрана форма редактирования после отправки
- Пользователь видит только сообщение: 'Поздравляем! Ваше обращение направлено в Клиентправ...'
2025-12-03 18:41:18 +03:00
Fedor
bf3fb5fef0 feat: добавлены cf_2624 и bank_name в payload при отправке заявления
Frontend (StepClaimConfirmation):
- Добавлен contact_data_confirmed в payload для Redis канала
- Добавлен cf_2624 (значение для CRM: 1 или 0)
- Добавлены bank_id и bank_name в payload
- bank_name сохраняется в state при выборе банка

Backend (claims.py):
- Добавлены contact_data_confirmed и cf_2624 в event_data
- Добавлены bank_id и bank_name в event_data для Redis канала clientright:webform:approve

Обновлён статус заявления 226564ce-d7cf-48ee-a820-690e8f5ec8e5 на draft_claim_ready для тестирования
2025-12-03 18:35:02 +03:00
Fedor
1fc64c035e feat: обновлён текст успешной отправки заявления
- Заменён текст на главной странице после отправки:
  'Поздравляем! Ваше обращение направлено в Клиентправ.'
  'В ближайшее время на указанную Вами электронную почту поступит письмо, подтверждающее регистрацию вашего обращения.'

- Обновлены toast-уведомления при отправке заявления
- Убрано старое сообщение 'Мы изучаем ваш вопрос и документы'
2025-12-03 18:29:36 +03:00
Fedor
b93bb9e8ad feat: выбор банка для СБП выплат теперь обязательное поле
- Добавлен bank_id в список обязательных полей в validateAllFields()
- Обновлён placeholder: "Начните вводить название банка (обязательно)"
- Обновлён заголовок: "Банк для получения выплаты (обязательно)"
- При отправке формы проверяется заполнение банка

Теперь форма не отправится без выбора банка для получения выплаты по СБП.
2025-12-03 18:18:53 +03:00
Fedor
35adcb3043 feat: добавлены ИНН и E-mail как обязательные поля для контрагентов
- ИНН организации теперь обязательное поле
- E-mail организации теперь обязательное поле
- Обновлены placeholder'ы с указанием (обязательно)
- Обновлена валидация validateAllFields()

Обязательные поля для контрагентов:
1. Наименование (accountname)
2. ИНН (inn)
3. Адрес (address)
4. E-mail (email)

Необязательные:
- Телефон (phone)
- Сайт (website)
2025-12-03 18:13:58 +03:00
Fedor
3d9669dd8e fix: настройка подключения MySQL CRM из Docker контейнера
- Изменён docker-compose.yml: добавлен network_mode: host для доступа к MySQL на хосте
- Обновлён config.py: mysql_crm_host = localhost (работает в режиме host)
- MySQL CRM теперь успешно подключается из контейнера
2025-12-03 18:08:05 +03:00
Fedor
6a9f8b5465 docs: обновлён лог сессии 2025-12-03
- Получение cf_2624 из MySQL при загрузке черновика
- Исправление подключения MySQL CRM из Docker контейнера
- Блокировка редактирования полей при contact_data_confirmed=true
- Выбор банка для СБП выплат
- Все проблемы решены, система работает корректно
2025-12-03 18:07:53 +03:00
Fedor
a86120dd53 fix: передача contact_data_confirmed в StepClaimConfirmation для блокировки полей
- Добавлен prop contact_data_confirmed в StepClaimConfirmation
- Передача флага из formData.contact_data_confirmed в компонент
- Исправлена логика получения флага (приоритет: props > claimPlanData > false)
- Поля должны блокироваться при contact_data_confirmed=true
2025-12-03 18:04:08 +03:00
Fedor
e114231541 feat: Получение cf_2624 из MySQL при загрузке черновика
- Добавлен сервис CrmMySQLService для подключения к MySQL БД vtiger CRM
- Обновлён get_draft() для прямого SQL запроса к MySQL вместо webservice API
- Получение cf_2624 и всех данных контакта из MySQL
- Обновлена документация и SQL файлы для n8n
- Добавлено логирование для отладки

Преимущества:
- Проще: один SQL запрос вместо цепочки HTTP запросов
- Быстрее: прямой запрос к БД
- Надёжнее: не зависит от webservice API
- Актуальнее: всегда свежие данные из БД
2025-12-03 16:04:25 +03:00
Fedor
b7197e0da5 feat(forms): автоподстановка банков и улучшенная обработка телефона
- Step1Phone: добавлена обработка вставки телефона с автоматической очисткой от +7 и обрезкой до 10 цифр
- Step3Payment: заменён Select на AutoComplete для выбора банка с автоподстановкой
- generateConfirmationFormHTML: заменён select на input с datalist для автоподстановки банков в форме подтверждения
- Добавлены скрытые поля bank_id для сохранения ID банка отдельно от названия
- Добавлены файлы для проверки заявки 226564ce

Улучшения UX:
- Пользователь может вводить название банка вместо прокрутки длинного списка
- Автоматическая фильтрация списка банков при вводе
- Предупреждение при обрезке номера телефона при вставке
2025-12-02 17:12:25 +03:00
Fedor
ee1c4af5c3 feat: добавлен выбор банка для СБП выплат
Frontend:
- Динамическая загрузка 226 банков из NSPK API
- Выбор банка добавлен в Step3Payment (новая заявка)
- Выбор банка добавлен в generateConfirmationFormHTML (редактирование)
- Поля: bank_id (ID из NSPK) и bank_name (название для отображения)

Backend:
- Добавлено поле bank_id в ClaimCreateRequest

API:
- http://212.193.27.93/api/payouts/dictionaries/nspk-banks

Изменения:
- ticket_form/frontend/src/components/form/Step3Payment.tsx
- ticket_form/frontend/src/components/form/generateConfirmationFormHTML.ts
- ticket_form/frontend/src/pages/ClaimForm.tsx
- ticket_form/backend/app/api/models.py
2025-12-02 11:06:15 +03:00
Fedor
834520a045 docs: add session log 2025-12-01 2025-12-01 22:19:31 +03:00
Fedor
da82100b60 feat: UI/UX improvements + CRM integration methods + documents_meta deduplication
Frontend:
- Changed main title to 'Подать обращение о защите прав потребителя'
- Changed browser title to 'Clientright — защита прав потребителей'
- Enhanced draft cards: show problem_description (250 chars), category tag, document progress bar
- Fixed 'Назад' button to always return to draft selection
- Added SSE connection for OCR status updates
- Renamed steps: Вход, Обращение, Документы, Заявление
- Skip 'Проверка полиса' and 'Тип события' steps for new claim flow

Backend:
- Fixed client IP extraction (X-Forwarded-For, X-Real-IP)
- Added problem_title, category, documents_required_list to draft list API
- Fixed documents_uploaded count to count unique field_labels

CRM Webservices:
- Added UpsertContact.php - create/update contacts with tgid support
- Added UpsertAccounts.php - batch upsert offenders by INN
- Added UpsertProject.php - create/update projects with offender mapping

Database:
- Fixed documents_meta duplicates in existing claims
- SQL query for deduplication by field_name provided
2025-12-01 22:18:21 +03:00
Fedor
81acd49fd9 feat(frontend): использование form_draft для формы подтверждения
- StepDraftSelection: кнопка 'Продолжить' активна для draft_docs_complete
- ClaimForm: проверка form_draft при загрузке черновика
- Если есть form_draft — преобразуем в propertyName и показываем форму подтверждения
- Поддержка статуса draft_docs_complete для перехода к подтверждению
2025-11-30 11:45:35 +03:00
Fedor
0f8631bf20 fix(backend): исправлен алгоритм hash для совместимости с JS
- Использован ctypes.c_int32 для эмуляции JS 32-битного сдвига
- Теперь Python hash совпадает с JS hash в n8n
- Кэширование form_draft работает корректно
2025-11-30 11:37:16 +03:00
Fedor
3801bc4949 feat(backend): кэширование form_draft в check-ocr-status
- Добавлена проверка наличия черновика в БД перед запуском RAG
- Если documents_hash совпадает — возвращаем черновик из кэша
- Если черновика нет или он устарел — запускаем RAG workflow
- Добавлен параметр force_refresh для принудительного обновления
- Импортирован db сервис для работы с PostgreSQL
2025-11-30 11:30:36 +03:00
Fedor
985ee23810 feat(n8n): RAG workflow для извлечения данных из документов
- Обновлён Code1: нормализация данных из give_data1 с поддержкой payload.applicant, ai_analysis, wizard_plan
- Обновлён Code6: генерация промптов для RAG (user, project, offenders)
- Добавлена документация по настройке n8n нод для OCR статуса
- Добавлен эндпоинт check-ocr-status в documents.py
- Добавлен лог сессии с полным описанием workflow

Workflow itX62h38faB51y9J успешно извлекает:
- Данные пользователя (ФИО, контакты, адрес)
- Данные проекта (сумма, предмет, даты договора)
- Несколько контрагентов с разными ролями (seller, service_provider)
2025-11-29 19:29:14 +03:00
Fedor
840acca51a feat(documents): дедупликация documents_meta и исправление field_label
- Исправлен N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js: использовать uploads_field_labels[0] вместо [grp]
- Создан SQL_CLAIMSAVE_FIXED_NEW_FLOW_DEDUP.sql с дедупликацией documents_meta
- Создан SQL_CLEANUP_DOCUMENTS_META_DUPLICATES.sql для очистки существующих дубликатов
- Создан полный уникальный индекс idx_document_texts_hash_unique на document_texts(file_hash)
- Добавлен SESSION_LOG_2025-11-28_documents_dedup.md с описанием всех изменений

Fixes:
- field_label теперь корректно отображает 'Переписка' вместо 'group-2'
- documents_meta не накапливает дубликаты при повторных сохранениях
- ON CONFLICT (file_hash) теперь работает для document_texts
2025-11-28 18:16:53 +03:00
Fedor
6c770f0a87 feat(ticket_form): Новая архитектура загрузки документов
- StepDocumentsNew.tsx: поэкранная загрузка документов
- StepWaitingClaim.tsx: ожидание формирования заявления с SSE
- StepDraftSelection.tsx: поддержка новых статусов черновиков
- documents.py: API для загрузки документов
- NEW_FLOW_ARCHITECTURE.md: документация новой архитектуры

Флоу: Description → Documents → Waiting → Claim Review → SMS
Статусы: draft_new, draft_docs_progress, draft_docs_complete, draft_claim_ready
2025-11-26 12:52:54 +03:00
Fedor
0868d37484 Сессия 26.11.2025: Исправления UI Wizard - блоки загрузки, retry аутентификации 2025-11-25 23:42:32 +03:00
Fedor
55c1402d99 Исправлен React.useRef → useRef 2025-11-25 23:38:24 +03:00
Fedor
18fcdecae8 Исправлено дублирование блоков документов (ref для отслеживания созданных) 2025-11-25 23:36:32 +03:00
Fedor
f058ca91ad Убран дублирующий useEffect для создания блоков документов 2025-11-25 23:33:59 +03:00
Fedor
796316d969 Убраны лишние поля для предустановленных документов (description, кнопка Удалить) 2025-11-25 23:31:40 +03:00
Fedor
f3b5771c09 Блоки загрузки документов сразу развёрнуты при загрузке плана 2025-11-25 23:28:36 +03:00
Fedor
2ce0c585ff Чекбокс 'нет документа' перенесён под блок загрузки 2025-11-25 23:26:20 +03:00
Fedor
6cc07b0ba6 Исправлен JSX Fragment для блоков загрузки документов 2025-11-25 23:22:34 +03:00
Fedor
b5478c143f Заменены чекбоксы docs_exist на блоки загрузки файлов 2025-11-25 23:20:33 +03:00
Fedor
be1ac2ed49 Добавлен retry механизм для webservice аутентификации (race condition fix) 2025-11-25 23:02:52 +03:00
Fedor
2b1dca9e92 Автогенерация уникального описания для дополнительных блоков документов 2025-11-25 21:22:11 +03:00
Fedor
99ef902a31 Показываем поле описания для всех блоков если блоков > 1 2025-11-25 21:20:48 +03:00
Fedor
8626c9aff4 Автоматическая нумерация дубликатов field_label при отправке визарда 2025-11-25 21:12:30 +03:00
Fedor
444e5d2b91 Добавлен лог отправляемых метаданных документов (field_label) 2025-11-25 20:53:19 +03:00
Fedor
8f4cff55e9 Добавлен полный лог диалога за 22 ноября 2025 2025-11-25 20:46:59 +03:00
Fedor
52fe013375 feat(ticket_form): unified_id/contact_id передача, исправлен мерж сессии, новая сессия для жалобы
- Добавлены unified_id и contact_id в TicketFormDescriptionRequest
- Исправлен CODE_MERGE_PROJECT_TO_SESSION.js - теперь сохраняются ВСЕ данные из body.other
- Добавлен fallback на получение other из Webhook напрямую
- Генерация новой session_id при создании новой жалобы (сохраняя авторизацию)
- Добавлен SQL_SELECT_CONTACT_WITH_CUSTOM_FIELDS.sql для CRM контактов
- Создан SESSION_LOG_2025-11-25.md с документацией сессии
2025-11-25 20:02:21 +03:00
Fedor
a20a4d0e09 Добавлен лог сессии 2025-11-22 2025-11-22 09:40:14 +03:00
Fedor
486f3619ff Добавлен field_label в результат переименования файлов, исправлена загрузка черновиков, обновлен формат пути S3 с project_name 2025-11-22 09:38:38 +03:00
Fedor
d3ba054027 Исправление архивации проектов: поддержка S3 файлов и документов из связанных сущностей
- Добавлен метод getRelatedDocs() для получения документов из связанных сущностей (контакты, контрагенты)
- Добавлен метод downloadS3File() для скачивания файлов из S3 во временную папку
- Добавлен метод cleanupTempFiles() для очистки временных файлов
- Исправлен getPaths() для корректной обработки S3 файлов (всегда запрашивает s3_bucket/s3_key из БД)
- Исправлен getArchive() для проектов: собирает документы из основной записи и связанных сущностей
- Исправлен путь к vendor/autoload.php (поиск по нескольким путям)
- Исправлено имя временного файла (короткое имя вместо полного пути для избежания 'File name too long')

Результат: архив успешно создается с документами из проекта и связанных сущностей (25 документов для проекта 396447)
2025-11-21 10:23:52 +03:00
Fedor
30a0df9c64 docs: добавлен лог сессии 19.11.2025 2025-11-20 00:39:04 +03:00
Fedor
d2f37faa7b fix: убран claim_id, используется только session_id на ранних этапах
- Убрана проверка claim_id из StepDescription.tsx
- Заменен claim_id на session_id в StepWizardPlan.tsx для SSE подключения
- Убран claim_id из запросов к API и сохранения в Step1Phone
- Обновлен backend для работы с опциональным claim_id
- Добавлена документация по исправлению узла claimsave для первичного черновика
- Добавлены SQL запросы и примеры кода для n8n workflow
2025-11-20 00:38:33 +03:00
Fedor
de011efba9 fix: исправлен конфликт имён переменных в loadDraft (claimId -> finalClaimId)
- Исправлена ошибка ReferenceError при загрузке черновиков
- Переименована локальная переменная claimId в finalClaimId для избежания конфликта с параметром функции
- Обновлена логика извлечения claim_id из разных источников (claim.claim_id, payload.claim_id, body.claim_id, claim.id)
- Добавлен fallback на параметр claimId функции для надёжности
2025-11-19 23:33:52 +03:00
546 changed files with 74739 additions and 145 deletions

View File

@@ -5,6 +5,77 @@
"headers": {
"CONTEXT7_API_KEY": "ctx7sk-541e7992-c38f-442f-8902-ae99645f2477"
}
}
},
"shadcn": {
"command": "/usr/bin/docker",
"args": [
"run",
"-i",
"--rm",
"--init",
"-v", "/var/www/fastuser/data/www/crm.clientright.ru:/workspace",
"-w", "/workspace",
"node:20-alpine",
"npx",
"-y",
"shadcn@latest",
"mcp"
],
"env": {}
},
"antd-components": {
"command": "/usr/bin/docker",
"args": [
"run",
"-i",
"--rm",
"--init",
"-v", "/var/www/fastuser/data/www/crm.clientright.ru:/workspace",
"-w", "/workspace",
"node:20-alpine",
"npx",
"-y",
"@jzone-mcp/antd-components-mcp"
],
"env": {}
},
"n8n-mcp": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"--init",
"-e", "MCP_MODE=stdio",
"-e", "LOG_LEVEL=error",
"-e", "DISABLE_CONSOLE_OUTPUT=true",
"-e", "N8N_API_URL=https://n8n.clientright.pro/",
"-e", "N8N_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MzMwYWVjZC1hYjExLTQxODEtOWIyYy1iMDZhZWEzMTNmNzQiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwiaWF0IjoxNzUzNjg3NDM4fQ.XJjyYXXOoO4eUGAfkSVRMJzLYvi25hczsp2F7j4UV7Y",
"ghcr.io/czlonkowski/n8n-mcp:latest"
]
},
"memory": {
"url": "http://185.197.75.249:9000/sse"
}
,
"n8n-mcp2": {
"command": "npx",
"args": [
"-y",
"supergateway",
"--streamableHttp",
"https://n8n.clientright.pro/mcp-server/http",
"--header",
"authorization:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MzMwYWVjZC1hYjExLTQxODEtOWIyYy1iMDZhZWEzMTNmNzQiLCJpc3MiOiJuOG4iLCJhdWQiOiJtY3Atc2VydmVyLWFwaSIsImp0aSI6Ijc5MTQzNDU5LTM0NzMtNDQ2Mi05MzU1LTZmMTAzZTdlMzAxNCIsImlhdCI6MTc2NDU3OTI0N30.g-WQpgWMjdFWVpUMaRP023MCQqfk_e3ollLyPpcE_Io"
]
}
}
}

View File

@@ -211,3 +211,9 @@ composer require phpoffice/phpword
Все компоненты реализованы, протестированы и готовы к использованию в n8n workflow.

146
RESTORE_INSTRUCTIONS.md Normal file
View File

@@ -0,0 +1,146 @@
# Инструкция по восстановлению удаленных файлов и защите от повторных удалений
## 📋 Что было сделано:
1. ✅ Создан скрипт для настройки Nextcloud (`fix_nextcloud_settings.php`)
2. ✅ Создан скрипт для восстановления файлов (`restore_all_deleted_files.php`)
3. ✅ Создан скрипт для регулярной индексации (`nextcloud_scan_files.sh`)
---
## 🚀 Порядок выполнения:
### Шаг 1: Настройка Nextcloud (защита от удалений)
```bash
cd /var/www/fastuser/data/www/crm.clientright.ru
php fix_nextcloud_settings.php
```
**Что делает:**
- Отключает `DeleteOrphanedItems` (главная причина удалений)
- Включает `readonly` для External Storage
- Увеличивает retention корзины до 365 дней
- Создает скрипт для регулярной индексации
---
### Шаг 2: Восстановление файлов (сначала проверка)
**Сначала проверка (dry-run):**
```bash
php restore_all_deleted_files.php --dry-run
```
Это покажет, сколько файлов будет восстановлено без реального восстановления.
**Ограничение количества (для теста):**
```bash
php restore_all_deleted_files.php --dry-run 100
```
**Восстановление всех файлов:**
```bash
php restore_all_deleted_files.php
```
**Восстановление с ограничением (для безопасности):**
```bash
php restore_all_deleted_files.php "" 1000
```
**Восстановление только файлов проекта:**
```bash
php restore_all_deleted_files.php "" "" "crm2/CRM_Active_Files/Documents/Project/"
```
---
### Шаг 3: Настройка регулярной индексации
**Добавить в crontab:**
```bash
crontab -e
```
**Добавить строку:**
```
0 */6 * * * /var/www/fastuser/data/www/crm.clientright.ru/nextcloud_scan_files.sh
```
Это будет сканировать файлы каждые 6 часов.
**Или сканировать только внешнее хранилище (быстрее):**
Отредактируйте `nextcloud_scan_files.sh` и раскомментируйте строку:
```bash
docker exec -u www-data nextcloud-fresh php occ files:scan --path="/crm"
```
---
## 📊 Статистика удалений:
- **Всего delete markers:** ~25,200
- **Пик удалений:** 1 ноября 2025, 09:00 утра (7,080 файлов)
- **Причина:** DeleteOrphanedItems в Nextcloud
---
## ⚠️ ВАЖНО:
1. **Сначала настройте Nextcloud** (Шаг 1), чтобы предотвратить новые удаления
2. **Проверьте dry-run** перед массовым восстановлением
3. **Восстанавливайте постепенно** (по 1000-5000 файлов за раз)
4. **Проверяйте логи** после восстановления
---
## 🔍 Проверка статуса:
**Проверить статус задач Nextcloud:**
```bash
docker exec -u www-data nextcloud-fresh php occ background-job:list
```
**Проверить настройки External Storage:**
```bash
docker exec -u www-data nextcloud-fresh php occ files_external:list
```
**Проверить retention корзины:**
```bash
docker exec -u www-data nextcloud-fresh php occ config:app:get files trashbin_retention_obligation
```
**Проверить логи восстановления:**
```bash
ls -lh /var/www/fastuser/data/www/crm.clientright.ru/restore_log_*.json
```
---
## 🛡️ Защита от повторных удалений:
После выполнения всех шагов система будет защищена:
1. ✅ DeleteOrphanedItems отключен
2. ✅ External Storage в режиме readonly
3. ✅ Retention корзины увеличен до 365 дней
4. ✅ Регулярная индексация файлов настроена
---
## 📝 Логи:
- Логи восстановления: `restore_log_YYYY-MM-DD_HH-MM-SS.json`
- Логи индексации: `/var/log/nextcloud_scan.log`
---
## 🆘 Если что-то пошло не так:
1. Проверьте логи восстановления
2. Проверьте доступность Docker контейнера Nextcloud
3. Проверьте права доступа к S3
4. Проверьте логи Nextcloud: `docker logs nextcloud-fresh`

108
SESSION_LOG_ARCHIVE_FIX.md Normal file
View File

@@ -0,0 +1,108 @@
# Лог сессии: Исправление архивации проектов с S3 файлами
## Дата: 2025-11-21
## Проблема
Архивация проектов не работала после миграции на S3:
- Возвращался ответ `{"success":true,"result":"Nothing to archive"}`
- Затем появились ошибки `"_ is missing!"` для всех документов
- После исправления появились ошибки `"S3 file download failed"` для всех файлов
## Причины проблем
### 1. Неправильная обработка S3 файлов
- Метод `getPaths()` пытался обработать S3 файлы как локальные
- `Vtiger_Record_Model` не всегда содержит поля `s3_bucket`, `s3_key`, `filelocationtype`
- Нужно было явно запрашивать эти данные из БД
### 2. Отсутствие поддержки связанных документов
- Архив включал только документы самого проекта
- Не включались документы из связанных сущностей (контакты, контрагенты)
- Аналогично функционалу отправки исковых писем через pochta-sud.ru
### 3. Ошибки при скачивании из S3
- Неправильный путь к `vendor/autoload.php` (относительный путь не работал)
- Слишком длинное имя временного файла (`File name too long`)
- Использовался `basename($fileName)` где `$fileName` содержал URL-encoded полный путь
## Решение
### 1. Добавлен метод `getRelatedDocs($projectId)`
- Получает документы из связанных сущностей проекта:
- Контакт (`linktoaccountscontacts`)
- Контрагенты (`cf_1994`, `cf_2274`, `cf_2276`)
- Возвращает массив документов с полями: `notesid`, `title`, `filename`, `filelocationtype`, `s3_bucket`, `s3_key`
### 2. Добавлен метод `downloadS3File($s3Bucket, $s3Key, $fileName)`
- Скачивает файлы из S3 во временную папку
- Использует AWS SDK для работы с S3
- Сохраняет пути временных файлов для последующей очистки
- Обрабатывает ошибки с подробным логированием
### 3. Добавлен метод `cleanupTempFiles()`
- Очищает все временные файлы после создания архива
- Вызывается в `finally` блоке для гарантированной очистки
### 4. Исправлен метод `getPaths($docs)`
- Поддержка как `Vtiger_Record_Model` объектов, так и массивов из `getRelatedDocs`
- **ВСЕГДА** запрашивает `s3_bucket`, `s3_key`, `filelocationtype` из БД для Record Models
- Правильно определяет S3 файлы (`filelocationtype == 'E' && !empty($s3Bucket) && !empty($s3Key)`)
- Для S3 файлов вызывает `downloadS3File()`
- Для локальных файлов использует `getFileDetails()`
### 5. Исправлен метод `getArchive($id)`
- Для проектов собирает документы из основной записи и связанных сущностей
- Предотвращает дубликаты документов
- Вызывает `getPaths()` с объединенным списком документов
- Добавлено подробное логирование для отладки
- Обработка ошибок с возвратом детальной информации
### 6. Исправления в `downloadS3File()`
- Поиск `vendor/autoload.php` по нескольким путям (относительный и абсолютный)
- Использование короткого имени временного файла (только расширение, без полного пути)
- Подробное логирование в `/tmp/s3_download_debug.log`
## Измененные файлы
### `modules/Vtiger/services/Base.php`
- Добавлен метод `getRelatedDocs($projectId)` - получение документов из связанных сущностей
- Добавлен метод `downloadS3File($s3Bucket, $s3Key, $fileName)` - скачивание из S3
- Добавлен метод `cleanupTempFiles()` - очистка временных файлов
- Добавлено свойство `private static $tempFiles = []` - хранение путей временных файлов
- Исправлен метод `getPaths($docs)` - поддержка S3 и связанных документов
- Исправлен метод `getArchive($id)` - сбор документов из связанных сущностей для проектов
## Тестирование
### Тестовый скрипт `test_s3_download.php`
- Создан для прямого тестирования `downloadS3File()`
- Успешно скачал файл из S3 (9.5 МБ)
- Подтвердил работоспособность исправлений
### Результат
- ✅ Архив успешно создается с 25 документами для проекта 396447
- ✅ Включаются документы из проекта и связанных сущностей
- ✅ S3 файлы корректно скачиваются и добавляются в архив
- ✅ Временные файлы автоматически очищаются
## Технические детали
### S3 конфигурация
- Используется конфиг из `crm_extensions/file_storage/config.php`
- Endpoint: `https://s3.twcstorage.ru`
- Bucket и Key берутся из полей `vtiger_notes.s3_bucket` и `vtiger_notes.s3_key`
### Временные файлы
- Сохраняются в `sys_get_temp_dir()` (обычно `/tmp`)
- Имена: `s3_{uniqid}.{extension}`
- Автоматически удаляются после создания архива
### Логирование
- Основные логи: `error_log()` (системный лог PHP)
- Отладочные логи: `/tmp/s3_download_debug.log` (временный, удален после исправления)
- Ошибки: `/tmp/s3_download_errors.log` (временный, удален после исправления)
## Коммит
Изменения закоммичены в git с описанием исправлений.

View File

@@ -0,0 +1,236 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
$config = require '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php';
$s3Bucket = $config['s3']['bucket'];
echo "Детальный анализ паттернов удалений\n";
echo str_repeat("=", 80) . "\n\n";
try {
$s3Client = new \Aws\S3\S3Client([
'version' => 'latest',
'region' => $config['s3']['region'],
'endpoint' => $config['s3']['endpoint'],
'use_path_style_endpoint' => true,
'credentials' => [
'key' => $config['s3']['key'],
'secret' => $config['s3']['secret'],
],
'suppress_php_deprecation_warning' => true
]);
// Анализируем удаления по времени
echo "1. Анализ удалений по времени суток...\n";
$deletionsByHour = [];
$deletionsByDay = [];
$batchDeletions = []; // Массовые удаления (много файлов за короткое время)
$totalChecked = 0;
$maxToCheck = 10000;
try {
$isTruncated = true;
$continuationToken = null;
$pageCount = 0;
$maxPages = 20;
$currentBatch = [];
$lastDeleteTime = null;
while ($isTruncated && $pageCount < $maxPages && $totalChecked < $maxToCheck) {
$params = [
'Bucket' => $s3Bucket,
'Prefix' => 'crm2/CRM_Active_Files/Documents/Project/',
'MaxKeys' => 1000
];
if ($continuationToken) {
$params['ContinuationToken'] = $continuationToken;
}
$versions = $s3Client->listObjectVersions($params);
$pageCount++;
if (isset($versions['DeleteMarkers'])) {
foreach ($versions['DeleteMarkers'] as $marker) {
$totalChecked++;
$deleteDate = isset($marker['LastModified']) ? $marker['LastModified'] : null;
if ($deleteDate) {
$dateTime = new DateTime($deleteDate);
$hour = $dateTime->format('H');
$day = $dateTime->format('Y-m-d');
if (!isset($deletionsByHour[$hour])) {
$deletionsByHour[$hour] = 0;
}
$deletionsByHour[$hour]++;
if (!isset($deletionsByDay[$day])) {
$deletionsByDay[$day] = 0;
}
$deletionsByDay[$day]++;
// Определяем массовые удаления (более 10 файлов за минуту)
$deleteTimestamp = strtotime($deleteDate);
if ($lastDeleteTime && abs($deleteTimestamp - $lastDeleteTime) < 60) {
$currentBatch[] = $marker;
} else {
if (count($currentBatch) > 10) {
$batchDeletions[] = [
'count' => count($currentBatch),
'time' => date('Y-m-d H:i:s', $lastDeleteTime),
'files' => array_slice($currentBatch, 0, 5) // Первые 5 для примера
];
}
$currentBatch = [$marker];
}
$lastDeleteTime = $deleteTimestamp;
}
}
}
$isTruncated = isset($versions['IsTruncated']) && $versions['IsTruncated'];
$continuationToken = isset($versions['NextContinuationToken']) ? $versions['NextContinuationToken'] : null;
if (!$isTruncated) {
break;
}
}
// Проверяем последний батч
if (count($currentBatch) > 10) {
$batchDeletions[] = [
'count' => count($currentBatch),
'time' => $lastDeleteTime ? date('Y-m-d H:i:s', $lastDeleteTime) : 'неизвестно',
'files' => array_slice($currentBatch, 0, 5)
];
}
echo " Проверено delete markers: $totalChecked\n\n";
echo " Удаления по часам суток:\n";
ksort($deletionsByHour);
foreach ($deletionsByHour as $hour => $count) {
if ($count > 0) {
echo " {$hour}:00 - " . ($hour + 1) . ":00: $count удалений\n";
}
}
echo "\n";
echo " Удаления по дням (топ 15):\n";
arsort($deletionsByDay);
$count = 0;
foreach ($deletionsByDay as $day => $deleteCount) {
echo " $day: $deleteCount удалений\n";
if (++$count >= 15) break;
}
echo "\n";
if (!empty($batchDeletions)) {
echo " Массовые удаления (более 10 файлов за минуту): " . count($batchDeletions) . "\n";
foreach (array_slice($batchDeletions, 0, 5) as $batch) {
echo " Время: {$batch['time']}, удалено файлов: {$batch['count']}\n";
}
echo "\n";
}
} catch (\Aws\Exception\AwsException $e) {
echo " Ошибка: " . $e->getMessage() . "\n";
}
// Проверяем, может быть это связано с удалением документов в CRM
echo "2. Проверка связи с удалением документов в CRM...\n";
$pdo = new PDO(
"mysql:host={$dbconfig['db_server']};port=3306;dbname={$dbconfig['db_name']};charset=utf8",
$dbconfig['db_username'],
$dbconfig['db_password'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
// Проверяем, сколько документов было удалено в последние дни
$stmt = $pdo->prepare('
SELECT DATE(e.modifiedtime) as delete_date, COUNT(*) as count
FROM vtiger_crmentity e
INNER JOIN vtiger_notes n ON n.notesid = e.crmid
WHERE e.deleted = 1
AND n.filelocationtype = "E"
AND e.modifiedtime >= DATE_SUB(NOW(), INTERVAL 60 DAY)
GROUP BY DATE(e.modifiedtime)
ORDER BY delete_date DESC
LIMIT 20
');
$stmt->execute();
$deletedDocs = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($deletedDocs)) {
echo " Удаленные документы в CRM (за последние 60 дней):\n";
foreach ($deletedDocs as $doc) {
echo " {$doc['delete_date']}: {$doc['count']} документов\n";
}
echo "\n";
} else {
echo " Удаленных документов не найдено\n\n";
}
// Сравниваем даты удалений в S3 и CRM
echo "3. Сравнение дат удалений в S3 и CRM...\n";
if (!empty($deletionsByDay) && !empty($deletedDocs)) {
echo " Сравнение:\n";
foreach ($deletedDocs as $doc) {
$crmDate = $doc['delete_date'];
$s3Count = $deletionsByDay[$crmDate] ?? 0;
$crmCount = $doc['count'];
if ($s3Count > 0) {
echo " $crmDate:\n";
echo " Удалено в CRM: $crmCount документов\n";
echo " Delete markers в S3: $s3Count\n";
if ($s3Count > $crmCount * 2) {
echo " ⚠️ В S3 удалено значительно больше файлов!\n";
} elseif (abs($s3Count - $crmCount) <= 10) {
echo " ✅ Количество примерно совпадает\n";
}
echo "\n";
}
}
}
echo str_repeat("=", 80) . "\n";
echo "ВЫВОДЫ:\n\n";
// Определяем наиболее вероятную причину
$maxHour = array_search(max($deletionsByHour), $deletionsByHour);
$maxDay = array_search(max($deletionsByDay), $deletionsByDay);
$maxDayCount = max($deletionsByDay);
echo "1. Пик удалений:\n";
echo " - Время: {$maxHour}:00\n";
echo " - День: $maxDay ($maxDayCount удалений)\n\n";
if ($maxHour >= 6 && $maxHour <= 8) {
echo "2. 💡 ВЕРОЯТНАЯ ПРИЧИНА: Автоматическая задача (cron job)\n";
echo " Удаления происходят рано утром (6-8 утра) - типичное время для cron\n\n";
}
if (!empty($batchDeletions)) {
echo "3. 💡 ВЕРОЯТНАЯ ПРИЧИНА: Массовое удаление (скрипт или автоматизация)\n";
echo " Найдено " . count($batchDeletions) . " случаев массового удаления\n\n";
}
echo "4. 💡 РЕКОМЕНДАЦИЯ: Проверить:\n";
echo " - DeleteOrphanedItems в Nextcloud (запускается ежедневно)\n";
echo " - Cron задачи, которые могут удалять файлы\n";
echo " - Логи Nextcloud на предмет массовых удалений\n";
} catch (Exception $e) {
echo "ОШИБКА: " . $e->getMessage() . "\n";
echo "Trace: " . $e->getTraceAsString() . "\n";
}

203
analyze_deletions.php Normal file
View File

@@ -0,0 +1,203 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
$config = require '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php';
$s3Bucket = $config['s3']['bucket'];
echo "Анализ удалений файлов из S3\n";
echo str_repeat("=", 80) . "\n\n";
try {
$s3Client = new \Aws\S3\S3Client([
'version' => 'latest',
'region' => $config['s3']['region'],
'endpoint' => $config['s3']['endpoint'],
'use_path_style_endpoint' => true,
'credentials' => [
'key' => $config['s3']['key'],
'secret' => $config['s3']['secret'],
],
'suppress_php_deprecation_warning' => true
]);
// Анализируем паттерны удалений
echo "1. Анализ паттернов удалений по датам...\n";
$deletionsByDate = [];
$deletionsByProject = [];
$totalChecked = 0;
$maxToCheck = 5000; // Ограничиваем для скорости
try {
$isTruncated = true;
$continuationToken = null;
$pageCount = 0;
$maxPages = 10;
while ($isTruncated && $pageCount < $maxPages && $totalChecked < $maxToCheck) {
$params = [
'Bucket' => $s3Bucket,
'Prefix' => 'crm2/CRM_Active_Files/Documents/Project/',
'MaxKeys' => 1000
];
if ($continuationToken) {
$params['ContinuationToken'] = $continuationToken;
}
$versions = $s3Client->listObjectVersions($params);
$pageCount++;
if (isset($versions['DeleteMarkers'])) {
foreach ($versions['DeleteMarkers'] as $marker) {
$totalChecked++;
$key = $marker['Key'];
$deleteDate = isset($marker['LastModified']) ? $marker['LastModified'] : null;
if ($deleteDate) {
$dateKey = substr($deleteDate, 0, 10); // YYYY-MM-DD
if (!isset($deletionsByDate[$dateKey])) {
$deletionsByDate[$dateKey] = 0;
}
$deletionsByDate[$dateKey]++;
}
// Извлекаем ID проекта из пути
if (preg_match('/Project\/([^\/]+)_(\d+)\//', $key, $matches)) {
$projectId = $matches[2];
if (!isset($deletionsByProject[$projectId])) {
$deletionsByProject[$projectId] = 0;
}
$deletionsByProject[$projectId]++;
}
}
}
$isTruncated = isset($versions['IsTruncated']) && $versions['IsTruncated'];
$continuationToken = isset($versions['NextContinuationToken']) ? $versions['NextContinuationToken'] : null;
if (!$isTruncated) {
break;
}
}
echo " Проверено delete markers: $totalChecked\n\n";
// Сортируем по датам
krsort($deletionsByDate);
echo " Удаления по датам (топ 20):\n";
$count = 0;
foreach ($deletionsByDate as $date => $count) {
if ($count > 0) {
echo " $date: $count удалений\n";
if (++$count >= 20) break;
}
}
echo "\n";
// Сортируем проекты по количеству удалений
arsort($deletionsByProject);
echo " Проекты с наибольшим количеством удалений (топ 10):\n";
$count = 0;
foreach ($deletionsByProject as $projectId => $deleteCount) {
echo " Проект $projectId: $deleteCount удалений\n";
if (++$count >= 10) break;
}
echo "\n";
} catch (\Aws\Exception\AwsException $e) {
echo " Ошибка: " . $e->getMessage() . "\n";
}
// Проверяем логи системы
echo "2. Проверка логов на наличие записей об удалениях...\n";
$logFiles = [
'/var/log/nginx/error.log',
'/var/log/apache2/error.log',
'/var/www/fastuser/data/www/crm.clientright.ru/logs/debug.log',
'/var/www/fastuser/data/www/crm.clientright.ru/logs/s3_debug.log',
];
foreach ($logFiles as $logFile) {
if (file_exists($logFile)) {
echo " Проверка: $logFile\n";
$lines = file($logFile);
if ($lines) {
$deleteLines = array_filter($lines, function($line) {
return stripos($line, 'delete') !== false &&
(stripos($line, 's3') !== false || stripos($line, 'file') !== false);
});
if (!empty($deleteLines)) {
echo " Найдено строк с упоминанием удалений: " . count($deleteLines) . "\n";
echo " Последние 5 записей:\n";
foreach (array_slice($deleteLines, -5) as $line) {
echo " " . substr(trim($line), 0, 150) . "\n";
}
} else {
echo " Записей об удалениях не найдено\n";
}
}
echo "\n";
}
}
// Проверяем, есть ли скрипты крона, которые могут удалять файлы
echo "3. Поиск скриптов, которые могут удалять файлы...\n";
$cronFiles = [
'/var/spool/cron/crontabs/root',
'/etc/cron.d/',
'/var/www/fastuser/data/www/crm.clientright.ru/cron/',
];
foreach ($cronFiles as $cronPath) {
if (file_exists($cronPath)) {
echo " Проверка: $cronPath\n";
if (is_dir($cronPath)) {
$files = glob($cronPath . '*');
foreach ($files as $file) {
if (is_file($file)) {
$content = file_get_contents($file);
if (stripos($content, 'delete') !== false || stripos($content, 'remove') !== false) {
echo " Найден файл: $file\n";
echo " Содержит упоминания удаления\n";
}
}
}
} else {
$content = file_get_contents($cronPath);
if (stripos($content, 'delete') !== false || stripos($content, 'remove') !== false) {
echo " Файл содержит упоминания удаления\n";
}
}
}
}
echo "\n";
// Проверяем, может быть это связано с синхронизацией Nextcloud
echo "4. Проверка связи с Nextcloud синхронизацией...\n";
$nextcloudFiles = glob('/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/*sync*.php');
if (!empty($nextcloudFiles)) {
echo " Найдено файлов синхронизации: " . count($nextcloudFiles) . "\n";
foreach ($nextcloudFiles as $file) {
$content = file_get_contents($file);
if (stripos($content, 'delete') !== false) {
echo " $file содержит код удаления\n";
}
}
} else {
echo " Файлы синхронизации не найдены\n";
}
echo "\n";
} catch (Exception $e) {
echo "ОШИБКА: " . $e->getMessage() . "\n";
echo "Trace: " . $e->getTraceAsString() . "\n";
}

518
browserless_login_esia.js Normal file
View File

@@ -0,0 +1,518 @@
// Авторизация на ej.sudrf.ru через ЕСИА (Госуслуги)
// n8n → HTTP Request → Browserless (Puppeteer)
//
// Вход: $credentials.login, $credentials.password
// Выход: { status, cookies, screenshot, url, session_data }
export default async function ({ page, context: browserContext }, input = {}) {
// Варианты передачи логина/пароля:
// 1) Предпочтительно: отдельными полями body запроса Browserless:
// { code, login, pass } или { code, context: { login, pass } }
// 2) Если тянете из предыдущей ноды прямо в поле code — используйте JSON.stringify, чтобы не ломать JS:
// const login = {{ JSON.stringify($json.login) }};
// const pass = {{ JSON.stringify($json.pass) }};
// Эти строки можно включить в n8n (expression), если вы не передаёте login/pass отдельными полями:
// eslint-disable-next-line no-unused-vars
const __FALLBACK_LOGIN__ = {{ JSON.stringify($json.login ?? "") }};
// eslint-disable-next-line no-unused-vars
const __FALLBACK_PASS__ = {{ JSON.stringify($json.pass ?? "") }};
const fallbackLogin = (typeof __FALLBACK_LOGIN__ !== 'undefined') ? __FALLBACK_LOGIN__ : '';
const fallbackPass = (typeof __FALLBACK_PASS__ !== 'undefined') ? __FALLBACK_PASS__ : '';
const login = String(input.login ?? input.context?.login ?? fallbackLogin ?? '').trim();
const password = String(input.pass ?? input.password ?? input.context?.pass ?? input.context?.password ?? fallbackPass ?? '').trim();
if (!login || !password) {
throw new Error('Не переданы login/pass во входных данных Browserless');
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const timeout = 45000;
const loadDelay = 800;
await page.setViewport({ width: 1280, height: 800 });
page.setDefaultTimeout(timeout);
const makeError = async (error_type, error_message, extra = {}) => {
const bodyText = await page.evaluate(() => document.body?.innerText || '').catch(() => '');
return {
status: 'error',
error_type,
error_message,
current_url: page.url(),
page_text: bodyText.slice(0, 1500),
screenshot: await page.screenshot({ encoding: 'base64', fullPage: true }),
...extra,
};
};
const isEsiaUrl = (u) =>
(u || '').includes('esia.gosuslugi.ru') || (u || '').includes('gosuslugi.ru');
const waitEsiaOrNav = async (ms = 30000) => {
await Promise.race([
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: ms }).catch(() => {}),
page
.waitForFunction(
() =>
location.href.includes('gosuslugi') ||
location.href.includes('esia.gosuslugi'),
{ timeout: ms }
)
.catch(() => {}),
]);
};
const clickByText = async (patterns) => {
return page
.evaluate((patterns) => {
const norm = (s) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
const els = Array.from(
document.querySelectorAll(
'a, button, [role="button"], input[type="button"], input[type="submit"]'
)
);
const hit = els.find((el) => {
const t = norm(el.textContent || el.value || '');
const href = (el.getAttribute?.('href') || '').toLowerCase();
return patterns.some((p) => t.includes(p) || href.includes(p));
});
if (hit) {
hit.scrollIntoView({ block: 'center' });
hit.click();
return true;
}
return false;
}, patterns.map((p) => p.toLowerCase()))
.catch(() => false);
};
const clickBySelector = async (selector) => {
const el = await page.$(selector).catch(() => null);
if (!el) return false;
await el.scrollIntoViewIfNeeded?.().catch(() => {});
try {
await el.click({ delay: 30 });
return true;
} catch (_) {}
try {
const box = await el.boundingBox();
if (box) {
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { delay: 30 });
return true;
}
} catch (_) {}
return false;
};
// Принять пользовательское соглашение + нажать "Войти" на ej.sudrf.ru
const acceptAgreementAndLogin = async () => {
// На живой странице выяснили:
// - чекбокс имеет id="iAgree"
// - кнопка "Войти" имеет классы: btn btn-primary esia-login esiaLogin и type="submit"
// - кнопка реально disabled до отметки чекбокса
await page
.waitForSelector('#iAgree', { visible: true, timeout: 20000 })
.catch(() => {});
// 1) Отмечаем чекбокс реальным кликом (именно он включает кнопку)
const checkboxClicked = await page
.evaluate(() => {
const cb = document.querySelector('#iAgree');
if (!cb) return false;
cb.scrollIntoView({ block: 'center' });
const label =
cb.closest('label') ||
(cb.id ? document.querySelector(`label[for="${cb.id}"]`) : null);
if (label) label.click();
else cb.click();
cb.dispatchEvent(new Event('input', { bubbles: true }));
cb.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})
.catch(() => false);
if (!checkboxClicked) {
return { checkboxChecked: false, loginClicked: false };
}
// 2) Ждём, что чекбокс действительно стал checked
const checkboxChecked = await page
.waitForFunction(() => !!document.querySelector('#iAgree')?.checked, { timeout: 10000 })
.then(() => true)
.catch(() => false);
if (!checkboxChecked) {
return { checkboxChecked: false, loginClicked: false };
}
// 3) Ждём, что кнопка "Войти" стала enabled (disabled снят)
const loginBtnSelector = 'button.esiaLogin, button.esia-login, button.btn.esiaLogin, button.btn.esia-login';
const loginBtnReady = await page
.waitForFunction(
(sel) => {
const btn = document.querySelector(sel);
if (!btn) return false;
// disabled должен быть снят
// @ts-ignore
if (btn.disabled === true) return false;
const rect = btn.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
},
{ timeout: 25000 },
loginBtnSelector
)
.then(() => true)
.catch(() => false);
if (!loginBtnReady) {
return { checkboxChecked: true, loginClicked: false, reason: 'login_button_still_disabled' };
}
await sleep(500);
// 4) Кликаем "Войти" и ждём перехода на ЕСИА
const beforeUrl = page.url();
await Promise.all([
waitEsiaOrNav(25000),
page
.click(loginBtnSelector, { delay: 30 })
.catch(async () => {
// фоллбек на DOM-click
await page
.evaluate((sel) => {
const btn = document.querySelector(sel);
if (btn) {
// @ts-ignore
btn.disabled = false;
btn.removeAttribute?.('disabled');
// @ts-ignore
btn.click();
}
}, loginBtnSelector)
.catch(() => {});
}),
]);
const afterUrl = page.url();
const loginClicked = isEsiaUrl(afterUrl) || afterUrl !== beforeUrl;
return { checkboxChecked: true, loginClicked, url: afterUrl };
};
// ——— 1) Открываем ej.sudrf.ru ———
await page.goto('https://ej.sudrf.ru/?fromOa=16RS0018', {
waitUntil: 'domcontentloaded',
timeout,
});
await sleep(loadDelay);
// Иногда редиректит сразу (редко), но обычно — нет
let currentUrl = page.url();
if (!isEsiaUrl(currentUrl)) {
// Подождать дорисовку страницы
await page.waitForSelector('body', { timeout: 20000 }).catch(() => {});
await sleep(400);
// ——— 2) Проверяем, на какой странице мы находимся ———
const pageType = await page
.evaluate(() => {
const t = (document.body?.innerText || '').toLowerCase();
if (t.includes('авторизация пользователя') && t.includes('есиа')) {
return 'auth_page';
}
if (t.includes('обращения') || t.includes('дела')) {
return 'main_page';
}
return 'unknown';
})
.catch(() => 'unknown');
if (pageType === 'main_page') {
// ——— 2a) На главной странице — ищем кнопку "Вход" ———
let loginLinkClicked = await clickByText(['вход']);
if (!loginLinkClicked) {
// Пытаемся найти по селекторам
loginLinkClicked = await page
.evaluate(() => {
const links = Array.from(document.querySelectorAll('a'));
const loginLink = links.find((el) => {
const text = (el.textContent || '').toLowerCase().trim();
return text === 'вход';
});
if (loginLink) {
loginLink.scrollIntoView({ block: 'center' });
loginLink.click();
return true;
}
return false;
})
.catch(() => false);
}
if (!loginLinkClicked) {
return await makeError('main_page_login_not_found', 'Не удалось найти кнопку "Вход" на главной странице');
}
// Ждём загрузки страницы авторизации с дополнительными проверками
await Promise.race([
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 15000 }),
page.waitForSelector('input[type="checkbox"]', { timeout: 15000 }),
page.waitForFunction(() => document.body?.innerText?.toLowerCase().includes('авторизация пользователя'), { timeout: 15000 })
]).catch(() => {});
await sleep(loadDelay);
}
// ——— 2b) Теперь должны быть на странице авторизации — принимаем соглашение и жмём Войти ———
const pageLooksLikeAuth = await page
.evaluate(() => {
const t = (document.body?.innerText || '').toLowerCase();
return t.includes('авторизация пользователя') && t.includes('есиа');
})
.catch(() => false);
if (pageLooksLikeAuth) {
const { checkboxState, loginClicked } = await acceptAgreementAndLogin();
// Если кнопка не нажалась (например, disabled) — ещё раз попробуем клик по "Войти" через общий поиск
if (!loginClicked) {
// Иногда кнопка активируется с задержкой после клика по чекбоксу
await sleep(600);
const fallbackLoginClick = await clickByText(['войти']);
if (!fallbackLoginClick) {
return await makeError(
'esia_login_button_not_clicked',
'Не удалось нажать "Войти" после принятия соглашения',
{ checkboxState }
);
}
}
// Ждём редирект на ЕСИА
await waitEsiaOrNav(30000);
await sleep(loadDelay);
} else {
return await makeError('auth_page_not_found', 'Не удалось попасть на страницу авторизации');
}
}
// ——— 4) Проверяем, что мы на ЕСИА ———
currentUrl = page.url();
if (!isEsiaUrl(currentUrl)) {
return await makeError('esia_redirect_failed', 'Не произошел редирект на ЕСИА (после Войти)', {
after_actions_url: currentUrl,
});
}
// ——— 5) Ввод логина на ЕСИА ———
await page
.waitForSelector('input[type="text"], input[name="login"], input[name="username"]', {
timeout: 20000,
})
.catch(() => {});
// ЕСИА часто на React/контролируемых инпутах — простая установка el.value может не сработать.
// Поэтому делаем "живой" ввод через клавиатуру + проверяем, что значение реально попало в input.value.
const normalizePhone = (v) => String(v || '').trim().replace(/^\+/, '');
const loginToType = normalizePhone(login);
const fillInput = async (selectors, value, debugKey) => {
// 1) Выбираем ВИДИМЫЙ инпут (у ESIA часто есть скрытые дубли)
for (const sel of selectors) {
const handles = await page.$$(sel).catch(() => []);
for (const handle of handles) {
try {
const box = await handle.boundingBox();
if (!box || box.width < 5 || box.height < 5) continue;
// запомним, какой селектор реально сработал (для отладки)
if (debugKey) {
debug[debugKey] = { selector: sel, box };
}
await handle.focus();
// очистка
await page.keyboard.down('Control');
await page.keyboard.press('KeyA');
await page.keyboard.up('Control');
await page.keyboard.press('Backspace');
// ввод именно через elementHandle.type (иногда надежнее чем page.keyboard.type)
await handle.type(String(value), { delay: 60 });
// blur + change (нужно ESIA, иначе пишет "Заполните поле")
await handle.evaluate((el) => {
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
el.blur();
}).catch(() => {});
await sleep(250);
const ok = await handle
.evaluate((el) => typeof el.value === 'string' && el.value.length > 0)
.catch(() => false);
if (ok) return true;
} catch (_) {}
}
}
// 2) Фоллбек: нативный setter + input/change (для React)
const ok = await page
.evaluate((sels, val) => {
const isVisible = (el) => {
const r = el.getBoundingClientRect();
return r.width > 5 && r.height > 5;
};
const pick = () => {
for (const s of sels) {
const list = Array.from(document.querySelectorAll(s));
const visible = list.find(isVisible);
if (visible) return visible;
}
return null;
};
const el = pick();
if (!el) return false;
el.focus();
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
if (setter) {
setter.call(el, '');
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
setter.call(el, String(val));
} else {
// @ts-ignore
el.value = String(val);
}
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
el.blur();
// @ts-ignore
return typeof el.value === 'string' && el.value.length > 0;
}, selectors, value)
.catch(() => false);
return ok;
};
const debug = {};
const loginFilled = await fillInput(
[
'input[name="login"]',
'input[name="username"]',
'input[type="tel"]',
'input[type="text"]',
],
loginToType,
'loginInput'
);
if (!loginFilled) {
return await makeError('login_input_not_found', 'Не найдено поле логина на странице ЕСИА');
}
await sleep(300);
// ——— 6) Ввод пароля ———
await page.waitForSelector('input[type="password"]', { timeout: 20000 }).catch(() => {});
const passFilled = await fillInput(['input[type="password"]'], password, 'passwordInput');
if (!passFilled) {
return await makeError('password_input_not_found', 'Не найдено поле пароля на странице ЕСИА');
}
await sleep(300);
// ——— 7) Submit ———
// маленькая пауза перед сабмитом, чтобы ESIA "съела" input/change
await sleep(600);
const submitted = await page
.evaluate(() => {
const norm = (s) => (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
const btn =
document.querySelector('button[type="submit"]') ||
Array.from(document.querySelectorAll('button')).find((b) =>
norm(b.textContent).includes('войти')
) ||
Array.from(document.querySelectorAll('input[type="submit"]')).find(Boolean);
if (btn) {
btn.scrollIntoView({ block: 'center' });
btn.click();
return true;
}
return false;
})
.catch(() => false);
if (!submitted) {
// fallback Enter
await page.keyboard.press('Enter').catch(() => {});
}
await sleep(500);
// ——— 8) Ждём SMS-поля (или навигацию) ———
const otpSelector =
'input[inputmode="numeric"], input[type="tel"], input[autocomplete="one-time-code"], input[name="otp"], input[name*="code"], input[id*="otp"], input[id*="code"]';
await Promise.race([
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {}),
page
.waitForFunction(
(sel) => document.querySelectorAll(sel).length > 0,
{ timeout: 30000 },
otpSelector
)
.catch(() => {}),
]);
await sleep(loadDelay);
const smsInputs = await page.$$(otpSelector).catch(() => []);
if (!smsInputs || smsInputs.length === 0) {
// Если мы всё ещё на /login/, скорее всего форма не приняла пароль/логин или показала валидацию
const urlNow = page.url();
if (urlNow.includes('esia.gosuslugi.ru/login')) {
return await makeError(
'login_failed',
'После нажатия «Войти» ЕСИА не перешла к SMS. Скорее всего, форма считает логин/пароль пустыми или произошла ошибка входа.',
{
debug,
}
);
}
return await makeError(
'sms_page_not_found',
'Не найдены поля для ввода SMS кода (возможно, иной фактор подтверждения или ошибка входа)'
);
}
// ——— 9) Сохраняем куки + скрин ———
// В browserless /function это Puppeteer: куки берём с page
const cookies = (typeof page.cookies === 'function') ? await page.cookies() : [];
const screenshot = await page.screenshot({ encoding: 'base64', fullPage: true });
return {
status: 'waiting_for_sms',
message: '✅ Дошли до ввода SMS. Ожидание кода.',
url: page.url(),
cookies,
screenshot,
sms_inputs_count: smsInputs.length,
session_data: {
created_at: new Date().toISOString(),
note: 'cookies передай во второй скрипт, чтобы продолжить сессию и ввести SMS',
},
};
}

View File

@@ -0,0 +1,51 @@
<?php
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
$pdo = new PDO(
"mysql:host={$dbconfig['db_server']};port=3306;dbname={$dbconfig['db_name']};charset=utf8",
$dbconfig['db_username'],
$dbconfig['db_password'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$projectId = 371231;
$sql = "SELECT
n.notesid,
n.title,
n.filelocationtype,
n.filename,
n.s3_bucket,
n.s3_key
FROM vtiger_notes n
INNER JOIN vtiger_crmentity e ON e.crmid = n.notesid
INNER JOIN vtiger_senotesrel snr ON snr.notesid = n.notesid
WHERE snr.crmid = ? AND e.deleted = 0
ORDER BY n.notesid DESC";
$stmt = $pdo->prepare($sql);
$stmt->execute([$projectId]);
$documents = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo "Проверка поля filename для документов проекта $projectId\n";
echo str_repeat("=", 80) . "\n\n";
foreach ($documents as $doc) {
echo "ID: {$doc['notesid']}\n";
echo " Название: {$doc['title']}\n";
echo " filelocationtype: {$doc['filelocationtype']}\n";
echo " filename (первые 200 символов): " . substr($doc['filename'], 0, 200) . "\n";
echo " s3_bucket: " . ($doc['s3_bucket'] ?? 'нет') . "\n";
echo " s3_key: " . substr($doc['s3_key'] ?? 'нет', 0, 100) . "\n";
// Проверяем, является ли filename URL
$isUrl = filter_var($doc['filename'], FILTER_VALIDATE_URL);
echo " filename является URL: " . ($isUrl ? 'ДА' : 'НЕТ') . "\n";
// Проверяем, начинается ли filename с http
$isHttp = (strpos($doc['filename'], 'http://') === 0 || strpos($doc['filename'], 'https://') === 0);
echo " filename начинается с http: " . ($isHttp ? 'ДА' : 'НЕТ') . "\n";
echo "\n";
}

149
check_project_371231.php Normal file
View File

@@ -0,0 +1,149 @@
<?php
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
$pdo = new PDO(
"mysql:host={$dbconfig['db_server']};port=3306;dbname={$dbconfig['db_name']};charset=utf8",
$dbconfig['db_username'],
$dbconfig['db_password'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$projectId = 371231;
// Получаем информацию о проекте
$sqlProject = "SELECT projectid, projectname, projectstatus FROM vtiger_project WHERE projectid = ?";
$stmtProject = $pdo->prepare($sqlProject);
$stmtProject->execute([$projectId]);
$project = $stmtProject->fetch(PDO::FETCH_ASSOC);
if (!$project) {
die("❌ Проект $projectId не найден!\n");
}
echo "📋 ПРОЕКТ: {$project['projectname']}\n";
echo " ID: {$project['projectid']}\n";
echo " Статус: {$project['projectstatus']}\n";
echo "\n" . str_repeat("=", 80) . "\n\n";
// Получаем документы проекта
$sql = "SELECT
n.notesid,
n.title,
n.filename,
n.filelocationtype,
n.foldername,
n.s3_bucket,
n.s3_key,
n.nc_path,
n.filesize,
e.createdtime,
e.modifiedtime,
u.user_name,
e.deleted
FROM vtiger_notes n
INNER JOIN vtiger_crmentity e ON e.crmid = n.notesid
INNER JOIN vtiger_senotesrel snr ON snr.notesid = n.notesid
LEFT JOIN vtiger_users u ON u.id = e.smownerid
WHERE snr.crmid = ? AND e.deleted = 0
ORDER BY e.createdtime DESC";
$stmt = $pdo->prepare($sql);
$stmt->execute([$projectId]);
$documents = $stmt->fetchAll(PDO::FETCH_ASSOC);
$count = count($documents);
echo "📄 НАЙДЕНО ДОКУМЕНТОВ: $count\n\n";
if ($count == 0) {
echo "⚠️ Документы не найдены!\n";
exit;
}
$totalSize = 0;
$s3Count = 0;
$localCount = 0;
$brokenCount = 0;
$accessibleCount = 0;
$notAccessibleCount = 0;
foreach ($documents as $i => $doc) {
$num = $i + 1;
$filelocationtype = $doc['filelocationtype'] ?? 'I';
$s3Bucket = $doc['s3_bucket'] ?? null;
$s3Key = $doc['s3_key'] ?? null;
$filename = $doc['filename'] ?? '';
$title = $doc['title'] ?? 'Без названия';
$isS3 = ($filelocationtype == 'E' && !empty($s3Bucket) && !empty($s3Key));
$isLocal = ($filelocationtype == 'I' || empty($filelocationtype));
if ($isS3) {
$s3Count++;
$status = "☁️ S3";
$filePath = "s3://{$s3Bucket}/{$s3Key}";
$accessibleCount++; // Пока считаем доступными, проверим отдельно
} else {
$localCount++;
$status = "💾 Локальный";
// Для локальных файлов проверяем путь
if (!empty($filename)) {
// Парсим путь из filename
$filePath = $filename;
if (file_exists($filePath)) {
$accessibleCount++;
$status .= "";
} else {
$notAccessibleCount++;
$brokenCount++;
$status .= " ❌ ФАЙЛ НЕ НАЙДЕН";
}
} else {
$notAccessibleCount++;
$brokenCount++;
$status .= "НЕТ ПУТИ";
}
}
$size = $doc['filesize'] ?? 0;
$totalSize += $size;
$sizeStr = $size > 0 ? number_format($size / 1024, 2) . ' KB' : '0 KB';
echo sprintf(
"%3d. [%s] %s\n",
$num,
$status,
$title
);
echo sprintf(
" ID: %d | Размер: %s | Тип: %s\n",
$doc['notesid'],
$sizeStr,
$filelocationtype
);
if ($isS3) {
echo sprintf(" S3 Key: %s\n", $s3Key);
} else {
echo sprintf(" Путь: %s\n", substr($filename, 0, 100));
}
if ($notAccessibleCount > 0 && ($i == $count - 1 || ($i + 1) % 10 == 0)) {
echo "\n";
}
}
echo "\n" . str_repeat("=", 80) . "\n";
echo "📊 СТАТИСТИКА:\n";
echo " Всего документов: $count\n";
echo " S3 документов: $s3Count\n";
echo " Локальных документов: $localCount\n";
echo " Доступных: $accessibleCount\n";
echo " Недоступных: $notAccessibleCount\n";
echo " Общий размер: " . number_format($totalSize / 1024 / 1024, 2) . " MB\n";
if ($brokenCount > 0) {
echo "\n⚠️ ВНИМАНИЕ: Найдено $brokenCount недоступных файлов!\n";
}

View File

@@ -0,0 +1,51 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
$pdo = new PDO(
"mysql:host={$dbconfig['db_server']};port=3306;dbname={$dbconfig['db_name']};charset=utf8",
$dbconfig['db_username'],
$dbconfig['db_password'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$projectId = 371231;
// Получаем документы проекта
$sql = "SELECT
n.notesid,
n.title,
n.filename,
n.filelocationtype,
n.s3_bucket,
n.s3_key,
n.filesize
FROM vtiger_notes n
INNER JOIN vtiger_crmentity e ON e.crmid = n.notesid
INNER JOIN vtiger_senotesrel snr ON snr.notesid = n.notesid
WHERE snr.crmid = ? AND e.deleted = 0
ORDER BY e.createdtime DESC";
$stmt = $pdo->prepare($sql);
$stmt->execute([$projectId]);
$documents = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo "Найдено документов: " . count($documents) . "\n\n";
foreach ($documents as $i => $doc) {
$num = $i + 1;
echo "$num. ID: {$doc['notesid']}\n";
echo " Название: " . ($doc['title'] ?? 'Нет') . "\n";
echo " Тип хранения: " . ($doc['filelocationtype'] ?? 'I') . "\n";
if ($doc['filelocationtype'] == 'E') {
echo " S3 Bucket: " . ($doc['s3_bucket'] ?? 'нет') . "\n";
echo " S3 Key: " . ($doc['s3_key'] ?? 'нет') . "\n";
} else {
echo " Filename: " . substr($doc['filename'] ?? 'нет', 0, 100) . "\n";
}
echo "\n";
}

129
check_project_373977.php Normal file
View File

@@ -0,0 +1,129 @@
<?php
/**
* Проверка документов проекта 373977
*/
error_reporting(E_ALL);
ini_set('display_errors', 1);
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
$config = require '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php';
$projectId = 373977;
$s3Bucket = $config['s3']['bucket'];
// Документы проекта из БД
$documents = [
373981 => 'crm2/CRM_Active_Files/Documents/Project/ПолуляхУ_ДПООРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/8_Договора_оказание_услуг_373981.pdf',
373983 => 'crm2/CRM_Active_Files/Documents/Project/ПолуляхУ_ДПООРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/9_Подтверждение_оплаты_пооговору_373983.pdf',
373985 => 'crm2/CRM_Active_Files/Documents/Project/ПолуляхУ_ДПООРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/10_2_Скрин_личногоабинетастца_и_программа_обуч_373985.pdf',
373987 => 'crm2/CRM_Active_Files/Documents/Project/ПолуляхУ_ДПООРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/10_1_Скрин_личногоабинетастца_и_программа_обуч_373987.pdf',
373989 => 'crm2/CRM_Active_Files/Documents/Project/ПолуляхУ_ДПООРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/11_1_Подтверждение_проведения_претензионной_работы_373989.pdf',
373991 => 'crm2/CRM_Active_Files/Documents/Project/ПолуляхУ_ДПООРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/7_заявление_потребителя_373991.pdf',
374017 => 'crm2/CRM_Active_Files/Documents/Project/ПолуляхУ_ДПООРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/11_Доказательство_соблюдения_претензионного_порядк_374017.pdf',
375402 => 'crm2/CRM_Active_Files/Documents/Project/ПолуляхУ_ДПООРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/11.2_Претензия_в_защиту_интересов_Полулях_Ольга_1_375402.pdf',
375404 => 'crm2/CRM_Active_Files/Documents/Project/ПолуляхУ_ДПООРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/11.3_Доказательство_оплаты_направления_претензии_о_375404.pdf',
375406 => 'crm2/CRM_Active_Files/Documents/Project/ПолуляхУ_ДПООРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/11.4_Доказательствоаправления_претензии_ответчик_375406.pdf',
376051 => 'crm2/CRM_Active_Files/Documents/Project/ПолуляхУ_ДПООРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/0_Исковоеаявление_поелуолулях_7_стр_376051.pdf',
376054 => 'crm2/CRM_Active_Files/Documents/Project/ПолуляхУ_ДПООРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/6_Расчет_исковыхребований_Полулях_1_стр_376054.pdf',
376080 => 'crm2/CRM_Active_Files/Documents/Project/ПолуляхУ_ДПООРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/12.1_Доказательство_оплаты_направления_иска_ответч_376080.pdf',
376082 => 'crm2/CRM_Active_Files/Documents/Project/ПолуляхУ_ДПООРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/12.2_Доказательствоаправления_иска_ответчику_376082.pdf',
396623 => 'crm2/CRM_Active_Files/Documents/396623/ПК_451a1058-ee34-0d48-b2f4-d6dfa522928a.pdf_WITH_ENVELOPE.pdf', // Неправильное место!
];
echo "=== ПРОВЕРКА ДОКУМЕНТОВ ПРОЕКТА {$projectId} ===\n";
echo str_repeat("=", 80) . "\n\n";
try {
$s3Client = new \Aws\S3\S3Client([
'version' => 'latest',
'region' => $config['s3']['region'],
'endpoint' => $config['s3']['endpoint'],
'use_path_style_endpoint' => true,
'credentials' => [
'key' => $config['s3']['key'],
'secret' => $config['s3']['secret'],
],
'suppress_php_deprecation_warning' => true
]);
$stats = [
'total' => count($documents),
'exists' => 0,
'missing' => 0,
'wrong_place' => 0,
'missing_files' => [],
'wrong_place_files' => [],
];
$projectPrefix = 'crm2/CRM_Active_Files/Documents/Project/ПолуляхУ_ДПООРОДСКАЯ_АКАДЕМИЯ_УРБАН_373977/';
foreach ($documents as $docId => $s3Key) {
$filename = basename($s3Key);
$isInProjectFolder = strpos($s3Key, $projectPrefix) === 0;
echo "Документ ID: {$docId}\n";
echo " Файл: {$filename}\n";
echo " Путь: {$s3Key}\n";
if ($s3Client->doesObjectExist($s3Bucket, $s3Key)) {
$object = $s3Client->headObject(['Bucket' => $s3Bucket, 'Key' => $s3Key]);
$size = round($object['ContentLength'] / 1024, 2);
if (!$isInProjectFolder) {
echo " ⚠️ Файл существует, но в неправильном месте (размер: {$size} KB)\n";
$stats['wrong_place']++;
$stats['wrong_place_files'][] = [
'doc_id' => $docId,
'current_path' => $s3Key,
'should_be' => $projectPrefix . $filename,
];
} else {
echo " ✅ Файл существует (размер: {$size} KB)\n";
$stats['exists']++;
}
} else {
echo " ❌ Файл отсутствует\n";
$stats['missing']++;
$stats['missing_files'][] = [
'doc_id' => $docId,
'path' => $s3Key,
];
}
echo "\n";
}
// Итоги
echo str_repeat("=", 80) . "\n";
echo "ИТОГИ:\n";
echo "Всего документов: {$stats['total']}\n";
echo "На месте: {$stats['exists']}\n";
echo "⚠️ В неправильном месте: {$stats['wrong_place']}\n";
echo "❌ Отсутствуют: {$stats['missing']}\n\n";
if (!empty($stats['wrong_place_files'])) {
echo "ФАЙЛЫ В НЕПРАВИЛЬНОМ МЕСТЕ:\n";
foreach ($stats['wrong_place_files'] as $file) {
echo " - Документ {$file['doc_id']}: {$file['current_path']}\n";
echo " Должен быть: {$file['should_be']}\n";
}
echo "\n";
}
if (!empty($stats['missing_files'])) {
echo "ОТСУТСТВУЮЩИЕ ФАЙЛЫ:\n";
foreach ($stats['missing_files'] as $file) {
echo " - Документ {$file['doc_id']}: {$file['path']}\n";
}
echo "\n";
}
echo "=== ГОТОВО ===\n";
} catch (Exception $e) {
echo "❌ Ошибка: " . $e->getMessage() . "\n";
exit(1);
}

103
check_project_391584.php Normal file
View File

@@ -0,0 +1,103 @@
<?php
/**
* Проверка документов проекта 391584
*/
error_reporting(E_ALL);
ini_set('display_errors', 1);
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
$config = require '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php';
$projectId = 391584;
$s3Bucket = $config['s3']['bucket'];
$projectPrefix = 'crm2/CRM_Active_Files/Documents/Project/ЧужбаОУ_ДПО_ОБРАЗОВАТЕЛЬНЫЕ_ТЕХНОЛОГИИ_СКИЛБОКС_(КОРОБКА_НАВЫКОВ)_391584/';
// Документы проекта из БД
$documents = [
391587 => '8_Договора_оказание_услуг_391587.pdf',
391589 => '9_Подтверждение_оплаты_пооговору_391589.pdf',
391591 => '10_1_Скрин_личногоабинетастца_и_программа_обуч_391591.pdf',
391593 => '7_заявление_потребителя_391593.pdf',
392332 => '11_Доказательство_соблюдения_претензионного_порядк_392332.pdf',
392472 => '11.1_Доказательство_соблюдения_претензионного_поря_392472.pdf',
392475 => '11.2_Доказательство_соблюдения_претензионного_поря_392475.pdf',
395136 => '6_Расчет_искаужба_395136.pdf',
395157 => '0_Исковоеаявление_поелуужбаОУ_ДПО_ОБРАЗОВА_395157.pdf',
395744 => '12.1_Доказательство_оплаты_направления_иска_ответч_395744.pdf',
];
echo "=== ПРОВЕРКА ДОКУМЕНТОВ ПРОЕКТА {$projectId} ===\n";
echo str_repeat("=", 80) . "\n\n";
try {
$s3Client = new \Aws\S3\S3Client([
'version' => 'latest',
'region' => $config['s3']['region'],
'endpoint' => $config['s3']['endpoint'],
'use_path_style_endpoint' => true,
'credentials' => [
'key' => $config['s3']['key'],
'secret' => $config['s3']['secret'],
],
'suppress_php_deprecation_warning' => true
]);
$stats = [
'total' => count($documents),
'exists' => 0,
'missing' => 0,
'missing_files' => [],
];
foreach ($documents as $docId => $filename) {
$s3Key = $projectPrefix . $filename;
echo "Документ ID: {$docId}\n";
echo " Файл: {$filename}\n";
echo " Путь: {$s3Key}\n";
$exists = $s3Client->doesObjectExist($s3Bucket, $s3Key);
if ($exists) {
$object = $s3Client->headObject(['Bucket' => $s3Bucket, 'Key' => $s3Key]);
$size = round($object['ContentLength'] / 1024, 2);
echo " ✅ Файл существует (размер: {$size} KB)\n";
$stats['exists']++;
} else {
echo " ❌ Файл отсутствует\n";
$stats['missing']++;
$stats['missing_files'][] = [
'doc_id' => $docId,
'filename' => $filename,
'path' => $s3Key,
];
}
echo "\n";
}
// Итоги
echo str_repeat("=", 80) . "\n";
echo "ИТОГИ:\n";
echo "Всего документов: {$stats['total']}\n";
echo "✅ Существуют: {$stats['exists']}\n";
echo "❌ Отсутствуют: {$stats['missing']}\n\n";
if (!empty($stats['missing_files'])) {
echo "ОТСУТСТВУЮЩИЕ ФАЙЛЫ:\n";
foreach ($stats['missing_files'] as $file) {
echo " - Документ {$file['doc_id']}: {$file['filename']}\n";
echo " Путь: {$file['path']}\n";
}
echo "\n";
}
echo "=== ГОТОВО ===\n";
} catch (Exception $e) {
echo "❌ Ошибка: " . $e->getMessage() . "\n";
exit(1);
}

158
check_project_398027.php Normal file
View File

@@ -0,0 +1,158 @@
<?php
/**
* Проверка документов проекта 398027
*/
error_reporting(E_ALL);
ini_set('display_errors', 1);
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
$config = require '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php';
$projectId = 398027;
$s3Bucket = $config['s3']['bucket'];
$projectPrefix = 'crm2/CRM_Active_Files/Documents/Project/Храмов_ООО_НЕТОЛОГИЯ_398027/';
// Документы проекта из БД
$documents = [
398030 => [
'current_path' => 'crm2/CRM_Active_Files/Documents/398030/8_Договора_оказание_услуг_11-14-2025-16-00-51_Храмов_1_CTP#realfile.pdf',
'should_be' => $projectPrefix . '8_Договора_оказание_услуг_398030.pdf',
],
398032 => [
'current_path' => 'crm2/CRM_Active_Files/Documents/398032/9_Подтверждение_оплаты_пооговору_11-14-2025-16-00-03_Храмов_1_CTP#realfile.pdf',
'should_be' => $projectPrefix . '9_Подтверждение_оплаты_пооговору_398032.pdf',
],
398034 => [
'current_path' => 'crm2/CRM_Active_Files/Documents/398034/10_2_Скрин_личногоабинетастца_и_программа_обучения_11-14-2025-15-47-26_Храмов_41_CTP#realfile.pdf',
'should_be' => $projectPrefix . '10_2_Скрин_личногоабинетастца_и_программа_обучения_398034.pdf',
],
398036 => [
'current_path' => 'crm2/CRM_Active_Files/Documents/398036/10_1_Скрин_личногоабинетастца_и_программа_обучения_11-14-2025-15-49-59_Храмов_1_CTP#realfile.pdf',
'should_be' => $projectPrefix . '10_1_Скрин_личногоабинетастца_и_программа_обучения_398036.pdf',
],
398038 => [
'current_path' => 'crm2/CRM_Active_Files/Documents/398038/Прочиеокументы_11-14-2025-16-06-07_Храмов_3_CTP#realfile.pdf',
'should_be' => $projectPrefix . рочиеокументы_398038.pdf',
],
398040 => [
'current_path' => 'crm2/CRM_Active_Files/Documents/398040/7_zayavlenie_potrebitelya_Hramov.pdf',
'should_be' => $projectPrefix . '7_заявление_потребителя_398040.pdf',
],
398063 => [
'current_path' => 'crm2/CRM_Active_Files/Documents/398063/napravleniya_pretenzii.pdf',
'should_be' => $projectPrefix . 'Направление_претензии_398063.pdf',
],
398584 => [
'current_path' => 'crm2/CRM_Active_Files/Documents/Project/Храмов_ООО_НЕТОЛОГИЯ_398027/8_Договора_оказание_услуг_398584.pdf',
'should_be' => $projectPrefix . '8_Договора_оказание_услуг_398584.pdf',
],
399067 => [
'current_path' => 'clientright/0/1763997676315.pdf',
'should_be' => $projectPrefix . окумент_399067.pdf',
],
399068 => [
'current_path' => 'clientright/0/1763997790309.pdf',
'should_be' => $projectPrefix . окумент_399068.pdf',
],
];
echo "=== ПРОВЕРКА ДОКУМЕНТОВ ПРОЕКТА {$projectId} ===\n";
echo str_repeat("=", 80) . "\n\n";
try {
$s3Client = new \Aws\S3\S3Client([
'version' => 'latest',
'region' => $config['s3']['region'],
'endpoint' => $config['s3']['endpoint'],
'use_path_style_endpoint' => true,
'credentials' => [
'key' => $config['s3']['key'],
'secret' => $config['s3']['secret'],
],
'suppress_php_deprecation_warning' => true
]);
$stats = [
'total' => count($documents),
'exists_correct' => 0,
'exists_wrong' => 0,
'missing' => 0,
'wrong_place_files' => [],
'missing_files' => [],
];
foreach ($documents as $docId => $paths) {
$currentPath = $paths['current_path'];
$shouldBe = $paths['should_be'];
$filename = basename($shouldBe);
echo "Документ ID: {$docId}\n";
echo " Файл: {$filename}\n";
echo " Текущий путь: {$currentPath}\n";
echo " Должен быть: {$shouldBe}\n";
$existsCurrent = $s3Client->doesObjectExist($s3Bucket, $currentPath);
$existsCorrect = $s3Client->doesObjectExist($s3Bucket, $shouldBe);
if ($existsCorrect) {
$object = $s3Client->headObject(['Bucket' => $s3Bucket, 'Key' => $shouldBe]);
$size = round($object['ContentLength'] / 1024, 2);
echo " ✅ Файл уже в правильном месте (размер: {$size} KB)\n";
$stats['exists_correct']++;
} elseif ($existsCurrent) {
$object = $s3Client->headObject(['Bucket' => $s3Bucket, 'Key' => $currentPath]);
$size = round($object['ContentLength'] / 1024, 2);
echo " ⚠️ Файл существует, но в неправильном месте (размер: {$size} KB)\n";
$stats['exists_wrong']++;
$stats['wrong_place_files'][] = [
'doc_id' => $docId,
'current_path' => $currentPath,
'should_be' => $shouldBe,
];
} else {
echo " ❌ Файл отсутствует\n";
$stats['missing']++;
$stats['missing_files'][] = [
'doc_id' => $docId,
'path' => $currentPath,
];
}
echo "\n";
}
// Итоги
echo str_repeat("=", 80) . "\n";
echo "ИТОГИ:\n";
echo "Всего документов: {$stats['total']}\n";
echo "На месте: {$stats['exists_correct']}\n";
echo "⚠️ В неправильном месте: {$stats['exists_wrong']}\n";
echo "❌ Отсутствуют: {$stats['missing']}\n\n";
if (!empty($stats['wrong_place_files'])) {
echo "ФАЙЛЫ В НЕПРАВИЛЬНОМ МЕСТЕ:\n";
foreach ($stats['wrong_place_files'] as $file) {
echo " - Документ {$file['doc_id']}\n";
echo " От: {$file['current_path']}\n";
echo " К: {$file['should_be']}\n";
}
echo "\n";
}
if (!empty($stats['missing_files'])) {
echo "ОТСУТСТВУЮЩИЕ ФАЙЛЫ:\n";
foreach ($stats['missing_files'] as $file) {
echo " - Документ {$file['doc_id']}: {$file['path']}\n";
}
echo "\n";
}
echo "=== ГОТОВО ===\n";
} catch (Exception $e) {
echo "❌ Ошибка: " . $e->getMessage() . "\n";
exit(1);
}

View File

@@ -0,0 +1,172 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
$config = require '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php';
$projectId = 384256;
$s3Bucket = $config['s3']['bucket'];
echo "Проверка доступности всех файлов проекта $projectId\n";
echo str_repeat("=", 80) . "\n\n";
try {
// Инициализация S3 клиента
$s3Client = new \Aws\S3\S3Client([
'version' => 'latest',
'region' => $config['s3']['region'],
'endpoint' => $config['s3']['endpoint'],
'use_path_style_endpoint' => true,
'credentials' => [
'key' => $config['s3']['key'],
'secret' => $config['s3']['secret'],
],
'suppress_php_deprecation_warning' => true
]);
// Подключение к БД
$pdo = new PDO(
"mysql:host={$dbconfig['db_server']};port=3306;dbname={$dbconfig['db_name']};charset=utf8",
$dbconfig['db_username'],
$dbconfig['db_password'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
// Получаем все документы проекта
$stmt = $pdo->prepare('
SELECT n.notesid, n.title, n.s3_key, n.filename, n.filelocationtype
FROM vtiger_notes n
INNER JOIN vtiger_crmentity e ON e.crmid = n.notesid
INNER JOIN vtiger_senotesrel snr ON snr.notesid = n.notesid
WHERE snr.crmid = ? AND e.deleted = 0
ORDER BY n.notesid ASC
');
$stmt->execute([$projectId]);
$docs = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo "Всего документов в проекте: " . count($docs) . "\n\n";
$accessible = [];
$notAccessible = [];
foreach ($docs as $doc) {
$docId = $doc['notesid'];
$title = $doc['title'];
$s3Key = $doc['s3_key'];
$filelocationtype = $doc['filelocationtype'];
echo "ID: $docId | $title\n";
if ($filelocationtype == 'E' && !empty($s3Key)) {
// Проверяем доступность в S3
try {
$result = $s3Client->headObject([
'Bucket' => $s3Bucket,
'Key' => $s3Key
]);
$size = number_format($result['ContentLength'] / 1024, 2);
echo " ✅ Доступен в S3 (" . $size . " KB)\n";
echo " Путь: $s3Key\n";
$accessible[] = ['doc' => $doc, 'size' => $result['ContentLength']];
} catch (\Aws\Exception\AwsException $e) {
if ($e->getAwsErrorCode() == 'NotFound') {
echo "НЕ найден в S3\n";
echo " Ожидаемый путь: $s3Key\n";
$notAccessible[] = $doc;
} else {
echo " ⚠️ Ошибка доступа: " . $e->getAwsErrorCode() . "\n";
$notAccessible[] = $doc;
}
}
} else {
echo " ⚠️ Тип хранения: " . ($filelocationtype ?: 'не указан') . "\n";
if (!empty($doc['filename'])) {
echo " Filename: " . substr($doc['filename'], 0, 100) . "\n";
}
}
echo "\n";
}
echo str_repeat("=", 80) . "\n";
echo "СТАТИСТИКА:\n";
echo " Доступных файлов: " . count($accessible) . "\n";
echo " Недоступных файлов: " . count($notAccessible) . "\n\n";
// Поиск недоступных файлов в других местах S3
if (!empty($notAccessible)) {
echo "Поиск недоступных файлов в других местах S3...\n\n";
foreach ($notAccessible as $doc) {
$docId = $doc['notesid'];
$title = $doc['title'];
echo "Поиск файла для документа $docId: $title\n";
// Ищем по ID документа в разных местах
$searchPatterns = [
"temp/$projectId/",
"temp/",
"crm2/CRM_Active_Files/Documents/",
"Documents/",
];
$found = false;
foreach ($searchPatterns as $prefix) {
try {
$objects = $s3Client->listObjectsV2([
'Bucket' => $s3Bucket,
'Prefix' => $prefix,
'MaxKeys' => 1000
]);
if (isset($objects['Contents'])) {
foreach ($objects['Contents'] as $object) {
$key = $object['Key'];
// Ищем файлы, содержащие ID документа или похожие названия
if (strpos($key, (string)$docId) !== false ||
strpos($key, (string)($docId - 1)) !== false ||
strpos($key, (string)($docId + 1)) !== false) {
// Проверяем доступность
try {
$headResult = $s3Client->headObject([
'Bucket' => $s3Bucket,
'Key' => $key
]);
echo " ✅ НАЙДЕН: $key\n";
echo " Размер: " . number_format($headResult['ContentLength'] / 1024, 2) . " KB\n";
echo " Дата: " . ($headResult['LastModified'] ?? 'не указана') . "\n";
// Предлагаем переместить
echo " 💡 Рекомендация: переместить в правильный путь\n";
$found = true;
break 2;
} catch (\Aws\Exception\AwsException $e) {
// Пропускаем
}
}
}
}
} catch (\Aws\Exception\AwsException $e) {
// Пропускаем ошибки
}
}
if (!$found) {
echo " ❌ Файл не найден ни в одном месте S3\n";
}
echo "\n";
}
}
} catch (Exception $e) {
echo "ОШИБКА: " . $e->getMessage() . "\n";
echo "Trace: " . $e->getTraceAsString() . "\n";
}

116
check_s3_access_371231.php Normal file
View File

@@ -0,0 +1,116 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
require_once '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php';
$pdo = new PDO(
"mysql:host={$dbconfig['db_server']};port=3306;dbname={$dbconfig['db_name']};charset=utf8",
$dbconfig['db_username'],
$dbconfig['db_password'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$projectId = 371231;
// Получаем документы проекта
$sql = "SELECT
n.notesid,
n.title,
n.filename,
n.filelocationtype,
n.s3_bucket,
n.s3_key
FROM vtiger_notes n
INNER JOIN vtiger_crmentity e ON e.crmid = n.notesid
INNER JOIN vtiger_senotesrel snr ON snr.notesid = n.notesid
WHERE snr.crmid = ? AND e.deleted = 0 AND n.filelocationtype = 'E'
ORDER BY n.notesid DESC";
$stmt = $pdo->prepare($sql);
$stmt->execute([$projectId]);
$documents = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo "Проверка доступности S3 файлов для проекта $projectId\n";
echo str_repeat("=", 80) . "\n\n";
// Инициализируем S3 клиент
$s3Config = $config['s3'];
$awsClient = new \Aws\S3\S3Client([
'version' => 'latest',
'region' => $s3Config['region'],
'endpoint' => $s3Config['endpoint'],
'credentials' => [
'key' => $s3Config['key'],
'secret' => $s3Config['secret'],
],
'use_path_style_endpoint' => true,
]);
$accessible = 0;
$notAccessible = 0;
$errors = [];
foreach ($documents as $doc) {
$notesid = $doc['notesid'];
$title = $doc['title'];
$s3Bucket = $doc['s3_bucket'];
$s3Key = $doc['s3_key'];
try {
$exists = $awsClient->doesObjectExist($s3Bucket, $s3Key);
if ($exists) {
$accessible++;
echo "✅ ID: $notesid - $title\n";
} else {
$notAccessible++;
echo "❌ ID: $notesid - $title\n";
echo " S3 Key: $s3Key\n";
$errors[] = [
'id' => $notesid,
'title' => $title,
's3_key' => $s3Key,
'reason' => 'File does not exist in S3'
];
}
} catch (\Aws\Exception\AwsException $e) {
$notAccessible++;
echo "❌ ID: $notesid - $title\n";
echo " Ошибка: " . $e->getMessage() . "\n";
echo " S3 Key: $s3Key\n";
$errors[] = [
'id' => $notesid,
'title' => $title,
's3_key' => $s3Key,
'reason' => 'AWS Exception: ' . $e->getMessage()
];
} catch (Exception $e) {
$notAccessible++;
echo "❌ ID: $notesid - $title\n";
echo " Ошибка: " . $e->getMessage() . "\n";
$errors[] = [
'id' => $notesid,
'title' => $title,
's3_key' => $s3Key,
'reason' => 'Exception: ' . $e->getMessage()
];
}
}
echo "\n" . str_repeat("=", 80) . "\n";
echo "СТАТИСТИКА:\n";
echo " Доступных файлов: $accessible\n";
echo " Недоступных файлов: $notAccessible\n";
if (!empty($errors)) {
echo "\nНЕДОСТУПНЫЕ ФАЙЛЫ:\n";
foreach ($errors as $error) {
echo " - ID {$error['id']}: {$error['title']}\n";
echo " S3 Key: {$error['s3_key']}\n";
echo " Причина: {$error['reason']}\n\n";
}
}

191
cleanup_disk.php Normal file
View File

@@ -0,0 +1,191 @@
<?php
/**
* Скрипт для безопасной очистки диска
*
* Удаляет:
* 1. Старые бэкапы SQL (оставляет последние N)
* 2. Большие логи (очищает или удаляет старые)
*/
error_reporting(E_ALL);
ini_set('display_errors', 1);
$dryRun = !isset($argv[1]) || $argv[1] !== '--execute';
$keepBackups = isset($argv[2]) ? (int)$argv[2] : (isset($argv[1]) && is_numeric($argv[1]) ? (int)$argv[1] : 5); // Сколько бэкапов оставить
echo "Очистка диска\n";
echo str_repeat("=", 80) . "\n\n";
if (!$dryRun) {
echo "⚠️ РЕЖИМ ПРОВЕРКИ (dry-run)\n";
echo " Для реального удаления запустите: php cleanup_disk.php --execute [количество_бэкапов]\n";
echo " Пример: php cleanup_disk.php --execute 5\n\n";
} else {
echo "⚠️ РЕЖИМ УДАЛЕНИЯ - файлы будут удалены!\n\n";
}
$stats = [
'backups_found' => 0,
'backups_to_delete' => 0,
'backups_size' => 0,
'logs_found' => 0,
'logs_to_clean' => 0,
'logs_size' => 0,
'total_freed' => 0
];
// 1. Обработка бэкапов SQL
echo "1. ОБРАБОТКА БЭКАПОВ SQL\n";
echo str_repeat("-", 80) . "\n";
$backupDir = __DIR__;
$backups = glob($backupDir . '/backup_before_migration_*.sql');
if (empty($backups)) {
echo " Бэкапы не найдены\n\n";
} else {
$stats['backups_found'] = count($backups);
// Сортируем по дате изменения (новые первыми)
usort($backups, function($a, $b) {
return filemtime($b) - filemtime($a);
});
echo " Найдено бэкапов: {$stats['backups_found']}\n";
echo " Оставим последних: {$keepBackups}\n";
echo " Будет удалено: " . max(0, $stats['backups_found'] - $keepBackups) . "\n\n";
$toDelete = array_slice($backups, $keepBackups);
$stats['backups_to_delete'] = count($toDelete);
if (!empty($toDelete)) {
echo " Файлы для удаления:\n";
foreach ($toDelete as $backup) {
$size = filesize($backup);
$stats['backups_size'] += $size;
$sizeMB = round($size / 1024 / 1024, 2);
$date = date('Y-m-d H:i:s', filemtime($backup));
echo " - " . basename($backup) . " ({$sizeMB}MB, {$date})\n";
}
if ($dryRun && !empty($toDelete)) {
echo "\n Удаление файлов...\n";
foreach ($toDelete as $backup) {
if (unlink($backup)) {
echo "" . basename($backup) . " - удален\n";
} else {
echo "" . basename($backup) . " - ошибка удаления\n";
}
}
}
echo "\n";
} else {
echo " Нет файлов для удаления\n\n";
}
}
// 2. Обработка больших логов
echo "2. ОБРАБОТКА ЛОГОВ\n";
echo str_repeat("-", 80) . "\n";
$largeLogs = [
__DIR__ . '/wdall.log',
__DIR__ . '/wdall2.log',
__DIR__ . '/wa_inbound.log',
__DIR__ . '/wa_outbound.log',
];
foreach ($largeLogs as $logFile) {
if (file_exists($logFile)) {
$size = filesize($logFile);
$sizeMB = round($size / 1024 / 1024, 2);
if ($size > 10 * 1024 * 1024) { // Больше 10MB
$stats['logs_found']++;
$stats['logs_size'] += $size;
echo " Найден большой лог: " . basename($logFile) . " ({$sizeMB}MB)\n";
if ($dryRun) {
// Очищаем лог (оставляем последние 1000 строк)
$lines = file($logFile);
if (count($lines) > 1000) {
$keepLines = array_slice($lines, -1000);
if (file_put_contents($logFile, implode('', $keepLines))) {
$newSize = filesize($logFile);
$freedMB = round(($size - $newSize) / 1024 / 1024, 2);
echo " ✅ Очищен (освобождено {$freedMB}MB)\n";
$stats['logs_to_clean']++;
} else {
echo " ❌ Ошибка очистки\n";
}
} else {
echo " Лог небольшой, пропущен\n";
}
} else {
echo " ⏸️ Будет очищен (dry-run)\n";
$stats['logs_to_clean']++;
}
}
}
}
// Обработка логов в папке logs/
$logsDir = __DIR__ . '/logs';
if (is_dir($logsDir)) {
$logFiles = glob($logsDir . '/*.log*');
foreach ($logFiles as $logFile) {
$size = filesize($logFile);
if ($size > 20 * 1024 * 1024) { // Больше 20MB
$sizeMB = round($size / 1024 / 1024, 2);
$mtime = filemtime($logFile);
$daysOld = (time() - $mtime) / 86400;
if ($daysOld > 7) {
$stats['logs_found']++;
$stats['logs_size'] += $size;
echo " Старый большой лог: " . basename($logFile) . " ({$sizeMB}MB, " . round($daysOld) . " дней)\n";
if ($dryRun) {
if (unlink($logFile)) {
echo " ✅ Удален\n";
$stats['logs_to_clean']++;
} else {
echo " ❌ Ошибка удаления\n";
}
} else {
echo " ⏸️ Будет удален (dry-run)\n";
$stats['logs_to_clean']++;
}
}
}
}
}
echo "\n";
// Итоговая статистика
$stats['total_freed'] = $stats['backups_size'] + $stats['logs_size'];
$totalFreedMB = round($stats['total_freed'] / 1024 / 1024, 2);
$totalFreedGB = round($stats['total_freed'] / 1024 / 1024 / 1024, 2);
echo str_repeat("=", 80) . "\n";
echo "ИТОГОВАЯ СТАТИСТИКА:\n\n";
echo "Бэкапы:\n";
echo " - Найдено: {$stats['backups_found']}\n";
echo " - Будет удалено: {$stats['backups_to_delete']}\n";
echo " - Размер: " . round($stats['backups_size'] / 1024 / 1024 / 1024, 2) . "GB\n\n";
echo "Логи:\n";
echo " - Найдено больших: {$stats['logs_found']}\n";
echo " - Будет обработано: {$stats['logs_to_clean']}\n";
echo " - Размер: " . round($stats['logs_size'] / 1024 / 1024, 2) . "MB\n\n";
echo "ОБЩЕЕ ОСВОБОЖДЕНИЕ: {$totalFreedGB}GB ({$totalFreedMB}MB)\n\n";
if (!$dryRun) {
echo "⚠️ Это был режим проверки. Для реального удаления запустите:\n";
echo " php cleanup_disk.php --execute {$keepBackups}\n\n";
} else {
echo "✅ Очистка завершена!\n\n";
}

36
cleanup_nextcloud_logs.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
# Скрипт для очистки логов Nextcloud
echo "=== ОЧИСТКА ЛОГОВ NEXTCLOUD ==="
echo ""
# Проверяем размеры логов
echo "Размеры логов до очистки:"
docker exec nextcloud-fresh find /var/www/html/data -name "*.log" -type f -exec ls -lh {} \; 2>&1 | awk '{print $5, $9}'
echo ""
echo "Очистка логов..."
# Очищаем nextcloud.log если больше 100MB
SIZE=$(docker exec nextcloud-fresh stat -c%s /var/www/html/data/nextcloud.log 2>/dev/null || echo "0")
if [ "$SIZE" -gt 104857600 ]; then
echo "nextcloud.log больше 100MB, очищаем..."
docker exec nextcloud-fresh truncate -s 0 /var/www/html/data/nextcloud.log
echo "✅ nextcloud.log очищен"
fi
# Очищаем flow.log если больше 50MB
SIZE=$(docker exec nextcloud-fresh stat -c%s /var/www/html/data/flow.log 2>/dev/null || echo "0")
if [ "$SIZE" -gt 52428800 ]; then
echo "flow.log больше 50MB, очищаем..."
docker exec nextcloud-fresh truncate -s 0 /var/www/html/data/flow.log
echo "✅ flow.log очищен"
fi
echo ""
echo "Размеры логов после очистки:"
docker exec nextcloud-fresh find /var/www/html/data -name "*.log" -type f -exec ls -lh {} \; 2>&1 | awk '{print $5, $9}'
echo ""
echo "✅ Очистка завершена"

View File

@@ -79,9 +79,25 @@ class S3Client {
/**
* Создание временной ссылки для скачивания
* @param string $s3Key S3 ключ файла
* @param mixed $expiresIn Время жизни URL в секундах (число) или строка типа '+10 minutes'
*/
public function getPresignedUrl($s3Key, $expiresIn = 3600) {
try {
// Преобразуем строку TTL в секунды, если нужно
if (is_string($expiresIn)) {
// Если строка начинается с '+', используем её как есть для strtotime
if (strpos($expiresIn, '+') === 0) {
$expiresIn = strtotime($expiresIn) - time();
} else {
// Иначе пытаемся распарсить как число секунд
$expiresIn = (int)$expiresIn;
}
}
// Минимум 60 секунд, максимум 7 дней
$expiresIn = max(60, min($expiresIn, 604800));
$cmd = $this->client->getCommand('GetObject', [
'Bucket' => $this->bucket,
'Key' => $s3Key
@@ -97,7 +113,9 @@ class S3Client {
} catch (AwsException $e) {
return [
'success' => false,
'error' => $e->getMessage()
'error' => $e->getMessage(),
'error_code' => $e->getAwsErrorCode(),
'request_id' => $e->getAwsRequestId()
];
}
}

View File

@@ -432,3 +432,9 @@ PUBLISH ai:response:task-xxx {"success": true, "documentUrl": "..."}
- Ошибки изолированы
- Легко отлаживать

View File

@@ -197,3 +197,9 @@ https://crm.clientright.ru/crm_extensions/file_storage/api/create_document_with_
- Документ сразу доступен для редактирования в OnlyOffice
- Путь формируется автоматически: `{module}/{recordName}_{recordId}/{fileName}.{ext}`

View File

@@ -244,3 +244,9 @@ _курсив_
3. **Гибкость** — можно комбинировать элементы
4. **Автоматическое форматирование** — документ получается красивым без ручной правки

View File

@@ -152,3 +152,9 @@ curl -X POST "https://crm.clientright.ru/crm_extensions/file_storage/api/create_
}'
```

View File

@@ -211,3 +211,9 @@ curl -u admin:office "https://office.clientright.ru:8443/remote.php/dav/files/ad
- [Nextcloud WebDAV API](https://docs.nextcloud.com/server/latest/user_manual/files/webdav.html)
- [OnlyOffice Integration](https://api.onlyoffice.com/)

View File

@@ -246,3 +246,9 @@ echo json_encode(['success' => true, 'templates' => $templates]);
3. **Решение:** Использовать WebDAV PROPFIND для получения списка файлов из папки Templates
4. **Статус:** Наш текущий подход (WebDAV + PHPWord) является правильным и оптимальным решением

View File

@@ -133,3 +133,9 @@ curl "https://office.clientright.ru:8443/index.php/apps/onlyoffice/ajax/template
3. Протестировать получение списка через `list_templates.php`
4. Использовать шаблоны через `create_from_template.php`

View File

@@ -15,6 +15,10 @@ function openProjectFolder(projectId, projectName) {
projectName = projectName.replace(/"/g, '_');
// Заменяем ВСЕ пробелы на подчёркивания
projectName = projectName.replace(/\s+/g, '_');
// Заменяем множественные подчёркивания на одинарное
projectName = projectName.replace(/_+/g, '_');
// Убираем подчёркивания в начале и конце
projectName = projectName.replace(/^_+|_+$/g, '');
}
// Формируем URL для папки проекта в Nextcloud

View File

@@ -0,0 +1,124 @@
# Авторизация ej.sudrf.ru через ЕСИА (n8n + Browserless)
Скрипт `browserless_login_esia.js` выполняет в headless-браузере (Browserless) авторизацию на портале ГАС «Правосудие» через ЕСИА (Госуслуги) и доводит процесс до экрана ввода SMS-кода.
## Назначение
- Используется в n8n workflow: нода **HTTP Request** дергает Browserless по адресу вида
`http://<browserless-host>:3000/function?token=...&timeout=180000`.
- Вход: логин (телефон/почта/СНИЛС) и пароль от ЕСИА.
- Выход: статус `waiting_for_sms`, куки сессии и скриншот — для следующего шага (ввод SMS и получение финальных кук ej.sudrf.ru).
## Требования
- **Browserless** (Puppeteer): endpoint `/function` с передачей кода и опционально `context`/полей в body.
- **n8n**: предыдущая нода (например, Set) передаёт `login` и `pass` (или они задаются в body запроса к Browserless).
## Шаг 1: До экрана SMS (этот скрипт)
### Что делает скрипт
1. Открывает `https://ej.sudrf.ru/?fromOa=16RS0018`.
2. Если нужно — кликает «Вход» и переходит на страницу «Авторизация пользователя».
3. Ставит галочку согласия (`#iAgree`), ждёт активации кнопки «Войти», нажимает её.
4. На ЕСИА заполняет логин и пароль (видимые поля, ввод через клавиатуру + blur/change).
5. Нажимает «Войти» на ЕСИА.
6. Ждёт появления полей для ввода SMS-кода (или перехода на другую страницу).
7. Возвращает куки через `page.cookies()` и скриншот.
### Входные данные
Скрипт принимает второй аргумент `input` (объект из body запроса к Browserless):
| Поле | Описание |
|------------|------------------------------------|
| `login` | Телефон / эл. почта / СНИЛС (ЕСИА) |
| `pass` или `password` | Пароль ЕСИА |
Либо те же поля внутри `input.context` (например `context.login`, `context.pass`).
В n8n в **Body** запроса к Browserless можно передать:
- Отдельные поля (рекомендуется, без спецсимволов в коде):
```json
{
"code": "<содержимое browserless_login_esia.js>",
"login": "={{ $json.login }}",
"pass": "={{ $json.pass }}"
}
```
- Либо один объект `context`:
```json
{
"code": "...",
"context": {
"login": "={{ $json.login }}",
"pass": "={{ $json.pass }}"
}
}
```
Пароль может содержать спецсимволы (например `!`); передавать его отдельным полем в body предпочтительно, чтобы не ломать разбор кода.
### Выходные данные
При успехе:
- `status`: `"waiting_for_sms"`
- `url`: текущий URL (страница ЕСИА с полями SMS).
- `cookies`: массив кук (для передачи во второй скрипт или сохранения).
- `screenshot`: base64 скриншот страницы.
- `sms_inputs_count`: число полей для ввода кода.
- `session_data`: заметка по использованию кук.
При ошибке:
- `status`: `"error"`
- `error_type`: тип ошибки (см. ниже).
- `error_message`: текст.
- `current_url`, `page_text`, `screenshot` — для отладки.
- Для `login_failed` дополнительно может быть объект `debug` (какой инпут использовался).
### Типы ошибок
| error_type | Описание |
|---------------------------|----------|
| `login_button_not_found` | Не найдена кнопка «Вход» на ej.sudrf.ru. |
| `esia_redirect_failed` | После «Войти» не произошёл редирект на ЕСИА. |
| `login_input_not_found` | Не найдено поле логина на странице ЕСИА. |
| `password_input_not_found`| Не найдено поле пароля на ЕСИА. |
| `login_failed` | После нажатия «Войти» на ЕСИА остались на /login/ (форма не приняла логин/пароль или валидация). |
| `sms_page_not_found` | Не найдены поля для ввода SMS-кода. |
## Шаг 2: Ввод SMS и получение кук ej.sudrf.ru
Отдельный скрипт (или вторая нода) должен:
1. Принять от пользователя SMS-код (например через Telegram или Webhook).
2. Восстановить сессию: передать в Browserless сохранённые `cookies` из шага 1.
3. Открыть страницу ЕСИА с полями SMS (или текущий URL из шага 1).
4. Ввести код по цифре в каждое поле (или в одно поле, в зависимости от разметки ЕСИА).
5. Дождаться редиректа на `ej.sudrf.ru`.
6. Собрать куки для ej.sudrf.ru (`PHPSESSID`, `fromOa` и др.) и вернуть их (например записать в файл или передать в следующую ноду).
Формат кук для последующих запросов к ej.sudrf.ru:
```
PHPSESSID=...; fromOa=16RS0018
```
## Технические детали
- **Куки в Browserless**: в окружении `/function` используется `page.cookies()`, а не `browserContext.cookies()`.
- **Видимость полей**: на ЕСИА выбираются только видимые инпуты (проверка по `boundingBox`), чтобы не заполнять скрытые дубликаты.
- **Таймауты**: по умолчанию используются таймауты порядка 1530 с для навигации и ожидания селекторов; при необходимости их можно увеличить в параметрах вызова Browserless (`timeout` в URL).
## Файлы
- `browserless_login_esia.js` — скрипт шага 1 (до SMS).
- Документация: этот файл (`docs/BROWSERLESS_EJ_SUDRF_ESIA_LOGIN.md`).
## См. также
- Настройка n8n: передача `login`/`pass` из предыдущей ноды (Set) или из Credentials (Custom Auth) в body HTTP Request к Browserless.
- Сохранение кук: после шага 2 записать строку кук в файл или переменную для использования в запросах к ej.sudrf.ru (подача обращений и т.д.).

View File

@@ -0,0 +1,132 @@
<?php
/**
* Исправление всех таблиц с utf8mb3_bin на utf8mb4_general_ci
*/
$dbHost = '192.168.128.3';
$dbUser = 'nextcloud';
$dbPass = 'nextcloud_password';
$dbName = 'nextcloud';
try {
$pdo = new PDO(
"mysql:host=$dbHost;dbname=$dbName;charset=utf8mb4",
$dbUser,
$dbPass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
echo "=== ИСПРАВЛЕНИЕ ВСЕХ ТАБЛИЦ С utf8mb3_bin ===\n\n";
// Находим все колонки с utf8mb3_bin
$query = "
SELECT
TABLE_NAME,
COLUMN_NAME,
DATA_TYPE,
COLUMN_TYPE,
CHARACTER_SET_NAME,
COLLATION_NAME
FROM
INFORMATION_SCHEMA.COLUMNS
WHERE
TABLE_SCHEMA = ?
AND TABLE_NAME LIKE 'oc_%'
AND COLLATION_NAME LIKE '%utf8mb3%'
ORDER BY TABLE_NAME, COLUMN_NAME
";
$stmt = $pdo->prepare($query);
$stmt->execute([$dbName]);
$columns = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($columns)) {
echo "Все колонки уже имеют правильную collation!\n";
exit(0);
}
echo "Найдено колонок с utf8mb3: " . count($columns) . "\n\n";
$fixed = 0;
$errors = 0;
$tables = [];
foreach ($columns as $col) {
$table = $col['TABLE_NAME'];
$column = $col['COLUMN_NAME'];
$dataType = $col['DATA_TYPE'];
$columnType = $col['COLUMN_TYPE'];
$charSet = $col['CHARACTER_SET_NAME'];
$collation = $col['COLLATION_NAME'];
// Группируем по таблицам
if (!isset($tables[$table])) {
$tables[$table] = [];
}
$tables[$table][] = $col;
}
// Исправляем каждую таблицу
foreach ($tables as $table => $tableColumns) {
echo "Таблица: $table\n";
foreach ($tableColumns as $col) {
$column = $col['COLUMN_NAME'];
$columnType = $col['COLUMN_TYPE'];
// Получаем полную информацию о колонке
$colInfoQuery = "SHOW FULL COLUMNS FROM `$table` WHERE Field = ?";
$colInfoStmt = $pdo->prepare($colInfoQuery);
$colInfoStmt->execute([$column]);
$colInfo = $colInfoStmt->fetch(PDO::FETCH_ASSOC);
if (!$colInfo) {
echo " ⚠️ Не удалось получить информацию о колонке $column\n";
continue;
}
// Строим ALTER TABLE запрос
$type = $colInfo['Type'];
// Заменяем utf8mb3 на utf8mb4
$type = preg_replace('/utf8mb3/i', 'utf8mb4', $type);
$type = preg_replace('/utf8(_bin)?/i', 'utf8mb4', $type);
// Убираем старую collation и добавляем новую
$type = preg_replace('/COLLATE\s+\w+/i', '', $type);
$type = preg_replace('/CHARACTER\s+SET\s+\w+/i', '', $type);
// Добавляем новую collation
if (preg_match('/varchar|char|text/i', $type)) {
$type .= ' CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci';
}
$null = $colInfo['Null'] === 'YES' ? 'NULL' : 'NOT NULL';
$default = '';
if ($colInfo['Default'] !== null) {
$default = "DEFAULT '" . addslashes($colInfo['Default']) . "'";
}
$extra = $colInfo['Extra'] ?: '';
$alterQuery = "ALTER TABLE `$table` MODIFY COLUMN `$column` $type $null $default $extra";
try {
echo " Исправляю: $column ... ";
$pdo->exec($alterQuery);
echo "\n";
$fixed++;
} catch (PDOException $e) {
echo "❌ Ошибка: " . $e->getMessage() . "\n";
$errors++;
}
}
echo "\n";
}
echo "\n=== РЕЗУЛЬТАТ ===\n";
echo "Исправлено колонок: $fixed\n";
echo "Ошибок: $errors\n";
} catch (PDOException $e) {
echo "❌ Ошибка подключения к БД: " . $e->getMessage() . "\n";
exit(1);
}

30
fix_indexes_collation.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/bin/bash
# Исправление индексов с неправильной collation в Nextcloud
echo "=== ИСПРАВЛЕНИЕ ИНДЕКСОВ С НЕПРАВИЛЬНОЙ COLLATION ==="
echo ""
# Получаем список таблиц с проблемными индексами
docker exec nextcloud-db-fresh mariadb -unextcloud -pnextcloud_password nextcloud -e "
SELECT DISTINCT TABLE_NAME
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'nextcloud'
AND TABLE_NAME LIKE 'oc_%'
AND COLLATION = 'utf8mb3_general_ci';
" 2>&1 | grep -v "Warning" | grep -v "TABLE_NAME" | while read table; do
if [ -n "$table" ]; then
echo "Проверяю таблицу: $table"
# Получаем информацию об индексах
docker exec nextcloud-db-fresh mariadb -unextcloud -pnextcloud_password nextcloud -e "SHOW INDEX FROM \`$table\`;" 2>&1 | grep -i "utf8mb3" || echo " ✅ Нет проблемных индексов"
fi
done
echo ""
echo "=== РЕКОМЕНДАЦИЯ ==="
echo "Если проблема сохраняется, попробуйте:"
echo "1. Пересоздать индексы через Nextcloud:"
echo " docker exec nextcloud-fresh php occ db:add-missing-indices"
echo ""
echo "2. Или временно отключить синхронизацию в клиенте Nextcloud"
echo " и открыть файлы через Web UI для индексации"

104
fix_nextcloud_collation.php Normal file
View File

@@ -0,0 +1,104 @@
<?php
/**
* Исправление проблемы с collation в БД Nextcloud
* Заменяет utf8mb3_general_ci на utf8mb4_general_ci
*/
error_reporting(E_ALL);
ini_set('display_errors', 1);
echo "=== ИСПРАВЛЕНИЕ COLLATION В БД NEXTCLOUD ===\n";
echo str_repeat("=", 80) . "\n\n";
// Подключение к БД Nextcloud
$host = '192.168.128.3';
$user = 'nextcloud';
$password = 'nextcloud_password';
$database = 'nextcloud';
try {
$db = new mysqli($host, $user, $password, $database);
if ($db->connect_error) {
throw new Exception("Ошибка подключения: " . $db->connect_error);
}
$db->set_charset('utf8mb4');
echo "✅ Подключились к БД Nextcloud\n\n";
// Находим таблицы с неправильной collation
echo "🔍 Поиск таблиц с неправильной collation...\n";
$result = $db->query("
SELECT TABLE_NAME, TABLE_COLLATION
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'nextcloud'
AND TABLE_COLLATION LIKE '%utf8mb3%'
");
$tables = [];
while ($row = $result->fetch_assoc()) {
$tables[] = $row['TABLE_NAME'];
echo " - {$row['TABLE_NAME']}: {$row['TABLE_COLLATION']}\n";
}
if (empty($tables)) {
echo "Все таблицы имеют правильную collation\n";
exit(0);
}
echo "\n📊 Найдено таблиц для исправления: " . count($tables) . "\n\n";
// Исправляем collation для каждой таблицы
echo "🔧 Исправление collation...\n\n";
$fixed = 0;
$errors = 0;
foreach ($tables as $table) {
echo " Исправление таблицы: {$table}... ";
// Изменяем collation таблицы
$sql = "ALTER TABLE `{$table}` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci";
if ($db->query($sql)) {
echo "\n";
$fixed++;
} else {
echo "❌ Ошибка: " . $db->error . "\n";
$errors++;
}
}
echo "\n";
echo str_repeat("=", 80) . "\n";
echo "ИТОГИ:\n";
echo "Исправлено таблиц: {$fixed}\n";
echo "Ошибок: {$errors}\n\n";
// Проверяем результат
echo "🔍 Проверка результата...\n";
$result = $db->query("
SELECT COUNT(*) as count
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'nextcloud'
AND TABLE_COLLATION LIKE '%utf8mb3%'
");
$row = $result->fetch_assoc();
if ($row['count'] == 0) {
echo "Все таблицы исправлены!\n";
} else {
echo "⚠️ Осталось таблиц с неправильной collation: {$row['count']}\n";
}
$db->close();
echo "\n=== ГОТОВО ===\n";
} catch (Exception $e) {
echo "❌ Критическая ошибка: " . $e->getMessage() . "\n";
exit(1);
}

View File

@@ -0,0 +1,137 @@
<?php
/**
* Исправление всех колонок с неправильной collation в Nextcloud
* Исправляет utf8mb3_general_ci → utf8mb4_general_ci
*/
$dbHost = '192.168.128.3';
$dbUser = 'nextcloud';
$dbPass = 'nextcloud_password';
$dbName = 'nextcloud';
try {
$pdo = new PDO(
"mysql:host=$dbHost;dbname=$dbName;charset=utf8mb4",
$dbUser,
$dbPass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
echo "=== ИСПРАВЛЕНИЕ COLLATION В NEXTCLOUD ===\n\n";
// Находим все колонки с неправильной collation
$query = "
SELECT
TABLE_NAME,
COLUMN_NAME,
DATA_TYPE,
CHARACTER_SET_NAME,
COLLATION_NAME
FROM
INFORMATION_SCHEMA.COLUMNS
WHERE
TABLE_SCHEMA = ?
AND TABLE_NAME LIKE 'oc_%'
AND COLLATION_NAME = 'utf8mb3_general_ci'
ORDER BY TABLE_NAME, COLUMN_NAME
";
$stmt = $pdo->prepare($query);
$stmt->execute([$dbName]);
$columns = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($columns)) {
echo "Все колонки уже имеют правильную collation!\n";
exit(0);
}
echo "Найдено колонок с неправильной collation: " . count($columns) . "\n\n";
$fixed = 0;
$errors = 0;
foreach ($columns as $col) {
$table = $col['TABLE_NAME'];
$column = $col['COLUMN_NAME'];
$dataType = $col['DATA_TYPE'];
$charSet = $col['CHARACTER_SET_NAME'];
// Определяем новый тип данных
$newCharSet = 'utf8mb4';
$newCollation = 'utf8mb4_general_ci';
// Для TEXT типов нужно указать CHARACTER SET
$alterQuery = "ALTER TABLE `$table` MODIFY COLUMN `$column` ";
if (in_array(strtoupper($dataType), ['VARCHAR', 'CHAR', 'TEXT', 'TINYTEXT', 'MEDIUMTEXT', 'LONGTEXT'])) {
// Получаем текущие параметры колонки
$colInfoQuery = "SHOW FULL COLUMNS FROM `$table` WHERE Field = ?";
$colInfoStmt = $pdo->prepare($colInfoQuery);
$colInfoStmt->execute([$column]);
$colInfo = $colInfoStmt->fetch(PDO::FETCH_ASSOC);
if ($colInfo) {
$type = $colInfo['Type'];
// Заменяем charset в типе
$type = preg_replace('/utf8mb3/i', 'utf8mb4', $type);
$type = preg_replace('/utf8(_general_ci)?/i', 'utf8mb4', $type);
$null = $colInfo['Null'] === 'YES' ? 'NULL' : 'NOT NULL';
$default = $colInfo['Default'] !== null ? "DEFAULT '{$colInfo['Default']}'" : '';
$extra = $colInfo['Extra'] ?: '';
$alterQuery .= "$type CHARACTER SET $newCharSet COLLATE $newCollation $null $default $extra";
} else {
echo "⚠️ Не удалось получить информацию о колонке $table.$column\n";
continue;
}
} else {
// Для других типов просто меняем collation
$alterQuery .= "`$column` $dataType CHARACTER SET $newCharSet COLLATE $newCollation";
}
try {
echo "Исправляю: $table.$column ... ";
$pdo->exec($alterQuery);
echo "\n";
$fixed++;
} catch (PDOException $e) {
echo "❌ Ошибка: " . $e->getMessage() . "\n";
$errors++;
}
}
echo "\n=== РЕЗУЛЬТАТ ===\n";
echo "Исправлено: $fixed\n";
echo "Ошибок: $errors\n";
// Проверяем индексы
echo "\n=== ПРОВЕРКА ИНДЕКСОВ ===\n";
$indexQuery = "
SELECT DISTINCT
TABLE_NAME,
INDEX_NAME
FROM
INFORMATION_SCHEMA.STATISTICS
WHERE
TABLE_SCHEMA = ?
AND TABLE_NAME LIKE 'oc_%'
AND COLLATION = 'utf8mb3_general_ci'
";
$indexStmt = $pdo->prepare($indexQuery);
$indexStmt->execute([$dbName]);
$indexes = $indexStmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($indexes)) {
echo "⚠️ Найдено индексов с неправильной collation: " . count($indexes) . "\n";
echo "Индексы нужно пересоздать вручную или через Nextcloud\n";
} else {
echo "Все индексы имеют правильную collation\n";
}
} catch (PDOException $e) {
echo "❌ Ошибка подключения к БД: " . $e->getMessage() . "\n";
exit(1);
}

38
fix_nextcloud_issues.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
# Исправление проблем Nextcloud из страницы проверки безопасности
echo "=== ИСПРАВЛЕНИЕ ПРОБЛЕМ NEXTCLOUD ==="
echo ""
# 1. Запуск background jobs вручную
echo "1. Запуск background jobs..."
docker exec nextcloud-fresh php occ background:cron 2>&1 | head -20
# 2. Проверка и исправление collation для поддержки 4-байтовых символов
echo ""
echo "2. Проверка collation для поддержки 4-байтовых символов..."
docker exec nextcloud-db-fresh mariadb -unextcloud -pnextcloud_password nextcloud -e "
SELECT
TABLE_NAME,
COUNT(*) as bad_cols
FROM
INFORMATION_SCHEMA.COLUMNS
WHERE
TABLE_SCHEMA = 'nextcloud'
AND TABLE_NAME LIKE 'oc_%'
AND COLLATION_NAME = 'utf8mb3_general_ci'
GROUP BY TABLE_NAME
ORDER BY bad_cols DESC;
" 2>&1 | grep -v "Warning"
echo ""
echo "=== РЕКОМЕНДАЦИИ ==="
echo ""
echo "Для автоматического запуска background jobs добавьте в crontab:"
echo "*/5 * * * * docker exec nextcloud-fresh php occ background:cron"
echo ""
echo "Или используйте webcron (менее надежно):"
echo "docker exec nextcloud-fresh php occ config:app:set core backgroundjobs_mode --value='webcron'"
echo ""
echo "Для исправления collation запустите скрипт fix_nextcloud_collation_all.php"

150
fix_nextcloud_settings.php Executable file
View File

@@ -0,0 +1,150 @@
<?php
/**
* Скрипт для настройки Nextcloud и защиты от удаления файлов
*
* Выполняет:
* 1. Отключает DeleteOrphanedItems
* 2. Включает readonly для External Storage
* 3. Увеличивает retention корзины до 365 дней
*/
error_reporting(E_ALL);
ini_set('display_errors', 1);
echo "Настройка Nextcloud для защиты от удаления файлов\n";
echo str_repeat("=", 80) . "\n\n";
// Проверяем, доступен ли Docker
$dockerAvailable = shell_exec('which docker 2>/dev/null');
if (!$dockerAvailable) {
die("❌ Docker не найден. Убедитесь, что Docker установлен и доступен.\n");
}
// Имя контейнера Nextcloud
$containerName = 'nextcloud-fresh';
$user = 'www-data';
// Проверяем, существует ли контейнер
$containerExists = shell_exec("docker ps -a --filter 'name=$containerName' --format '{{.Names}}' 2>/dev/null");
if (empty(trim($containerExists))) {
echo "⚠️ Контейнер '$containerName' не найден.\n";
echo "Попробуем найти контейнер Nextcloud...\n";
$allContainers = shell_exec("docker ps -a --format '{{.Names}}' 2>/dev/null");
echo "Доступные контейнеры:\n";
echo $allContainers . "\n";
echo "\nВведите имя контейнера Nextcloud (или нажмите Enter для пропуска): ";
$handle = fopen("php://stdin", "r");
$line = fgets($handle);
$containerName = trim($line);
fclose($handle);
if (empty($containerName)) {
echo "Пропускаем настройку Nextcloud.\n";
exit(0);
}
}
echo "Используем контейнер: $containerName\n\n";
$commands = [];
$results = [];
// 1. Отключить DeleteOrphanedItems
echo "1. Отключение DeleteOrphanedItems...\n";
$jobId = 31; // ID задачи DeleteOrphanedItems
$cmd = "docker exec -u $user $containerName php occ background-job:delete $jobId 2>&1";
echo " Команда: $cmd\n";
$output = shell_exec($cmd);
$results['delete_orphaned'] = $output;
echo " Результат: " . (empty($output) ? "✅ Команда выполнена" : $output) . "\n\n";
// Проверяем статус задачи
$checkCmd = "docker exec -u $user $containerName php occ background-job:list 2>&1 | grep -i 'DeleteOrphanedItems' || echo 'Задача не найдена (возможно, уже отключена)'";
$checkOutput = shell_exec($checkCmd);
echo " Проверка: $checkOutput\n\n";
// 2. Включить readonly для External Storage
echo "2. Включение readonly для External Storage...\n";
// Сначала найдем ID внешнего хранилища
$listCmd = "docker exec -u $user $containerName php occ files_external:list 2>&1";
$listOutput = shell_exec($listCmd);
echo " Список внешних хранилищ:\n";
echo " $listOutput\n";
// Обычно ID = 1 для первого хранилища, но проверим
$storageId = 1;
$readonlyCmd = "docker exec -u $user $containerName php occ files_external:option $storageId readonly true 2>&1";
echo " Команда: $readonlyCmd\n";
$readonlyOutput = shell_exec($readonlyCmd);
$results['readonly'] = $readonlyOutput;
echo " Результат: " . (empty($readonlyOutput) || strpos($readonlyOutput, 'error') === false ? "✅ Readonly включен" : $readonlyOutput) . "\n\n";
// Проверяем настройки
$verifyCmd = "docker exec -u $user $containerName php occ files_external:list --output json 2>&1";
$verifyOutput = shell_exec($verifyCmd);
echo " Проверка настроек:\n";
echo " $verifyOutput\n\n";
// 3. Увеличить retention корзины до 365 дней
echo "3. Увеличение retention корзины до 365 дней...\n";
$retentionCmd = "docker exec -u $user $containerName php occ config:app:set files trashbin_retention_obligation --value=\"auto, 365\" 2>&1";
echo " Команда: $retentionCmd\n";
$retentionOutput = shell_exec($retentionCmd);
$results['retention'] = $retentionOutput;
echo " Результат: " . (empty($retentionOutput) || strpos($retentionOutput, 'error') === false ? "✅ Retention установлен на 365 дней" : $retentionOutput) . "\n\n";
// Проверяем текущее значение
$checkRetentionCmd = "docker exec -u $user $containerName php occ config:app:get files trashbin_retention_obligation 2>&1";
$checkRetentionOutput = shell_exec($checkRetentionCmd);
echo " Текущее значение retention: $checkRetentionOutput\n\n";
// 4. Настройка регулярной индексации
echo "4. Настройка регулярной индексации файлов...\n";
echo " Рекомендуется добавить в crontab:\n";
echo " 0 */6 * * * docker exec -u $user $containerName php occ files:scan --all\n";
echo " (сканирование каждые 6 часов)\n\n";
// Создаем скрипт для cron
$cronScript = <<<'SCRIPT'
#!/bin/bash
# Скрипт для регулярной индексации файлов Nextcloud
# Запускать каждые 6 часов через cron
CONTAINER_NAME="nextcloud-fresh"
USER="www-data"
# Сканируем все файлы
docker exec -u $USER $CONTAINER_NAME php occ files:scan --all >> /var/log/nextcloud_scan.log 2>&1
# Сканируем только внешнее хранилище (быстрее)
# docker exec -u $USER $CONTAINER_NAME php occ files:scan --path="/crm" >> /var/log/nextcloud_scan.log 2>&1
echo "$(date): Nextcloud files scan completed" >> /var/log/nextcloud_scan.log
SCRIPT;
$cronScriptPath = '/var/www/fastuser/data/www/crm.clientright.ru/nextcloud_scan_files.sh';
file_put_contents($cronScriptPath, $cronScript);
chmod($cronScriptPath, 0755);
echo " ✅ Создан скрипт: $cronScriptPath\n";
echo " Для добавления в crontab выполните:\n";
echo " crontab -e\n";
echo " Добавьте строку: 0 */6 * * * $cronScriptPath\n\n";
echo str_repeat("=", 80) . "\n";
echo "ИТОГОВЫЙ ОТЧЕТ:\n\n";
echo "✅ Выполнено:\n";
echo " 1. DeleteOrphanedItems отключен\n";
echo " 2. Readonly включен для External Storage\n";
echo " 3. Retention корзины увеличен до 365 дней\n";
echo " 4. Создан скрипт для регулярной индексации\n\n";
echo "⚠️ ВАЖНО:\n";
echo " - Добавьте скрипт индексации в crontab\n";
echo " - Проверьте логи Nextcloud на наличие ошибок\n";
echo " - Регулярно проверяйте статус задач: docker exec -u $user $containerName php occ background-job:list\n\n";
echo "📝 Логи команд сохранены в переменных \$results\n";

View File

@@ -115,10 +115,34 @@ try {
continue; // Пропускаем пустые сообщения
}
// Формируем timestamp в ISO формате для JavaScript
$timestamp = null;
if (isset($item['created_at']) && !empty($item['created_at'])) {
$createdAt = $item['created_at'];
// Если created_at уже в ISO формате (содержит 'T'), используем как есть
if (strpos($createdAt, 'T') !== false) {
// Уже в ISO формате (например, "2025-11-14T06:21:55.207Z"), используем как есть
$timestamp = $createdAt;
} else {
// Если в другом формате, преобразуем в ISO
$parsedTime = strtotime($createdAt);
if ($parsedTime !== false) {
$timestamp = date('c', $parsedTime); // ISO 8601 формат
} else {
// Если не удалось распарсить, используем текущее время
error_log("Chat History: Failed to parse created_at: {$createdAt}, using current time");
$timestamp = date('c');
}
}
} else {
// Если нет created_at, используем текущее время в ISO формате
$timestamp = date('c'); // ISO 8601 формат
}
$message = [
'type' => isset($item['sender_type']) && $item['sender_type'] === 'user' ? 'user' : 'assistant',
'message' => $item['content'] ?? '',
'timestamp' => isset($item['created_at']) ? date('H:i:s', strtotime($item['created_at'])) : date('H:i:s'),
'timestamp' => $timestamp,
'id' => $item['id'] ?? '',
'dialog_id' => $item['dialog_id'] ?? ''
];
@@ -131,7 +155,7 @@ try {
$history[] = [
'type' => 'assistant',
'message' => "Привет! Я ваш AI ассистент. Работаем с '{$projectName}'. Чем могу помочь?",
'timestamp' => date('H:i:s'),
'timestamp' => date('c'), // ISO 8601 формат
'id' => 'welcome-' . time(),
'dialog_id' => 'new-dialog'
];

View File

@@ -0,0 +1,175 @@
<?php
/*********************************************************************************
* API-интерфейс для создания Проекта КлиентПрав из web-формы ticket_form
* Уникальность проекта обеспечивается по claim_id (cf_2620)
* Автор: GPT-5.1 Codex & Фёдор, 2025-11-15
********************************************************************************/
include_once 'include/Webservices/Query.php';
include_once 'modules/Users/Users.php';
require_once('include/Webservices/Utils.php');
require_once 'include/Webservices/Create.php';
require_once 'include/Webservices/Retrieve.php';
require_once 'includes/Loader.php';
vimport ('includes.runtime.Globals');
vimport ('includes.runtime.BaseModel');
vimport ('includes.runtime.LanguageHandler');
/**
* Создание проекта КлиентПрав по заявке ticket_form
*
* @param string $contact_id - ID контакта (обязательное)
* @param string $claim_id - Уникальный ID обращения (обязательное, cf_2620)
* @param string $session_id - Сессия фронтенда (опционально, cf_2618)
* @param string $description - Описание пользователя (опционально, description)
* @param string $ai_response - Ответ AI модели (опционально, cf_2622)
* @param string $phone - Телефон для генерации имени (опционально)
* @param string $firstname - Имя контакта (опционально)
* @param string $lastname - Фамилия контакта (опционально)
* @return array {project_id, project_name, is_new}
*/
function vtws_createclientproject($contact_id, $claim_id, $session_id = '', $description = '', $ai_response = '', $phone = '', $firstname = '', $lastname = '', $user = false) {
ob_start();
$logPrefix = date("Y-m-d H:i:s") . ' ';
file_put_contents('logs/CreateClientProject.log', $logPrefix . json_encode($_REQUEST) . PHP_EOL, FILE_APPEND);
try {
global $adb, $current_user;
if (empty($claim_id)) {
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, "Не указан claim_id");
}
if (empty($contact_id)) {
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, "Не указан ID контакта");
}
$claim_id = trim($claim_id);
$session_id = trim($session_id);
// Нормализуем contact_id
$contactIdNumeric = preg_replace('/[^0-9]/', '', $contact_id);
$contactIdWithPrefix = '12x' . $contactIdNumeric;
// Подтягиваем данные контакта (фамилию/телефон), если не переданы
if (empty($lastname) || empty($firstname) || empty($phone)) {
try {
$contactRecord = vtws_retrieve($contactIdWithPrefix, $current_user);
if (empty($lastname) && !empty($contactRecord['lastname'])) {
$lastname = $contactRecord['lastname'];
}
if (empty($firstname) && !empty($contactRecord['firstname'])) {
$firstname = $contactRecord['firstname'];
}
if (empty($phone) && !empty($contactRecord['phone'])) {
$phone = preg_replace('/[^0-9]/', '', $contactRecord['phone']);
}
} catch (Exception $e) {
file_put_contents('logs/CreateClientProject.log', $logPrefix . '⚠️ Не удалось получить контакт: ' . $e->getMessage() . PHP_EOL, FILE_APPEND);
}
}
$isNew = false;
$output = null;
file_put_contents('logs/CreateClientProject.log', $logPrefix . "🔎 Ищем проект по claim_id={$claim_id}" . PHP_EOL, FILE_APPEND);
// Ищем проект по claim_id (cf_2620)
$query = "SELECT p.projectid
FROM vtiger_project p
INNER JOIN vtiger_projectcf pcf ON p.projectid = pcf.projectid
INNER JOIN vtiger_crmentity e ON e.crmid = p.projectid
WHERE e.deleted = 0
AND pcf.cf_2620 = ?
LIMIT 1";
$result = $adb->pquery($query, array($claim_id));
if (!$result) {
throw new Exception("SQL error while searching project");
}
$projectName = '';
if ($adb->num_rows($result) > 0) {
$output = $adb->query_result($result, 0, 'projectid');
$isNew = false;
file_put_contents('logs/CreateClientProject.log', $logPrefix . "✅ Проект найден по claim_id {$claim_id}: {$output}" . PHP_EOL, FILE_APPEND);
} else {
// Генерируем имя проекта
$lastname = trim($lastname);
if (!empty($lastname)) {
$projectName = $lastname . '_КлиентПрав';
} elseif (!empty($phone)) {
$projectName = $phone . '_КлиентПрав';
} else {
$projectName = 'КлиентПрав_' . $claim_id;
}
$params = array(
'projectname' => $projectName,
'projectstatus' => 'Черновик',
'projecttype' => 'претензионно-исковая работа',
'linktoaccountscontacts' => $contactIdWithPrefix,
'cf_1994' => '11x62345', // Заявитель (МОО КлиентПрав)
'cf_2620' => $claim_id,
'assigned_user_id' => vtws_getWebserviceEntityId('Users', $current_user->id)
);
if (!empty($session_id)) {
$params['cf_2618'] = $session_id;
}
if (!empty($description)) {
$params['description'] = $description;
}
if (!empty($ai_response)) {
$params['cf_2622'] = $ai_response;
}
file_put_contents('logs/CreateClientProject.log', $logPrefix . 'Массив для создания: ' . json_encode($params) . PHP_EOL, FILE_APPEND);
try {
$project = vtws_create('Project', $params, $current_user);
$output = substr($project['id'], 3);
$isNew = true;
file_put_contents('logs/CreateClientProject.log', $logPrefix . "✅ Создан новый проект: {$output}" . PHP_EOL, FILE_APPEND);
} catch (WebServiceException $ex) {
file_put_contents('logs/CreateClientProject.log', $logPrefix . '❌ Ошибка создания: ' . $ex->getMessage() . PHP_EOL, FILE_APPEND);
throw $ex;
}
}
// Получаем название проекта (если проект был найден, а не создан)
if (empty($projectName) && !empty($output)) {
try {
$query = "SELECT projectname FROM vtiger_project WHERE projectid = ? LIMIT 1";
$result = $adb->pquery($query, array($output));
if ($adb->num_rows($result) > 0) {
$projectName = $adb->query_result($result, 0, 'projectname');
file_put_contents('logs/CreateClientProject.log', $logPrefix . "📝 Получено название проекта: {$projectName}" . PHP_EOL, FILE_APPEND);
}
} catch (Exception $e) {
file_put_contents('logs/CreateClientProject.log', $logPrefix . '⚠️ Не удалось получить название проекта: ' . $e->getMessage() . PHP_EOL, FILE_APPEND);
}
}
$result = array(
'project_id' => $output,
'project_name' => $projectName,
'is_new' => $isNew
);
file_put_contents('logs/CreateClientProject.log', $logPrefix . 'Return: ' . json_encode($result) . PHP_EOL, FILE_APPEND);
ob_end_clean();
return $result;
} catch (Exception $ex) {
file_put_contents('logs/CreateClientProject.log', $logPrefix . '❌ Exception: ' . $ex->getMessage() . PHP_EOL, FILE_APPEND);
ob_end_clean();
throw $ex;
}
}

View File

@@ -65,6 +65,16 @@ function vtws_createwebclaim($title, $contact_id, $project_id, $event_type, $des
global $adb, $current_user;
// Нормализуем ID контакта и проекта (можно передавать как "12x123" или "123")
$contactIdNumeric = preg_replace('/[^0-9]/', '', $contact_id);
$projectIdNumeric = preg_replace('/[^0-9]/', '', $project_id);
$contactWsId = '12x' . $contactIdNumeric;
$projectWsId = '33x' . $projectIdNumeric;
$logstring = date('Y-m-d H:i:s').' Нормализовали ID: contact='.$contactIdNumeric.' (raw='.$contact_id.'), project='.$projectIdNumeric.' (raw='.$project_id.')'.PHP_EOL;
file_put_contents('logs/CreateWebClaim.log', $logstring, FILE_APPEND);
// Маппинг типов событий на русские названия для категории
$eventTypeMap = array(
'delay_flight' => 'Задержка рейса',
@@ -108,8 +118,8 @@ function vtws_createwebclaim($title, $contact_id, $project_id, $event_type, $des
'parent_id' => '11x67458', // Заявитель - контрагент
'ticketcategories' => $ticketCategory,
'ticketstatus' => 'рассмотрение',
'contact_id' => '12x'.$contact_id,
'cf_2066' => '33x'.$project_id, // Связь с проектом
'contact_id' => $contactWsId,
'cf_2066' => $projectWsId, // Связь с проектом
'ticketpriorities' => 'High',
'assigned_user_id' => vtws_getWebserviceEntityId('Users', $current_user->id),
'description' => $fullDescription
@@ -126,6 +136,32 @@ function vtws_createwebclaim($title, $contact_id, $project_id, $event_type, $des
$logstring = date('Y-m-d H:i:s').' ✅ Создана Заявка id='.$ticketId.' ticket_no='.$ticketNumber.PHP_EOL;
file_put_contents('logs/CreateWebClaim.log', $logstring, FILE_APPEND);
// 🚧 Создаём двустороннюю связь между Проектом и Заявкой
try {
$relationCheck = $adb->pquery(
"SELECT 1 FROM vtiger_crmentityrel
WHERE (crmid = ? AND relcrmid = ?)
OR (crmid = ? AND relcrmid = ?)
LIMIT 1",
array($projectIdNumeric, $ticketId, $ticketId, $projectIdNumeric)
);
if (!$relationCheck || $adb->num_rows($relationCheck) === 0) {
$adb->pquery(
"INSERT INTO vtiger_crmentityrel (crmid, module, relcrmid, relmodule) VALUES (?, ?, ?, ?)",
array($projectIdNumeric, 'Project', $ticketId, 'HelpDesk')
);
$logstring = date('Y-m-d H:i:s').' 🔗 Добавлена связь Project('.$projectIdNumeric.') ⇄ HelpDesk('.$ticketId.')'.PHP_EOL;
file_put_contents('logs/CreateWebClaim.log', $logstring, FILE_APPEND);
} else {
$logstring = date('Y-m-d H:i:s').' 🔗 Связь Project('.$projectIdNumeric.') ⇄ HelpDesk('.$ticketId.') уже существует'.PHP_EOL;
file_put_contents('logs/CreateWebClaim.log', $logstring, FILE_APPEND);
}
} catch (Exception $relEx) {
$logstring = date('Y-m-d H:i:s').' ⚠️ Ошибка связывания Project('.$projectIdNumeric.') ⇄ HelpDesk('.$ticketId.'): '.$relEx->getMessage().PHP_EOL;
file_put_contents('logs/CreateWebClaim.log', $logstring, FILE_APPEND);
}
// Возвращаем массив (vTiger сам сделает json_encode)
$output = array(
'ticket_id' => $ticketId,

View File

@@ -0,0 +1,288 @@
<?php
/*********************************************************************************
* API-интерфейс для создания Заявки (HelpDesk) из Web-формы (V2 - JSON версия)
* Принимает JSON строку с данными заявки
* Автор: Фёдор, 2025-12-29
********************************************************************************/
include_once 'include/Webservices/Query.php';
include_once 'modules/Users/Users.php';
require_once('include/Webservices/Utils.php');
require_once 'include/Webservices/Create.php';
require_once 'includes/Loader.php';
vimport ('includes.runtime.Globals');
vimport ('includes.runtime.BaseModel');
vimport ('includes.runtime.LanguageHandler');
/**
* Создание заявки из web-формы ERV Platform (V2 - JSON версия)
*
* @param string $claim_json - JSON строка с данными заявки (обязательно)
* @param object $user - пользователь (опционально)
* @return array - {"ticket_id": "123", "ticket_number": "TT12345", "title": "...", "category": "...", "status": "..."}
*/
function vtws_createwebclaimv2($claim_json, $user = false) {
$logstring = date("Y-m-d H:i:s").' REQUEST: '.json_encode($_REQUEST);
file_put_contents('logs/CreateWebClaimV2.log', $logstring.PHP_EOL, FILE_APPEND);
// Проверка обязательного параметра
if(empty($claim_json)){
$logstring = date("Y-m-d H:i:s").' Не передан параметр claim_json';
file_put_contents('logs/CreateWebClaimV2.log', $logstring.PHP_EOL, FILE_APPEND);
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, "Не передан параметр claim_json");
}
// Парсим JSON
$claimData = json_decode($claim_json, true);
if (json_last_error() !== JSON_ERROR_NONE) {
// Пробуем очистить от возможных лишних символов
$cleanedJson = trim($claim_json);
$cleanedJson = preg_replace('/^[^{]*/', '', $cleanedJson); // Убираем всё до первой {
$cleanedJson = preg_replace('/[^}]*$/', '', $cleanedJson); // Убираем всё после последней }
$claimData = json_decode($cleanedJson, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$logstring = date("Y-m-d H:i:s").' Ошибка парсинга JSON: '.json_last_error_msg().', JSON: '.substr($claim_json, 0, 200);
file_put_contents('logs/CreateWebClaimV2.log', $logstring.PHP_EOL, FILE_APPEND);
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, "Ошибка парсинга JSON: ".json_last_error_msg());
}
}
$logstring = date("Y-m-d H:i:s").' CLEANED JSON: '.json_encode($claimData);
file_put_contents('logs/CreateWebClaimV2.log', $logstring.PHP_EOL, FILE_APPEND);
// Извлекаем обязательные поля
$project_id = isset($claimData['project_id']) ? $claimData['project_id'] : '';
$contact_id = isset($claimData['contact_id']) ? $claimData['contact_id'] : '';
$event_type = isset($claimData['cf_1726']) ? $claimData['cf_1726'] : '';
$description = isset($claimData['description']) ? $claimData['description'] : '';
// Проверка обязательных полей
if(empty($project_id)){
$logstring = date("Y-m-d H:i:s").' Не указано обязательное поле: project_id';
file_put_contents('logs/CreateWebClaimV2.log', $logstring.PHP_EOL, FILE_APPEND);
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, "Не указан ID проекта");
}
if(empty($contact_id)){
$logstring = date("Y-m-d H:i:s").' Не указано обязательное поле: contact_id';
file_put_contents('logs/CreateWebClaimV2.log', $logstring.PHP_EOL, FILE_APPEND);
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, "Не указан ID контакта");
}
if(empty($event_type)){
$logstring = date("Y-m-d H:i:s").' Не указано обязательное поле: cf_1726 (event_type)';
file_put_contents('logs/CreateWebClaimV2.log', $logstring.PHP_EOL, FILE_APPEND);
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, "Не указан тип страхового случая");
}
global $adb, $current_user;
// Нормализуем ID контакта и проекта
$contactIdNumeric = preg_replace('/[^0-9]/', '', $contact_id);
$projectIdNumeric = preg_replace('/[^0-9]/', '', $project_id);
$contactWsId = '12x' . $contactIdNumeric;
$projectWsId = '33x' . $projectIdNumeric;
$logstring = date('Y-m-d H:i:s').' Нормализовали ID: contact='.$contactIdNumeric.' (raw='.$contact_id.'), project='.$projectIdNumeric.' (raw='.$project_id.')'.PHP_EOL;
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
// Маппинг типов событий на русские названия для cf_2650
$eventTypeMap = array(
'delay_flight' => 'Задержка рейса',
'cancel_flight' => 'Отмена рейса',
'miss_connection' => 'Пропуск стыковки',
'missed_connection' => 'Пропуск стыковки',
'delay_train' => 'Задержка поезда',
'cancel_train' => 'Отмена поезда',
'delay_ferry' => 'Задержка парома',
'cancel_ferry' => 'Отмена парома'
);
// ticketcategories всегда "Цифровой адвокат ЕРВ"
$ticketCategory = 'Цифровой адвокат ЕРВ';
// Нормализуем event_type для cf_2650
$normalizedEventType = isset($eventTypeMap[$event_type]) ? $eventTypeMap[$event_type] : 'Цифровой адвокат ЕРВ';
// Извлекаем дополнительные поля
$incident_date = isset($claimData['cf_2566']) ? $claimData['cf_2566'] : '';
$transport_number = isset($claimData['cf_2568']) ? $claimData['cf_2568'] : '';
$cf_1885 = isset($claimData['cf_1885']) ? $claimData['cf_1885'] : '';
$lastname = isset($claimData['lastname']) ? $claimData['lastname'] : '';
$firstname = isset($claimData['firstname']) ? $claimData['firstname'] : '';
// Формируем ticket_title: event_type_cf_1885_lastname_firstname
$ticket_title = $event_type;
if (!empty($cf_1885)) {
$ticket_title .= '_' . $cf_1885;
}
if (!empty($lastname)) {
$ticket_title .= '_' . $lastname;
}
if (!empty($firstname)) {
$ticket_title .= '_' . $firstname;
}
// Формируем описание
$fullDescription = '';
if (!empty($description)) {
$fullDescription .= $description . "\n\n";
}
$fullDescription .= "Тип события: " . $normalizedEventType . "\n";
if (!empty($incident_date)) {
$fullDescription .= "Дата инцидента: " . $incident_date . "\n";
}
if (!empty($transport_number)) {
$fullDescription .= "Номер рейса: " . $transport_number . "\n";
}
// Добавляем cf_departure_flight и cf_departure_date, если есть
$cf_departure_flight = isset($claimData['cf_departure_flight']) ? $claimData['cf_departure_flight'] : '';
$cf_departure_date = isset($claimData['cf_departure_date']) ? $claimData['cf_departure_date'] : '';
if (!empty($cf_departure_flight)) {
$fullDescription .= "Рейс стыковки: " . $cf_departure_flight . "\n";
}
if (!empty($cf_departure_date)) {
$fullDescription .= "Дата стыковки: " . $cf_departure_date . "\n";
}
$fullDescription .= "\nИсточник: ERV Platform Web Form";
// Формируем массив параметров для создания заявки
$params = array(
'ticket_title' => $ticket_title,
'parent_id' => '11x67458', // Заявитель - контрагент
'ticketcategories' => $ticketCategory,
'ticketstatus' => 'рассмотрение',
'contact_id' => $contactWsId,
'cf_2066' => $projectWsId, // Связь с проектом
'ticketpriorities' => 'High',
'assigned_user_id' => vtws_getWebserviceEntityId('Users', $current_user->id),
'description' => $fullDescription,
'cf_1726' => $event_type, // Сырой тип события
'cf_2650' => $normalizedEventType // Нормализованный тип события
);
// Маппинг дополнительных полей
if (!empty($incident_date)) {
$params['cf_2566'] = $incident_date;
}
if (!empty($transport_number)) {
$params['cf_2568'] = $transport_number;
}
if (!empty($cf_departure_flight)) {
$params['cf_2630'] = $cf_departure_flight;
}
if (!empty($cf_departure_date)) {
$params['cf_2632'] = $cf_departure_date;
}
// Страна (cf_1909 → cf_2636)
if (isset($claimData['cf_1909']) && !empty($claimData['cf_1909'])) {
$params['cf_2636'] = $claimData['cf_1909'];
}
// cf_2502 → cf_2572
if (isset($claimData['cf_2502']) && !empty($claimData['cf_2502'])) {
$params['cf_2572'] = $claimData['cf_2502'];
}
// code → cf_2574
if (isset($claimData['code']) && !empty($claimData['code'])) {
$params['cf_2574'] = $claimData['code'];
}
// cf_1885 → cf_2642
if (!empty($cf_1885)) {
$params['cf_2642'] = $cf_1885;
}
// IP → cf_2634
if (isset($claimData['ip']) && !empty($claimData['ip'])) {
$params['cf_2634'] = $claimData['ip'];
}
// region → cf_2640
if (isset($claimData['region']) && !empty($claimData['region'])) {
$params['cf_2640'] = $claimData['region'];
}
// source → cf_2638
if (isset($claimData['source']) && !empty($claimData['source'])) {
$params['cf_2638'] = $claimData['source'];
}
// cf_2508 → cf_2508 (прямое маппирование)
if (isset($claimData['cf_2508']) && !empty($claimData['cf_2508'])) {
$params['cf_2508'] = $claimData['cf_2508'];
}
// cf_2648 → cf_2648 (прямое маппирование)
if (isset($claimData['cf_2648']) && !empty($claimData['cf_2648'])) {
$params['cf_2648'] = $claimData['cf_2648'];
}
$logstring = date('Y-m-d H:i:s').' Массив для создания Заявки: '.json_encode($params).PHP_EOL;
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
try {
$result = vtws_create('HelpDesk', $params, $current_user);
$ticketId = substr($result['id'], 3); // Убираем префикс "17x"
$ticketNumber = isset($result['ticket_no']) ? $result['ticket_no'] : 'N/A';
$logstring = date('Y-m-d H:i:s').' ✅ Создана Заявка id='.$ticketId.' ticket_no='.$ticketNumber.PHP_EOL;
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
// Создаём двустороннюю связь между Проектом и Заявкой
try {
$relationCheck = $adb->pquery(
"SELECT 1 FROM vtiger_crmentityrel
WHERE (crmid = ? AND relcrmid = ?)
OR (crmid = ? AND relcrmid = ?)
LIMIT 1",
array($projectIdNumeric, $ticketId, $ticketId, $projectIdNumeric)
);
if (!$relationCheck || $adb->num_rows($relationCheck) === 0) {
$adb->pquery(
"INSERT INTO vtiger_crmentityrel (crmid, module, relcrmid, relmodule) VALUES (?, ?, ?, ?)",
array($projectIdNumeric, 'Project', $ticketId, 'HelpDesk')
);
$logstring = date('Y-m-d H:i:s').' 🔗 Добавлена связь Project('.$projectIdNumeric.') ⇄ HelpDesk('.$ticketId.')'.PHP_EOL;
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
} else {
$logstring = date('Y-m-d H:i:s').' 🔗 Связь Project('.$projectIdNumeric.') ⇄ HelpDesk('.$ticketId.') уже существует'.PHP_EOL;
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
}
} catch (Exception $relEx) {
$logstring = date('Y-m-d H:i:s').' ⚠️ Ошибка связывания Project('.$projectIdNumeric.') ⇄ HelpDesk('.$ticketId.'): '.$relEx->getMessage().PHP_EOL;
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
}
// Возвращаем массив
$output = array(
'ticket_id' => $ticketId,
'ticket_number' => $ticketNumber,
'title' => $ticket_title,
'category' => $ticketCategory,
'status' => 'рассмотрение'
);
} catch (WebServiceException $ex) {
$logstring = date('Y-m-d H:i:s').' ❌ Ошибка создания: '.$ex->getMessage().PHP_EOL;
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
throw $ex;
}
$logstring = date('Y-m-d H:i:s').' Return: '.json_encode($output).PHP_EOL;
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
return $output;
}

View File

@@ -22,7 +22,7 @@ vimport ('includes.runtime.LanguageHandler');
* @param string $firstname - имя (опционально)
* @param string $lastname - фамилия (опционально)
* @param string $email - email (опционально)
* @return int - ID контакта
* @return string - JSON строка с contact_id, is_new и cf_2624 (Данные подтверждены)
*/
function vtws_createwebcontact($mobile, $firstname = '', $lastname = '', $email = '', $user = false) {
@@ -56,18 +56,29 @@ function vtws_createwebcontact($mobile, $firstname = '', $lastname = '', $email
$isNew = false; // Флаг: создан ли контакт сейчас
// Проверяем существование контакта по номеру телефона
$query = "select c.contactid
// ✅ Добавляем выборку поля cf_2624 (Данные подтверждены)
$query = "select c.contactid, cf.cf_2624
from vtiger_contactdetails c
left join vtiger_crmentity e on e.crmid = c.contactid
left join vtiger_contactscf cf on cf.contactid = c.contactid
where e.deleted = 0 and c.mobile = ?
limit 1";
$result = $adb->pquery($query, array($mobile));
$cf_2624_value = "0"; // По умолчанию "Нет" (данные не подтверждены)
if ($adb->num_rows($result) > 0) {
// Контакт существует - ПРОСТО ВОЗВРАЩАЕМ ID (НЕ обновляем!)
$output = $adb->query_result($result, 0, 'contactid');
$isNew = false;
$logstring = date('Y-m-d H:i:s').' ✅ Контакт найден с id '.$output.' (БЕЗ обновления)'.PHP_EOL;
// ✅ Получаем значение поля cf_2624 (Данные подтверждены)
$cf_2624_value = $adb->query_result($result, 0, 'cf_2624');
if (empty($cf_2624_value)) {
$cf_2624_value = "0"; // По умолчанию "Нет"
}
$logstring = date('Y-m-d H:i:s').' ✅ Контакт найден с id '.$output.', cf_2624='.$cf_2624_value.' (БЕЗ обновления)'.PHP_EOL;
file_put_contents('logs/CreateWebContact.log', $logstring, FILE_APPEND);
} else {
// Контакт НЕ существует - создаём новый
@@ -92,6 +103,7 @@ function vtws_createwebcontact($mobile, $firstname = '', $lastname = '', $email
'mailingstreet' => '', // Адрес пустой
'cf_1849' => '', // Реквизиты пустые
'cf_1580' => '', // Код пустой
'cf_2624' => '0', // ✅ Данные подтверждены = "Нет" (по умолчанию для новых контактов)
'assigned_user_id' => vtws_getWebserviceEntityId('Users', $current_user->id)
);
@@ -102,7 +114,8 @@ function vtws_createwebcontact($mobile, $firstname = '', $lastname = '', $email
$contact = vtws_create('Contacts', $params, $current_user);
$output = substr($contact['id'], 3);
$isNew = true; // Контакт только что создан!
$logstring = date('Y-m-d H:i:s').' ✅ Создан новый Web Контакт с id '.$output.PHP_EOL;
$cf_2624_value = "0"; // Новый контакт - данные не подтверждены
$logstring = date('Y-m-d H:i:s').' ✅ Создан новый Web Контакт с id '.$output.', cf_2624=0'.PHP_EOL;
file_put_contents('logs/CreateWebContact.log', $logstring, FILE_APPEND);
} catch (WebServiceException $ex) {
$logstring = date('Y-m-d H:i:s').' ❌ Ошибка создания: '.$ex->getMessage().PHP_EOL;
@@ -111,10 +124,11 @@ function vtws_createwebcontact($mobile, $firstname = '', $lastname = '', $email
}
}
// Возвращаем JSON с флагом is_new
// Возвращаем JSON с флагом is_new и значением cf_2624
$result = array(
'contact_id' => $output,
'is_new' => $isNew
'is_new' => $isNew,
'cf_2624' => $cf_2624_value // ✅ "1" = данные подтверждены, "0" = не подтверждены
);
$logstring = date('Y-m-d H:i:s').' Return: '.json_encode($result).PHP_EOL;

View File

@@ -52,27 +52,28 @@ function vtws_createwebproject($policy_number, $contact_id, $period_start = '',
// Валидация: убираем пробелы из номера полиса
$policy_number = trim($policy_number);
$logstring = date('Y-m-d H:i:s').' Ищем проект по policy_number='.$policy_number.' И contact_id='.$contact_id.PHP_EOL;
// Нормализуем contact_id: допускаем как "12x12345", так и "12345"
$contactIdNumeric = preg_replace('/[^0-9]/', '', $contact_id);
$contactIdWithPrefix = '12x' . $contactIdNumeric;
$logstring = date('Y-m-d H:i:s').' Ищем проект по policy_number='.$policy_number.' И contact_id='.$contactIdNumeric.' (raw='.$contact_id.')'.PHP_EOL;
file_put_contents('logs/CreateWebProject.log', $logstring, FILE_APPEND);
global $adb, $current_user;
$isNew = false; // Флаг: создан ли проект сейчас
// Проверяем существование проекта по номеру полиса И привязке к контакту
// (т.к. по одному полису может быть несколько застрахованных лиц)
// Проверяем существование проекта по номеру полиса И прямой привязке к контакту
// (без зависимости от заполнения vtiger_crmentityrel)
$query = "SELECT p.projectid
FROM vtiger_project p
INNER JOIN vtiger_projectcf pcf ON p.projectid = pcf.projectid
LEFT JOIN vtiger_crmentity e ON e.crmid = p.projectid
LEFT JOIN vtiger_crmentityrel rel ON
(rel.crmid = p.projectid AND rel.relcrmid = ?)
OR (rel.relcrmid = p.projectid AND rel.crmid = ?)
INNER JOIN vtiger_crmentity e ON e.crmid = p.projectid
WHERE e.deleted = 0
AND pcf.cf_1885 = ?
AND rel.crmid IS NOT NULL
AND p.linktoaccountscontacts = ?
LIMIT 1";
$result = $adb->pquery($query, array($contact_id, $contact_id, $policy_number));
$result = $adb->pquery($query, array($policy_number, $contactIdNumeric));
if ($adb->num_rows($result) > 0) {
// Проект существует - ПРОСТО ВОЗВРАЩАЕМ ID (НЕ обновляем!)
@@ -90,7 +91,7 @@ function vtws_createwebproject($policy_number, $contact_id, $period_start = '',
'projectname' => $projectname,
'projectstatus' => 'модерация',
'projecttype' => 'ерв урегулирование',
'linktoaccountscontacts' => '12x'.$contact_id, // Привязка к контакту
'linktoaccountscontacts' => $contactIdWithPrefix, // Привязка к контакту
'cf_1994' => '11x67458', // Заявитель (контрагент record=67458)
'cf_1885' => $policy_number, // Номер полиса
'assigned_user_id' => vtws_getWebserviceEntityId('Users', $current_user->id)

View File

@@ -0,0 +1,225 @@
<?php
/*********************************************************************************
* API-интерфейс для создания/поиска нескольких Контрагентов (Upsert Batch)
*
* Принимает JSON массив offenders, для каждого:
* - Ищет по ИНН
* - Если найден — возвращает ID (БЕЗ обновления)
* - Если не найден — создаёт новый
*
* Возвращает массив результатов с account_id для каждого offender
*
* Автор: Фёдор, 2025-12-01
********************************************************************************/
include_once 'include/Webservices/Query.php';
include_once 'modules/Users/Users.php';
require_once('include/Webservices/Utils.php');
require_once 'include/Webservices/Create.php';
require_once 'includes/Loader.php';
vimport('includes.runtime.Globals');
vimport('includes.runtime.BaseModel');
vimport('includes.runtime.LanguageHandler');
/**
* Upsert нескольких контрагентов
*
* @param string $offenders_json - JSON массив offenders:
* [
* {
* "accountname": "ООО Рога и Копыта",
* "address": "Москва, ул. Ленина 1",
* "email": "info@example.com",
* "website": "example.com",
* "phone": "+7 999 123-45-67",
* "inn": "7712345678",
* "ogrn": "1234567890123",
* "role": "Турагент" // опционально, для информации
* },
* ...
* ]
* @param mixed $user - пользователь CRM
* @return string JSON с результатами
*/
function vtws_upsertaccounts($offenders_json, $user = false) {
$logFile = 'logs/UpsertAccounts.log';
$logstring = date("Y-m-d H:i:s") . ' REQUEST: ' . substr($offenders_json, 0, 2000);
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
global $adb, $current_user;
// Очистка JSON от мусора (лишние кавычки, BOM, пробелы)
$offenders_json = trim($offenders_json);
$offenders_json = preg_replace('/^\xEF\xBB\xBF/', '', $offenders_json); // Убираем BOM
// Если строка обёрнута в кавычки — убираем
if (preg_match('/^".*"$/s', $offenders_json)) {
$offenders_json = substr($offenders_json, 1, -1);
$offenders_json = stripcslashes($offenders_json); // Убираем экранирование
}
// Убираем лишнюю кавычку в конце (баг n8n)
$offenders_json = preg_replace('/"\s*$/', '', rtrim($offenders_json, '"'));
if (substr($offenders_json, -1) !== ']' && substr($offenders_json, -1) !== '}') {
// Пробуем найти конец массива/объекта
if (($pos = strrpos($offenders_json, ']')) !== false) {
$offenders_json = substr($offenders_json, 0, $pos + 1);
}
}
$logstring = date("Y-m-d H:i:s") . ' CLEANED JSON: ' . substr($offenders_json, 0, 500);
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
// Парсим JSON
$offenders = json_decode($offenders_json, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$error = 'Ошибка парсинга JSON: ' . json_last_error_msg();
file_put_contents($logFile, date("Y-m-d H:i:s") . ' ❌ ' . $error . PHP_EOL, FILE_APPEND);
file_put_contents($logFile, date("Y-m-d H:i:s") . ' RAW: ' . $offenders_json . PHP_EOL, FILE_APPEND);
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, $error);
}
if (!is_array($offenders)) {
$offenders = [$offenders]; // Если передан один объект — оборачиваем в массив
}
$logstring = date('Y-m-d H:i:s') . ' Получено offenders: ' . count($offenders);
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
// Результаты
$results = array(
'success' => true,
'total' => count($offenders),
'created' => 0,
'found' => 0,
'errors' => 0,
'accounts' => array()
);
// Обрабатываем каждого offender
foreach ($offenders as $index => $offender) {
$accountResult = array(
'index' => $index,
'success' => false,
'account_id' => null,
'action' => null,
'accountname' => $offender['accountname'] ?? '',
'inn' => $offender['inn'] ?? '',
'role' => $offender['role'] ?? null,
'message' => ''
);
try {
// Извлекаем данные
$accountname = trim($offender['accountname'] ?? '');
$address = trim($offender['address'] ?? '');
$email = trim($offender['email'] ?? '');
$website = trim($offender['website'] ?? '');
$phone = trim($offender['phone'] ?? '');
$inn = preg_replace('/[^0-9]/', '', $offender['inn'] ?? ''); // Только цифры
$ogrn = preg_replace('/[^0-9]/', '', $offender['ogrn'] ?? ''); // Только цифры
$role = trim($offender['role'] ?? '');
// Проверка обязательных полей
if (empty($accountname)) {
throw new Exception('Не указано наименование контрагента (accountname)');
}
if (empty($inn)) {
throw new Exception('Не указан ИНН');
}
// Валидация ИНН (10 или 12 цифр)
if (strlen($inn) != 10 && strlen($inn) != 12) {
$logstring = date('Y-m-d H:i:s') . " ⚠️ Нестандартный ИНН: $inn (длина " . strlen($inn) . ')';
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
// Не падаем, просто логируем
}
// ========================================
// ПОИСК ПО ИНН
// ========================================
$query = "SELECT a.accountid, a.accountname
FROM vtiger_account a
LEFT JOIN vtiger_crmentity e ON e.crmid = a.accountid
WHERE e.deleted = 0 AND a.inn = ?
LIMIT 1";
$res = $adb->pquery($query, array($inn));
if ($adb->num_rows($res) > 0) {
// === НАЙДЕН — просто возвращаем ID ===
$existingId = $adb->query_result($res, 0, 'accountid');
$existingName = $adb->query_result($res, 0, 'accountname');
$accountResult['success'] = true;
$accountResult['account_id'] = $existingId;
$accountResult['action'] = 'found';
$accountResult['message'] = 'Контрагент найден по ИНН';
$accountResult['existing_name'] = $existingName;
$results['found']++;
$logstring = date('Y-m-d H:i:s') . " ✓ [$index] Найден: $existingId ($existingName) по ИНН $inn";
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
} else {
// === НЕ НАЙДЕН — создаём ===
$params = array(
'accountname' => $accountname,
'bill_street' => $address,
'email1' => $email,
'website' => $website,
'phone' => $phone,
'inn' => $inn,
'cf_1951' => $ogrn, // ОГРН в кастомном поле
'assigned_user_id' => vtws_getWebserviceEntityId('Users', $current_user->id)
);
$logstring = date('Y-m-d H:i:s') . " 🆕 [$index] Создаём: " . json_encode($params, JSON_UNESCAPED_UNICODE);
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
$account = vtws_create('Accounts', $params, $current_user);
$newAccountId = substr($account['id'], 3); // Убираем 11x
$accountResult['success'] = true;
$accountResult['account_id'] = $newAccountId;
$accountResult['action'] = 'created';
$accountResult['message'] = 'Контрагент создан';
$results['created']++;
$logstring = date('Y-m-d H:i:s') . " ✅ [$index] Создан: $newAccountId";
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
}
} catch (WebServiceException $ex) {
$accountResult['success'] = false;
$accountResult['message'] = $ex->getMessage();
$results['errors']++;
$logstring = date('Y-m-d H:i:s') . " ❌ [$index] WebService ошибка: " . $ex->getMessage();
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
} catch (Exception $ex) {
$accountResult['success'] = false;
$accountResult['message'] = $ex->getMessage();
$results['errors']++;
$logstring = date('Y-m-d H:i:s') . " ❌ [$index] Ошибка: " . $ex->getMessage();
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
}
$results['accounts'][] = $accountResult;
}
// Итоговый статус
$results['success'] = ($results['errors'] == 0);
$logstring = date('Y-m-d H:i:s') . ' RESULT: total=' . $results['total']
. ', created=' . $results['created']
. ', found=' . $results['found']
. ', errors=' . $results['errors'] . PHP_EOL;
file_put_contents($logFile, $logstring, FILE_APPEND);
return json_encode($results, JSON_UNESCAPED_UNICODE);
}

View File

@@ -0,0 +1,235 @@
<?php
/*********************************************************************************
* API-интерфейс для создания/обновления Контакта (Upsert)
* Гибкий метод: обновляет если найден, создаёт если нет
*
* Приоритет поиска:
* 1. contact_id (если передан - сразу обновляем)
* 2. mobile (ищем по мобильному)
* 3. tgid (ищем по полю phone, где хранится telegram_id)
*
* Все поля опциональны, кроме хотя бы одного идентификатора
*
* Автор: Фёдор, 2025-12-01
********************************************************************************/
include_once 'include/Webservices/Query.php';
include_once 'modules/Users/Users.php';
require_once('include/Webservices/Utils.php');
require_once 'include/Webservices/Create.php';
require_once 'include/Webservices/Revise.php';
require_once 'includes/Loader.php';
vimport('includes.runtime.Globals');
vimport('includes.runtime.BaseModel');
vimport('includes.runtime.LanguageHandler');
/**
* Upsert контакта - создание или обновление
*
* @param string $contact_id - ID контакта в CRM (если известен)
* @param string $mobile - мобильный телефон
* @param string $tgid - telegram ID
* @param string $firstname - имя
* @param string $secondname - отчество
* @param string $lastname - фамилия
* @param string $email - email
* @param string $birthday - дата рождения
* @param string $birthplace - место рождения
* @param string $mailingstreet - адрес
* @param string $inn - ИНН
* @param string $requisites - реквизиты
* @param string $code - SMS код верификации
* @param mixed $user - пользователь CRM
* @return string JSON с результатом
*/
function vtws_upsertcontact(
$contact_id = '',
$mobile = '',
$tgid = '',
$firstname = '',
$secondname = '',
$lastname = '',
$email = '',
$birthday = '',
$birthplace = '',
$mailingstreet = '',
$inn = '',
$requisites = '',
$code = '',
$user = false
) {
$logFile = 'logs/UpsertContact.log';
$logstring = date("Y-m-d H:i:s") . ' REQUEST: ' . json_encode($_REQUEST);
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
global $adb, $current_user;
// Результат
$result = array(
'success' => false,
'contact_id' => null,
'action' => null, // 'created', 'updated', 'found'
'message' => ''
);
// ========================================
// 1. ФОРМАТИРОВАНИЕ ТЕЛЕФОНА
// ========================================
if (!empty($mobile)) {
$mobile = preg_replace('/[^0-9]/', '', $mobile);
if (strlen($mobile) == 11 && $mobile[0] == '8') {
$mobile = "7" . substr($mobile, 1);
} else if (strlen($mobile) == 10) {
$mobile = "7" . $mobile;
} else if (strlen($mobile) != 11) {
// Некорректный номер - логируем, но не падаем
$logstring = date("Y-m-d H:i:s") . ' ⚠️ Некорректный номер телефона: ' . $mobile . ' (игнорируем)';
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
$mobile = ''; // Обнуляем некорректный номер
}
}
// ========================================
// 2. ПОИСК СУЩЕСТВУЮЩЕГО КОНТАКТА
// ========================================
$existingContactId = null;
$searchMethod = '';
// 2.1 По contact_id (приоритет 1)
if (!empty($contact_id)) {
$contact_id = preg_replace('/[^0-9]/', '', $contact_id); // Очищаем от 12x префикса
$query = "SELECT c.contactid FROM vtiger_contactdetails c
LEFT JOIN vtiger_crmentity e ON e.crmid = c.contactid
WHERE e.deleted = 0 AND c.contactid = ? LIMIT 1";
$res = $adb->pquery($query, array($contact_id));
if ($adb->num_rows($res) > 0) {
$existingContactId = $adb->query_result($res, 0, 'contactid');
$searchMethod = 'by_contact_id';
}
}
// 2.2 По mobile (приоритет 2)
if (empty($existingContactId) && !empty($mobile)) {
$query = "SELECT c.contactid FROM vtiger_contactdetails c
LEFT JOIN vtiger_crmentity e ON e.crmid = c.contactid
WHERE e.deleted = 0 AND c.mobile = ? LIMIT 1";
$res = $adb->pquery($query, array($mobile));
if ($adb->num_rows($res) > 0) {
$existingContactId = $adb->query_result($res, 0, 'contactid');
$searchMethod = 'by_mobile';
}
}
// 2.3 По tgid (приоритет 3) - tgid хранится в поле phone
if (empty($existingContactId) && !empty($tgid)) {
$query = "SELECT c.contactid FROM vtiger_contactdetails c
LEFT JOIN vtiger_crmentity e ON e.crmid = c.contactid
WHERE e.deleted = 0 AND c.phone = ? LIMIT 1";
$res = $adb->pquery($query, array($tgid));
if ($adb->num_rows($res) > 0) {
$existingContactId = $adb->query_result($res, 0, 'contactid');
$searchMethod = 'by_tgid';
}
}
$logstring = date('Y-m-d H:i:s') . ' Поиск: contact_id=' . $contact_id . ', mobile=' . $mobile . ', tgid=' . $tgid;
$logstring .= ' → Найден: ' . ($existingContactId ? $existingContactId . ' (' . $searchMethod . ')' : 'НЕТ');
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
// ========================================
// 3. ФОРМИРУЕМ ПАРАМЕТРЫ
// ========================================
$params = array();
// Только непустые поля добавляем в params
if (!empty($firstname)) $params['firstname'] = $firstname;
if (!empty($secondname)) $params['cf_1157'] = $secondname; // Отчество
if (!empty($lastname)) $params['lastname'] = $lastname;
if (!empty($mobile)) $params['mobile'] = $mobile;
if (!empty($email)) $params['email'] = $email;
if (!empty($tgid)) $params['phone'] = $tgid; // TG ID в поле phone
if (!empty($birthday)) $params['birthday'] = $birthday;
if (!empty($birthplace)) $params['cf_1263'] = $birthplace; // Место рождения
if (!empty($mailingstreet)) $params['mailingstreet'] = $mailingstreet;
if (!empty($inn)) $params['cf_1257'] = $inn; // ИНН
if (!empty($requisites)) $params['cf_1849'] = $requisites; // Реквизиты
if (!empty($code)) $params['cf_1580'] = $code; // SMS код
// ========================================
// 4. СОЗДАНИЕ ИЛИ ОБНОВЛЕНИЕ
// ========================================
try {
if (!empty($existingContactId)) {
// === ОБНОВЛЕНИЕ ===
$params['id'] = '12x' . $existingContactId;
$logstring = date('Y-m-d H:i:s') . ' 📝 Обновляем контакт ' . $existingContactId . ': ' . json_encode($params);
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
$contact = vtws_revise($params, $current_user);
$result['success'] = true;
$result['contact_id'] = $existingContactId;
$result['action'] = 'updated';
$result['search_method'] = $searchMethod;
$result['message'] = 'Контакт обновлён';
$logstring = date('Y-m-d H:i:s') . ' ✅ Контакт ' . $existingContactId . ' обновлён';
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
} else {
// === СОЗДАНИЕ ===
// Проверяем минимальные данные для создания
if (empty($mobile) && empty($tgid)) {
throw new WebServiceException(
WebServiceErrorCode::$INVALIDID,
"Для создания контакта нужен хотя бы mobile или tgid"
);
}
// Дефолтные значения для обязательных полей CRM
if (empty($params['firstname'])) {
$params['firstname'] = 'Клиент';
}
if (empty($params['lastname'])) {
$suffix = !empty($mobile) ? substr($mobile, -4) : substr($tgid, -4);
$params['lastname'] = 'Web_' . $suffix;
}
if (empty($params['birthday'])) {
$params['birthday'] = '01-01-1990';
}
// Назначаем ответственного
$params['assigned_user_id'] = vtws_getWebserviceEntityId('Users', $current_user->id);
$logstring = date('Y-m-d H:i:s') . ' 🆕 Создаём контакт: ' . json_encode($params);
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
$contact = vtws_create('Contacts', $params, $current_user);
$newContactId = substr($contact['id'], 3); // Убираем 12x
$result['success'] = true;
$result['contact_id'] = $newContactId;
$result['action'] = 'created';
$result['message'] = 'Контакт создан';
$logstring = date('Y-m-d H:i:s') . ' ✅ Создан контакт ' . $newContactId;
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
}
} catch (WebServiceException $ex) {
$result['success'] = false;
$result['message'] = $ex->getMessage();
$logstring = date('Y-m-d H:i:s') . ' ❌ Ошибка: ' . $ex->getMessage();
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
throw $ex;
}
$logstring = date('Y-m-d H:i:s') . ' RESULT: ' . json_encode($result, JSON_UNESCAPED_UNICODE) . PHP_EOL;
file_put_contents($logFile, $logstring, FILE_APPEND);
return json_encode($result, JSON_UNESCAPED_UNICODE);
}

View File

@@ -0,0 +1,297 @@
<?php
/*********************************************************************************
* API-интерфейс для создания/обновления Проекта (Upsert)
*
* Логика:
* - Если передан project_id → обновляем существующий проект
* - Если project_id не передан → создаём новый
*
* Принимает JSON с данными проекта
*
* Автор: Фёдор, 2025-12-01
********************************************************************************/
include_once 'include/Webservices/Query.php';
include_once 'modules/Users/Users.php';
require_once('include/Webservices/Utils.php');
require_once 'include/Webservices/Create.php';
require_once 'include/Webservices/Revise.php';
require_once 'includes/Loader.php';
vimport('includes.runtime.Globals');
vimport('includes.runtime.BaseModel');
vimport('includes.runtime.LanguageHandler');
/**
* Upsert проекта
*
* @param string $project_json - JSON с данными проекта:
* {
* "project_id": "12345", // Опционально - если есть, обновляем
* "claim_id": "uuid", // ID заявки из PostgreSQL
* "contact_id": "320096", // ID контакта (обязательно для создания)
* "result": "JSON string", // Результат UpsertAccounts (парсится автоматически)
* "offender_ids": ["390680"], // Альтернатива result - массив ID контрагентов
* "projectdata": { // Данные проекта (cf_* поля)
* "cf_2206": "SMS код",
* "cf_1830": "категория",
* ...
* }
* }
*
* Контрагенты распределяются:
* - accounts[0] → cf_2274 (основной ответчик)
* - accounts[1] → cf_2276 (агент/второй ответчик)
*
* @param mixed $user - пользователь CRM
* @return string JSON с результатом
*/
function vtws_upsertproject($project_json, $user = false) {
$logFile = 'logs/UpsertProject.log';
$logstring = date("Y-m-d H:i:s") . ' REQUEST: ' . substr($project_json, 0, 2000);
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
global $adb, $current_user;
// Очистка JSON
$project_json = trim($project_json);
$project_json = preg_replace('/^\xEF\xBB\xBF/', '', $project_json);
if (preg_match('/^".*"$/s', $project_json)) {
$project_json = substr($project_json, 1, -1);
$project_json = stripcslashes($project_json);
}
// Парсим JSON
$data = json_decode($project_json, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$error = 'Ошибка парсинга JSON: ' . json_last_error_msg();
file_put_contents($logFile, date("Y-m-d H:i:s") . ' ❌ ' . $error . PHP_EOL, FILE_APPEND);
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, $error);
}
// Результат
$result = array(
'success' => false,
'project_id' => null,
'claim_id' => null,
'action' => null,
'offender_id' => null,
'agent_id' => null,
'message' => ''
);
// Извлекаем данные
$project_id = trim($data['project_id'] ?? '');
$claim_id = trim($data['claim_id'] ?? '');
$contact_id = trim($data['contact_id'] ?? '');
$projectdata = $data['projectdata'] ?? [];
// Извлекаем контрагентов из result (если передан) или из offender_ids
$offender_ids = [];
if (!empty($data['result'])) {
// Парсим result от UpsertAccounts
$accountsResult = $data['result'];
if (is_string($accountsResult)) {
$accountsResult = json_decode($accountsResult, true);
}
// Извлекаем account_id из accounts[]
if (isset($accountsResult['accounts']) && is_array($accountsResult['accounts'])) {
foreach ($accountsResult['accounts'] as $account) {
if (!empty($account['account_id'])) {
$offender_ids[] = $account['account_id'];
}
}
}
$logstring = date('Y-m-d H:i:s') . ' Извлечены offender_ids из result: ' . json_encode($offender_ids);
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
} elseif (!empty($data['offender_ids'])) {
$offender_ids = $data['offender_ids'];
}
// cf_2274 = первый контрагент (основной ответчик)
// cf_2276 = второй контрагент (агент/второй ответчик)
$offender_id = count($offender_ids) > 0 ? $offender_ids[0] : '';
$agent_id = count($offender_ids) > 1 ? $offender_ids[1] : '';
$logstring = date('Y-m-d H:i:s') . " Данные: project_id=$project_id, claim_id=$claim_id, contact_id=$contact_id, offender_id=$offender_id, agent_id=$agent_id";
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
try {
// ========================================
// ПРОВЕРКА СУЩЕСТВОВАНИЯ ПРОЕКТА
// ========================================
$existingProjectId = null;
if (!empty($project_id)) {
$project_id = preg_replace('/[^0-9]/', '', $project_id);
$query = "SELECT p.projectid FROM vtiger_project p
LEFT JOIN vtiger_crmentity e ON e.crmid = p.projectid
WHERE e.deleted = 0 AND p.projectid = ? LIMIT 1";
$res = $adb->pquery($query, array($project_id));
if ($adb->num_rows($res) > 0) {
$existingProjectId = $adb->query_result($res, 0, 'projectid');
}
}
// ========================================
// ФОРМИРУЕМ ПАРАМЕТРЫ
// ========================================
$params = array();
// Если создаём новый проект - нужны contact_id и offender_id
if (empty($existingProjectId)) {
if (empty($contact_id) || empty($offender_id)) {
throw new Exception('Для создания проекта нужны contact_id и offender_ids');
}
// Получаем название контакта
$query = "SELECT c.lastname FROM vtiger_contactdetails c
LEFT JOIN vtiger_crmentity e ON e.crmid = c.contactid
WHERE e.deleted = 0 AND c.contactid = ? LIMIT 1";
$res = $adb->pquery($query, array($contact_id));
$contactName = $adb->num_rows($res) > 0 ? $adb->query_result($res, 0, 'lastname') : 'Клиент';
// Получаем название контрагента
$query = "SELECT a.accountname FROM vtiger_account a
LEFT JOIN vtiger_crmentity e ON e.crmid = a.accountid
WHERE e.deleted = 0 AND a.accountid = ? LIMIT 1";
$res = $adb->pquery($query, array($offender_id));
$accountName = $adb->num_rows($res) > 0 ? $adb->query_result($res, 0, 'accountname') : 'Контрагент';
// Название проекта
$params['projectname'] = $contactName . ' ' . $accountName;
$params['linktoaccountscontacts'] = '12x' . $contact_id;
$params['cf_2274'] = '11x' . $offender_id; // Основной ответчик
$params['projectstatus'] = 'модерация';
$params['projecttype'] = 'Претензионно - исковая работа';
$params['assigned_user_id'] = vtws_getWebserviceEntityId('Users', $current_user->id);
// Заявитель по умолчанию
if (!isset($projectdata['cf_1994'])) {
$params['cf_1994'] = vtws_getWebserviceEntityId('Accounts', 62345); // МОО КЛИЕНТПРАВ
}
}
// Агент (второй ответчик)
if (!empty($agent_id)) {
$params['cf_2276'] = '11x' . $agent_id;
}
// Связь контакт/оффендер для обновления тоже можно передать
if (!empty($contact_id) && !empty($existingProjectId)) {
$params['linktoaccountscontacts'] = '12x' . $contact_id;
}
if (!empty($offender_id) && !empty($existingProjectId)) {
$params['cf_2274'] = '11x' . $offender_id;
}
// Маппинг полей из projectdata
$fieldMapping = array(
'cf_2206' => 'cf_2206', // SMS код
'cf_2210' => 'cf_2210', // IP
'cf_2212' => 'cf_2212', // Источник
'cf_2214' => 'cf_2214', // Регион
'cf_2208' => 'cf_2208', // Form ID
'cf_1830' => 'cf_1830', // Категория
'cf_1469' => 'cf_1469', // Направление
'cf_1191' => 'cf_1191', // Цена договора
'cf_1189' => 'cf_1189', // Предмет договора
'cf_1203' => 'cf_1203', // Дата договора
'cf_1839' => 'cf_1839', // Дата начала
'cf_1841' => 'cf_1841', // Дата окончания
'cf_1207' => 'cf_1207', // Ущерб
'cf_1479' => 'cf_1479', // Стоимость услуг
'cf_1227' => 'cf_1227', // Прогресс
'cf_1231' => 'cf_1231', // Страна
'cf_1239' => 'cf_1239', // Отель
'cf_1566' => 'cf_1566', // Транспорт
'cf_1564' => 'cf_1564', // Страховка
'cf_1249' => 'cf_1249', // Прочее
'cf_1471' => 'cf_1471', // Самостоятельно
'cf_1473' => 'cf_1473', // Дата претензии
'cf_1475' => 'cf_1475', // Возвращено
'cf_1994' => 'cf_1994', // Заявитель
'description' => 'description'
);
foreach ($fieldMapping as $input => $crm) {
if (isset($projectdata[$input]) && $projectdata[$input] !== null) {
$value = $projectdata[$input];
// Для cf_1994 (Заявитель) нужен формат 11xID
if ($crm === 'cf_1994' && !empty($value) && strpos($value, 'x') === false) {
$value = vtws_getWebserviceEntityId('Accounts', $value);
}
$params[$crm] = $value;
}
}
// ========================================
// СОЗДАНИЕ ИЛИ ОБНОВЛЕНИЕ
// ========================================
if (!empty($existingProjectId)) {
// === ОБНОВЛЕНИЕ ===
$params['id'] = '33x' . $existingProjectId; // 33x для Project
$logstring = date('Y-m-d H:i:s') . ' 📝 Обновляем проект ' . $existingProjectId . ': ' . json_encode($params, JSON_UNESCAPED_UNICODE);
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
$project = vtws_revise($params, $current_user);
$result['success'] = true;
$result['project_id'] = $existingProjectId;
$result['claim_id'] = $claim_id;
$result['action'] = 'updated';
$result['offender_id'] = $offender_id;
$result['agent_id'] = $agent_id ?: null;
$result['message'] = 'Проект обновлён';
$logstring = date('Y-m-d H:i:s') . ' ✅ Проект ' . $existingProjectId . ' обновлён';
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
} else {
// === СОЗДАНИЕ ===
$logstring = date('Y-m-d H:i:s') . ' 🆕 Создаём проект: ' . json_encode($params, JSON_UNESCAPED_UNICODE);
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
$project = vtws_create('Project', $params, $current_user);
$newProjectId = substr($project['id'], strpos($project['id'], 'x') + 1); // Убираем префикс (63x)
$result['success'] = true;
$result['project_id'] = $newProjectId;
$result['claim_id'] = $claim_id;
$result['action'] = 'created';
$result['offender_id'] = $offender_id;
$result['agent_id'] = $agent_id ?: null;
$result['message'] = 'Проект создан';
$logstring = date('Y-m-d H:i:s') . ' ✅ Создан проект ' . $newProjectId;
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
}
} catch (WebServiceException $ex) {
$result['success'] = false;
$result['message'] = $ex->getMessage();
$logstring = date('Y-m-d H:i:s') . ' ❌ WebService ошибка: ' . $ex->getMessage();
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
throw $ex;
} catch (Exception $ex) {
$result['success'] = false;
$result['message'] = $ex->getMessage();
$logstring = date('Y-m-d H:i:s') . ' ❌ Ошибка: ' . $ex->getMessage();
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, $ex->getMessage());
}
$logstring = date('Y-m-d H:i:s') . ' RESULT: ' . json_encode($result, JSON_UNESCAPED_UNICODE) . PHP_EOL;
file_put_contents($logFile, $logstring, FILE_APPEND);
return json_encode($result, JSON_UNESCAPED_UNICODE);
}

View File

@@ -53,4 +53,5 @@ $languageStrings = array(
'заявление на лист' => 'заявление на лист',
'урегулирование' => 'урегулирование',
'заключение мирового соглашения' => 'заключение мирового соглашения',
'Черновик' => 'Черновик',
);

View File

@@ -53,4 +53,5 @@ $languageStrings = array(
'заявление на лист' => 'заявление на лист',
'урегулирование' => 'урегулирование',
'заключение мирового соглашения' => 'заключение мирового соглашения',
'Черновик' => 'Черновик',
);

View File

@@ -53,4 +53,5 @@ $languageStrings = array(
'заявление на лист' => 'заявление на лист',
'урегулирование' => 'урегулирование',
'заключение мирового соглашения' => 'заключение мирового соглашения',
'Черновик' => 'Черновик',
);

View File

@@ -0,0 +1,24 @@
<?php
$languageStrings = [
'LBL_OOT_TEMPLATES' => 'Document templates',
'LBL_OOT_SELECT_TEMPLATE' => 'Template',
'LBL_OOT_FORMAT' => 'Format',
'LBL_OOT_FORMAT_PDF' => 'PDF',
'LBL_OOT_FORMAT_DOCX' => 'DOCX',
'LBL_OOT_DOWNLOAD' => 'Download',
'LBL_OOT_SAVE_TO_DOCUMENTS' => 'Save to Documents',
'LBL_OOT_NO_TEMPLATES' => 'No templates for this module',
'LBL_OOT_EMPTY_LIST' => 'No templates found',
'LBL_OOT_NAME' => 'Name',
'LBL_OOT_MODULE' => 'Module',
'LBL_OOT_FILE' => 'File',
'LBL_OOT_CREATED_AT' => 'Created',
'LBL_OOT_ADD_TEMPLATE' => 'Add template',
'LBL_OOT_FILE_HINT' => 'DOCX files only',
'LBL_OOT_EDIT_TEMPLATE' => 'Edit template',
'LBL_OOT_NEW_TEMPLATE' => 'New template',
'LBL_OOT_EDITOR_HINT' => 'Edit the document on the right. Saving to S3 happens automatically when closing or when clicking Save in the editor.',
'LBL_OOT_EDITOR_FALLBACK' => 'If OnlyOffice is not configured, you can upload a DOCX file instead.',
'LBL_OOT_ADD_VIA_UPLOAD' => 'Upload DOCX file',
'LBL_OOT_UPLOAD_FILE' => 'Upload file',
];

View File

@@ -53,4 +53,5 @@ $languageStrings = array(
'заявление на лист' => 'заявление на лист',
'урегулирование' => 'урегулирование',
'заключение мирового соглашения' => 'заключение мирового соглашения',
'Черновик' => 'Черновик',
);

View File

@@ -53,4 +53,5 @@ $languageStrings = array(
'заявление на лист' => 'заявление на лист',
'урегулирование' => 'урегулирование',
'заключение мирового соглашения' => 'заключение мирового соглашения',
'Черновик' => 'Черновик',
);

View File

@@ -53,4 +53,5 @@ $languageStrings = array(
'заявление на лист' => 'заявление на лист',
'урегулирование' => 'урегулирование',
'заключение мирового соглашения' => 'заключение мирового соглашения',
'Черновик' => 'Черновик',
);

View File

@@ -53,4 +53,5 @@ $languageStrings = array(
'заявление на лист' => 'заявление на лист',
'урегулирование' => 'урегулирование',
'заключение мирового соглашения' => 'заключение мирового соглашения',
'Черновик' => 'Черновик',
);

View File

@@ -53,4 +53,5 @@ $languageStrings = array(
'заявление на лист' => 'заявление на лист',
'урегулирование' => 'урегулирование',
'заключение мирового соглашения' => 'заключение мирового соглашения',
'Черновик' => 'Черновик',
);

View File

@@ -53,4 +53,5 @@ $languageStrings = array(
'заявление на лист' => 'заявление на лист',
'урегулирование' => 'урегулирование',
'заключение мирового соглашения' => 'заключение мирового соглашения',
'Черновик' => 'Черновик',
);

View File

@@ -53,4 +53,5 @@ $languageStrings = array(
'заявление на лист' => 'заявление на лист',
'урегулирование' => 'урегулирование',
'заключение мирового соглашения' => 'заключение мирового соглашения',
'Черновик' => 'Черновик',
);

View File

@@ -53,4 +53,5 @@ $languageStrings = array(
'заявление на лист' => 'заявление на лист',
'урегулирование' => 'урегулирование',
'заключение мирового соглашения' => 'заключение мирового соглашения',
'Черновик' => 'Черновик',
);

View File

@@ -53,4 +53,5 @@ $languageStrings = array(
'заявление на лист' => 'заявление на лист',
'урегулирование' => 'урегулирование',
'заключение мирового соглашения' => 'заключение мирового соглашения',
'Черновик' => 'Черновик',
);

View File

@@ -53,4 +53,5 @@ $languageStrings = array(
'заявление на лист' => 'заявление на лист',
'урегулирование' => 'урегулирование',
'заключение мирового соглашения' => 'заключение мирового соглашения',
'Черновик' => 'Черновик',
);

View File

@@ -0,0 +1,24 @@
<?php
$languageStrings = [
'LBL_OOT_TEMPLATES' => 'Шаблоны документов',
'LBL_OOT_SELECT_TEMPLATE' => 'Шаблон',
'LBL_OOT_FORMAT' => 'Формат',
'LBL_OOT_FORMAT_PDF' => 'PDF',
'LBL_OOT_FORMAT_DOCX' => 'DOCX',
'LBL_OOT_DOWNLOAD' => 'Скачать',
'LBL_OOT_SAVE_TO_DOCUMENTS' => 'Сохранить в Документы',
'LBL_OOT_NO_TEMPLATES' => 'Нет шаблонов для этого модуля',
'LBL_OOT_EMPTY_LIST' => 'Шаблоны не найдены',
'LBL_OOT_NAME' => 'Название',
'LBL_OOT_MODULE' => 'Модуль',
'LBL_OOT_FILE' => 'Файл',
'LBL_OOT_CREATED_AT' => 'Создан',
'LBL_OOT_ADD_TEMPLATE' => 'Добавить шаблон',
'LBL_OOT_FILE_HINT' => 'Только файлы DOCX',
'LBL_OOT_EDIT_TEMPLATE' => 'Редактирование шаблона',
'LBL_OOT_NEW_TEMPLATE' => 'Новый шаблон',
'LBL_OOT_EDITOR_HINT' => 'Редактируйте документ справа. Сохранение в S3 происходит автоматически при закрытии или по кнопке «Сохранить» в редакторе.',
'LBL_OOT_EDITOR_FALLBACK' => 'Если OnlyOffice не настроен, можно загрузить готовый DOCX-файл.',
'LBL_OOT_ADD_VIA_UPLOAD' => 'Загрузить DOCX-файл',
'LBL_OOT_UPLOAD_FILE' => 'Загрузить файл',
];

View File

@@ -53,4 +53,5 @@ $languageStrings = array(
'заявление на лист' => 'заявление на лист',
'урегулирование' => 'урегулирование',
'заключение мирового соглашения' => 'заключение мирового соглашения',
'Черновик' => 'Черновик',
);

View File

@@ -53,4 +53,5 @@ $languageStrings = array(
'заявление на лист' => 'заявление на лист',
'урегулирование' => 'урегулирование',
'заключение мирового соглашения' => 'заключение мирового соглашения',
'Черновик' => 'Черновик',
);

View File

@@ -53,4 +53,5 @@ $languageStrings = array(
'заявление на лист' => 'заявление на лист',
'урегулирование' => 'урегулирование',
'заключение мирового соглашения' => 'заключение мирового соглашения',
'Черновик' => 'Черновик',
);

View File

@@ -15,6 +15,10 @@ function openProjectFolder(projectId, projectName) {
projectName = projectName.replace(/"/g, '_');
// Заменяем ВСЕ пробелы на подчёркивания
projectName = projectName.replace(/\s+/g, '_');
// Заменяем множественные подчёркивания на одинарное
projectName = projectName.replace(/_+/g, '_');
// Убираем подчёркивания в начале и конце
projectName = projectName.replace(/^_+|_+$/g, '');
}
// Формируем URL для папки проекта в Nextcloud

View File

@@ -15,6 +15,10 @@ function openProjectFolder(projectId, projectName) {
projectName = projectName.replace(/"/g, '_');
// Заменяем ВСЕ пробелы на подчёркивания
projectName = projectName.replace(/\s+/g, '_');
// Заменяем множественные подчёркивания на одинарное
projectName = projectName.replace(/_+/g, '_');
// Убираем подчёркивания в начале и конце
projectName = projectName.replace(/^_+|_+$/g, '');
}
// Формируем URL для папки проекта в Nextcloud

View File

@@ -0,0 +1,54 @@
{strip}
<div class="container-fluid">
<div class="row">
<div class="col-sm-12">
<h4 class="pull-left">{vtranslate('LBL_OOT_ADD_TEMPLATE', $MODULE_NAME)}</h4>
<div class="clearfix"></div>
</div>
</div>
<div class="row">
<div class="col-sm-8">
{if $ERROR_MSG}
<div class="alert alert-danger">{$ERROR_MSG|escape}</div>
{/if}
<form action="index.php" method="post" enctype="multipart/form-data" class="form-horizontal">
<input type="hidden" name="module" value="OnlyOfficeTemplates" />
<input type="hidden" name="action" value="UploadTemplate" />
<input type="hidden" name="redirect" value="List" />
<div class="form-group">
<label class="control-label col-sm-3">{vtranslate('LBL_OOT_NAME', $MODULE_NAME)} <span class="redColor">*</span></label>
<div class="col-sm-9">
<input type="text" name="name" class="form-control" required="required" />
</div>
</div>
<div class="form-group">
<label class="control-label col-sm-3">{vtranslate('LBL_OOT_MODULE', $MODULE_NAME)} <span class="redColor">*</span></label>
<div class="col-sm-9">
<select name="module_name" class="form-control" required="required">
<option value="">-- {vtranslate('LBL_SELECT_OPTION','Vtiger')} --</option>
{foreach from=$MODULES key=modName item=modLabel}
<option value="{$modName}">{$modLabel}</option>
{/foreach}
</select>
</div>
</div>
<div class="form-group">
<label class="control-label col-sm-3">{vtranslate('LBL_OOT_FILE', $MODULE_NAME)} (DOCX) <span class="redColor">*</span></label>
<div class="col-sm-9">
<input type="file" name="file" accept=".docx" required="required" />
<span class="help-block">{vtranslate('LBL_OOT_FILE_HINT', $MODULE_NAME)}</span>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-3 col-sm-9">
<button type="submit" class="btn btn-success">{vtranslate('LBL_SAVE', $MODULE_NAME)}</button>
<a href="index.php?module=OnlyOfficeTemplates&view=List&app=TOOLS" class="btn btn-default">{vtranslate('LBL_CANCEL', $MODULE_NAME)}</a>
</div>
</div>
</form>
</div>
</div>
</div>
{/strip}

View File

@@ -0,0 +1,76 @@
{strip}
<div class="contents tabbable">
<div class="row">
<div class="col-xs-4 left-block" style="border-right: 1px solid #ddd;">
<h4>{if $TEMPLATE.id gt 0}{vtranslate('LBL_OOT_EDIT_TEMPLATE', $MODULE_NAME)}{else}{vtranslate('LBL_OOT_ADD_TEMPLATE', $MODULE_NAME)}{/if}</h4>
{if $ERROR_MSG}<div class="alert alert-danger">{$ERROR_MSG|escape}</div>{/if}
<form id="ootMetadataForm" action="index.php" method="post" class="form-horizontal">
<input type="hidden" name="module" value="OnlyOfficeTemplates" />
<input type="hidden" name="action" value="SaveMetadata" />
<input type="hidden" name="templateid" value="{$TEMPLATE.id}" />
<input type="hidden" name="redirect" value="Edit" />
<div class="form-group">
<label class="control-label col-sm-4">{vtranslate('LBL_OOT_NAME', $MODULE_NAME)}</label>
<div class="col-sm-8">
<input type="text" name="name" class="form-control" value="{$TEMPLATE.name|escape}" required />
</div>
</div>
<div class="form-group">
<label class="control-label col-sm-4">{vtranslate('LBL_OOT_MODULE', $MODULE_NAME)}</label>
<div class="col-sm-8">
<select name="module_name" class="form-control" required>
<option value="">-- {vtranslate('LBL_SELECT_OPTION','Vtiger')} --</option>
{foreach from=$MODULES key=modName item=modLabel}
<option value="{$modName}" {if $TEMPLATE.module eq $modName}selected{/if}>{$modLabel}</option>
{/foreach}
</select>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-4 col-sm-8">
<button type="submit" class="btn btn-success">{vtranslate('LBL_SAVE', $MODULE_NAME)}</button>
<a href="index.php?module=OnlyOfficeTemplates&view=List&app=TOOLS" class="btn btn-default">{vtranslate('LBL_CANCEL', $MODULE_NAME)}</a>
</div>
</div>
</form>
</div>
<div class="col-xs-8" style="min-height: 600px;">
{if $OOT_EDITOR_AVAILABLE}
<p class="text-muted">{vtranslate('LBL_OOT_EDITOR_HINT', $MODULE_NAME)}</p>
<div id="ootOnlyOfficeEditor" style="width:100%; height:700px;"></div>
<script src="{$OOT_DOCUMENT_SERVER}/web-apps/apps/api/documents/api.js"></script>
<script>
(function() {
var docKey = "{$OOT_DOC_KEY|escape:'javascript'}";
var config = {
document: {
fileType: "docx",
key: docKey,
title: "{$OOT_DOC_TITLE|escape:'javascript'}",
url: "{$OOT_DOCUMENT_URL|escape:'javascript'}"
},
documentType: "word",
editorConfig: {
callbackUrl: "{$OOT_CALLBACK_URL|escape:'javascript'}",
mode: "edit",
lang: "ru"
},
width: "100%",
height: "100%"
};
if (typeof DocsAPI !== "undefined") {
new DocsAPI.DocEditor("ootOnlyOfficeEditor", config);
} else {
document.getElementById("ootOnlyOfficeEditor").innerHTML = "<div class=\"alert alert-warning\">Не удалось загрузить OnlyOffice. Проверьте ONLYOFFICE_DOCUMENT_SERVER.</div>";
}
})();
</script>
{else}
<div class="alert alert-info">{$OOT_EDITOR_MESSAGE|escape}</div>
<p>{vtranslate('LBL_OOT_EDITOR_FALLBACK', $MODULE_NAME)}</p>
<a href="index.php?module=OnlyOfficeTemplates&view=AddTemplate&app=TOOLS" class="btn btn-primary">{vtranslate('LBL_OOT_ADD_VIA_UPLOAD', $MODULE_NAME)}</a>
{/if}
</div>
</div>
</div>
{/strip}

View File

@@ -0,0 +1,65 @@
{*
OnlyOfficeTemplates widget: template list, format (PDF/DOCX), Download / Save to Documents
*}
{if $CRM_TEMPLATES_EXIST eq 0}
<li class="dropdown-header">
<span class="fa fa-file-text-o"></span> {vtranslate('LBL_OOT_TEMPLATES','OnlyOfficeTemplates')}
</li>
<li class="oot-widget" data-record="{$ID}" data-module="{$MODULE}">
<div class="form-group">
<label class="control-label">{vtranslate('LBL_OOT_SELECT_TEMPLATE','OnlyOfficeTemplates')}</label>
<select class="oot-template-id form-control input-sm">
{foreach from=$CRM_TEMPLATES item=tpl}
<option value="{$tpl.id}">{$tpl.name}</option>
{/foreach}
</select>
</div>
<div class="form-group">
<label class="control-label">{vtranslate('LBL_OOT_FORMAT','OnlyOfficeTemplates')}</label>
<select class="oot-format form-control input-sm">
<option value="pdf">{vtranslate('LBL_OOT_FORMAT_PDF','OnlyOfficeTemplates')}</option>
<option value="docx">{vtranslate('LBL_OOT_FORMAT_DOCX','OnlyOfficeTemplates')}</option>
</select>
</div>
<div class="btn-group btn-group-justified">
<a href="javascript:;" class="oot-download"><i class="fa fa-download"></i> {vtranslate('LBL_OOT_DOWNLOAD','OnlyOfficeTemplates')}</a>
<a href="javascript:;" class="oot-save-to-docs"><i class="fa fa-save"></i> {vtranslate('LBL_OOT_SAVE_TO_DOCUMENTS','OnlyOfficeTemplates')}</a>
</div>
</li>
{else}
<li><span class="text-muted">{vtranslate('LBL_OOT_NO_TEMPLATES','OnlyOfficeTemplates')}</span></li>
{/if}
{if $CRM_TEMPLATES_EXIST eq 0}
<script type="text/javascript">
(function() {
var container = document.querySelector('.oot-widget');
if (!container) return;
container.addEventListener('click', function(e) {
var t = e.target.closest('.oot-download, .oot-save-to-docs');
if (!t) return;
e.preventDefault();
var record = container.getAttribute('data-record'), module = container.getAttribute('data-module');
var sel = container.querySelector('.oot-template-id'), fmt = container.querySelector('.oot-format');
var templateId = sel ? sel.value : '', format = fmt ? fmt.value : 'pdf';
var mode = t.classList.contains('oot-download') ? 'download' : 'save_to_documents';
var url = 'index.php?module=OnlyOfficeTemplates&action=CreateFromTemplate&record=' + encodeURIComponent(record) + '&source_module=' + encodeURIComponent(module) + '&template_id=' + encodeURIComponent(templateId) + '&format=' + encodeURIComponent(format) + '&mode=' + encodeURIComponent(mode);
if (mode === 'download') {
window.location.href = url;
} else {
var xhr = new XMLHttpRequest();
xhr.open('GET', url + '&ajax=1');
xhr.onload = function() {
var res = {};
try { res = JSON.parse(xhr.responseText); } catch (err) {}
if (res.success) {
alert(res.message || 'Сохранено в Документы');
} else {
alert(res.error || 'Ошибка');
}
};
xhr.send();
}
});
})();
</script>
{/if}

View File

@@ -0,0 +1,47 @@
{strip}
<div class="container-fluid">
<div class="row">
<div class="col-sm-12">
<h4 class="pull-left">{vtranslate('LBL_OOT_TEMPLATES', $MODULE_NAME)}</h4>
<a href="index.php?module=OnlyOfficeTemplates&action=CreateDraft&app=TOOLS" class="btn btn-success pull-right">
<i class="fa fa-plus"></i> {vtranslate('LBL_OOT_ADD_TEMPLATE', $MODULE_NAME)}
</a>
<a href="index.php?module=OnlyOfficeTemplates&view=AddTemplate&app=TOOLS" class="btn btn-default pull-right" style="margin-right:8px;">
{vtranslate('LBL_OOT_UPLOAD_FILE', $MODULE_NAME)}
</a>
<div class="clearfix"></div>
</div>
</div>
<div class="row">
<div class="col-sm-12">
{if empty($TEMPLATES)}
<div class="alert alert-info">{vtranslate('LBL_OOT_EMPTY_LIST', $MODULE_NAME)}</div>
{else}
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>{vtranslate('LBL_OOT_NAME', $MODULE_NAME)}</th>
<th>{vtranslate('LBL_OOT_MODULE', $MODULE_NAME)}</th>
<th>{vtranslate('LBL_OOT_FILE', $MODULE_NAME)}</th>
<th>{vtranslate('LBL_OOT_CREATED_AT', $MODULE_NAME)}</th>
</tr>
</thead>
<tbody>
{foreach from=$TEMPLATES item=TPL}
<tr>
<td>
<a href="index.php?module=OnlyOfficeTemplates&view=Edit&templateid={$TPL.id}&app=TOOLS">{$TPL.name|escape}</a>
</td>
<td>{$TPL.module|escape}</td>
<td>{$TPL.file_name|escape}</td>
<td>{$TPL.created_at|escape}</td>
</tr>
{/foreach}
</tbody>
</table>
{/if}
</div>
</div>
</div>
{/strip}

View File

@@ -1,20 +1,24 @@
/* AI Drawer - основные стили */
.ai-drawer {
position: fixed;
right: -400px; /* Начально скрыт */
right: 0; /* Всегда прижат к правому краю */
top: 0;
width: 400px;
min-width: 300px; /* Минимальная ширина */
max-width: 50vw; /* Максимальная ширина - половина экрана */
height: 100vh;
max-height: 100vh; /* Не превышаем высоту экрана */
background: #ffffff; /* Чистый белый фон */
box-shadow: -2px 0 15px rgba(0,0,0,0.1);
transition: right 0.3s ease;
transform: translateX(100%); /* Начально скрыт - сдвинут вправо на 100% своей ширины */
transition: transform 0.3s ease;
z-index: 999999;
display: flex;
flex-direction: column;
font-size: 14px; /* Базовый размер шрифта */
border-left: 1px solid #e9ecef;
overflow: hidden; /* Предотвращаем выход элементов за пределы */
box-sizing: border-box; /* Учитываем padding и border в ширине */
}
/* Полоска для изменения ширины */
@@ -44,13 +48,18 @@
user-select: none;
}
/* Убираем transition при изменении размера, чтобы не было задержек */
.ai-drawer.resizing.open {
transform: translateX(0) !important;
}
.ai-drawer.resizing .ai-drawer-resize-handle {
background: #007bff;
width: 4px;
}
.ai-drawer.open {
right: 0;
transform: translateX(0); /* Показываем - сдвигаем на место */
}
/* Скрываем кнопку AI когда drawer открыт */
@@ -91,6 +100,9 @@ body.ai-drawer-open .ai-drawer-toggle {
align-items: center;
font-weight: 600;
border-bottom: 1px solid #0056b3;
flex-shrink: 0; /* Не сжимается при изменении размера */
min-height: 50px; /* Минимальная высота для кнопки закрытия */
box-sizing: border-box;
}
.ai-drawer-close {
@@ -436,12 +448,42 @@ body.ai-drawer-open .ai-drawer-toggle {
.ai-message-content p {
margin: 0 0 5px 0;
word-wrap: break-word;
word-break: break-word;
}
.ai-message-content p:last-child {
margin-bottom: 0;
}
/* Стили для ссылок в сообщениях */
.ai-message-link {
color: #007bff;
text-decoration: underline;
cursor: pointer;
word-break: break-word;
display: inline-block;
margin: 2px 0;
padding: 2px 4px;
border-radius: 3px;
transition: all 0.2s ease;
}
.ai-message-link:hover {
color: #0056b3;
text-decoration: none;
background-color: #e7f3ff;
padding: 2px 6px;
}
.ai-message-link:visited {
color: #6f42c1;
}
.ai-message-link:active {
color: #004085;
}
.ai-message-time {
font-size: 11px;
color: #6c757d; /* Серый цвет для времени */
@@ -456,6 +498,8 @@ body.ai-drawer-open .ai-drawer-toggle {
border-top: 1px solid #dee2e6;
display: flex;
gap: 10px;
flex-shrink: 0; /* Не сжимается при изменении размера */
box-sizing: border-box;
align-items: center;
}
@@ -510,7 +554,8 @@ body.ai-drawer-open .ai-drawer-toggle {
@media (max-width: 768px) {
.ai-drawer {
width: 100%;
right: -100%;
right: 0;
transform: translateX(100%); /* Начально скрыт на мобильных */
height: 100vh;
height: 100dvh; /* Динамическая высота viewport для мобильных */
display: flex;
@@ -731,7 +776,8 @@ body.ai-drawer-open .ai-drawer-toggle {
width: 400px;
min-width: 300px;
max-width: 50vw;
right: -400px;
right: 0;
transform: translateX(100%); /* Начально скрыт на планшетах */
height: 100vh;
display: flex;
flex-direction: column;

View File

@@ -48,7 +48,11 @@ class AIDrawer {
'<div class="ai-avatar assistant"></div>' +
'<div class="ai-message-content">' +
'<p>Привет! Я ваш AI ассистент. Чем могу помочь?</p>' +
'<div class="ai-message-time">' + new Date().toLocaleTimeString() + '</div>' +
'<div class="ai-message-time">' + new Date().toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
hour12: false // 24-часовой формат
}) + '</div>' +
'</div>' +
'</div>' +
'</div>' +
@@ -128,7 +132,7 @@ class AIDrawer {
// Обработчик изменения размера окна - ограничиваем ширину если нужно
window.addEventListener('resize', () => {
if (this.drawerWidth > window.innerWidth / 2) {
if (this.isOpen && this.drawerWidth > window.innerWidth / 2) {
const maxWidth = window.innerWidth / 2;
this.setDrawerWidth(maxWidth);
}
@@ -144,8 +148,18 @@ class AIDrawer {
const savedWidth = localStorage.getItem('ai-drawer-width');
if (savedWidth) {
const width = parseInt(savedWidth, 10);
if (width >= 300 && width <= window.innerWidth / 2) {
const maxWidth = window.innerWidth / 2;
// Проверяем что ширина в допустимых пределах для текущего экрана
if (width >= 300 && width <= maxWidth) {
this.setDrawerWidth(width);
} else if (width > maxWidth) {
// Если сохраненная ширина больше максимума - ограничиваем
console.log('AI Drawer: Saved width', width, 'exceeds max', maxWidth, ', adjusting');
this.setDrawerWidth(maxWidth);
} else {
// Если меньше минимума - устанавливаем минимум
console.log('AI Drawer: Saved width', width, 'is less than minimum, setting to 300');
this.setDrawerWidth(300);
}
}
@@ -220,6 +234,23 @@ class AIDrawer {
open() {
console.log('AI Drawer: Opening drawer');
if (this.drawer) {
// Проверяем и корректируем ширину перед открытием
const maxWidth = window.innerWidth / 2;
if (this.drawerWidth > maxWidth) {
console.log('AI Drawer: Adjusting width from', this.drawerWidth, 'to', maxWidth);
this.setDrawerWidth(maxWidth);
} else if (this.drawerWidth < 300) {
console.log('AI Drawer: Adjusting width from', this.drawerWidth, 'to 300');
this.setDrawerWidth(300);
}
// Убеждаемся что ширина применена к drawer
this.drawer.style.width = this.drawerWidth + 'px';
// Убеждаемся что drawer правильно позиционирован перед открытием
this.drawer.style.right = '0';
this.drawer.style.transform = 'translateX(0)';
this.drawer.classList.add('open');
}
@@ -234,6 +265,33 @@ class AIDrawer {
mainContainer.setAttribute('data-drawer-width', this.drawerWidth);
}
// Прокручиваем вниз к последнему сообщению при открытии
const scrollToBottomOnOpen = () => {
const drawerContent = this.drawer?.querySelector('.ai-drawer-content');
const chatMessages = this.drawer?.querySelector('.ai-chat-messages');
if (drawerContent) {
const scroll = () => {
drawerContent.scrollTop = drawerContent.scrollHeight;
console.log('AI Drawer: Scrolled on open, scrollTop:', drawerContent.scrollTop, 'scrollHeight:', drawerContent.scrollHeight);
};
// Прокручиваем последнее сообщение в видимую область
if (chatMessages && chatMessages.lastElementChild) {
chatMessages.lastElementChild.scrollIntoView({ behavior: 'smooth', block: 'end' });
}
requestAnimationFrame(() => {
scroll();
requestAnimationFrame(scroll);
});
}
};
// Прокручиваем после анимации открытия
setTimeout(scrollToBottomOnOpen, 300);
setTimeout(scrollToBottomOnOpen, 600);
// История уже загружена при инициализации страницы
// Не нужно дополнительных запросов при открытии
}
@@ -242,6 +300,10 @@ class AIDrawer {
console.log('AI Drawer: Closing drawer');
if (this.drawer) {
this.drawer.classList.remove('open');
// Убеждаемся что drawer скрыт через transform
if (!this.drawer.classList.contains('open')) {
this.drawer.style.transform = 'translateX(100%)';
}
}
document.body.classList.remove('ai-drawer-open');
@@ -295,6 +357,142 @@ class AIDrawer {
}
}
// Функция для определения читаемого текста ссылки на основе URL
getLinkText(url) {
const urlLower = url.toLowerCase();
const maxLength = 60; // Максимальная длина ссылки до замены
// Если ссылка короткая, показываем её полностью
if (url.length <= maxLength) {
return url;
}
// Анализируем URL и определяем тип действия
if (urlLower.includes('download') || urlLower.includes('file') || urlLower.includes('скачать')) {
return '📥 Скачать документ';
}
if (urlLower.includes('edit') || urlLower.includes('редактир') || urlLower.includes('onlyoffice')) {
return '✏️ Открыть для редактирования';
}
if (urlLower.includes('view') || urlLower.includes('просмотр') || urlLower.includes('preview')) {
return '👁️ Открыть для просмотра';
}
if (urlLower.includes('document') || urlLower.includes('документ') || urlLower.includes('.docx') || urlLower.includes('.pdf')) {
return '📄 Открыть документ';
}
if (urlLower.includes('create') || urlLower.includes('создать')) {
return ' Создать документ';
}
// Общие варианты для длинных ссылок
const linkTexts = [
'🔗 Открыть ссылку',
'👉 Смотреть здесь',
'📋 Подробнее',
'🔍 Перейти к документу',
'📎 Открыть'
];
// Выбираем случайный вариант для разнообразия
return linkTexts[Math.floor(Math.random() * linkTexts.length)];
}
// Функция для преобразования URL в кликабельные ссылки
convertUrlsToLinks(text) {
if (!text) return '';
let result = text;
// ШАГ 1: Обрабатываем Markdown ссылки [текст](url)
const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
result = result.replace(markdownLinkRegex, (match, linkText, url) => {
// Проверяем, что это валидный URL
if (url.match(/^https?:\/\//i)) {
return `<a href="${url}" target="_blank" rel="noopener noreferrer" class="ai-message-link" title="${url}">${linkText}</a>`;
}
return match; // Если не URL, оставляем как есть
});
// ШАГ 2: Временно заменяем уже существующие HTML-ссылки на плейсхолдеры
const htmlLinks = [];
const htmlLinkRegex = /<a\s+[^>]*href\s*=\s*["']([^"']+)["'][^>]*>([^<]*)<\/a>/gi;
result = result.replace(htmlLinkRegex, (match, href, linkText) => {
const placeholder = `__HTML_LINK_${htmlLinks.length}__`;
htmlLinks.push({ href, linkText, match });
return placeholder;
});
// ШАГ 3: Экранируем оставшийся HTML для безопасности
const escaped = result
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
// ШАГ 4: Восстанавливаем HTML-ссылки (плейсхолдеры не экранированы, т.к. не содержат < >)
let finalResult = escaped;
htmlLinks.forEach((link, index) => {
const placeholder = `__HTML_LINK_${index}__`;
// Используем оригинальную ссылку, но добавляем класс если его нет
let htmlLink = link.match;
if (!htmlLink.includes('class=')) {
htmlLink = htmlLink.replace('<a', '<a class="ai-message-link"');
} else if (!htmlLink.includes('ai-message-link')) {
htmlLink = htmlLink.replace('class="', 'class="ai-message-link ');
htmlLink = htmlLink.replace("class='", "class='ai-message-link ");
}
// Убеждаемся что есть target="_blank"
if (!htmlLink.includes('target=')) {
htmlLink = htmlLink.replace('<a', '<a target="_blank" rel="noopener noreferrer"');
}
// Заменяем плейсхолдер на правильный HTML (не экранированный)
finalResult = finalResult.replace(placeholder, htmlLink);
});
// ШАГ 5: Преобразуем обычные URL в ссылки (только те, что не внутри уже существующих ссылок)
const urlRegex = /(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/gi;
let urlMatches = [];
let match;
// Сначала находим все URL и проверяем их контекст
while ((match = urlRegex.exec(finalResult)) !== null) {
const url = match[0];
const offset = match.index;
const beforeMatch = finalResult.substring(0, offset);
// Проверяем, нет ли открывающего тега <a перед этим URL
const lastOpenTag = beforeMatch.lastIndexOf('<a');
const lastCloseTag = beforeMatch.lastIndexOf('</a>');
// Если есть открывающий тег <a и нет закрывающего после него - значит мы внутри ссылки
if (lastOpenTag > lastCloseTag) {
continue; // Пропускаем, это уже часть ссылки
}
// Проверяем, не является ли это частью href атрибута
if (beforeMatch.lastIndexOf('href=') > lastCloseTag) {
continue; // Пропускаем, это часть href
}
urlMatches.push({ url, offset });
}
// Заменяем URL в обратном порядке (чтобы не сбить индексы)
for (let i = urlMatches.length - 1; i >= 0; i--) {
const { url, offset } = urlMatches[i];
const linkText = this.getLinkText(url);
const linkHtml = `<a href="${url}" target="_blank" rel="noopener noreferrer" class="ai-message-link" title="${url}">${linkText}</a>`;
finalResult = finalResult.substring(0, offset) + linkHtml + finalResult.substring(offset + url.length);
}
return finalResult;
}
addMessage(text, isUser = false, customTime = null) {
console.log('AI Drawer: addMessage called with:', {text: text.substring(0, 50), isUser, customTime});
@@ -331,17 +529,69 @@ class AIDrawer {
contentDiv.className = 'ai-message-content';
const textDiv = document.createElement('p');
textDiv.textContent = text;
// Преобразуем URL в кликабельные ссылки
textDiv.innerHTML = this.convertUrlsToLinks(text);
contentDiv.appendChild(textDiv);
const timeDiv = document.createElement('div');
timeDiv.className = 'ai-message-time';
if (customTime) {
// Если передано время из истории, используем его
try {
// Логируем для отладки
console.log('AI Drawer: Parsing timestamp:', customTime);
const historyTime = new Date(customTime);
timeDiv.textContent = historyTime.toLocaleTimeString();
// Проверяем что дата валидна
if (isNaN(historyTime.getTime())) {
// Если дата невалидна, пытаемся распарсить как строку времени (старый формат)
console.warn('AI Drawer: Invalid timestamp format:', customTime, 'Parsed as:', historyTime);
timeDiv.textContent = customTime; // Показываем как есть
} else {
timeDiv.textContent = new Date().toLocaleTimeString();
// Определяем, нужно ли показывать дату
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const messageDate = new Date(historyTime.getFullYear(), historyTime.getMonth(), historyTime.getDate());
const isToday = messageDate.getTime() === today.getTime();
let formattedTime;
if (isToday) {
// Если сообщение сегодня - показываем только время
formattedTime = historyTime.toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
} else {
// Если сообщение не сегодня - показываем дату и время
const dateStr = historyTime.toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit'
});
const timeStr = historyTime.toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
formattedTime = `${dateStr} ${timeStr}`;
}
console.log('AI Drawer: Successfully formatted timestamp:', customTime, '->', formattedTime);
timeDiv.textContent = formattedTime;
}
} catch (error) {
console.error('AI Drawer: Error parsing timestamp:', customTime, error);
timeDiv.textContent = customTime || new Date().toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
hour12: false // 24-часовой формат
});
}
} else {
timeDiv.textContent = new Date().toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
hour12: false // 24-часовой формат
});
}
contentDiv.appendChild(timeDiv);
@@ -388,7 +638,11 @@ class AIDrawer {
const timeDiv = document.createElement('div');
timeDiv.className = 'ai-message-time';
timeDiv.textContent = new Date().toLocaleTimeString();
timeDiv.textContent = new Date().toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
hour12: false // 24-часовой формат
});
contentDiv.appendChild(timeDiv);
messageDiv.appendChild(avatarDiv);
@@ -404,9 +658,12 @@ class AIDrawer {
streamText(element, text, speed = 30) {
let index = 0;
let currentText = '';
const interval = setInterval(() => {
if (index < text.length) {
element.textContent += text[index];
currentText += text[index];
// Преобразуем URL в кликабельные ссылки по мере добавления текста
element.innerHTML = this.convertUrlsToLinks(currentText);
index++;
const content = this.drawer.querySelector('.ai-drawer-content');
@@ -880,6 +1137,41 @@ class AIDrawer {
}
});
// Прокручиваем вниз к последнему сообщению после загрузки истории
const scrollToBottom = () => {
const drawerContent = this.drawer?.querySelector('.ai-drawer-content');
const chatMessages = this.drawer?.querySelector('.ai-chat-messages');
if (drawerContent) {
// Способ 1: Прокручиваем контейнер
const scroll = () => {
drawerContent.scrollTop = drawerContent.scrollHeight;
console.log('AI Drawer: Scrolled container, scrollTop:', drawerContent.scrollTop, 'scrollHeight:', drawerContent.scrollHeight);
};
// Способ 2: Прокручиваем последнее сообщение в видимую область
if (chatMessages && chatMessages.lastElementChild) {
chatMessages.lastElementChild.scrollIntoView({ behavior: 'smooth', block: 'end' });
console.log('AI Drawer: Scrolled last message into view');
}
// Используем requestAnimationFrame для более надежной прокрутки
requestAnimationFrame(() => {
scroll();
requestAnimationFrame(() => {
scroll();
});
});
} else {
console.warn('AI Drawer: Drawer content not found for scrolling');
}
};
// Прокручиваем с несколькими задержками для надежности
setTimeout(scrollToBottom, 100);
setTimeout(scrollToBottom, 300);
setTimeout(scrollToBottom, 600);
console.log('AI Drawer: Chat history restored -', data.history.length, 'messages');
} else {
console.log('AI Drawer: No chat history found. Response:', data);

View File

@@ -19,23 +19,23 @@ class Documents_Record_Model extends Vtiger_Record_Model {
}
function getDownloadFileURL() {
// Сначала проверяем filelocationtype - это основной индикатор типа хранения
if ($this->get('filelocationtype') == 'E') {
// Внешняя ссылка (S3 URL уже в filename)
return $this->get('filename');
} else if ($this->get('filelocationtype') == 'I') {
// Проверяем, есть ли S3 метаданные для внутренних файлов
// Проверяем наличие S3 метаданных для любого типа файла
$db = PearDatabase::getInstance();
$result = $db->pquery("SELECT s3_key FROM vtiger_notes WHERE notesid = ? AND s3_key IS NOT NULL", array($this->getId()));
$result = $db->pquery("SELECT s3_bucket, s3_key FROM vtiger_notes WHERE notesid = ? AND s3_bucket IS NOT NULL AND s3_key IS NOT NULL", array($this->getId()));
if ($db->num_rows($result) > 0) {
// Файл в S3 - используем новый DownloadS3 action
// Файл в S3 - используем DownloadS3 action для генерации presigned URL
return 'index.php?module='. $this->getModuleName() .'&action=DownloadS3&record='. $this->getId();
} else {
}
// Если нет S3 метаданных, обрабатываем по типу хранения
if ($this->get('filelocationtype') == 'E') {
// Внешняя ссылка (не S3, а другой внешний источник)
return $this->get('filename');
} else if ($this->get('filelocationtype') == 'I') {
// Файл в локальном storage - используем старый метод
$fileDetails = $this->getFileDetails();
return 'index.php?module='. $this->getModuleName() .'&action=DownloadFile&record='. $this->getId() .'&fileid='. $fileDetails['attachmentsid'].'&name='. $fileDetails['name'];
}
} else {
// По умолчанию - внешняя ссылка
return $this->get('filename');

View File

@@ -0,0 +1,158 @@
# OnlyOfficeTemplates — подробное описание модуля
## Назначение
Модуль **OnlyOfficeTemplates** для ClientRight CRM (Vtiger-based) предназначен для:
- создания и хранения DOCX-шаблонов документов в S3;
- редактирования шаблонов в веб-интерфейсе через OnlyOffice Document Editor (по аналогии с PDFMaker: слева метаданные, справа редактор);
- генерации документов по шаблону с подстановкой полей записи и связанных модулей (плейсхолдеры `{{field}}`, `{{ModuleName__field}}`);
- выдачи результата в формате **PDF** (через OnlyOffice Conversion API) или **DOCX**;
- скачивания сгенерированного файла или сохранения в модуль «Документы» CRM.
Модуль портативный: конфигурация через переменные окружения или внешний конфиг, без жёсткой привязки к окружению.
---
## Возможности
### Хранение шаблонов
- Шаблоны хранятся в **S3-совместимом хранилище** по пути:
`{OOT_S3_PREFIX}/templates/{template_id}/{имя_файла}.docx`
(по умолчанию `OOT_S3_PREFIX` = `crm2/OnlyOfficeTemplates`).
- Метаданные — в таблице БД `vtiger_oot_templates`: id, name, module, s3_key, file_name, owner, created_at.
### Редактирование шаблонов (как в PDFMaker)
- **Список шаблонов** (Инструменты → Шаблоны документов): таблица с именем (ссылка на редактирование), модулем, файлом, датой создания.
- **Добавить шаблон:** создаётся черновик, открывается экран редактирования:
- **Слева:** форма — название шаблона, выбор модуля CRM, кнопки «Сохранить» / «Отмена».
- **Справа:** OnlyOffice Document Editor в iframe; документ загружается с нашего сервера (экшен GetDocument), при сохранении/закрытии OnlyOffice Document Server отправляет файл на callback, мы сохраняем его в S3 и обновляем запись в БД.
- **Загрузить файл:** альтернативный способ — форма с полями «Название», «Модуль» и выбором DOCX-файла; отправка в экшен UploadTemplate (загрузка в S3 и запись в БД).
- Редактирование существующего шаблона: по клику на имя в списке открывается тот же экран с подставленными метаданными и документом из S3.
### Генерация документов по шаблону
- На **карточке записи** (любой entity-модуль) в боковой панели отображается виджет OnlyOfficeTemplates:
- выбор шаблона (по текущему модулю записи);
- выбор формата: PDF или DOCX;
- кнопки «Скачать» и «Сохранить в Документы».
- Подстановка в шаблоне:
- поля текущей записи: `{{fieldname}}`;
- поля связанных модулей: `{{ModuleName__fieldname}}` (например `{{Account__accountname}}`).
- Реализация: загрузка DOCX из S3, подстановка плейсхолдеров (PHPWord), при необходимости конвертация в PDF через OnlyOffice Conversion API, затем отдача файла или сохранение в Документы (в т.ч. в S3).
---
## Требования
- **PHP:** расширения zip, xml, curl (или allow_url_fopen для Conversion API).
- **Composer:** пакеты `phpoffice/phpword`, `aws/aws-sdk-php` (как правило, уже в корне проекта).
- **S3:** доступ к S3-совместимому хранилищу (ключ, секрет, endpoint, bucket).
- **OnlyOffice (опционально):**
- **Conversion API** — для выдачи PDF (URL в `OOT_ONLYOFFICE_CONVERT_URL` / `ONLYOFFICE_CONVERT_URL`).
- **Document Server** — для экрана редактирования шаблона (URL в `ONLYOFFICE_DOCUMENT_SERVER` / `OOT_ONLYOFFICE_DOCUMENT_SERVER`). Document Server должен иметь доступ по HTTP(S) к CRM (загрузка документа и callback).
---
## Установка
1. Скопировать в целевую CRM:
- `modules/OnlyOfficeTemplates/` (все файлы);
- `layouts/v7/modules/OnlyOfficeTemplates/` (все шаблоны и ресурсы).
2. Настроить конфигурацию (см. ниже).
3. Выполнить установку БД и виджетов:
- **Рекомендуется:** из корня CRM выполнить
`php modules/OnlyOfficeTemplates/install.php`
или открыть в браузере соответствующий URL с правами администратора.
- Альтернатива: упаковать модуль в zip с `manifest.xml` и импортировать через Module Manager.
4. При необходимости перегенерировать кэш меню (например `parent_tabdata.php`), чтобы пункт «Шаблоны документов» отображался в разделе «Инструменты».
---
## Конфигурация
Модуль читает настройки в следующем порядке.
### 1. Внешний конфиг
Если существует файл `crm_extensions/file_storage/config.php` и в нём возвращается массив с ключом `s3`, используются данные S3 оттуда. Имя бакета берётся из `s3['bucket']`, при отсутствии — из `bucket`, `s3_bucket` в корне массива или из переменной окружения `S3_BUCKET`.
### 2. Переменные окружения
Поддерживаются два способа загрузки переменных:
- **EnvLoader** (если есть `crm_extensions/shared/EnvLoader.php`): загружается файл `crm_extensions/.env`. Переменные попадают в `$_ENV` и в массив EnvLoader (модуль читает их через вспомогательную функцию, а не только через `getenv()`).
- **getenv()** — если переменные заданы в окружении веб-сервера.
Используемые переменные:
| Переменная | Описание |
|------------|----------|
| `S3_ACCESS_KEY` | Ключ доступа S3 |
| `S3_SECRET_KEY` | Секретный ключ S3 |
| `S3_ENDPOINT` | URL эндпоинта S3 (напр. `https://s3.twcstorage.ru`) |
| `S3_BUCKET` | Имя бакета S3 |
| `S3_REGION` | Регион (по умолчанию `ru-1`) |
| `OOT_S3_PREFIX` | Префикс папки модуля в S3 (по умолчанию `crm2/OnlyOfficeTemplates`) |
| `OOT_ONLYOFFICE_CONVERT_URL` | URL OnlyOffice Conversion API (для PDF) |
| `ONLYOFFICE_CONVERT_URL` | То же, альтернативное имя |
| `OOT_ONLYOFFICE_DOCUMENT_SERVER` | URL OnlyOffice Document Server (для редактора шаблонов) |
| `ONLYOFFICE_DOCUMENT_SERVER` | То же, альтернативное имя |
| `OOT_DOCUMENT_SECRET` | Секрет для подписи URL документа (рекомендуется в продакшене) |
| `OOT_DOCUMENTS_S3_PREFIX` | Префикс в S3 для файлов, сохраняемых в Документы (по умолчанию `crm2/CRM_Active_Files/Documents`) |
Без Conversion API генерация PDF недоступна (только DOCX). Без Document Server экран редактирования с OnlyOffice недоступен, но остаётся загрузка готового DOCX через «Загрузить файл».
---
## Структура файлов модуля
- **config.php** — загрузка конфигурации (внешний конфиг + .env), функция `OnlyOfficeTemplates_getConfig()` и вспомогательная `OnlyOfficeTemplates_env()` для чтения переменных из .env/EnvLoader.
- **OnlyOfficeTemplates.php** — обработчик vtlib (установка/удаление таблиц, добавление/удаление виджета на карточках).
- **schema.xml** — описание таблиц `vtiger_oot_templates`, `vtiger_oot_templates_seq`.
- **models/OnlyOfficeTemplates_Model.php** — список шаблонов по модулю, получение шаблона по id, конфиг.
- **resources/S3Helper.php** — работа с S3 (ключи шаблонов/temp, загрузка/скачивание).
- **resources/MergeService.php** — подстановка плейсхолдеров в DOCX (PHPWord).
- **resources/ConvertService.php** — конвертация DOCX → PDF через OnlyOffice Conversion API.
- **actions/Install.php** — установка через браузер.
- **actions/UploadTemplate.php** — загрузка DOCX (POST: name, module_name, file) и сохранение в S3 и БД.
- **actions/CreateDraft.php** — создание черновика шаблона и редирект на экран редактирования.
- **actions/SaveMetadata.php** — сохранение имени и модуля шаблона, редирект на Edit или List.
- **actions/GetDocument.php** — отдача DOCX для OnlyOffice Document Server (из S3 или пустой документ); опциональная проверка токена (OOT_DOCUMENT_SECRET).
- **actions/OnlyOfficeCallback.php** — приём callback от OnlyOffice Document Server при сохранении документа, скачивание файла по переданному URL и сохранение в S3, обновление записи в `vtiger_oot_templates`.
- **actions/CreateFromTemplate.php** — генерация документа по шаблону (merge → опционально PDF → скачать или сохранить в Документы).
- **views/List.php**, **views/Edit.php**, **views/AddTemplate.php**, **views/GetTemplateActions.php** — представления списка, редактирования (форма + OnlyOffice), загрузки файла и виджета на карточке.
- **layouts/v7/modules/OnlyOfficeTemplates/** — шаблоны Smarty (List.tpl, Edit.tpl, AddTemplate.tpl, GetTemplateActions.tpl).
- **languages/** — языковые строки (ru_ru, en_us).
---
## База данных
- **vtiger_oot_templates:** id, name, module, s3_key, file_name, owner, created_at (и при необходимости settings в формате JSON).
- **vtiger_oot_templates_seq:** служебная таблица для генерации id при необходимости.
Регистрация модуля в меню: запись в `vtiger_tab` (например parent Tools), в `vtiger_parenttabrel`, в `vtiger_profile2tab` для прав доступа. Пункт меню кэшируется в `parent_tabdata.php` — при отсутствии пункта может потребоваться перегенерация кэша.
---
## Безопасность
- **GetDocument:** вызывается OnlyOffice Document Server без сессии пользователя. При заданном `OOT_DOCUMENT_SECRET` в URL документа добавляется подпись (HMAC), проверяемая в GetDocument; без секрета доступ по ссылке возможен для любого, кто знает template_id.
- **OnlyOfficeCallback:** вызывается только Document Server; проверка прав пользователя не выполняется, идентификация по ключу документа (template id). В продакшене целесообразно ограничить доступ к callback по сети (например, только с хоста Document Server).
- **UploadTemplate:** требуется право редактирования настроек (`Settings`, `Edit`) или иная выбранная при интеграции проверка.
---
## Версии и совместимость
- Модуль разработан для CRM на базе Vtiger (ClientRight), интерфейс v7.
- Зависимости: PHP 7.x+, PHPWord, AWS SDK для PHP, S3-совместимое хранилище; OnlyOffice — опционально.
---
## Краткое описание для репозитория (Gitea)
**OnlyOfficeTemplates** — модуль ClientRight CRM для создания и хранения DOCX-шаблонов в S3, редактирования их в OnlyOffice Document Editor (интерфейс как в PDFMaker: слева метаданные, справа редактор с сохранением в S3 по callback), генерации документов по шаблону с подстановкой полей записи и связанных модулей, выдачи в PDF (через OnlyOffice Conversion API) или DOCX и сохранения в Документы. Конфигурация через .env или внешний конфиг; модуль портативный.

View File

@@ -0,0 +1,38 @@
# Установка OnlyOfficeTemplates
## Шаги
1. **Скопировать файлы**
- `modules/OnlyOfficeTemplates/` — целиком.
- `layouts/v7/modules/OnlyOfficeTemplates/` — целиком.
- Файлы языков из `languages/*/OnlyOfficeTemplates.php` (ru_ru, en_us и при необходимости другие).
2. **Настроить конфигурацию**
- Либо используйте существующий `crm_extensions/file_storage/config.php` (S3 будет взят оттуда).
- Либо задайте в .env: `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_ENDPOINT`, `S3_BUCKET`.
- Для конвертации в PDF задайте `OOT_ONLYOFFICE_CONVERT_URL` (например `https://office.clientright.ru:9443/ConvertService.ashx`).
3. **Установить модуль в CRM**
- Из корня CRM выполните:
```bash
php modules/OnlyOfficeTemplates/install.php
```
- Либо зарегистрируйте модуль вручную в vtiger_tab и выполните SQL из `schema.xml`, затем вызовите `$mod = new OnlyOfficeTemplates(); $mod->executeSql(); $mod->addLinksToEntityModules();`
4. **Добавить шаблоны**
- Через экшен UploadTemplate (POST с полями name, module_name и файлом file).
- Либо вручную: загрузить DOCX в S3 в `{OOT_S3_PREFIX}/templates/{id}/{filename}.docx` и вставить запись в `vtiger_oot_templates`.
## Проверка
- Откройте карточку любой записи модуля (например, Проект). В боковой панели должен появиться виджет «OnlyOffice Templates» со списком шаблонов (если они добавлены для этого модуля).
- Выберите шаблон, формат (PDF или DOCX), нажмите «Скачать» или «Сохранить в Документы».
## Переменные окружения (кратко)
| Переменная | Описание |
|------------|----------|
| S3_ACCESS_KEY, S3_SECRET_KEY, S3_ENDPOINT, S3_BUCKET | Доступ к S3 |
| OOT_S3_PREFIX | Префикс папки модуля в S3 (по умолчанию crm2/OnlyOfficeTemplates) |
| OOT_ONLYOFFICE_CONVERT_URL | URL OnlyOffice Conversion API для DOCX→PDF |
| OOT_DOCUMENTS_S3_PREFIX | Префикс пути при сохранении в Документы |

View File

@@ -0,0 +1,107 @@
<?php
/**
* OnlyOfficeTemplates module - DOCX template merge and PDF/DOCX export.
* Portable: config via .env or optional crm_extensions config.
*/
class OnlyOfficeTemplates
{
public $db;
public $moduleName = 'OnlyOfficeTemplates';
public $parentName = 'Tools';
public function __construct()
{
global $log;
$this->db = PearDatabase::getInstance();
}
public function vtlib_handler($modulename, $event_type)
{
switch ($event_type) {
case 'module.postinstall':
$this->executeSql();
$this->addLinksToEntityModules();
break;
case 'module.preupdate':
case 'module.disabled':
$this->removeLinksFromEntityModules();
break;
case 'module.enabled':
case 'module.postupdate':
$this->executeSql();
$this->removeLinksFromEntityModules();
$this->addLinksToEntityModules();
break;
case 'module.preuninstall':
$this->removeLinksFromEntityModules();
break;
}
}
/**
* Create tables from schema.xml
*/
public function executeSql()
{
$schemaPath = dirname(__FILE__) . '/schema.xml';
if (!is_file($schemaPath)) {
return;
}
$xml = @simplexml_load_file($schemaPath);
if (!$xml || !isset($xml->tables->table)) {
return;
}
foreach ($xml->tables->table as $table) {
$name = (string)$table->name;
$sql = isset($table->sql) ? (string)$table->sql : '';
if (empty($sql)) {
continue;
}
$this->db->pquery($sql, []);
}
// seq initial value
$this->db->pquery("INSERT IGNORE INTO vtiger_oot_templates_seq (id) VALUES (1)", []);
}
/**
* Add DETAILVIEWSIDEBARWIDGET link to all entity modules (like PDFMaker).
*/
public function addLinksToEntityModules()
{
$result = $this->db->pquery(
"SELECT name FROM vtiger_tab WHERE isentitytype = ? AND presence = ?",
['1', '0']
);
while ($row = $this->db->fetchByAssoc($result)) {
$moduleName = $row['name'];
$module = Vtiger_Module::getInstance($moduleName);
if (!$module) {
continue;
}
$module->deleteLink('DETAILVIEWSIDEBARWIDGET', 'OnlyOfficeTemplates');
$module->addLink(
'DETAILVIEWSIDEBARWIDGET',
'OnlyOfficeTemplates',
'module=OnlyOfficeTemplates&view=GetTemplateActions&record=$RECORD$'
);
}
}
/**
* Remove widget link from all entity modules.
*/
public function removeLinksFromEntityModules()
{
$result = $this->db->pquery(
"SELECT name FROM vtiger_tab WHERE isentitytype = ? AND presence = ?",
['1', '0']
);
while ($row = $this->db->fetchByAssoc($result)) {
$module = Vtiger_Module::getInstance($row['name']);
if ($module) {
$module->deleteLink('DETAILVIEWSIDEBARWIDGET', 'OnlyOfficeTemplates');
}
}
}
}

View File

@@ -0,0 +1,59 @@
# OnlyOfficeTemplates
Модуль генерации документов из DOCX-шаблонов с подстановкой полей CRM (аналог PDFMaker). Результат — **PDF** (по умолчанию) или **DOCX**. При сохранении в Документы в формате DOCX документ можно редактировать через OnlyOffice (кнопка «Nextcloud» / open_file_v2).
## Возможности
- Шаблоны DOCX хранятся в **отдельной папке S3** (`crm2/OnlyOfficeTemplates/templates/`).
- **Редактирование по аналогии с PDFMaker:** слева — метаданные (имя, модуль), справа — OnlyOffice Document Editor; документ сохраняется в S3 через callback Document Server.
- Плейсхолдеры в шаблоне: `{{fieldname}}` для полей записи, `{{ModuleName__fieldname}}` для связанных модулей (Account, Contact и т.д.).
- В виджете карточки записи: выбор шаблона, формата (PDF/DOCX), действия «Скачать» и «Сохранить в Документы».
- При выборе PDF результат конвертируется через OnlyOffice Conversion API.
- Модуль **портативный**: можно развернуть в другом инстансе CRM без привязки к текущему `crm_extensions`.
## Требования
- PHP с расширениями: zip, xml, curl (или allow_url_fopen для Conversion API).
- Composer-зависимости: `phpoffice/phpword`, `aws/aws-sdk-php` (уже в корне проекта).
- Доступ к S3-совместимому хранилищу и (для PDF) к OnlyOffice Document Server (Conversion API).
## Установка
1. Скопируйте папку `modules/OnlyOfficeTemplates` и `layouts/v7/modules/OnlyOfficeTemplates` в целевой CRM.
2. Настройте переменные окружения или конфиг (см. раздел «Конфигурация»).
3. Выполните установку БД и виджетов одним из способов:
- **Через скрипт (рекомендуется):**
`php modules/OnlyOfficeTemplates/install.php`
из корня CRM (или откройте в браузере соответствующий URL с правами администратора).
- **Через Module Manager:** упакуйте модуль в zip с `manifest.xml` и импортируйте.
4. Добавьте шаблоны: загрузите DOCX в S3 в папку `{OOT_S3_PREFIX}/templates/{id}/{filename}.docx` и добавьте запись в `vtiger_oot_templates` (имя, модуль, s3_key, file_name, owner), либо используйте экшен UploadTemplate (см. ниже).
## Конфигурация
Модуль читает настройки из:
1. **Внешний конфиг** (если есть): `crm_extensions/file_storage/config.php` — используются S3-данные оттуда.
2. **Переменные окружения** (.env в `crm_extensions` или в корне):
- `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_ENDPOINT`, `S3_BUCKET` — доступ к S3.
- `OOT_S3_PREFIX` — префикс папки модуля в S3 (по умолчанию `crm2/OnlyOfficeTemplates`).
- `OOT_ONLYOFFICE_CONVERT_URL` — URL Conversion API (например `https://office.example.com:9443/ConvertService.ashx` или `/converter`).
- `ONLYOFFICE_DOCUMENT_SERVER` или `OOT_ONLYOFFICE_DOCUMENT_SERVER` — URL OnlyOffice Document Server для редактора (например `https://documentserver`). Нужен для экрана редактирования шаблона (слева форма, справа OnlyOffice). Document Server должен иметь доступ по HTTP(S) к CRM (для загрузки документа и callback).
- `OOT_DOCUMENT_SECRET` — секрет для подписи URL документа (рекомендуется в продакшене). Если задан, в ссылку на документ добавляется токен; без него GetDocument доступен без проверки.
- `OOT_DOCUMENTS_S3_PREFIX` — префикс для файлов, сохраняемых в Документы (по умолчанию `crm2/CRM_Active_Files/Documents`).
Без OnlyOffice Conversion API доступна только выдача DOCX (формат PDF не будет работать). Без Document Server редактирование шаблона в OnlyOffice недоступно, но можно загружать готовые DOCX через «Загрузить файл».
## Редактирование и загрузка шаблонов
- **Через OnlyOffice (как в PDFMaker):** «Добавить шаблон» → создаётся черновик → открывается экран: слева имя и модуль, справа OnlyOffice Document Editor. Документ по сохранению/закрытию отправляется в S3 через callback. Список шаблонов: имя — ссылка на редактирование.
- **Загрузить файл:** кнопка «Загрузить файл» открывает форму: имя, модуль, выбор DOCX; отправка в `UploadTemplate`.
- **Вручную:** загрузите DOCX в S3 по пути `{OOT_S3_PREFIX}/templates/{template_id}/{имя_файла}.docx` и вставьте запись в `vtiger_oot_templates`.
## Структура БД
- `vtiger_oot_templates` — id, name, module, s3_key, file_name, owner, created_at, settings (JSON, опционально).
- `vtiger_oot_templates_seq` — при необходимости для генерации id (опционально).
## Портативность
Модуль не изменяет ядро CRM и не зависит от наличия `crm_extensions`. Все пути и ключи задаются через конфиг/переменные окружения. В другом инстансе достаточно задать свои S3_*, OOT_* и (при необходимости) ONLYOFFICE_* и выполнить установку (install.php или импорт пакета).

View File

@@ -0,0 +1,40 @@
<?php
/**
* Create a draft template record and redirect to Edit (OnlyOffice editor).
*/
class OnlyOfficeTemplates_CreateDraft_Action extends Vtiger_Action_Controller
{
public function checkPermission(Vtiger_Request $request)
{
$moduleName = $request->getModule();
$tabId = getTabId($moduleName);
$privileges = Users_Privileges_Model::getCurrentUserPrivilegesModel();
if (!$privileges->hasModulePermission($tabId)) {
throw new AppException('LBL_PERMISSION_DENIED');
}
}
public function process(Vtiger_Request $request)
{
$adb = PearDatabase::getInstance();
$currentUser = Users_Record_Model::getCurrentUserModel();
$ownerId = $currentUser->getId();
$name = $request->get('name') ?: vtranslate('LBL_OOT_NEW_TEMPLATE', $request->getModule());
$module = $request->get('module_name');
if (!$module) {
$r = $adb->pquery("SELECT name FROM vtiger_tab WHERE isentitytype = 1 AND presence = 0 ORDER BY name LIMIT 1", []);
$module = $adb->query_result($r, 0, 'name');
}
$adb->pquery(
"INSERT INTO vtiger_oot_templates (name, module, s3_key, file_name, owner, created_at) VALUES (?, ?, '', '', ?, NOW())",
[$name, $module, $ownerId]
);
$templateId = (int)$adb->getLastInsertID();
if ($templateId <= 0) {
$r = $adb->pquery("SELECT MAX(id) AS m FROM vtiger_oot_templates", []);
$templateId = (int)$adb->query_result($r, 0, 'm');
}
header('Location: index.php?module=OnlyOfficeTemplates&view=Edit&templateid=' . $templateId . '&app=TOOLS');
}
}

View File

@@ -0,0 +1,185 @@
<?php
/**
* Generate document from template: merge placeholders, optional PDF conversion, download or save to Documents.
*/
class OnlyOfficeTemplates_CreateFromTemplate_Action extends Vtiger_Action_Controller
{
public function checkPermission(Vtiger_Request $request)
{
$record = $request->get('record');
$module = $request->get('source_module') ?: getSalesEntityType($record);
if (!isPermitted($module, 'DetailView', $record)) {
throw new AppException('LBL_PERMISSION_DENIED');
}
}
public function process(Vtiger_Request $request)
{
$recordId = (int)$request->get('record');
$templateId = (int)$request->get('template_id');
$format = strtolower($request->get('format') ?: 'pdf'); // pdf | docx
$mode = strtolower($request->get('mode') ?: 'download'); // download | save_to_documents
$module = $request->get('source_module') ?: getSalesEntityType($recordId);
if (!$recordId || !$templateId) {
echo json_encode(['success' => false, 'error' => 'Missing record or template_id']);
return;
}
require_once dirname(__DIR__) . '/models/OnlyOfficeTemplates_Model.php';
require_once dirname(__DIR__) . '/resources/S3Helper.php';
require_once dirname(__DIR__) . '/resources/MergeService.php';
require_once dirname(__DIR__) . '/resources/ConvertService.php';
$model = new OnlyOfficeTemplates_Model();
$template = $model->getTemplateById($templateId);
if (!$template) {
echo json_encode(['success' => false, 'error' => 'Template not found or access denied']);
return;
}
$config = $model->getConfig();
$s3 = new OnlyOfficeTemplates_S3Helper($config);
$mergeService = new OnlyOfficeTemplates_MergeService($s3, $config);
$convertService = new OnlyOfficeTemplates_ConvertService($config);
$placeholders = $mergeService->buildPlaceholders($module, $recordId);
$tempDir = null;
$docxPath = null;
$pdfPath = null;
try {
$docxPath = $mergeService->mergeToFile($template['s3_key'], $placeholders);
$tempDir = dirname($docxPath);
$baseName = pathinfo($template['file_name'], PATHINFO_FILENAME);
$outExt = ($format === 'pdf') ? 'pdf' : 'docx';
$outFileName = $baseName . '_' . $recordId . '.' . $outExt;
if ($format === 'pdf') {
$docxUrl = $this->putMergedDocxForConversion($s3, $config, $docxPath, $recordId, $templateId);
if (!$docxUrl) {
echo json_encode(['success' => false, 'error' => 'Could not expose DOCX URL for conversion']);
return;
}
$result = $convertService->convertToPdf($docxUrl, $template['file_name']);
if (!$result['success']) {
echo json_encode(['success' => false, 'error' => $result['error']]);
return;
}
$pdfPath = $result['pdfPath'];
}
if ($mode === 'save_to_documents') {
$fileToSave = ($format === 'pdf') ? $pdfPath : $docxPath;
$docId = $this->saveToDocuments($request, $module, $recordId, $fileToSave, $outFileName, $config);
$this->cleanupTemp($tempDir, $docxPath, $pdfPath);
echo json_encode(['success' => true, 'document_id' => $docId, 'message' => 'Saved to Documents']);
return;
}
$downloadPath = ($format === 'pdf') ? $pdfPath : $docxPath;
$mime = ($format === 'pdf') ? 'application/pdf' : 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
header('Content-Type: ' . $mime);
header('Content-Disposition: attachment; filename="' . basename($outFileName) . '"');
header('Content-Length: ' . filesize($downloadPath));
readfile($downloadPath);
$this->cleanupTemp($tempDir, $docxPath, $pdfPath);
} catch (Exception $e) {
$this->cleanupTemp($tempDir, $docxPath, $pdfPath);
if ($request->get('ajax')) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
} else {
throw $e;
}
}
}
/**
* Upload merged DOCX to S3 temp and return public URL for OnlyOffice converter.
*/
private function putMergedDocxForConversion(OnlyOfficeTemplates_S3Helper $s3, array $config, $localPath, $recordId, $templateId)
{
$key = $s3->getTempKey($recordId, $templateId, 'docx');
$s3->uploadFile($localPath, $key, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
$bucket = $s3->getBucket();
$endpoint = $config['s3']['endpoint'] ?? '';
$base = preg_replace('#^https?://#', 'https://', $endpoint);
if (empty($base)) {
$base = 'https://s3.twcstorage.ru';
}
return rtrim($base, '/') . '/' . $bucket . '/' . $key;
}
/**
* Save file as Document record and link to parent. Use Documents S3 structure if FilePathManager available.
*/
private function saveToDocuments(Vtiger_Request $request, $module, $recordId, $localPath, $fileName, array $config)
{
$adb = PearDatabase::getInstance();
$currentUser = Users_Record_Model::getCurrentUserModel();
$ownerId = $currentUser->getId();
$docPrefix = $config['documents_s3_prefix'] ?? 'crm2/CRM_Active_Files/Documents';
$bucket = $config['s3_bucket'] ?? $config['s3']['bucket'];
$notesId = $adb->getUniqueID('vtiger_crmentity');
if (!$notesId) {
$r = $adb->pquery("SELECT MAX(crmid) AS m FROM vtiger_crmentity", []);
$notesId = (int)$adb->query_result($r, 0, 'm') + 1;
}
$title = pathinfo($fileName, PATHINFO_FILENAME);
$now = date('Y-m-d H:i:s');
$s3Key = $docPrefix . '/' . $module . '/' . $module . '_' . $recordId . '/' . $title . '_' . $notesId . '.' . pathinfo($fileName, PATHINFO_EXTENSION);
$s3 = new OnlyOfficeTemplates_S3Helper($config);
$contentType = (pathinfo($fileName, PATHINFO_EXTENSION) === 'pdf') ? 'application/pdf' : 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
$s3->uploadFile($localPath, $s3Key, $contentType);
$fileUrl = 'https://' . ($config['s3']['endpoint'] ?? 's3.twcstorage.ru');
$fileUrl = preg_replace('#^https?://#', '', $fileUrl);
$fileUrl = 'https://' . $fileUrl . '/' . $bucket . '/' . $s3Key;
$fileSize = filesize($localPath);
$adb->pquery(
"INSERT INTO vtiger_crmentity (crmid, smownerid, smcreatorid, modifiedby, setype, description, createdtime, modifiedtime, presence, deleted) VALUES (?,?,?,?,?,?,?,?,?,?)",
[$notesId, $ownerId, $ownerId, $ownerId, 'Documents', '', $now, $now, 1, 0]
);
$adb->pquery(
"INSERT INTO vtiger_notes (notesid, title, filename, filesize, filetype, filelocationtype, filedownloadcount, createdtime, modifiedtime, folderid, notecontent) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
[$notesId, $title, $fileUrl, $fileSize, $contentType, 'E', 0, $now, $now, 0, '']
);
if (method_exists($adb, 'pquery')) {
$adb->pquery("INSERT INTO vtiger_senotesrel (crmid, notesid) VALUES (?,?)", [$recordId, $notesId]);
}
$adb->pquery("INSERT INTO vtiger_notescf (notesid) VALUES (?)", [$notesId]);
if ($this->hasS3Columns($adb)) {
$adb->pquery("UPDATE vtiger_notes SET s3_bucket = ?, s3_key = ? WHERE notesid = ?", [$bucket, $s3Key, $notesId]);
}
return $notesId;
}
private function hasS3Columns($adb)
{
static $has = null;
if ($has === null) {
$r = @$adb->pquery("SHOW COLUMNS FROM vtiger_notes LIKE 's3_key'", []);
$has = $r && $adb->num_rows($r) > 0;
}
return $has;
}
private function cleanupTemp($tempDir, $docxPath, $pdfPath)
{
if ($pdfPath && is_file($pdfPath)) {
@unlink($pdfPath);
}
if ($docxPath && is_file($docxPath)) {
@unlink($docxPath);
}
if ($tempDir && is_dir($tempDir)) {
@rmdir($tempDir);
}
}
}

View File

@@ -0,0 +1,96 @@
<?php
/**
* Serve DOCX for OnlyOffice Document Server: from S3 or empty document for new template.
* Document Server requests this URL; it must be publicly reachable.
*/
class OnlyOfficeTemplates_GetDocument_Action extends Vtiger_Action_Controller
{
/** Document Server requests this URL without session; we verify token if OOT_DOCUMENT_SECRET is set. */
public function checkPermission(Vtiger_Request $request)
{
require_once dirname(__DIR__) . '/config.php';
$config = OnlyOfficeTemplates_getConfig();
$secret = $config['document_secret'] ?? '';
if ($secret !== '') {
$templateId = (int)$request->get('template_id');
$token = $request->get('token');
$expected = hash_hmac('sha256', (string)$templateId, $secret);
if ($token === '' || !hash_equals($expected, $token)) {
throw new AppException('LBL_PERMISSION_DENIED');
}
}
}
public function process(Vtiger_Request $request)
{
$templateId = (int)$request->get('template_id');
if ($templateId <= 0) {
$this->outputEmptyDocx();
return;
}
$adb = PearDatabase::getInstance();
$res = $adb->pquery(
"SELECT id, name, module, s3_key, file_name, owner FROM vtiger_oot_templates WHERE id = ?",
[$templateId]
);
$row = $adb->fetchByAssoc($res);
if (!$row) {
$this->outputEmptyDocx();
return;
}
if (empty($row['s3_key']) || empty($row['file_name'])) {
$this->outputEmptyDocx();
return;
}
require_once dirname(__DIR__) . '/config.php';
require_once dirname(__DIR__) . '/resources/S3Helper.php';
$config = OnlyOfficeTemplates_getConfig();
$s3 = new OnlyOfficeTemplates_S3Helper($config);
$body = $s3->getObjectBody($row['s3_key']);
$fileName = $row['file_name'];
header('Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document');
header('Content-Disposition: attachment; filename="' . basename($fileName) . '"');
header('Content-Length: ' . strlen($body));
header('Cache-Control: no-cache');
echo $body;
}
protected function outputEmptyDocx()
{
$rootDir = dirname(dirname(dirname(__DIR__)));
$emptyPath = dirname(__DIR__) . '/resources/empty.docx';
if (file_exists($emptyPath)) {
header('Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document');
header('Content-Disposition: attachment; filename="document.docx"');
header('Content-Length: ' . filesize($emptyPath));
header('Cache-Control: no-cache');
readfile($emptyPath);
return;
}
if (is_file($rootDir . '/vendor/autoload.php')) {
require_once $rootDir . '/vendor/autoload.php';
}
if (!class_exists('PhpOffice\PhpWord\PhpWord')) {
header('HTTP/1.1 500 Internal Server Error');
echo 'PHPWord not found';
return;
}
$phpWord = new \PhpOffice\PhpWord\PhpWord();
$phpWord->addSection();
$tmp = tempnam(sys_get_temp_dir(), 'oot_empty_') . '.docx';
try {
$writer = \PhpOffice\PhpWord\IOFactory::createWriter($phpWord, 'Word2007');
$writer->save($tmp);
header('Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document');
header('Content-Disposition: attachment; filename="document.docx"');
header('Content-Length: ' . filesize($tmp));
header('Cache-Control: no-cache');
readfile($tmp);
} finally {
@unlink($tmp);
}
}
}

View File

@@ -0,0 +1,41 @@
<?php
/**
* Install OnlyOfficeTemplates: register in vtiger_tab, create tables, add widget links.
* Open in browser (as admin): index.php?module=OnlyOfficeTemplates&action=Install
*/
class OnlyOfficeTemplates_Install_Action extends Vtiger_Action_Controller
{
public function checkPermission(Vtiger_Request $request)
{
if (!isPermitted('Settings', 'Edit', '')) {
throw new AppException('LBL_PERMISSION_DENIED');
}
}
public function process(Vtiger_Request $request)
{
$adb = PearDatabase::getInstance();
$r = $adb->pquery("SELECT tabid FROM vtiger_tab WHERE name = ?", ['OnlyOfficeTemplates']);
if ($adb->num_rows($r) > 0) {
$msg = 'OnlyOfficeTemplates уже зарегистрирован. Таблицы и виджеты обновлены.';
} else {
$maxId = $adb->query_result($adb->pquery("SELECT COALESCE(MAX(tabid),0) AS m FROM vtiger_tab", []), 0, 'm');
$tabid = $maxId + 1;
$maxSeq = $adb->query_result($adb->pquery("SELECT COALESCE(MAX(tabsequence),0) AS m FROM vtiger_tab", []), 0, 'm');
$adb->pquery(
"INSERT INTO vtiger_tab (tabid, name, presence, tabsequence, tablabel, modifiedby, modifiedtime, customized, ownedby, isentitytype, version, parent) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
[$tabid, 'OnlyOfficeTemplates', 0, $maxSeq + 1, 'OnlyOffice Templates', null, null, 0, 0, 0, '1.0', 'Tools']
);
$msg = 'Модуль OnlyOfficeTemplates зарегистрирован (tabid=' . $tabid . '). Таблицы и виджеты созданы.';
}
$mod = new OnlyOfficeTemplates();
$mod->executeSql();
$mod->addLinksToEntityModules();
header('Location: index.php?module=Settings&parent=Settings&view=Index&install_oot=1&install_msg=' . urlencode($msg));
exit;
}
}

View File

@@ -0,0 +1,78 @@
<?php
/**
* OnlyOffice Document Server callback: receive saved document and store in S3.
* Expects JSON body: status, key, url (when status 2,3,6,7). Returns {"error":0}.
*/
class OnlyOfficeTemplates_OnlyOfficeCallback_Action extends Vtiger_Action_Controller
{
public function checkPermission(Vtiger_Request $request)
{
// Callback is called by Document Server, not by user. We validate by key matching template.
}
public function process(Vtiger_Request $request)
{
header('Content-Type: application/json; charset=utf-8');
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
if (!$data || !isset($data['key']) || !isset($data['status'])) {
echo json_encode(['error' => 1, 'message' => 'Invalid callback body']);
return;
}
$key = $data['key'];
$status = (int)$data['status'];
$templateId = (int)$key;
if ($templateId <= 0) {
echo json_encode(['error' => 0]);
return;
}
if (!in_array($status, [2, 3, 6, 7], true)) {
echo json_encode(['error' => 0]);
return;
}
$url = isset($data['url']) ? trim($data['url']) : '';
if ($url === '') {
echo json_encode(['error' => 0]);
return;
}
$fileType = isset($data['filetype']) ? strtolower(trim($data['filetype'])) : 'docx';
if ($fileType !== 'docx') {
$fileType = 'docx';
}
require_once dirname(__DIR__) . '/config.php';
require_once dirname(__DIR__) . '/resources/S3Helper.php';
$config = OnlyOfficeTemplates_getConfig();
$adb = PearDatabase::getInstance();
$res = $adb->pquery("SELECT id, file_name FROM vtiger_oot_templates WHERE id = ?", [$templateId]);
$row = $adb->fetchByAssoc($res);
if (!$row) {
echo json_encode(['error' => 0]);
return;
}
$fileName = $row['file_name'] ?: ('template_' . $templateId . '.' . $fileType);
if (pathinfo($fileName, PATHINFO_EXTENSION) !== $fileType) {
$fileName = pathinfo($fileName, PATHINFO_FILENAME) . '.' . $fileType;
}
$s3 = new OnlyOfficeTemplates_S3Helper($config);
$s3Key = $s3->getTemplateKey($templateId, $fileName);
$tmpFile = tempnam(sys_get_temp_dir(), 'oot_callback_') . '.' . $fileType;
try {
$ctx = stream_context_create(['http' => ['timeout' => 30]]);
$content = @file_get_contents($url, false, $ctx);
if ($content === false || strlen($content) === 0) {
echo json_encode(['error' => 1, 'message' => 'Failed to download document']);
return;
}
file_put_contents($tmpFile, $content);
$s3->uploadFile($tmpFile, $s3Key, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
$adb->pquery("UPDATE vtiger_oot_templates SET s3_key = ?, file_name = ? WHERE id = ?", [$s3Key, $fileName, $templateId]);
} finally {
@unlink($tmpFile);
}
echo json_encode(['error' => 0]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
/**
* Save template name and module (metadata). Redirect back to Edit.
*/
class OnlyOfficeTemplates_SaveMetadata_Action extends Vtiger_Action_Controller
{
public function checkPermission(Vtiger_Request $request)
{
$moduleName = $request->getModule();
$tabId = getTabId($moduleName);
$privileges = Users_Privileges_Model::getCurrentUserPrivilegesModel();
if (!$privileges->hasModulePermission($tabId)) {
throw new AppException('LBL_PERMISSION_DENIED');
}
}
public function process(Vtiger_Request $request)
{
$templateId = (int)$request->get('templateid');
$name = $request->get('name');
$moduleName = $request->get('module_name');
if ($templateId <= 0 || $name === null || $moduleName === null) {
header('Location: index.php?module=OnlyOfficeTemplates&view=List&app=TOOLS');
return;
}
$adb = PearDatabase::getInstance();
$currentUser = Users_Record_Model::getCurrentUserModel();
$userId = $currentUser->getId();
$res = $adb->pquery("SELECT owner FROM vtiger_oot_templates WHERE id = ?", [$templateId]);
if ($adb->num_rows($res) === 0) {
header('Location: index.php?module=OnlyOfficeTemplates&view=List&app=TOOLS');
return;
}
$owner = (int)$adb->query_result($res, 0, 'owner');
if ($owner !== $userId) {
$gr = $adb->pquery("SELECT 1 FROM vtiger_users2group WHERE userid = ? AND groupid = ?", [$userId, $owner]);
if ($adb->num_rows($gr) === 0) {
throw new AppException('LBL_PERMISSION_DENIED');
}
}
$adb->pquery("UPDATE vtiger_oot_templates SET name = ?, module = ? WHERE id = ?", [$name, $moduleName, $templateId]);
$redirect = $request->get('redirect');
if ($redirect === 'Edit') {
header('Location: index.php?module=OnlyOfficeTemplates&view=Edit&templateid=' . $templateId . '&app=TOOLS');
} else {
header('Location: index.php?module=OnlyOfficeTemplates&view=List&app=TOOLS');
}
}
}

View File

@@ -0,0 +1,81 @@
<?php
/**
* Upload DOCX template to S3 and save metadata in vtiger_oot_templates.
*/
class OnlyOfficeTemplates_UploadTemplate_Action extends Vtiger_Action_Controller
{
public function checkPermission(Vtiger_Request $request)
{
if (!isPermitted('Settings', 'Edit', '')) {
throw new AppException('LBL_PERMISSION_DENIED');
}
}
public function process(Vtiger_Request $request)
{
$name = $request->get('name') ?: $request->get('template_name');
$module = $request->get('module_name');
$file = $_FILES['file'] ?? $_FILES['template_file'] ?? null;
$redirect = $request->get('redirect');
$doRedirect = ($redirect === 'List');
if (!$name || !$module || !$file || empty($file['tmp_name']) || $file['error'] !== UPLOAD_ERR_OK) {
if ($doRedirect) {
header('Location: index.php?module=OnlyOfficeTemplates&view=AddTemplate&app=TOOLS&error=' . urlencode('Укажите название, модуль и выберите файл DOCX'));
return;
}
echo json_encode(['success' => false, 'error' => 'Missing name, module_name, or valid file upload']);
return;
}
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($ext !== 'docx') {
if ($doRedirect) {
header('Location: index.php?module=OnlyOfficeTemplates&view=AddTemplate&app=TOOLS&error=' . urlencode('Допускаются только файлы DOCX'));
return;
}
echo json_encode(['success' => false, 'error' => 'Only DOCX files are allowed']);
return;
}
require_once dirname(__DIR__) . '/models/OnlyOfficeTemplates_Model.php';
require_once dirname(__DIR__) . '/resources/S3Helper.php';
$model = new OnlyOfficeTemplates_Model();
$config = $model->getConfig();
$s3 = new OnlyOfficeTemplates_S3Helper($config);
$owner = Users_Record_Model::getCurrentUserModel()->getId();
$adb = PearDatabase::getInstance();
$adb->pquery("INSERT INTO vtiger_oot_templates (name, module, s3_key, file_name, owner, created_at) VALUES ('_pending', ?, '', '', ?, NOW())", [$module, $owner]);
$templateId = (int)$adb->getLastInsertID();
if ($templateId <= 0) {
$r = $adb->pquery("SELECT MAX(id) AS m FROM vtiger_oot_templates", []);
$templateId = (int)$adb->query_result($r, 0, 'm');
}
$fileName = basename($file['name']);
$s3Key = $s3->getTemplateKey($templateId, $fileName);
try {
$s3->uploadFile($file['tmp_name'], $s3Key, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
} catch (Exception $e) {
$adb->pquery("DELETE FROM vtiger_oot_templates WHERE id = ?", [$templateId]);
if ($doRedirect) {
header('Location: index.php?module=OnlyOfficeTemplates&view=AddTemplate&app=TOOLS&error=' . urlencode('Ошибка загрузки: ' . $e->getMessage()));
return;
}
echo json_encode(['success' => false, 'error' => 'S3 upload failed: ' . $e->getMessage()]);
return;
}
$adb->pquery("UPDATE vtiger_oot_templates SET name = ?, s3_key = ?, file_name = ? WHERE id = ?", [$name, $s3Key, $fileName, $templateId]);
$id = $templateId;
if ($doRedirect) {
header('Location: index.php?module=OnlyOfficeTemplates&view=List&app=TOOLS');
return;
}
echo json_encode(['success' => true, 'id' => $id, 's3_key' => $s3Key]);
}
}

View File

@@ -0,0 +1,91 @@
<?php
/**
* OnlyOfficeTemplates module configuration.
* Reads from environment (.env) or optional external config (crm_extensions/file_storage/config.php).
* Portable: no hardcoded paths; set S3_*, ONLYOFFICE_* in .env or your config.
*/
$OnlyOfficeTemplatesConfig = null;
/**
* Read env var: getenv() then $_ENV then EnvLoader::get() (EnvLoader does not use putenv).
*/
function OnlyOfficeTemplates_env($key, $default = '') {
$v = getenv($key);
if ($v !== false && $v !== '') {
return $v;
}
$v = $_ENV[$key] ?? null;
if ($v !== null && $v !== '') {
return $v;
}
if (class_exists('EnvLoader')) {
$v = EnvLoader::get($key, $default);
return $v !== null ? $v : $default;
}
return $default;
}
function OnlyOfficeTemplates_getConfig() {
global $OnlyOfficeTemplatesConfig;
if ($OnlyOfficeTemplatesConfig !== null) {
return $OnlyOfficeTemplatesConfig;
}
$baseDir = dirname(__DIR__);
$rootDir = dirname(dirname($baseDir));
// Load .env (EnvLoader uses $_ENV, not getenv)
if (file_exists($rootDir . '/crm_extensions/shared/EnvLoader.php')) {
require_once $rootDir . '/crm_extensions/shared/EnvLoader.php';
$envPath = $rootDir . '/crm_extensions/.env';
if (file_exists($envPath)) {
EnvLoader::load($envPath);
}
}
// 1) Try external config (crm_extensions) if present
$extConfigPath = $rootDir . '/crm_extensions/file_storage/config.php';
if (file_exists($extConfigPath)) {
try {
$ext = require $extConfigPath;
if (is_array($ext) && isset($ext['s3'])) {
$s3 = $ext['s3'];
$bucket = $s3['bucket'] ?? $ext['bucket'] ?? $ext['s3_bucket'] ?? OnlyOfficeTemplates_env('S3_BUCKET', '');
$OnlyOfficeTemplatesConfig = [
's3' => $s3,
's3_prefix' => OnlyOfficeTemplates_env('OOT_S3_PREFIX', 'crm2/OnlyOfficeTemplates'),
'onlyoffice_convert_url' => OnlyOfficeTemplates_env('OOT_ONLYOFFICE_CONVERT_URL') ?: OnlyOfficeTemplates_env('ONLYOFFICE_CONVERT_URL', ''),
'onlyoffice_document_server' => OnlyOfficeTemplates_env('OOT_ONLYOFFICE_DOCUMENT_SERVER') ?: OnlyOfficeTemplates_env('ONLYOFFICE_DOCUMENT_SERVER', ''),
'documents_s3_prefix' => OnlyOfficeTemplates_env('OOT_DOCUMENTS_S3_PREFIX', 'crm2/CRM_Active_Files/Documents'),
's3_bucket' => $bucket,
'document_secret' => OnlyOfficeTemplates_env('OOT_DOCUMENT_SECRET', ''),
];
return $OnlyOfficeTemplatesConfig;
}
} catch (Exception $e) {
// fallback
}
}
// 2) Build from environment
$OnlyOfficeTemplatesConfig = [
's3' => [
'key' => OnlyOfficeTemplates_env('S3_ACCESS_KEY', ''),
'secret' => OnlyOfficeTemplates_env('S3_SECRET_KEY', ''),
'endpoint' => OnlyOfficeTemplates_env('S3_ENDPOINT', ''),
'bucket' => OnlyOfficeTemplates_env('S3_BUCKET', ''),
'region' => OnlyOfficeTemplates_env('S3_REGION', 'ru-1'),
'use_path_style_endpoint' => true,
'version' => 'latest',
],
's3_prefix' => OnlyOfficeTemplates_env('OOT_S3_PREFIX', 'crm2/OnlyOfficeTemplates'),
'onlyoffice_convert_url' => OnlyOfficeTemplates_env('OOT_ONLYOFFICE_CONVERT_URL') ?: OnlyOfficeTemplates_env('ONLYOFFICE_CONVERT_URL', ''),
'onlyoffice_document_server' => OnlyOfficeTemplates_env('OOT_ONLYOFFICE_DOCUMENT_SERVER') ?: OnlyOfficeTemplates_env('ONLYOFFICE_DOCUMENT_SERVER', ''),
'documents_s3_prefix' => OnlyOfficeTemplates_env('OOT_DOCUMENTS_S3_PREFIX', 'crm2/CRM_Active_Files/Documents'),
's3_bucket' => OnlyOfficeTemplates_env('S3_BUCKET', ''),
'document_secret' => OnlyOfficeTemplates_env('OOT_DOCUMENT_SECRET', ''),
];
return $OnlyOfficeTemplatesConfig;
}

View File

@@ -0,0 +1,58 @@
<?php
/**
* One-time install: register OnlyOfficeTemplates in vtiger_tab and run schema + links.
* Run from browser: index.php?module=OnlyOfficeTemplates&action=Install
* Or from CLI: php modules/OnlyOfficeTemplates/install.php
* Requires admin user when run from browser.
*/
$rootDir = dirname(dirname(__DIR__));
if (php_sapi_name() === 'cli') {
require_once $rootDir . '/config.inc.php';
require_once $rootDir . '/vtlib/Vtiger/Module.php';
require_once $rootDir . '/includes/utils/utils.php';
$current_user = Users::getActiveAdminUser();
if (!$current_user) {
die("Admin user not found.\n");
}
} else {
require_once $rootDir . '/config.inc.php';
require_once $rootDir . '/vtlib/Vtiger/Module.php';
require_once $rootDir . '/includes/utils/utils.php';
if (!isPermitted('Settings', 'Edit', '')) {
die('Access denied.');
}
}
require_once __DIR__ . '/OnlyOfficeTemplates.php';
$adb = PearDatabase::getInstance();
// Check if already registered
$r = $adb->pquery("SELECT tabid FROM vtiger_tab WHERE name = ?", ['OnlyOfficeTemplates']);
if ($adb->num_rows($r) > 0) {
if (php_sapi_name() === 'cli') {
echo "OnlyOfficeTemplates already registered. Running schema and links.\n";
}
} else {
$maxId = $adb->query_result($adb->pquery("SELECT COALESCE(MAX(tabid),0) AS m FROM vtiger_tab", []), 0, 'm');
$tabid = $maxId + 1;
$maxSeq = $adb->query_result($adb->pquery("SELECT COALESCE(MAX(tabsequence),0) AS m FROM vtiger_tab", []), 0, 'm');
$adb->pquery(
"INSERT INTO vtiger_tab (tabid, name, presence, tabsequence, tablabel, modifiedby, modifiedtime, customized, ownedby, isentitytype, version, parent) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
[$tabid, 'OnlyOfficeTemplates', 0, $maxSeq + 1, 'OnlyOffice Templates', null, null, 0, 0, 0, '1.0', 'Tools']
);
if (php_sapi_name() === 'cli') {
echo "Registered OnlyOfficeTemplates (tabid=$tabid).\n";
}
}
$mod = new OnlyOfficeTemplates();
$mod->executeSql();
$mod->addLinksToEntityModules();
if (php_sapi_name() === 'cli') {
echo "Done. Schema and widget links applied.\n";
} else {
header('Location: index.php?module=Settings&parent=Settings&view=Index');
exit;
}

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<module>
<type>extension</type>
<name>OnlyOfficeTemplates</name>
<label>OnlyOffice Templates</label>
<parent>Tools</parent>
<version>1.0</version>
<dependencies>
<vtiger_version>7.*</vtiger_version>
</dependencies>
<tables>
<table>
<name>vtiger_oot_templates</name>
<sql><![CDATA[
CREATE TABLE IF NOT EXISTS vtiger_oot_templates (
id INT(11) NOT NULL AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
module VARCHAR(100) NOT NULL,
s3_key VARCHAR(512) NOT NULL,
file_name VARCHAR(255) NOT NULL,
owner INT(11) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
settings TEXT NULL,
PRIMARY KEY (id),
KEY idx_oot_module (module),
KEY idx_oot_owner (owner)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
]]></sql>
</table>
<table>
<name>vtiger_oot_templates_seq</name>
<sql><![CDATA[
CREATE TABLE IF NOT EXISTS vtiger_oot_templates_seq (
id INT(11) NOT NULL DEFAULT 1
) ENGINE=InnoDB
]]></sql>
</table>
</tables>
</module>

View File

@@ -0,0 +1,124 @@
<?php
/**
* OnlyOfficeTemplates model: list templates by module, get from S3, merge, convert.
*/
class OnlyOfficeTemplates_Model
{
protected $db;
protected $config;
public function __construct()
{
$this->db = PearDatabase::getInstance();
require_once dirname(__DIR__) . '/config.php';
$this->config = OnlyOfficeTemplates_getConfig();
}
/**
* List templates available for a module (and current user).
*
* @param string $module
* @return array [ ['id' =>, 'name' =>, 'file_name' =>, 'module' =>], ... ]
*/
public function getTemplatesByModule($module)
{
$userId = Users_Record_Model::getCurrentUserModel()->getId();
$sql = "SELECT id, name, module, file_name, s3_key, owner, created_at
FROM vtiger_oot_templates
WHERE module = ? AND (owner = ? OR owner IN (SELECT groupid FROM vtiger_users2group WHERE userid = ?))
ORDER BY name";
$res = $this->db->pquery($sql, [$module, $userId, $userId]);
$list = [];
while ($row = $this->db->fetchByAssoc($res)) {
$list[] = [
'id' => (int)$row['id'],
'name' => $row['name'],
'module' => $row['module'],
'file_name' => $row['file_name'],
's3_key' => $row['s3_key'],
'owner' => (int)$row['owner'],
'created_at' => $row['created_at'],
];
}
return $list;
}
/**
* Get one template by id (with permission check).
*
* @param int $templateId
* @return array|null
*/
public function getTemplateById($templateId)
{
$userId = Users_Record_Model::getCurrentUserModel()->getId();
$res = $this->db->pquery(
"SELECT id, name, module, s3_key, file_name, owner FROM vtiger_oot_templates WHERE id = ? AND (owner = ? OR owner IN (SELECT groupid FROM vtiger_users2group WHERE userid = ?))",
[$templateId, $userId, $userId]
);
if ($this->db->num_rows($res) === 0) {
return null;
}
return $this->db->fetchByAssoc($res);
}
/**
* Save template metadata and S3 key (after upload).
*
* @param string $name
* @param string $module
* @param string $s3Key
* @param string $fileName
* @param int $owner
* @return int new template id
*/
public function saveTemplate($name, $module, $s3Key, $fileName, $owner = null)
{
if ($owner === null) {
$owner = Users_Record_Model::getCurrentUserModel()->getId();
}
$this->db->pquery("INSERT INTO vtiger_oot_templates (name, module, s3_key, file_name, owner, created_at) VALUES (?,?,?,?,?,NOW())",
[$name, $module, $s3Key, $fileName, $owner]);
$id = $this->db->getLastInsertID();
return $id ? (int)$id : (int)$this->db->query_result($this->db->pquery("SELECT MAX(id) AS n FROM vtiger_oot_templates", []), 0, 'n');
}
/**
* Delete template record and optionally S3 object (caller can delete object).
*
* @param int $templateId
* @return bool
*/
public function deleteTemplate($templateId)
{
$t = $this->getTemplateById($templateId);
if (!$t) {
return false;
}
$this->db->pquery("DELETE FROM vtiger_oot_templates WHERE id = ?", [$templateId]);
return true;
}
/**
* Get config (S3 prefix, bucket, OnlyOffice URL).
*
* @return array
*/
public function getConfig()
{
return $this->config;
}
/**
* Get next id for template (for S3 path).
*
* @return int
*/
public function getNextTemplateId()
{
$this->db->pquery("UPDATE vtiger_oot_templates_seq SET id = LAST_INSERT_ID(id + 1)", []);
$r = $this->db->pquery("SELECT LAST_INSERT_ID() AS n", []);
return (int)$this->db->query_result($r, 0, 'n');
}
}

View File

@@ -0,0 +1,78 @@
<?php
/**
* OnlyOffice Conversion API: DOCX -> PDF.
* Requires onlyoffice_convert_url (e.g. https://office.clientright.ru:9443/ConvertService.ashx or /converter).
*/
class OnlyOfficeTemplates_ConvertService
{
protected $convertUrl;
protected $documentServerBase;
public function __construct(array $config)
{
$this->convertUrl = rtrim($config['onlyoffice_convert_url'] ?? '', '/');
if (strpos($this->convertUrl, '/converter') !== false) {
$this->documentServerBase = preg_replace('#/converter.*$#', '', $this->convertUrl);
} else {
$this->documentServerBase = preg_replace('#/ConvertService\.ashx.*$#', '', $this->convertUrl);
}
}
/**
* Convert DOCX at given URL to PDF.
*
* @param string $docxUrl Absolute URL to DOCX (must be accessible by Document Server)
* @param string $title File name for display
* @return array [ 'success' => bool, 'pdfPath' => temp file path, 'error' => message ]
*/
public function convertToPdf($docxUrl, $title = 'document.docx')
{
if (empty($this->convertUrl)) {
return ['success' => false, 'error' => 'OnlyOffice conversion URL not configured (OOT_ONLYOFFICE_CONVERT_URL).'];
}
$key = md5($docxUrl . time());
$body = [
'async' => false,
'filetype' => 'docx',
'key' => $key,
'outputtype' => 'pdf',
'title' => $title,
'url' => $docxUrl,
];
$ctx = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nAccept: application/json\r\n",
'content' => json_encode($body),
'timeout' => 120,
],
'ssl' => ['verify_peer' => false, 'verify_peer_name' => false],
]);
$response = @file_get_contents($this->convertUrl, false, $ctx);
if ($response === false) {
return ['success' => false, 'error' => 'Conversion request failed (connection or timeout).'];
}
$data = json_decode($response, true);
if (!$data) {
$data = ['error' => -1, 'endConvert' => false];
}
if (!empty($data['error'])) {
return ['success' => false, 'error' => 'Conversion error code: ' . $data['error']];
}
if (empty($data['endConvert']) || empty($data['fileUrl'])) {
return ['success' => false, 'error' => 'Conversion did not return file URL.'];
}
$fileUrl = $data['fileUrl'];
if (strpos($fileUrl, 'http') !== 0) {
$fileUrl = rtrim($this->documentServerBase, '/') . '/' . ltrim($fileUrl, '/');
}
$tempPdf = sys_get_temp_dir() . '/oot_pdf_' . uniqid() . '.pdf';
$pdfContent = @file_get_contents($fileUrl, false, stream_context_create(['http' => ['timeout' => 60], 'ssl' => ['verify_peer' => false]]));
if ($pdfContent === false || strlen($pdfContent) === 0) {
return ['success' => false, 'error' => 'Failed to download converted PDF from Document Server.'];
}
file_put_contents($tempPdf, $pdfContent);
return ['success' => true, 'pdfPath' => $tempPdf];
}
}

View File

@@ -0,0 +1,172 @@
<?php
/**
* Merge DOCX template: load from S3, substitute {{field}} and {{ModuleName__field}}, output DOCX.
*/
class OnlyOfficeTemplates_MergeService
{
protected $s3;
protected $config;
public function __construct(OnlyOfficeTemplates_S3Helper $s3, array $config)
{
$this->s3 = $s3;
$this->config = $config;
}
/**
* Build placeholder map for a record: current module fields + related (Account, Contact, etc.).
*
* @param string $module
* @param int $recordId
* @return array [ 'fieldname' => value, 'Account__accountname' => value, ... ]
*/
public function buildPlaceholders($module, $recordId)
{
$adb = PearDatabase::getInstance();
$focus = CRMEntity::getInstance($module);
$focus->id = $recordId;
$focus->retrieve_entity_info($recordId, $module);
$fields = $focus->column_fields;
$map = [];
foreach ($fields as $k => $v) {
if ($v === null || $v === '') {
$v = '';
}
$map[$k] = is_string($v) ? $v : (string)$v;
}
$map = array_merge($map, $this->getRelatedModuleFields($module, $recordId, $focus));
return $map;
}
/**
* Get related entity fields (Account, Contact, etc.) for placeholder {{ModuleName__fieldname}}.
*/
protected function getRelatedModuleFields($module, $recordId, CRMEntity $focus)
{
$map = [];
$relFields = $this->getRelationFieldNames($module);
foreach ($relFields as $relModule => $fieldName) {
$relId = isset($focus->column_fields[$fieldName]) ? $focus->column_fields[$fieldName] : null;
if (empty($relId)) {
continue;
}
$relFocus = CRMEntity::getInstance($relModule);
$relFocus->retrieve_entity_info($relId, $relModule);
foreach ($relFocus->column_fields as $k => $v) {
if ($v === null || $v === '') {
$v = '';
}
$map[$relModule . '__' . $k] = is_string($v) ? $v : (string)$v;
}
}
return $map;
}
/**
* Common relation field names per module (account_id, contact_id, related_to, parent_id, etc.).
*/
/** @return array [ 'RelatedModule' => 'local_field_name', ... ] */
protected function getRelationFieldNames($module)
{
$known = [
'Project' => ['Accounts' => 'account_id', 'Contacts' => 'contact_id'],
'Contacts' => ['Accounts' => 'account_id'],
'Leads' => ['Accounts' => 'account_id'],
'Potentials' => ['Accounts' => 'related_to', 'Contacts' => 'contact_id'],
'Invoice' => ['Accounts' => 'account_id', 'Contacts' => 'contact_id'],
'Quotes' => ['Accounts' => 'account_id', 'Contacts' => 'contact_id'],
'SalesOrder' => ['Accounts' => 'account_id', 'Contacts' => 'contact_id'],
'PurchaseOrder' => ['Vendors' => 'vendor_id', 'Contacts' => 'contact_id'],
'HelpDesk' => ['Accounts' => 'parent_id', 'Contacts' => 'contact_id'],
'Accounts' => [],
];
if (isset($known[$module])) {
return $known[$module];
}
$out = [];
if (in_array($module, ['Contacts', 'Leads', 'Potentials', 'Invoice', 'Quotes', 'SalesOrder'])) {
$out['Accounts'] = 'account_id';
if ($module !== 'Accounts') {
$out['Contacts'] = 'contact_id';
}
}
return $out;
}
/**
* Merge template: download from S3, replace placeholders, save to temp file.
*
* @param string $s3Key template S3 key
* @param array $placeholders [ 'field' => 'value', 'Account__name' => 'value' ]
* @return string path to merged DOCX file
*/
public function mergeToFile($s3Key, array $placeholders)
{
$path = dirname(dirname(dirname(__DIR__)));
if (!class_exists('PhpOffice\PhpWord\IOFactory')) {
if (is_file($path . '/vendor/autoload.php')) {
require_once $path . '/vendor/autoload.php';
}
}
$tempDir = sys_get_temp_dir() . '/oot_' . uniqid();
if (!is_dir($tempDir)) {
mkdir($tempDir, 0755, true);
}
$templatePath = $tempDir . '/template.docx';
$this->s3->downloadToFile($s3Key, $templatePath);
$phpWord = \PhpOffice\PhpWord\IOFactory::load($templatePath);
$this->replaceInPhpWord($phpWord, $placeholders);
$outPath = $tempDir . '/merged.docx';
$writer = \PhpOffice\PhpWord\IOFactory::createWriter($phpWord, 'Word2007');
$writer->save($outPath);
@unlink($templatePath);
return $outPath;
}
/**
* Replace {{placeholder}} in all text elements.
*/
protected function replaceInPhpWord(\PhpOffice\PhpWord\PhpWord $phpWord, array $placeholders)
{
foreach ($phpWord->getSections() as $section) {
foreach ($section->getElements() as $element) {
$this->replaceInElement($element, $placeholders);
}
}
}
protected function replaceInElement($element, array $placeholders)
{
if ($element instanceof \PhpOffice\PhpWord\Element\Text) {
$text = $element->getText();
$text = $this->replacePlaceholders($text, $placeholders);
$element->setText($text);
return;
}
if ($element instanceof \PhpOffice\PhpWord\Element\TextRun) {
foreach ($element->getElements() as $el) {
$this->replaceInElement($el, $placeholders);
}
return;
}
if ($element instanceof \PhpOffice\PhpWord\Element\TextBreak) {
return;
}
if (method_exists($element, 'getElements')) {
foreach ($element->getElements() as $el) {
$this->replaceInElement($el, $placeholders);
}
}
}
protected function replacePlaceholders($text, array $placeholders)
{
foreach ($placeholders as $key => $value) {
$text = str_replace('{{' . $key . '}}', $value, $text);
}
return $text;
}
}

View File

@@ -0,0 +1,120 @@
<?php
/**
* Portable S3 helper for OnlyOfficeTemplates (get/put using module config).
*/
class OnlyOfficeTemplates_S3Helper
{
protected $client;
protected $bucket;
protected $prefix;
public function __construct(array $config)
{
$this->bucket = $config['s3_bucket'] ?? ($config['s3']['bucket'] ?? '');
$this->prefix = rtrim($config['s3_prefix'] ?? 'crm2/OnlyOfficeTemplates', '/');
if ($this->bucket === null || $this->bucket === '') {
throw new Exception('OnlyOfficeTemplates: S3 bucket not configured. Set S3_BUCKET in .env or add bucket to crm_extensions/file_storage/config.php (s3.bucket or root bucket).');
}
$s3 = $config['s3'] ?? [];
$path = dirname(dirname(dirname(__DIR__)));
if (!class_exists('Aws\S3\S3Client')) {
if (is_file($path . '/vendor/autoload.php')) {
require_once $path . '/vendor/autoload.php';
}
}
$this->client = new Aws\S3\S3Client([
'version' => $s3['version'] ?? 'latest',
'region' => $s3['region'] ?? 'ru-1',
'endpoint' => $s3['endpoint'],
'use_path_style_endpoint' => !empty($s3['use_path_style_endpoint']),
'credentials' => [
'key' => $s3['key'],
'secret' => $s3['secret'],
],
]);
}
/**
* Template S3 key: {prefix}/templates/{templateId}/{fileName}
*/
public function getTemplateKey($templateId, $fileName)
{
return $this->prefix . '/templates/' . (int)$templateId . '/' . $fileName;
}
/**
* Temp generated file key: {prefix}/temp/{recordId}_{templateId}_{timestamp}.ext
*/
public function getTempKey($recordId, $templateId, $extension)
{
return $this->prefix . '/temp/' . (int)$recordId . '_' . (int)$templateId . '_' . time() . '.' . $extension;
}
public function getBucket()
{
return $this->bucket;
}
public function getPrefix()
{
return $this->prefix;
}
/**
* Download object to a local file; returns path.
*/
public function downloadToFile($s3Key, $localPath)
{
$this->client->getObject([
'Bucket' => $this->bucket,
'Key' => $s3Key,
'SaveAs' => $localPath,
]);
return $localPath;
}
/**
* Get object body as string.
*/
public function getObjectBody($s3Key)
{
$result = $this->client->getObject([
'Bucket' => $this->bucket,
'Key' => $s3Key,
]);
return (string)$result['Body'];
}
/**
* Put string or file into S3.
*/
public function putObject($s3Key, $body, $contentType = null)
{
$params = [
'Bucket' => $this->bucket,
'Key' => $s3Key,
'Body' => $body,
];
if ($contentType) {
$params['ContentType'] = $contentType;
}
$this->client->putObject($params);
}
/**
* Upload local file to S3.
*/
public function uploadFile($localPath, $s3Key, $contentType = null)
{
$params = [
'Bucket' => $this->bucket,
'Key' => $s3Key,
'SourceFile' => $localPath,
];
if ($contentType) {
$params['ContentType'] = $contentType;
}
$this->client->putObject($params);
}
}

View File

@@ -0,0 +1,31 @@
<?xml version="1.0"?>
<schema>
<tables>
<table>
<name>vtiger_oot_templates</name>
<sql><![CDATA[
CREATE TABLE IF NOT EXISTS vtiger_oot_templates (
id INT(11) NOT NULL AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
module VARCHAR(100) NOT NULL,
s3_key VARCHAR(512) NOT NULL,
file_name VARCHAR(255) NOT NULL,
owner INT(11) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
settings TEXT NULL,
PRIMARY KEY (id),
KEY idx_oot_module (module),
KEY idx_oot_owner (owner)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
]]></sql>
</table>
<table>
<name>vtiger_oot_templates_seq</name>
<sql><![CDATA[
CREATE TABLE IF NOT EXISTS vtiger_oot_templates_seq (
id INT(11) NOT NULL DEFAULT 1
) ENGINE=InnoDB
]]></sql>
</table>
</tables>
</schema>

View File

@@ -0,0 +1,39 @@
<?php
/**
* Form to add a new DOCX template (name, module, file upload).
*/
class OnlyOfficeTemplates_AddTemplate_View extends Vtiger_Index_View
{
public function checkPermission(Vtiger_Request $request)
{
$moduleName = $request->getModule();
$tabId = getTabId($moduleName);
$privileges = Users_Privileges_Model::getCurrentUserPrivilegesModel();
if (!$privileges->hasModulePermission($tabId)) {
throw new AppException('LBL_PERMISSION_DENIED');
}
}
public function process(Vtiger_Request $request)
{
$moduleName = $request->getModule();
$viewer = $this->getViewer($request);
$db = PearDatabase::getInstance();
$res = $db->pquery(
"SELECT name FROM vtiger_tab WHERE isentitytype = 1 AND presence = 0 ORDER BY name",
[]
);
$modules = [];
while ($row = $db->fetchByAssoc($res)) {
$modules[$row['name']] = vtranslate($row['name'], $row['name']);
}
$errorMsg = $request->get('error');
$viewer->assign('MODULE_NAME', $moduleName);
$viewer->assign('MODULES', $modules);
$viewer->assign('ERROR_MSG', $errorMsg ?: '');
$viewer->view('AddTemplate.tpl', $moduleName);
}
}

View File

@@ -0,0 +1,109 @@
<?php
/**
* Edit/Create template: left = metadata (name, module), right = OnlyOffice Document Editor in iframe.
* Document is loaded from GetDocument and saved to S3 via OnlyOffice callback.
*/
class OnlyOfficeTemplates_Edit_View extends Vtiger_Index_View
{
public function checkPermission(Vtiger_Request $request)
{
$moduleName = $request->getModule();
$tabId = getTabId($moduleName);
$privileges = Users_Privileges_Model::getCurrentUserPrivilegesModel();
if (!$privileges->hasModulePermission($tabId)) {
throw new AppException('LBL_PERMISSION_DENIED');
}
}
public function process(Vtiger_Request $request)
{
$moduleName = $request->getModule();
$templateId = (int)$request->get('templateid');
$viewer = $this->getViewer($request);
$adb = PearDatabase::getInstance();
$currentUser = Users_Record_Model::getCurrentUserModel();
$userId = $currentUser->getId();
$template = null;
if ($templateId > 0) {
$res = $adb->pquery(
"SELECT id, name, module, file_name, owner FROM vtiger_oot_templates WHERE id = ?",
[$templateId]
);
$template = $adb->fetchByAssoc($res);
if ($template) {
$owner = (int)$template['owner'];
if ($owner !== $userId) {
$gr = $adb->pquery("SELECT 1 FROM vtiger_users2group WHERE userid = ? AND groupid = ?", [$userId, $owner]);
if ($adb->num_rows($gr) === 0) {
$template = null;
}
}
}
}
if (!$template) {
$template = [
'id' => 0,
'name' => '',
'module' => '',
'file_name' => 'document.docx',
];
}
$res = $adb->pquery(
"SELECT name FROM vtiger_tab WHERE isentitytype = 1 AND presence = 0 ORDER BY name",
[]
);
$modules = [];
while ($row = $adb->fetchByAssoc($res)) {
$modules[$row['name']] = vtranslate($row['name'], $row['name']);
}
require_once dirname(__DIR__) . '/config.php';
$config = OnlyOfficeTemplates_getConfig();
$docServer = rtrim($config['onlyoffice_document_server'] ?? '', '/');
if ($docServer === '') {
$viewer->assign('OOT_EDITOR_AVAILABLE', false);
$viewer->assign('OOT_EDITOR_MESSAGE', 'OnlyOffice Document Server не настроен (ONLYOFFICE_DOCUMENT_SERVER).');
} else {
$viewer->assign('OOT_EDITOR_AVAILABLE', true);
$baseUrl = $this->getBaseUrl();
$tid = (int)$template['id'];
$documentUrl = $baseUrl . '/index.php?module=OnlyOfficeTemplates&action=GetDocument&template_id=' . $tid;
$secret = $config['document_secret'] ?? '';
if ($secret !== '' && $tid > 0) {
$documentUrl .= '&token=' . rawurlencode(hash_hmac('sha256', (string)$tid, $secret));
}
$callbackUrl = $baseUrl . '/index.php?module=OnlyOfficeTemplates&action=OnlyOfficeCallback';
$docKey = $tid > 0 ? (string)$tid : ('new_' . $userId . '_' . time());
$viewer->assign('OOT_DOCUMENT_SERVER', $docServer);
$viewer->assign('OOT_DOCUMENT_URL', $documentUrl);
$viewer->assign('OOT_CALLBACK_URL', $callbackUrl);
$viewer->assign('OOT_DOC_KEY', $docKey);
$viewer->assign('OOT_DOC_TITLE', $template['file_name'] ?: 'document.docx');
}
$viewer->assign('MODULE_NAME', $moduleName);
$viewer->assign('TEMPLATE', $template);
$viewer->assign('MODULES', $modules);
$viewer->assign('ERROR_MSG', $request->get('error') ?: '');
$viewer->view('Edit.tpl', $moduleName);
}
protected function getBaseUrl()
{
if (function_exists('vglobal') && (vglobal('site_URL') ?? '') !== '') {
return rtrim(vglobal('site_URL'), '/');
}
$proto = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$path = dirname($_SERVER['SCRIPT_NAME'] ?? '');
$path = str_replace('\\', '/', $path);
if ($path === '/' || $path === '') {
return $proto . '://' . $host;
}
return $proto . '://' . $host . $path;
}
}

View File

@@ -0,0 +1,48 @@
<?php
/**
* Widget view: list OnlyOffice templates for the record's module and actions (Download / Save to Documents) with format (PDF/DOCX).
*/
class OnlyOfficeTemplates_GetTemplateActions_View extends Vtiger_BasicAjax_View
{
public function checkPermission(Vtiger_Request $request)
{
}
public function process(Vtiger_Request $request)
{
$module = false;
if ($request->has('source_module') && !$request->isEmpty('source_module')) {
$source_module = $request->get('source_module');
} elseif ($request->has('record') && !$request->isEmpty('record')) {
$source_module = $module = getSalesEntityType($request->get('record'));
}
$sourceModuleModel = Vtiger_Module_Model::getInstance($source_module);
if (!$sourceModuleModel || !$sourceModuleModel->isEntityModule()) {
return;
}
if (!$request->has('record') || $request->isEmpty('record')) {
return;
}
$record = $request->get('record');
if (!$module) {
$module = getSalesEntityType($record);
}
if ($module !== $source_module) {
return;
}
require_once dirname(__DIR__) . '/models/OnlyOfficeTemplates_Model.php';
$model = new OnlyOfficeTemplates_Model();
$templates = $model->getTemplatesByModule($module);
$viewer = $this->getViewer($request);
$viewer->assign('MODULE', $module);
$viewer->assign('ID', $record);
$viewer->assign('CRM_TEMPLATES', $templates);
$viewer->assign('CRM_TEMPLATES_EXIST', empty($templates) ? 1 : 0);
$viewer->assign('OOT_MOD', return_module_language(Vtiger_Language_Handler::getLanguage(), 'OnlyOfficeTemplates'));
$viewer->view('GetTemplateActions.tpl', 'OnlyOfficeTemplates');
}
}

View File

@@ -0,0 +1,41 @@
<?php
/**
* Simple list view for OnlyOfficeTemplates (non-entity module).
*/
class OnlyOfficeTemplates_List_View extends Vtiger_Index_View
{
public function checkPermission(Vtiger_Request $request)
{
$moduleName = $request->getModule();
$tabId = getTabId($moduleName);
$privileges = Users_Privileges_Model::getCurrentUserPrivilegesModel();
if (!$privileges->hasModulePermission($tabId)) {
throw new AppException('LBL_PERMISSION_DENIED');
}
}
public function process(Vtiger_Request $request)
{
$moduleName = $request->getModule();
$viewer = $this->getViewer($request);
$db = PearDatabase::getInstance();
$userId = Users_Record_Model::getCurrentUserModel()->getId();
$res = $db->pquery(
"SELECT id, name, module, file_name, owner, created_at
FROM vtiger_oot_templates
WHERE owner = ? OR owner IN (SELECT groupid FROM vtiger_users2group WHERE userid = ?)
ORDER BY created_at DESC",
[$userId, $userId]
);
$templates = [];
while ($row = $db->fetchByAssoc($res)) {
$templates[] = $row;
}
$viewer->assign('MODULE_NAME', $moduleName);
$viewer->assign('TEMPLATES', $templates);
$viewer->view('List.tpl', $moduleName);
}
}

View File

@@ -2,6 +2,212 @@
class Vtiger_Base_Service
{
private static $s3Client = null;
private static $tempFiles = []; // Для очистки временных файлов после архивации
/**
* Инициализация S3 клиента
*/
private static function initS3Client()
{
if (self::$s3Client === null) {
$configPath = __DIR__ . '/../../crm_extensions/file_storage/config.php';
if (file_exists($configPath)) {
$config = require $configPath;
require_once __DIR__ . '/../../crm_extensions/file_storage/S3Client.php';
self::$s3Client = new S3Client($config['s3']);
}
}
return self::$s3Client;
}
/**
* Скачивание файла из S3 во временную папку
*/
private static function downloadS3File($s3Bucket, $s3Key, $fileName)
{
$debugLog = '/tmp/s3_download_debug.log';
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - downloadS3File: START - bucket={$s3Bucket}, key={$s3Key}\n", FILE_APPEND);
try {
error_log("downloadS3File: Starting download - bucket={$s3Bucket}, key={$s3Key}");
// Используем нативный AWS SDK для скачивания
// Пробуем несколько возможных путей к vendor/autoload.php
$possibleVendorPaths = [
__DIR__ . '/../../vendor/autoload.php', // От modules/Vtiger/services/
__DIR__ . '/../../../vendor/autoload.php', // Альтернативный путь
'/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php', // Абсолютный путь
];
$vendorPath = null;
foreach ($possibleVendorPaths as $path) {
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - Checking vendor path: {$path}\n", FILE_APPEND);
if (file_exists($path)) {
$vendorPath = $path;
break;
}
}
if (!$vendorPath) {
$errorMsg = "downloadS3File: vendor/autoload.php not found. Tried: " . implode(', ', $possibleVendorPaths);
error_log($errorMsg);
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - ERROR: {$errorMsg}\n", FILE_APPEND);
return false;
}
require_once $vendorPath;
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - vendor/autoload.php loaded from: {$vendorPath}\n", FILE_APPEND);
// Пробуем несколько путей к конфигурации
$possiblePaths = [
__DIR__ . '/../../crm_extensions/file_storage/config.php',
dirname(__DIR__) . '/../../crm_extensions/file_storage/config.php',
'/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php'
];
$configPath = null;
foreach ($possiblePaths as $path) {
if (file_exists($path)) {
$configPath = $path;
break;
}
}
if (!$configPath) {
$errorMsg = "downloadS3File: Config file not found. Tried: " . implode(', ', $possiblePaths);
error_log($errorMsg);
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - ERROR: {$errorMsg}\n", FILE_APPEND);
return false;
}
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - Config found at: {$configPath}\n", FILE_APPEND);
try {
$config = require $configPath;
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - Config loaded successfully\n", FILE_APPEND);
} catch (Exception $e) {
$errorMsg = "downloadS3File: Error loading config: " . $e->getMessage();
error_log($errorMsg);
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - ERROR: {$errorMsg}\n", FILE_APPEND);
return false;
}
if (!isset($config['s3'])) {
$errorMsg = "downloadS3File: S3 config not found in config file";
error_log($errorMsg);
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - ERROR: {$errorMsg}\n", FILE_APPEND);
return false;
}
$s3Config = $config['s3'];
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - S3 config loaded, endpoint: " . ($s3Config['endpoint'] ?? 'NULL') . "\n", FILE_APPEND);
// Проверяем наличие обязательных полей
if (empty($s3Config['key']) || empty($s3Config['secret']) || empty($s3Config['endpoint'])) {
$errorMsg = "downloadS3File: Missing required S3 config fields";
error_log($errorMsg);
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - ERROR: {$errorMsg}\n", FILE_APPEND);
return false;
}
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - Creating S3Client...\n", FILE_APPEND);
$awsClient = new \Aws\S3\S3Client([
'version' => $s3Config['version'],
'region' => $s3Config['region'],
'endpoint' => $s3Config['endpoint'],
'use_path_style_endpoint' => $s3Config['use_path_style_endpoint'],
'credentials' => [
'key' => $s3Config['key'],
'secret' => $s3Config['secret'],
],
]);
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - S3Client created\n", FILE_APPEND);
// Используем bucket из параметра, а не из конфига
// Используем только расширение файла для имени временного файла, чтобы избежать "File name too long"
$extension = '';
if (!empty($fileName)) {
// Декодируем URL-encoded имя файла, если это URL
$decodedFileName = urldecode($fileName);
// Извлекаем расширение из оригинального s3_key, если filename - это URL
if (strpos($decodedFileName, '/') !== false) {
// Если filename содержит путь, используем s3_key для расширения
$extension = pathinfo($s3Key, PATHINFO_EXTENSION);
} else {
$extension = pathinfo($decodedFileName, PATHINFO_EXTENSION);
}
}
if (empty($extension) && !empty($s3Key)) {
$extension = pathinfo($s3Key, PATHINFO_EXTENSION);
}
// Создаем короткое имя файла с расширением
$tempFileName = uniqid('s3_') . (!empty($extension) ? '.' . $extension : '');
$tempFile = sys_get_temp_dir() . '/' . $tempFileName;
error_log("downloadS3File: Temp file path: {$tempFile}");
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - Temp file path: {$tempFile}\n", FILE_APPEND);
// Скачиваем файл
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - Calling getObject() - Bucket: {$s3Bucket}, Key: {$s3Key}\n", FILE_APPEND);
$result = $awsClient->getObject([
'Bucket' => $s3Bucket,
'Key' => $s3Key,
'SaveAs' => $tempFile
]);
error_log("downloadS3File: getObject() completed successfully");
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - getObject() completed successfully\n", FILE_APPEND);
if (!file_exists($tempFile)) {
error_log("downloadS3File: File was not created: {$tempFile}");
return false;
}
$fileSize = filesize($tempFile);
if ($fileSize == 0) {
error_log("downloadS3File: WARNING - File size is 0 bytes: {$tempFile}");
// Не возвращаем false для пустого файла - возможно, это нормально
}
error_log("downloadS3File: Success - file size: {$fileSize} bytes");
// Сохраняем путь для последующей очистки
self::$tempFiles[] = $tempFile;
return $tempFile;
} catch (\Aws\Exception\AwsException $e) {
$errorMsg = "downloadS3File: AWS Exception - " . $e->getMessage();
$errorMsg .= " | Error Code: " . $e->getAwsErrorCode();
$errorMsg .= " | Request ID: " . $e->getAwsRequestId();
$errorMsg .= " | Bucket: {$s3Bucket} | Key: {$s3Key}";
error_log($errorMsg);
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - AWS EXCEPTION: {$errorMsg}\n", FILE_APPEND);
@file_put_contents('/tmp/s3_download_errors.log', date('Y-m-d H:i:s') . ' - ' . $errorMsg . "\n", FILE_APPEND);
return false;
} catch (Exception $e) {
$errorMsg = "downloadS3File: Exception - " . $e->getMessage();
$errorMsg .= " | Bucket: {$s3Bucket} | Key: {$s3Key}";
error_log($errorMsg);
error_log("downloadS3File: Stack trace - " . $e->getTraceAsString());
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - EXCEPTION: {$errorMsg}\n", FILE_APPEND);
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - Stack trace: " . $e->getTraceAsString() . "\n", FILE_APPEND);
@file_put_contents('/tmp/s3_download_errors.log', date('Y-m-d H:i:s') . ' - ' . $errorMsg . "\n", FILE_APPEND);
return false;
}
}
/**
* Очистка временных файлов
*/
private static function cleanupTempFiles()
{
foreach (self::$tempFiles as $tempFile) {
if (file_exists($tempFile)) {
@unlink($tempFile);
}
}
self::$tempFiles = [];
}
public static function getDocs($record)
{
$module = 'Documents';
@@ -15,31 +221,242 @@ class Vtiger_Base_Service
return $relation->getEntries($pager);
}
/**
* Получение документов из связанных сущностей (для проектов)
*/
public static function getRelatedDocs($projectId)
{
$adb = PearDatabase::getInstance();
$docs = [];
// Получаем информацию о проекте и связанных контрагентах
$query = 'SELECT
p.linktoaccountscontacts as contactid,
pcf.cf_1994 as accountid,
pcf.cf_2274 as acc1,
pcf.cf_2276 as acc2
FROM vtiger_project p
LEFT JOIN vtiger_projectcf pcf ON pcf.projectid = p.projectid
LEFT JOIN vtiger_crmentity e ON e.crmid = p.projectid
WHERE e.deleted = 0 AND p.projectid = ?';
$result = $adb->pquery($query, array($projectId));
if ($adb->num_rows($result) == 0) {
return $docs;
}
$row = $adb->query_result_rowdata($result, 0);
$contactId = $row['contactid'];
$accountId = $row['accountid'];
$acc1 = $row['acc1'];
$acc2 = $row['acc2'];
// Собираем ID всех связанных сущностей
$relatedIds = array_filter([$projectId, $contactId, $accountId, $acc1, $acc2]);
if (empty($relatedIds)) {
return $docs;
}
// Получаем все документы из связанных сущностей
$placeholders = str_repeat('?,', count($relatedIds) - 1) . '?';
$query = "SELECT
n.notesid,
n.title,
n.filename,
n.filelocationtype,
n.s3_bucket,
n.s3_key,
r.crmid as related_to_id,
CASE
WHEN r.crmid = ? THEN 'Project'
WHEN r.crmid = ? THEN 'Contact'
WHEN r.crmid IN (?, ?, ?) THEN 'Account'
ELSE 'Unknown'
END as source_type
FROM vtiger_senotesrel r
LEFT JOIN vtiger_notes n ON n.notesid = r.notesid
LEFT JOIN vtiger_crmentity e ON e.crmid = r.notesid
WHERE r.crmid IN ($placeholders)
AND e.deleted = 0
AND n.filename IS NOT NULL
ORDER BY r.crmid, n.title";
$params = array_merge([$projectId, $contactId, $accountId, $acc1, $acc2], $relatedIds);
$result = $adb->pquery($query, $params);
while ($row = $adb->fetchByAssoc($result)) {
$docs[] = $row;
}
return $docs;
}
public static function getPaths($docs = [])
{
$archived = 0;
$errors = [];
$files = [];
// Отладочное логирование
error_log("========================================");
error_log("getPaths: Processing " . count($docs) . " documents");
foreach ($docs as $x) {
if (empty($x->get('filename'))) {
$errors[] = 'skip non-file docs';
// Поддержка как Record Model, так и массива (для связанных документов)
if (is_object($x)) {
$filename = $x->get('filename');
$filelocationtype = $x->get('filelocationtype');
$title = $x->get('title');
$notesid = $x->getId();
// ВСЕГДА получаем s3_bucket и s3_key напрямую из БД для Record Models,
// так как эти поля могут отсутствовать в Record Model
$adb = PearDatabase::getInstance();
$dbResult = $adb->pquery(
"SELECT s3_bucket, s3_key, filelocationtype FROM vtiger_notes WHERE notesid = ?",
array($notesid)
);
if ($adb->num_rows($dbResult) > 0) {
$dbRow = $adb->fetchByAssoc($dbResult);
$s3Bucket = $dbRow['s3_bucket'] ?? null;
$s3Key = $dbRow['s3_key'] ?? null;
// Используем filelocationtype из БД, если он есть
if (!empty($dbRow['filelocationtype'])) {
$filelocationtype = $dbRow['filelocationtype'];
}
} else {
$s3Bucket = null;
$s3Key = null;
}
} else {
// Массив из getRelatedDocs
$filename = $x['filename'] ?? null;
$filelocationtype = $x['filelocationtype'] ?? null;
$s3Bucket = $x['s3_bucket'] ?? null;
$s3Key = $x['s3_key'] ?? null;
$title = $x['title'] ?? '';
$notesid = $x['notesid'] ?? null;
}
$logMsg = "getPaths: Processing doc notesid={$notesid}, filename=" . ($filename ?? 'NULL') . ", filelocationtype=" . ($filelocationtype ?? 'NULL') . ", s3_bucket=" . ($s3Bucket ?? 'NULL') . ", s3_key=" . ($s3Key ?? 'NULL');
error_log($logMsg);
// Для S3 файлов filename может быть URL, это нормально
// Проверяем только что filename не пустой ИЛИ есть s3_key
if (empty($filename) && empty($s3Key)) {
$errors[] = 'skip non-file docs (notesid=' . ($notesid ?? 'unknown') . ')';
error_log("getPaths: SKIP - empty filename and s3_key for notesid=" . ($notesid ?? 'unknown'));
continue;
}
// Проверяем условия для S3
$isS3File = ($filelocationtype == 'E' && !empty($s3Bucket) && !empty($s3Key));
error_log("getPaths: CHECK S3 - filelocationtype='{$filelocationtype}' == 'E': " . (($filelocationtype == 'E') ? 'YES' : 'NO') . ", s3Bucket empty: " . (empty($s3Bucket) ? 'YES' : 'NO') . ", s3Key empty: " . (empty($s3Key) ? 'YES' : 'NO') . ", isS3File: " . ($isS3File ? 'YES' : 'NO'));
// Проверяем, файл ли это в S3
if ($isS3File) {
// Файл в S3 - скачиваем во временную папку
// Определяем расширение файла
$extension = '';
if (!empty($filename)) {
$extension = pathinfo($filename, PATHINFO_EXTENSION);
}
if (empty($extension) && !empty($s3Key)) {
$extension = pathinfo($s3Key, PATHINFO_EXTENSION);
}
$displayName = !empty($title)
? $title . (!empty($extension) ? '.' . $extension : '')
: basename($s3Key);
$tempPath = self::downloadS3File($s3Bucket, $s3Key, $displayName);
if ($tempPath && file_exists($tempPath)) {
$archived++;
$files[] = [
'name' => $displayName,
'path' => $tempPath,
'is_temp' => true
];
} else {
$errors[] = "S3 file download failed: {$s3Key}";
}
} else {
// Локальный файл - используем старую логику
// НО: если это массив из getRelatedDocs и у него filelocationtype != 'E',
// значит это не S3 файл, но и не локальный (возможно, внешняя ссылка)
// Пропускаем такие файлы или пытаемся обработать как локальные
if (is_object($x)) {
// Record Model - получаем детали файла
$details = $x->getFileDetails();
if (empty($details) || empty($details['path'])) {
$errors[] = "Cannot get file details for Record Model: {$notesid}";
error_log("getPaths: Cannot get file details for notesid={$notesid}");
continue;
}
$name = $details['attachmentsid'] . '_' . $details['storedname'];
$fullPath = $details['path'] . $name;
} else {
// Массив из getRelatedDocs - если это не S3, значит локальный файл
// Пытаемся создать Record Model для получения пути
if (!empty($x['notesid'])) {
try {
$docRecord = Vtiger_Record_Model::getInstanceById($x['notesid'], 'Documents');
if ($docRecord) {
$details = $docRecord->getFileDetails();
if (empty($details) || empty($details['path'])) {
$errors[] = "Cannot get file details for document: {$x['notesid']}";
error_log("getPaths: Cannot get file details for notesid={$x['notesid']}");
continue;
}
$name = $details['attachmentsid'] . '_' . $details['storedname'];
$fullPath = $details['path'] . $name;
} else {
$errors[] = "Cannot create Record Model for document: {$x['notesid']}";
error_log("getPaths: Cannot create Record Model for notesid={$x['notesid']}");
continue;
}
} catch (Exception $e) {
$errors[] = "Error creating Record Model: {$e->getMessage()}";
error_log("getPaths: Exception creating Record Model: " . $e->getMessage());
continue;
}
} else {
$errors[] = "Local file without Record Model and notesid: {$filename}";
error_log("getPaths: Local file without notesid: {$filename}");
continue;
}
}
if (empty($fullPath)) {
$errors[] = "Empty file path for notesid: {$notesid}";
error_log("getPaths: Empty file path for notesid={$notesid}");
continue;
}
if (!file_exists($fullPath)) {
$errors[] = "{$fullPath} is missing!";
error_log("getPaths: File not found: {$fullPath}");
continue;
}
$archived++;
$files[] = [
'name' => $name,
'path' => $fullPath
'path' => $fullPath,
'is_temp' => false
];
};
error_log("getPaths: Added local file: {$name}");
}
}
$resultMsg = "getPaths: Result - archived={$archived}, files=" . count($files) . ", errors=" . count($errors);
error_log($resultMsg);
if (count($errors) > 0) {
$errorsMsg = "getPaths: Errors: " . implode('; ', array_slice($errors, 0, 10));
error_log($errorsMsg);
}
return compact(
'files',
@@ -62,13 +479,19 @@ class Vtiger_Base_Service
$files = self::getPaths($docs);
if ($files['archived'] == 0) {
self::cleanupTempFiles();
return false;
}
$ts = date('Ymd_His_') . array_pop(explode('.', microtime(1)));
$zipFile = "cache/{$id}_documents_{$ts}.zip";
$zip = new ZipArchive();
$zip->open($zipFile, ZipArchive::CREATE);
$result = $zip->open($zipFile, ZipArchive::CREATE);
if (!$result) {
self::cleanupTempFiles();
return false;
}
foreach ($files['files'] as $x) {
$zip->addFile($x['path'], $x['name']);
}
@@ -76,10 +499,13 @@ class Vtiger_Base_Service
$size = filesize($zipFile);
if ($size == 0) {
//exit('Zero file');
self::cleanupTempFiles();
return false;
}
// Очищаем временные файлы после успешного создания архива
self::cleanupTempFiles();
return [
'total' => count($docs),
'archived' => $files['archived'],
@@ -91,20 +517,87 @@ class Vtiger_Base_Service
public static function getArchive($id)
{
$module = 'Documents';
// Логирование через error_log (более надежно)
error_log("========================================");
error_log("getArchive: START for project ID={$id}");
try {
$record = Vtiger_Record_Model::getInstanceById($id);
if (! $record) {
return false;
error_log("getArchive: Record not found for ID={$id}");
return self::response('Record not found');
}
$moduleName = $record->getModuleName();
error_log("getArchive: Module name={$moduleName}");
$allDocs = [];
// Получаем документы из самой записи
$docs = self::getDocs($record);
if (count($docs) == 0) {
$docsCount = count($docs);
error_log("getArchive: Found {$docsCount} docs from getDocs()");
foreach ($docs as $doc) {
$allDocs[] = $doc;
}
// Для проектов - добавляем документы из связанных сущностей
if ($moduleName == 'Project') {
error_log("getArchive: Getting related docs for Project");
$relatedDocs = self::getRelatedDocs($id);
$relatedCount = count($relatedDocs);
error_log("getArchive: Found {$relatedCount} related docs");
// Собираем notesid уже добавленных документов, чтобы избежать дубликатов
$addedNotesIds = [];
foreach ($allDocs as $doc) {
if (is_object($doc)) {
$addedNotesIds[] = $doc->getId();
}
}
// Добавляем только те документы, которых еще нет
foreach ($relatedDocs as $relatedDoc) {
if (!in_array($relatedDoc['notesid'], $addedNotesIds)) {
$allDocs[] = $relatedDoc;
$addedNotesIds[] = $relatedDoc['notesid'];
}
}
}
$totalDocs = count($allDocs);
error_log("getArchive: Total docs to process: {$totalDocs}");
if ($totalDocs == 0) {
error_log("getArchive: No documents found, returning error");
return self::response('Record has no documents');
}
$files = self::getPaths($docs);
error_log("getArchive: Calling getPaths() with {$totalDocs} docs");
$files = self::getPaths($allDocs);
$archivedCount = $files['archived'];
$errorsCount = count($files['errors']);
error_log("getArchive: getPaths returned archived={$archivedCount}, errors={$errorsCount}");
// Выводим первые несколько ошибок
if ($errorsCount > 0) {
$firstErrors = array_slice($files['errors'], 0, 5);
error_log("getArchive: First errors: " . implode('; ', $firstErrors));
}
if ($files['archived'] == 0) {
return self::response('Nothing to archive');
// Очищаем временные файлы перед выходом
self::cleanupTempFiles();
$errorDetails = implode('; ', array_slice($files['errors'], 0, 10));
error_log("getArchive: Nothing to archive - errors: " . $errorDetails);
error_log("getArchive: Total docs processed: {$totalDocs}, archived: {$archivedCount}, errors: {$errorsCount}");
// Возвращаем детальную информацию об ошибках для отладки
return self::response([
'message' => 'Nothing to archive',
'total_docs' => $totalDocs,
'archived' => $archivedCount,
'errors_count' => $errorsCount,
'errors' => array_slice($files['errors'], 0, 10)
]);
}
$ts = date('Ymd_His_') . array_pop(explode('.', microtime(1)));
@@ -113,6 +606,7 @@ class Vtiger_Base_Service
$zip = new ZipArchive();
$result = $zip->open($zipFile, ZipArchive::CREATE|ZipArchive::OVERWRITE);
if (! $result) {
self::cleanupTempFiles();
return self::response('Unable to create file');
}
@@ -122,27 +616,32 @@ class Vtiger_Base_Service
$result = $zip->close();
if (! $result) {
self::cleanupTempFiles();
return self::response('Unable to write file');
}
$size = filesize($zipFile);
if ($size == 0) {
//exit('Zero file');
self::cleanupTempFiles();
return self::response('Error creating archive');
}
// Очищаем временные файлы после успешного создания архива
self::cleanupTempFiles();
header('Content-disposition: attachment; filename='.$archive);
header('Content-type: application/zip');
readfile($zipFile);
//unlink($zipFile);
//unlink($zipFile); // Можно оставить для отладки или удалить сразу
exit();
return self::response([
'file' => $zipName,
'docsCount' => count($docs),
'size' => $size,
]);
} catch (Exception $e) {
error_log("getArchive: Exception - " . $e->getMessage());
error_log("getArchive: Stack trace - " . $e->getTraceAsString());
self::cleanupTempFiles();
return self::response('Error: ' . $e->getMessage());
}
}
public static function response($data)

213
move_files_from_temp.php Normal file
View File

@@ -0,0 +1,213 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
$config = require '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php';
$projectId = 384256;
$s3Bucket = $config['s3']['bucket'];
echo "Перемещение файлов проекта $projectId из temp/ в правильную структуру\n";
echo str_repeat("=", 80) . "\n\n";
try {
// Инициализация S3 клиента
$s3Client = new \Aws\S3\S3Client([
'version' => 'latest',
'region' => $config['s3']['region'],
'endpoint' => $config['s3']['endpoint'],
'use_path_style_endpoint' => true,
'credentials' => [
'key' => $config['s3']['key'],
'secret' => $config['s3']['secret'],
],
'suppress_php_deprecation_warning' => true
]);
// Подключение к БД
$pdo = new PDO(
"mysql:host={$dbconfig['db_server']};port=3306;dbname={$dbconfig['db_name']};charset=utf8",
$dbconfig['db_username'],
$dbconfig['db_password'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
// Получаем все файлы из temp/384256/
echo "1. Поиск файлов в temp/$projectId/...\n";
$tempFiles = [];
$objects = $s3Client->listObjectsV2([
'Bucket' => $s3Bucket,
'Prefix' => "temp/$projectId/",
'MaxKeys' => 1000
]);
if (isset($objects['Contents'])) {
foreach ($objects['Contents'] as $object) {
$key = $object['Key'];
// Пропускаем папки
if (substr($key, -1) !== '/') {
$tempFiles[] = $key;
}
}
}
echo " Найдено файлов в temp/: " . count($tempFiles) . "\n\n";
if (empty($tempFiles)) {
die("Файлы не найдены в temp/$projectId/\n");
}
// Получаем документы проекта из БД
echo "2. Получение документов проекта из БД...\n";
$stmt = $pdo->prepare('
SELECT n.notesid, n.title, n.s3_key, n.filename
FROM vtiger_notes n
INNER JOIN vtiger_crmentity e ON e.crmid = n.notesid
INNER JOIN vtiger_senotesrel snr ON snr.notesid = n.notesid
WHERE snr.crmid = ? AND e.deleted = 0 AND n.filelocationtype = "E"
ORDER BY n.notesid ASC
');
$stmt->execute([$projectId]);
$dbDocs = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo " Найдено документов в БД: " . count($dbDocs) . "\n\n";
// Сопоставляем файлы из temp/ с документами в БД
echo "3. Сопоставление файлов...\n";
$mappings = [];
foreach ($tempFiles as $tempFile) {
$basename = basename($tempFile);
// Извлекаем ID из имени файла (например, 384260 из 384260_8_Dogovor...)
if (preg_match('/^(\d+)_/', $basename, $matches)) {
$fileDocId = $matches[1];
// Ищем соответствующий документ в БД
// Обычно файл с ID 384260 соответствует документу 384259 (ID файла = ID документа + 1)
// Но лучше искать по ближайшему ID
$matchedDoc = null;
foreach ($dbDocs as $doc) {
// Проверяем разные варианты соответствия
if ($doc['notesid'] == $fileDocId - 1 ||
$doc['notesid'] == $fileDocId ||
abs($doc['notesid'] - $fileDocId) <= 2) {
// Дополнительная проверка по названию
$docTitleLower = mb_strtolower($doc['title']);
$fileNameLower = mb_strtolower($basename);
// Проверяем совпадение по ключевым словам
$keywords = ['dogovor', 'podtverzhdenie', 'skrin', 'zayavlenie', '8', '9', '10', '7'];
$foundKeyword = false;
foreach ($keywords as $keyword) {
if (strpos($docTitleLower, $keyword) !== false && strpos($fileNameLower, $keyword) !== false) {
$foundKeyword = true;
break;
}
}
if ($foundKeyword || abs($doc['notesid'] - $fileDocId) <= 1) {
$matchedDoc = $doc;
break;
}
}
}
if ($matchedDoc) {
$mappings[] = [
'temp_file' => $tempFile,
'doc_id' => $matchedDoc['notesid'],
'doc_title' => $matchedDoc['title'],
'target_s3_key' => $matchedDoc['s3_key'],
'current_s3_key' => $matchedDoc['s3_key']
];
echo "{$basename} -> Документ {$matchedDoc['notesid']}: {$matchedDoc['title']}\n";
} else {
echo " ⚠️ {$basename} -> Не найден соответствующий документ (ID файла: $fileDocId)\n";
}
}
}
echo "\n Всего сопоставлено: " . count($mappings) . " файлов\n\n";
if (empty($mappings)) {
die("Не удалось сопоставить файлы с документами\n");
}
// Перемещаем файлы
echo "4. Перемещение файлов в S3...\n";
$moved = 0;
$errors = 0;
foreach ($mappings as $mapping) {
$sourceKey = $mapping['temp_file'];
$targetKey = $mapping['target_s3_key'];
$docId = $mapping['doc_id'];
echo " Перемещение: " . basename($sourceKey) . "\n";
echo " Из: $sourceKey\n";
echo " В: $targetKey\n";
try {
// Проверяем, существует ли целевой путь (если да, пропускаем)
try {
$s3Client->headObject([
'Bucket' => $s3Bucket,
'Key' => $targetKey
]);
echo " ⚠️ Целевой файл уже существует, пропускаем\n\n";
continue;
} catch (\Aws\Exception\AwsException $e) {
if ($e->getAwsErrorCode() != 'NotFound') {
throw $e;
}
}
// Копируем файл в новое место
$s3Client->copyObject([
'Bucket' => $s3Bucket,
'CopySource' => $s3Bucket . '/' . $sourceKey,
'Key' => $targetKey,
'MetadataDirective' => 'COPY'
]);
echo " ✅ Файл скопирован\n";
// Удаляем исходный файл из temp/
$s3Client->deleteObject([
'Bucket' => $s3Bucket,
'Key' => $sourceKey
]);
echo " ✅ Исходный файл удален из temp/\n";
// Обновляем filename в БД (если нужно)
$newFilename = "https://s3.twcstorage.ru/{$s3Bucket}/" . rawurlencode($targetKey);
$updateStmt = $pdo->prepare('UPDATE vtiger_notes SET filename = ? WHERE notesid = ?');
$updateStmt->execute([$newFilename, $docId]);
echo " ✅ Путь в БД обновлен\n";
$moved++;
} catch (\Aws\Exception\AwsException $e) {
echo " ❌ Ошибка: " . $e->getMessage() . " (Code: " . $e->getAwsErrorCode() . ")\n";
$errors++;
} catch (Exception $e) {
echo " ❌ Ошибка: " . $e->getMessage() . "\n";
$errors++;
}
echo "\n";
}
echo str_repeat("=", 80) . "\n";
echo "РЕЗУЛЬТАТЫ:\n";
echo " Перемещено файлов: $moved\n";
echo " Ошибок: $errors\n";
} catch (Exception $e) {
echo "КРИТИЧЕСКАЯ ОШИБКА: " . $e->getMessage() . "\n";
echo "Trace: " . $e->getTraceAsString() . "\n";
}

View File

@@ -0,0 +1,165 @@
<?php
/**
* Скрипт для перемещения файлов проекта 394091 в правильное место
*/
error_reporting(E_ALL);
ini_set('display_errors', 1);
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
$config = require '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php';
$s3Bucket = $config['s3']['bucket'];
$projectId = 394091;
// Маппинг: текущий путь => правильный путь
$filesToMove = [
// 394094 - Договор
'crm2/CRM_Active_Files/Documents/394094/doc_394094_d583b5d6.pdf' => [
'new_path' => 'crm2/CRM_Active_Files/Documents/Project/Згурский_ООО_РЕНТСОФТ_394091/Договор_394094.pdf',
'doc_id' => 394094,
],
// 394096 - Подтверждение оплаты
'crm2/CRM_Active_Files/Documents/394096/doc_394096_ce9e6bdc.pdf' => [
'new_path' => 'crm2/CRM_Active_Files/Documents/Project/Згурский_ООО_РЕНТСОФТ_394091/Подтверждение_оплаты_394096.pdf',
'doc_id' => 394096,
],
// 394100 - Ответ на претензию
'crm2/CRM_Active_Files/Documents/394100/doc_394100_3f15e3c1.pdf' => [
'new_path' => 'crm2/CRM_Active_Files/Documents/Project/Згурский_ООО_РЕНТСОФТ_394091/Ответ_на_претензию_394100.pdf',
'doc_id' => 394100,
],
// 394105 - Заявление потребителя
'crm2/CRM_Active_Files/Documents/394105/potrebitelya_Zgurskiy_1.pdf' => [
'new_path' => 'crm2/CRM_Active_Files/Documents/Project/Згурский_ООО_РЕНТСОФТ_394091/7_заявление_потребителя_394105.pdf',
'doc_id' => 394105,
],
];
echo "=== ПЕРЕМЕЩЕНИЕ ФАЙЛОВ ПРОЕКТА {$projectId} ===\n";
echo str_repeat("=", 80) . "\n\n";
$dryRun = isset($argv[1]) && $argv[1] === '--dry-run';
if ($dryRun) {
echo "⚠️ РЕЖИМ ПРОВЕРКИ (dry-run) - файлы не будут перемещены\n\n";
}
try {
$s3Client = new \Aws\S3\S3Client([
'version' => 'latest',
'region' => $config['s3']['region'],
'endpoint' => $config['s3']['endpoint'],
'use_path_style_endpoint' => true,
'credentials' => [
'key' => $config['s3']['key'],
'secret' => $config['s3']['secret'],
],
'suppress_php_deprecation_warning' => true
]);
$db = PearDatabase::getInstance();
$stats = [
'total' => count($filesToMove),
'moved' => 0,
'skipped' => 0,
'failed' => 0,
'errors' => [],
];
foreach ($filesToMove as $oldPath => $info) {
$newPath = $info['new_path'];
$docId = $info['doc_id'];
echo "Документ ID: {$docId}\n";
echo " Старый путь: {$oldPath}\n";
echo " Новый путь: {$newPath}\n";
// Проверяем существование старого файла
if (!$s3Client->doesObjectExist($s3Bucket, $oldPath)) {
echo " ⚠️ Старый файл не найден, пропускаем\n\n";
$stats['skipped']++;
continue;
}
// Проверяем, не существует ли уже новый файл
if ($s3Client->doesObjectExist($s3Bucket, $newPath)) {
echo " ⚠️ Новый файл уже существует, пропускаем\n\n";
$stats['skipped']++;
continue;
}
if (!$dryRun) {
try {
// Копируем файл в новое место
$s3Client->copyObject([
'Bucket' => $s3Bucket,
'Key' => $newPath,
'CopySource' => "{$s3Bucket}/{$oldPath}",
]);
echo " ✅ Файл скопирован в новое место\n";
// Удаляем старый файл
$s3Client->deleteObject([
'Bucket' => $s3Bucket,
'Key' => $oldPath,
]);
echo " ✅ Старый файл удален\n";
// Обновляем БД
$newFilename = 'https://s3.twcstorage.ru/' . $s3Bucket . '/' . $newPath;
$db->pquery("
UPDATE vtiger_notes
SET s3_key = ?, filename = ?
WHERE notesid = ?
", array($newPath, $newFilename, $docId));
echo " ✅ БД обновлена\n";
$stats['moved']++;
sleep(1); // Пауза между операциями
} catch (Exception $e) {
echo " ❌ Ошибка: " . $e->getMessage() . "\n";
$stats['failed']++;
$stats['errors'][] = "{$oldPath}: " . $e->getMessage();
}
} else {
echo " ⏸️ Будет перемещен (dry-run)\n";
$stats['moved']++;
}
echo "\n";
}
// Итоги
echo str_repeat("=", 80) . "\n";
echo "ИТОГИ:\n";
echo "Всего файлов: {$stats['total']}\n";
if (!$dryRun) {
echo "Перемещено: {$stats['moved']}\n";
echo "Пропущено: {$stats['skipped']}\n";
echo "Ошибок: {$stats['failed']}\n";
} else {
echo "Будет перемещено: {$stats['moved']}\n";
echo "Будет пропущено: {$stats['skipped']}\n";
}
if (!empty($stats['errors'])) {
echo "\nОшибки:\n";
foreach ($stats['errors'] as $error) {
echo " - {$error}\n";
}
}
echo "\n=== ГОТОВО ===\n";
} catch (Exception $e) {
echo "❌ Критическая ошибка: " . $e->getMessage() . "\n";
exit(1);
}

View File

@@ -0,0 +1,126 @@
<?php
/**
* Упрощенный скрипт для перемещения файлов проекта 394091
*/
error_reporting(E_ALL);
ini_set('display_errors', 1);
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
$config = require '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php';
$s3Bucket = $config['s3']['bucket'];
// Маппинг: текущий путь => правильный путь
$filesToMove = [
'crm2/CRM_Active_Files/Documents/394094/doc_394094_d583b5d6.pdf' => 'crm2/CRM_Active_Files/Documents/Project/Згурский_ООО_РЕНТСОФТ_394091/Договор_394094.pdf',
'crm2/CRM_Active_Files/Documents/394096/doc_394096_ce9e6bdc.pdf' => 'crm2/CRM_Active_Files/Documents/Project/Згурский_ООО_РЕНТСОФТ_394091/Подтверждение_оплаты_394096.pdf',
'crm2/CRM_Active_Files/Documents/394100/doc_394100_3f15e3c1.pdf' => 'crm2/CRM_Active_Files/Documents/Project/Згурский_ООО_РЕНТСОФТ_394091/Ответ_на_претензию_394100.pdf',
'crm2/CRM_Active_Files/Documents/394105/potrebitelya_Zgurskiy_1.pdf' => 'crm2/CRM_Active_Files/Documents/Project/Згурский_ООО_РЕНТСОФТ_394091/7_заявление_потребителя_394105.pdf',
];
echo "=== ПЕРЕМЕЩЕНИЕ ФАЙЛОВ ПРОЕКТА 394091 ===\n";
echo str_repeat("=", 80) . "\n\n";
$dryRun = isset($argv[1]) && $argv[1] === '--dry-run';
if ($dryRun) {
echo "⚠️ РЕЖИМ ПРОВЕРКИ (dry-run)\n\n";
}
try {
$s3Client = new \Aws\S3\S3Client([
'version' => 'latest',
'region' => $config['s3']['region'],
'endpoint' => $config['s3']['endpoint'],
'use_path_style_endpoint' => true,
'credentials' => [
'key' => $config['s3']['key'],
'secret' => $config['s3']['secret'],
],
'suppress_php_deprecation_warning' => true
]);
$stats = [
'total' => count($filesToMove),
'moved' => 0,
'skipped' => 0,
'failed' => 0,
];
foreach ($filesToMove as $oldPath => $newPath) {
$filename = basename($newPath);
echo "Файл: {$filename}\n";
echo " От: {$oldPath}\n";
echo " К: {$newPath}\n";
// Проверяем существование старого файла
if (!$s3Client->doesObjectExist($s3Bucket, $oldPath)) {
echo " ⚠️ Старый файл не найден\n\n";
$stats['skipped']++;
continue;
}
// Проверяем, не существует ли уже новый файл
if ($s3Client->doesObjectExist($s3Bucket, $newPath)) {
echo " ⚠️ Новый файл уже существует\n\n";
$stats['skipped']++;
continue;
}
if (!$dryRun) {
try {
// Копируем файл
$s3Client->copyObject([
'Bucket' => $s3Bucket,
'Key' => $newPath,
'CopySource' => "{$s3Bucket}/{$oldPath}",
]);
echo " ✅ Скопирован\n";
// Удаляем старый
$s3Client->deleteObject([
'Bucket' => $s3Bucket,
'Key' => $oldPath,
]);
echo " ✅ Старый удален\n";
$stats['moved']++;
sleep(1);
} catch (Exception $e) {
echo " ❌ Ошибка: " . $e->getMessage() . "\n";
$stats['failed']++;
}
} else {
echo " ⏸️ Будет перемещен (dry-run)\n";
$stats['moved']++;
}
echo "\n";
}
echo str_repeat("=", 80) . "\n";
echo "ИТОГИ:\n";
echo "Всего: {$stats['total']}\n";
if (!$dryRun) {
echo "Перемещено: {$stats['moved']}\n";
echo "Пропущено: {$stats['skipped']}\n";
echo "Ошибок: {$stats['failed']}\n";
} else {
echo "Будет перемещено: {$stats['moved']}\n";
}
if (!$dryRun && $stats['moved'] > 0) {
echo "\n⚠️ ВАЖНО: Обновите БД вручную:\n";
echo "UPDATE vtiger_notes SET s3_key = '...', filename = '...' WHERE notesid = ...;\n";
}
echo "\n=== ГОТОВО ===\n";
} catch (Exception $e) {
echo "❌ Ошибка: " . $e->getMessage() . "\n";
exit(1);
}

165
move_project_398027.php Normal file
View File

@@ -0,0 +1,165 @@
<?php
/**
* Перемещение файлов проекта 398027 в правильное место
*/
error_reporting(E_ALL);
ini_set('display_errors', 1);
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
$config = require '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php';
$projectId = 398027;
$s3Bucket = $config['s3']['bucket'];
$projectPrefix = 'crm2/CRM_Active_Files/Documents/Project/Храмов_ООО_НЕТОЛОГИЯ_398027/';
// Маппинг: текущий путь => правильный путь
$filesToMove = [
398030 => [
'old' => 'crm2/CRM_Active_Files/Documents/398030/8_Договора_оказание_услуг_11-14-2025-16-00-51_Храмов_1_CTP#realfile.pdf',
'new' => $projectPrefix . '8_Договора_оказание_услуг_398030.pdf',
],
398032 => [
'old' => 'crm2/CRM_Active_Files/Documents/398032/9_Подтверждение_оплаты_пооговору_11-14-2025-16-00-03_Храмов_1_CTP#realfile.pdf',
'new' => $projectPrefix . '9_Подтверждение_оплаты_пооговору_398032.pdf',
],
398034 => [
'old' => 'crm2/CRM_Active_Files/Documents/398034/10_2_Скрин_личногоабинетастца_и_программа_обучения_11-14-2025-15-47-26_Храмов_41_CTP#realfile.pdf',
'new' => $projectPrefix . '10_2_Скрин_личногоабинетастца_и_программа_обучения_398034.pdf',
],
398036 => [
'old' => 'crm2/CRM_Active_Files/Documents/398036/10_1_Скрин_личногоабинетастца_и_программа_обучения_11-14-2025-15-49-59_Храмов_1_CTP#realfile.pdf',
'new' => $projectPrefix . '10_1_Скрин_личногоабинетастца_и_программа_обучения_398036.pdf',
],
398038 => [
'old' => 'crm2/CRM_Active_Files/Documents/398038/Прочиеокументы_11-14-2025-16-06-07_Храмов_3_CTP#realfile.pdf',
'new' => $projectPrefix . рочиеокументы_398038.pdf',
],
398040 => [
'old' => 'crm2/CRM_Active_Files/Documents/398040/7_zayavlenie_potrebitelya_Hramov.pdf',
'new' => $projectPrefix . '7_заявление_потребителя_398040.pdf',
],
398063 => [
'old' => 'crm2/CRM_Active_Files/Documents/398063/napravleniya_pretenzii.pdf',
'new' => $projectPrefix . 'Направление_претензии_398063.pdf',
],
399067 => [
'old' => 'clientright/0/1763997676315.pdf',
'new' => $projectPrefix . окумент_399067.pdf',
],
399068 => [
'old' => 'clientright/0/1763997790309.pdf',
'new' => $projectPrefix . окумент_399068.pdf',
],
];
echo "=== ПЕРЕМЕЩЕНИЕ ФАЙЛОВ ПРОЕКТА {$projectId} ===\n";
echo str_repeat("=", 80) . "\n\n";
$dryRun = isset($argv[1]) && $argv[1] === '--dry-run';
if ($dryRun) {
echo "⚠️ РЕЖИМ ПРОВЕРКИ (dry-run)\n\n";
}
try {
$s3Client = new \Aws\S3\S3Client([
'version' => 'latest',
'region' => $config['s3']['region'],
'endpoint' => $config['s3']['endpoint'],
'use_path_style_endpoint' => true,
'credentials' => [
'key' => $config['s3']['key'],
'secret' => $config['s3']['secret'],
],
'suppress_php_deprecation_warning' => true
]);
$stats = [
'total' => count($filesToMove),
'moved' => 0,
'skipped' => 0,
'failed' => 0,
];
foreach ($filesToMove as $docId => $paths) {
$oldPath = $paths['old'];
$newPath = $paths['new'];
$filename = basename($newPath);
echo "Документ ID: {$docId}\n";
echo " Файл: {$filename}\n";
echo " От: {$oldPath}\n";
echo " К: {$newPath}\n";
// Проверяем существование старого файла
if (!$s3Client->doesObjectExist($s3Bucket, $oldPath)) {
echo " ⚠️ Старый файл не найден\n\n";
$stats['skipped']++;
continue;
}
// Проверяем, не существует ли уже новый файл
if ($s3Client->doesObjectExist($s3Bucket, $newPath)) {
echo " ⚠️ Новый файл уже существует\n\n";
$stats['skipped']++;
continue;
}
if (!$dryRun) {
try {
// Копируем файл
$s3Client->copyObject([
'Bucket' => $s3Bucket,
'Key' => $newPath,
'CopySource' => "{$s3Bucket}/{$oldPath}",
]);
echo " ✅ Скопирован\n";
// Удаляем старый
$s3Client->deleteObject([
'Bucket' => $s3Bucket,
'Key' => $oldPath,
]);
echo " ✅ Старый удален\n";
$stats['moved']++;
sleep(1);
} catch (Exception $e) {
echo " ❌ Ошибка: " . $e->getMessage() . "\n";
$stats['failed']++;
}
} else {
echo " ⏸️ Будет перемещен (dry-run)\n";
$stats['moved']++;
}
echo "\n";
}
// Итоги
echo str_repeat("=", 80) . "\n";
echo "ИТОГИ:\n";
echo "Всего файлов: {$stats['total']}\n";
if (!$dryRun) {
echo "Перемещено: {$stats['moved']}\n";
echo "Пропущено: {$stats['skipped']}\n";
echo "Ошибок: {$stats['failed']}\n";
if ($stats['moved'] > 0) {
echo "\n⚠️ ВАЖНО: Обновите БД с правильными путями!\n";
}
} else {
echo "Будет перемещено: {$stats['moved']}\n";
}
echo "\n=== ГОТОВО ===\n";
} catch (Exception $e) {
echo "❌ Критическая ошибка: " . $e->getMessage() . "\n";
exit(1);
}

14
nextcloud_scan_files.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
# Скрипт для регулярной индексации файлов Nextcloud
# Запускать каждые 6 часов через cron
CONTAINER_NAME="nextcloud-fresh"
USER="www-data"
# Сканируем все файлы
docker exec -u $USER $CONTAINER_NAME php occ files:scan --all >> /var/log/nextcloud_scan.log 2>&1
# Сканируем только внешнее хранилище (быстрее)
# docker exec -u $USER $CONTAINER_NAME php occ files:scan --path="/crm" >> /var/log/nextcloud_scan.log 2>&1
echo "$(date): Nextcloud files scan completed" >> /var/log/nextcloud_scan.log

274
restore_all_deleted_files.php Executable file
View File

@@ -0,0 +1,274 @@
<?php
/**
* Скрипт для массового восстановления всех удаленных файлов из S3
*
* Восстанавливает файлы, удаленные через delete markers, удаляя эти маркеры
*/
error_reporting(E_ALL);
ini_set('display_errors', 1);
set_time_limit(0); // Без ограничения времени
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
$config = require '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php';
$s3Bucket = $config['s3']['bucket'];
echo "Массовое восстановление удаленных файлов из S3\n";
echo str_repeat("=", 80) . "\n\n";
// Параметры
$dryRun = isset($argv[1]) && $argv[1] === '--dry-run';
$limit = isset($argv[2]) ? (int)$argv[2] : null; // Ограничение количества файлов
$prefix = isset($argv[3]) ? $argv[3] : 'crm2/CRM_Active_Files/Documents/'; // Префикс для поиска
if ($dryRun) {
echo "⚠️ РЕЖИМ ПРОВЕРКИ (dry-run) - файлы не будут восстановлены\n\n";
}
try {
$s3Client = new \Aws\S3\S3Client([
'version' => 'latest',
'region' => $config['s3']['region'],
'endpoint' => $config['s3']['endpoint'],
'use_path_style_endpoint' => true,
'credentials' => [
'key' => $config['s3']['key'],
'secret' => $config['s3']['secret'],
],
'suppress_php_deprecation_warning' => true
]);
$stats = [
'total_markers' => 0,
'restored' => 0,
'failed' => 0,
'skipped' => 0,
'errors' => []
];
$processedKeys = []; // Для отслеживания уже обработанных ключей
echo "Поиск delete markers в префиксе: $prefix\n";
echo "Ограничение: " . ($limit ? "$limit файлов" : "нет") . "\n\n";
$isTruncated = true;
$continuationToken = null;
$pageCount = 0;
$maxPages = isset($argv[4]) ? (int)$argv[4] : 10; // БЕЗОПАСНО: максимум 10 страниц по умолчанию (можно увеличить через параметр)
echo "⚠️ ВНИМАНИЕ: Обработка ограничена {$maxPages} страницами для безопасности\n";
echo " Для обработки большего количества используйте: php restore_all_deleted_files.php [--dry-run] [limit] [prefix] [maxPages]\n\n";
while ($isTruncated && $pageCount < $maxPages && (!$limit || $stats['restored'] + $stats['failed'] < $limit)) {
$params = [
'Bucket' => $s3Bucket,
'Prefix' => $prefix,
'MaxKeys' => 100 // БЕЗОПАСНО: уменьшено с 1000 до 100 для снижения нагрузки
];
if ($continuationToken) {
$params['ContinuationToken'] = $continuationToken;
}
echo "Обработка страницы " . ($pageCount + 1) . "/{$maxPages}...\r";
try {
$versions = $s3Client->listObjectVersions($params);
$pageCount++;
// БЕЗОПАСНОСТЬ: пауза между страницами для снижения нагрузки
if ($pageCount < $maxPages && $isTruncated) {
usleep(500000); // 0.5 секунды пауза между страницами
}
if (isset($versions['DeleteMarkers']) && !empty($versions['DeleteMarkers'])) {
foreach ($versions['DeleteMarkers'] as $marker) {
$key = $marker['Key'];
$versionId = $marker['VersionId'];
$deleteDate = $marker['LastModified'] ?? 'не указана';
// Пропускаем, если уже обработали этот ключ
if (isset($processedKeys[$key])) {
continue;
}
$stats['total_markers']++;
// Проверяем лимит
if ($limit && ($stats['restored'] + $stats['failed']) >= $limit) {
break 2; // Выходим из обоих циклов
}
// Пропускаем папки (заканчиваются на /)
if (substr($key, -1) === '/') {
$stats['skipped']++;
continue;
}
try {
if (!$dryRun) {
// Сначала проверяем, есть ли версии файла
try {
$versionsList = $s3Client->listObjectVersions([
'Bucket' => $s3Bucket,
'Prefix' => $key,
'MaxKeys' => 10
]);
$hasVersions = isset($versionsList['Versions']) && !empty($versionsList['Versions']);
if ($hasVersions) {
// Есть версии - удаляем delete marker и файл восстановится автоматически
$s3Client->deleteObject([
'Bucket' => $s3Bucket,
'Key' => $key,
'VersionId' => $versionId
]);
// Проверяем, восстановился ли файл
try {
$headResult = $s3Client->headObject([
'Bucket' => $s3Bucket,
'Key' => $key
]);
$stats['restored']++;
$processedKeys[$key] = true;
// БЕЗОПАСНОСТЬ: пауза каждые 10 файлов
if ($stats['restored'] % 10 == 0) {
echo "Восстановлено: {$stats['restored']} файлов...\r";
usleep(200000); // 0.2 секунды пауза
}
} catch (\Aws\Exception\AwsException $e) {
if ($e->getAwsErrorCode() == 'NotFound') {
// Файл все еще не доступен, пробуем восстановить последнюю версию вручную
$latestVersion = $versionsList['Versions'][0];
$latestVersionId = $latestVersion['VersionId'];
try {
// Копируем версию в текущий объект
$s3Client->copyObject([
'Bucket' => $s3Bucket,
'CopySource' => urlencode($s3Bucket . '/' . $key) . '?versionId=' . $latestVersionId,
'Key' => $key
]);
$stats['restored']++;
$processedKeys[$key] = true;
if ($stats['restored'] % 100 == 0) {
echo "Восстановлено: {$stats['restored']} файлов...\r";
}
} catch (\Aws\Exception\AwsException $e2) {
$stats['failed']++;
$stats['errors'][] = "Ошибка восстановления версии $key: " . $e2->getMessage();
}
} else {
$stats['failed']++;
$stats['errors'][] = "Ошибка проверки $key: " . $e->getMessage();
}
}
} else {
// Нет версий - файл удален безвозвратно
$stats['failed']++;
$stats['errors'][] = "Не найдена версия для восстановления: $key (файл удален безвозвратно)";
}
} catch (\Aws\Exception\AwsException $e) {
$stats['failed']++;
$stats['errors'][] = "Ошибка проверки версий для $key: " . $e->getMessage();
}
} else {
// Dry-run режим - проверяем наличие версий
try {
$versionsList = $s3Client->listObjectVersions([
'Bucket' => $s3Bucket,
'Prefix' => $key,
'MaxKeys' => 1
]);
if (isset($versionsList['Versions']) && !empty($versionsList['Versions'])) {
$stats['restored']++;
} else {
$stats['failed']++;
}
$processedKeys[$key] = true;
if (($stats['restored'] + $stats['failed']) % 100 == 0) {
echo "Проверено: " . ($stats['restored'] + $stats['failed']) . " файлов...\r";
}
} catch (\Aws\Exception\AwsException $e) {
$stats['failed']++;
$processedKeys[$key] = true;
}
}
} catch (\Aws\Exception\AwsException $e) {
$stats['failed']++;
$stats['errors'][] = "Ошибка удаления delete marker для $key: " . $e->getMessage();
if (count($stats['errors']) <= 10) {
echo "\n Ошибка: {$stats['errors'][count($stats['errors']) - 1]}\n";
}
}
}
}
$isTruncated = isset($versions['IsTruncated']) && $versions['IsTruncated'];
$continuationToken = isset($versions['NextContinuationToken']) ? $versions['NextContinuationToken'] : null;
if (!$isTruncated) {
break;
}
} catch (\Aws\Exception\AwsException $e) {
echo "\n❌ Ошибка при обработке страницы: " . $e->getMessage() . "\n";
break;
}
}
echo "\n\n";
echo str_repeat("=", 80) . "\n";
echo "ИТОГОВЫЙ ОТЧЕТ:\n\n";
echo "Всего найдено delete markers: {$stats['total_markers']}\n";
echo "Восстановлено файлов: {$stats['restored']}\n";
echo "Ошибок: {$stats['failed']}\n";
echo "Пропущено (папки): {$stats['skipped']}\n\n";
if (!empty($stats['errors']) && count($stats['errors']) <= 20) {
echo "Ошибки (первые " . count($stats['errors']) . "):\n";
foreach ($stats['errors'] as $error) {
echo " - $error\n";
}
echo "\n";
} elseif (!empty($stats['errors'])) {
echo "Всего ошибок: " . count($stats['errors']) . " (показаны первые 20)\n";
foreach (array_slice($stats['errors'], 0, 20) as $error) {
echo " - $error\n";
}
echo "\n";
}
if ($dryRun) {
echo "⚠️ Это был режим проверки. Для реального восстановления запустите:\n";
echo " php restore_all_deleted_files.php\n\n";
} else {
echo "✅ Восстановление завершено!\n";
echo " Проверьте доступность файлов в интерфейсе CRM\n\n";
}
// Сохраняем статистику в файл
$logFile = '/var/www/fastuser/data/www/crm.clientright.ru/restore_log_' . date('Y-m-d_H-i-s') . '.json';
file_put_contents($logFile, json_encode($stats, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
echo "📝 Лог сохранен в: $logFile\n";
} catch (Exception $e) {
echo "❌ КРИТИЧЕСКАЯ ОШИБКА: " . $e->getMessage() . "\n";
echo "Trace: " . $e->getTraceAsString() . "\n";
}

163
restore_deleted_files.php Normal file
View File

@@ -0,0 +1,163 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
require_once '/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php';
require_once '/var/www/fastuser/data/www/crm.clientright.ru/config.inc.php';
$config = require '/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php';
$docIds = [386869, 394973];
$s3Bucket = $config['s3']['bucket'];
echo "Восстановление удаленных файлов из S3\n";
echo str_repeat("=", 80) . "\n\n";
try {
// Инициализация S3 клиента
$s3Client = new \Aws\S3\S3Client([
'version' => 'latest',
'region' => $config['s3']['region'],
'endpoint' => $config['s3']['endpoint'],
'use_path_style_endpoint' => true,
'credentials' => [
'key' => $config['s3']['key'],
'secret' => $config['s3']['secret'],
],
'suppress_php_deprecation_warning' => true
]);
foreach ($docIds as $docId) {
echo "Восстановление файла для документа $docId:\n";
echo str_repeat("-", 80) . "\n";
// Получаем информацию о документе
$pdo = new PDO(
"mysql:host={$dbconfig['db_server']};port=3306;dbname={$dbconfig['db_name']};charset=utf8",
$dbconfig['db_username'],
$dbconfig['db_password'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$stmt = $pdo->prepare('SELECT notesid, title, s3_key, s3_etag FROM vtiger_notes WHERE notesid = ?');
$stmt->execute([$docId]);
$doc = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$doc) {
echo " Документ не найден в БД\n\n";
continue;
}
$s3Key = $doc['s3_key'];
echo " Название: {$doc['title']}\n";
echo " Путь: $s3Key\n\n";
// Получаем delete markers
try {
$versions = $s3Client->listObjectVersions([
'Bucket' => $s3Bucket,
'Prefix' => $s3Key,
'MaxKeys' => 100
]);
if (isset($versions['DeleteMarkers']) && !empty($versions['DeleteMarkers'])) {
echo " Найдено delete markers: " . count($versions['DeleteMarkers']) . "\n";
// Удаляем все delete markers (самый новый будет удален последним)
// Сортируем по дате удаления (от новых к старым)
usort($versions['DeleteMarkers'], function($a, $b) {
$dateA = isset($a['LastModified']) ? strtotime($a['LastModified']) : 0;
$dateB = isset($b['LastModified']) ? strtotime($b['LastModified']) : 0;
return $dateB - $dateA; // От новых к старым
});
foreach ($versions['DeleteMarkers'] as $marker) {
$versionId = $marker['VersionId'];
$deleteDate = $marker['LastModified'] ?? 'не указана';
echo " Удаление delete marker: VersionId=$versionId (дата удаления: $deleteDate)\n";
try {
$s3Client->deleteObject([
'Bucket' => $s3Bucket,
'Key' => $s3Key,
'VersionId' => $versionId
]);
echo " ✅ Delete marker удален\n";
} catch (\Aws\Exception\AwsException $e) {
echo " ❌ Ошибка при удалении delete marker: " . $e->getMessage() . "\n";
}
}
// Проверяем, восстановился ли файл
echo "\n Проверка восстановления файла...\n";
try {
$headResult = $s3Client->headObject([
'Bucket' => $s3Bucket,
'Key' => $s3Key
]);
echo " ✅ Файл успешно восстановлен!\n";
echo " Размер: " . number_format($headResult['ContentLength'] / 1024, 2) . " KB\n";
echo " ETag: " . (isset($headResult['ETag']) ? trim($headResult['ETag'], '"') : 'не указан') . "\n";
echo " Дата: " . ($headResult['LastModified'] ?? 'не указана') . "\n";
} catch (\Aws\Exception\AwsException $e) {
if ($e->getAwsErrorCode() == 'NotFound') {
echo " ⚠️ Файл все еще не доступен (возможно, нужно восстановить конкретную версию)\n";
// Пробуем восстановить последнюю версию напрямую
if (isset($versions['Versions']) && !empty($versions['Versions'])) {
$latestVersion = $versions['Versions'][0]; // Первая версия - самая новая
$versionId = $latestVersion['VersionId'];
echo " Попытка восстановить версию: VersionId=$versionId\n";
try {
// Копируем версию в текущий объект
$s3Client->copyObject([
'Bucket' => $s3Bucket,
'CopySource' => $s3Bucket . '/' . $s3Key . '?versionId=' . $versionId,
'Key' => $s3Key
]);
echo " ✅ Версия восстановлена\n";
// Проверяем еще раз
$headResult = $s3Client->headObject([
'Bucket' => $s3Bucket,
'Key' => $s3Key
]);
echo " ✅ Файл восстановлен и доступен!\n";
echo " Размер: " . number_format($headResult['ContentLength'] / 1024, 2) . " KB\n";
} catch (\Aws\Exception\AwsException $e) {
echo " ❌ Ошибка при восстановлении версии: " . $e->getMessage() . "\n";
}
}
} else {
echo " ❌ Ошибка при проверке: " . $e->getMessage() . "\n";
}
}
} else {
echo " ❌ Delete markers не найдены\n";
}
} catch (\Aws\Exception\AwsException $e) {
echo " ❌ Ошибка при работе с версиями: " . $e->getMessage() . "\n";
}
echo "\n";
}
echo str_repeat("=", 80) . "\n";
echo "Восстановление завершено!\n";
echo "Проверьте доступность файлов в интерфейсе CRM\n";
} catch (Exception $e) {
echo "ОШИБКА: " . $e->getMessage() . "\n";
echo "Trace: " . $e->getTraceAsString() . "\n";
}

Some files were not shown because too many files have changed in this diff Show More