Битрикс

Синхронизация остатков и цен из Коробки Битрикс24 на сайт 1С-Битрикс — пошаговое руководство

Синхронизация остатков и цен из Коробки Битрикс24 на сайт 1С-Битрикс — пошаговое руководство

Синхронизация Битрикс24 и 1С-Битрикс

Синхронизация остатков и цен из коробки Битрикс24 на сайт 1С-Битрикс: полное руководство

Задача: менеджеры ведут товары и остатки в CRM-системе (коробочный Битрикс24), а на сайте интернет-магазина (1С-Битрикс) данные должны обновляться автоматически — без ручного дублирования и без сторонних сервисов.

Если вы управляете интернет-магазином на 1С-Битрикс и ведёте учёт товаров в коробочном Битрикс24, рано или поздно встаёт вопрос: как автоматически передавать актуальные цены и остатки из CRM на сайт? Ручной перенос данных — это ошибки, задержки и потерянные заказы. В этой статье мы разберём, как настроить автоматическую одностороннюю синхронизацию товаров с готовым кодом и пояснениями к каждому шагу.


Содержание

  1. Для кого эта статья
  2. Архитектура решения
  3. Что понадобится перед началом работы
  4. Шаг 1. Код для Битрикс24 — отправка данных
  5. Шаг 2. Код для сайта — приём и обновление
  6. Шаг 3. Безопасность и защита
  7. Шаг 4. Тестирование
  8. Частые проблемы и их решение
  9. Когда стоит обратиться к специалисту
  10. Итоги

Для кого эта статья

Эта инструкция будет полезна:

  • Владельцам интернет-магазинов на 1С-Битрикс, которые ведут каталог и остатки в коробочном Битрикс24.
  • Веб-разработчикам и системным интеграторам, которые настраивают обмен данными между CRM и сайтом.
  • Руководителям отделов продаж в компаниях Москвы, Санкт-Петербурга, Екатеринбурга и других городов России, где менеджеры работают в Б24, а клиенты покупают через сайт.

Уровень сложности: средний. Нужен доступ к файловой системе обоих серверов и базовое понимание PHP.


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

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

┌──────────────────────┐                    ┌──────────────────────┐
│                      │   HTTP POST        │                      │
│   КОРОБКА БИТРИКС24  │ ─────────────────► │   САЙТ 1С-БИТРИКС   │
│   (Источник данных)  │   JSON + Токен     │   (Интернет-магазин) │
│                      │                    │                      │
│  • Менеджер меняет   │                    │  • Принимает запрос   │
│    цену или остаток  │                    │  • Находит товар      │
│  • Событие Битрикс   │                    │    по XML_ID          │
│    срабатывает       │                    │  • Обновляет цену     │
│  • Данные            │                    │    и остаток          │
│    отправляются      │                    │  • Пишет лог          │
│                      │                    │                      │
└──────────────────────┘                    └──────────────────────┘

Ключевые принципы

ПринципРеализация
Связка товаровПо полю XML_ID (Внешний код) — одинаковый в Б24 и на сайте
ТриггерСобытие OnAfterIBlockElementUpdate в Б24
ТранспортHTTP POST запрос с JSON-телом
БезопасностьСекретный токен + проверка IP + HTTPS
Защита от зацикливанияГлобальный флаг + проверка, что действие выполнил живой пользователь
СкладОдин основной, передаётся общий остаток QUANTITY

Почему именно XML_ID?

XML_ID (Внешний код) — стандартное поле в Битриксе, предназначенное для связи элементов между разными системами. Если ваши товары выгружаются из 1С, XML_ID уже заполнен. Если нет — его нужно заполнить вручную или скриптом, и значение должно совпадать в Б24 и на сайте.


Что понадобится перед началом работы

Доступы

  • SSH или FTP-доступ к серверу Битрикс24 (коробка).
  • SSH или FTP-доступ к серверу сайта (1С-Битрикс).
  • Права администратора в обоих системах.

Данные для настройки

Перед написанием кода определите:

ПараметрГде узнатьПример
ID инфоблока каталога в Б24Админка Б24 → Контент → Инфоблоки14
ID инфоблока каталога на сайтеАдминка сайта → Контент → Инфоблоки2
ID типа базовой ценыМагазин → Типы цен1 (BASE)
URL сайтаhttps://example.ru

Как узнать ID инфоблока

Выполните в командной строке PHP на соответствующем сервере:

