Перейти к содержанию

Синхронизация связывает записи CRM с записями внешней системы и переносит изменения в обе стороны.

В примере статьи учетная система передает покупателя и заказ, код создает или обновляет контакт и связанную сделку, а затем возвращает учетной системе стадию сделки, которую изменили в CRM. Код выполняется после подключения пролога и модуля crm. В примерах учетная система называется erp, модуль приложения — my.module, а значения вроде contact-1001 и order-5001 обозначают идентификаторы записей учетной системы. Замените их своими.

Определить границы обмена

До написания кода согласуйте с владельцами учетной системы три условия обмена.

  • Какая система владеет каждым полем.

  • В каком виде учетная система отдает изменения.

  • От имени какого пользователя CRM код пишет данные и где хранит настройки.

Распределить данные между системами

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

|

|| Данные | Владелец | Направление | Что делает импорт при конфликте || || Имя и фамилия покупателя | Учетная система | Учетная система -> CRM | Перезаписывает значение в CRM || || Email покупателя | Учетная система | Учетная система -> CRM | Добавляет адрес, если его нет у контакта, и не удаляет другие адреса || || Название заказа | Учетная система | Учетная система -> CRM | Перезаписывает название сделки || || Покупатель заказа | Учетная система | Учетная система -> CRM | Привязывает контакт к сделке и не отвязывает прежние контакты || || Стадия сделки | CRM | CRM -> учетная система | Не меняет стадию существующей сделки || |#

Импорт пишет только поля, которыми владеет учетная система. Экспорт передает только стадию. Поэтому запись одной системы не возвращается к ней как изменение из другой системы.

Если у заказа может смениться покупатель, решите, нужно ли отвязывать прежний контакт. Замену основного контакта и всего набора контактов описывает статья Связи между элементами.

Покупатель-компания, товарные позиции, дела и элементы смарт-процессов не входят в пример. Привязку компании к сделке описывает статья Связи между элементами. Если обмен должен их передавать, используйте статьи Товары в элементах CRM, Дела и история взаимодействий и Смарт-процессы.

Задать контракт учетной системы

Учетная система отдает изменения порциями. Каждое изменение содержит номер sequence, который растет вместе с порядком изменений. По этому номеру код продолжает импорт после сбоя и пропускает уже обработанные изменения.

Пример. Порция из одного изменения после разбора JSON-ответа учетной системы. Массив contact описывает покупателя, массив order — заказ.

$changes = [
    [
        'sequence' => 1001,
        'contact' => [
            'id' => 'contact-1001',
            'name' => 'Тестовый',
            'lastName' => 'Покупатель',
            'email' => 'buyer@example.com',
        ],
        'order' => [
            'id' => 'order-5001',
            'title' => 'Заказ order-5001',
        ],
    ],
];

Ключи id содержат постоянные идентификаторы записей учетной системы. Они не должны меняться при изменении имени, email или названия. Код приложения получает порцию по HTTP и разбирает ответ через HTTP-клиент. Номер последнего обработанного изменения код передает в запросе, чтобы учетная система вернула следующую порцию.

В ответ CRM возвращает учетной системе отчет обмена. Для каждого sequence он содержит статус, а для успешно обработанного изменения — еще ID контакта и ID сделки. Учетная система сохраняет ID сделки — он понадобится, если сделку удалят в CRM.

Одна и та же строка отчета может прийти повторно после сбоя. Учетная система принимает ее и заменяет прежний статус этого sequence, например retry на ok.

Подготовить пользователя и настройки

Агент работает без авторизованного пользователя, поэтому ID пользователя CRM передайте операциям через объект Context.

Создайте для обмена отдельного пользователя. Выдайте ему право читать, добавлять и изменять контакты и сделки. При изменении привязок операция проверяет право читать каждый привязанный контакт сделки, поэтому чтение контактов тоже обязательно. Запуск операций от нужного пользователя описывает статья Права доступа в PHP API CRM.

Не назначайте пользователя обмена исполнителем роботов, бизнес-процессов и ручной работы в CRM. Экспорт отличает собственные изменения обмена по этому пользователю.

Если у нового элемента не задан ответственный ASSIGNED_BY_ID, операция назначает ответственным пользователя из контекста. Поэтому код примера назначает новым контактам и сделкам ответственного из настроек модуля. Если пользователю обмена разрешено добавлять только элементы, где он сам ответственный, операция заменит это значение его ID.

Настройки и состояние обмена храните в настройках модуля через Bitrix\Main\Config\Option.

  • erp_user_id — ID пользователя обмена,

  • erp_assigned_id — ID сотрудника, которого код примера назначает ответственным за новые контакты и сделки,

  • erp_import_sequence — номер последнего изменения учетной системы, отчет о котором учетная система приняла,

  • erp_export_cursor — курсор экспорта: сохраненные время смены стадии и ID последней переданной сделки. По ним код определяет, с какой сделки продолжить следующую порцию. Значение хранится в формате JSON.

Задайте пользователя обмена и ответственного один раз, например в установщике модуля или в командной PHP-строке административного раздела. Подставьте ID пользователей своей установки вместо 15 и 7.

use Bitrix\Main\Config\Option;

Option::set('my.module', 'erp_user_id', '15');
Option::set('my.module', 'erp_assigned_id', '7');

Номер изменения и курсор экспорта задавать не нужно. Пока их нет, импорт начинает с номера 0, а экспорт — с начала отсчета времени.

Адрес и токен учетной системы не храните в коде и в настройках модуля. Добавьте собственную секцию в файл .settings_extra.php и читайте ее методом Configuration::getValue(), как описано в статье Конфигурация ядра.

Создайте файл /local/php_interface/lib/ErpSync.php и добавьте в него пространство имен, импорты, константы и вспомогательные функции. Функции обмена из следующих разделов добавляйте в этот же файл после них.

<?php

namespace Example\ErpSync;

use Bitrix\Crm\Binding\EntityBinding;
use Bitrix\Crm\CommunicationType;
use Bitrix\Crm\Integrity\DuplicateCommunicationCriterion;
use Bitrix\Crm\Item;
use Bitrix\Crm\Multifield\Type\Email;
use Bitrix\Crm\Multifield\Value;
use Bitrix\Crm\Service\Container;
use Bitrix\Crm\Service\Context;
use Bitrix\Crm\Service\Factory;
use Bitrix\Main\Config\Option;
use Bitrix\Main\Loader;
use Bitrix\Main\Type\DateTime;
use CCrmOwnerType;

const MODULE_ID = 'my.module';
const ORIGINATOR = 'erp';

function getFactory(int $entityTypeId): Factory
{
    $factory = Container::getInstance()->getFactory($entityTypeId);
    if ($factory === null)
    {
        throw new \RuntimeException('Фабрика CRM недоступна: ' . $entityTypeId);
    }

    return $factory;
}

function createContext(): Context
{
    $userId = (int)Option::get(MODULE_ID, 'erp_user_id', 0);
    if ($userId <= 0)
    {
        throw new \RuntimeException('Не задан пользователь обмена');
    }

    return (new Context())->setUserId($userId);
}

function getAssignedId(): int
{
    $assignedId = (int)Option::get(MODULE_ID, 'erp_assigned_id', 0);
    if ($assignedId <= 0)
    {
        throw new \RuntimeException('Не задан ответственный за элементы обмена');
    }

    return $assignedId;
}

Функция getAssignedId() возвращает ID ответственного для новых элементов. Функция createContext() возвращает контекст с пользователем обмена. Все операции примера получают этот контекст и проверяют права этого пользователя.

Без настройки erp_user_id функция createContext() останавливает обмен до первой записи. Без настройки erp_assigned_id функция getAssignedId() выбрасывает исключение при создании первого нового элемента. Изменения порции, обработанные до него, сохраняются.

Сопоставить записи двух систем

Перед записью код ищет элемент CRM, который уже соответствует записи учетной системы. Основной способ — внешний ключ, сохраненный в самом элементе. Поиск по email нужен только для контакта, который появился в CRM раньше обмена и еще не получил ключ.

Найти элемент по внешнему ключу

У контакта и сделки есть строковые поля для связи с внешней системой.

  • ORIGINATOR_ID — код внешней системы, которой принадлежит запись. В примере это erp.

  • ORIGIN_ID — ID записи во внешней системе, например contact-1001.

Поля не показываются в карточке и принимают строку длиной до 255 символов. Более длинное значение обрезается без ошибки, и разные ключи с общим началом совпадут. Проверяйте длину ключа в коде приложения до записи, функции примера ее не проверяют. Пара полей отличает записи разных внешних систем с одинаковыми ID.

CRM не проверяет уникальность пары ORIGINATOR_ID и ORIGIN_ID. Если два процесса одновременно не найдут запись и создадут ее, появится дубль. Поэтому запускайте импорт только из агента. Если нужна защита на уровне базы данных, храните соответствие ключей в собственной ORM-таблице с уникальным индексом.

Для поиска используйте метод фабрики getItems(), а не getItemsFilteredByPermissions(). Выборка с учетом прав не вернет запись, которую пользователь обмена не может читать, и код создаст дубль. Операция все равно проверит права на запись при launch().

Пример. Функция findByOrigin() возвращает элемент с внешним ключом или null. Если ключ нашелся у нескольких элементов, функция останавливает обработку.

function findByOrigin(Factory $factory, string $originId): ?Item
{
    $items = $factory->getItems([
        'filter' => [
            '=' . Item::FIELD_NAME_ORIGINATOR_ID => ORIGINATOR,
            '=' . Item::FIELD_NAME_ORIGIN_ID => $originId,
        ],
        'limit' => 2,
    ]);

    if (count($items) > 1)
    {
        throw new \DomainException('Несколько элементов с внешним ключом ' . $originId);
    }

    return $items[0] ?? null;
}

Выборка ограничена двумя записями — этого достаточно, чтобы обнаружить дубль. Исключение DomainException в примере обозначает изменение, которое нужно разобрать вручную. Функция runImport() записывает такие изменения в отчет со статусом review.

При добавлении контакта или сделки с непустым ORIGIN_ID операция дополнительно отправляет событие OnAfterExternalCrmContactAdd или OnAfterExternalCrmDealAdd. Учитывайте это, если в проекте есть обработчики таких событий.

У смарт-процесса полей ORIGINATOR_ID и ORIGIN_ID нет. Внешний ключ его элемента храните в XML_ID, как описано в статье Смарт-процессы. Для обмена ищите элемент по XML_ID тем же методом getItems() без фильтра прав.

Проверить дубль по email

Если контакт с ключом не найден, он мог появиться в CRM до обмена, например из формы на сайте. Класс DuplicateCommunicationCriterion находит контакты с тем же email по индексу дубликатов. Поиск и нормализацию значения подробно описывает статья Поля и данные клиента.

Совпадение email не доказывает, что это тот же покупатель. Один адрес может принадлежать нескольким людям, например общий адрес отдела. Функция findFreeContactIdByEmail() связывает запись автоматически, только если найден ровно один контакт и у него еще нет внешнего ключа. Если контактов несколько или у единственного контакта уже есть внешний ключ, функция останавливает обработку изменения для ручной проверки.

Пример. Функция findFreeContactIdByEmail() возвращает ID единственного подходящего контакта или null, если совпадений нет.

function findFreeContactIdByEmail(Factory $contactFactory, string $email): ?int
{
    $criterion = new DuplicateCommunicationCriterion(CommunicationType::EMAIL_NAME, $email);
    $duplicate = $criterion->find(CCrmOwnerType::Contact, 20);
    $candidateIds = $duplicate === null ? [] : $duplicate->getEntityIDsByType(CCrmOwnerType::Contact);
    if ($candidateIds === [])
    {
        return null;
    }

    $candidates = $contactFactory->getItems([
        'select' => [Item::FIELD_NAME_ID, Item::FIELD_NAME_ORIGIN_ID],
        'filter' => ['@ID' => $candidateIds],
    ]);

    if (count($candidates) !== 1 || (string)$candidates[0]->get(Item::FIELD_NAME_ORIGIN_ID) !== '')
    {
        throw new \DomainException('Нужна ручная проверка дубля контакта');
    }

    return $candidates[0]->getId();
}

Сообщение исключения не содержит email. Номер sequence связывает строку отчета с изменением, поэтому персональные данные не попадают в журнал. Функция ищет контакты без учета прав, как и findByOrigin().

Импортировать изменения без дублей

Каждое изменение учетной системы код обрабатывает в одном порядке.

  1. Найти контакт по внешнему ключу, а если его нет — проверить дубль по email.

  2. Создать контакт или обновить поля, которыми владеет учетная система.

  3. Найти сделку по ключу заказа, создать или обновить ее и привязать контакт.

  4. Записать результат в отчет.

Функция агента отправляет отчет порции и сохраняет номер изменения. Повторный запуск того же изменения проходит те же шаги. Контакт и сделка находятся по ключам, поэтому новые элементы не появляются.

Создать или обновить контакт

Функция importContact() заполняет ключ, имя, фамилию и email, а новому контакту назначает ответственного из функции getAssignedId(). Email она добавляет в коллекцию FM, которую вернул элемент. Поле FM хранит телефоны, email и другие способы связи контакта, поэтому добавление в полученную коллекцию сохраняет остальные значения. Порядок работы с этой коллекцией объясняет статья Поля и данные клиента.

Пример. Функция importContact() создает или обновляет контакт и возвращает его ID.

