diff --git a/languages/en_us/OnlyOfficeTemplates.php b/languages/en_us/OnlyOfficeTemplates.php
new file mode 100644
index 00000000..1eae169d
--- /dev/null
+++ b/languages/en_us/OnlyOfficeTemplates.php
@@ -0,0 +1,24 @@
+ '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',
+];
diff --git a/languages/ru_ru/OnlyOfficeTemplates.php b/languages/ru_ru/OnlyOfficeTemplates.php
new file mode 100644
index 00000000..318218f9
--- /dev/null
+++ b/languages/ru_ru/OnlyOfficeTemplates.php
@@ -0,0 +1,24 @@
+ 'Шаблоны документов',
+ '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' => 'Загрузить файл',
+];
diff --git a/layouts/v7/modules/OnlyOfficeTemplates/AddTemplate.tpl b/layouts/v7/modules/OnlyOfficeTemplates/AddTemplate.tpl
new file mode 100644
index 00000000..958bfd85
--- /dev/null
+++ b/layouts/v7/modules/OnlyOfficeTemplates/AddTemplate.tpl
@@ -0,0 +1,54 @@
+{strip}
+
+
+
+
{vtranslate('LBL_OOT_ADD_TEMPLATE', $MODULE_NAME)}
+
+
+
+
+
+
+ {if $ERROR_MSG}
+
{$ERROR_MSG|escape}
+ {/if}
+
+
+
+
+{/strip}
diff --git a/layouts/v7/modules/OnlyOfficeTemplates/Edit.tpl b/layouts/v7/modules/OnlyOfficeTemplates/Edit.tpl
new file mode 100644
index 00000000..30021093
--- /dev/null
+++ b/layouts/v7/modules/OnlyOfficeTemplates/Edit.tpl
@@ -0,0 +1,76 @@
+{strip}
+
+
+
+
{if $TEMPLATE.id gt 0}{vtranslate('LBL_OOT_EDIT_TEMPLATE', $MODULE_NAME)}{else}{vtranslate('LBL_OOT_ADD_TEMPLATE', $MODULE_NAME)}{/if}
+ {if $ERROR_MSG}
{$ERROR_MSG|escape}
{/if}
+
+
+
+
+
+{/strip}
diff --git a/layouts/v7/modules/OnlyOfficeTemplates/GetTemplateActions.tpl b/layouts/v7/modules/OnlyOfficeTemplates/GetTemplateActions.tpl
new file mode 100644
index 00000000..f3582368
--- /dev/null
+++ b/layouts/v7/modules/OnlyOfficeTemplates/GetTemplateActions.tpl
@@ -0,0 +1,65 @@
+{*
+ OnlyOfficeTemplates widget: template list, format (PDF/DOCX), Download / Save to Documents
+*}
+{if $CRM_TEMPLATES_EXIST eq 0}
+
+
+
+
+
+
+
+
+
+
+
+
+{else}
+ {vtranslate('LBL_OOT_NO_TEMPLATES','OnlyOfficeTemplates')}
+{/if}
+{if $CRM_TEMPLATES_EXIST eq 0}
+
+{/if}
diff --git a/layouts/v7/modules/OnlyOfficeTemplates/List.tpl b/layouts/v7/modules/OnlyOfficeTemplates/List.tpl
new file mode 100644
index 00000000..5bee1030
--- /dev/null
+++ b/layouts/v7/modules/OnlyOfficeTemplates/List.tpl
@@ -0,0 +1,47 @@
+{strip}
+
+
+
+
+
+ {if empty($TEMPLATES)}
+
{vtranslate('LBL_OOT_EMPTY_LIST', $MODULE_NAME)}
+ {else}
+
+
+
+ | {vtranslate('LBL_OOT_NAME', $MODULE_NAME)} |
+ {vtranslate('LBL_OOT_MODULE', $MODULE_NAME)} |
+ {vtranslate('LBL_OOT_FILE', $MODULE_NAME)} |
+ {vtranslate('LBL_OOT_CREATED_AT', $MODULE_NAME)} |
+
+
+
+ {foreach from=$TEMPLATES item=TPL}
+
+ |
+ {$TPL.name|escape}
+ |
+ {$TPL.module|escape} |
+ {$TPL.file_name|escape} |
+ {$TPL.created_at|escape} |
+
+ {/foreach}
+
+
+ {/if}
+
+
+
+{/strip}
diff --git a/modules/OnlyOfficeTemplates/DESCRIPTION.md b/modules/OnlyOfficeTemplates/DESCRIPTION.md
new file mode 100644
index 00000000..c30995be
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/DESCRIPTION.md
@@ -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 или внешний конфиг; модуль портативный.
diff --git a/modules/OnlyOfficeTemplates/INSTALL.md b/modules/OnlyOfficeTemplates/INSTALL.md
new file mode 100644
index 00000000..6e297ba6
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/INSTALL.md
@@ -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 | Префикс пути при сохранении в Документы |
diff --git a/modules/OnlyOfficeTemplates/OnlyOfficeTemplates.php b/modules/OnlyOfficeTemplates/OnlyOfficeTemplates.php
new file mode 100644
index 00000000..cfea35af
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/OnlyOfficeTemplates.php
@@ -0,0 +1,107 @@
+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');
+ }
+ }
+ }
+}
diff --git a/modules/OnlyOfficeTemplates/README.md b/modules/OnlyOfficeTemplates/README.md
new file mode 100644
index 00000000..49489bb3
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/README.md
@@ -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 или импорт пакета).
diff --git a/modules/OnlyOfficeTemplates/actions/CreateDraft.php b/modules/OnlyOfficeTemplates/actions/CreateDraft.php
new file mode 100644
index 00000000..5716bd96
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/actions/CreateDraft.php
@@ -0,0 +1,40 @@
+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');
+ }
+}
diff --git a/modules/OnlyOfficeTemplates/actions/CreateFromTemplate.php b/modules/OnlyOfficeTemplates/actions/CreateFromTemplate.php
new file mode 100644
index 00000000..3210eb75
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/actions/CreateFromTemplate.php
@@ -0,0 +1,185 @@
+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);
+ }
+ }
+}
diff --git a/modules/OnlyOfficeTemplates/actions/GetDocument.php b/modules/OnlyOfficeTemplates/actions/GetDocument.php
new file mode 100644
index 00000000..fda2b09c
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/actions/GetDocument.php
@@ -0,0 +1,96 @@
+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);
+ }
+ }
+}
diff --git a/modules/OnlyOfficeTemplates/actions/Install.php b/modules/OnlyOfficeTemplates/actions/Install.php
new file mode 100644
index 00000000..a4162ce0
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/actions/Install.php
@@ -0,0 +1,41 @@
+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;
+ }
+}
diff --git a/modules/OnlyOfficeTemplates/actions/OnlyOfficeCallback.php b/modules/OnlyOfficeTemplates/actions/OnlyOfficeCallback.php
new file mode 100644
index 00000000..dc898379
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/actions/OnlyOfficeCallback.php
@@ -0,0 +1,78 @@
+ 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]);
+ }
+}
diff --git a/modules/OnlyOfficeTemplates/actions/SaveMetadata.php b/modules/OnlyOfficeTemplates/actions/SaveMetadata.php
new file mode 100644
index 00000000..0df7b4f2
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/actions/SaveMetadata.php
@@ -0,0 +1,50 @@
+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');
+ }
+ }
+}
diff --git a/modules/OnlyOfficeTemplates/actions/UploadTemplate.php b/modules/OnlyOfficeTemplates/actions/UploadTemplate.php
new file mode 100644
index 00000000..dcfde07d
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/actions/UploadTemplate.php
@@ -0,0 +1,81 @@
+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]);
+ }
+}
diff --git a/modules/OnlyOfficeTemplates/config.php b/modules/OnlyOfficeTemplates/config.php
new file mode 100644
index 00000000..6dbdeca7
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/config.php
@@ -0,0 +1,91 @@
+ $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;
+}
diff --git a/modules/OnlyOfficeTemplates/install.php b/modules/OnlyOfficeTemplates/install.php
new file mode 100644
index 00000000..98b076b9
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/install.php
@@ -0,0 +1,58 @@
+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;
+}
diff --git a/modules/OnlyOfficeTemplates/manifest.xml b/modules/OnlyOfficeTemplates/manifest.xml
new file mode 100644
index 00000000..ba637977
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/manifest.xml
@@ -0,0 +1,39 @@
+
+
+ extension
+ OnlyOfficeTemplates
+
+ Tools
+ 1.0
+
+ 7.*
+
+
+
+ vtiger_oot_templates
+
+
+
+ vtiger_oot_templates_seq
+
+
+
+
diff --git a/modules/OnlyOfficeTemplates/models/OnlyOfficeTemplates_Model.php b/modules/OnlyOfficeTemplates/models/OnlyOfficeTemplates_Model.php
new file mode 100644
index 00000000..0e81dcb4
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/models/OnlyOfficeTemplates_Model.php
@@ -0,0 +1,124 @@
+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');
+ }
+}
diff --git a/modules/OnlyOfficeTemplates/resources/ConvertService.php b/modules/OnlyOfficeTemplates/resources/ConvertService.php
new file mode 100644
index 00000000..3cdf6ffc
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/resources/ConvertService.php
@@ -0,0 +1,78 @@
+ 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];
+ }
+}
diff --git a/modules/OnlyOfficeTemplates/resources/MergeService.php b/modules/OnlyOfficeTemplates/resources/MergeService.php
new file mode 100644
index 00000000..576eedd3
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/resources/MergeService.php
@@ -0,0 +1,172 @@
+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;
+ }
+}
diff --git a/modules/OnlyOfficeTemplates/resources/S3Helper.php b/modules/OnlyOfficeTemplates/resources/S3Helper.php
new file mode 100644
index 00000000..9c5fb10a
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/resources/S3Helper.php
@@ -0,0 +1,120 @@
+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);
+ }
+}
diff --git a/modules/OnlyOfficeTemplates/schema.xml b/modules/OnlyOfficeTemplates/schema.xml
new file mode 100644
index 00000000..51b86566
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/schema.xml
@@ -0,0 +1,31 @@
+
+
+
+
+ vtiger_oot_templates
+
+
+
+ vtiger_oot_templates_seq
+
+
+
+
diff --git a/modules/OnlyOfficeTemplates/views/AddTemplate.php b/modules/OnlyOfficeTemplates/views/AddTemplate.php
new file mode 100644
index 00000000..f73c786a
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/views/AddTemplate.php
@@ -0,0 +1,39 @@
+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);
+ }
+}
diff --git a/modules/OnlyOfficeTemplates/views/Edit.php b/modules/OnlyOfficeTemplates/views/Edit.php
new file mode 100644
index 00000000..7d76f6e9
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/views/Edit.php
@@ -0,0 +1,109 @@
+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;
+ }
+}
diff --git a/modules/OnlyOfficeTemplates/views/GetTemplateActions.php b/modules/OnlyOfficeTemplates/views/GetTemplateActions.php
new file mode 100644
index 00000000..d8f13dcb
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/views/GetTemplateActions.php
@@ -0,0 +1,48 @@
+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');
+ }
+}
diff --git a/modules/OnlyOfficeTemplates/views/List.php b/modules/OnlyOfficeTemplates/views/List.php
new file mode 100644
index 00000000..550d969c
--- /dev/null
+++ b/modules/OnlyOfficeTemplates/views/List.php
@@ -0,0 +1,41 @@
+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);
+ }
+}