Play
online · 30 мин
← все статьи
Туториалы 25 мин чтения 12.05.2026

События 1С в Bitrix: как менять данные при импорте — рецепты под реальные задачи

Импорт из 1С в Bitrix может превратиться в ад, если повесить обработчик не на то событие. Разбираем полную карту событий импорта — от OnBeforeCatalogImport1C до финальных хуков — чтобы вы перестали гадать и начали проектировать интеграцию осознанно.
TL;DR
События OnBefore/OnAfterCatalogImport1C обрамляют весь сеанс импорта — идеально для общей очистки кеша и нотификаций. OnAfterIBlockElementUpdate стреляет тысячи раз за импорт; тяжёлые обработчики лучше уносить в очередь. Транзакции DB: throw в обработчике роняет всю партию, а не один элемент — используйте try/catch.

Какие события стреляют при импорте 1С: полный список

Когда к нам приходят с задачей «нужно что-то сделать с товарами после выгрузки из 1С», первый вопрос — а какое событие ловить? Ошибка здесь стоит дорого: повесишь обработчик на не то событие — получишь либо пропуск данных, либо двойную работу, либо падение сессии. У нас в практике был случай, когда разработчик подписался на OnAfterIBlockElementUpdate для дозаполнения SEO-полей, а импорт 10 000 товаров вместо 20 минут шёл 4 часа,обработчик вызывался по 7 раз на каждый элемент из-за цепной реакции обновлений. Карта событий импорта,это буквально навигатор: зная, какой хук на каком этапе стреляет и с какими аргументами, вы перестаёте гадать и начинаете проектировать интеграцию осознанно. Ниже,полная матрица событий, которые мы регулярно используем в проектах с большим каталогом.

Событие Модуль Когда стреляет Аргументы Можно отменить
OnBeforeCatalogImport1C catalog Перед началом сессии импорта каталога $arParams (IBLOCK_ID, файлы, флаги) Да, return false
OnSuccessCatalogImport1C catalog После успешной загрузки всех данных сессии $arParams Нет
OnAfterCatalogImport1C catalog Финал сессии (успех или ошибка) $arParams Нет
OnBeforeIBlockElementAdd iblock Перед добавлением каждого нового товара/SKU &$arFields Да, return false
OnAfterIBlockElementAdd iblock Сразу после добавления элемента &$arFields Нет
OnBeforeIBlockElementUpdate iblock Перед обновлением существующего товара &$arFields Да, return false
OnAfterIBlockElementUpdate iblock Сразу после обновления элемента &$arFields Нет
OnBeforeCatalogPriceUpdate catalog Перед изменением цены товара $id, &$arFields Да, return false
OnCatalogStoreProductUpdate catalog При изменении остатков на складе $storeId, $productId, &$arFields Нет
OnSuccessOfferAddImport1C catalog После успешного добавления торгового предложения $offerId, $arFields Нет
OnSuccessOfferUpdateImport1C catalog После успешного обновления торгового предложения $offerId, $arFields,
$oldFields
Нет

В таблице,только «рабочие лошадки», которые мы используем в каждом втором проекте с интеграцией 1С. Самые частые,это OnAfterIBlockElementUpdate и OnBeforeIBlockElementAdd: на них вешают дозаполнение SEO, генерацию URL, синхронизацию с поиском. OnAfterCatalogImport1C,для финальных действий: очистка кеша, отправка уведомлений, запуск агентов. Редкие, но ценные,OnBeforeCatalogPriceUpdate (если нужно подменить цену на лету) и OnCatalogStoreProductUpdate (логирование остатков). Обратите внимание: события OnBefore* позволяют отменить действие,верните false, и элемент не сохранится. Это удобно для валидации: например, заблокировать товар без артикула.

Где разместить обработчик событий: init.php, модуль или AGENT

Когда мы только разбирались с интеграцией 1С на одном из первых проектов, то по наивности регистрировали все обработчики прямо в init.php — и быстро упёрлись в проблему: файл разрастался до тысяч строк, а любая правка требовала перезагрузки всего ядра. Сейчас у нас в команде чёткое правило: место регистрации напрямую определяет, насколько стабильно будет работать импорт и сколько времени уйдёт на поддержку. Неудачно выбранный файл — и вы либо тонете в спагетти-коде, либо получаете таймауты из-за синхронных операций. Поэтому перед тем как писать AddEventHandler, стоит выбрать стратегию размещения под конкретную задачу.

Есть три основных варианта, и у каждого своя цена:

  • init.php (главный модуль),плюс: быстро, не требует создания модуля, подходит для 1–2 простых обработчиков. Минус: при росте количества хуков файл становится нечитаемым, любая ошибка валит весь сайт, невозможно обновлять через marketplace.
  • Собственный модуль,плюс: изолированная логика, можно подключать/отключать через админку, удобно версионировать, поддерживать и передавать между проектами. Минус: нужно оформлять структуру модуля, регистрировать его в системе, тратить время на файлы install/index.php и options.php.
  • AGENT с задержкой,плюс: не блокирует поток импорта, идеально для тяжёлых операций (синхронизация с CRM, генерация микроразметки, отправка уведомлений). Минус: нужен работающий cron, данные могут устареть за время ожидания, сложнее отладка.
  • runtime/ (Bitrix D7),плюс: модульное размещение без формальной регистрации модуля, подходит для кастомных решений в local/. Минус: требует понимания автозагрузки namespace'ов, не все разработчики знакомы с D7-архитектурой.
  • Прямая вставка в 1c_exchange.php,плюс: полный контроль над потоком, можно прервать импорт до его начала. Минус: патч ядра, который слетает при обновлении платформы,категорически не рекомендуем.

