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

от автора

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


ссылка на оригинал статьи https://habr.com/ru/articles/935914/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *