Всем привет, я Сергей — ведущий программист в 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? Была ли статья полезна вам в практическом плане?
Поделитесь своим мнением в комментариях.