Jump to content
SpravkaCRM.ru - Ваш справочник по CRM
SpravkaCRM.ru

SuiteCRM + Camunda: История одного демо-стенда

Recommended Posts

Всем добрый день!

Хочу рассказать об одной интересной работе, которую делали последнюю неделю и которую закрыли вот прям буквально сегодня.

К нам обратилась некая компания, которая попросила показать демо-стенд реализации интеграции Camunda с CRM-системой на базе SuiteCRM.

В SuiteCRM есть свой собственный модуль "Процессы". Но его наглядность - не самое его сильное качество. Да и функциональность конечно весьма не плоха, но очень далеко до "отличная". По этому компания приняла решение в качестве BPM-платформы выбрать стороннее решение в виде Camunda.

Вот что пишут про Camunda:

Цитата

Camunda – это платформа для критически важных бизнес-процессов. Данную систему используют крупные компании во всем мире, где важна масштабируемость, надёжность, производительность.

2018-11-07_19-40-23.thumb.png.206ddc65905b3968c8b7e9a9557e6020.png

Погуглив по Camunda ничего толком не нашел в русском сегменте интернета. Какие то общие описания. В английском секторе чуть больше, и есть более менее не плохое описание и REST API

Так же очень помогла наработка от ребят из hardsoft321: при помощи этого класса получилось достаточно быстро "поднять протокол" - использовать API Camunda.

Постановка задачи

Итак, далее опишу что нужно было сделать, что было сделано и как это все работает.

По задумке надо было организовать:

  1. Прием заявки с сайта (сработала форма)
  2. Заявка назначается на ответственного менеджера
  3. Менеджер обрабатывает новую заявку проверяя хватает ли данных для оказания услуги
  4. Если данных достаточно, то менеджер передает заявку в другой отдел (в службу безопасности)
  5. В службе безопасности заявка назначается на сотрудника, он проверяет заявку
  6. Если проверка выявила проблемы, то сотрудник службы безопасности возвращает заявку обратно менеджеру
  7. Если проверка проблем не выявила, то сотрудник службы безопасности отправляет заявку на следующий этап - в юридический отдел для предоставления непосредственно самой услуги
  8. В юридическом отделе заявка назначается на сотрудника этого отдела
  9. Сотрудник оказывает услугу по заявке
  10. Если по заявке были выявлены проблемы - сотрудник юридического отдела должен иметь возможность вернуть заявку обратно менеджеру
  11. После того, как услуга по заявка была оказана, сотрудник юридического отдела возвращает заявку обратно менеджеру, чтобы тот уведомил клиента о оказании ему услуги.
  12. После уведомления клиента заявка уходит в архив

Если графически, то бизнес-процесс должен выглядеть примерно так:

2018-11-12_20-58-30.thumb.png.0fdf170eae62fc2b6b43f931c15b6019.png

В связке SuiteCRM + Camunda предполагалось, что все логические действия по движению заявки по отделам и статусам будет выполнять Camunda, а SuiteCRM будет выступать в виде некоего UI, в котором будут работать сотрудники: видеть список своих заявок в работе, иметь возможность отправлять заявки дальше по цепочке.

Чтобы реализовать подобное взаимодействие потребуется двухсторонняя синхронизация:

  1. В момент создания заявки, а также в моменты, когда сотрудники готовы отдать заявку дальше по цепочке, SuiteCRM должна сообщать о этом Camunda, чтобы та уже определяла дальнейшее действие.
  2. В момент, когда Camunda определяет что же дальше должно происходить с заявкой, она должна свое решение передавать обратно в SuiteCRM, чтобы та соответствующим образом меня статус заявки, ответственного по заявке и прочие действия, характерные для прохождения той или иной стадии.

