Когда 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.

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

Скрипт построен как автоматизированный цикл, который срабатывает каждые 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, возможно, пришло время их познакомить. Приходите на консультацию, проанализируем потребности вашего бизнеса и предложим рабочее решение.

Код класса по работе с 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;
        }

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

        $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];
    }
}

Другие кейсы

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