<?php
// Подключаем ядро Битрикс
require_once($_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php');
CModule::IncludeModule('iblock');

$rs = CIBlock::GetList(['SORT' => 'ASC'], []);
while ($ar = $rs->Fetch()) {
    echo "ID: {$ar['ID']} | {$ar['NAME']} | Тип: {$ar['IBLOCK_TYPE_ID']}\n";
}

Генерация секретного токена

Используйте надёжный токен длиной от 32 символов:

# В терминале Linux/Mac:
openssl rand -hex 32

# Результат (пример):
# a3f8b2c1d4e5f6071829304a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e8

Шаг 1. Код для Битрикс24 — отправка данных

Что делает этот код

  1. Подписывается на событие обновления товара в инфоблоке.
  2. Проверяет, что изменение сделал живой пользователь (не робот, не агент, не фоновый процесс).
  3. Собирает XML_ID, цену и остаток.
  4. Отправляет JSON-запрос на сайт.

Файл: /local/php_interface/init.php

⚠️ Если файл init.php уже существует — не заменяйте его, а добавьте код в конец.

<?php
/**
 * =============================================================
 * СИНХРОНИЗАЦИЯ ТОВАРОВ: Коробка Б24 → Сайт
 * Добавить в /local/php_interface/init.php на сервере Битрикс24
 * =============================================================
 */

use Bitrix\Main\EventManager;
use Bitrix\Main\Loader;

// ----- КОНФИГУРАЦИЯ (измените под себя) -----

if (!defined('SYNC_SITE_URL')) {
    define('SYNC_SITE_URL', 'https://ваш-сайт.ru/local/api/sync_handler.php');
}

if (!defined('SYNC_AUTH_TOKEN')) {
    define('SYNC_AUTH_TOKEN', 'ВСТАВЬТЕ_СГЕНЕРИРОВАННЫЙ_ТОКЕН');
}

if (!defined('SYNC_CATALOG_IBLOCK_ID')) {
    define('SYNC_CATALOG_IBLOCK_ID', 14); // ID инфоблока каталога в Б24
}

if (!defined('SYNC_PRICE_TYPE_ID')) {
    define('SYNC_PRICE_TYPE_ID', 1); // ID типа базовой цены
}

if (!defined('SYNC_HTTP_TIMEOUT')) {
    define('SYNC_HTTP_TIMEOUT', 10);
}

if (!defined('SYNC_ENABLE_LOG')) {
    define('SYNC_ENABLE_LOG', true);
}

if (!defined('SYNC_LOG_FILE')) {
    define('SYNC_LOG_FILE', $_SERVER['DOCUMENT_ROOT']
        . '/local/logs/sync_outgoing.log');
}

// ----- РЕГИСТРАЦИЯ ОБРАБОТЧИКОВ СОБЫТИЙ -----

$eventManager = EventManager::getInstance();

// Событие обновления элемента инфоблока
$eventManager->addEventHandlerCompatible(
    'iblock',
    'OnAfterIBlockElementUpdate',
    'SyncProductToSite'
);

// Событие изменения цены (цена может меняться отдельно)
$eventManager->addEventHandlerCompatible(
    'catalog',
    'OnPriceUpdate',
    'SyncProductToSiteOnPriceChange'
);

// Событие изменения складских данных
$eventManager->addEventHandlerCompatible(
    'catalog',
    'OnProductUpdate',
    'SyncProductToSiteOnProductUpdate'
);

// ----- ФЛАГ ЗАЩИТЫ ОТ ЗАЦИКЛИВАНИЯ -----

$GLOBALS['SYNC_TO_SITE_RUNNING'] = false;


// =============================================================
// ОБРАБОТЧИК: обновление элемента инфоблока
// =============================================================
function SyncProductToSite(&$arFields)
{
    if ($GLOBALS['SYNC_TO_SITE_RUNNING'] === true) {
        return;
    }

    if (
        !isset($arFields['IBLOCK_ID'])
        || (int)$arFields['IBLOCK_ID'] !== SYNC_CATALOG_IBLOCK_ID
    ) {
        return;
    }

    if (!_syncIsManualAction()) {
        return;
    }

    $elementId = (int)$arFields['ID'];
    if ($elementId <= 0) {
        return;
    }

    $GLOBALS['SYNC_TO_SITE_RUNNING'] = true;

    try {
        $productData = _syncGetProductData($elementId);
        if ($productData !== null) {
            _syncSendToSite($productData);
        }
    } catch (\Exception $e) {
        _syncLog('ERROR', 'Exception: ' . $e->getMessage());
    }

    $GLOBALS['SYNC_TO_SITE_RUNNING'] = false;
}


// =============================================================
// ОБРАБОТЧИК: изменение цены
// =============================================================
function SyncProductToSiteOnPriceChange($ID, $arFields)
{
    if ($GLOBALS['SYNC_TO_SITE_RUNNING'] === true) {
        return;
    }

    if (!_syncIsManualAction()) {
        return;
    }

    $productId = (int)($arFields['PRODUCT_ID'] ?? 0);
    if ($productId <= 0) {
        return;
    }

    $catalogGroupId = (int)($arFields['CATALOG_GROUP_ID'] ?? 0);
    if ($catalogGroupId !== SYNC_PRICE_TYPE_ID) {
        return;
    }

    $GLOBALS['SYNC_TO_SITE_RUNNING'] = true;

    try {
        $productData = _syncGetProductData($productId);
        if ($productData !== null) {
            _syncSendToSite($productData);
        }
    } catch (\Exception $e) {
        _syncLog('ERROR', 'OnPriceUpdate Exception: ' . $e->getMessage());
    }

    $GLOBALS['SYNC_TO_SITE_RUNNING'] = false;
}


// =============================================================
// ОБРАБОТЧИК: изменение остатков
// =============================================================
function SyncProductToSiteOnProductUpdate($ID, $arFields)
{
    if ($GLOBALS['SYNC_TO_SITE_RUNNING'] === true) {
        return;
    }

    if (!_syncIsManualAction()) {
        return;
    }

    $productId = (int)$ID;
    if ($productId <= 0) {
        return;
    }

    $GLOBALS['SYNC_TO_SITE_RUNNING'] = true;

    try {
        $productData = _syncGetProductData($productId);
        if ($productData !== null) {
            _syncSendToSite($productData);
        }
    } catch (\Exception $e) {
        _syncLog('ERROR', 'OnProductUpdate Exception: ' . $e->getMessage());
    }

    $GLOBALS['SYNC_TO_SITE_RUNNING'] = false;
}


// =============================================================
// ПРОВЕРКА: действие выполнено вручную?
// =============================================================
function _syncIsManualAction(): bool
{
    global $USER;

    // CLI (агенты, cron) — не ручное действие
    if (php_sapi_name() === 'cli') {
        return false;
    }

    // Нет HTTP-хоста — фоновый процесс
    if (empty($_SERVER['HTTP_HOST'])) {
        return false;
    }

    // Нет авторизованного пользователя
    if (
        !is_object($USER)
        || !method_exists($USER, 'GetID')
        || (int)$USER->GetID() <= 0
    ) {
        return false;
    }

    return true;
}


// =============================================================
// СБОР ДАННЫХ О ТОВАРЕ
// =============================================================
function _syncGetProductData(int $elementId): ?array
{
    Loader::includeModule('iblock');
    Loader::includeModule('catalog');

    // Получаем элемент инфоблока
    $rsElement = \CIBlockElement::GetList(
        [],
        [
            'ID'        => $elementId,
            'IBLOCK_ID' => SYNC_CATALOG_IBLOCK_ID,
        ],
        false,
        false,
        ['ID', 'XML_ID', 'NAME', 'IBLOCK_ID']
    );

    $arElement = $rsElement->Fetch();
    if (!$arElement) {
        _syncLog('SKIP', "Элемент ID={$elementId} не найден");
        return null;
    }

    $xmlId = trim($arElement['XML_ID'] ?? '');
    if ($xmlId === '') {
        _syncLog('SKIP', "Элемент ID={$elementId}: пустой XML_ID");
        return null;
    }

    // Получаем базовую цену
    $price    = 0;
    $currency = 'RUB';

    $rsPrice = \CPrice::GetList(
        [],
        [
            'PRODUCT_ID'       => $elementId,
            'CATALOG_GROUP_ID' => SYNC_PRICE_TYPE_ID,
        ]
    );

    if ($arPrice = $rsPrice->Fetch()) {
        $price    = (float)$arPrice['PRICE'];
        $currency = $arPrice['CURRENCY'] ?: 'RUB';
    }

    // Получаем остаток
    $quantity  = 0;
    $rsProduct = \CCatalogProduct::GetByID($elementId);
    if ($rsProduct) {
        $quantity = (float)$rsProduct['QUANTITY'];
    }

    $data = [
        'xml_id'    => $xmlId,
        'price'     => $price,
        'currency'  => $currency,
        'quantity'  => $quantity,
        'name'      => $arElement['NAME'],
        'source_id' => $elementId,
    ];

    _syncLog('DATA', 'Подготовлено: '
        . json_encode($data, JSON_UNESCAPED_UNICODE));

    return $data;
}


// =============================================================
// ОТПРАВКА ДАННЫХ НА САЙТ
// =============================================================
function _syncSendToSite(array $productData): bool
{
    $httpClient = new \Bitrix\Main\Web\HttpClient([
        'socketTimeout' => SYNC_HTTP_TIMEOUT,
        'streamTimeout' => SYNC_HTTP_TIMEOUT,
        'redirect'      => false,
    ]);

    $httpClient->setHeader('Content-Type', 'application/json');
    $httpClient->setHeader('Accept', 'application/json');

    $requestBody = json_encode([
        'auth_token' => SYNC_AUTH_TOKEN,
        'action'     => 'update_product',
        'timestamp'  => date('c'),
        'data'       => $productData,
    ], JSON_UNESCAPED_UNICODE);

    _syncLog('SEND', "→ " . SYNC_SITE_URL
        . " | XML_ID: " . $productData['xml_id']);

    $response   = $httpClient->post(SYNC_SITE_URL, $requestBody);
    $httpStatus = $httpClient->getStatus();
    $errors     = $httpClient->getError();

    if (!empty($errors)) {
        $errorStr = implode('; ', $errors);
        _syncLog('ERROR', "HTTP ошибка: {$errorStr}");
        return false;
    }

    if ($httpStatus !== 200) {
        _syncLog('ERROR', "HTTP {$httpStatus}: {$response}");
        return false;
    }

    $responseData = json_decode($response, true);
    $success = ($responseData['status'] ?? '') === 'success';
    $message = $responseData['message'] ?? '';

    _syncLog(
        $success ? 'OK' : 'FAIL',
        "XML_ID: {$productData['xml_id']} | {$message}"
    );

    return $success;
}


// =============================================================
// ЛОГИРОВАНИЕ
// =============================================================
function _syncLog(string $level, string $message): void
{
    if (!SYNC_ENABLE_LOG) {
        return;
    }

    $logDir = dirname(SYNC_LOG_FILE);
    if (!is_dir($logDir)) {
        mkdir($logDir, 0775, true);
    }

    $timestamp = date('Y-m-d H:i:s');
    $line = "[{$timestamp}] [{$level}] {$message}" . PHP_EOL;

    file_put_contents(SYNC_LOG_FILE, $line, FILE_APPEND | LOCK_EX);

    // Ротация при > 5 МБ
    if (
        file_exists(SYNC_LOG_FILE)
        && filesize(SYNC_LOG_FILE) > 5 * 1024 * 1024
    ) {
        rename(
            SYNC_LOG_FILE,
            SYNC_LOG_FILE . '.' . date('Ymd_His') . '.bak'
        );
    }
}

Пояснения к коду

Зачем три обработчика событий?

В Битрикс24 цена, остаток и основные поля товара могут обновляться независимо друг от друга. Менеджер может изменить только цену, не трогая остальные поля — в этом случае OnAfterIBlockElementUpdate не сработает. Поэтому мы подписываемся на три события:

СобытиеКогда срабатывает
OnAfterIBlockElementUpdateИзменение полей элемента (название, XML_ID, активность и т.д.)
OnPriceUpdateИзменение цены
OnProductUpdateИзменение складских данных (количество)

Как работает защита от зацикливания?

Глобальная переменная $GLOBALS['SYNC_TO_SITE_RUNNING'] — это простой, но надёжный механизм. Когда один из обработчиков начинает работу, он ставит флаг true. Если в процессе работы Битрикс вызовет другой обработчик (например, обновление цены вызовет пересчёт элемента) — второй обработчик увидит флаг и сразу выйдет.

Дополнительно функция _syncIsManualAction() отсекает:

  • Агенты — фоновые задачи, выполняемые по расписанию.
  • CLI-скриптыcron-задачи, импорт из 1С.
  • Запросы без авторизации — REST API, вебхуки.

Шаг 2. Код для сайта — приём и обновление

Что делает этот код

  1. Принимает POST-запрос от Б24.
  2. Проверяет секретный токен.
  3. Находит товар на сайте по XML_ID.
  4. Обновляет цену и остаток.
  5. Отвечает JSON-ом с результатом.
  6. Всё логирует.

Подготовка

Создайте директории на сервере сайта:

mkdir -p /home/bitrix/www/local/api
mkdir -p /home/bitrix/www/local/logs
chmod 755 /home/bitrix/www/local/api
chmod 775 /home/bitrix/www/local/logs

Файл: /local/api/sync_handler.php

<?php
/**
 * =============================================================
 * ПРИЁМНИК СИНХРОНИЗАЦИИ ТОВАРОВ
 * Файл: /local/api/sync_handler.php на сервере сайта (1С-Битрикс)
 * =============================================================
 */

// ----- КОНФИГУРАЦИЯ (измените под себя) -----

const SYNC_AUTH_TOKEN            = 'ВСТАВЬТЕ_ТОТ_ЖЕ_ТОКЕН_ЧТО_И_В_Б24';
const SYNC_SITE_CATALOG_IBLOCK_ID = 2;   // ID инфоблока каталога на сайте
const SYNC_LOG_FILE              = __DIR__ . '/../../logs/sync.log';
const SYNC_LOG_MAX_SIZE          = 5 * 1024 * 1024; // 5 МБ

// Разрешённые IP (пустой массив = любые)
const SYNC_ALLOWED_IPS = [
    // '111.222.333.444',  // IP сервера Б24
];

// ----- ИНИЦИАЛИЗАЦИЯ ЯДРА БИТРИКС -----

$_SERVER['DOCUMENT_ROOT'] = realpath(__DIR__ . '/../../..');
$DOCUMENT_ROOT = $_SERVER['DOCUMENT_ROOT'];

define('NO_KEEP_STATISTIC', true);
define('NOT_CHECK_PERMISSIONS', true);
define('BX_NO_ACCELERATOR_RESET', true);
define('STOP_STATISTICS', true);
define('NO_AGENT_STATISTIC', 'Y');
define('NO_AGENT_CHECK', true);
define('DisableEventsCheck', true);

require($DOCUMENT_ROOT . '/bitrix/modules/main/include/prolog_before.php');

use Bitrix\Main\Loader;

header('Content-Type: application/json; charset=utf-8');

// ----- ОСНОВНАЯ ЛОГИКА -----

try {
    // 1. Проверка метода
    if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
        syncResponse('error', 'Допустим только POST', 405);
    }

    // 2. Проверка IP
    if (!empty(SYNC_ALLOWED_IPS)) {
        $clientIp = $_SERVER['REMOTE_ADDR'] ?? '';
        if (!in_array($clientIp, SYNC_ALLOWED_IPS, true)) {
            syncLog('DENIED', "Запрет IP: {$clientIp}");
            syncResponse('error', 'Доступ запрещён', 403);
        }
    }

    // 3. Чтение тела запроса
    $rawInput = file_get_contents('php://input');

    if (empty($rawInput)) {
        syncLog('ERROR', 'Пустое тело запроса');
        syncResponse('error', 'Пустой запрос', 400);
    }

    $requestData = json_decode($rawInput, true);

    if (json_last_error() !== JSON_ERROR_NONE) {
        syncLog('ERROR', 'Невалидный JSON: '
            . json_last_error_msg());
        syncResponse('error', 'Невалидный JSON', 400);
    }

    // 4. Проверка токена
    $receivedToken = $requestData['auth_token'] ?? '';

    if (!hash_equals(SYNC_AUTH_TOKEN, $receivedToken)) {
        syncLog('DENIED', 'Неверный токен');
        syncResponse('error', 'Ошибка авторизации', 401);
    }

    // 5. Проверка действия
    if (($requestData['action'] ?? '') !== 'update_product') {
        syncResponse('error', 'Неизвестное действие', 400);
    }

    // 6. Извлечение данных
    $data = $requestData['data'] ?? [];

    $xmlId    = trim($data['xml_id'] ?? '');
    $price    = (float)($data['price'] ?? 0);
    $currency = trim($data['currency'] ?? 'RUB');
    $quantity = (float)($data['quantity'] ?? 0);
    $name     = $data['name'] ?? '';

    if ($xmlId === '') {
        syncLog('ERROR', 'Пустой XML_ID');
        syncResponse('error', 'XML_ID обязателен', 400);
    }

    syncLog('RECV', "XML_ID={$xmlId} | Цена={$price} {$currency}"
        . " | Остаток={$quantity} | {$name}");

    // 7. Подключаем модули
    Loader::includeModule('iblock');
    Loader::includeModule('catalog');

    // 8. Ищем товар по XML_ID
    $productId = syncFindProductByXmlId($xmlId);

    if ($productId === null) {
        syncLog('NOT_FOUND', "Товар XML_ID={$xmlId} не найден");
        syncResponse('error',
            "Товар XML_ID={$xmlId} не найден на сайте", 404);
    }

    syncLog('FOUND', "XML_ID={$xmlId} → ID={$productId}");

    // 9. Обновляем цену
    $priceOk = syncUpdatePrice($productId, $price, $currency);

    // 10. Обновляем остаток
    $qtyOk = syncUpdateQuantity($productId, $quantity);

    // 11. Ответ
    $msg = sprintf(
        'ID=%d: цена %s, остаток %s',
        $productId,
        $priceOk ? '✓' : '✗',
        $qtyOk   ? '✓' : '✗'
    );

    syncLog('DONE', $msg);

    $status = ($priceOk && $qtyOk) ? 'success' : 'partial';
    syncResponse($status, $msg, 200);

} catch (\Exception $e) {
    syncLog('EXCEPTION', $e->getMessage());
    syncResponse('error', 'Внутренняя ошибка', 500);
}


