Всем привет, я Сергей — ведущий программист в e-commerce агентстве KISLOROD.

Чаще всего я решаю задачи разработки для сайтов на 1С-Битрикс, но также иногда работаю с Битрикс24. Сегодня хочу рассказать о модульной доработке Б24 в одном из кейсов.

Мы занимаемся доработками Битрикс24 уже несколько лет и за это время, разумеется, обобщили свой опыт. И обратили внимание, что наши клиенты, независимо от сферы их деятельности, сталкиваются с одинаковыми проблемами:

  1. При большом потоке обращений менеджеры пропускают звонки, а значит, и упускают возможных клиентов.

  2. Проекты имеют проблемы безопасности внешних интеграций с партнерами по REST API.

  3. Разработчики сталкиваются с неудобствами при хранении нестандартных данных, под которые требуется кастомизировать карточку сделки, — создавать сложносоставные поля.

Очевидно, что заказчики хотят:

  • автоматизировать рабочие процессы;

  • уменьшить процент потерянных сделок;

  • увеличить прибыль за счет роста количества успешных сделок.

При этом заказчики больше доверяют готовым и проверенным на практике решениям, поскольку им нужен результаwт и как можно быстрее.

Со своей стороны разработчики сталкиваются со следующими проблемами:

  • часто для коробочной версии Б24 мало документации в открытых источниках;

  • мало опытных наставников, которые имеют практический опыт и могут подсказать решение.

Поэтому нередко разработчики просто не хотят рисковать и тратить время и силы на изучение Б24. Время может быть потрачено впустую: квалификация не вырастет и  заказов не будет, а доходы снизятся.

Для себя этот вопрос наша компания решила просто — мы используем для доработок Б24 собственные модульные решения. Таким образом мы снижаем риски для разработчиков, упрощаем им задачу, а заказчикам гарантируем результат.

Модули в Битрикс24

Модуль — это объемный блок кода, который отвечает за определенную функциональность продукта.

Когда модуль устанавливается на портал, он автоматически распаковывается и совершает действия, которые прописали разработчики в коде, — а новый функционал сразу появляется у всех пользователей, по алгоритму прав доступа, заложенному в модуле. При этом работа модуля не затрагивает ядро системы и настройки.

Таким образом, модульный подход быстрее и безопаснее, чем не дай Бог файловая доработка ядра, компонентов или шаблонов Б24.

Билет в Ад для тех, кто вносит изменения в ядро Битрикс

И вот почему:

  • Все файлы модуля хранятся в отдельной папке и имеют собственное пространство имен.

  • Модуль легко установить или удалить нажатием одной кнопки, при этом не затрагивая настройки на портале.

  • Модули ускоряют и упрощают труд разработчика. «Скелет» модуля можно использовать на многих проектах, так как алгоритмы бывают очень схожи.

  • Обновление ядра системы не влияет на работу модуля: доработки не удаляются, как это происходит при переписывании ядра, шаблонов и компонентов.

В итоге, все глобальные изменения в Б24 наша команда производит через модули. 

Для разработки модулей требуется знание их архитектуры и роли каждого файла. 

О модульной структуре Bitrix Framework можно прочитать здесь. Модули для 1С-Битрикс: Управление сайтом (БУС) и Битрикс24 имеют много общего. Именно поэтому переход к разработке на Б24 оказался не таким сложным. 

Я уже не раз успешно применял модули в своих проектах и готов поделиться опытом.

Для всех доработок мы используем один базовый модуль, на котором и рассмотрим практические примеры. Эти примеры взяты из опыта внедрения аналогичных задач для разных клиентов в разное время, и, разумеется, все данные в примерах выдуманы.

Для примера пусть название нашего модуля будет — o2k.d7 «Практический опыт доработки Битрикс24 для бизнеса». В этой статье я не буду рассматривать базовый установщик модулей Битрикс — он неинтересен и идентичен БУС.

Предлагаю сосредоточиться на первом примере, а в следующей публикации расскажу еще о нескольких интересных кейсах для коробочной версии Битрикс24.

Практическое внедрение в CRM. Сущности «Контакт», «Компания» и «Сделка»

Формулировка задачи

Проблема клиента:

  • Менеджеры теряют сделки и упускают покупателей.

Задачи:

  • Создать отчет (грид, таблицу), в котором будут отражены сделки клиентов с определенными статусами и группировкой по ответственным менеджерам.

  • Разработать удобную и быструю систему перехода в клиента/менеджера/сделку.

  • Предусмотреть поиск и быструю фильтрацию данных по отчету.

Решение

Для примера возьмем сущность CRM «Сделка». 

Рассмотрим на практике следующие возможности:

  1. Вывод сделок в собственный грид (таблицу).

  2. Вывод фильтра для грида по кастомным полям. 

  3. Группировка сущностей по полю в гриде.

  4. Рассылка уведомлений пользователям на портале.

  5. Вывод настроек нашего модуля в админке Битрикс24.

Итак, начнем с классической структуры модуля, которая описана в документации, и будем дополнять ее по мере необходимости.

Структура примерно следующая:

Вывод сделок в собственный грид (таблицу)

Для того чтобы можно было работать с данными, и не перегружать CRM — будем хранить информацию в ORM-таблице, заодно и посмотрим, как с ними работать. 

Почитать об ORM можно здесь→

Чтобы хранить в таблице значение в нужном формате, допустим, множественное, такое как список сделок клиента, мы будем использовать кастомное поле в нужном формате сериализованного массива, и заодно рассмотрим, как создаются такие кастомные поля. 

Для того чтобы создать свое поле в ORM, нужно наследоваться от подходящей сущности. 

Так как мы собираемся хранить строку, то логично будет наследоваться от Bitrix\Main\Entity\TextField.

Создадим свой класс DealField

Конечно, можно наследоваться от \Bitrix\Main\ScalarField, но в нем ограничение в 255 символов, а у нас может быть и больше.

Подробнее прочитать можно здесь→

Так как мы будем использовать сразу несколько кастомных полей, и значения для вывода в таблицу нужно будет форматировать (выводить в красивом виде) — заведем два метода для работы с данными, а именно:

  1. getHTMLValues — вывод в форматированном виде ссылок на сделки (непосредственно для вывода в грид).

  2. 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 для дальнейшего вывода в грид со списком сделок.

В этом методе есть несколько ключевых моментов.

  1. В настройках модуля есть путь к детальной странице сделки. Хранится он в b_option и называется path_to_deal_details и позволяет избежать проблем, если вдруг путь к сделкам поменяется. Воспользуемся им для построения пути к открытию детального слайдера сделки.

  2. 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?>&amp;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? Была ли статья полезна вам в практическом плане? 

Поделитесь своим мнением в комментариях.