Главная/Статьи/Битрикс: создание пользовательского типа свойства

Битрикс: создание пользовательского типа свойства

Разработка собственного типа свойства инфоблока с дополнительными полями. Привязка к элементам с количеством, ценой или описанием.

ДМ
Дмитрий Мещеряков
📅 27 ноября 2019 г.📖 9 мин чтения

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

Механизм пользовательских типов свойств закрывает такие случаи, но у него крутая кривая входа: нужно понимать, в какой момент вызывается каждый колбэк и что именно уезжает в базу. Разберём на рабочем примере — и на граблях, которые в нём заложены.

Задача

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

Применения:

  • Комплектующие товара с количеством
  • Опции товара с ценой
  • Связанные товары с описанием связи
⚠️ Важно

Проверьте базовый тип до того, как начнёте. В примере ниже PROPERTY_TYPE объявлен как 'E' — привязка к элементу, — но в базу пишется сериализованная структура. Для Битрикса тип E означает, что значением является идентификатор элемента: на нём строятся связи, фильтрация по привязке и, при втором варианте хранения свойств, числовой тип колонки в таблице b_iblock_element_prop_s{IBLOCK_ID}.

Прежде чем брать этот код в проект, проверьте на своём инфоблоке, что структура сохраняется и читается без потерь. Для составного значения безопаснее базовый тип 'S' (строка): он не обещает ядру ничего про содержимое, и никакой логики привязки к нему не привязано. Возможностью выбрать элемент через iblock_element_search.php вы при этом не жертвуете — её даёт ваш собственный GetPropertyFieldHtml, а не базовый тип.

Структура пользовательского типа

php
<?php
// /local/php_interface/classes/UserType/ElementWithQuantity.php

namespace Local\UserType;

class ElementWithQuantity
{
    /**
     * Регистрация типа свойства
     */
    public static function getUserTypeDescription(): array
    {
        return [
            'PROPERTY_TYPE' => 'E', // Базовый тип — привязка к элементу
            'USER_TYPE' => 'ElementWithQuantity',
            'DESCRIPTION' => 'Привязка к элементу с количеством',
            'GetPropertyFieldHtml' => [self::class, 'getPropertyFieldHtml'],
            'GetAdminListViewHTML' => [self::class, 'getAdminListViewHtml'],
            'GetPublicViewHTML' => [self::class, 'getPublicViewHtml'],
            'ConvertToDB' => [self::class, 'convertToDB'],
            'ConvertFromDB' => [self::class, 'convertFromDB'],
            'GetSearchContent' => [self::class, 'getSearchContent'],
        ];
    }

    /**
     * HTML для формы редактирования в админке
     */
    public static function getPropertyFieldHtml(
        array $property,
        array $value,
        array $htmlControl
    ): string {
        // Десериализуем данные
        $elementId = 0;
        $quantity = 1;
        
        if (!empty($value['VALUE'])) {
            $data = self::unserializeValue($value['VALUE']);
            $elementId = $data['ELEMENT_ID'] ?? 0;
            $quantity = $data['QUANTITY'] ?? 1;
        }

        // Получаем информацию о связанном элементе
        $elementName = '';
        if ($elementId > 0) {
            $element = \CIBlockElement::GetList(
                [],
                ['ID' => $elementId, 'IBLOCK_ID' => $property['LINK_IBLOCK_ID']],
                false,
                ['nTopCount' => 1],
                ['ID', 'NAME']
            )->Fetch();
            
            if ($element) {
                $elementName = $element['NAME'];
            }
        }

        $inputName = $htmlControl['VALUE'];
        $inputId = md5($inputName);

        $html = '<div class="element-with-quantity" style="display: flex; gap: 10px; align-items: center;">';
        
        // Поле выбора элемента
        $html .= '<input type="hidden" name="' . $inputName . '[ELEMENT_ID]" id="element_' . $inputId . '" value="' . (int)$elementId . '">';
        $html .= '<span id="element_name_' . $inputId . '" style="min-width: 200px;">' . htmlspecialchars($elementName) . '</span>';
        
        // Кнопка выбора
        $html .= '<input type="button" value="Выбрать" onclick="jsUtils.OpenWindow(\'/bitrix/admin/iblock_element_search.php?lang=' . LANGUAGE_ID . '&IBLOCK_ID=' . (int)$property['LINK_IBLOCK_ID'] . '&n=' . $inputName . '[ELEMENT_ID]&k=element_name_' . $inputId . '\', 900, 700);">';
        
        // Поле количества
        $html .= '<label style="margin-left: 15px;">Кол-во: ';
        $html .= '<input type="number" name="' . $inputName . '[QUANTITY]" value="' . (int)$quantity . '" min="1" style="width: 60px;">';
        $html .= '</label>';
        
        $html .= '</div>';

        return $html;
    }

    /**
     * Отображение в списке админки
     */
    public static function getAdminListViewHtml(
        array $property,
        array $value,
        array $htmlControl
    ): string {
        if (empty($value['VALUE'])) {
            return '';
        }

        $data = self::unserializeValue($value['VALUE']);
        $elementId = $data['ELEMENT_ID'] ?? 0;
        $quantity = $data['QUANTITY'] ?? 1;

        if ($elementId <= 0) {
            return '';
        }

        $element = \CIBlockElement::GetByID($elementId)->Fetch();
        $name = $element ? $element['NAME'] : "ID: {$elementId}";

        return htmlspecialchars($name) . ' × ' . $quantity;
    }

    /**
     * Публичное отображение
     */
    public static function getPublicViewHtml(
        array $property,
        array $value,
        array $htmlControl
    ): string {
        return self::getAdminListViewHtml($property, $value, $htmlControl);
    }

    /**
     * Преобразование для сохранения в БД
     */
    public static function convertToDB(array $property, array $value): array
    {
        $elementId = (int)($value['VALUE']['ELEMENT_ID'] ?? 0);
        $quantity = (int)($value['VALUE']['QUANTITY'] ?? 1);

        if ($elementId <= 0) {
            return ['VALUE' => '', 'DESCRIPTION' => ''];
        }

        return [
            'VALUE' => serialize([
                'ELEMENT_ID' => $elementId,
                'QUANTITY' => max(1, $quantity),
            ]),
            'DESCRIPTION' => '',
        ];
    }

    /**
     * Преобразование при чтении из БД
     */
    public static function convertFromDB(array $property, array $value): array
    {
        if (empty($value['VALUE'])) {
            return ['VALUE' => ''];
        }

        // Значение уже сериализовано — возвращаем как есть
        return ['VALUE' => $value['VALUE']];
    }

    /**
     * Контент для поиска
     */
    public static function getSearchContent(array $property, array $value, array $htmlControl): string
    {
        if (empty($value['VALUE'])) {
            return '';
        }

        $data = self::unserializeValue($value['VALUE']);
        $elementId = $data['ELEMENT_ID'] ?? 0;

        if ($elementId <= 0) {
            return '';
        }

        $element = \CIBlockElement::GetByID($elementId)->Fetch();
        return $element ? $element['NAME'] : '';
    }

    /**
     * Десериализация значения
     */
    private static function unserializeValue($value): array
    {
        if (is_array($value)) {
            return $value;
        }

        // allowed_classes => false обязательно: без него unserialize
        // восстанавливает объекты и вызывает их магические методы.
        // Значение приходит из базы, но в базу оно могло попасть импортом
        $unserialized = @unserialize($value, ['allowed_classes' => false]);

        return is_array($unserialized) ? $unserialized : [];
    }
}

Почему JSON вместо serialize

В расширенном примере ниже данные хранятся в JSON, и это не вопрос вкуса.

serialize() умеет восстанавливать объекты, вызывая при этом магические методы — а значит, unserialize() над строкой из базы становится точкой входа для PHP object injection, если в базу удалось что-то записать (импорт, интеграция, любая другая уязвимость). Флаг allowed_classes => false эту дыру закрывает, но нужно не забыть его поставить в каждом вызове.

json_decode() таких возможностей не имеет в принципе — худшее, что он вернёт, это null. Плюс JSON читается глазами прямо в базе, что экономит время при разборе инцидентов, и не ломается при смене версии PHP.

Serialize здесь оставлен намеренно: это формат, который вы встретите в большинстве существующих проектов, и знать про allowed_classes нужно именно для них. В новом коде берите JSON.

Регистрация типа свойства

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

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

// Автозагрузка классов
Loader::registerAutoLoadClasses(null, [
    'Local\\UserType\\ElementWithQuantity' => '/local/php_interface/classes/UserType/ElementWithQuantity.php',
]);

// Регистрация типа свойства
EventManager::getInstance()->addEventHandler(
    'iblock',
    'OnIBlockPropertyBuildList',
    ['\\Local\\UserType\\ElementWithQuantity', 'getUserTypeDescription']
);

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

Получение данных

php
<?php
use Bitrix\Main\Loader;

Loader::includeModule('iblock');

$element = \CIBlockElement::GetList(
    [],
    ['ID' => $productId],
    false,
    false,
    ['ID', 'NAME', 'PROPERTY_COMPONENTS']
)->GetNext();

// Свойство содержит сериализованные данные
$components = [];

if (!empty($element['PROPERTY_COMPONENTS_VALUE'])) {
    $values = is_array($element['PROPERTY_COMPONENTS_VALUE']) 
        ? $element['PROPERTY_COMPONENTS_VALUE'] 
        : [$element['PROPERTY_COMPONENTS_VALUE']];

    foreach ($values as $serialized) {
        $data = unserialize($serialized);
        
        if (!empty($data['ELEMENT_ID'])) {
            $linkedElement = \CIBlockElement::GetByID($data['ELEMENT_ID'])->Fetch();
            
            $components[] = [
                'ID' => $data['ELEMENT_ID'],
                'NAME' => $linkedElement['NAME'] ?? '',
                'QUANTITY' => $data['QUANTITY'] ?? 1,
            ];
        }
    }
}

// Вывод
foreach ($components as $component) {
    echo "{$component['NAME']} × {$component['QUANTITY']}<br>";
}

Сохранение данных

php
<?php
\CIBlockElement::SetPropertyValuesEx($elementId, $iblockId, [
    'COMPONENTS' => [
        ['VALUE' => ['ELEMENT_ID' => 100, 'QUANTITY' => 2]],
        ['VALUE' => ['ELEMENT_ID' => 101, 'QUANTITY' => 5]],
    ],
]);

Расширенная версия: с ценой и описанием

php
<?php

namespace Local\UserType;

class ElementWithOptions
{
    public static function getUserTypeDescription(): array
    {
        return [
            'PROPERTY_TYPE' => 'E',
            'USER_TYPE' => 'ElementWithOptions',
            'DESCRIPTION' => 'Привязка с опциями (кол-во, цена, описание)',
            'GetPropertyFieldHtml' => [self::class, 'getPropertyFieldHtml'],
            'ConvertToDB' => [self::class, 'convertToDB'],
            'ConvertFromDB' => [self::class, 'convertFromDB'],
        ];
    }

    public static function getPropertyFieldHtml($property, $value, $htmlControl): string
    {
        $data = self::parseValue($value['VALUE'] ?? '');
        $inputName = $htmlControl['VALUE'];
        $inputId = md5($inputName);

        $elementName = '';
        if ($data['ELEMENT_ID'] > 0) {
            $el = \CIBlockElement::GetByID($data['ELEMENT_ID'])->Fetch();
            $elementName = $el['NAME'] ?? '';
        }

        return '
        <div style="display: grid; grid-template-columns: 200px auto; gap: 8px; align-items: center; margin-bottom: 10px; padding: 10px; border: 1px solid #ddd; border-radius: 4px;">
            <label>Элемент:</label>
            <div style="display: flex; gap: 8px; align-items: center;">
                <input type="hidden" name="' . $inputName . '[ELEMENT_ID]" id="el_' . $inputId . '" value="' . (int)$data['ELEMENT_ID'] . '">
                <span id="el_name_' . $inputId . '">' . htmlspecialchars($elementName) . '</span>
                <input type="button" value="..." onclick="jsUtils.OpenWindow(\'/bitrix/admin/iblock_element_search.php?lang=' . LANGUAGE_ID . '&IBLOCK_ID=' . (int)$property['LINK_IBLOCK_ID'] . '&n=' . $inputName . '[ELEMENT_ID]&k=el_name_' . $inputId . '\', 900, 700);">
            </div>
            
            <label>Количество:</label>
            <input type="number" name="' . $inputName . '[QUANTITY]" value="' . (int)$data['QUANTITY'] . '" min="1" style="width: 80px;">
            
            <label>Цена:</label>
            <input type="number" name="' . $inputName . '[PRICE]" value="' . (float)$data['PRICE'] . '" step="0.01" style="width: 100px;">
            
            <label>Описание:</label>
            <input type="text" name="' . $inputName . '[DESCRIPTION]" value="' . htmlspecialchars($data['DESCRIPTION']) . '" style="width: 300px;">
        </div>';
    }

    public static function convertToDB($property, $value): array
    {
        $data = $value['VALUE'];
        
        if (empty($data['ELEMENT_ID'])) {
            return ['VALUE' => ''];
        }

        return [
            'VALUE' => json_encode([
                'ELEMENT_ID' => (int)$data['ELEMENT_ID'],
                'QUANTITY' => max(1, (int)$data['QUANTITY']),
                'PRICE' => (float)$data['PRICE'],
                'DESCRIPTION' => trim($data['DESCRIPTION'] ?? ''),
            ], JSON_UNESCAPED_UNICODE),
        ];
    }

    public static function convertFromDB($property, $value): array
    {
        return ['VALUE' => $value['VALUE']];
    }

    private static function parseValue($value): array
    {
        $defaults = [
            'ELEMENT_ID' => 0,
            'QUANTITY' => 1,
            'PRICE' => 0,
            'DESCRIPTION' => '',
        ];

        if (empty($value)) {
            return $defaults;
        }

        $data = json_decode($value, true);
        return is_array($data) ? array_merge($defaults, $data) : $defaults;
    }
}

Что учесть перед внедрением

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

Фильтрация и поиск по такому свойству не работают «сами». В базе лежит строка с сериализованными данными: умный фильтр по ней ничего не построит, ORM-фильтр по значению — тоже. Если по свойству нужно фильтровать, продумывайте это на этапе проектирования, а не после.

Формат данных фиксируется навсегда. Как только контент-менеджеры заполнили тысячу товаров, менять структуру можно только вместе со скриптом миграции. Продумайте набор полей заранее и лучше заложите один лишний.

Обмен с 1С про ваш формат не знает. Если инфоблок участвует в обмене, проверьте поведение до внедрения: в лучшем случае свойство будет игнорироваться, в худшем — затираться.

Экспорт, импорт и копирование элементов. Штатные механизмы работают со значением как со строкой. Обычно это то, что нужно, но проверить стоит — особенно копирование элемента, которым контент-менеджеры пользуются постоянно.

GetPublicViewHTML в примере возвращает то же, что админский метод. Для публичной части это почти всегда неверно: разметка и вёрстка витрины отличаются от админской. Либо реализуйте метод отдельно, либо не выводите свойство напрямую, а разбирайте значение в шаблоне компонента.

💡 Совет

Прежде чем писать свой тип, оцените альтернативу. Часто задачу «элемент плюс количество» закрывает пара обычных множественных свойств с согласованным порядком значений или отдельный HL-блок со связями. Выглядит менее элегантно, зато работает со штатной фильтрацией, обменом и экспортом без единой строки кода. Собственный тип свойства оправдан, когда важно именно удобство ввода в админке и структура действительно неразделима.

🚀

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

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

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

Комментарии

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