Наша рекомендация по опыту десятков проектов: для простых кейсов,один обработчик на генерацию SEO-полей,смело используйте init.php. Если вы планируете 5+ обработчиков, работу с кешем, интеграцию с внешними системами,оформляйте собственный модуль. А для всего, что может подождать 30–60 секунд (отправка писем, логи, синхронизация), лучше ставить AGENT. Это разделение убережёт вас от ситуации, когда импорт из 1С встаёт колом из-за того, что обработчик ждёт ответа от внешнего API.

Жизненный цикл одной сессии импорта 1С

Когда к нам приходят с задачей «импорт из 1С работает, но нужно подправить данные на лету», мы первым делом просим показать лог сессии. Типичная картина: четыре последовательных GET-запроса к /bitrix/admin/1c_exchange.php с параметром modecheckauth, init, file, import. Это и есть скелет жизненного цикла. На каждом шаге 1С передаёт управление Bitrix, а движок, в свою очередь, стреляет событиями, в которые можно вклиниться. checkauth проверяет логин/пароль — здесь событий нет, только HTTP-заголовки. init инициализирует сессию: создаётся временная директория, записываются параметры обмена. На этом этапе срабатывает OnBeforeCatalogImport1C,единственная точка входа до разбора данных. file,загрузка XML-файла на сервер; здесь событий тоже нет. А вот import,основная сцена: парсинг CommerceML, создание/обновление разделов, товаров, предложений, цен, остатков. Внутри этого шага последовательно стреляют десятки событий модулей catalog, iblock и sale,от OnBeforeIBlockElementAdd до OnAfterCatalogImport1C.

flowchart LR
    A[checkauth] -->|"авторизация"| B[init]
    B -->|"инициализация сессии"| C[file]
    C -->|"загрузка XML"| D[import]
    D -->|"парсинг CommerceML"| E[финал]
    
    B -.->|"OnBeforeCatalogImport1C"| B1[(" ")]
    D -.->|"OnBeforeIBlockElementAdd/Update"| D1[(" ")]
    D -.->|"OnAfterIBlockElementAdd/Update"| D2[(" ")]
    D -.->|"OnBeforeCatalogPriceUpdate"| D3[(" ")]
    D -.->|"OnSuccessCatalogImport1C / OnAfterCatalogImport1C"| E

Разберём каждый этап подробнее. Init,единственное место, где можно остановить импорт до того, как он начал есть ресурсы. Мы вешаем сюда проверку: не запущена ли уже другая сессия (через файловый lock или Redis), и если да,возвращаем ошибку. Это спасает от race condition, когда 1С запускает два параллельных потока. Import,самая мясистая часть. Здесь XML разбирается по секциям: сначала разделы каталога (OnBeforeIBlockSectionAdd / OnAfterIBlockSectionAdd), потом товары (OnBeforeIBlockElementAdd / OnAfterIBlockElementAdd), затем предложения (SKU) с отдельными событиями OnSuccessOfferAddImport1C / OnSuccessOfferUpdateImport1C, и в конце,цены (OnBeforeCatalogPriceUpdate) с остатками (OnCatalogStoreProductUpdate). И вот где мы обычно вмешиваемся: на OnAfterIBlockElementUpdate подставляем SEO-поля, если 1С их не передала; на OnBeforeCatalogPriceUpdate округляем цены до нужной кратности; на OnSuccessOfferUpdateImport1C отправляем данные в Elasticsearch. Главное,помнить, что все эти события стреляют внутри одной транзакции: если ваш обработчик кинет исключение, откатится вся пачка (~50–100 элементов), а не один товар. Поэтому мы всегда оборачиваем логику в try/catch и логируем ошибки, но не пробрасываем их наружу.

Как добавить мета-теги к товару при импорте 1С

Типичный сценарий: у вас в инфоблоке товаров есть SEO-поля META_TITLE, META_KEYWORDS и META_DESCRIPTION. Менеджеры вручную заполняют их через админку, а после очередного импорта из 1С все мета-теги обнуляются. Движок 1С-обмена не отличает «поле не передавалось в XML» от «передано пустое значение» — и просто затирает SEO-поля пустыми строками. В наших проектах с большим каталогом мы регулярно решаем эту задачу через событие OnAfterIBlockElementAdd и OnAfterIBlockElementUpdate: ловим момент сохранения элемента, проверяем, что SEO-поля пусты, и подставляем шаблонные значения. Это решение комбинируется с дозаполнением Open Graph-меток и автоматической генерацией микроразметки Schema.org.

PHP
// bitrix/php_interface/init.php
use Bitrix\Main\EventManager;
use Bitrix\Iblock\ElementTable;

$eventManager = EventManager::getInstance();

// Регистрируем обработчик на оба события — добавление и обновление элемента
foreach (['OnAfterIBlockElementAdd', 'OnAfterIBlockElementUpdate'] as $event) {
    $eventManager->addEventHandler(
        'iblock',
        $event,
        ['SeoImportHandler', 'fillMetaTags']
    );
}

Когда применять этот хук