function importContact(array $data, Context $context): int
{
    $factory = getFactory(CCrmOwnerType::Contact);
    $originId = (string)$data['id'];
    $email = mb_strtolower(trim((string)($data['email'] ?? '')));

    $contact = findByOrigin($factory, $originId);
    if ($contact === null && $email !== '')
    {
        $contactId = findFreeContactIdByEmail($factory, $email);
        $contact = $contactId === null ? null : $factory->getItem($contactId);
    }

    if ($contact === null)
    {
        $contact = $factory->createItem();
        $contact->set(Item::FIELD_NAME_ASSIGNED, getAssignedId());
    }

    $contact->set(Item::FIELD_NAME_ORIGINATOR_ID, ORIGINATOR);
    $contact->set(Item::FIELD_NAME_ORIGIN_ID, $originId);
    $contact->set(Item::FIELD_NAME_NAME, (string)$data['name']);
    $contact->set(Item::FIELD_NAME_LAST_NAME, (string)$data['lastName']);

    if ($email !== '')
    {
        $fm = $contact->getFm();
        $hasEmail = false;
        foreach ($fm->filterByType(Email::ID) as $value)
        {
            if (mb_strtolower(trim((string)$value->getValue())) === $email)
            {
                $hasEmail = true;
                break;
            }
        }

        if (!$hasEmail)
        {
            $fm->add(
                (new Value())
                    ->setTypeId(Email::ID)
                    ->setValueType(Email::VALUE_TYPE_WORK)
                    ->setValue($email)
            );
            $contact->setFm($fm);
        }
    }

    $operation = $contact->isNew()
        ? $factory->getAddOperation($contact, $context)
        : $factory->getUpdateOperation($contact, $context)
    ;
    $result = $operation->launch();
    if (!$result->isSuccess())
    {
        throw new \DomainException(implode('; ', $result->getErrorMessages()));
    }

    return $contact->getId();
}

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

Операция проверяет права пользователя из контекста, обязательные поля и все значения коллекции FM. Если учетная система не передала обязательное поле или у контакта уже сохранен некорректный email, launch() вернет ошибку. Функция выбрасывает DomainException, и изменение уходит на ручной разбор. Метод getRequiredFields() возвращает список обязательных полей для операции, как описано в статье Поля и данные клиента.

Создать или обновить сделку

Новая сделка получает внешний ключ заказа, ответственного из функции getAssignedId() и начальную стадию, доступную пользователю обмена. У существующей сделки функция меняет только название и не трогает стадию — ей владеет CRM. Метод bindContacts() привязывает контакт и сохраняет остальные контакты сделки. Варианты привязки описывает статья Связи между элементами.

Пример. Функция importDeal() создает или обновляет сделку заказа, привязывает к ней контакт $contactId и возвращает ID сделки.

function importDeal(array $data, int $contactId, Context $context): int
{
    $factory = getFactory(CCrmOwnerType::Deal);
    $originId = (string)$data['id'];

    $deal = findByOrigin($factory, $originId);
    if ($deal === null)
    {
        $deal = $factory->createItem();
        $deal->set(Item::FIELD_NAME_ASSIGNED, getAssignedId());
        $deal->set(Item::FIELD_NAME_ORIGINATOR_ID, ORIGINATOR);
        $deal->set(Item::FIELD_NAME_ORIGIN_ID, $originId);

        if ($factory->setStartStageIdPermittedForUser($deal, $context->getUserId()) === null)
        {
            throw new \RuntimeException('Нет доступной начальной стадии сделки');
        }
    }

    $deal->set(Item::FIELD_NAME_TITLE, (string)$data['title']);

    if (!in_array($contactId, $deal->getContactIds(), true))
    {
        $deal->bindContacts(
            EntityBinding::prepareEntityBindings(CCrmOwnerType::Contact, [$contactId])
        );
    }

    $operation = $deal->isNew()
        ? $factory->getAddOperation($deal, $context)
        : $factory->getUpdateOperation($deal, $context)
    ;
    $result = $operation->launch();
    if (!$result->isSuccess())
    {
        throw new \DomainException(implode('; ', $result->getErrorMessages()));
    }

    return $deal->getId();
}

После успешного launch() сделка содержит ключ заказа, название и привязку к контакту. Проверка in_array() не дает повторно менять привязку, если контакт уже связан со сделкой.

Метод setStartStageIdPermittedForUser() ставит первую стадию направления сделки, доступную пользователю для добавления. Новая сделка создается в направлении по умолчанию. Чтобы выбрать другое направление, задайте CATEGORY_ID до вызова метода. Создание сделки в выбранном направлении описывает статья Направления и стадии.

Операции добавления и изменения выполняют связанные действия. Они записывают историю, отправляют события и по настройкам CRM запускают бизнес-процессы и автоматизацию. Не отключайте их ради обмена без согласования с владельцами процессов. Состав действий и способы их настройки описывает статья События и расширение поведения CRM.

Сформировать отчет и номер изменения

Функция runImport() обрабатывает порцию по возрастанию sequence и пропускает изменения с номером не больше $afterSequence. Она возвращает отчет и номер последнего обработанного изменения, но сама номер не сохраняет. Код агента сохраняет его только после того, как учетная система приняла отчет. Иначе при сбое отправки учетная система не получила бы ID сделок, а импорт уже не вернулся бы к этим изменениям.

