Главная/Статьи/Битрикс: страница товаров со скидками

Битрикс: страница товаров со скидками

Создание раздела /sale/ с товарами, на которые действуют скидки из маркетинговых правил. Класс для фильтрации и кэширования.

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

«Сделайте страницу со всеми акционными товарами» — задача, которая звучит как получасовая, а оказывается одной из самых неприятных в Битриксе. Причина простая: скидка в Битриксе не является свойством товара. Это правило, которое вычисляется в момент показа корзины или карточки, и заранее «списка товаров со скидками» нигде не существует.

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

Задача

Создать страницу /sale/, на которой отображаются все товары с активными скидками из маркетинговых предложений и правил работы с корзиной.

Стандартный компонент catalog.section не умеет фильтровать по скидкам — нужен собственный класс.

⚠️ Важно

Про природу решения. Класс ниже разбирает структуры CONDITIONS и ACTIONS из таблицы b_sale_discount — то есть опирается на внутренний формат данных модуля, который не документирован и меняется между версиями. Это осознанный компромисс, а не образец архитектуры: штатного способа спросить «какие товары сейчас со скидкой» в API нет.

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

Класс для получения товаров со скидками

php
<?php
// /local/php_interface/classes/Discount/ProductDiscountFilter.php

namespace Local\Discount;

use Bitrix\Main\Loader;
use Bitrix\Sale\Internals\DiscountTable;

class ProductDiscountFilter
{
    private array $discountIds = [];
    private array $productIds = [];
    private ?int $userGroupId = null;

    public function __construct()
    {
        Loader::includeModule('sale');
        Loader::includeModule('catalog');
        Loader::includeModule('iblock');
    }

    /**
     * Установить группу пользователя для фильтрации скидок
     */
    public function setUserGroup(int $groupId): self
    {
        $this->userGroupId = $groupId;
        return $this;
    }

    /**
     * Получить ID товаров со скидками
     */
    public function getProductIds(): array
    {
        $this->loadActiveDiscounts();
        $this->extractProductsFromDiscounts();
        
        return array_unique($this->productIds);
    }

    /**
     * Получить фильтр для catalog.section
     */
    public function getFilter(): array
    {
        $productIds = $this->getProductIds();
        
        if (empty($productIds)) {
            // Вернуть невозможное условие, чтобы ничего не показывать
            return ['ID' => 0];
        }
        
        return ['ID' => $productIds];
    }

    /**
     * Загрузить активные скидки
     */
    private function loadActiveDiscounts(): void
    {
        $filter = [
            'ACTIVE' => 'Y',
            'SITE_ID' => SITE_ID,
            '<=ACTIVE_FROM' => new \Bitrix\Main\Type\DateTime(),
            [
                'LOGIC' => 'OR',
                ['>=ACTIVE_TO' => new \Bitrix\Main\Type\DateTime()],
                ['ACTIVE_TO' => null],
            ],
        ];

        // Фильтр по группе пользователей
        if ($this->userGroupId) {
            $filter['USER_GROUPS'] = $this->userGroupId;
        }

        $discounts = DiscountTable::getList([
            'select' => ['ID', 'CONDITIONS', 'ACTIONS'],
            'filter' => $filter,
        ]);

        while ($discount = $discounts->fetch()) {
            $this->discountIds[] = $discount['ID'];
            $this->parseDiscountConditions($discount);
        }
    }

    /**
     * Парсинг условий скидки для извлечения ID товаров
     */
    private function parseDiscountConditions(array $discount): void
    {
        // Парсим CONDITIONS
        if (!empty($discount['CONDITIONS'])) {
            $conditions = is_string($discount['CONDITIONS']) 
                ? unserialize($discount['CONDITIONS'], ['allowed_classes' => false]) 
                : $discount['CONDITIONS'];
            
            $this->extractProductsFromCondition($conditions);
        }

        // Парсим ACTIONS
        if (!empty($discount['ACTIONS'])) {
            $actions = is_string($discount['ACTIONS']) 
                ? unserialize($discount['ACTIONS'], ['allowed_classes' => false]) 
                : $discount['ACTIONS'];
            
            $this->extractProductsFromCondition($actions);
        }
    }

