← Назад в блог

Двусторонняя синхронизация статусов CMS и CRM в 1C-Битрикс

Как связать статусы заказов интернет-магазина и сделок CRM в коробочном 1C-Битрикс: OnSaleOrderSaved, OnAfterCrmDealUpdate, без циклов и с приоритетом оплаты. Готовые примеры на PHP.

Двусторонняя синхронизация статусов CMS и CRM в 1C-Битрикс

Требования

  • 1C-Битрикс (коробочная версия с модулями Интернет-магазин и CRM)
  • PHP 7.4+
  • Понимание событий Bitrix (AddEventHandler, EventManager)
  • Доступ к файловой системе сервера

Двусторонняя синхронизация статусов: CMS и CRM в 1C-Битрикс

Типичная схема на коробочном Битрикс: заказы создаются в интернет-магазине (CMS), сделки ведутся в CRM, а бизнесу нужна синхронизация в обе стороны. Меняют статус в одном месте — он должен подтянуться в другом, без ручного дублирования и без зависаний при оплате.

В статье — как реализовать такую синхронизацию без циклов, без костылей и с приоритетом для оплаты, по документации 1C-Битрикс и с готовыми примерами кода. В конце — блоки про отладку, типичные грабли (обновления ядра, кастомные воронки, права) и когда синхронизацию разумнее вынести в очередь.


Задача

Формулировка от заказчика обычно такая:

Статусы заказа в CMS и стадии сделки в CRM должны совпадать в обе стороны.

То есть:

  • при смене статуса заказа в CMS должна обновиться связанная сделка в CRM;
  • при смене стадии сделки в CRM — связанный заказ в CMS;
  • оплата заказа должна почти сразу отражаться в CRM;
  • без зацикливаний и лишних обновлений;
  • без костылей в виде крона или ручного дублирования.

Почему нельзя делать «в лоб»

Если навесить обработчики на события без учёта источника изменения:

  1. CMS меняет статус → CRM по событию обновляет сделку.
  2. CRM меняет стадию → CMS по событию обновляет заказ.
  3. 🔁 Возникает цикл: обновление тянет за собой следующее обновление.
  4. Растёт нагрузка на БД, статусы «прыгают», данные перестают быть консистентными.

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


Где размещать код и как подключать

Коробка 1C-Битрикс: CMS и CRM на одном ядре

В типовой установке один сервер, одно ядро, но два приложения:

  • CMS (сайт магазина) — например, /ext_www/<domain>/public_html;
  • CRM-портал — например, /ext_www/crm.<domain>/.

У каждого свой DOCUMENT_ROOT, поэтому код синхронизации разносим по двум местам: в CMS — логика по заказам, в CRM — по сделкам. Структура в обоих случаях: каталог local/php_interface/lib/ и подключение из init.php.


CMS (сайт)

Файл синхронизации (путь относительно сервера):

/home/bitrix/ext_www/<domain>/public_html/local/php_interface/lib/
└── SyncCmsOrderToDeal.php

Подключение в init.php:

$path_order_to_deal = $_SERVER["DOCUMENT_ROOT"] . "/local/php_interface/lib/SyncCmsOrderToDeal.php";

if (file_exists($path_order_to_deal)) {
    require_once $path_order_to_deal;

    // Основной триггер — создание и сохранение заказа
    \Bitrix\Main\EventManager::getInstance()->addEventHandler(
        'sale',
        'OnSaleOrderSaved',
        [\Local\Sync\SyncCmsOrderToDeal::class, 'onOrderSaved']
    );

    // При необходимости — отдельный хук на смену статуса
    // AddEventHandler("sale", "OnSaleStatusOrder", [\Local\Sync\SyncCmsOrderToDeal::class, "onOrderStatusChange"]);
}

При сохранении заказа (событие OnSaleOrderSaved) скрипт проверяет, есть ли связанная сделка и нужно ли обновить её стадию.


CRM (портал)

Файлы синхронизации (на портале CRM):

/home/bitrix/ext_www/crm.<domain>/local/php_interface/lib/
├── SyncCrmDealToOrder.php
└── SyncCrmOrderToDeal.php

Подключение в init.php:

$sync_deal_to_order = $_SERVER["DOCUMENT_ROOT"] . "/local/php_interface/lib/SyncCrmDealToOrder.php";
$path_order_to_deal = $_SERVER["DOCUMENT_ROOT"] . "/local/php_interface/lib/SyncCrmOrderToDeal.php";

if (file_exists($sync_deal_to_order)) {
    require_once $sync_deal_to_order;
}

if (file_exists($path_order_to_deal)) {
    require_once $path_order_to_deal;
}

