Когда CRM встречает ЭДО. Как мы интегрировали Битрикс24 и Диадок без готовых решений

Бизнесу важно, чтобы ключевые процессы работали без сбоев: документы в Диадоке и сделки в Битрикс24 с полной синхронизацией. Мы создали интеграцию с нуля, объединив сервисы в единую систему. В результате компания получила удобный и прозрачный процесс документооборота, который экономит время и снижает риски.
Электронный документооборот давно перестал быть дополнительной опцией — для большинства компаний это обязательный элемент бизнеса.
Диадок от Контур помогает отправлять и получать счета, акты, накладные и договоры в электронном виде. Битрикс24 — CRM, где клиенты, лиды, сделки, звонки и вся воронка продаж.
Задача клиента — производителя спецтехники — настроить интеграцию так, чтобы входящие и исходящие документы из ящика Диадок автоматически превращались в лиды в облачном портале Битрикс24.
Для бизнеса это значит: менеджеры видят все документы прямо в CRM, не переключаясь между системами. Для разработчиков это вызов: две системы изначально никак не связаны, нужно построить мост.

Как мы подошли к задаче

У клиента был ящик в Диадок, где хранятся все документы компании, облачный портал Битрикс24 и собственный сайт.
Для решения рассматривали два пути:
  • написать отдельное приложение для Битрикс24;
  • использовать сайт как посредник и настроить обмен данными через вебхуки.
Мы выбрали второй вариант. Он оказался быстрее в разработке и дешевле в поддержке, а сайт при этом стал связующим звеном между Диадок и CRM.
Интеграцию мы разбили на три шага:

1. Сформировали структуру данных через Protocol Buffers.

Сначала описали, какие данные мы хотим получить от Диадока: статусы, документы, события. Для этого создали proto-файлы и перевели их с устаревшего синтаксиса proto2 на актуальный proto3. Это дало возможность безопасно компилировать структуру в PHP-классы.

2. Настроили API для связи с Диадоком.

На основе официальной документации создали REST-запросы, которые позволяют получать данные о документообороте из ящика клиента. Добавили авторизацию, работу с токенами и логику фильтрации по времени и событиям.

3. Связали сайт и Битрикс24 через вебхуки.

На сайте реализовали механизм отправки данных в облачный Битрикс24 с помощью REST API. Добавили:
  • создание новых лидов;
  • обновление существующих;
  • добавление комментариев с изменениями документов.
Каждый объект CRM теперь сопровождается логикой сравнения: меняем только то, что действительно изменилось, чтобы не перегружать систему.

Дьявол в деталях — что было важно

Вот что могло превратиться в подводные камни, но с чем мы успешно справились:

Формат обмена данными

Для обмена мы использовали Protocol Buffers — компактный формат, который рекомендует сам Диадок. Он оказался надежнее и стабильнее, чем привычный JSON.
Была одна особенность: документация Диадока изначально использовала старый синтаксис proto2, а в официальных релизах Protocol Buffer компилятора второй версии поддержка PHP уже была убрана. Из-за этого структуру данных пришлось адаптировать под актуальный формат. После обновления все заработало стабильно — данные стали корректно собираться и передаваться.

Работа с API: не все однозначно

