← Назад в блог

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

Как связать статусы заказов и сделок в Битрикс без циклов: OnSaleOrderSaved, OnAfterCrmDealUpdate, маппинг статусов, проверка расхождений. Готовые примеры кода.

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

Требования

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

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

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

Решение: разделение на три сценария (CMS → CRM, CRM → CMS, оплата), проверка расхождений перед обновлением, использование корректных событий из документации. Готовые примеры кода для коробочной версии.


В чём проблема: почему нельзя делать «в лоб»

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

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

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

Решение: явно разделять сценарии и обновлять вторую сторону только когда значения реально различаются.


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

Логику разносим на три независимых сценария:

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

  • Триггер: сохранение заказа в CMS (OnSaleOrderSaved)
  • Действие: обновить стадию сделки в CRM
  • Защита от цикла: проверять, что статус заказа изменился

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

  • Триггер: обновление сделки в CRM (OnAfterCrmDealUpdate)
  • Действие: обновить статус заказа в CMS
  • Защита от цикла: проверять, что стадя сделки изменилась

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

  • Триггер: смена статуса заказа на оплаченный
  • Действие: сразу обновить сделку в CRM
  • Особенность: без лишних проверок, минимальная задержка

Где размещать код

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

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

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

У каждого свой DOCUMENT_ROOT, поэтому код синхронизации разносим по двум местам.

Структура файлов

CMS (сайт):
/home/bitrix/ext_www/<domain>/public_html/local/php_interface/
├── init.php
└── lib/
    └── SyncCmsOrderToDeal.php

CRM (портал):
/home/bitrix/ext_www/crm.<domain>/local/php_interface/
├── init.php
├── lib/
│   ├── SyncCrmDealToOrder.php
│   └── SyncCrmOrderToDeal.php

Рабочее решение: пошаговая реализация

Шаг 1: Подключение обработчиков в CMS

Файл /local/php_interface/init.php (CMS):

<?php
// Подключение синхронизации CMS → CRM
$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']
    );
}

Шаг 2: Подключение обработчиков в CRM

Файл /local/php_interface/init.php (CRM):

<?php
// Подключение синхронизации CRM ↔ CMS
$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"]);

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

Шаг 3: Обработчик CMS → CRM

Файл /local/php_interface/lib/SyncCmsOrderToDeal.php:

<?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]);
    }
}

Шаг 4: Обработчик CRM → CMS

Файл /local/php_interface/lib/SyncCrmDealToOrder.php:

<?php
namespace Local\Sync;

class SyncCrmDealToOrder
{
    /**
     * Обработчик OnAfterCrmDealUpdate: параметры ($id, &$arFields)
     */
    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();
    }
}

Шаг 5: Обработчик смены статуса заказа в CRM (OnSaleStatusOrder)

Событие OnSaleStatusOrder вызывается после изменения статуса заказа. Параметры по документации Битрикс: $ID — идентификатор заказа, $val — новый идентификатор статуса. Обработчик размещается в CRM (там же, где висит событие).

Файл /local/php_interface/lib/SyncCrmOrderToDeal.php (на портале CRM):

<?php
namespace Local\Sync;

/**
 * Синхронизация: смена статуса заказа (CMS) → обновление стадии сделки (CRM).
 * Обработчик события OnSaleStatusOrder (параметры по документации: ID заказа, val — код статуса).
 * @see https://dev.1c-bitrix.ru/api_help/sale/events/events_status_order.php
 */
class SyncCrmOrderToDeal
{
    /**
     * Вызывается после изменения статуса заказа. Параметры: $ID, $val (документация Sale).
     * @param int $ID   Идентификатор заказа
     * @param string $val Новый идентификатор статуса (N, P, F, C и т.д.)
     */
    public static function onOrderStatusChange($ID, $val): void
    {
        $orderId = (int) $ID;
        $status  = (string) $val;

        if ($orderId <= 0) {
            return;
        }

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

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

Важно: код поля связи сделки с заказом (UF_CRM_ORDER_ID) и коды стадий могут отличаться в вашей конфигурации — проверьте в настройках CRM (Настройки → Сделки → Поля сделки) и в документации CCrmDeal::GetList, Update.


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

Связь «заказ ↔ сделка» делается через пользовательские поля:

  • У сделки — поле с ID заказа (часто UF_CRM_ORDER_ID)
  • У заказа — свойство с ID сделки (опционально)

Получить 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) может отличаться в вашей конфигурации — смотрите свойства сделки в настройках CRM.


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

Коды статусов заказа и коды стадий сделки не совпадают, поэтому нужны две функции перевода:

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

Важно: маппинг должен быть симметричным — иначе часть переходов «потеряется».


Проверка результата

1. Проверка связи заказ-сделка

// В init.php временно включить логирование
define('LOCAL_SYNC_DEBUG', true);

// Создать тестовый заказ в CMS
// Проверить лог: /local/log/sync_status.log

2. Проверка синхронизации CMS → CRM

# 1. Изменить статус заказа в CMS
# 2. Открыть сделку в CRM
# 3. Проверить, что стадия обновилась

3. Проверка синхронизации CRM → CMS

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

4. Проверка оплаты

# 1. Оплатить заказ в CMS
# 2. Проверить сделку в CRM (должна обновиться за доли секунды)

5. Проверка отсутствия циклов

# 1. Включить логирование
# 2. Изменить статус заказа
# 3. Проверить лог: должно быть 1-2 обновления, не больше

Типичные ошибки

❌ Ошибка: обработчик не срабатывает

Причина: неверное имя события или путь к файлу.

Решение:

// Проверить подключение в init.php
require_once $_SERVER["DOCUMENT_ROOT"] . "/local/php_interface/lib/SyncCmsOrderToDeal.php";

// Проверить имя события
AddEventHandler('sale', 'OnSaleOrderSaved', [...]);  // Не OnSaleOrderSave!

❌ Ошибка: зацикливание обновлений

Причина: нет проверки расхождений перед обновлением.

Решение: всегда проверять текущее значение:

if ($currentStage === $targetStage) {
    return;  // Не обновлять, если статусы совпадают
}

❌ Ошибка: связь заказ-сделка не находится

Причина: неверный код поля связи (UF_CRM_ORDER_ID).

Решение: проверить в настройках CRM: Настройки → Настройки продукта → Сделки → Поля сделки.

❌ Ошибка: после обновления ядра синхронизация пропала

Причина: код лежит в /bitrix/ вместо /local/.

Решение: всегда размещать код в /local/php_interface/ — эта папка не затрагивается при обновлениях.


Отладка и логирование

Минимальный лог в обработчике

// В классе SyncCmsOrderToDeal
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
    );
}

// Использование
self::log('updating deal', ['dealId' => $dealId, 'stage' => $targetStage]);

Включение отладки

// В init.php временно включить
define('LOCAL_SYNC_DEBUG', true);

// После отладки выключить
// define('LOCAL_SYNC_DEBUG', false);

Где применять

  • Интернет-магазины на Битрикс: синхронизация заказов и сделок
  • CRM-интеграции: двусторонний обмен статусами
  • Автоматизация: оплата, отгрузка, уведомления

Связанные статьи:

Документация:


0 просмотров

Комментарии

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