Ошибки делятся на два вида.

  • DomainException означает, что изменение нельзя применить без ручного разбора. Это несколько кандидатов-дублей или отказ операции из-за прав и данных. Функция записывает статус review и переходит к следующему изменению.

  • Любое другое исключение означает ошибку окружения, которая не зависит от данных изменения. Например, недоступна фабрика, не задан ответственный erp_assigned_id, нет доступной начальной стадии или произошла ошибка базы данных. Функция записывает статус retry и останавливает порцию. Номер этого изменения в результат не попадает, и следующий запуск начнет с него, когда окружение исправят.

Функция переводит отказ операции в review, а не в повтор. Повтор с теми же данными завершится той же ошибкой, и одно такое изменение останавливало бы весь импорт при каждом запуске агента.

Пример. Функция runImport() импортирует порцию $changes после изменения с номером $afterSequence и возвращает массив с ключами lastSequence и report.

function runImport(array $changes, int $afterSequence, Context $context): array
{
    usort($changes, static fn(array $a, array $b): int => $a['sequence'] <=> $b['sequence']);

    $lastSequence = $afterSequence;
    $report = [];
    foreach ($changes as $change)
    {
        $sequence = (int)$change['sequence'];
        if ($sequence <= $lastSequence)
        {
            continue;
        }

        try
        {
            $contactId = importContact($change['contact'], $context);
            $dealId = importDeal($change['order'], $contactId, $context);
            $report[] = [
                'sequence' => $sequence,
                'status' => 'ok',
                'contactId' => $contactId,
                'dealId' => $dealId,
            ];
        }
        catch (\DomainException $exception)
        {
            $report[] = [
                'sequence' => $sequence,
                'status' => 'review',
                'error' => $exception->getMessage(),
            ];
        }
        catch (\Throwable $exception)
        {
            $report[] = [
                'sequence' => $sequence,
                'status' => 'retry',
                'error' => $exception->getMessage(),
            ];
            break;
        }

        $lastSequence = $sequence;
    }

    return [
        'lastSequence' => $lastSequence,
        'report' => $report,
    ];
}

Ключ lastSequence содержит номер последнего изменения со статусом ok или review. Строка массива report содержит следующие ключи.

  • sequence — номер изменения учетной системы,

  • status — статус ok, review или retry,

  • contactId и dealId — ID контакта и сделки, только для статуса ok,

  • error — текст ошибки, только для статусов review и retry.

Строки со статусом review разберите вручную и после исправления данных попросите учетную систему отправить изменение повторно с новым номером.

Сообщения операций в отчете содержат текст ошибки CRM, например о незаполненном обязательном поле. Значения полей покупателя функции примера в сообщения не добавляют.

Повторить порцию после сбоя

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

|

|| Где произошел сбой | Что найдет повторный запуск | Результат || || После создания контакта, до создания сделки | Контакт по ключу contact-1001 | Контакт не дублируется, сделка создается || || После записи сделки, до сохранения номера изменения | Контакт и сделку по ключам | Новые элементы не появляются, номер сохраняется || |#

Отдельный случай — операция сохранила сделку и вернула ошибку связанного действия. Изменение получает статус review, и строка отчета не содержит ID сделки. После разбора учетная система отправляет изменение с новым номером, импорт находит сделку по ключу order-5001 и возвращает ее ID в отчете. До этого сверка такую сделку не видит.

Перед повтором ищите элемент по ключу, как функции findByOrigin() и importDeal(), а не создавайте его заново по ID из отчета. Этапы операции перечисляет статья Схема работы CRM и основные объекты API.

Повтор безопасен, только если одно изменение не обрабатывают одновременно два процесса. Не запускайте runImport() вручную, пока работает агент обмена.

Передать изменения из CRM

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

Выбрать источник изменений

Изменения можно получать из событий или из выборки по времени. Пример получает стадию выборкой по времени перемещения сделки.

|

|| Источник | Что получает код | Ограничения || || Поля MOVED_TIME и MOVED_BY | Время последней смены стадии и пользователя, который ее сменил | Подходят только для стадии. Время записано с точностью до секунды || || Поле UPDATED_TIME | Время последнего изменения любого поля | Меняется и при записи импорта, поэтому требует дополнительной проверки собственных изменений || || Событие OnAfterCrmDealUpdate | Массив полей при каждом изменении | Выполняется синхронно с сохранением. Пропущенное событие нельзя получить повторно без собственной очереди || |#

Операция записывает в MOVED_TIME текущее время, а в MOVED_BY — ID пользователя из контекста, только когда меняется поле STAGE_ID. Импорт стадию существующей сделки не меняет. Новую сделку импорт создает от имени пользователя обмена, и ее MOVED_BY равен ID этого пользователя. Фильтр по MOVED_BY исключает такие сделки из экспорта, поэтому собственная запись импорта не возвращается в учетную систему.

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