У методов API Диадока, как это часто бывает, несколько версий. Мы выбрали ту, что обеспечивала корректное отображение статусов документов — это было важнее всего. У метода /V3/GetDocflowEvents на момент разработки не было нужного поля. Мы обратились в поддержку Диадока, где подтвердили, что в новой версии оно пока не предусмотрено. Поэтому временно использовали старый метод /V2/GetDocflowEvents — в нем нужное поле было. Позже разработчики добавили значение итогового статуса и в новую версию API.
Код:
<?php 
namespace O2k\Diadoc\Integration;
class Api
{
private const API_KEY = '';
private const SERVICE_URL = 'https://diadoc-api.kontur.ru';
private const RESOURCE_AUTHENTICATE = '/V3/Authenticate';
private const RESOURCE_GET_DOCFLOWS_EVENTS_V2 = '/V2/GetDocflowEvents';
private const RESOURCE_GET_DOCFLOWS_EVENTS_V3 = '/V3/GetDocflowEvents';
private const RESOURCE_GET_DOCUMENT = '/V3/GetDocument';
private const RESOURCE_GET_DOCUMENT_TYPES = '/V2/GetDocumentTypes';
private const RESOURCE_GET_BOX = '/GetBox';
private const RESOURCE_PARSE_TITLE_XML = '/ParseTitleXml';
private const RESOURCE_GET_ENTITY_CONTENT = '/V4/GetEntityContent';
private const RESOURCE_GENERATE_PRINT_FORM_FROM_ATTACHMENT = '/GeneratePrintFormFromAttachment';
private const RESOURCE_GET_GENERATED_PRINT_FORM = '/GetGeneratedPrintForm';
private const RESOURCE_GET_MESSAGE = '/V5/GetMessage';

const METHOD_GET = 'GET';
    const METHOD_POST = 'POST';

private $token;

public function getDocflowEventsV3(
$boxId,
$startTimestamp,
$endTimestamp,
$sortDirection = 1,
$afterIndexKey = null,
$populateDocuments = false,
$injectEntityContent = false,
$populatePreviousDocumentStates = false
)
    {
if (!$boxId) {
return false;
}

$uriParameters = [
'boxId' => $boxId,
];

$startProtoTimestamp = new \Diadoc\Api\Proto\Timestamp(['Ticks' => \O2k\DateTime\Helper::convertTimestampToTicks($startTimestamp)]);
$endProtoTimestamp = new \Diadoc\Api\Proto\Timestamp(['Ticks' => \O2k\DateTime\Helper::convertTimestampToTicks($endTimestamp)]);

$protoFilter = new \Diadoc\Api\Proto\TimeBasedFilter();
$protoFilter->setFromTimestamp($startProtoTimestamp);
$protoFilter->setToTimestamp($endProtoTimestamp);
$protoFilter->setSortDirection($sortDirection);

$getDocflowEventsRequest = new \Diadoc\Api\Proto\Docflow\GetDocflowEventsRequest([
'Filter' => $protoFilter,
'AfterIndexKey' => $afterIndexKey,
'PopulateDocuments' => $populateDocuments,
'InjectEntityContent' => $injectEntityContent,
'PopulatePreviousDocumentStates' => $populatePreviousDocumentStates
]);
$serializedProtoData = $getDocflowEventsRequest->serializeToString();

$response = $this->doRequest(
            self::RESOURCE_GET_DOCFLOWS_EVENTS_V3,
            [
                'boxId' => $boxId,
            ],
'POST',
$serializedProtoData
        );
$docflowEvents = new \Diadoc\Api\Proto\Docflow\GetDocflowEventsResponseV3;
$docflowEvents->mergeFromString($response);

return $docflowEvents;
    }

public function getDocflowEventsV2(
$boxId,
$startTimestamp,
$endTimestamp,
$sortDirection = 1,
$afterIndexKey = null,
$populateDocuments = false,
$injectEntityContent = false,
$populatePreviousDocumentStates = false
)
    {
if (!$boxId) {
return false;
}

$uriParameters = [
'boxId' => $boxId,
];

$startProtoTimestamp = new \Diadoc\Api\Proto\Timestamp(['Ticks' => \O2k\DateTime\Helper::convertTimestampToTicks($startTimestamp)]);
$endProtoTimestamp = new \Diadoc\Api\Proto\Timestamp(['Ticks' => \O2k\DateTime\Helper::convertTimestampToTicks($endTimestamp)]);

$protoFilter = new \Diadoc\Api\Proto\TimeBasedFilter();
$protoFilter->setFromTimestamp($startProtoTimestamp);
$protoFilter->setToTimestamp($endProtoTimestamp);
$protoFilter->setSortDirection($sortDirection);

$getDocflowEventsRequest = new \Diadoc\Api\Proto\Docflow\GetDocflowEventsRequest([
'Filter' => $protoFilter,
'AfterIndexKey' => $afterIndexKey,
'PopulateDocuments' => $populateDocuments,
'InjectEntityContent' => $injectEntityContent,
'PopulatePreviousDocumentStates' => $populatePreviousDocumentStates
]);
$serializedProtoData = $getDocflowEventsRequest->serializeToString();

$response = $this->doRequest(
            self::RESOURCE_GET_DOCFLOWS_EVENTS_V2,
            [
                'boxId' => $boxId,
            ],
'POST',
$serializedProtoData
        );
$docflowEvents = new \Diadoc\Api\Proto\Docflow\GetDocflowEventsResponse;
$docflowEvents->mergeFromString($response);

return $docflowEvents;
    }

public function getMessage($boxId, $messageId, $entityId, $originalSignature = false, $injectEntityContent = false)
{
$requestData = [
'boxId' => $boxId,
'messageId' => $messageId,
'entityId' => $entityId
];

if ($originalSignature) {
$requestData['originalSignature'] = $originalSignature;
}
if ($injectEntityContent) {
$requestData['injectEntityContent'] = $injectEntityContent;
}

$response = $this->doRequest(
            self::RESOURCE_GET_MESSAGE,
            $requestData
        );

$message = new \Diadoc\Api\Proto\Events\Message;
$message->mergeFromString($response);
        return $message;
}

public function getEntityContent($boxId, $messageId, $entityId)
{
$response = $this->doRequest(
            self::RESOURCE_GET_ENTITY_CONTENT,
            [
                'boxId' =>  $boxId,
                'messageId' =>  $messageId,
                'entityId'  =>  $entityId
            ]
        );
        return $response;
}

public function generatePrintFormFromAttachment($documentType, $fromBoxId, $documentContent)
{
$response = $this->doRequest(
            self::RESOURCE_GENERATE_PRINT_FORM_FROM_ATTACHMENT,
            [
                'documentType' => $documentType,
                'fromBoxId' => $fromBoxId
            ],
'POST',
$documentContent
        );
        return $response;
}

public function getGeneratedPrintForm($printFormId)
{
$response = $this->doRequest(
            self::RESOURCE_GET_GENERATED_PRINT_FORM,
            [
                'printFormId' => $printFormId,
            ]
        );
        return $response;
}

public function parseTitleXml($boxId, $documentTypeNamedId, $documentFunction, $documentVersion, $titleIndex, $xmlFileContent)
{
$response = $this->doRequest(
            self::RESOURCE_PARSE_TITLE_XML,
            [
                'boxId' => $boxId,
                'documentTypeNamedId' => $documentTypeNamedId,
                'documentFunction' => $documentFunction,
                'documentVersion' => $documentVersion,
                'titleIndex' => $titleIndex
            ],
'POST',
$xmlFileContent
        );
        return $response;
}

public function getDocumentTypes($boxId)
    {
$response = $this->doRequest(
self::RESOURCE_GET_DOCUMENT_TYPES,
[
'boxId' => $boxId,
]
);
$documentTypes = new \Diadoc\Api\Proto\Documents\Types\GetDocumentTypesResponseV2();
$documentTypes->mergeFromString($response);

        return $documentTypes;
}

public function getBox($boxId)
    {
        $response = $this->doRequest(
            self::RESOURCE_GET_BOX,
            [
                'boxId' => $boxId
            ]
        );
$box = new \Diadoc\Api\Proto\Box();
$box->mergeFromString($response);

        return $box;
    }

public function authenticateByPassword($login, $password)
    {
$uriParameters = [
'type' => 'password',
];

$protoData = new \Diadoc\Api\Proto\LoginPassword(['Login' => $login, 'Password' => $password]);
$serializedProtoData = $protoData->serializeToString();

$response = $this->doRequest(
            self::RESOURCE_AUTHENTICATE,
            $uriParameters,
'POST',
$serializedProtoData
        );
        $this->setToken($response);
        return $response;
    }

protected function getUri($action, $params = [])
{
$uri = self::SERVICE_URL.$action;
if ($params) {
$uri .= '?'.http_build_query($params);
}

return $uri;
}

protected function doRequest($resource, $params = [], $method = self::METHOD_GET, $data = array())
    {
$uri = sprintf(
            '%s%s?%s',
            self::SERVICE_URL,
            $resource,
            http_build_query($params)
        );
        $ch = curl_init($uri);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
curl_setopt($ch, CURLOPT_TIMEOUT, 180);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $this->buildRequestHeaders());
        if ($method == self::METHOD_POST) {
            curl_setopt($ch, CURLOPT_POST, 0);
            curl_setopt($ch, CURLOPT_POSTFIELDS, is_array($data) ? http_build_query($data) : $data);
        }
        elseif ($method == self::METHOD_GET) {
            curl_setopt($ch, CURLOPT_HTTPGET, 1);
            curl_setopt($ch, CURLOPT_BINARYTRANSFER, 1);
        }
        $response = curl_exec($ch);
        curl_close($ch);
        return $response;
    }

    protected function getToken()
    {
        return $this->token;
    }
    public function setToken($token)
    {
        $this->token = $token;
    }

