Всем привет, я Сергей — ведущий программист в e-commerce агентстве KISLOROD.
Чаще всего я решаю задачи разработки для сайтов на 1С-Битрикс, но также иногда работаю с Битрикс24. Сегодня хочу рассказать о модульной доработке Б24 в одном из кейсов.
Мы занимаемся доработками Битрикс24 уже несколько лет и за это время, разумеется, обобщили свой опыт. И обратили внимание, что наши клиенты, независимо от сферы их деятельности, сталкиваются с одинаковыми проблемами:
-
При большом потоке обращений менеджеры пропускают звонки, а значит, и упускают возможных клиентов.
-
Проекты имеют проблемы безопасности внешних интеграций с партнерами по REST API.
-
Разработчики сталкиваются с неудобствами при хранении нестандартных данных, под которые требуется кастомизировать карточку сделки, — создавать сложносоставные поля.
Очевидно, что заказчики хотят:
-
автоматизировать рабочие процессы;
-
уменьшить процент потерянных сделок;
-
увеличить прибыль за счет роста количества успешных сделок.
При этом заказчики больше доверяют готовым и проверенным на практике решениям, поскольку им нужен результаwт и как можно быстрее.
Со своей стороны разработчики сталкиваются со следующими проблемами:
-
часто для коробочной версии Б24 мало документации в открытых источниках;
-
мало опытных наставников, которые имеют практический опыт и могут подсказать решение.
Поэтому нередко разработчики просто не хотят рисковать и тратить время и силы на изучение Б24. Время может быть потрачено впустую: квалификация не вырастет и заказов не будет, а доходы снизятся.
Для себя этот вопрос наша компания решила просто — мы используем для доработок Б24 собственные модульные решения. Таким образом мы снижаем риски для разработчиков, упрощаем им задачу, а заказчикам гарантируем результат.
Модули в Битрикс24
Модуль — это объемный блок кода, который отвечает за определенную функциональность продукта.
Когда модуль устанавливается на портал, он автоматически распаковывается и совершает действия, которые прописали разработчики в коде, — а новый функционал сразу появляется у всех пользователей, по алгоритму прав доступа, заложенному в модуле. При этом работа модуля не затрагивает ядро системы и настройки.
Таким образом, модульный подход быстрее и безопаснее, чем не дай Бог файловая доработка ядра, компонентов или шаблонов Б24.
И вот почему:
-
Все файлы модуля хранятся в отдельной папке и имеют собственное пространство имен.
-
Модуль легко установить или удалить нажатием одной кнопки, при этом не затрагивая настройки на портале.
-
Модули ускоряют и упрощают труд разработчика. «Скелет» модуля можно использовать на многих проектах, так как алгоритмы бывают очень схожи.
-
Обновление ядра системы не влияет на работу модуля: доработки не удаляются, как это происходит при переписывании ядра, шаблонов и компонентов.
В итоге, все глобальные изменения в Б24 наша команда производит через модули.
Для разработки модулей требуется знание их архитектуры и роли каждого файла.
О модульной структуре Bitrix Framework можно прочитать здесь. Модули для 1С-Битрикс: Управление сайтом (БУС) и Битрикс24 имеют много общего. Именно поэтому переход к разработке на Б24 оказался не таким сложным.
Я уже не раз успешно применял модули в своих проектах и готов поделиться опытом.
Для всех доработок мы используем один базовый модуль, на котором и рассмотрим практические примеры. Эти примеры взяты из опыта внедрения аналогичных задач для разных клиентов в разное время, и, разумеется, все данные в примерах выдуманы.
Для примера пусть название нашего модуля будет — o2k.d7 «Практический опыт доработки Битрикс24 для бизнеса». В этой статье я не буду рассматривать базовый установщик модулей Битрикс — он неинтересен и идентичен БУС.
Предлагаю сосредоточиться на первом примере, а в следующей публикации расскажу еще о нескольких интересных кейсах для коробочной версии Битрикс24.
Практическое внедрение в CRM. Сущности «Контакт», «Компания» и «Сделка»
Формулировка задачи
Проблема клиента:
-
Менеджеры теряют сделки и упускают покупателей.
Задачи:
-
Создать отчет (грид, таблицу), в котором будут отражены сделки клиентов с определенными статусами и группировкой по ответственным менеджерам.
-
Разработать удобную и быструю систему перехода в клиента/менеджера/сделку.
-
Предусмотреть поиск и быструю фильтрацию данных по отчету.
Решение
Для примера возьмем сущность CRM «Сделка».
Рассмотрим на практике следующие возможности:
-
Вывод сделок в собственный грид (таблицу).
-
Вывод фильтра для грида по кастомным полям.
-
Группировка сущностей по полю в гриде.
-
Рассылка уведомлений пользователям на портале.
-
Вывод настроек нашего модуля в админке Битрикс24.
Итак, начнем с классической структуры модуля, которая описана в документации, и будем дополнять ее по мере необходимости.
Структура примерно следующая:

Вывод сделок в собственный грид (таблицу)
Для того чтобы можно было работать с данными, и не перегружать CRM — будем хранить информацию в ORM-таблице, заодно и посмотрим, как с ними работать.
Почитать об ORM можно здесь→
Чтобы хранить в таблице значение в нужном формате, допустим, множественное, такое как список сделок клиента, мы будем использовать кастомное поле в нужном формате сериализованного массива, и заодно рассмотрим, как создаются такие кастомные поля.
Для того чтобы создать свое поле в ORM, нужно наследоваться от подходящей сущности.
Так как мы собираемся хранить строку, то логично будет наследоваться от Bitrix\Main\Entity\TextField.
Создадим свой класс DealField.
Конечно, можно наследоваться от \Bitrix\Main\ScalarField, но в нем ограничение в 255 символов, а у нас может быть и больше.
Подробнее прочитать можно здесь→
Так как мы будем использовать сразу несколько кастомных полей, и значения для вывода в таблицу нужно будет форматировать (выводить в красивом виде) — заведем два метода для работы с данными, а именно:
-
getHTMLValues — вывод в форматированном виде ссылок на сделки (непосредственно для вывода в грид).
-
getDealStages — для получения форматированного вывода стадии сделок.
Заведомо зная, что у нас есть права на просмотр сделок между сотрудниками компании, — добавим protected-переменную фильтра для использования ее при выборке.
protected $filter = [];
И при создании кастомного поля, передадим в нее фильтр, чтобы у нас выводились все сделки, независимо от того какие есть права на просмотр сделок у пользователя.
'filter' => [ 'CHECK_PERMISSIONS' => 'N' ],
Итоговый класс с ORM-таблицей выглядит так:
Скрытый текст
namespace o2k\d7\Tables; use Bitrix\Main\Entity; use Bitrix\Main\Localization\Loc; use o2k\d7\Entities; use o2k\d7\Conf\Settings; Loc::loadMessages( __FILE__ ); class TestTable extends Entity\DataManager { public static function getTableName() { return 'o2k_test_table'; } public static function getField(string $code) { $result = false; if(!empty($code)) { $tableMap = static::getMap(); foreach($tableMap as $field) { if($field->getName() === $code) { $result = $field; } else { continue; } } } return $result; } public static function getMap() { return [ 'ID' => new Entity\IntegerField('ID', [ 'column_name' => 'ID', 'primary' => true, 'autocomplete' => true, 'title' => 'ID' ]), 'RESPONSIBLE' => new Entities\UserField('RESPONSIBLE', [ 'column_name' => 'RESPONSIBLE', 'title' => Loc::getMessage(Settings::$langPrefix.'_RESPONSIBLE'), 'filter' => [ '=ACTIVE' => 'Y' ], 'required' => false ]), 'RESPONSIBLE_REF' => new Entity\ReferenceField('RESPONSIBLE_REF', 'Bitrix\Main\UserTable', ['=this.RESPONSIBLE_ID' => 'ref.ID'], ['join_type' => 'LEFT'] ), 'DEALS' => new Entities\DealField('DEALS', [ 'column_name' => 'DEALS', 'title' => Loc::getMessage(Settings::$langPrefix.'_DEALS'), 'filter' => ['CHECK_PERMISSIONS' => 'N'], 'required' => false, 'save_data_modification' => function() { return [ function($value){ return serialize($value); } ]; }, 'fetch_data_modification' => function() { return [ function($value){ return unserialize($value); } ]; } ]) ]; } }
Здесь в карте сущностей видим следующие поля:
-
ID — порядковый номер в таблице, он же первичный ключ.
-
RESPONSIBLE, RESPONSIBLE_REF — связь по ID ответственного за сделку с фильтром по активности.
-
DEALS — список сделок в сериализованном массиве.
Чтобы организовать хранение данных определенного кастомного формата, в поле есть возможность модификации данных при записи и при выводе значений.
За это отвечают функции:
-
save_data_modification;
-
fetch_data_modification.
Подробнее про них можно прочитать здесь→
Так как в дальнейшем нам нужно будет получать тип и некоторые значения поля, то создадим поиск по полям (филдам). За это будет отвечать метод getField.
По входу у него — код поля, возвращает он — объект филда. А также наш созданный класс для работы с полем — DealField.
namespace o2k\d7\Entities; use Bitrix\Main\Loader, Bitrix\Main\Localization\Loc, Bitrix\Main\Entity\TextField, Bitrix\Main\Config\Option, Bitrix\Crm\DealTable, Bitrix\Crm\StatusTable, Bitrix\Main\ORM, Bitrix\Iblock\ORM as IblockORM, o2k\d7\Conf\Settings; Loc::loadMessages( __FILE__ ); class DealField extends TextField { protected $filter = []; public function __construct(string $name, array $params = []) { parent::__construct($name, $params); if(is_array($params['filter']) && count($params['filter']) > 0) { $this->filter = $params['filter']; } } public static function getHTMLValues(array $id = [], int $ownerId = 0): string { $result = ''; if(Loader::includeModule(Settings::$crmMid) && (is_array($id) && count($id) > 0 ) || $ownerId > 0) { $getDealPathTemplate = Option::get(Settings::$crmMid, 'path_to_deal_details'); $arDealStages = self::getDealStages(); if(!empty($id) && count($id) > 0) { $this->filter['=ID'] = $id; } else { $this->filter['=CONTACT_ID'] = $ownerId; } $query = new IblockORM\Query(DealTable::getEntity()); $query->setSelect([ 'ID', 'TITLE', 'STAGE_ID', 'ASSIGNED_BY_ID' ]); $query->setOrder([ 'ID' => 'ASC' ]); $query->setFilter($this->filter); $arDeals = ORM\Query\QueryHelper::decompose($query); if(is_object($arDeals) && count($arDeals) > 0) { foreach($arDeals as $deal) { $deal = $deal->collectValues(ORM\Objectify\Values::ALL, ORM\Fields\FieldTypeMask::ALL, true); $stage = ''; if(!empty($deal['STAGE_ID'])) { $dealStatusInfo = $arDealStages[$deal['STAGE_ID']]; if(!empty($dealStatusInfo['NAME_INIT'])) { $stage = (!empty($dealStatusInfo['COLOR'])) ? '<span style="color:'.trim($dealStatusInfo['COLOR']).'">'.trim($dealStatusInfo['NAME_INIT']).'</span>' : trim($dealStatusInfo['NAME_INIT']); } elseif(!empty($dealStatusInfo['NAME'])) { $stage = (!empty($dealStatusInfo['COLOR'])) ? '<span style="color:'.trim($dealStatusInfo['COLOR']).'">'.trim($dealStatusInfo['NAME']).'</span>' : trim($dealStatusInfo['NAME']); } } $result .= '<a href="'.str_replace('#deal_id#', $deal['ID'], $getDealPathTemplate).'">'.trim($deal['TITLE']).'</a> '.Loc::getMessage(Settings::$langPrefix.'_STAGE', ['#STAGE#' => $stage])."</br>"; } } } return $result; } public static function getDealStages(): array { $result = []; $query = new IblockORM\Query(StatusTable::getEntity()); $query->setSelect([ 'STATUS_ID', 'NAME', 'NAME_INIT', 'COLOR' ]); $query->setFilter([ 'ENTITY_ID' => Settings::$stageEntityId ]); $arStatuses = ORM\Query\QueryHelper::decompose($query); if(is_object($arStatuses) && count($arStatuses) > 0) { foreach($arStatuses as $status) { $status = $status->collectValues(ORM\Objectify\Values::ALL, ORM\Fields\FieldTypeMask::ALL, true); $result[$status['STATUS_ID']]['NAME'] = $status['NAME']; $result[$status['STATUS_ID']]['NAME_INIT'] = !empty($status['NAME_INIT']) ? $status['NAME_INIT'] : $status['NAME']; $result[$status['STATUS_ID']]['COLOR'] = $status['COLOR']; } } return $result; } }
Далее видим следующие методы.
Метод __construct
-
__construct — конструктор класса, в котором заложена инициализация фильтра.
Метод getHTMLValues
-
getHTMLValues — непосредственно формирование html для дальнейшего вывода в грид со списком сделок.
В этом методе есть несколько ключевых моментов.
-
В настройках модуля есть путь к детальной странице сделки. Хранится он в b_option и называется path_to_deal_details и позволяет избежать проблем, если вдруг путь к сделкам поменяется. Воспользуемся им для построения пути к открытию детального слайдера сделки.
-
ORM D7 запросы. Прочитать подробнее можно здесь→
В частности все спотыкаются о «Множественность» в результатах выборки — если в товаре есть множественное свойство, то товар будет в выборке обозначен дважды.
Избежать это можно следующей конструкцией.
$query = new IblockORM\Query(DealTable::getEntity()); $query->setSelect([ 'ID', 'TITLE', 'STAGE_ID', 'ASSIGNED_BY_ID' ]); $query->setOrder([ 'ID' => 'ASC' ]); $query->setFilter($this->filter); $arDeals = ORM\Query\QueryHelper::decompose($query); if(is_object($arDeals) && count($arDeals) > 0) { foreach($arDeals as $deal) { $deal = $deal->collectValues(ORM\Objectify\Values::ALL, ORM\Fields\FieldTypeMask::ALL, true);
ORM\Query\QueryHelper::decompose — это, по сути, фетч.
Результат в виде объекта.
$deal->collectValues(ORM\Objectify\Values::ALL,ORM\Fields\FieldTypeMask::ALL,true);
Где collectValues используется для получения всех значений объекта в виде массива.
Почитать про него можно здесь→
3. Для запроса Bitrix\Iblock\ORM\Query требуется Entity таблицы.
Откуда его взять если это наша вновь созданная таблица? А все проще чем кажется. У каждой созданной ORM-таблицы есть свой Entity.
Получить Entity, допустим, для таблицы со стандартными сделками, можно следующим образом:
DealTable::getEntity()
Метод getDealStages
-
getDealStages — метод выборки стадий сделки.
Вы, наверное, заметили, что в коде используется класс o2k\d7\Conf\Settings — это конфигурационный файл, который создан для удобства, т. к. в нескольких местах будут использоваться его конфигурационные параметры.
Выглядит он следующим образом:
namespace o2k\d7\Conf; class Settings { public static $langPrefix = 'O2K'; public static $mid = 'o2k.d7'; public static $voximplantMid = 'voximplant'; public static $crmMid = 'crm'; public static $stageEntityId = 'DEAL_STAGE'; public static $intranetMid = 'intranet'; }
Для того чтобы классы и методы были «видны», их следует «подгрузить». За это в Битриксе отвечает файл include.php в корне модуля. У нас он выглядит следующим образом.
Скрытый текст
Bitrix\Main\Loader::registerAutoloadClasses( "o2k.d7", array( "o2k\\d7\\Conf\\Settings" => "conf.php", "o2k\\d7\\Agents\\Deals" => "agents/Deals.php", "o2k\\d7\\Tables\\TestTable" => "classes/mysql/TestTable.php", "o2k\\d7\\Entities\\UserField" => "classes/entities/UserField.php", "o2k\\d7\\Entities\\DealField" => "classes/entities/DealField.php", "o2k\\d7\\Events\\Voximplant" => "classes/events/Voximplant.php", "o2k\\d7\\Events\\CrmMenu" => "classes/events/CrmMenu.php", "o2k\\d7\\Events\\DealContextItemMenu" => "classes/events/DealContextItemMenu.php", ) );
Здесь мы используем регистратор классов для автозагрузки.
Почитать про него можно здесь→
Для актуализации и наполнения нашей таблицы будем использовать агента Битрикс. В идеале, конечно, чтобы перед каждым входом на страницу пользователя данные обновлялись. Но в таком методе есть тоже недостатки.
Решение этой задачки мы оставим вам.
А мы будем просто очищать таблицу по агенту и класть в нее данные по фильтру «Статус сделки», который будем устанавливать в настройках модуля. Для этого мы создадим простую страницу в настройках модуля с выбором такого статуса. По умолчанию за страницу с настройками в Битриксе отвечает файл модуля options.php.
В самом простом варианте он будет выглядеть так:
Скрытый текст
if(!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) die(); use Bitrix\Main\Loader, Bitrix\Main\Localization\Loc, Bitrix\Main\HttpApplication, o2k\d7\Conf\Settings, o2k\d7\Entities\DealField; Loc::loadMessages(__FILE__); if($APPLICATION->GetGroupRight(Settings::$mid)<'R') { $APPLICATION->AuthForm(Loc::getMessage('ACCESS_DENIED')); } Loader::includeModule(Settings::$mid); $request = HttpApplication::getInstance()->getContext()->getRequest(); $arDealStatuses = []; $getDealStatuses = DealField::getDealStages(); if(is_array($getDealStatuses ) && count($getDealStatuses) > 0) { foreach($getDealStatuses as $id => $status) { $arDealStatuses[$id] = $status['NAME']; } } $arMainOptions[] = Loc::getMessage(Settings::$langPrefix.'_TITLE_FILTER'); $arMainOptions[] = [ 'DEAL_STATUS_FILTER', Loc::getMessage(Settings::$langPrefix.'_FILTER_DEALS').':', '', ['multiselectbox', $arDealStatuses] ]; $arTabs = [ [ 'DIV' => 'settings', 'TAB' => Loc::getMessage(Settings::$langPrefix.'_SETTINGS'), 'TITLE' => Loc::getMessage(Settings::$langPrefix.'_SETTINGS_TITLE'), 'OPTIONS' => ((!empty($arMainOptions) && count($arMainOptions)>0) ? $arMainOptions : ['']) ], [ 'DIV' => 'rights', 'TAB' => Loc::GetMessage('MAIN_TAB_RIGHTS'), 'ICON' => 'ldap_settings', 'TITLE' => Loc::GetMessage('MAIN_TAB_TITLE_RIGHTS') ] ]; if($request->isPost() && check_bitrix_sessid()) { if(strlen($request['save'])>0) { foreach($arTabs as $arTab) { __AdmSettingsSaveOptions(Settings::$mid, $arTab['OPTIONS']); } } } $tabControl = new CAdminTabControl('tabControl', $arTabs); ?> <form method="post" action="<?=$APPLICATION->GetCurPage()?>?mid=<?=Settings::$mid?>&lang=<?=$request['lang']?>" name="<?=Settings::$mid?>_settings"> <?$tabControl->Begin();?> <?foreach($arTabs as $aTab):?> <?if($aTab['OPTIONS']):?> <?$tabControl->BeginNextTab();?> <?__AdmSettingsDrawList(Settings::$mid, $aTab['OPTIONS']);?> <?endif;?> <?endforeach;?> <?=bitrix_sessid_post(); $tabControl->Buttons(['btnApply' => false, 'btnCancel' => false, 'btnSaveAndAdd' => false, 'btnSave' => true]); ?> <?$tabControl->End();?> <input type="hidden" name="Update" value="Y" /> </form> <? if($request->isPost()) { LocalRedirect($APPLICATION->GetCurPage().'?lang='.LANGUAGE_ID.'&mid='.Settings::$mid.'&tabControl_active_tab='.urlencode($_REQUEST["tabControl_active_tab"])); }
Настройки в админке выглядят следующим образом:
Для того чтобы наполнить нашу ORM-таблицу данными, будем использовать агента Битрикс. Выглядеть он будет так.
Скрытый текст
namespace o2k\d7\Agents; use o2k\d7\Tables, o2k\d7\Conf\Settings, Bitrix\Main\ORM, Bitrix\Iblock\ORM as IblockORM, Bitrix\Main\Application, Bitrix\Main\Config\Option, Bitrix\Crm\DealTable; class Deals { public static function runActualize() { self::actualize(); return __METHOD__ . '();'; } private static function actualize() { Application::getConnection(Tables\TestTable::getConnectionName())-> queryExecute('TRUNCATE TABLE '.Tables\TestTable::getTableName()); $stageParam = Option::get(Settings::$mid, 'DEAL_STATUS_FILTER'); $query = new IblockORM\Query(DealTable::getEntity()); $query->setSelect([ 'ID', 'STAGE_ID', 'ASSIGNED_BY_ID' ]); $query->setOrder([ 'ID' => 'ASC' ]); $query->setFilter([ 'STAGE_ID' => $stageParam ]); $arDeals = ORM\Query\QueryHelper::decompose($query); $multiArray = []; if(is_object($arDeals) && count($arDeals) > 0) { foreach($arDeals as $deal) { $deal = $deal->collectValues(ORM\Objectify\Values::ALL, ORM\Fields\FieldTypeMask::ALL, true); $multiArray[$deal['ASSIGNED_BY_ID']]['RESPONSIBLE'] = $deal['ASSIGNED_BY_ID']; $multiArray[$deal['ASSIGNED_BY_ID']]['CRM_DEALS'][] = $deal['ID']; } $success = Tables\TestTable::addMulti($multiArray); if(!$success->isSuccess()) { var_dump($result->getErrorMessages()); } } } }
Здесь мы очищаем нашу ORM-таблицу, а затем добавляем в нее все сделки в статусе, который выбрали в настройках модуля.
После отработки агента данные в таблице выглядят следующим образом.
Как видим, наши кастомные поля работают и корректно добавляют ID сделок по ответственным менеджерам.
Компонент для работы с модулем
Для работы с ORM-таблицей, в частности, для вывода и фильтрации данных, рассмотрим простейший компонент, который будет выводить данные и фильтр по ORM-таблице, используя функции форматирования филдов, созданные ранее.
Давайте же рассмотрим файл class.php компонента o2k.d7.
Скрытый текст
if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) die(); use o2k\d7\Tables, o2k\d7\Conf\Settings, o2k\d7\Entities, Bitrix\Main\Entity, Bitrix\Main\ORM, Bitrix\Iblock\ORM as IblockORM, Bitrix\Main\Application, Bitrix\Main\Grid\Options as GridOptions, Bitrix\Main\UI\Filter\Options as FilterOptions, Bitrix\Main\UI\PageNavigation; class Co2kTestComponent extends CBitrixComponent { protected $request; private $arTestTableMap = []; private $arDealStages = []; private $arGridSelect = []; private $filter = []; public function onPrepareComponentParams($arParams=[]) { $this->request = Application::getInstance()->getContext()->getRequest(); if(is_array($arParams['FILTER']) && count($arParams['FILTER']) > 0) { $this->$filter = $arParams['FILTER']; } $arMapFields = []; $arMapList = Tables\TestTable::getMap(); if(!empty($arMapList) && count($arMapList) > 0) { $this->arTestTableMap = $arMapList; foreach($arMapList as $mapField) { $arMapFields[] = $mapField->getName(); if( $mapField instanceof Entities\UserField || $mapField instanceof Entities\DealField || $mapField instanceof Entity\IntegerField ) { $this->arGridSelect[] = $mapField->getName(); } } } $this->arDealStages = Entities\DealField::getDealStages(); return $arParams; } public function executeComponent() { $this->initFilter(); $this->initGridColumns(); $this->getItems(); $this->includeComponentTemplate(); } private function initFilter() { $filter = []; if(!empty($this->arTestTableMap) && count($this->arTestTableMap) > 0) { foreach($this->arTestTableMap as $mapField) { if($mapField instanceof Entity\ReferenceField) { continue; } elseif($mapField instanceof Entities\UserField) { $filter[] = [ 'id' => $mapField->getName(), 'name' => $mapField->getTitle(), 'type' => 'dest_selector', 'params' => [ 'context' => strtolower($mapField->getName()), 'multiple' => 'Y', 'contextCode' => 'U', 'enableAll' => 'N', 'enableSonetgroups' => 'N', 'allowEmailInvitation' => 'N', 'allowSearchEmailUsers' => 'N', 'departmentSelectDisable' => 'Y', 'isNumeric' => 'Y', 'prefix' => 'U' ], 'default' => ($mapField->isRequired() ? true : false) ]; } elseif($mapField instanceof Entities\DealField) { $arStatuses = []; foreach($this->arDealStages as $sID => $status) { if(!is_array($status) || empty($status)) { continue; } $arStatuses[$sID] = $status['NAME_INIT']; } $filter[] = [ 'id' => $mapField->getName(), 'name' => $mapField->getTitle(), 'type' => 'list', 'items' => $arStatuses, 'params' => [ 'multiple' => 'Y' ], 'default' => ($mapField->isRequired() ? true : false) ]; } else { $filter[] = [ 'id' => $mapField->getName(), 'name' => $mapField->getTitle(), 'type' => 'text', 'default' => ($mapField->isRequired() ? true : false) ]; } } $this->arResult['FILTER_FIELDS'] = $filter; } return $this->arResult['FILTER_FIELDS']; } private function initGridColumns() { $columns = []; if(!empty($this->arTestTableMap) && count($this->arTestTableMap) > 0) { foreach($this->arTestTableMap as $mapField) { if($mapField instanceof Entity\ReferenceField) { continue; } $columns[] = [ 'id' => $mapField->getName(), 'name' => $mapField->getTitle(), 'sort' => ($mapField instanceof Entities\CrmDealsField ? false : $mapField->getName()), 'default' => true ]; } $this->arResult['COLUMNS'] = $columns; } return $this->arResult['COLUMNS']; } private function getItems() { $arFilter = []; if(is_array($this->$filter) && !empty($this->$filter)) { $arFilter = $this->$filter; } $gridOptions = new GridOptions($this->arParams['GRID_ID']); $sort = $gridOptions->GetSorting( [ 'sort' => [ 'ID' => 'DESC' ], 'vars' => [ 'by' => 'by', 'order' => 'order' ] ] ); $navParams = $gridOptions->GetNavParams(); $this->arResult['NAV_OBJECT'] = new PageNavigation('nav-grid-'.strtolower($this->arParams['GRID_ID'])); $this->arResult['NAV_OBJECT']->allowAllRecords(true)->setPageSize($navParams['nPageSize'])->initFromUri(); $filterOption = new FilterOptions($this->arParams['FILTER_ID']); $filterData = $filterOption->getFilter([]); if($filterData['FILTER_APPLIED']) { foreach($filterData as $field => $value) { $mapField = Tables\TestTable::getField(trim($field)); if($mapField instanceof Entities\DealField) { if(is_array($value)) { $arFilter['!'.$field] = false; $arFilter[$field] = ['LOGIC' => 'OR']; foreach($value as $data) { $arFilter[$field][] = "%".$data."%"; } } else { $arFilter[$field] = '%'.$value.'%'; } } elseif( $mapField instanceof Entities\UserField) { if(!empty($value) && count($value)>0) { $arFilter['!'.$field] = false; $arFilter[$field] = ['LOGIC' => 'OR']; foreach($value as $data) { $arFilter[$field][] = $data; } } } } } $rows = []; $rows = Tables\TestTable::query() ->setSelect($this->arGridSelect) ->setOrder($sort['sort']) ->setFilter($arFilter) ->setLimit($this->arResult['NAV_OBJECT']->getLimit()) ->setOffset($this->arResult['NAV_OBJECT']->getOffset()) ->exec() ->fetchAll(); if(is_array($rows) && count($rows) > 0) { $this->arResult['ITEMS'] = $this->format($rows); $this->arResult['NAV_OBJECT']->setRecordCount(count($rows)); $this->arResult['TOTAL_ROWS_COUNT'] = count($rows); } return $this->arResult['ITEMS']; } private function format($rows) { $result = []; foreach($rows as $i => $row) { foreach($row as $code => $value) { $mapField = Tables\TestTable::getField($code); if($mapField instanceof Entities\UserField) { $row[$code] = (!empty($value) && intval($value)>0) ? $mapField->getHTMLValues($value, true) : ''; } elseif($mapField instanceof Entities\DealField) { $row[$code] = (!empty($value) && count($value)>0) ? $mapField->getHTMLValues($value) : ''; } } $result[$i] = [ 'id' => $row['ID'], 'data' => $row, 'actions' => [], 'columns' => false ]; } return $result; } }
Здесь мы видим подготовку параметров с помощью метода onPrepareComponentParams, а именно:
-
берем из нашей ORM поля $arMapList = Tables\TestTable::getMap(); для дальнейшей работы с ними $this->arTestTableMap = $arMapList;
-
и поля для выборки в грид и фильтр $this->arGridSelect[] = $mapField->getName();
-
также выборку по статусам сделки $this->arDealStages = Entities\DealField::getDealStages(); для дальнейшей работы с ними.
После подготовки параметров мы видим главный метод компонента public function executeComponent(), который содержит в себе запуски основных методов по выборке и фильтрации данных и подключение шаблона.
public function executeComponent() { $this->initFilter(); $this->initGridColumns(); $this->getItems(); $this->includeComponentTemplate(); }
Думаю, тут понятно, что метод initFilter инициализирует фильтр, метод initGridColumns инициализирует колонки грида и метод getItems, который производит выборку.
Подробно останавливаться на их функционале не будем, но рассмотрим важные моменты.
Например, для проверки существования и отсечения лишних филдов используются проверки на тип филда — $mapField instanceof Entities\DealField.
Названия, ID и прочие параметры филдов можно получить соответствующими методами, например: $mapField->getTitle() — вернет имя филда.
Из интересного как раз-таки форматирование и вывод филда.
} elseif($mapField instanceof Entities\DealField) { $row[$code] = (!empty($value) && count($value)>0) ? $mapField->getHTMLValues($value) : ''; }
Здесь мы определяем наше поле (класс DealField) из ORM-таблицы и вызываем метод для форматирования данных getHTMLValues, который описан выше.
Так как, по факту, поле DEALS представляет из себя сериализованную строку с ID сделок в ORM-таблице, то возникает вопрос — как же по ней можно фильтровать?
Ответ прост — использовать для фильтра структуру следующего вида.
$arFilter['!'.$field] = false; $arFilter[$field] = ['LOGIC' => 'OR']; foreach($value as $data) { $arFilter[$field][] = "%".$data."%"; }
Да, подход не сильно эффективен. Но для некоторых задач можно использовать и его, ведь это всего лишь пример 🙂
Можно еще посмотреть в сторону «Отношений» сущностей.
Шаблон нашего компонента выглядит следующим образом.
Скрытый текст
if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) die(); use Bitrix\Main\Localization\Loc, Bitrix\Main\UI\Extension, Bitrix\Main\Page\Asset; Loc::loadMessages( __FILE__ ); Extension::load('jquery'); ?> <?if($arParams['IS_TAB'] != 'Y') {?> <?$APPLICATION->IncludeComponent( 'bitrix:crm.control_panel', '', [ 'ID' => 'O2K_TEST', 'ACTIVE_ITEM_ID' => 'O2K_TEST', ], $component );?> <?}?> <? if($arParams['IS_TAB'] !== 'Y') { $APPLICATION->SetPageProperty('BodyClass', 'no-paddings pagetitle-toolbar-field-view flexible-layout crm-pagetitle-view crm-toolbar'); $this->SetViewTarget('inside_pagetitle'); } ?> <div class="pagetitle-container pagetitle-flexible-space"> <?$APPLICATION->IncludeComponent( "bitrix:crm.interface.filter", "title", [ "FILTER_ID" => $arParams["FILTER_ID"], "GRID_ID" => $arParams["GRID_ID"], "FILTER" => $arResult["FILTER_FIELDS"], "ENABLE_LIVE_SEARCH" => false, "ENABLE_LABEL" => true, "DISABLE_SEARCH" => true ], $this->getComponent(), ["HIDE_ICONS" => "Y"] );?> </div> <?if($arParams['IS_TAB'] !== 'Y'):?> <?$this->endViewTarget();?> <?endif;?> <div style="clear: both;"></div> <?$APPLICATION->IncludeComponent( "bitrix:main.ui.grid", "", [ "GRID_ID" => $arParams["GRID_ID"], "COLUMNS" => $arResult["COLUMNS"], "ROWS" => $arResult["ITEMS"], "NAV_OBJECT" => $arResult["NAV_OBJECT"], "NAV_STRING" => true, "TOTAL_ROWS_COUNT" => $arResult["TOTAL_ROWS_COUNT"], "PAGE_SIZES" => [ ["NAME" => "10", "VALUE" => "10"], ["NAME" => "20", "VALUE" => "20"], ["NAME" => "50", "VALUE" => "50"], ["NAME" => "100", "VALUE" => "100"], ["NAME" => "200", "VALUE" => "200"], ["NAME" => "500", "VALUE" => "500"] ], "CURRENT_PAGE" => intval($arResult["NAV_OBJECT"]->getCurrentPage()), "AJAX_MODE" => "Y", "AJAX_ID" => \CAjax::getComponentID('bitrix:main.ui.grid', '.default', ''), "ENABLE_NEXT_PAGE" => true, "ACTION_PANEL" => $arResult["ACTION_PANEL"], "AJAX_OPTION_JUMP" => "Y", "SHOW_CHECK_ALL_CHECKBOXES" => (!empty($arResult["ACTION_PANEL"]) ? true : false), "SHOW_ROW_CHECKBOXES" => (!empty($arResult["ACTION_PANEL"]) ? true : false), "SHOW_ROW_ACTIONS_MENU" => true, "SHOW_GRID_SETTINGS_MENU" => true, "SHOW_NAVIGATION_PANEL" => true, "SHOW_PAGINATION" => true, "SHOW_SELECTED_COUNTER" => (!empty($arResult["ACTION_PANEL"]) ? true : false), "SHOW_TOTAL_COUNTER" => true, "SHOW_PAGESIZE" => ($arParams["IS_TAB"] != "Y") ? true : false, "SHOW_ACTION_PANEL" => (!empty($arResult["ACTION_PANEL"]) ? true : false), "ALLOW_COLUMNS_SORT" => true, "ALLOW_COLUMNS_RESIZE" => true, "ALLOW_HORIZONTAL_SCROLL" => true, "ALLOW_SORT" => true, "ALLOW_PIN_HEADER" => true, "AJAX_OPTION_HISTORY" => "N", "NAV_PARAMS" => ["SEF_MODE" => "N"], "GRID_PAGE_SIZES" => [ ["NAME" => "10", "VALUE" => "10"], ["NAME" => "20", "VALUE" => "20"], ["NAME" => "50", "VALUE" => "50"], ["NAME" => "100", "VALUE" => "100"], ["NAME" => "200", "VALUE" => "200"], ["NAME" => "500", "VALUE" => "500"] ], "EXTENSION" => [ "ID" => $arParams["GRID_ID"], "CONFIG" => [ "gridId" => $arParams["GRID_ID"], "ownerTypeName" => 'O2K_TEST' ], "MESSAGES" => [] ] ], $this->getComponent(), ["HIDE_ICONS" => "Y"] );?> <script type="text/javascript"> BX.ready(function() { BX.CrmUIGridExtension.create('<?=$arParams['GRID_ID']?>', { gridId: '<?=$arParams['GRID_ID']?>', ownerTypeName: 'O2K_TEST', }); }); </script>
По факту, — это связка bitrix:crm.interface.filter и bitrix:main.ui.grid, данные для которых мы готовили в class.php компонента.
Итого
Получаем вот такой результат:
Функции редактирования строки грида и «красивости» мы делать в этой статье не будем, т. к. главная цель — показать практические примеры разработки модуля под Битрикс24.
Заключение
Вот таким несложным способом мы доработали Битрикс24 под нужды клиента.

На наш взгляд, разработчикам не стоит бояться работы с Б24:
-
в сети появляется все больше вспомогательных материалов и документации;
-
также есть примеры практической реализации конкретных функций (как в нашей статье);
-
в реальности работа с Битрикс24 не так сложна, какой кажется на первый взгляд, в том числе и с коробочной версией.
В следующей статье я расскажу, как мы создали напоминание менеджерам о пропущенных звонках клиентов, а также вывод кастомной информации (своей вкладки) по клиенту, сделке или компании.
А вы работали/работаете с Битрикс24? Была ли статья полезна вам в практическом плане?
Поделитесь своим мнением в комментариях.
ссылка на оригинал статьи https://habr.com/ru/articles/904166/
Добавить комментарий