Этот подход оправдан, когда вы не хотите переписывать логику импорта на стороне 1С — проще подстраховаться на уровне Bitrix. Он особенно полезен, если в каталоге несколько сотен или тысяч товаров, и менеджеры физически не успевают проставлять SEO-поля вручную после каждой выгрузки. У нас такой хук работает в проектах с динамическими ценами, где импорт запускается каждые 15–30 минут, и ручное заполнение мета-тегов было бы узким местом. Единственное условие,у вас должно быть хотя бы одно свойство или поле, из которого можно сгенерировать мета-теги (например, NAME, PREVIEW_TEXT, или пользовательское свойство «SEO_TITLE»).

PHP
class SeoImportHandler
{
    // ID инфоблока, для которого применяем автозаполнение
    const IBLOCK_ID = 4; // замените на ваш ID

    public static function fillMetaTags(int $elementId, array $arFields): void
    {
        // Проверяем, что это наш инфоблок
        if ((int)($arFields['IBLOCK_ID'] ?? 0) !== self::IBLOCK_ID) {
            return;
        }

        // Получаем текущие значения элемента
        $element = ElementTable::getByPrimary($elementId, [
                'select' => ['ID', 'NAME', 'PREVIEW_TEXT', 'META_TITLE', 'META_KEYWORDS', 'META_DESCRIPTION']
        ])->fetch();

        if (!$element) {
            return;
        }

        $updateFields = [];

        // Если META_TITLE пуст — ставим NAME
        if (empty($element['META_TITLE'])) {
            $updateFields['META_TITLE'] = $element['NAME'];
        }

        // Если META_KEYWORDS пуст — генерируем из PREVIEW_TEXT (первые 200 символов)
        if (empty($element['META_KEYWORDS']) && !empty($element['PREVIEW_TEXT'])) {
            $keywords = strip_tags($element['PREVIEW_TEXT']);
            $updateFields['META_KEYWORDS'] = mb_substr($keywords, 0, 200);
        }

        // Если META_DESCRIPTION пуст — используем PREVIEW_TEXT (первые 300 символов)
        if (empty($element['META_DESCRIPTION']) && !empty($element['PREVIEW_TEXT'])) {
            $description = strip_tags($element['PREVIEW_TEXT']);
            $updateFields['META_DESCRIPTION'] = mb_substr($description, 0, 300);
        }

        // Если есть что обновлять — пишем без повторного вызова событий
        if (!empty($updateFields)) {
            $updateFields['ID'] = $elementId;
            CIBlockElement::Update($elementId, $updateFields);
        }
    }
}

Что под капотом: почему OnAfterIBlockElementAdd, а не OnBefore

Мы сознательно выбрали OnAfter, а не OnBefore. Дело в том, что на момент срабатывания OnBeforeIBlockElementAdd элемент ещё не сохранён в базу, и мы не можем прочитать его ID для последующего обновления. В OnAfter элемент уже создан,у нас есть $elementId, и мы можем безопасно дозаписать SEO-поля через CIBlockElement::Update. Единственный нюанс: при вызове CIBlockElement::Update внутри обработчика снова сработает OnAfterIBlockElementUpdate,это может привести к рекурсии. Чтобы этого избежать, мы проверяем в начале обработчика, что META_TITLE действительно пуст, иначе выходим. Так рекурсия не возникает, потому что после нашего обновления поле уже не пустое.

Грабли с транзакцией.Все события OnAfterIBlockElement* выполняются внутри транзакции импорта. Если в обработчике выбросить исключение (throw), откатится не один товар, а вся партия из 50–100 элементов. Поэтому мы всегда оборачиваем вызов CIBlockElement::Update в try/catch и логируем ошибки, но никогда не прерываем импорт. Исключение,критическая ситуация, когда несохранение SEO-полей делает каталог нерабочим; тогда можно выбросить исключение, но будьте готовы к полному откату партии.

Как отправить уведомление в Telegram об окончании импорта

Помню случай у клиента с каталогом на 35 тысяч SKU: импорт из 1С запускался каждые 20 минут по крону, занимал около 12 минут, и всё это время разработчики сидели в административной панели и вручную обновляли страницу, чтобы убедиться, что процесс не упал на середине. Когда импорт зависал на этапе загрузки изображений или падал с ошибкой парсинга XML, об этом узнавали только через час — когда менеджеры начинали жаловаться, что товары не обновляются. Мы тогда впервые задумались: почему бы не завязать уведомление о завершении импорта на Telegram-бота? Решение оказалось настолько простым, что теперь мы ставим его во всех проектах с активной выгрузкой из 1С. Оно не требует отдельного сервера для очередей, работает через стандартный curl и даёт мгновенную обратную связь команде: импорт завершился успешно, упал, или завис дольше обычного. В этой секции покажем, как повесить отправку сообщения в Telegram на событие OnAfterCatalogImport1C — и не сломать при этом сам импорт.

PHP
<?php
// init.php — регистрация обработчика на завершение импорта каталога
use Bitrix\Main\EventManager;

EventManager::getInstance()->addEventHandler(
    'catalog',
    'OnAfterCatalogImport1C',
    function(array $arParams) {
        $message = '✅ Импорт каталога завершён.';
        $message .= PHP_EOL . 'Инфоблок: ' . ($arParams['IBLOCK_ID'] ?? 'не указан');
        $message .= PHP_EOL . 'Время: ' . date('d.m.Y H:i:s');

        $telegramToken = '1234567890:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw'; // замените на ваш токен
        $chatId = '-1001234567890'; // замените на ID чата или группы

        $url = 'https://api.telegram.org/bot' . $telegramToken . '/sendMessage';
        $data = [
            'chat_id' => $chatId,
            'text' => $message,
            'parse_mode' => 'HTML',
        ];

        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_TIMEOUT, 5);
        curl_exec($ch);
        curl_close($ch);
    }
);