protected function buildRequestHeaders()
    {
        $header = 'DiadocAuth ddauth_api_client_id='.self::API_KEY;
        if ($token = $this->getToken()) {
            $header .= ', ddauth_token='.$token;
        }
        return ['Authorization: ' . $header];
    }
}

Скрипт без скрипа

Скрипт построен как автоматизированный цикл, который срабатывает каждые 5 минут. За это время выполняется полный цикл проверки новых событий в документообороте и актуализации данных в CRM. Вот как это работает:
1. Запуск скрипта по расписанию. На сервере каждые 5 минут запускается PHP-скрипт. Он инициирует обращение к API Диадок и CRM Битрикс24.
Код:
$diadocEngine = new O2k\Diadoc\Integration\Api;
$controller = new \O2k\Diadoc\Integration\Bitrix24\Controller($diadocEngine);
if ($eventsData = $controller->getDocflowEventsV2Data()) {
$arExternalData = $controller->getExternalView($eventsData);
if ($arExternalData) {
$arExistingLeads = $controller->getLeadList(array_keys($arExternalData));
$existingExternalIds = array_keys($arExistingLeads);
$result = $controller->prepareData($arExternalData, $arExistingLeads);
foreach ($result as $externalId => $arLeadData) {
if (in_array($externalId, $existingExternalIds)) {
$controller->updateLead($arExistingLeads[$externalId], $arLeadData);
} else {
$controller->addLead($arLeadData);
}
}
}
}
2. Получение новых событий из Диадок. Через REST API Диадок система получает список событий, например, отправка документа, за последние 5 минут.
3. Проверка наличия документа в Битрикс24. Каждый документ из события проверяется по внешнему коду. Если соответствующий лид уже существует — система переходит к этапу сравнения. Если лида нет, то создает.
4. Сравнение состояния документа. Использовали механизм сравнения полей: текущие данные из события сверяются с предыдущим состоянием документа, который передается в ответе API Диадока. Это позволяет избежать лишних обновлений и точно отслеживать значимые изменения — например, смену статуса или появление нового файла.
5. Принятие решения о действии:
  • если документ новый — создается лид с заполнением полей.
  • если документ изменился — начинается проверка, нужно ли обновить лид или достаточно добавить комментарий о событии.