// =============================================================
// ФУНКЦИИ
// =============================================================

/**
 * Поиск товара по XML_ID
 */
function syncFindProductByXmlId(string $xmlId): ?int
{
    // Сначала ищем среди активных товаров
    $rsElement = \CIBlockElement::GetList(
        ['ID' => 'ASC'],
        [
            'IBLOCK_ID' => SYNC_SITE_CATALOG_IBLOCK_ID,
            'XML_ID'    => $xmlId,
            'ACTIVE'    => 'Y',
        ],
        false,
        ['nTopCount' => 1],
        ['ID', 'XML_ID', 'NAME']
    );

    if ($arElement = $rsElement->Fetch()) {
        return (int)$arElement['ID'];
    }

    // Если среди активных не нашли — ищем среди всех
    $rsElement2 = \CIBlockElement::GetList(
        ['ID' => 'ASC'],
        [
            'IBLOCK_ID' => SYNC_SITE_CATALOG_IBLOCK_ID,
            'XML_ID'    => $xmlId,
        ],
        false,
        ['nTopCount' => 1],
        ['ID', 'XML_ID', 'NAME']
    );

    if ($arElement2 = $rsElement2->Fetch()) {
        syncLog('WARN', "XML_ID={$xmlId} найден, но неактивен"
            . " (ID={$arElement2['ID']})");
        return (int)$arElement2['ID'];
    }

    return null;
}