Как не завалить импорт отправкой

Синхронный curl внутри обработчика события,это первое, что приходит в голову, но на практике опасная штука. Если Telegram API в момент отправки недоступен, таймаут в 5 секунд задержит весь импорт. А если на 10 тысячах товаров событие OnAfterCatalogImport1C стреляет только один раз за сессию, то задержка кажется несущественной. Но представьте: у вас несколько параллельных сессий импорта (цены, остатки, предложения), и каждая висит на ожидании ответа от Telegram,импорт может растянуться на минуты. В наших проектах мы предпочитаем выносить отправку в агент, который выполняется сразу после завершения основного потока. Это снимает риск блокировки и не требует внешних очередей.

PHP
<?php
// init.php — асинхронная отправка через агента
use Bitrix\Main\EventManager;
use Bitrix\Main\Application;

EventManager::getInstance()->addEventHandler(
    'catalog',
    'OnAfterCatalogImport1C',
    function(array $arParams) {
        $agentName = '\Bitrix\Main\Application::getConnection()->queryExecute("
        INSERT INTO b_agent (
            MODULE_ID, NAME, ACTIVE, NEXT_EXEC, SORT, USER_ID, IS_PERIOD
        ) VALUES (
            \'main\',
            \'CTelegramNotifier::send("' . addslashes(json_encode($arParams)) . '")\',
            \'Y\',
            now(),
            100,
            0,
            \'N\'
        )
        ")';

        // Альтернатива через CAgent (если не хотите прямого SQL)
        // \CAgent::AddAgent(
        //     'CTelegramNotifier::send(\'' . addslashes(json_encode($arParams)) . '\');',
        //     'main',
        //     'N',
        //     0,
        //     '',
        //     'Y',
        //     date('d.m.Y H:i:s'),
        //     100
        // );
    }
);

// Класс для отправки (можно разместить в /local/php_interface/include/)
class CTelegramNotifier
{
    public static function send(string $paramsJson): string
    {
        $arParams = json_decode($paramsJson, true);
        if (!$arParams) {
            return '';
        }

        $message = '✅ Импорт каталога завершён.' . PHP_EOL;
        $message .= 'Инфоблок: ' . ($arParams['IBLOCK_ID'] ?? 'не указан') . PHP_EOL;
        $message .= 'Время: ' . date('d.m.Y H:i:s');

        $telegramToken = '1234567890:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw';
        $chatId = '-1001234567890';

        $url = 'https://api.telegram.org/bot' . $telegramToken . '/sendMessage';
        $data = [
            'chat_id' => $chatId,
            'text' => $message,
            'parse_mode' => 'HTML',
        ];

        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_TIMEOUT, 5);
        curl_exec($ch);
        curl_close($ch);

        return ''; // пустая строка — агент не перезапускается
    }
}

Как очистить кеш каталога после импорта 1С

Реальная история: у одного из заказчиков с каталогом на 25 тысяч SKU после обновления цен из 1С на витрине ещё сутки висели старые цифры. Клиенты оформляли заказы по вчерашним ценам, менеджеры вручную пересчитывали счета — нервов потратили больше, чем на сам импорт. Корень проблемы был прост: Bitrix не чистит кеш каталога автоматически после завершения импорта 1С. Движок считает, что данные в кеше валидны, пока не истечёт их время жизни или пока кто-то явно не скажет «стереть». В проектах с активным импортом (каждые 15–30 минут) это превращается в хроническую болезнь: менеджеры видят в админке актуальные остатки, а на сайте — вчерашнюю картинку. Мы регулярно сталкиваемся с этим у клиентов, где 1С заливает не только товары, но и цены, остатки, свойства. Если не добавить принудительную очистку кеша в OnAfterCatalogImport1C, пользователи будут видеть устаревшие данные до следующего автоматического сброса кеша по cron или до ручной команды в админке. Решение,повесить обработчик на финальное событие импорта и инвалидировать кеш каталога сразу после того, как 1С закончила выгрузку.

PHP
<?php
// init.php
use Bitrix\Main\EventManager;
use Bitrix\Main\Data\Cache;

EventManager::getInstance()->addEventHandler(
    'catalog',
    'OnAfterCatalogImport1C',
    function (array $arParams) {
        // Очищаем кеш каталога после завершения импорта
        $cache = Cache::createInstance();
        $cache->cleanDir('/catalog');

        // Дополнительно — кеш компонентов, которые используют catalog
        $cache->cleanDir('/bitrix/catalog');

        // Если используете композитный кеш
        if (method_exists('\Bitrix\Main\Data\Cache', 'cleanDirUncached')) {
            \Bitrix\Main\Data\Cache::cleanDirUncached('/catalog');
        }
    }
);

Какой метод очистки выбрать: BXClearCache vs CCacheManager

В старых проектах до сих пор встречается BXClearCache(true, '/catalog'),устаревшая функция, которая работает только с файловым кешем. В современных версиях Bitrix (22.x и новее) мы перешли на CCacheManager или его D7-аналог \Bitrix\Main\Data\Cache. Разница принципиальна: BXClearCache обходит только файловое хранилище, а CCacheManager::ClearByTag умеет чистить кеш независимо от бэкенда,будь то файлы, memcache, redis или APCu. В наших проектах с большим каталогом мы используем тегированный кеш: вешаем на каждый товар, раздел и торговое предложение тег catalog_IBLOCK_ID, а после импорта сбрасываем все теги каталога одной командой. Это надёжнее, чем чистить директории,не нужно гадать, какие пути закешированы. К тому же тегированный подход не трогает кеш других разделов сайта (новости, статьи), а значит, не вызывает лавинного перестроения всего кеша.