С первым пунктом интеграции было все более или менее понятно:

  • Необходимо в Camunda разработать и загрузить процесс, который будет описывать всю логику.
  • При помощи Camunda Rest API мы будем должны стартовать этот разработанный процесс в момент прихода новой заявки из формы на сайте
  • В процессе работы над заявкой, когда сотрудники отмечают свою работу выполненной, мы должны передавать в процесс информацию о этом, чтобы процесс двигал заявку дальше

С вторым пунктом было больше вопросов:

  • Как передать из Camunda в SuiteCRM информацию о очередном этапе?
  • Умеет ли Camunda отправлять Webhooks, или используется какая то другая технология?
  • Есть ли в Camunda встроенные возможности такого взаимодействия, или необходимо привлекать разработчиков чтобы реализовать подобные возможности?

Поверхностное гугление не давало четкого ответа на эти вопросы.

Решение задачи

Со стороны SuiteCRM:

За основу была взята CRM-система для салонов красоты и парикмахерских. В ней мы переделали модуль "Посещения" на "Заявки":

screencapture-u1200-crmhosting-ru-index-php-2018-11-13-18_45_07.thumb.png.ddd38e712131c95712d1e7fa24b95c96.png

Для списка предоставляемых услуг подошел модуль "Прейскурант":

2018-11-13_18-46-25.thumb.png.bc9b26adee5befc0da2c7ac32cfea0c9.png

Клиенты так и остались в карточках модуля "Клиенты":

2018-11-13_18-47-40.thumb.png.ff0734eae49b1a037c4bb195b89bef56.png

Для тестирования получения данных из формы при помощи модуля "Компании" была сгенерирована форма тестовой заявки:

2018-11-13_18-49-54.thumb.png.e8cc0c7f8b875af5eb5c28b14636d4b8.png

Данные на нее попадали прямиком в CRM-систему.

Первоначально весь бизнес-процесс мы настроили силами самой CRM при помощи модуля "Процессы":

2018-11-13_18-51-20.thumb.png.62ace6ccb0c1932f2cbc46e982f7e694.png

Таким образом Заявка, при попадании в CRM-систему, автоматически создавала как запись с Клиентом, так и с Заявкой. В заявке назначался ответственный менеджер из списка сотрудников, находящихся в роли "Менеджер". Кстати были созданы 3 роли: Менеджер, СБ и Юрист:

2018-11-13_18-53-39.thumb.png.e08fc335d8d53f5cbfea682ae9d6cdaa.png

Сотрудники, имеющие ту или иную роль, назначаются Заявке в соответствующий момент времени.

Движение заявки по статусам осуществляется при помощи кнопок, находящихся вверху карточки Заявки.

Передача заявки в службу безопасности:

2018-11-13_18-56-11.thumb.png.c5b7de1134100341d54008e95755bc93.png

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

2018-11-13_18-57-35.thumb.png.f483f5ef116608d259ee9a5c7e7c7835.png

Если сотрудник службы безопасности подтвердил заявку, то она уходит сотруднику с ролью Юрист. Юрист также может вернуть обратно в работу заявку менеджеру (если в ней чего-либо не хватает), или выполнить свою работу и отметить заявку выполненной:

2018-11-13_19-03-08.thumb.png.3e71c85f3a1b59f67d2bc865509914be.png

После того, как Юрист выполнит свою работу и отметит заявку выполненной, она возвращается Менеджеру в статусе "Уведомление клиента" и менеджеру необходимо уведомить клиента о выполнении его заявки, после чего менеджер может закрыть заявку:

2018-11-13_19-03-52.thumb.png.3593d6f1fbaf9fd70f53a93d496cf517.png

2018-11-13_19-05-37.thumb.png.8bef3074cfd1e23b677cbc03eb8756da.png

 

Со стороны Camunda:

Сначала мы развернули демо-стенд с Camunda. Самый быстрый и простой способ это сделать оказалось загрузить на сервер Docker-образ с официального репозитория:

# docker pull camunda/camunda-bpm-platform