// CRM → CMS (изменение стадии сделки)
AddEventHandler("crm", "OnAfterCrmDealUpdate", [\Local\Sync\SyncCrmDealToOrder::class, "onDealUpdate"]);
// AddEventHandler("crm", "OnAfterCrmDealAdd", [\Local\Sync\SyncCrmDealToOrder::class, "onDealAdd"]);

// CMS → CRM (изменение статуса заказа)
AddEventHandler("sale", "OnSaleStatusOrder", [\Local\Sync\SyncCrmOrderToDeal::class, "onOrderStatusChange"]);

При изменении сделки (OnAfterCrmDealUpdate) обновляется связанный заказ в CMS; при необходимости можно дополнительно обрабатывать смену статуса заказа (OnSaleStatusOrder) для обратного направления CMS → CRM.


Один ядро — два контекста

Общее ядро не значит общий контекст: у CMS и CRM разный DOCUMENT_ROOT, подключать файлы «через соседний портал» (типа ../) нельзя — каждый портал живёт в своём наборе событий. Разнесение кода по порталам убирает конфликты, не мешает обновлениям ядра и упрощает поддержку.


Архитектура решения

Логику удобно разнести на три сценария:

  1. CMS → CRM — смена статуса заказа тянет за собой стадию сделки.
  2. CRM → CMS — смена стадии сделки тянет за собой статус заказа.
  3. Оплата — отдельный приоритетный кейс, чтобы оплата сразу попадала в CRM.

В каждом сценарии обработчик реагирует только на своё событие, перед записью проверяет текущее состояние (чтобы не дублировать одно и то же значение) и не вызывает обратное обновление — так циклы не возникают.


Сценарий 1: CMS → CRM

Триггер: сохранение заказа в CMS (событие OnSaleOrderSaved) или смена статуса (OnSaleStatusOrder).

Логика: убедиться, что статус заказа реально изменился; найти связанную сделку (по свойству заказа/сделки); смаппить статус заказа в стадию сделки; обновить сделку только если текущая стадия не совпадает с нужной — так мы не провоцируем обратный вызов в CMS.

Псевдокод:

onOrderStatusChange(order):
  if статус не изменился → return
  dealId = getLinkedDeal(order)
  if нет dealId → return
  crmStatus = mapOrderStatusToDeal(order.status)
  if deal.stage != crmStatus → updateDealStatus(dealId, crmStatus)

Пример класса для CMS (по документации Bitrix — обработчик получает объект события):

<?php
namespace Local\Sync;

use Bitrix\Main\Event;
use Bitrix\Main\EventResult;

class SyncCmsOrderToDeal
{
    public static function onOrderSaved(Event $event): void
    {
        if (!\CModule::IncludeModule('crm')) {
            return;
        }
        $order = $event->getParameter('ENTITY');
        if (!$order || !method_exists($order, 'getId')) {
            return;
        }

        $orderId = $order->getId();
        $status = (string) $order->getField('STATUS_ID');

        $dealId = self::getLinkedDealId($orderId);
        if (!$dealId) {
            return;
        }

        $targetStage = self::mapOrderStatusToDeal($status);
        $currentStage = self::getDealStage($dealId);
        if ($currentStage === $targetStage) {
            return;
        }

        self::updateDealStage($dealId, $targetStage);
    }

    private static function getLinkedDealId(int $orderId): ?int
    {
        $res = \CCrmDeal::GetList(
            [],
            ['UF_CRM_ORDER_ID' => $orderId],
            false,
            ['nTopCount' => 1],
            ['ID']
        );
        $row = $res ? $res->Fetch() : null;
        return $row ? (int) $row['ID'] : null;
    }

    private static function getDealStage(int $dealId): ?string
    {
        $deal = \CCrmDeal::GetByID($dealId);
        return $deal ? ($deal['STAGE_ID'] ?? null) : null;
    }

    private static function mapOrderStatusToDeal(string $orderStatus): string
    {
        $map = ['N' => 'NEW', 'P' => 'PREPARATION', 'F' => 'WON', 'C' => 'LOSE'];
        return $map[$orderStatus] ?? 'NEW';
    }

    private static function updateDealStage(int $dealId, string $stageId): void
    {
        $deal = new \CCrmDeal(false);
        $deal->Update($dealId, ['STAGE_ID' => $stageId]);
    }
}

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


Сценарий 2: CRM → CMS

Триггер: обновление сделки в CRM (OnAfterCrmDealUpdate).

Логика: по сделке находим связанный заказ; маппим стадию сделки в статус заказа; обновляем заказ только если его текущий статус не совпадает с целевым — тот же принцип «обновляем при расхождении», что и в сценарии 1.

Псевдокод:

onDealUpdate(deal):
  orderId = getLinkedOrder(deal)
  if нет orderId → return
  orderStatus = mapDealStageToOrder(deal.STAGE_ID)
  if order.status != orderStatus → updateOrderStatus(orderId, orderStatus)

