Как стать автором
Обновить
KISLOROD
Создаем цифровые продукты для e-commerce

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

Уровень сложностиПростой
Время на прочтение21 мин
Количество просмотров342

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

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

Теги:
Хабы:
+5
Комментарии0

Публикации

Информация

Сайт
o2k.ru
Дата регистрации
Дата основания
Численность
51–100 человек
Местоположение
Россия
Представитель
Максим Жуков