Доработки в Битрикс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');

Далее при срабатывании события:

  1. Проверяем статус звонка. У пропущенного звонка статус 304.

  2. Если статус совпал, получаем CALL_ID.

  3. По CALL_ID запрашиваем номер телефона клиента из таблицы StatisticTable модуля voximplant.

  4. Используем номер телефона, чтобы найти контакт в CRM. Пока работаем только с контактами, компании не обрабатываем.

  5. Находим контакт — формируем уведомление с нужными ссылками и отправляем ответственному менеджеру.

(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;
   }
}

Вот такое сообщение получаем в уведомлениях:

Уведомления в Б24
Уведомления в Б24

На визуал опять не обращаем внимание, главное сейчас — понять, как это работает.

Кейс. Вывод кастомной информации по клиенту, сделке или компании.

Проблема клиента:
Менеджеры тратят много времени на поиск данных в сторонней системе. Это замедляет работу и снижает качество общения с клиентом во время звонков.

Задача:
Добавить закладку в карточке сделки, чтобы ускорить поиск информации и выводить кастомные данные по клиенту.

Часто возникает необходимость выводить индивидуальные данные по клиенту, сделке или компании. Для этого можно встроить вкладку в интерфейс нужной карточки. В стандартном компоненте 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-модели и фильтром для удобного поиска.

Сделки ответственного по фильтру
Сделки ответственного по фильтру

Дальше все зависит от вашей фантазии — вы можете создавать любые таблицы с разными данными.

Кейс. Встраивание в выпадающее меню сделки

Проблема клиента:
Формирование уникального отчета по сделке требует множества действий. Каждый отчет должен пройти несколько стадий перед финальным утверждением. Это занимает много времени.

Еще сотрудники часто забывают нажать нужную кнопку в задаче или не вносят важные данные. Из-за этого отчет невозможно сформировать оперативно.

Задачи:

  1. Добавить в контекстное меню сделки (в списке сделок) два отчета. У каждого — свой алгоритм: запуск бизнес-процесса, создание задачи, отправка на согласование, генерация PDF и другие шаги.

  2. Максимально автоматизировать эти действия и использовать индивидуальные шаблоны для каждого отчета.

Теперь давайте рассмотрим, как реализовать встраивание в выпадающее меню сделки — это довольно распространенный сценарий.

\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.

Комментарии (0)