/**
 * Обновление базовой цены товара
 */
function syncUpdatePrice(
    int $productId,
    float $price,
    string $currency = 'RUB'
): bool {
    // Читаем текущую цену для сравнения
    $currentPrice = null;
    $rsPrice = \CPrice::GetList(
        [],
        [
            'PRODUCT_ID'       => $productId,
            'CATALOG_GROUP_ID' => 1,
        ]
    );

    if ($arCurrent = $rsPrice->Fetch()) {
        $currentPrice = (float)$arCurrent['PRICE'];
    }

    // Цена не изменилась — пропускаем
    if (
        $currentPrice !== null
        && abs($currentPrice - $price) < 0.01
    ) {
        syncLog('SKIP_PRICE',
            "ID={$productId}: цена не изменилась ({$price})");
        return true;
    }

    $result = \CPrice::SetBasePrice(
        $productId,
        $price,
        $currency
    );

    if ($result) {
        syncLog('PRICE_OK', sprintf(
            'ID=%d | %s → %s %s',
            $productId,
            $currentPrice !== null ? $currentPrice : 'н/д',
            $price,
            $currency
        ));
        return true;
    }

    syncLog('PRICE_ERR',
        "Не удалось обновить цену ID={$productId}");
    return false;
}


/**
 * Обновление остатка товара
 */