После этого появляется возможность развернуть уже настроенную систему с предустановленной Camunda на борту. Я воспользовался системой разворачивания виртуальных хостов, которая работает на нашем сайте crmhosting.ru, но вы можете самостоятельно посмотреть про работу с docker. Если будет совсем не понятно, то пишите под статьей, дополню подобной информацией.

Оставлю тут  ссылку на демо-стенд: http://camunda.crmhosting.ru/camunda/

логин/пароль = demo/demo

2018-11-12_21-22-58.thumb.png.ec18fba64b0437786d5ade876dfa7305.png

Путем изучения Camunda пришло понимание, что данное ПО умеет обрабатывать диаграммы BPMN, но не умеет их создавать. Для создания диаграмм используется распространяемый Modeler от Camunda:

2018-11-12_21-26-37.thumb.png.ff35aae288bee52a9e6c9e0301577edc.png

В изучении возможностей моделера и Camunda в том числе очень помог процесс, присутствующий в Camunda по умолчанию: обработка счетов:

2018-11-12_21-28-58.thumb.png.c9defb00029f7b844349ec4de1f3d846.png

 

Настройка взаимодействия:

Задачей нашего демо-стенда было показать принципиальную возможность взаимодействия между SuiteCRM и Camunda. Для этого мы решили не реализовывать весь процесс на связке Camunda+SuiteCRM, а добавить 1-2 звена для отображения того, как SuiteCRM отправляет данные в Camunda и как Camunda уведомляет SuiteCRM.

В Modeler от Camunda мы разработали простенький процесс, задачей которого было стартовать при срабатывании формы и назначить заявке ответственного менеджера по заданному алгоритму, а затем вернуть назначенного менеджера в SuiteCRM, чтобы та, в свою очередь, назначила этого ответственного за Заявкой. Таким образом мы убрали из наших ранее настроенных бизнес-процессов определение ответственного силами SuiteCRM и перевели этот блок на плечи Camunda. Вот сам получившийся процесс в Camunda:

2018-11-13_19-19-40.thumb.png.d01bf1d34c6b731ca4936d242cc6fed1.png

Для формирования логики блока "Определение ответственного менеджера" существует множество разных способов. Мы выбрали указание пользователя при помощи Модели принятия решений (DMN). Настройки блока "Определение ответственного менеджера":

2018-11-13_19-23-42.thumb.png.e322fb1aeed26e6b8738e1aa41be784f.png

Сама модель идет в виде отдельного файла *.dmn В ней мы явно указываем, что не зависимо не от чего результат будет равен строке "manager1":

2018-11-13_19-25-55.thumb.png.e0726979b0c4eac2e478ea95be3c5294.png

Таким образом при старте процесса Camunda назначает ответственным сотрудником юзера "manager1" и уходит в блок "Утверждение введенных данных менеджером" (этот блок никак не был автоматизирован).

Чтобы стартовать процесс Camunda из SuiteCRM мы создали в SuiteCRM Hook для события before_save, который через API стартует наш процесс. В настройках LogicHooks:

$hook_array['before_save'][] = Array(
    10,
    'Стартуем процесс',
    'modules/Opportunities/Hooks.php',
    'OpportunitiesLogicHooks',
    'camundaStartProcess'
);