Пример класса для CRM (обработчик вызывается из init.php портала CRM; в документации — события модуля CRM):

<?php
namespace Local\Sync;

class SyncCrmDealToOrder
{
    /**
     * Обработчик OnAfterCrmDealUpdate: параметры ($id, &$arFields) по документации 1C-Битрикс.
     */
    public static function onDealUpdate($id, &$arFields): void
    {
        $id = (int) $id;
        if ($id <= 0) {
            return;
        }

        if (!\CModule::IncludeModule('sale')) {
            return;
        }

        $deal = \CCrmDeal::GetByID($id);
        if (!$deal) {
            return;
        }

        $orderId = (int) ($deal['UF_CRM_ORDER_ID'] ?? 0);
        if ($orderId <= 0) {
            return;
        }

        $newStage = (string) ($deal['STAGE_ID'] ?? '');
        $targetOrderStatus = self::mapDealStageToOrder($newStage);

        $currentStatus = self::getOrderStatus($orderId);
        if ($currentStatus === $targetOrderStatus) {
            return;
        }

        self::updateOrderStatus($orderId, $targetOrderStatus);
    }

    private static function getOrderStatus(int $orderId): ?string
    {
        $order = \Bitrix\Sale\Order::load($orderId);
        return $order ? $order->getField('STATUS_ID') : null;
    }

    private static function mapDealStageToOrder(string $stageId): string
    {
        $map = ['NEW' => 'N', 'PREPARATION' => 'P', 'WON' => 'F', 'LOSE' => 'C'];
        return $map[$stageId] ?? 'N';
    }

    private static function updateOrderStatus(int $orderId, string $statusId): void
    {
        $order = \Bitrix\Sale\Order::load($orderId);
        if (!$order) {
            return;
        }
        $order->setField('STATUS_ID', $statusId);
        $order->save();
    }
}

Имя поля связи сделки с заказом (UF_CRM_ORDER_ID) может отличаться в вашей конфигурации — смотрите свойства сделки в настройках CRM.


Сценарий 3: Оплата (приоритет)

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

Триггер: смена статуса заказа на оплаченный (в обработчике CMS по OnSaleOrderSaved или отдельно по OnSaleStatusOrder проверяем, что новый статус — оплата).

Пример фрагмента в обработчике заказа в CMS:

// В том же SyncCmsOrderToDeal или отдельном обработчике OnSaleStatusOrder
public static function onOrderStatusChange(int $orderId, string $status): void
{
    // Статус «оплачен» в типовой конфигурации интернет-магазина — например 'P' или свой код
    $paidStatuses = ['P', 'F']; // подставьте свои коды оплаченных статусов
    if (!in_array($status, $paidStatuses, true)) {
        return;
    }

    $dealId = self::getLinkedDealId($orderId);
    if (!$dealId) {
        return;
    }

    $stagePaid = 'PREPARATION'; // или ваша стадия «Оплачено»
    $current = self::getDealStage($dealId);
    if ($current !== $stagePaid) {
        self::updateDealStage($dealId, $stagePaid);
    }
}

В результате задержка — доли секунды; в CRM оплата отображается сразу после смены статуса заказа.


Как связать заказ и сделку

В 1C-Битрикс связь «заказ ↔ сделка» делают через пользовательские поля: у сделки — поле с ID заказа (часто UF_CRM_ORDER_ID или свой код в настройках CRM), у заказа при необходимости — свойство с ID сделки. Код полей смотрите в настройках модуля CRM и в полях заказа. Ниже — примеры по API CCrmDeal.

Получить ID связанной сделки по ID заказа:

function getLinkedDealId(int $orderId): ?int
{
    $res = \CCrmDeal::GetList(
        [],
        ['UF_CRM_ORDER_ID' => $orderId],
        false,
        ['nTopCount' => 1],
        ['ID', 'STAGE_ID']
    );
    $row = $res ? $res->Fetch() : null;
    return $row ? (int) $row['ID'] : null;
}

Получить ID связанного заказа по ID сделки:

function getLinkedOrderId(int $dealId): ?int
{
    $deal = \CCrmDeal::GetByID($dealId);
    if (!$deal) {
        return null;
    }
    $orderId = $deal['UF_CRM_ORDER_ID'] ?? null;
    return $orderId ? (int) $orderId : null;
}

Если у вас поле связи называется иначе — подставьте свой код вместо UF_CRM_ORDER_ID.


Маппинг статусов

Коды статусов заказа и коды стадий сделки в Битрикс не совпадают, поэтому нужны две функции перевода: заказ → сделка и сделка → заказ. В типовой конфигурации часто встречаются такие соответствия (актуальные коды смотрите в настройках статусов и воронки):

