Глаза Битрикса боятся, а руки делают — как кастомизировать сделки с нуля

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

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

Источник: https://habr.com/ru/companies/kislorod/articles/935914/


Интересные статьи

Интересные статьи

Всем привет! Это наша ежегодная подборка лучших бесплатных OSINT-инструментов по версии наших специалистов. Как обычно, в рубрике новинки, неувядающая классика и ставший вновь актуальным софт. По...
Для каких целей? На момент возникновения идеи было еще не совсем понятно. Но предпосылки развития идеи были
Компания “Волжское пароходство” —  это больше 3 тысяч сотрудников, часть из которых используют Битрикс24 во время перевозок. По пути следования судна интернет не везде стабильный, поэтому нужен б...
Всем доброго времени суток. Мы решили описать построение отказоустойчивой инфраструктуры для bitrix в рамках серии статей, и сегодня публикуем первую часть. Вариант, который мы будем описывать, - не и...
Инженеры из MIT разработали математическую модель, которая может увеличить производительность сетей IaaS-провайдеров. В ее основу легли некоторые подходы, используемые профессиональными инвестора...