PHP
<?php
// init.php — очистка через CCacheManager с тегами
use Bitrix\Main\EventManager;
use Bitrix\Main\Application;

EventManager::getInstance()->addEventHandler(
    'catalog',
    'OnAfterCatalogImport1C',
    function (array $arParams) {
        $taggedCache = Application::getInstance()->getTaggedCache();

        // Сбрасываем все теги, связанные с каталогом
        $taggedCache->clearByTag('catalog');

        // Если импортируется конкретный инфоблок — можно по его ID
        if (!empty($arParams['IBLOCK_ID'])) {
            $taggedCache->clearByTag('iblock_id_' . (int)$arParams['IBLOCK_ID']);
        }

        // Сбрасываем managed-кеш (композит, кеш компонентов)
        \Bitrix\Main\Data\Cache::clearCache();
    }
);

Как заблокировать товар без артикула при импорте 1С

Самый частый запрос на эту тему — «при импорте из 1С проскочил товар без артикула, и он висит на витрине с пустым UF_BARCODE». В наших проектах с каталогом от 10 тысяч SKU такая ситуация возникает регулярно: менеджер 1С забыл заполнить поле «Артикул» в учётной системе, XML приезжает без кода, а Bitrix послушно создаёт элемент с пустым реквизитом. Потом это всплывает при выгрузке в маркетплейсы — Ozon или Wildberries просто не принимают товар без артикула. Мы предпочитаем решать задачу на входе: если UF_BARCODE не заполнен,не даём сохранить элемент вообще. Это чище, чем потом искать «пустышки» через админку и удалять вручную. Решение комбинируется с событием OnBeforeIBlockElementAdd,ловим момент создания товара, проверяем наличие артикула и возвращаем ошибку.

PHP
<?php
// init.php или bitrix/php_interface/init.php
use Bitrix\Main\EventManager;
use Bitrix\Main\Localization\Loc;

EventManager::getInstance()->addEventHandler(
    'iblock',
    'OnBeforeIBlockElementAdd',
    ['CustomImportHandler', 'blockProductWithoutBarcode']
);

Как вернуть ошибку и отменить сохранение

Событие OnBeforeIBlockElementAdd позволяет отменить сохранение элемента, если вернуть false и установить сообщение об ошибке через CMain::ThrowException или метод $APPLICATION->ThrowException. Внутри обработчика мы проверяем, что свойство UF_BARCODE (или любое другое обязательное поле) не пустое. Если артикул отсутствует,прерываем сохранение. Ключевой момент: возвращать нужно именно false, а не null или пустой массив,Bitrix интерпретирует false как сигнал «не сохранять». Сообщение об ошибке попадёт в лог импорта, и 1С увидит, что элемент не прошёл валидацию.

PHP
class CustomImportHandler
{
    /**
    * Блокирует сохранение элемента, если не заполнен UF_BARCODE.
    *
    * @param array &$arFields Поля элемента
    * @return bool
    */
    public static function blockProductWithoutBarcode(array &$arFields): bool
    {
        // ID инфоблока товаров — замените на ваш
        $targetIblockId = 17;

        if ((int)$arFields['IBLOCK_ID'] !== $targetIblockId) {
            return true;
        }

        // Проверяем свойство UF_BARCODE
        $barcode = $arFields['PROPERTY_VALUES']['UF_BARCODE'] ?? null;

        if (empty($barcode)) {
            global $APPLICATION;
            $APPLICATION->ThrowException(
                'Товар не сохранён: не заполнен артикул (UF_BARCODE).'
            );
            return false;
        }

        return true;
    }
}

Как подменить название товара на лету при импорте

Типичный сценарий: у клиента с каталогом на 15 тысяч SKU артикулы хранятся отдельным свойством, а в названии товара они не отображаются. Менеджеры вручную проставляют артикул в NAME при создании карточки, но после очередного импорта из 1С названия сбрасываются — потому что 1С передаёт только «чистое» наименование. Или обратная ситуация: в 1С названия приходят транслитом (например, «Stul ofisnyj chernyj»), а на витрине нужны русские наименования. Когда к нам приходят с такой задачей, мы используем событие OnBeforeIBlockElementUpdate, чтобы подменить NAME на лету, не дожидаясь повторной выгрузки и не правя данные в самой 1С. Это же решение комбинируется с генерацией SEO-заголовков и добавлением артикула в мета-теги.

PHP
use Bitrix\Main\EventManager;

EventManager::getInstance()->addEventHandler(
    'iblock',
    'OnBeforeIBlockElementUpdate',
    ['CustomImportHandler', 'modifyElementName']
);

Как изменить $arFields без потери данных

Ключевая особенность OnBeforeIBlockElementUpdate — обработчик получает $arFields по ссылке, и любые изменения в этом массиве применяются при сохранении элемента. Но есть нюанс: если вы напрямую присвоите $arFields['NAME'], вы рискуете потерять изменения, которые внесли другие обработчики до вас. В наших проектах мы всегда читаем текущее значение через CIBlockElement::GetByID, если нужно сохранить часть исходного названия, или используем переданное $arFields['NAME'], если заменяем его полностью. Второй важный момент,не забудьте проверить IBLOCK_ID: обработчик будет вызываться для всех инфоблоков, а не только для каталога товаров.