function syncUpdateQuantity(int $productId, float $quantity): bool
{
    // Читаем текущий остаток для сравнения
    $currentQty = null;
    $rsProduct  = \CCatalogProduct::GetByID($productId);

    if ($rsProduct) {
        $currentQty = (float)$rsProduct['QUANTITY'];
    }

    // Остаток не изменился — пропускаем
    if (
        $currentQty !== null
        && abs($currentQty - $quantity) < 0.001
    ) {
        syncLog('SKIP_QTY',
            "ID={$productId}: остаток не изменился ({$quantity})");
        return true;
    }

    $result = \CCatalogProduct::Update($productId, [
        'QUANTITY'               => $quantity,
        'QUANTITY_TRACE'         => 'Y',
        'CAN_BUY_ZERO'          => 'N',
        'NEGATIVE_AMOUNT_TRACE'  => 'N',
    ]);

    if ($result) {
        syncLog('QTY_OK', sprintf(
            'ID=%d | %s → %s',
            $productId,
            $currentQty !== null ? $currentQty : 'н/д',
            $quantity
        ));
        return true;
    }

    syncLog('QTY_ERR',
        "Не удалось обновить остаток ID={$productId}");
    return false;
}


/**
 * JSON-ответ и завершение скрипта
 */
function syncResponse(
    string $status,
    string $message,
    int $httpCode = 200
): void {
    http_response_code($httpCode);

    echo json_encode([
        'status'    => $status,
        'message'   => $message,
        'timestamp' => date('c'),
    ], JSON_UNESCAPED_UNICODE);

    if (
        defined('B_PROLOG_INCLUDED')
        && B_PROLOG_INCLUDED === true
    ) {
        require_once(
            $_SERVER['DOCUMENT_ROOT']
            . '/bitrix/modules/main/include/epilog_after.php'
        );
    }

    die();
}


/**
 * Логирование
 */
function syncLog(string $level, string $message): void
{
    $logDir = dirname(SYNC_LOG_FILE);
    if (!is_dir($logDir)) {
        mkdir($logDir, 0775, true);
    }

    $ts       = date('Y-m-d H:i:s');
    $ip       = $_SERVER['REMOTE_ADDR'] ?? '-';
    $logLine  = "[{$ts}] [{$level}] [IP:{$ip}] {$message}" . PHP_EOL;

    file_put_contents(
        SYNC_LOG_FILE,
        $logLine,
        FILE_APPEND | LOCK_EX
    );

    // Ротация при превышении лимита
    if (
        file_exists(SYNC_LOG_FILE)
        && filesize(SYNC_LOG_FILE) > SYNC_LOG_MAX_SIZE
    ) {
        rename(
            SYNC_LOG_FILE,
            SYNC_LOG_FILE . '.' . date('Ymd_His') . '.bak'
        );
    }
}

Шаг 3. Безопасность и защита

Синхронизация передаёт коммерческие данные между серверами. Вот обязательные меры защиты.

3.1. HTTPS

Оба сервера обязаны работать по HTTPS. Без шифрования токен и данные передаются в открытом виде.

# Проверка SSL-сертификата сайта:
curl -I https://ваш-сайт.ru/local/api/sync_handler.php

3.2. Файл .htaccess для директории API

Файл: /local/api/.htaccess

# Запрещаем листинг директории
Options -Indexes

# Разрешаем доступ только к обработчику
<Files "sync_handler.php">
    Require all granted
</Files>

# Всё остальное — запрещено
<FilesMatch "^(?!sync_handler\.php$)">
    Require all denied
</FilesMatch>

# Логи — запрещены
<FilesMatch "\.(log|bak)$">
    Require all denied
</FilesMatch>

3.3. Файл .htaccess для директории логов

Файл: /local/logs/.htaccess

# Полный запрет доступа к логам через веб
Require all denied

3.4. Ограничение по IP

Узнайте IP-адрес сервера Б24 и добавьте его в конфигурацию приёмника:

// В файле sync_handler.php:
const SYNC_ALLOWED_IPS = [
    '123.45.67.89',  // IP вашего сервера Битрикс24
];

Как узнать IP сервера Б24:

# На сервере Б24 выполните:
curl ifconfig.me

3.5. Требования к токену

ПараметрМинимумРекомендация
Длина32 символа64 символа
СоставБуквы + цифры+ спецсимволы
ХранениеВ кодеВ .env или dbconn.php

Шаг 4. Тестирование

4.1. Тестовый скрипт

Создайте временный файл для проверки. Удалите его сразу после тестирования!

Файл: /local/api/test_sync.php

<?php
/**
 * ТЕСТОВЫЙ СКРИПТ — УДАЛИТЬ ПОСЛЕ ПРОВЕРКИ!
 * Имитирует запрос от Б24 к сайту.
 */

$siteUrl   = 'https://ваш-сайт.ru/local/api/sync_handler.php';
$authToken = 'ВСТАВЬТЕ_ВАШ_ТОКЕН';

$testData = [
    'auth_token' => $authToken,
    'action'     => 'update_product',
    'timestamp'  => date('c'),
    'data'       => [
        'xml_id'    => 'TEST_001',   // ← реальный XML_ID с сайта!
        'price'     => 2499.99,
        'currency'  => 'RUB',
        'quantity'  => 10,
        'name'      => 'Тестовый товар',
        'source_id' => 0,
    ],
];

