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

URL: https://dmeshcheryakov.ru/blog/bitrix-discount-section-page/
Раздел: 1С-Битрикс
Теги: 1С-Битрикс, Скидки, Каталог, PHP
Опубликовано: 2018-10-24
Обновлено: 2026-09-07
Автор: Дмитрий Мещеряков (https://dmeshcheryakov.ru)

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

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

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

## Почему в Битриксе нет готового списка товаров со скидкой

Создать страницу `/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 минут никого не беспокоит, а вот пустая или неверная страница «Акции» беспокоит всех.

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