PHP
class CustomImportHandler
{
    const IBLOCK_ID = 17; // замените на ваш ID инфоблока

    public static function modifyElementName(&$arFields): void
    {
        if ((int)$arFields['IBLOCK_ID'] !== self::IBLOCK_ID) {
            return;
        }

        // Если название не меняется — не трогаем
        if (!isset($arFields['NAME'])) {
            return;
        }

        // Читаем артикул из свойства
        $propertyValues = \CIBlockElement::GetPropertyValues(
            self::IBLOCK_ID,
            ['ID' => $arFields['ID']],
            false,
            ['CODE' => 'ARTICUL']
        )->Fetch();

        $article = $propertyValues['ARTICUL'] ?? '';

        if ($article) {
            // Формируем новое название: "Артикул — Наименование"
            $arFields['NAME'] = $article . ' — ' . $arFields['NAME'];
        }
    }
}

Грабли: OnBeforeIBlockElementUpdate вызывается даже если ни одно поле элемента не изменилось,1С передаёт полный набор данных при каждом обновлении, и Bitrix триггерит событие независимо от того, были ли реальные изменения. Если ваш обработчик выполняет тяжёлую логику (запросы к внешним API, перестроение индексов), он будет отрабатывать на каждый товар при каждом импорте. Фильтруйте по разнице между $arFields и текущими данными в БД, либо используйте флаг в сессии, чтобы обработать элемент только один раз за сессию импорта.

Как синхронизировать товары с внешней CRM после импорта

Типичный сценарий: каталог на 50 тысяч SKU, импорт из 1С каждые 20 минут, а менеджеры работают в AmoCRM. После того как 1С обновила цену или остаток, нужно чтобы эти изменения улетели во внешнюю CRM — иначе менеджер видит в своей системе неактуальные данные и обещает клиенту товар, которого уже нет. У нас в практике это одна из самых частых задач после настройки базового импорта. Решение: ловить событие OnSuccessOfferUpdateImport1C (или OnAfterIBlockElementUpdate для простых товаров) и отправлять данные во внешнюю систему. Но есть нюанс: если делать это синхронно, прямо внутри обработчика, один сбой в AmoCRM положит всю партию импорта. Поэтому мы всегда комбинируем отправку с асинхронной очередью — об этом ниже.

PHP
// Регистрация в init.php
use Bitrix\Main\EventManager;

EventManager::getInstance()->addEventHandler(
    'catalog',
    'OnSuccessOfferUpdateImport1C',
    ['My\CRM\SyncHandler', 'handleOfferUpdate']
);

Почему не стоит делать синхронно

Когда мы впервые реализовывали синхронизацию с RetailCRM на одном из проектов, то написали прямой HTTP-запрос внутри обработчика OnAfterIBlockElementUpdate. Всё работало, пока у CRM не лёг сервер,импорт встал намертво, потому что каждый вызов curl ждал таймаута 30 секунд. При 10 тысячах обновлённых товаров это давало 300 секунд простоя только на ожидании. И это ещё полбеды: если CRM ответила 500-й ошибкой, мы теряли всю партию импорта из-за исключения внутри транзакции. С тех пор мы вынесли все внешние вызовы в очередь,обработчик только кладёт задачу, а фоновый воркер разбирает её без давления на основной поток импорта.

PHP
// Отправка в очередь (Redis/Beanstalkd)
use My\Queue\Client;
use Bitrix\Main\Engine\CurrentUser;

$event->addEventHandler('catalog', 'OnSuccessOfferUpdateImport1C', function($offerId, $arFields) {
        // Формируем минимальный набор данных для синхронизации
        $job = [
            'offer_id' => (int)$offerId,
            'iblock_id' => (int)$arFields['IBLOCK_ID'],
            'timestamp' => time(),
            'user' => CurrentUser::get()->getLogin() ?: '1c_import'
        ];

        // Кладём в очередь — не ждём ответа
        Client::getInstance()->push('crm_sync', $job);
});

Как измерить время каждого этапа импорта (аналитика)

Реальная история: у клиента с каталогом на 40 тысяч SKU импорт из 1С занимал почти три часа. Менеджеры жаловались, что цены на витрине обновляются раз в сутки, хотя 1С выгружала данные каждые 20 минут. Мы открыли лог и увидели, что сессия импорта не падает — она просто работает неприлично долго. Но где именно теряются минуты? Без замеров это гадание: может, парсинг XML тормозит, может, запись каждого товара в БД, а может, обработчики событий, которые накрутили за годы разработки. Мы регулярно сталкиваемся с такой задачей у клиентов с большим каталогом и выработали простой приём: обернуть каждый этап импорта в замер времени через microtime(true) на событиях OnBefore* / OnAfter*. Это не требует внешних сервисов — достаточно пары обработчиков в init.php и файла лога.

PHP
// init.php — регистрация измерителей времени для этапов импорта
use Bitrix\Main\EventManager;

$eventManager = EventManager::getInstance();

// Измеряем общее время сессии импорта каталога
$eventManager->addEventHandler('catalog', 'OnBeforeCatalogImport1C', function() {
        $_SESSION['IMPORT_TIMER'] = [];
        $_SESSION['IMPORT_TIMER']['session_start'] = microtime(true);
});