function mapOrderStatusToDeal(string $orderStatus): string
{
    $mapping = [
        'N' => 'NEW',           // Новый
        'P' => 'PREPARATION',   // Принят / в работе
        'F' => 'WON',           // Выполнен
        'C' => 'LOSE',          // Отменён
    ];
    return $mapping[$orderStatus] ?? 'NEW';
}

function mapDealStageToOrder(string $dealStage): string
{
    $mapping = [
        'NEW' => 'N',
        'PREPARATION' => 'P',
        'WON' => 'F',
        'LOSE' => 'C',
    ];
    return $mapping[$dealStage] ?? 'N';
}

Маппинг лучше сделать симметричным и учесть все статусы и стадии, которые реально используются в проекте — иначе часть переходов «потеряется».


Отладка и поддержка

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

Минимальный лог в обработчике (без лишней нагрузки):

// В начале класса или в конфиге
private static function log(string $message, array $context = []): void
{
    if (!defined('LOCAL_SYNC_DEBUG') || !LOCAL_SYNC_DEBUG) {
        return;
    }
    $line = date('Y-m-d H:i:s') . ' [Sync] ' . $message;
    if ($context !== []) {
        $line .= ' ' . json_encode($context, JSON_UNESCAPED_UNICODE);
    }
    file_put_contents(
        $_SERVER['DOCUMENT_ROOT'] . '/local/log/sync_status.log',
        $line . PHP_EOL,
        FILE_APPEND | LOCK_EX
    );
}

Перед вызовом Update/save() вызывайте self::log('updating deal', ['dealId' => $dealId, 'stage' => $targetStage]) — по логу будет видно, срабатывает ли обработчик и какие значения уходят. Каталог local/log/ создайте вручную и добавьте в .gitignore или вынесите путь в настройки. Временно включить отладку: в init.php или в константах проекта задать define('LOCAL_SYNC_DEBUG', true); и не забыть выключить после разбора.

Проверка связи заказ–сделка: если синхронизация «не едет», первым делом убедитесь, что у сделки реально заполнено поле с ID заказа и что код поля в коде совпадает с кодом в настройках CRM (Настройки → Настройки продукта → Сделки → Поля сделки). Опечатка в UF_CRM_ORDER_ID vs UF_ORDER_ID — частая причина «ничего не обновляется».


Типичные грабли и нюансы

Обновления ядра. Всё лежит в local/, ядро в bitrix/ не трогаем — после обновления 1C-Битрикс ваши обработчики остаются на месте. События OnSaleOrderSaved и OnAfterCrmDealUpdate давно в ядре, ломать их в патчах не должны. Если после обновления что-то перестало срабатывать — смотрите список изменений в обновлении по модулям sale и crm.

Кастомные воронки и статусы. В статье приведён маппинг для типовой конфигурации. Если у вас своя воронка сделок или свои статусы заказа (отдельные коды для «Оплачен», «Передан в доставку» и т.д.) — вынесите маппинг в конфиг (массив в отдельном файле или в настройках модуля) и заполните его по справке «Статусы заказов» и «Стадии сделок» в админке. Один раз настроили — дальше меняется только конфиг, без правок логики.

Повторный вызов при save(). В D7 при $order->save() ядро может вызывать несколько событий; в старом API при обновлении сделки тоже возможны двойные вызовы. Проверка «текущее значение уже равно целевому» как раз отсекает лишние обновления и защищает от циклов. Оставляйте её обязательно.

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


Когда синхронизацию лучше не вешать на события

Подход с событиями подходит, когда объёмы умеренные и задержка в доли секунды допустима. Если заказов сотни в минуту и каждый тянет за собой обновление сделки, нагрузка на БД и время отклика могут вырасти. В таких случаях имеет смысл рассмотреть очередь: обработчик только ставит задачу (в агент Битрикс, во внешнюю очередь или в таблицу заданий), а обновление сделки/заказа выполняет фоновый процесс пакетами. Для большинства магазинов событийная схема из статьи достаточна; очередь — следующий уровень оптимизации при реально высоких нагрузках.


Что в итоге

При такой схеме:

  • статусы заказа и стадии сделки остаются согласованными между CMS и CRM;
  • оплата попадает в CRM практически сразу;
  • циклов и лишних обновлений нет;
  • лишней нагрузки на БД не создаётся.

Кратко по сути

Двусторонняя синхронизация в Битрикс держится на трёх вещах: разделении сценариев (кто на какое событие реагирует), проверке «нужно ли вообще что-то менять» перед записью и использовании корректных событий из документации. Тогда система работает стабильно и не требует постоянного вмешательства.


Репозиторий

Готовый обезличенный код, примеры и структура проекта:
https://github.com/va-proger/vp_bitrix_deal_to_order_status_sync

0 просмотров

Комментарии

Загрузка комментариев...
Пока нет комментариев. Будьте первым!