Работу с событиями и защиту обработчиков от повторов описывает статья События и расширение поведения CRM.

Отобрать сделки после курсора

После отправки порции код запоминает, на какой сделке остановился. Эта позиция называется курсором экспорта и хранится в настройке erp_export_cursor. Она состоит из времени смены стадии MOVED_TIME и ID последней сделки, которую приняла учетная система.

Например, последняя переданная сделка имеет ID 120 и время смены стадии 10:30:00. Следующая порция включает сделки с более поздним временем, а также сделки с временем 10:30:00 и ID больше 120. Проверка ID нужна, потому что несколько сделок могут сменить стадию в одну секунду.

Выборка учитывает права пользователя обмена через getItemsFilteredByPermissions(). Сделка, которую этот пользователь не может читать, в экспорт не попадет. Проверьте, что пользователю доступны все сделки обмена, иначе часть стадий не дойдет до учетной системы.

Пример. Функция exportDealStages() отбирает до 50 сделок обмена, передает их стадии в функцию $send и сохраняет курсор, если $send вернула true.

function exportDealStages(Context $context, callable $send, int $limit = 50): int
{
    $factory = getFactory(CCrmOwnerType::Deal);
    $userId = $context->getUserId();

    $cursor = json_decode((string)Option::get(MODULE_ID, 'erp_export_cursor', ''), true);
    $cursorTime = DateTime::createFromTimestamp((int)($cursor['time'] ?? 0));
    $cursorId = (int)($cursor['id'] ?? 0);

    $deals = $factory->getItemsFilteredByPermissions([
        'select' => [
            Item::FIELD_NAME_ID,
            Item::FIELD_NAME_ORIGIN_ID,
            Item::FIELD_NAME_STAGE_ID,
            Item::FIELD_NAME_MOVED_TIME,
        ],
        'filter' => [
            '=' . Item::FIELD_NAME_ORIGINATOR_ID => ORIGINATOR,
            '!=' . Item::FIELD_NAME_MOVED_BY => $userId,
            [
                'LOGIC' => 'OR',
                ['>' . Item::FIELD_NAME_MOVED_TIME => $cursorTime],
                [
                    '=' . Item::FIELD_NAME_MOVED_TIME => $cursorTime,
                    '>' . Item::FIELD_NAME_ID => $cursorId,
                ],
            ],
        ],
        'order' => [
            Item::FIELD_NAME_MOVED_TIME => 'ASC',
            Item::FIELD_NAME_ID => 'ASC',
        ],
        'limit' => $limit,
    ], $userId);

    if ($deals === [])
    {
        return 0;
    }

    $stages = [];
    foreach ($deals as $deal)
    {
        $stages[] = [
            'orderId' => (string)$deal->get(Item::FIELD_NAME_ORIGIN_ID),
            'dealId' => $deal->getId(),
            'stageId' => (string)$deal->get(Item::FIELD_NAME_STAGE_ID),
        ];
    }

    if ($send($stages) !== true)
    {
        throw new \RuntimeException('Учетная система не подтвердила прием стадий');
    }

    $lastDeal = $deals[count($deals) - 1];
    Option::set(MODULE_ID, 'erp_export_cursor', json_encode([
        'time' => $lastDeal->get(Item::FIELD_NAME_MOVED_TIME)->getTimestamp(),
        'id' => $lastDeal->getId(),
    ]));

    return count($stages);
}

Функция возвращает число переданных сделок. Каждая строка $stages содержит ключ заказа orderId, ID сделки dealId и код стадии stageId. Коды стадий зависят от направления сделки, поэтому сопоставьте их с состояниями заказа в учетной системе заранее. Получение стадий направления описывает статья Направления и стадии.

Если $send не вернула true, функция выбрасывает RuntimeException и не меняет курсор. Следующий запуск передаст те же сделки, поэтому учетная система должна принимать повторно ту же стадию без изменений у себя.

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

Если такие пропуски возможны при нагрузке проекта, выбирайте сделки начиная с момента на несколько секунд раньше времени в курсоре. Часть стадий придет повторно, поэтому учетная система должна принимать их без повторных изменений у себя. В этот промежуток должно попадать меньше $limit сделок, иначе выборка будет возвращать одну и ту же порцию. В показанной функции такой отступ не реализован.

Учесть удаления

Выборка по MOVED_TIME не находит сделки, которых больше нет в CRM. После удаления CRM отправляет событие OnAfterCrmDealDelete с ID сделки, но прочитать ее поля, включая ORIGIN_ID, уже нельзя. Поэтому учетная система хранит ID сделки из отчета импорта и по нему определяет заказ.