$eventManager->addEventHandler('catalog', 'OnAfterCatalogImport1C', function() {
        if (!empty($_SESSION['IMPORT_TIMER']['session_start'])) {
            $total = microtime(true) - $_SESSION['IMPORT_TIMER']['session_start'];
            $log = date('Y-m-d H:i:s') . " | Сессия импорта: " . round($total, 4) . " сек\n";
            file_put_contents($_SERVER['DOCUMENT_ROOT'] . '/upload/import_timers.log', $log, FILE_APPEND);
        }
});

// Замер каждого добавления / обновления товара
$eventManager->addEventHandler('iblock', 'OnBeforeIBlockElementAdd', function(&$arFields) {
        $_SESSION['IMPORT_TIMER']['element_add_start'] = microtime(true);
});

$eventManager->addEventHandler('iblock', 'OnAfterIBlockElementAdd', function(&$arFields) {
        if (!empty($_SESSION['IMPORT_TIMER']['element_add_start'])) {
            $elapsed = microtime(true) - $_SESSION['IMPORT_TIMER']['element_add_start'];
            $log = date('Y-m-d H:i:s') . " | Добавление элемента ID={$arFields['ID']}: {$elapsed} сек\n";
            file_put_contents($_SERVER['DOCUMENT_ROOT'] . '/upload/import_timers.log', $log, FILE_APPEND);
        }
});

Как логировать без утечки памяти

На первый взгляд всё просто: открыли файл, дописали строку, закрыли. Но при импорте 10 тысяч товаров file_put_contents с флагом FILE_APPEND будет дёргаться 10 тысяч раз,и каждый раз система открывает/закрывает файловый дескриптор. В наших проектах мы заметили, что на больших каталогах (50k+ SKU) это даёт прирост времени на 15–20% только на операции ввода-вывода. Лучше буферизировать замеры в памяти и сбрасывать раз в 100–200 записей,или вообще после завершения сессии. Второй важный момент: $_SESSION при импорте из 1С не всегда доступна (сессия может не стартовать), поэтому надёжнее использовать глобальный массив или статическое свойство класса.

PHP
// init.php — буферизированное логирование с микротаймами
class ImportTimer
{
    private static array $buffer = [];
    private static int $flushCount = 0;

    public static function start(string $label): void
    {
        self::$buffer[$label . '_start'] = microtime(true);
    }

    public static function stop(string $label, ?int $elementId = null): void
    {
        $startKey = $label . '_start';
        if (!isset(self::$buffer[$startKey])) {
            return;
        }
        $elapsed = microtime(true) - self::$buffer[$startKey];
        $log = date('Y-m-d H:i:s') . " | {$label}" . ($elementId ? " ID={$elementId}" : '') . ": " . round($elapsed, 4) . " сек\n";
        self::$buffer['log'][] = $log;
        unset(self::$buffer[$startKey]);

        // Сбрасываем на диск каждые 200 замеров
        self::$flushCount++;
        if (self::$flushCount >= 200) {
            self::flush();
        }
    }

    public static function flush(): void
    {
        if (empty(self::$buffer['log'])) {
            return;
        }
        file_put_contents(
            $_SERVER['DOCUMENT_ROOT'] . '/upload/import_timers.log',
            implode('', self::$buffer['log']),
            FILE_APPEND
        );
        self::$buffer['log'] = [];
        self::$flushCount = 0;
    }
}

// Регистрация обработчиков с использованием класса
use Bitrix\Main\EventManager;

$eventManager = EventManager::getInstance();

$eventManager->addEventHandler('catalog', 'OnBeforeCatalogImport1C', function() {
        ImportTimer::start('session');
});

$eventManager->addEventHandler('catalog', 'OnAfterCatalogImport1C', function() {
        ImportTimer::stop('session');
        ImportTimer::flush(); // гарантированно сбрасываем остатки
});

$eventManager->addEventHandler('iblock', 'OnBeforeIBlockElementUpdate', function(&$arFields) {
        ImportTimer::start('element_update');
});

$eventManager->addEventHandler('iblock', 'OnAfterIBlockElementUpdate', function(&$arFields) {
        ImportTimer::stop('element_update', (int)$arFields['ID']);
});

Самые частые ошибки, которые ломают импорт

Помню случай у клиента с каталогом на 30 тысяч SKU: импорт из 1С стабильно зависал на втором часу, а в логах висела ошибка MySQL deadlock. Мы потратили неделю, чтобы выяснить — две параллельные сессии импорта пытались одновременно обновить один и тот же товар. Набили шишек и на других граблях: транзакции, которые откатывают всю партию из-за одного невалидного артикула, кеш, который не чистится сам, и события, которые стреляют по 10 тысяч раз за сессию. Собрали самые болезненные ошибки — с причинами и проверенными решениями.

Грабля Причина Решение
Импорт зависает намертво Тяжёлый обработчик внутри транзакции Вынести логику в очередь (AGENT / RabbitMQ)
Товар сохранился без цены OnAfterIBlockElementAdd сработал до загрузки цен Подписаться на OnBeforeCatalogPriceUpdate с задержкой
Кеш не обновился сутки Bitrix не чистит кеш каталога после импорта Вызвать CCacheManager::ClearByTag('catalog') в OnAfterCatalogImport1C
Данные в CRM дублируются Два параллельных импорта без блокировки Мьютекс через Redis SETNX или flock
Ошибка «не хватает памяти» Обработчики держат ссылки на $arFields unset() в конце + gc_collect_cycles()