Сам хук:

    public function camundaStartProcess($bean, $event, $arguments) {
        global $sugar_config;

        if(isset($GLOBALS['OpportunityCamundaStartProcess'][$bean->id])) return;

        print_array("*** camundaStartProcess - start ***",0,1);

        require_once 'custom/include/PestJSON.php';
        require_once 'modules/CamundaProcesses/SugarCamunda.php';

        print_array('$bean->id = ' . $bean->id,0,1);
        print_array('$bean->new_with_id = ' . var_export($bean->new_with_id,1),0,1);
        print_array('$bean->fetched_row = ' . var_export($bean->fetched_row,1),0,1);


        if(empty($bean->camunda_id)) {
            // Создание новой Заявки

            print_array("* Создание новой Заявки",0,1);

            if(empty($bean->id)) {
                $bean->id = create_guid();
                $bean->new_with_id = true;
            }

            if($bean->lead_source == 'Form') {
                // Если источник Заявки = Форма

                print_array("* источник Заявки = Форма",0,1);

                // Подключаемся к Camunda
                $camunda = SugarCamunda::getJsonClient();

                // Получаем список процессов
                $processes = $camunda->get("/process-definition");

                if(isset($sugar_config['camunda_new_opportunity_process_key'])) {
                    // Если в конфигурации указан процесс для входящего запроса с формы

                    print_array("* в конфигурации указан процесс для входящего запроса с формы = " . $sugar_config['camunda_new_opportunity_process_key'],0,1);

                    foreach ($processes as $processInfo) {
                        if($processInfo['key'] == $sugar_config['camunda_new_opportunity_process_key']) {
                            // Нашли процесс, указанный в конфиге

                            print_array("* Нашли процесс, указанный в конфиге",0,1);

                            // Стартуем процесс
                            print_array("* Стартуем процесс",0,1);

                            $GLOBALS['OpportunityCamundaStartProcess'][$bean->id] = true;

                            $resultStart = $camunda->post("/process-definition/{$processInfo['id']}/start",[
                                'opportunity_id' => $bean->id,
                            ]);

                            if(isset($resultStart['id'])) {
                                // Записываем ID процесса в Заявку
                                print_array("* Записываем ID процесса в Заявку: " . $resultStart['id'],0,1);
                                print_array("** resultStart: " . var_export($resultStart,1),0,1);
                                $bean->camunda_id = $resultStart['id'];
                            }

                        }
                    }

                }

            }
        }

    }

В Camunda может быть описано множество разных процессов. В конфигурационной переменной SuiteCRM мы указали название нашего нужного процесса.

$sugar_config['camunda_new_opportunity_process_key'] = 'testSuite';

Обращаясь через API Camunda к списку всех доступных процессов мы их перебираем, и как только находим процесс с нужным названием, то сразу запускаем его через вызов метода 

"/process-definition/{$processInfo['id']}/start"

В результате вызова мы получаем ID запущенного процесса (именно не ID схемы процесса, а ID именно текущего старта), и записываем его в Заявку в поле camunda_id:

$bean->camunda_id = $resultStart['id'];

Так как данное действие выполняется во время before_save, то данное поле естественным образом запишется в Заявку без каких-либо дополнительных действий с нашей стороны.

 

Дальше начинается самая интересная и сложная часть, на которую ушло больше всего времени - мы заставляем Camunda отдать в SuiteCRM логин выбранного менеджера.

После долгого гугления и попыток осознать предоставляемые Camunda возможности пришло понимание, что встроенных нативных средств в Camunda для передачи данных в внешнюю среду нет. Вроде "пичаль", но не совсем. У Camunda зато есть ряд других возможностей, подразумевающих программирование. И реализуются они при помощи Listeners в Modeler при редактировании свойств блока "Определение ответственного менеджера". Listeners - это возможность вставить свою логику в тот или иной блок диаграммы используя или специально разработанные Java-классы, или другие языки программирования.

Существующего опыта программирования на Java нам не хватало для анализа существующих возможностей и разворачивания действующего демо-стенда в заданные сроки. Вроде как есть поддержка JavaScript, но попытки использовать XMLHttpRequest закончились неудачей: Camunda отказалась работать с этими функциями.

Но на выручку пришел язык groovy:

2018-11-13_19-46-19.thumb.png.b2df2f85f8da001b91538f0e0f2f85e1.png

В этом языке нашлись конструкции, которые позволяют обращаться к сторонним ресурсам по HTTP и получать результаты. А в нашем случае на текущем этапе нам надо было всего лишь обратиться к SuiteCRM с ID текущего процесса и назначенным ответственным, чтобы передать в SuiteCRM эту информацию. И вот что в результате получилось:

2018-11-13_19-41-04.thumb.png.a01b4416f440b15675c7487354c28a19.png

Таким образом скрипт на groove выполнялся ПОСЛЕ того, как ответственный менеджер был выбран, и отправлял эту информацию в SuiteCRM.

SuiteCRM, в свою очередь, принимала этот запрос и обрабатывала его. Для приема был добавлен новый entryPoint = camunda

В настройках entryPoint:

$entry_point_registry['camunda'] = array(
	    'file' => 'modules/CamundaProcesses/inboundRequests.php',
	    'auth' => false
	);

Файл modules/CamundaProcesses/inboundRequests.php:

<?php
/**
 * Created by PhpStorm.
 * User: crmhosting
 * Date: 06.11.2018
 * Time: 19:26
 */
include_once "modules/Opportunities/Opportunity.php";

global $db;
$json = new JSON();

if(isset($_REQUEST['action'])) {
    // Пришел запрос с действием

    print_array('*** inboundRequests - start ***',0,1);
    print_array($_REQUEST,0,1);

    // Формируем массив с подробностями
    $content = [];

    switch ($_REQUEST['action']) {
        case 'setAssignedUserInOpportunity':
            // Указан ответственный пользователь у Заявки
            if(isset($_REQUEST['user']) AND $_REQUEST['user'] != '') {
                $content['user'] = $_REQUEST['user'];
            }
            break;
    }

    $camundaID = isset($_REQUEST['camundaID']) ? $_REQUEST['camundaID'] : '';
    $camundaIDArray  =explode(":", $camundaID);
    if(isset($camundaIDArray[2])) $camundaID = $camundaIDArray[2];


    // Записываем входящее действие в таблицу
    print_array('* Записываем входящее действие в таблицу',0,1);

    $sql = "
            INSERT INTO
                `camunda_inbound`
            SET
                `action` = '".$_REQUEST['action']."',
                `camundaID` = '{$camundaID}',
                `content` = '".base64_encode($json->encode($content))."'
    ";
    $db->query($sql, true);
}

Таким образом мы в этом entryPoint можем принимать из Camunda запросы и подготовили тем самым платформу для реагирования на самые разные запросы.

Кстати, в процессе тестирования выявилась интересная особенность: изначально мы думал, что процесс взаимодействия и взаимных вызовов будет строиться следующим образом:

Пришли данные из формы -> Стартовали процесс в Camunda -> Получили ID процесса из Camunda -> Сохранили заявку -> Пришел запрос из Camunda с указанием ответственного -> определили заявку -> указали ответственного в SuiteCRM -> Сохранили заявку

Но все оказалось немного иначе. Camunda не хотела отдавать ID процесса, пока не пройдется по всем своим блокам. А так как блок с определением ответственного стартует обратный вызов SuiteCRM, то фактическая цепочка получалась такой:

Пришли данные из формы -> Стартовали процесс в Camunda -> Пришел запрос из Camunda с указанием ответственного -> пытаемся определить заявку (ее еще нет) -> некуда указывать ответственного -> вызов из Camunda закончился в пустую -> Получили ID процесса из Camunda -> Сохранили заявку

То есть из Camunda приходит обратный запрос с ответственным еще до того, как Camunda отдала ID процесса при создании заявки. И так, как ID заявки в SuiteCRM пока нет, то ничего нормально мы не сохранили и ничего не работало.

Но выход нашелся в работе через вспомогательную таблицу `camunda_inbound`: в нее мы промежуточно сохраняем информацию когда приходит ответ от Camunda - в каком процессе какой ответственный был указан. Таким образом мы корректно завершаем Hook в заявке, получаем ID процесса и сохраняем его в Заявке. А хуком after_save мы лезем в таблицу `camunda_inbound` и обрабатываем накопившиеся там отбойки от Camunda:

    public function camundaCheckInboundProcess($bean, $event, $arguments) {
        include_once "modules/Opportunities/OpportunityCamundaHelper.php";

        if(isset($GLOBALS['OpportunityCamundaStartProcess'][$bean->id])) {
            // Запускаем анализ всех пришедших активностей из Camunda
            OpportunityCamundaHelper::processAllCamundaInbound();

            unset($GLOBALS['OpportunityCamundaStartProcess'][$bean->id]);

        }

    }

 

