Главная/Статьи/Битрикс: отслеживание изменений свойств элемента инфоблока

Битрикс: отслеживание изменений свойств элемента инфоблока

Обработчики событий для реакции на изменение свойств. Синхронизация цен, логирование изменений, триггеры бизнес-логики.

ДМ
Дмитрий Мещеряков
📅 21 июля 2020 г.📖 7 мин чтения

«Поменялось свойство — сделай действие» звучит просто, а на практике это самая частая причина странного поведения инфоблоков: сохранение то срабатывает, то нет, обмен с 1С замедляется в разы, а иногда элемент вообще перестаёт сохраняться.

Причина в том, что события инфоблоков устроены неочевидно: в обработчик обновления приходят только изменённые поля, а свойства меняются не только через Update(). Разберём, как понять, что именно поменялось, и как не сломать при этом импорт.

Задача

Автоматически выполнять действия при изменении свойств элемента инфоблока:

  • Обновлять цену товара при изменении свойства PRICE
  • Логировать изменения важных полей
  • Синхронизировать данные с внешними системами

События изменения элементов

СобытиеКогда вызываетсяМожно отменить
OnBeforeIBlockElementUpdateПеред сохранениемДа
OnStartIBlockElementUpdateВ начале сохраненияНет
OnAfterIBlockElementUpdateПосле сохраненияНет
⚠️ Важно

В $arFields приходит не элемент целиком, а только то, что передали в Update(). Это ключевой факт для всей темы, и из него следуют два практических правила.

Проверяйте наличие ключа, а не его значение. if ($arFields['ACTIVE'] === 'N') для обновления, где активность не менялась, обратится к несуществующему ключу — и на PHP 8 вы получите предупреждение, а логика молча отработает неверно. Правильно: if (isset($arFields['ACTIVE']) && ...).

Хотите знать старое значение — читайте его сами. В OnBefore* элемент в базе ещё прежний, поэтому именно там (а не в OnAfter*) можно сравнить «было» и «стало». В OnAfter* старого значения уже нет нигде.

Базовый пример: синхронизация цен

При изменении свойства PRICE обновляем цену в модуле каталога:

php
<?php
// /local/php_interface/init.php

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

Loader::includeModule('catalog');

EventManager::getInstance()->addEventHandler(
    'iblock',
    'OnBeforeIBlockElementUpdate',
    'syncProductPrice'
);

function syncProductPrice(&$arFields): bool
{
    $catalogIblockId = 2; // ID инфоблока каталога
    
    if ($arFields['IBLOCK_ID'] != $catalogIblockId) {
        return true;
    }

    // Получаем значение свойства PRICE
    $price = null;
    
    if (isset($arFields['PROPERTY_VALUES'])) {
        foreach ($arFields['PROPERTY_VALUES'] as $propId => $propValue) {
            // Получаем информацию о свойстве
            $prop = \CIBlockProperty::GetByID($propId, $arFields['IBLOCK_ID'])->Fetch();
            
            if ($prop && $prop['CODE'] === 'PRICE') {
                $value = is_array($propValue) ? current($propValue) : $propValue;
                if (is_array($value) && isset($value['VALUE'])) {
                    $price = $value['VALUE'];
                } else {
                    $price = $value;
                }
                break;
            }
        }
    }

    if ($price === null || !is_numeric($price)) {
        return true;
    }

    $productId = $arFields['ID'];
    $priceTypeId = 1; // Базовая цена

    // Убеждаемся, что элемент является товаром
    if (!\CCatalogProduct::GetByID($productId)) {
        \CCatalogProduct::Add([
            'ID' => $productId,
            'QUANTITY' => 0,
        ]);
    }

    // Обновляем или добавляем цену
    $priceFields = [
        'PRODUCT_ID' => $productId,
        'CATALOG_GROUP_ID' => $priceTypeId,
        'PRICE' => (float) $price,
        'CURRENCY' => 'RUB',
    ];

    $existingPrice = \CPrice::GetList(
        [],
        [
            'PRODUCT_ID' => $productId,
            'CATALOG_GROUP_ID' => $priceTypeId,
        ]
    )->Fetch();

    if ($existingPrice) {
        \CPrice::Update($existingPrice['ID'], $priceFields);
    } else {
        \CPrice::Add($priceFields);
    }

    return true;
}

События для SetPropertyValues

Метод CIBlockElement::SetPropertyValues() вызывает свои события:

МетодСобытие доСобытие после
SetPropertyValuesOnIBlockElementSetPropertyValuesOnAfterIBlockElementSetPropertyValues
SetPropertyValuesExOnIBlockElementSetPropertyValuesExOnAfterIBlockElementSetPropertyValuesEx
php
<?php
EventManager::getInstance()->addEventHandler(
    'iblock',
    'OnAfterIBlockElementSetPropertyValues',
    function ($elementId, $iblockId, $propertyValues, $propertyCode) {
        // $propertyCode может быть массивом или строкой
        
        if ($propertyCode === 'PRICE' || 
            (is_array($propertyCode) && in_array('PRICE', $propertyCode))) {
            // Логика синхронизации цены
        }
    }
);

Класс для отслеживания изменений

php
<?php

namespace Local\Iblock;

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

class PropertyChangeTracker
{
    /**
     * Кэш предыдущих значений для сравнения
     */
    private static array $previousValues = [];

    /**
     * Зарегистрированные обработчики
     * [
     *     'IBLOCK_ID' => [
     *         'PROPERTY_CODE' => callable
     *     ]
     * ]
     */
    private static array $handlers = [];

    /**
     * Регистрация трекера
     */
    public static function register(): void
    {
        EventManager::getInstance()->addEventHandler(
            'iblock',
            'OnBeforeIBlockElementUpdate',
            [self::class, 'beforeUpdate']
        );

        EventManager::getInstance()->addEventHandler(
            'iblock',
            'OnAfterIBlockElementUpdate',
            [self::class, 'afterUpdate']
        );
    }

    /**
     * Добавление обработчика изменения свойства
     */
    public static function onPropertyChange(
        int $iblockId,
        string $propertyCode,
        callable $handler
    ): void {
        self::$handlers[$iblockId][$propertyCode][] = $handler;
    }

    /**
     * Сохраняем предыдущие значения перед обновлением
     */
    public static function beforeUpdate(&$arFields): bool
    {
        if (empty(self::$handlers[$arFields['IBLOCK_ID']])) {
            return true;
        }

        $elementId = $arFields['ID'];
        $propertyCodes = array_keys(self::$handlers[$arFields['IBLOCK_ID']]);

        // Получаем текущие значения свойств
        $properties = \CIBlockElement::GetProperty(
            $arFields['IBLOCK_ID'],
            $elementId,
            [],
            ['CODE' => $propertyCodes]
        );

        self::$previousValues[$elementId] = [];
        while ($prop = $properties->Fetch()) {
            self::$previousValues[$elementId][$prop['CODE']] = $prop['VALUE'];
        }

        return true;
    }

    /**
     * Сравниваем значения после обновления
     */
    public static function afterUpdate(&$arFields): void
    {
        $elementId = $arFields['ID'];
        $iblockId = $arFields['IBLOCK_ID'];

        if (empty(self::$handlers[$iblockId])) {
            return;
        }

        // Получаем новые значения
        $propertyCodes = array_keys(self::$handlers[$iblockId]);
        
        $properties = \CIBlockElement::GetProperty(
            $iblockId,
            $elementId,
            [],
            ['CODE' => $propertyCodes]
        );

        $newValues = [];
        while ($prop = $properties->Fetch()) {
            $newValues[$prop['CODE']] = $prop['VALUE'];
        }

        // Сравниваем и вызываем обработчики
        foreach (self::$handlers[$iblockId] as $propertyCode => $handlers) {
            $oldValue = self::$previousValues[$elementId][$propertyCode] ?? null;
            $newValue = $newValues[$propertyCode] ?? null;

            if ($oldValue !== $newValue) {
                foreach ($handlers as $handler) {
                    call_user_func($handler, [
                        'ELEMENT_ID' => $elementId,
                        'IBLOCK_ID' => $iblockId,
                        'PROPERTY_CODE' => $propertyCode,
                        'OLD_VALUE' => $oldValue,
                        'NEW_VALUE' => $newValue,
                    ]);
                }
            }
        }

        // Очищаем кэш
        unset(self::$previousValues[$elementId]);
    }
}

Использование трекера

php
<?php
// /local/php_interface/init.php

use Local\Iblock\PropertyChangeTracker;

PropertyChangeTracker::register();

// При изменении цены — синхронизируем с каталогом
PropertyChangeTracker::onPropertyChange(2, 'PRICE', function ($data) {
    syncCatalogPrice($data['ELEMENT_ID'], $data['NEW_VALUE']);
});

// При изменении статуса — отправляем уведомление
PropertyChangeTracker::onPropertyChange(2, 'STATUS', function ($data) {
    if ($data['NEW_VALUE'] === 'published') {
        sendPublishNotification($data['ELEMENT_ID']);
    }
});

// Логирование изменений артикула
PropertyChangeTracker::onPropertyChange(2, 'VENDOR', function ($data) {
    AddMessage2Log(sprintf(
        "Артикул изменён: элемент %d, было '%s', стало '%s'",
        $data['ELEMENT_ID'],
        $data['OLD_VALUE'],
        $data['NEW_VALUE']
    ), 'iblock_changes');
});

Логирование всех изменений

php
<?php

namespace Local\Iblock;

class ChangeLogger
{
    private const LOG_IBLOCK_ID = 10; // Инфоблок для логов

    public static function register(array $trackedIblocks): void
    {
        foreach ($trackedIblocks as $iblockId) {
            \Bitrix\Main\EventManager::getInstance()->addEventHandler(
                'iblock',
                'OnAfterIBlockElementUpdate',
                function (&$arFields) use ($iblockId) {
                    if ($arFields['IBLOCK_ID'] == $iblockId) {
                        self::logChange($arFields);
                    }
                }
            );
        }
    }

    private static function logChange(array $arFields): void
    {
        global $USER;

        $el = new \CIBlockElement();
        $el->Add([
            'IBLOCK_ID' => self::LOG_IBLOCK_ID,
            'NAME' => sprintf(
                'Изменение элемента #%d в инфоблоке %d',
                $arFields['ID'],
                $arFields['IBLOCK_ID']
            ),
            'ACTIVE' => 'Y',
            'PROPERTY_VALUES' => [
                'ELEMENT_ID' => $arFields['ID'],
                'IBLOCK_ID' => $arFields['IBLOCK_ID'],
                'USER_ID' => $USER->GetID(),
                'CHANGES' => json_encode($arFields, JSON_UNESCAPED_UNICODE),
            ],
        ]);
    }
}

// Регистрация
ChangeLogger::register([2, 3, 5]); // Логируем изменения в инфоблоках 2, 3, 5
⚠️ Важно

Логирование $arFields целиком — мина в этом коде. json_encode($arFields) пишет в журнал всё переданное содержимое: детальное описание товара, base64-данные загруженных файлов, служебные поля. На каталоге в обмене с 1С такая таблица растёт на гигабайты в неделю, а искать в ней что-либо невозможно.

Логируйте разницу, а не срез: список изменившихся полей со старым и новым значением, с обрезкой длинных строк. Заодно журнал становится читаемым для человека, ради чего он и заводился.

И проверьте $USER->GetID(): в обмене с 1С, в агенте и в консольном скрипте авторизованного пользователя нет. Обработчик, обращающийся к $USER без проверки, в этих контекстах ведёт себя непредсказуемо.

Цена обработчика на импорте

Об этом стоит думать до того, как обработчик написан, а не после первого ночного обмена.

Событие изменения элемента срабатывает на каждой позиции обмена с 1С. Каталог на 50 тысяч товаров — это 50 тысяч вызовов вашего кода за один прогон. Всё, что внутри, умножается на это число: запрос к базе за старым значением, запись в лог, сброс кэша, обращение к внешнему API.

Практические меры:

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

Никаких внешних вызовов внутри. HTTP-запрос к стороннему сервису на каждой позиции превращает десятиминутный обмен в многочасовой, а недоступность сервиса — в сорванный обмен. Ставьте задачу в очередь.

Сделайте рубильник. Константа или опция, отключающая обработчики на время массового импорта. Это тот случай, когда пять минут работы экономят ночь.

Итоги

Событие даёт изменённые поля, а не элемент. Проверяйте наличие ключей; за старым значением идите в базу в OnBefore*.

Update() и SetPropertyValues() — разные пути. Событие, повешенное на первый, не сработает на втором, и это самая частая причина «обработчик почему-то не вызывается». Если свойства могут меняться обоими способами, подписывайтесь на оба.

Обработчик выполняется в критическом пути записи и умножается на объём импорта. Внутри — только лёгкие операции и ранний выход.

Журнал изменений должен хранить разницу, а не дамп полей. Иначе он станет самой большой таблицей в базе и при этом бесполезной.

Не полагайтесь на $USER и $APPLICATION — тот же код выполнится в cron и в CLI.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.