    /**
     * Рекурсивное извлечение ID товаров из условий
     */
    private function extractProductsFromCondition($condition): void
    {
        if (!is_array($condition)) {
            return;
        }

        // Прямое указание товаров
        if (isset($condition['CLASS_ID'])) {
            $classId = $condition['CLASS_ID'];
            
            // Условие на конкретный товар
            if ($classId === 'CondIBElement' && !empty($condition['DATA']['value'])) {
                $this->addProductId($condition['DATA']['value']);
            }
            
            // Условие на XML_ID товара
            if ($classId === 'CondIBXmlID' && !empty($condition['DATA']['value'])) {
                $this->addProductByXmlId($condition['DATA']['value']);
            }
            
            // Условие на раздел
            if ($classId === 'CondIBSection' && !empty($condition['DATA']['value'])) {
                $this->addProductsBySection($condition['DATA']['value']);
            }
            
            // Условие на свойство
            if ($classId === 'CondIBProp' && !empty($condition['DATA'])) {
                $this->addProductsByProperty($condition['DATA']);
            }
        }

        // Рекурсивный обход вложенных условий
        if (isset($condition['CHILDREN']) && is_array($condition['CHILDREN'])) {
            foreach ($condition['CHILDREN'] as $child) {
                $this->extractProductsFromCondition($child);
            }
        }
    }

    /**
     * Добавить ID товара (или массив ID)
     */
    private function addProductId($value): void
    {
        if (is_array($value)) {
            $this->productIds = array_merge($this->productIds, array_map('intval', $value));
        } else {
            $this->productIds[] = (int) $value;
        }
    }

    /**
     * Найти товар по XML_ID
     */
    private function addProductByXmlId($xmlId): void
    {
        $element = \CIBlockElement::GetList(
            [],
            ['XML_ID' => $xmlId, 'ACTIVE' => 'Y'],
            false,
            ['nTopCount' => 1],
            ['ID']
        )->Fetch();

        if ($element) {
            $this->productIds[] = (int) $element['ID'];
        }
    }

    /**
     * Получить все товары из раздела
     */
    private function addProductsBySection($sectionId): void
    {
        $elements = \CIBlockElement::GetList(
            [],
            ['SECTION_ID' => $sectionId, 'ACTIVE' => 'Y', 'INCLUDE_SUBSECTIONS' => 'Y'],
            false,
            false,
            ['ID']
        );

        while ($element = $elements->Fetch()) {
            $this->productIds[] = (int) $element['ID'];
        }
    }

    /**
     * Получить товары по свойству
     */
    private function addProductsByProperty(array $data): void
    {
        if (empty($data['ID']) || !isset($data['value'])) {
            return;
        }

        $propertyCode = $data['ID'];
        $propertyValue = $data['value'];

        $elements = \CIBlockElement::GetList(
            [],
            ['ACTIVE' => 'Y', 'PROPERTY_' . $propertyCode => $propertyValue],
            false,
            false,
            ['ID']
        );

        while ($element = $elements->Fetch()) {
            $this->productIds[] = (int) $element['ID'];
        }
    }
}
💡 Совет

Здесь заложена главная неточность подхода. Условия скидки — это не всегда «на какие товары она распространяется». Правило вида «скидка 500 рублей на заказ, если в корзине есть товар из раздела „Аксессуары“» породит условие с этим разделом, и все аксессуары попадут в список «товаров со скидкой», хотя скидка даётся на заказ целиком, а не на них. Так же ведут себя условия на количество, на сумму корзины и отрицательные условия (!=), которые метод разбирает как положительные.

Практический вывод: список получается надмножеством реально уценённых товаров. Для витрины «Акции» это обычно приемлемо, для расчёта цен — категорически нет.

Регистрация класса

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

use Bitrix\Main\Loader;

Loader::registerAutoLoadClasses(null, [
    'Local\\Discount\\ProductDiscountFilter' => '/local/php_interface/classes/Discount/ProductDiscountFilter.php',
]);

Использование на странице /sale/

php
<?php
// /sale/index.php

require $_SERVER['DOCUMENT_ROOT'] . '/bitrix/header.php';

use Local\Discount\ProductDiscountFilter;

$APPLICATION->SetTitle('Товары со скидками');

// Получаем фильтр товаров со скидками
$discountFilter = new ProductDiscountFilter();
$discountFilter->setUserGroup(2); // Для всех пользователей

// getProductIds() внутри getFilter() уже отработал — сохраняем результат,
// иначе весь разбор скидок выполнится второй раз
$productIds = $discountFilter->getProductIds();
$productCount = count($productIds);