class OpportunityCamundaHelper extends Opportunity {

    static function processAllCamundaInbound() {
        global $db;

        print_array("*** processAllCamundaInbound - start ***",0,1);

        // Получаем все записи
        $sql = "SELECT * FROM `camunda_inbound` ORDER BY `id` ASC";
        $result = $db->query($sql, true);

        $json = new JSON();

        while ($row = $db->fetchByAssoc($result)) {

            $content = $json->decode(base64_decode($row['content']));

            switch ($row['action']) {


                case 'setAssignedUserInOpportunity':
                    // Указан ответственный пользователь у Заявки
                    print_array("* Пришел запрос на указание ответственного у Заявки",0,1);

                    if(isset($row['camundaID']) AND $row['camundaID'] != '') {
                        // Пришла информация о процессе
                        // Ищем Заявки с таким ID процесса
                        print_array("* camundaID указано ({$row['camundaID']})",0,1);

                        $sql = "SELECT `id` FROM `opportunities` WHERE `deleted` = 0 AND `camunda_id` = '{$row['camundaID']}'";
                        print_array('$sql: ' . $sql,0,1);
                        $opportunity_id = $db->getOne($sql, true);

                        if($opportunity_id) {
                            // Если ID Заявки найдено
                            print_array("* ID Заявки найдено: " . $opportunity_id,0,1);

                            $seedOpportunity = new Opportunity();
                            $seedOpportunity->retrieve($opportunity_id);

                            if(!empty($seedOpportunity->id) AND empty($seedOpportunity->assigned_user_id)) {
                                // Заявка корректно найдена

                                print_array("* Заявка найдена: " . $seedOpportunity->name . ' (ID = '.$seedOpportunity->id.')',0,1);

                                // Далее ищем ответсственного пользователя
                                if(isset($content['user']) AND $content['user'] != '') {
                                    // Пользователь указан

                                    print_array("* Пользователь указан: " . $content['user'],0,1);

                                    $sql = "SELECT `id` FROM `users` WHERE `deleted` = 0 AND `user_name` = '{$content['user']}'";
                                    $user_id = $db->getOne($sql, true);

                                    if($user_id) {
                                        // Если Пользователь найден

                                        print_array("* Пользователь найден: " . $user_id,0,1);

                                        $seedOpportunity->assigned_user_id = $user_id;
                                        $seedOpportunity->user_assigned_manager_id = $user_id;
                                        $seedOpportunity->save();

                                        print_array("** Сохранили: " . $seedOpportunity->assigned_user_name,0,1);

                                    }

                                }

                            }

                        }


                    }

                    break;

            }

            // Удаляем запись из таблицы
            $sql = "DELETE FROM `camunda_inbound` WHERE `id` = '{$row['id']}'";
            print_array("Удаляем запись из таблицы: " . $sql,0,1);
            $db->query($sql, true);

        }
    }
}

Вот такое получилось решение.

Могу предположить, что это не самое оптимальное решение. Если у кого то есть мысли как можно сделать лучше, или опыт чего то подобного, то буду рад если вы прокомментируете данную статью.

Демо-стенд SuiteCRM:

http://u1200.crmhosting.ru

admin/admin

manager1/Manager1

SB1/SB1

Yur1 / Yur1

Возможно эту сборку добавим в список сборок для автоматического разворачивания на нашем сервисе CRMHosting.ru (но это не точно )))) )

Презентация

Ну и под конец размещаю видео с презентацией получившегося решения:

 

 

Share this post


Link to post
Share on other sites

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now

×