
Синхронизация остатков и цен из коробки Битрикс24 на сайт 1С-Битрикс: полное руководство
Задача: менеджеры ведут товары и остатки в CRM-системе (коробочный Битрикс24), а на сайте интернет-магазина (1С-Битрикс) данные должны обновляться автоматически — без ручного дублирования и без сторонних сервисов.
Если вы управляете интернет-магазином на 1С-Битрикс и ведёте учёт товаров в коробочном Битрикс24, рано или поздно встаёт вопрос: как автоматически передавать актуальные цены и остатки из CRM на сайт? Ручной перенос данных — это ошибки, задержки и потерянные заказы. В этой статье мы разберём, как настроить автоматическую одностороннюю синхронизацию товаров с готовым кодом и пояснениями к каждому шагу.
Содержание
- Для кого эта статья
- Архитектура решения
- Что понадобится перед началом работы
- Шаг 1. Код для Битрикс24 — отправка данных
- Шаг 2. Код для сайта — приём и обновление
- Шаг 3. Безопасность и защита
- Шаг 4. Тестирование
- Частые проблемы и их решение
- Когда стоит обратиться к специалисту
- Итоги
Для кого эта статья
Эта инструкция будет полезна:
- Владельцам интернет-магазинов на 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 — отправка данных
Что делает этот код
- Подписывается на событие обновления товара в инфоблоке.
- Проверяет, что изменение сделал живой пользователь (не робот, не агент, не фоновый процесс).
- Собирает XML_ID, цену и остаток.
- Отправляет 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. Код для сайта — приём и обновление
Что делает этот код
- Принимает POST-запрос от Б24.
- Проверяет секретный токен.
- Находит товар на сайте по
XML_ID. - Обновляет цену и остаток.
- Отвечает JSON-ом с результатом.
- Всё логирует.
Подготовка
Создайте директории на сервере сайта:
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С-Битрикс — оставьте заявку, и мы поможем с внедрением.