Отдельно стоит разобрать проблему race condition при параллельных сессиях импорта. 1С может запускать несколько сессий одновременно,например, одна грузит каталог, другая цены, третья остатки. Если каждая из них в обработчике OnAfterIBlockElementUpdate пытается писать в одну и ту же внешнюю таблицу (скажем, в Elasticsearch или CRM), без блокировки данные поедут. Мы в своих проектах используем простой мьютекс на основе Redis SETNX или файлового лока через flock. Проверяем в OnBeforeCatalogImport1C: если ключ import_1c_lock уже занят,возвращаем false и прерываем сессию. Это гарантирует, что только один поток пишет в третью систему в любой момент времени. Вариант для проектов без Redis,file-based lock на /tmp/import_1c.lock с TTL 30 минут.

Как вынести логику обработчиков в очередь (архитектурный совет)

Помню случай у клиента в e-commerce с каталогом на 40 тысяч SKU: импорт из 1С стабильно падал по таймауту на этапе обработки OnAfterIBlockElementUpdate. Проблема была не в самом импорте — а в том, что внутри обработчика синхронно дёргалась внешняя CRM через REST API. Каждый запрос занимал 200–500 мс, и на 40 тысячах элементов это превращалось в часы ожидания. Решение лежало на поверхности: в обработчике нужно только класть задачу в очередь, а всю тяжёлую логику выполнять асинхронно, вне цикла импорта. Когда к нам приходят с такой задачей, мы рекомендуем Redis Streams или RabbitMQ — они надёжнее, чем таблица b_queue Битрикса, и не блокируют сессию 1С.

PHP
use Bitrix\Main\Application;
use Bitrix\Main\EventManager;
use Bitrix\Main\Diag\Debug;

// В init.php регистрируем лёгкий обработчик
EventManager::getInstance()->addEventHandler(
    'iblock',
    'OnAfterIBlockElementUpdate',
    function (&$arFields) {
        // Проверяем, что это наш инфоблок (замените ID на свой)
        if ((int)$arFields['IBLOCK_ID'] !== 37) return;

        // Формируем задачу: только ID элемента и тип операции
        $job = [
            'entity_type' => 'element',
            'entity_id'   => (int)$arFields['ID'],
            'action'      => 'sync_to_crm',
            'timestamp'   => time(),
        ];

        // Кладём в Redis Streams через нативный клиент
        $redis = new \Redis();
        $redis->connect('127.0.0.1', 6379);
        $redis->xAdd('import_jobs', '*', $job);
    }
);

Как настроить воркер

Сам по себе Redis Streams,это только очередь. Нужен воркер, который будет забирать задачи и выполнять реальную работу. В наших проектах мы запускаем отдельный PHP-скрипт через supervisor с бесконечным циклом: читаем новые сообщения из потока, выполняем логику (синхронизация с CRM, генерация микроразметки, отправка уведомлений) и подтверждаем удаление из очереди. Такой подход гарантирует, что даже при пиковой нагрузке импорт из 1С не зависнет,воркер просто накопит backlog и обработает его по мере возможности.

PHP
// worker.php — запускается через supervisor
declare(strict_types=1);

require_once $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';

$redis = new \Redis();
$redis->connect('127.0.0.1', 6379);

$lastId = '0-0'; // начальная позиция — с начала

while (true) {
    // Читаем новые сообщения (блокировка до 5 секунд)
    $messages = $redis->xRead(['import_jobs' => $lastId], 1, 5000);

    if (!$messages) {
        continue;
    }

    foreach ($messages as $stream => $entries) {
        foreach ($entries as $id => $job) {
            try {
                // Выполняем реальную логику (пример: синхронизация с CRM)
                $entityId = (int)$job['entity_id'];
                // ... здесь ваш код: вызов API, запись в БД и т.д.

                // Подтверждаем обработку — удаляем из очереди
                $redis->xAck('import_jobs', 'import_consumers', [$id]);
                $redis->xDel('import_jobs', [$id]);

                $lastId = $id; // сдвигаем указатель
            } catch (\Throwable $e) {
                // Логируем ошибку, но не удаляем — повторится при перезапуске
                Debug::writeToFile(
                    $e->getMessage(),
                    'Worker error',
                    '/local/logs/import_worker.log'
                );
            }
        }
    }
}
Ключевые выводы
  1. • События OnBefore/OnAfterCatalogImport1C обрамляют весь сеанс импорта — идеально для общей очистки кеша и нотификаций. • OnAfterIBlockElementUpdate стреляет тысячи раз за импорт; тяжёлые обработчики лучше уносить в очередь. • Транзакции DB: throw в обработчике роняет всю партию, а не один элемент — используйте try/catch.
#Bitrix #PHP #Bitrix модули
А
автор · Backend / SRE Engineer
Артем Колячек

Backend-разработчик и SRE в студии Paradigma. Занимается тем, что у других проектов обычно обнаруживается за неделю до запуска: переездом Bitrix-проектов между серверами, починкой кешей после миграций, выстраиванием pipeline для контейнеров, мониторингом под нагрузкой.

До Paradigma — 7 лет в backend (PHP + Postgres) и в operations (Linux, Docker, Coolify, restic-бэкапы). Любит когда логи разговаривают полным синтаксисом ошибки, а не «что-то пошло не так».

На блоге пишет ровно про те ситуации с которыми сам разбирался руками: какой Bitrix-апдейт сломал кеш и как откатить, почему Docker-сеть не находит контейнер после рекрейта, что делать когда asyncpg ругается на event loop.