Привет, Хабр! Меня зовут Иван, я битрикс-разработчик.

Заходят как-то в бар Битрикс24 и Диадок — и быстро выясняется, что работать вместе им пока сложно. Битрикс24 отвечает за сделки и коммуникации, Диадок — за документы и статусы. А бизнесу нужна единая цепочка: документ появился в Диадоке — менеджер сразу видит его в CRM без переключений и ручных сверок.

Рассказываю, как мы реализовали такую интеграцию с нуля. Настроили автоматическую передачу документов из Диадока в Битрикс24. Документы создают лиды, а статусы, файлы и история изменений отображаются в CRM.

Задача

Компания — производитель спецтехники. В работе используются три системы:

  • ящик Диадок с полным документооборотом;

  • облачный Битрикс24;

  • собственный сайт.

Необходимо настроить автоматическую передачу входящих и исходящих документов из Диадока в Битрикс24, чтобы они сразу превращались в лиды. Менеджеры должны видеть статусы, историю изменений и файлы прямо в CRM, без переключения между сервисами.

Для интеграции рассматривали два варианта: создать отдельное приложение для Битрикс24 или использовать сайт как посредник и настроить обмен через вебхуки.

Мы выбрали второй вариант. Он оказался быстрее в реализации и проще в поддержке, а сайт получил роль связующего звена между Диадоком и CRM.

Как мы реализовали интеграцию

Интеграцию разделили на три этапа:

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

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

  3. Связали сайт и Битрикс24 через вебхуки. На сайте настроили отправку данных в облачный Битрикс24 через REST API. Реализовали:  

    • создание новых лидов;

    • обновление существующих;

    • добавление комментариев о изменениях документов.

Каждый объект CRM проходит сравнение с предыдущим состоянием — обновляем только изменившиеся данные, чтобы не перегружать систему.

Точки риска подхода

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

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

Для передачи данных использовали Protocol Buffers — компактный формат, который рекомендует сам Диадок. Он поддерживает обратную совместимость, а значит,  более стабильный и надежный, чем JSON.

Сложность заключалась в том, что документация Диадока использовала синтаксис proto2, тогда как в официальных версиях компилятора Protocol Buffers поддержка PHP для этой версии уже отсутствовала. Поэтому структуру данных пришлось адаптировать под актуальный формат. После обновления данные начали корректно собираться и передаваться, и интеграция заработала стабильно.

Работа с API.

У API Диадока несколько версий, и не все методы содержат одинаковые данные.
Ключевым требованием было корректное отображение статусов документов. В методе /V3/GetDocflowEvents на момент разработки нужного поля не было. Поддержка Диадока подтвердила, что в новой версии оно пока не предусмотрено.

Мы временно использовали метод /V2/GetDocflowEvents, где это поле есть. Позже разработчики добавили итоговый статус и в версию V3.

Скрытый текст
<?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];
    }
}

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

Интеграция работает как автоматический цикл, который запускается каждые пять минут. За один такой цикл система проверяет новые события в документообороте и актуализирует данные в CRM. Алгоритм выглядит так:

  1. Запуск по расписанию. На сервере каждые пять минут запускается PHP-скрипт, который обращается к API Диадока и Битрикс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 загружается список событий за последние пять минут: отправка, подписание, согласование, отклонение и другие статусы.

  3. Проверка документа в Битрикс24. Каждый объект проверяется по внешнему коду. Если лид уже существует — переходим к сравнению данных. Если лида нет — создаем новый.

  4. Сравнение состояния документа. При обработке событий сравниваем текущее и предыдущее состояние документа, переданные Диадоком. Обновляем данные только при фактических изменениях статуса, наличия файла, без избыточных запросов.

  5. Принятие решения. Если документ новый — создается лид с заполнением всех полей. Если документ изменился — либо обновляем данные лида, либо добавляем комментарий о событии.

  6. Комментарии в ленте. Все ключевые изменения — подписан, отправлен, на согласовании, отклонен — фиксируются в виде комментариев внутри лида. Менеджер видит историю без перехода в Диадок.

  7. Хэширование данных. Чтобы не перегружать API, используем хэши. Если данные не изменились — обновления не отправляются. Комментарии это не затрагивает.

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

Результаты

Интеграцию сделали без готовых модулей и платформ. Сайт стал рабочей прослойкой между документооборотом и CRM со стабильной и предсказуемой передачей данных.

Интеграция оказалась полезной и для продаж, и для IT-отдела. Выделили основные эффекты:

  1. Автоматизация документооборота. Документы из Диадока автоматически превращаются в лиды в CRM с полной историей изменений.

  2. Снижение операционных затрат. Больше практически нет ручной работы по копированию, отслеживанию и обновлению документов.

  3. Прозрачность процессов. Статусы и комментарии отображаются прямо в карточке. Менеджеры видят всю хронологию.

  4. Быстрая обработка. Время между поступлением документа и реакцией команды заметно сократилось.

  5. Оптимальная нагрузка на API. Точечное обновление исключило лишние запросы.

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

  7. Готовность к развитию. Интеграцию можно масштабировать: подключать дополнительные ящики и менять логику обработки без переписывания всего решения.

Если в компании используются Диадок и CRM, но они работают отдельно, их можно объединить в единую систему. Такой подход упрощает процессы, снижает количество ручных операций и уменьшает вероятность ошибок. Если нужна консультация по подобной интеграции, мы готовы обсудить задачу и предложить техническое решение.

Комментарии (0)