«Поменялось свойство — сделай действие» звучит просто, а на практике это самая частая причина странного поведения инфоблоков: сохранение то срабатывает, то нет, обмен с 1С замедляется в разы, а иногда элемент вообще перестаёт сохраняться.
Причина в том, что события инфоблоков устроены неочевидно: в обработчик обновления приходят только изменённые поля, а свойства меняются не только через Update(). Разберём, как понять, что именно поменялось, и как не сломать при этом импорт.
Задача
Автоматически выполнять действия при изменении свойств элемента инфоблока:
- Обновлять цену товара при изменении свойства PRICE
- Логировать изменения важных полей
- Синхронизировать данные с внешними системами
События изменения элементов
| Событие | Когда вызывается | Можно отменить |
|---|---|---|
OnBeforeIBlockElementUpdate | Перед сохранением | Да |
OnStartIBlockElementUpdate | В начале сохранения | Нет |
OnAfterIBlockElementUpdate | После сохранения | Нет |
В $arFields приходит не элемент целиком, а только то, что передали в Update(). Это ключевой факт для всей темы, и из него следуют два практических правила.
Проверяйте наличие ключа, а не его значение. if ($arFields['ACTIVE'] === 'N') для обновления, где активность не менялась, обратится к несуществующему ключу — и на PHP 8 вы получите предупреждение, а логика молча отработает неверно. Правильно: if (isset($arFields['ACTIVE']) && ...).
Хотите знать старое значение — читайте его сами. В OnBefore* элемент в базе ещё прежний, поэтому именно там (а не в OnAfter*) можно сравнить «было» и «стало». В OnAfter* старого значения уже нет нигде.
Базовый пример: синхронизация цен
При изменении свойства PRICE обновляем цену в модуле каталога:
<?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() вызывает свои события:
| Метод | Событие до | Событие после |
|---|---|---|
SetPropertyValues | OnIBlockElementSetPropertyValues | OnAfterIBlockElementSetPropertyValues |
SetPropertyValuesEx | OnIBlockElementSetPropertyValuesEx | OnAfterIBlockElementSetPropertyValuesEx |
<?php
EventManager::getInstance()->addEventHandler(
'iblock',
'OnAfterIBlockElementSetPropertyValues',
function ($elementId, $iblockId, $propertyValues, $propertyCode) {
// $propertyCode может быть массивом или строкой
if ($propertyCode === 'PRICE' ||
(is_array($propertyCode) && in_array('PRICE', $propertyCode))) {
// Логика синхронизации цены
}
}
);Класс для отслеживания изменений
<?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
// /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
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.