$GLOBALS['arrFilter'] = $productCount > 0 ? ['ID' => $productIds] : ['ID' => 0];

if ($productCount === 0): ?>
    <div class="sale-empty">
        <h2>Скидок пока нет</h2>
        <p>Следите за нашими акциями!</p>
    </div>
<?php else: ?>
    <p class="sale-count">Найдено товаров: <?= $productCount ?></p>
    
    <?php $APPLICATION->IncludeComponent(
        "bitrix:catalog.section",
        "sale",
        [
            "IBLOCK_TYPE" => "catalog",
            "IBLOCK_ID" => 2,
            "SECTION_ID" => "",
            "SECTION_CODE" => "",
            "SECTION_USER_FIELDS" => [],
            "ELEMENT_SORT_FIELD" => "sort",
            "ELEMENT_SORT_ORDER" => "asc",
            "FILTER_NAME" => "arrFilter",
            "INCLUDE_SUBSECTIONS" => "Y",
            "SHOW_ALL_WO_SECTION" => "Y", // Важно!
            "PAGE_ELEMENT_COUNT" => 20,
            "LINE_ELEMENT_COUNT" => 4,
            "PROPERTY_CODE" => ["BRAND", "MATERIAL"],
            "OFFERS_FIELD_CODE" => ["NAME", "PREVIEW_PICTURE"],
            "OFFERS_PROPERTY_CODE" => ["COLOR", "SIZE"],
            "OFFERS_LIMIT" => 5,
            "PRICE_CODE" => ["BASE"],
            "USE_PRICE_COUNT" => "N",
            "SHOW_PRICE_COUNT" => 1,
            "PRICE_VAT_INCLUDE" => "Y",
            "CONVERT_CURRENCY" => "Y",
            "CURRENCY_ID" => "RUB",
            "HIDE_NOT_AVAILABLE" => "Y",
            "TEMPLATE_THEME" => "blue",
            "ADD_PICT_PROP" => "MORE_PHOTO",
            "LABEL_PROP" => ["NEWPRODUCT", "RECOMMEND", "SALELEADER"],
            "SHOW_DISCOUNT_PERCENT" => "Y",
            "SHOW_OLD_PRICE" => "Y",
            "ADD_TO_BASKET_ACTION" => "ADD",
            "DISPLAY_COMPARE" => "Y",
            "PAGER_TEMPLATE" => "modern",
            "DISPLAY_TOP_PAGER" => "N",
            "DISPLAY_BOTTOM_PAGER" => "Y",
            "PAGER_TITLE" => "Товары",
            "PAGER_SHOW_ALWAYS" => "N",
            "PAGER_DESC_NUMBERING" => "N",
            "PAGER_DESC_NUMBERING_CACHE_TIME" => 36000,
            "PAGER_SHOW_ALL" => "N",
            "CACHE_TYPE" => "A",
            "CACHE_TIME" => 3600,
            "CACHE_GROUPS" => "Y",
            "SET_TITLE" => "N",
            "SET_BROWSER_TITLE" => "N",
            "SET_META_KEYWORDS" => "N",
            "SET_META_DESCRIPTION" => "N",
            "ADD_SECTIONS_CHAIN" => "N",
            "SET_STATUS_404" => "N",
        ]
    ); ?>
<?php endif;

require $_SERVER['DOCUMENT_ROOT'] . '/bitrix/footer.php';
💡 Совет

SHOW_ALL_WO_SECTION => "Y" здесь обязателен — без него catalog.section без указанного раздела ничего не выведет. Это тот параметр, из-за которого страница «Акции» чаще всего оказывается пустой при внешне правильном фильтре.

Кэширование результата

php
<?php

namespace Local\Discount;

use Bitrix\Main\Data\Cache;

class CachedProductDiscountFilter extends ProductDiscountFilter
{
    private int $cacheTime = 3600;
    private string $cacheDir = '/discount_products/';

    public function setCacheTime(int $seconds): self
    {
        $this->cacheTime = $seconds;
        return $this;
    }

    public function getProductIds(): array
    {
        $cache = Cache::createInstance();
        $cacheId = $this->getCacheId();

        if ($cache->initCache($this->cacheTime, $cacheId, $this->cacheDir)) {
            $vars = $cache->getVars();
            return $vars['productIds'] ?? [];
        }

        $cache->startDataCache();

        $productIds = parent::getProductIds();

        $cache->endDataCache(['productIds' => $productIds]);

        return $productIds;
    }

    private function getCacheId(): string
    {
        global $USER;
        
        $userGroups = $USER->GetUserGroupArray();
        sort($userGroups);
        
        return 'discount_products_' . md5(implode('_', $userGroups) . '_' . SITE_ID);
    }

    /**
     * Очистка кэша (вызывать при изменении скидок)
     */
    public static function clearCache(): void
    {
        $cache = Cache::createInstance();
        $cache->cleanDir('/discount_products/');
    }
}
⚠️ Важно

Фильтр ['ID' => [...]] превращается в WHERE ID IN (...), и это ограничивает применимость всего решения. Достаточно одной скидки на крупный раздел — и в списке оказываются десятки тысяч идентификаторов. Запрос раздувается до сотен килобайт, упирается в max_allowed_packet, а планировщик MySQL перестаёт использовать индексы осмысленно.

Практическая граница — примерно несколько тысяч ID. Если скидки на вашем проекте задаются разделами, а не отдельными товарами, этот подход не подойдёт: смотрите альтернативу в конце статьи.

Автоматическая очистка кэша

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

use Bitrix\Main\EventManager;

// Очистка кэша при изменении скидок
EventManager::getInstance()->addEventHandler(
    'sale',
    'OnSaleDiscountAdd',
    function() {
        \Local\Discount\CachedProductDiscountFilter::clearCache();
    }
);

EventManager::getInstance()->addEventHandler(
    'sale',
    'OnSaleDiscountUpdate',
    function() {
        \Local\Discount\CachedProductDiscountFilter::clearCache();
    }
);

EventManager::getInstance()->addEventHandler(
    'sale',
    'OnSaleDiscountDelete',
    function() {
        \Local\Discount\CachedProductDiscountFilter::clearCache();
    }
);
💡 Совет

Кэш по времени здесь недостаточен. Скидка с датой окончания перестаёт действовать в конкретную минуту, а кэш живёт свой час независимо от этого — товар останется в разделе «Акции» без скидки, и пользователь увидит обычную цену там, где ждал уценённую. Обработчики выше сбрасывают кэш при редактировании скидок, но не при их истечении. Если даты окончания реально используются, добавьте агент, который сбрасывает кэш при переходе через ближайшую дату окончания, либо держите TTL в пределах нескольких минут.

Ограничения подхода

Соберу в одном месте то, о чём предупреждал по ходу статьи, — этот список стоит прочитать до того, как решение уедет в продакшен.

Список получается неточным в большую сторону. Условия скидки описывают, когда она применяется, а не к каким товарам. Скидка на заказ при наличии определённой категории в корзине даст ложные срабатывания.

Зависимость от внутреннего формата. CondIBElement, CondIBSection, структура CHILDREN — это внутренности модуля sale. Обновление Битрикса может изменить их без предупреждения, и статья сломается молча: скидки просто перестанут находиться.

Не обрабатывается логика условий. Метод обходит дерево рекурсивно и собирает всё подряд, игнорируя LOGIC (AND/OR) и отрицания. Условие «раздел „Обувь“ И бренд Nike» даст на выходе всю обувь плюс все товары Nike.

Плохо масштабируется. Фильтр по списку ID упирается в размер запроса на нескольких тысячах товаров.

Более устойчивая альтернатива

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

Заведите у товара служебное свойство HAS_DISCOUNT и наполняйте его агентом, который проходит по каталогу и для каждого товара спрашивает у самого Битрикса, есть ли на него скидка (CCatalogDiscount::GetDiscountByProduct() или расчёт цены через API каталога). Дальше страница «Акции» — это обычный catalog.section с фильтром по свойству: работает пагинация, умный фильтр, сортировка и кэш, никакого списка ID в запросе.

Что вы получаете взамен:

  • Точность. Ответ даёт та же подсистема, которая считает цены на витрине, а не ваш парсер условий.
  • Устойчивость к обновлениям. Используется публичный API, а не внутренний формат таблицы.
  • Нормальную работу штатных компонентов без обходных путей.

Чем платите — актуальностью: список обновляется с периодом работы агента, а не мгновенно. На практике для витрины акций задержка в 15–30 минут никого не беспокоит, а вот пустая или неверная страница «Акции» беспокоит всех.

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

🚀

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

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

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

Комментарии

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