Двусторонняя синхронизация статусов CMS и CRM в 1C-Битрикс
Как связать статусы заказов интернет-магазина и сделок CRM в коробочном 1C-Битрикс: OnSaleOrderSaved, OnAfterCrmDealUpdate, без циклов и с приоритетом оплаты. Готовые примеры на PHP.
Требования
- 1C-Битрикс (коробочная версия с модулями Интернет-магазин и CRM)
- PHP 7.4+
- Понимание событий Bitrix (AddEventHandler, EventManager)
- Доступ к файловой системе сервера
Двусторонняя синхронизация статусов: CMS и CRM в 1C-Битрикс
Типичная схема на коробочном Битрикс: заказы создаются в интернет-магазине (CMS), сделки ведутся в CRM, а бизнесу нужна синхронизация в обе стороны. Меняют статус в одном месте — он должен подтянуться в другом, без ручного дублирования и без зависаний при оплате.
В статье — как реализовать такую синхронизацию без циклов, без костылей и с приоритетом для оплаты, по документации 1C-Битрикс и с готовыми примерами кода. В конце — блоки про отладку, типичные грабли (обновления ядра, кастомные воронки, права) и когда синхронизацию разумнее вынести в очередь.
Задача
Формулировка от заказчика обычно такая:
Статусы заказа в CMS и стадии сделки в CRM должны совпадать в обе стороны.
То есть:
- при смене статуса заказа в CMS должна обновиться связанная сделка в CRM;
- при смене стадии сделки в CRM — связанный заказ в CMS;
- оплата заказа должна почти сразу отражаться в CRM;
- без зацикливаний и лишних обновлений;
- без костылей в виде крона или ручного дублирования.
Почему нельзя делать «в лоб»
Если навесить обработчики на события без учёта источника изменения:
- CMS меняет статус → CRM по событию обновляет сделку.
- CRM меняет стадию → CMS по событию обновляет заказ.
- 🔁 Возникает цикл: обновление тянет за собой следующее обновление.
- Растёт нагрузка на БД, статусы «прыгают», данные перестают быть консистентными.
Причина: обработчик не различает, кто инициировал изменение — пользователь или другой обработчик. Поэтому нужно явно разделять сценарии и обновлять вторую сторону только когда значения реально различаются.
Где размещать код и как подключать
Коробка 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, подключать файлы «через соседний портал» (типа ../) нельзя — каждый портал живёт в своём наборе событий. Разнесение кода по порталам убирает конфликты, не мешает обновлениям ядра и упрощает поддержку.
Архитектура решения
Логику удобно разнести на три сценария:
- CMS → CRM — смена статуса заказа тянет за собой стадию сделки.
- CRM → CMS — смена стадии сделки тянет за собой статус заказа.
- Оплата — отдельный приоритетный кейс, чтобы оплата сразу попадала в 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



Комментарии