Доработки в Битрикс24 без модулей — как стройка без чертежей: что-то получится, но надолго ли?
Всем привет, я Сергей — ведущий программист в e-commerce агентстве KISLOROD.
В первой части я рассказал, как разработать базовую структуру модуля и начать разработку под Битрикс24. Теперь — следующий шаг.
Битрикс24 можно доработать «под себя», но если задачи сложные — без модулей вы далеко не уедете. Модули позволяют объединить доработки в один понятный, управляемый блок. Это удобно как для команды разработки, так и для поддержки в будущем.
Что нужно знать о модулях
Модуль — это структура, которая объединяет весь функционал: бизнес-логику, интерфейсы и вспомогательные файлы в одном месте. У каждого файла в модуле есть своя роль, подробную структуру можно посмотреть в официальной документации — особенно полезно, если вы впервые работаете с модулями.
Структура модулей в Битрикс24 и в классической коробочной версии (БУС) практически идентична. Поэтому если вы уже работали с БУС, адаптация под коробочный Битрикс24 будет несложной.
Один модуль — множество решений
Рассматривать дальнейшие кейсы будем на примере разработанного модуля kislorod.d7, который был описан в прошлой статье. Все кейсы основаны на реальных задачах, которые мы решали для клиентов, но данные в них вымышленные. Некоторые кейсы можно реализовать и без участия модуля, стандартными средствами, но целью является ознакомление с API, событиями и модульной архитектурой. К тому же, возможно, стандартных средств для реализации задуманных задач может не хватить.
Кейс. Пропущенные звонки менеджеров в Битрикс24.
Проблема клиента:
Менеджеры пропускают звонки от клиентов, теряя потенциальные сделки.
Задача:
Автоматически напоминать менеджерам о пропущенных звонках. Уведомление должно содержать:
ссылку на карточку клиента;
ссылки на все его сделки;
текущие статусы этих сделок.
Как реализовать уведомление о пропущенном звонке
Для отслеживания окончания звонка используем событие onCallEnd модуля voximplant.
Подписываемся на него в инсталлере модуля:
\Bitrix\Main\EventManager::getInstance()->registerEventHandler('voximplant', 'onCallEnd', $this->MODULE_ID, '\o2k\d7\Events\Voximplant', 'endCall');
Далее при срабатывании события:
Проверяем статус звонка. У пропущенного звонка статус 304.
Если статус совпал, получаем CALL_ID.
По CALL_ID запрашиваем номер телефона клиента из таблицы StatisticTable модуля voximplant.
Используем номер телефона, чтобы найти контакт в CRM. Пока работаем только с контактами, компании не обрабатываем.
Находим контакт — формируем уведомление с нужными ссылками и отправляем ответственному менеджеру.
(int)$contactId = Voximplant::getOwnerIdByPhone($arCall['PHONE_NUMBER']);
Если нашелся контакт с привязанным номером телефона, получим его данные для подстановки в будущее сообщение:
$ownerContact = static::getExtranetClientById($contactId);
А также получим все сделки контакта, для наглядности:
$ownerDeals = Entities\DealField::getHTMLValues([], $contactId);
Далее формируем сообщение:
$message = Loc::getMessage(Settings::$langPrefix.'_MESSAGE', ['#CLIENT#' => $ownerContact, '#DEALS#' => $ownerDeals]);
И отправляем его пользователю с ID 1(по умолчанию админу):
Скрытый текст
$result = \CIMNotify::Add([
'TO_USER_ID' => 1, //отправлять будем допустим админу
'FROM_USER_ID' => 0,
'NOTIFY_TYPE' => IM_NOTIFY_SYSTEM,
'NOTIFY_MODULE' => Settings::$mid,
'NOTIFY_TAG' => 'missed-call'.intval($contactId).'-time-'.time(),
'NOTIFY_MESSAGE' => $message
]);
Событие onCallEnd — это гибкий инструмент. Он позволяет не только отправлять уведомления, но и запускать любой нужный бизнес-процесс. Вот примеры:
Автоматический запуск бизнес-процессов — например, постановка задачи по итогам пропущенного звонка.
Аналитика эффективности менеджеров — можно считать количество пропущенных звонков и строить отчеты по каждому сотруднику.
Автоматическое создание лидов или сделок — звонок пропущен? Создаем лид с привязкой к клиенту.
Расширение сценариев обработки звонков — например, сразу переводить клиента на другого менеджера, если у текущего много пропущенных.
И прочие кейсы коих бесконечное множество.
Сам класс с методами у нас будет выглядеть следующим образом:
Скрытый текст
<?php
namespace o2k\d7\Events;
use o2k\d7\Conf\Settings,
o2k\d7\Entities,
o2k\d7\Events\Voximplant,
Bitrix\Main\Loader,
Bitrix\Main\ORM,
Bitrix\Iblock\ORM as IblockORM,
Bitrix\Crm,
Bitrix\Voximplant\StatisticTable,
Bitrix\Main\Localization\Loc,
Bitrix\Main\Config\Option;
Loc::loadMessages(__FILE__);
class Voximplant
{
private $fail = 304;
public static function endCall($arFields)
{
if(
Loader::includeModule(Settings::$voximplantMid) &&
$arFields['CALL_FAILED_CODE'] == self::$fail &&
intval($arFields['CALL_TYPE']) === \CVoxImplantMain::CALL_INCOMING
) {
$callID = (!empty($arFields['CALL_ID'])) ? trim($arFields['CALL_ID']) : false;
if($callID) {
if(!Loader::includeModule(Settings::$voximplantMid)) {
return false;
}
$arCall = StatisticTable::getList([
'select' => ['PHONE_NUMBER'],
'filter' => ['=CALL_ID' => $callID],
'limit' => 1
])->fetch();
if(!empty($arCall['PHONE_NUMBER'])) {
$message = '';
(int)$contactId = Voximplant::getOwnerIdByPhone($arCall['PHONE_NUMBER']);
if($contactId > 0) {
$ownerContact = static::getExtranetClientById($contactId);
$ownerDeals = Entities\DealField::getHTMLValues([], $contactId);
$message = Loc::getMessage(Settings::$langPrefix.'_MESSAGE', ['#CLIENT#' => $ownerContact, '#DEALS#' => $ownerDeals]);
}
if(Loader::IncludeModule("im") && !empty($message)) {
$result = \CIMNotify::Add([
'TO_USER_ID' => 1, //отправлять будем допустим админу
'FROM_USER_ID' => 0,
'NOTIFY_TYPE' => IM_NOTIFY_SYSTEM,
'NOTIFY_MODULE' => Settings::$mid,
'NOTIFY_TAG' => 'missed-call'.intval($contactId).'-time-'.time(),
'NOTIFY_MESSAGE' => $message
]);
}
}
}
}
return true;
}
public static function getOwnerIdByPhone(string $phone):int
{
$result = 0;
$query = new IblockORM\Query(Crm\FieldMultiTable::getEntity());
$query->setSelect(['*']);
$query->setFilter([
'TYPE_ID' => 'PHONE',
'%VALUE' => trim($phone),
'ENTITY_ID' => \CCrmOwnerType::ContactName
]);
$query->setLimit(1);
$getOwner = ORM\Query\QueryHelper::decompose($query);
if(is_object($getOwner) && count($getOwner) > 0) {
foreach($getOwner as $owner) {
$owner = $owner->collectValues(ORM\Objectify\Values::ALL, ORM\Fields\FieldTypeMask::ALL, true);
$result = $owner['ELEMENT_ID'];
}
}
return $result;
}
public static function getExtranetClientById(int $id):string
{
$result = 0;
$query = new IblockORM\Query(Crm\ContactTable::getEntity());
$query->setSelect([
'FULL_NAME'
]);
$query->setFilter([
'=ID' => $id
]);
$query->setLimit(1);
$getOwner = ORM\Query\QueryHelper::decompose($query);
if(is_object($getOwner) && count($getOwner) > 0) {
$link = Option::get(Settings::$intranetMid, 'path_user', 'path_user', 's1');
foreach($getOwner as $owner) {
$owner = $owner->collectValues(ORM\Objectify\Values::ALL, ORM\Fields\FieldTypeMask::ALL, true);
if(!empty($link)) {
$result = \CCrmViewHelper::PrepareUserBaloonHtml([
'PREFIX' => (int)$owner['ID'].'_PREF',
'USER_ID' => (int)$owner['ID'],
'USER_NAME' => $owner['FULL_NAME'],
'USER_PROFILE_URL' => str_replace('#USER_ID#', (int)$owner['ID'], $link)
]);
}
}
}
return $result;
}
}
Вот такое сообщение получаем в уведомлениях:

На визуал опять не обращаем внимание, главное сейчас — понять, как это работает.
Кейс. Вывод кастомной информации по клиенту, сделке или компании.
Проблема клиента:
Менеджеры тратят много времени на поиск данных в сторонней системе. Это замедляет работу и снижает качество общения с клиентом во время звонков.
Задача:
Добавить закладку в карточке сделки, чтобы ускорить поиск информации и выводить кастомные данные по клиенту.
Часто возникает необходимость выводить индивидуальные данные по клиенту, сделке или компании. Для этого можно встроить вкладку в интерфейс нужной карточки. В стандартном компоненте bitrix:crm.entity.details для этого используется событие onEntityDetailsTabsInitialized, с помощью которого можно модифицировать массив меню и добавлять свои вкладки.
Скрытый текст
protected function updateTabsByEvent(array $tabs): array
{
$event = new Event('crm', 'onEntityDetailsTabsInitialized', [
'entityID' => $this->entityID,
'entityTypeID' => $this->entityTypeID,
'guid' => $this->guid,
'tabs' => $tabs,
]);
EventManager::getInstance()->send($event);
foreach($event->getResults() as $result) {
if($result->getType() === EventResult::SUCCESS) {
$parameters = $result->getParameters();
if(is_array($parameters) && is_array($parameters['tabs'])) {
$tabs = $parameters['tabs'];
}
}
}
return $tabs;
}
Рассмотрим на примере, как вывести данные из ORM-таблицы модуля с фильтрацией по ответственному сотруднику. Задача — получить все сделки, где ответственный совпадает с текущим в карточке сделки. Что нам потребуется:
подписаться на событие;
получить ID текущей сущности;
определить по этому ID ответственного;
передать его в обработчик компонента (ajax.php).
Начнем с подписки на событие. В файле install.php модуля, в функции InstallEvents(), подпишемся на событие.
\Bitrix\Main\EventManager::getInstance()->registerEventHandler('crm', 'onEntityDetailsTabsInitialized', $this->MODULE_ID, 'o2k\\d7\\Events\CrmMenu','getTab');
Создадим класс CrmMenu и метод getTab, который будет модифицировать меню, и запрашивать необходимую нам информацию из обработчика.
Еще нам понадобится тот самый обработчик ajax.php компонента. Он будет выглядеть следующим образом:
Скрытый текст
<?php
use Bitrix\Main\Application;
define('NO_KEEP_STATISTIC', 'Y');
define('NO_AGENT_STATISTIC', 'Y');
define('NO_AGENT_CHECK', true);
define('PUBLIC_AJAX_MODE', true);
define('DisableEventsCheck', true);
$siteID = isset($_REQUEST['site']) ? mb_substr(preg_replace('/[^a-z0-9_]/i', '', $_REQUEST['site']), 0, 2) : '';
if ($siteID !== '') {
define('SITE_ID', $siteID);
}
require_once($_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php');
if (!defined("B_PROLOG_INCLUDED") || B_PROLOG_INCLUDED !== true) {
die();
}
if (!check_bitrix_sessid()) {
die();
}
Header('Content-Type: text/html; charset=' . LANG_CHARSET);
global $APPLICATION;
$APPLICATION->ShowAjaxHead();
$request = Application::getInstance()->getContext()->getRequest();
$componentData = $request->get('PARAMS');
if(is_array($componentData)){
$componentParams = isset($componentData['params']) && is_array($componentData['params']) ? $componentData['params'] : [];
}
$server = $request->getServer();
$ajaxLoaderParams = [
'url' => $server->get('REQUEST_URI'),
'method' => 'POST',
'dataType' => 'ajax',
'data' => ['PARAMS' => $componentData]
];
$componentParams['AJAX_LOADER'] = $ajaxLoaderParams;
$APPLICATION->IncludeComponent(
'bitrix:ui.sidepanel.wrapper',
'',
[
'PLAIN_VIEW' => false,
'USE_PADDING' => true,
'POPUP_COMPONENT_NAME' => 'o2k:o2k.test',
'POPUP_COMPONENT_TEMPLATE_NAME' => $componentData['template'] ?? '',
'POPUP_COMPONENT_PARAMS' => $componentParams
]
);
\CMain::FinalActions();
Думаю, здесь в целом понятно, что происходит. Но один момент всё же стоит пояснить — это компонент bitrix:ui.sidepanel.wrapper.
Мы выше говорили о выводе данных из ORM-таблицы, а здесь вдруг используется другой компонент. Однако это не случайно: bitrix:ui.sidepanel.wrapper — это обертка, предназначенная для отображения компонентов внутри слайдера.
Если в iframe слайдера открыть обычную страницу, то она загрузится со всем шаблоном сайта — шапкой, меню, подвалом. Такой режим требует адаптации вывода.
bitrix:ui.sidepanel.wrapper как раз и решает эту задачу: он позволяет отобразить нужный компонент без лишних элементов оформления, корректно встроив его в слайдер.
Подробное описание — в официальной документации.
В коде видно, что мы передаем в параметры обертки наш компонент:'POPUP_COMPONENT_NAME' => 'o2k:o2k.test', который выводится в слайдере уже без шапки и футера. Также в ajax прокидываем параметры компонента o2k:o2k.test и фильтра.
Что касается класса CrmMenu, то он будет выглядеть следующим образом:
Скрытый текст
<?php
namespace o2k\d7\Events;
use Bitrix\Main\Loader,
o2k\d7\Conf\Settings,
Bitrix\Main\EventResult,
Bitrix\Crm\DealTable,
Bitrix\Main\ORM,
Bitrix\Iblock\ORM as IblockORM,
Bitrix\Main\Localization\Loc;
Loc::loadMessages( __FILE__ );
Loader::includeModule(Settings::$crmMid);
class CrmMenu
{
public static function getTab(\Bitrix\Main\Event $event): EventResult
{
$entityId = $event->getParameter('entityID');
$entityTypeID = $event->getParameter('entityTypeID');
$tabs = $event->getParameter('tabs');
switch ($entityTypeID) {
case \CCrmOwnerType::Deal:
$tabs = self::getResponsibleDeals($tabs, $entityId);
break;
}
return new EventResult(EventResult::SUCCESS, [
'tabs' => $tabs,
]);
}
private static function getResponsibleDeals(array $tabs, int $id): array
{
$responsible = 0;
$query = new IblockORM\Query(DealTable::getEntity());
$query->setSelect([
'ASSIGNED_BY_ID'
]);
$query->setFilter([
'=ID' => $id
]);
$query->setLimit(1);
$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);
$responsible = $deal['ASSIGNED_BY_ID'];
}
}
$tabs[] = [
'id' => 'deals_contact',
'name' => Loc::getMessage(Settings::$langPrefix.'_DEALS_RESPONSIBLE'),
'enabled' => !empty($id),
'loader' => [
'serviceUrl' => '/local/components/o2k/o2k.test/ajax.php?&site=' . \SITE_ID . '&' . \bitrix_sessid_get(),
'componentData' => [
'template' => '',
'params' => [
'GRID_ID' => 'test_grid_table',
'FILTER_ID' => 'test_grid_filter',
'FILTER' => ['RESPONSIBLE' => $responsible]
]
]
]
];
return $tabs;
}
}
Здесь мы видим, что при наступлении события onEntityDetailsTabsInitialized вызывается наш метод getTab.
Внутри метода мы проверяем, что текущая страница — это именно карточка сделки. Если это так, вызывается вспомогательный метод, который определяет ответственного по ID сущности (то есть сделки).
switch ($entityTypeID) {
case \CCrmOwnerType::Deal:
$tabs = self::getResponsibleDeals($tabs, $entityId);
break;
}
Найдя ответственного, он передается в массив параметров serviceUrl, который в свою очередь передает эти данные в виде фильтра на на обработчик компонента (файл ajax.php)
Скрытый текст
$tabs[] = [
'id' => 'deals_contact',
'name' => Loc::getMessage(Settings::$langPrefix.'_DEALS_RESPONSIBLE'),
'enabled' => !empty($id),
'loader' => [
'serviceUrl' => '/local/components/o2k/o2k.test/ajax.php?&site=' . \SITE_ID . '&' . \bitrix_sessid_get(),
'componentData' => [
'template' => '',
'params' => [
'GRID_ID' => 'test_grid_table',
'FILTER_ID' => 'test_grid_filter',
'FILTER' => ['RESPONSIBLE' => $responsible]
]
]
]
];
Параметры 'componentData' -> 'params' — это массив с настройками, которые будут переданы в компонент o2k.test.
В результате мы получаем новую вкладку с таблицей из нашей ORM-модели и фильтром для удобного поиска.