Передавайте удаление из обработчика события в собственную очередь заданий, а не обращайтесь к учетной системе синхронно. Контракт события и защиту очереди от повторов описывает статья События и расширение поведения CRM. Удаление, которое не дошло до учетной системы, найдет сверка.

После удаления сделки в CRM учетная система не должна присылать изменения этого заказа. Функция importDeal() не найдет сделку по ключу и создаст новую. Если учетная система продолжает менять такие заказы, отмечайте удаленные заказы у нее по сообщениям очереди удалений или результату сверки и пропускайте их.

Сделку, удаленную в корзину, могут восстановить. Восстановленная сделка сохраняет ORIGIN_ID, а операция восстановления повторно отправляет событие OnAfterExternalCrmDealAdd. Сверка перестанет показывать для ее заказа значение missing.

Решите заранее, что делать с заказом, который удалили в учетной системе. Удаление сделки в CRM запускает собственные связанные действия. Поэтому вместо удаления можно перевести сделку в финальную стадию по правилам CRM.

Запустить обмен по расписанию

Агент выполняет импорт и экспорт в одном запуске. На время запуска система блокирует агента на десять минут. Если запуск длится дольше, блокировка истекает, и тот же агент может запуститься параллельно. Держите порцию небольшой, чтобы запуск укладывался в десять минут, и не вызывайте функции обмена из другого места. Регистрацию, запуск через cron и ограничения агентов описывает статья Агенты и фоновые задачи.

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

Приложение реализует функции транспорта через HTTP-клиент в том же файле ErpSync.php. В этот же файл добавьте функцию агента runExchangeAgent().

  • fetchErpChanges(int $afterSequence, int $limit): array — возвращает порцию изменений после указанного номера в формате контракта.

  • sendErpReport(array $report): void — передает отчет импорта. При сбое выбрасывает исключение.

  • sendErpStages(array $stages): bool — передает стадии сделок и возвращает true, если учетная система подтвердила прием.

Пример. Функция runExchangeAgent() запускает импорт и экспорт и сохраняет номер изменения после отправки отчета.

function runExchangeAgent(): string
{
    $agent = '\\Example\\ErpSync\\runExchangeAgent();';
    if (!Loader::includeModule('crm'))
    {
        return $agent;
    }

    try
    {
        $context = createContext();

        $afterSequence = (int)Option::get(MODULE_ID, 'erp_import_sequence', 0);
        $import = runImport(fetchErpChanges($afterSequence, 50), $afterSequence, $context);
        if ($import['report'] !== [])
        {
            sendErpReport($import['report']);
        }

        if ($import['lastSequence'] > $afterSequence)
        {
            Option::set(MODULE_ID, 'erp_import_sequence', (string)$import['lastSequence']);
        }

        exportDealStages($context, static fn(array $stages): bool => sendErpStages($stages));
    }
    catch (\Throwable $exception)
    {
        error_log('ERP exchange failed: ' . $exception->getMessage());
    }

    return $agent;
}

Функция всегда возвращает строку своего вызова, поэтому агент запустится снова по расписанию. Ошибка одного запуска не останавливает агента.

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

Пример. Код подключает файл обмена без модуля и регистрирует агента, который запускает обмен каждые 300 секунд.

require_once $_SERVER['DOCUMENT_ROOT'] . '/local/php_interface/lib/ErpSync.php';

\CAgent::AddAgent(
    '\\Example\\ErpSync\\runExchangeAgent();',
    '',
    'N',
    300
);

Строку require_once добавьте в /local/php_interface/init.php, чтобы функция агента была доступна при каждом запуске. Вызов AddAgent() выполните один раз, например в установщике модуля. Если код обмена находится в собственном модуле, укажите ID модуля вторым аргументом. Функции пространства имен не подключаются автозагрузкой, поэтому в модуле подключайте файл с ними в include.php или перенесите код в класс.

Проверить обмен и восстановление

Запускайте проверку на тестовой установке с теми же правами пользователя обмена, что и в рабочей. Для проверки импорта учетная система не нужна.

Выполните код в командной PHP-строке административного раздела или в скрипте после пролога. Перед запуском подключите модуль crm через Loader::includeModule(), убедитесь, что ErpSync.php подключен в init.php, а настройки erp_user_id и erp_assigned_id заданы. Возьмите массив $changes и передайте его напрямую в runImport().

Проверить результат в обе стороны

Счетчик элементов по ключу подтверждает, что повтор не создал дубль. Метод фабрики getItemsCount() возвращает число элементов по фильтру без учета прав.

Запустите импорт порции дважды с нулевым номером и посчитайте сделки с ключом order-5001. Функция runImport() номер не сохраняет, поэтому второй запуск обработает те же изменения.

use Bitrix\Crm\Item;

$context = \Example\ErpSync\createContext();
\Example\ErpSync\runImport($changes, 0, $context);
\Example\ErpSync\runImport($changes, 0, $context);

$dealCount = \Example\ErpSync\getFactory(\CCrmOwnerType::Deal)->getItemsCount([
    '=' . Item::FIELD_NAME_ORIGINATOR_ID => \Example\ErpSync\ORIGINATOR,
    '=' . Item::FIELD_NAME_ORIGIN_ID => 'order-5001',
]);

Переменная $dealCount должна быть равна 1. Так же проверьте контакт с ключом contact-1001.

|

|| Проверка | Ожидаемый результат || || Импортировать новое изменение | Созданы контакт и сделка с ключами, сделка связана с контактом, отчет содержит статус ok || || Повторить ту же порцию | Число контактов и сделок с ключами не изменилось || || Импортировать покупателя с email существующего контакта без ключа | Контакт получил ключ, новый контакт не создан || || Импортировать покупателя с email двух контактов | Отчет содержит статус review, контакт и сделка не созданы || || Отозвать у пользователя обмена право изменять сделки | Отчет содержит статус review с ошибкой доступа, следующее изменение порции обработано || || Удалить значение erp_user_id из настроек модуля | Импорт остановлен до первой записи, номер изменения не сохранен || || Сделать поле контакта обязательным в настройках CRM и импортировать покупателя без него | Отчет содержит статус review, импорт продолжил следующее изменение || || Сменить стадию сделки в карточке от имени сотрудника, а не пользователя обмена | Следующий экспорт передал новую стадию, курсор сдвинулся || || Импортировать изменение названия сделки | Экспорт не передал сделку повторно || || Сменить стадию у 60 сделок обмена | Первый экспорт передал 50 сделок, следующий запуск агента — остальные 10 || || Удалить сделку обмена в CRM | Сверка вернула для ее заказа значение missing || || Вернуть из $send значение false | Функция выбросила RuntimeException, курсор не изменился, следующий экспорт повторно передал неподтвержденные сделки || || Выбросить исключение в sendErpReport() | Номер изменения не сохранен, экспорт в этом запуске не выполнен, следующий запуск повторил импорт без новых элементов || || Удалить значение erp_assigned_id и импортировать новый заказ | Отчет содержит статус retry, порция остановлена, номер изменения не сдвинулся || |#

Сверить расхождения

Функция findDealMismatches() находит то, что обмен пропустил, — удаленные сделки и ошибочно связанные ключи. Запускайте ее отдельным агентом, например раз в сутки. Список заказов с ID сделок из отчетов импорта получите от учетной системы через транспорт приложения, как изменения в fetchErpChanges(). Результат сверки передайте ответственному за обмен или учетной системе тем же способом, что и отчет импорта.

Пример. Функция findDealMismatches() принимает массив, где ключ — ID заказа, а значение — ID сделки, и возвращает расхождения.

function findDealMismatches(array $erpDeals): array
{
    if ($erpDeals === [])
    {
        return [];
    }

    $deals = getFactory(CCrmOwnerType::Deal)->getItems([
        'select' => [
            Item::FIELD_NAME_ID,
            Item::FIELD_NAME_ORIGINATOR_ID,
            Item::FIELD_NAME_ORIGIN_ID,
        ],
        'filter' => ['@ID' => array_values($erpDeals)],
    ]);

    $originByDealId = [];
    foreach ($deals as $deal)
    {
        $isOwnDeal = (string)$deal->get(Item::FIELD_NAME_ORIGINATOR_ID) === ORIGINATOR;
        $originByDealId[$deal->getId()] = $isOwnDeal ? (string)$deal->get(Item::FIELD_NAME_ORIGIN_ID) : '';
    }

    $mismatches = [];
    foreach ($erpDeals as $orderId => $dealId)
    {
        if (!isset($originByDealId[$dealId]))
        {
            $mismatches[$orderId] = 'missing';
        }
        elseif ($originByDealId[$dealId] !== (string)$orderId)
        {
            $mismatches[$orderId] = 'key-mismatch';
        }
    }

    return $mismatches;
}

Значение missing означает, что сделки с этим ID нет в CRM, например ее удалили. Значение key-mismatch означает, что у сделки другой внешний ключ или ключ другой внешней системы. Для большого списка вызывайте функцию порциями по несколько сотен ID. Расхождения разбирайте вручную, не исправляйте их автоматически тем же кодом обмена.

Вести журнал без персональных данных

Записи нужны, чтобы восстановить последовательность обмена после сбоя. Сохраняйте номер изменения, ключи учетной системы, ID элементов CRM, статус и текст ошибки. Не записывайте имена, email и другие данные покупателя. Для журнала используйте логгеры Framework вместо error_log() из примера.

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