6. Добавление комментариев в ленту лида. Все ключевые события документа — подписан, на согласовании, отклонен — фиксируются в ленте CRM в виде комментариев. Это дает менеджерам полную картину происходящего без переключения между системами.
7. Хэширование данных. Чтобы не обновлять лид лишний раз, использовали механизм хэширования. Он помогает снизить нагрузку на API и избежать повторных запросов. Комментарии при этом не затрагиваются.
8. Работа с форматом времени. В API Диадок время передается в формате тиков (100 наносекунд с 01.01.0001). Мы реализовали конвертацию этих значений в привычный формат даты и времени, чтобы корректно фиксировать хронологию событий.
В результате весь документооборот в CRM всегда находится в актуальном состоянии, а сотрудники работают только с проверенной и свежей информацией.
Когда CRM встречает ЭДО. Как мы интегрировали Битрикс24 и Диадок без готовых решений
Когда CRM встречает ЭДО. Как мы интегрировали Битрикс24 и Диадок без готовых решений
Когда CRM встречает ЭДО. Как мы интегрировали Битрикс24 и Диадок без готовых решений - KISLOROD
Когда CRM встречает ЭДО. Как мы интегрировали Битрикс24 и Диадок без готовых решений

Результаты

Интеграция оказалась полезной как для команды продаж, так и для IT-отдела:
Интеграцию сделали без готовых решений и тяжеловесных платформ. Сайт стал рабочей прослойкой между двумя мирами: документооборотом и CRM-системой.
Если у вас в компании тоже есть Диадок и параллельно работает CRM, возможно, пришло время их познакомить. Приходите на консультацию, проанализируем потребности вашего бизнеса и предложим рабочее решение.

Другие кейсы

Оставьте заявку, чтобы обсудить проект и задачи
*
*
*