Дальше все зависит от вашей фантазии — вы можете создавать любые таблицы с разными данными.
Кейс. Встраивание в выпадающее меню сделки
Проблема клиента:
Формирование уникального отчета по сделке требует множества действий. Каждый отчет должен пройти несколько стадий перед финальным утверждением. Это занимает много времени.
Еще сотрудники часто забывают нажать нужную кнопку в задаче или не вносят важные данные. Из-за этого отчет невозможно сформировать оперативно.
Задачи:
Добавить в контекстное меню сделки (в списке сделок) два отчета. У каждого — свой алгоритм: запуск бизнес-процесса, создание задачи, отправка на согласование, генерация PDF и другие шаги.
Максимально автоматизировать эти действия и использовать индивидуальные шаблоны для каждого отчета.
Теперь давайте рассмотрим, как реализовать встраивание в выпадающее меню сделки — это довольно распространенный сценарий.
\Bitrix\Main\EventManager::getInstance()->registerEventHandler('crm', 'onCrmDealListItemBuildMenu', $this->MODULE_ID, 'o2k\\d7\\Events\DealContextItemMenu','injectContextMenu');
Если открыть код стандартного компонента crm.deal.list, а точнее — файл template.php, можно увидеть интересную деталь: при построении контекстного меню строки сделки используется специальное событие.
Это событие — onCrmDealListItemBuildMenu. С его помощью можно дополнить или изменить пункты контекстного меню, встроив туда собственные действия.
Зная это, мы можем подписаться на это событие, модифицировать или добавлять меню. В файле install.php в функции
function InstallEvents()
Для модуля добавим подписку на событие:
\Bitrix\Main\EventManager::getInstance()->registerEventHandler('crm', 'onCrmDealListItemBuildMenu', $this->MODULE_ID, 'o2k\\d7\\Events\DealContextItemMenu','injectContextMenu');
Создадим класс, в котором опишем логику:
Скрытый текст
<?php
namespace o2k\d7\Events;
use Bitrix\Main\Loader,
o2k\d7\Conf\Settings,
Bitrix\Main\EventResult;
class DealContextItemMenu
{
public static function injectContextMenu($restPlacement, $contactId, array &$menu)
{
if($restPlacement == 'CRM_DEAL_LIST_MENU') {
$menu[]['SEPARATOR'] = true;
$menu[] = [
'TITLE' => 'Test',
'TEXT' => 'Test 1',
'MENU' => [
[
'TITLE' => 'Sub Test 1',
'TEXT' => 'Sub Test 1',
'ONCLICK' => 'console.log("test 1");'
],
[
'TITLE' => 'Sub Test 2',
'TEXT' => 'Sub Test 2',
'ONCLICK' => 'console.log("test 2");'
]
]
];
$menu[]['SEPARATOR'] = true;
}
return $menu;
}
}
Принцип простой: мы проверяем, что обрабатывается именно контекстное меню сделки, затем добавляем разделитель, чтобы визуально отделить наши пункты.
Далее создаём свой раздел — например, «Основное меню», — и в нем размещаем два подпункта.
Важно: параметр ONCLICK — это JavaScript-код, который будет выполнен при нажатии на пункт меню. Это может быть что угодно:
– открытие слайдера,
– отправка AJAX-запроса,
– генерация счета по сделке,
– или любое другое действие, которое часто требуется клиенту "в два клика".
Такие и другие события подробно описаны в документации События и обработка меню в Bitrix24.
В итоге получилось вот так:

Выводы
Как видно из примеров, работать с Битрикс24 не так уж сложно — особенно сейчас, когда появилась документация и для облачной версии (ранее она была менее информативна). Возможно, позже мы с вами рассмотрим и облачные приложения. Но это будет уже совсем другая история ?
Сегодня мы познакомились с:
фильтрацией по собственному гриду с кастомными свойствами;
встройкой пунктов «меню» в карточку сделки с фильтрацией;
встройкой собственных пунктов в контекстное меню списка сделкок (или лида, или компании).
методом serviceUrl;
использованием компонента bitrix:ui.sidepanel.wrapper;
Главная цель данной статьи — показать практические и работающие решения, которые реально применяются в проектах для наших клиентов.
С таким «джентльменским набором» уже можно решать довольно широкий круг задач в Битрикс24.