$json = json_encode($testData, JSON_UNESCAPED_UNICODE);

echo "=== ТЕСТ СИНХРОНИЗАЦИИ ===\n\n";
echo "URL: {$siteUrl}\n";
echo "Запрос:\n{$json}\n\n";

$ch = curl_init();
curl_setopt_array($ch, [
    CURLOPT_URL            => $siteUrl,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $json,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 15,
    CURLOPT_HTTPHEADER     => [
        'Content-Type: application/json',
        'Accept: application/json',
    ],
]);

$response  = curl_exec($ch);
$httpCode  = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);

echo "HTTP Code: {$httpCode}\n";

if ($curlError) {
    echo "Ошибка cURL: {$curlError}\n";
} else {
    $decoded = json_decode($response, true);
    echo "Ответ:\n";
    echo json_encode(
        $decoded,
        JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT
    ) . "\n";
}

echo "\n=== КОНЕЦ ТЕСТА ===\n";
echo "\n⚠️  УДАЛИТЕ ЭТОТ ФАЙЛ!\n";

4.2. Порядок тестирования

Шаг 1.  Положите все файлы на оба сервера
Шаг 2.  Запустите test_sync.php — проверьте ответ
Шаг 3.  Откройте /local/logs/sync.log — проверьте записи
Шаг 4.  Откройте карточку товара на сайте — цена и остаток обновились?
Шаг 5.  Измените цену товара в Б24 вручную
Шаг 6.  Проверьте sync_outgoing.log на сервере Б24
Шаг 7.  Проверьте sync.log на сайте
Шаг 8.  Проверьте товар на сайте — данные обновились?
Шаг 9.  УДАЛИТЕ test_sync.php

4.3. Пример записей в логе

Лог сайта (/local/logs/sync.log):

[2024-12-20 14:32:15] [RECV] [IP:123.45.67.89] XML_ID=CRM_001 | Цена=1500 RUB | Остаток=25 | Кабель USB-C
[2024-12-20 14:32:15] [FOUND] [IP:123.45.67.89] XML_ID=CRM_001 → ID=847
[2024-12-20 14:32:15] [PRICE_OK] [IP:123.45.67.89] ID=847 | 1200 → 1500 RUB
[2024-12-20 14:32:15] [QTY_OK] [IP:123.45.67.89] ID=847 | 30 → 25
[2024-12-20 14:32:15] [DONE] [IP:123.45.67.89] ID=847: цена ✓, остаток ✓

Лог Б24 (/local/logs/sync_outgoing.log):

[2024-12-20 14:32:14] [DATA] Подготовлено: {"xml_id":"CRM_001","price":1500,"currency":"RUB","quantity":25,"name":"Кабель USB-C","source_id":1234}
[2024-12-20 14:32:14] [SEND] → https://сайт.ru/local/api/sync_handler.php | XML_ID: CRM_001
[2024-12-20 14:32:15] [OK] XML_ID: CRM_001 | ID=847: цена ✓, остаток ✓

Частые проблемы и их решение

Проблема 1: «Товар не найден» (404)

Причина: XML_ID в Б24 и на сайте не совпадают.

Диагностика:

// Выполните на сайте:
CModule::IncludeModule('iblock');
$rs = CIBlockElement::GetList(
    [],
    ['IBLOCK_ID' => 2],  // ваш ID инфоблока
    false,
    ['nTopCount' => 10],
    ['ID', 'NAME', 'XML_ID']
);
while ($ar = $rs->Fetch()) {
    echo "ID={$ar['ID']} | XML_ID={$ar['XML_ID']} | {$ar['NAME']}\n";
}

Решение: Заполните XML_ID на сайте так, чтобы значения совпадали с Б24.

Проблема 2: «Ошибка авторизации» (401)

Причина: Токены не совпадают.

Решение: Скопируйте токен из настроек Б24 и вставьте его в sync_handler.php. Проверьте, что нет лишних пробелов и переносов строк.

Проблема 3: Синхронизация не срабатывает

Причины и решения:

СимптомПричинаРешение
Лог Б24 пустОбработчик не зарегистрированПроверьте, что init.php подключается. Очистите кеш Б24
В логе SKIPПустой XML_IDЗаполните XML_ID у товара в Б24
В логе HTTP ошибкаСайт недоступенПроверьте URL, SSL-сертификат, firewall
Лог Б24 есть, лог сайта пустЗапрос не доходитПроверьте .htaccess, firewall на server сайта

Проблема 4: Срабатывает при импорте из 1С

Причина: Импорт 1С обновляет элементы инфоблока и триггерит события.

Решение: Функция _syncIsManualAction() уже отсекает такие случаи, потому что импорт 1С обычно выполняется без авторизованного пользователя. Если ваш импорт работает иначе — добавьте дополнительную проверку:

// Добавьте в функцию _syncIsManualAction():
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
if (strpos($requestUri, '/bitrix/admin/1c_exchange.php') !== false) {
    return false; // Это импорт 1С — не ручное действие
}

Проблема 5: Таймаут при отправке

Причина: Сайт отвечает медленно.

Решение: Увеличьте таймаут:

define('SYNC_HTTP_TIMEOUT', 30); // вместо 10

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

СЕРВЕР БИТРИКС24
==========================================
/local/
├── php_interface/
│   └── init.php              ← код отправки
└── logs/
    ├── .htaccess             ← запрет веб-доступа
    └── sync_outgoing.log     ← лог отправленных данных


СЕРВЕР САЙТА
==========================================
/local/
├── api/
│   ├── .htaccess             ← защита директории
│   └── sync_handler.php      ← код приёма и обновления
└── logs/
    ├── .htaccess             ← запрет веб-доступа
    └── sync.log              ← лог принятых данных

Когда стоит обратиться к специалисту

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

  • Несколько складов — нужна синхронизация остатков с разбивкой по складам через CCatalogStoreProduct.
  • Торговые предложения (SKU) — у товара несколько вариантов (размер, цвет), каждый со своей ценой и остатком.
  • Несколько типов цен — оптовая, розничная, для дилеров.
  • Двусторонняя синхронизация — заказы с сайта должны попадать обратно в Б24.
  • Очередь и повторные попытки — при недоступности сайта данные должны отправляться позже.
  • Массовый импорт — тысячи товаров обновляются одновременно, и нужна пакетная отправка вместо поштучной.

Часто задаваемые вопросы (FAQ)

Можно ли синхронизировать товары из облачного Битрикс24, а не коробочного?

Нет, описанный метод работает только с коробочной версией Битрикс24, потому что требует доступа к файлу init.php и серверным событиям OnAfterIBlockElementUpdate. В облачном Б24 для аналогичной задачи нужно использовать REST API с исходящими вебхуками — это другая архитектура, которую мы рассмотрим в отдельной статье.

Что произойдёт, если сайт временно недоступен в момент синхронизации?

Данные не будут потеряны навсегда, но и не отправятся автоматически повторно — текущая реализация не включает очередь. HTTP-запрос вернёт ошибку, она запишется в лог sync_outgoing.log на сервере Б24. Чтобы исправить ситуацию, достаточно ещё раз сохранить товар в Б24 после восстановления сайта. Для критичных проектов рекомендуем добавить механизм повторных попыток с очередью.

Подходит ли это решение для каталога с 10 000+ товаров?

Для ежедневной работы — да. Синхронизация происходит поштучно: менеджер меняет один товар — обновляется один товар на сайте. Это мгновенно и не создаёт нагрузки. Однако для первоначальной массовой загрузки каталога этот метод не подходит — для этого используйте стандартный обмен с 1С или напишите пакетный скрипт.

Нужно ли устанавливать одинаковые XML_ID вручную?

Если товары попали в обе системы через импорт из 1С, XML_ID уже совпадают — это стандартное поведение. Если товары создавались вручную, XML_ID нужно заполнить. Можно сделать это разово скриптом или указывать при создании товара в поле «Внешний код».

Синхронизируются ли торговые предложения (размеры, цвета)?

В базовой версии — нет. Синхронизируются только простые товары. Торговые предложения (SKU) имеют отдельный инфоблок и собственные цены/остатки. Для их поддержки нужно доработать код: обрабатывать дочерний инфоблок SKU и передавать данные по каждому предложению отдельно.

Как часто происходит синхронизация?

Мгновенно, в момент сохранения товара менеджером. Это не синхронизация по расписанию (раз в час, раз в день), а событийная модель: произошло изменение → сработал обработчик → данные отправлены. Задержка составляет 1–3 секунды.

Безопасно ли передавать данные между серверами?

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

Будет ли работать с сайтом не на 1С-Битрикс?

Принцип отправки данных (Часть 1, код для Б24) универсален — это обычный HTTP POST с JSON. Принимающую сторону (Часть 2) нужно переписать под CMS вашего сайта: WordPress/WooCommerce, OpenCart, Laravel и т.д. Логика та же: принять запрос, проверить токен, найти товар, обновить данные.


Итоги

Мы реализовали надёжную одностороннюю синхронизацию товаров из коробочного Битрикс24 в интернет-магазин на 1С-Битрикс.

Что сделано

КомпонентРеализация
ТриггерТри события Битрикс: элемент, цена, остаток
ТранспортHTTP POST с JSON
Связка товаровПоле XML_ID (Внешний код)
БезопасностьТокен + проверка IP + HTTPS + .htaccess
Защита от цикловГлобальный флаг + проверка ручного действия
ЛогированиеОба сервера, ротация файлов
ОптимизацияПропуск обновления при неизменных данных

Что передаётся

ПолеОписание
xml_idВнешний код для связки
priceБазовая цена
currencyВалюта
quantityОбщий остаток (1 склад)

Преимущества подхода

  • Мгновенная синхронизация — данные обновляются сразу при сохранении товара менеджером, а не по расписанию.
  • Без сторонних сервисов — не нужны платные коннекторы и внешние API.
  • Прозрачность — подробные логи позволяют отследить каждую операцию.
  • Безопасность — многоуровневая защита от несанкционированного доступа.

Код готов к использованию — подставьте свои значения в блоки конфигурации, пройдите по чек-листу из Шага 4 и запускайте.


Если у вас возникли вопросы по настройке синхронизации Битрикс24 и 1С-Битрикс — оставьте заявку, и мы поможем с внедрением.