РАЗРАБОТКА НА БИТРИКС

Оптимизация работы при большом количестве товаров и торговых предложений

Подписывайтесь на канал для bitrix-разработчиков в Telegram!

Введение

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

Описание задачи

Согласно проведённому на проекте профилированию, выяснилось, что основную нагрузку на сайт производит пересчёт цен со скидками.
На сайте имеется обширный каталог с порядка 10 000 товаров и около 100–150 торговых предложений у каждого товара, а также порядка 450 правил для работы с корзиной.
Судя по отладочной информации, при загрузке страниц каталога без кэша происходит загрузка около 78 секунд, с кэшем около 19 секунд. Это происходит из-за того, что на странице выводятся товары со своими торговыми предложениями и для каждого из них производится пересчёт цены по всем правилам работы с корзиной. При этом даже с кэшем производится пересчёт скидок для основных торговых предложений. В случае же если отключить пересчёт скидок, даже без кэша страница загружается около 6 секунд.
Решением этой проблемы стало кэширование пересчёта цен: вместо пересчёта цен на каждом хите реализован пересчёт цен по всем товарам на событии добавлении/изменении/удалении скидок, с их сохранением в отдельный HL-блок, и с получением цен из этой таблицы, а не пересчитывания для каждого товара цен отдельно.

Исходные данные

  1. Торговый каталог с 10 000 товаров и около 100–150 торговых предложений у каждого товара.
  2. 450 правил для работы с корзиной.

Решение

  • Создание HL-блока для кэширования цен
Создаём HL-блок для кэширования цен. В нем должны быть следующие поля:
  • UF_ELEMENT_ID - ID товара
  • UF_OFFER_ID - ID торгового предложения
  • UF_PRICE_OLD - цена без скидки
  • UF_PRICE - цена со скидкой
  • UF_DISCOUNT - процент скидки
  • Создание кэша для цен
Поскольку в новых компонентах «Битрикс» реализовано наследование класса компонента от абстрактного класса в ядре. Мы должны сделать вариант данного класса с переопределением нужных нам методов.
Создаём абстрактный класс ElementListCustom, наследуя его от класса \\Bitrix\\Iblock\\Component\\ElementList из ядра:

<?php

namespace O2k\BitrixCustom;

use Bitrix\Main;
use Bitrix\Main\Loader;
use Bitrix\Currency;
use Bitrix\Catalog;


Loader::includeModule('iblock');

abstract class ElementListCustom extends \Bitrix\Iblock\Component\ElementList
{
    protected $facetList = [];

    /**
     * Блок расчёта цен (простая цена, диапазон количества и тд.).
     *
     * @param array $product Данные о товаре
     * @param array $priceBlock Цены.
     * @param int|float $ratio Значение коэффициента измерения.
     * @param bool $defaultBlock Сохранение значений в старые ключи (PRICES, PRICE_MATRIX, MIN_PRICE).
     * @return array|null
     */
    protected function calculatePriceBlock(array $product, array $priceBlock, $ratio, $defaultBlock = false)
    {
        if (empty($product) || empty($priceBlock)) {
            return null;
        }

        $enableCompatible = $defaultBlock && $this->isEnableCompatible();

        if ($enableCompatible && !$this->arParams['USE_PRICE_COUNT']) {
            $this->fillCompatibleRawPriceFields($product['ID'], $priceBlock);
        }

        $userGroups = $this->getUserGroups();

        $baseCurrency = Currency\CurrencyManager::getBaseCurrency();
        /** @var null|array $minimalPrice */
        $minimalPrice = null;
        /** @var null|array $minimalBuyerPrice */
        $minimalBuyerPrice = null;
        $fullPrices = [];

        $currencyConvert = $this->arParams['CONVERT_CURRENCY'] === 'Y';
        $resultCurrency = ($currencyConvert ? $this->storage['CONVERT_CURRENCY']['CURRENCY_ID'] : null);

        $vatRate = (float)$product['PRODUCT']['VAT_RATE'];
        $percentVat = $vatRate * 0.01;
        $percentPriceWithVat = 1 + $percentVat;
        $vatInclude = $product['PRODUCT']['VAT_INCLUDED'] === 'Y';

        $oldPrices = [];
        $oldMinPrice = false;
        $oldMatrix = false;
        if ($enableCompatible && $this->arParams['USE_PRICE_COUNT']) {
            $oldMatrix = $this->getCompatibleFieldValue($product['ID'], 'PRICE_MATRIX');
            if (empty($oldMatrix)) {
                $oldMatrix = $this->getEmptyPriceMatrix();
                $oldMatrix['AVAILABLE'] = $product['PRODUCT']['AVAILABLE'];
            }
        }

        foreach ($priceBlock as $rawPrice) {
            $priceType = (int)$rawPrice['CATALOG_GROUP_ID'];
            $price = (float)$rawPrice['PRICE'];
            if (!$vatInclude) {
                $price *= $percentPriceWithVat;
            }
            $currency = $rawPrice['CURRENCY'];

            $changeCurrency = $currencyConvert && $currency !== $resultCurrency;
            if ($changeCurrency) {
                $price = \CCurrencyRates::ConvertCurrency($price, $currency, $resultCurrency);
                $currency = $resultCurrency;
            }

            $discounts = [];
            if ($this->facetList[$product['ID']]) {
                // Если инициирована фасета
                // то скидки и цены со скидкой получаем из неё
                $discounts = $this->facetList[$product['ID']];
                $discountPrice = $this->facetList[$product['ID']]['DISCOUNT_PRICE'];
            } else {
                // Иначе получаем цену стандартным способом (если кэш не прогрет)
                if (\CIBlockPriceTools::isEnabledCalculationDiscounts()) {
                    \CCatalogDiscountSave::Disable();

                    $discounts = \CCatalogDiscount::GetDiscount(
                        $product['ID'],
                        $product['IBLOCK_ID'],
                        array($priceType),
                        $userGroups,
                        'N',
                        $this->getSiteId(),
                        []
                    );

                    \CCatalogDiscountSave::Enable();
                }
                $discountPrice = \CCatalogProduct::CountPriceWithDiscount(
                    $price,
                    $currency,
                    $discounts
                );
            }
            if ($discountPrice !== false) {
                $priceWithVat = array(
                    'UNROUND_BASE_PRICE' => $price,
                    'UNROUND_PRICE' => $discountPrice,
                    'BASE_PRICE' => Catalog\Product\Price::roundPrice(
                        $priceType,
                        $price,
                        $currency
                    ),
                    'PRICE' => Catalog\Product\Price::roundPrice(
                        $priceType,
                        $discountPrice,
                        $currency
                    )
                );

                $price /= $percentPriceWithVat;
                $discountPrice /= $percentPriceWithVat;

                $priceWithoutVat = array(
                    'UNROUND_BASE_PRICE' => $price,
                    'UNROUND_PRICE' => $discountPrice,
                    'BASE_PRICE' => Catalog\Product\Price::roundPrice(
                        $priceType,
                        $price,
                        $currency
                    ),
                    'PRICE' => Catalog\Product\Price::roundPrice(
                        $priceType,
                        $discountPrice,
                        $currency
                    )
                );

                if ($this->arParams['PRICE_VAT_INCLUDE']) {
                    $priceRow = $priceWithVat;
                } else {
                    $priceRow = $priceWithoutVat;
                }
                $priceRow['ID'] = $rawPrice['ID'];
                $priceRow['PRICE_TYPE_ID'] = $rawPrice['CATALOG_GROUP_ID'];
                $priceRow['CURRENCY'] = $currency;

                if (
                    empty($discounts)
                    || ($priceRow['BASE_PRICE'] <= $priceRow['PRICE'])
                ) {
                    $priceRow['BASE_PRICE'] = $priceRow['PRICE'];
                    $priceRow['DISCOUNT'] = 0;
                    $priceRow['PERCENT'] = 0;
                } else {
                    $priceRow['DISCOUNT'] = $priceRow['BASE_PRICE'] - $priceRow['PRICE'];
                    $priceRow['PERCENT'] = roundEx(100 * $priceRow['DISCOUNT'] / $priceRow['BASE_PRICE'], 0);
                }
                if ($this->arParams['PRICE_VAT_SHOW_VALUE']) {
                    $priceRow['VAT'] = ($vatRate > 0 ? $priceWithVat['PRICE'] - $priceWithoutVat['PRICE'] : 0);
                }

                if ($this->arParams['FILL_ITEM_ALL_PRICES']) {
                    $fullPrices[$priceType] = $priceRow;
                }

                $priceRow['QUANTITY_FROM'] = $rawPrice['QUANTITY_FROM'];
                $priceRow['QUANTITY_TO'] = $rawPrice['QUANTITY_TO'];
                $priceRow['QUANTITY_HASH'] = $this->getQuantityRangeHash($rawPrice);
                $priceRow['MEASURE_RATIO_ID'] = $rawPrice['MEASURE_RATIO_ID'];
                $priceRow['PRICE_SCALE'] = \CCurrencyRates::ConvertCurrency(
                    $priceRow['PRICE'],
                    $priceRow['CURRENCY'],
                    $baseCurrency
                );

                if ($minimalPrice === null || $minimalPrice['PRICE_SCALE'] > $priceRow['PRICE_SCALE']) {
                    $minimalPrice = $priceRow;
                }
                if (isset($this->storage['PRICES_CAN_BUY'][$priceRow['PRICE_TYPE_ID']])) {
                    if ($minimalBuyerPrice === null || $minimalBuyerPrice['PRICE_SCALE'] > $priceRow['PRICE_SCALE']) {
                        $minimalBuyerPrice = $priceRow;
                    }
                }

                if ($enableCompatible) {
                    if ($this->arParams['USE_PRICE_COUNT']) {
                        $rowIndex = $this->getQuantityRangeHash($rawPrice);
                        $oldMatrix['ROWS'][$rowIndex] = array(
                            'QUANTITY_FROM' => (float)$rawPrice['QUANTITY_FROM'],
                            'QUANTITY_TO' => (float)$rawPrice['QUANTITY_TO']
                        );
                        if (!isset($oldMatrix['MATRIX'][$priceType])) {
                            $oldMatrix['MATRIX'][$priceType] = [];
                            $oldMatrix['COLS'][$priceType] = $this->storage['PRICE_TYPES'][$priceType];
                        }
                        $oldMatrix['MATRIX'][$priceType][$rowIndex] = array(
                            'ID' => $priceRow['ID'],
                            'PRICE' => $priceRow['BASE_PRICE'],
                            'DISCOUNT_PRICE' => $priceRow['PRICE'],
                            'UNROUND_DISCOUNT_PRICE' => $priceRow['UNROUND_PRICE'],
                            'CURRENCY' => $priceRow['CURRENCY'],
                            'VAT_RATE' => $percentVat
                        );
                        if ($changeCurrency) {
                            $oldMatrix['MATRIX'][$priceType][$rowIndex]['ORIG_CURRENCY'] = $rawPrice['CURRENCY'];
                            $oldMatrix['MATRIX'][$priceType][$rowIndex]['ORIG_PRICE'] = \CCurrencyRates::ConvertCurrency(
                                $priceRow['BASE_PRICE'],
                                $priceRow['CURRENCY'],
                                $rawPrice['CURRENCY']
                            );
                            $oldMatrix['MATRIX'][$priceType][$rowIndex]['ORIG_DISCOUNT_PRICE'] = \CCurrencyRates::ConvertCurrency(
                                $priceRow['PRICE'],
                                $priceRow['CURRENCY'],
                                $rawPrice['CURRENCY']
                            );
                            $oldMatrix['MATRIX'][$priceType][$rowIndex]['ORIG_VAT_RATE'] = $percentVat; // crazy key, but above all the compatibility
                        }
                    } else {
                        $priceCode = $this->storage['PRICES_MAP'][$priceType];
                        $oldPriceRow = array(
                            'PRICE_ID' => $priceRow['PRICE_TYPE_ID'],
                            'ID' => $priceRow['ID'],
                            'CAN_ACCESS' => ($this->storage['PRICES'][$priceCode]['CAN_VIEW'] ? 'Y' : 'N'),
                            'CAN_BUY' => ($this->storage['PRICES'][$priceCode]['CAN_BUY'] ? 'Y' : 'N'),
                            'MIN_PRICE' => 'N',
                            'CURRENCY' => $priceRow['CURRENCY'],
                            'VALUE_VAT' => $priceWithVat['UNROUND_BASE_PRICE'],
                            'VALUE_NOVAT' => $priceWithoutVat['UNROUND_BASE_PRICE'],
                            'DISCOUNT_VALUE_VAT' => $priceWithVat['UNROUND_PRICE'],
                            'DISCOUNT_VALUE_NOVAT' => $priceWithoutVat['UNROUND_PRICE'],
                            'ROUND_VALUE_VAT' => $priceWithVat['PRICE'],
                            'ROUND_VALUE_NOVAT' => $priceWithoutVat['PRICE'],
                            'VALUE' => $priceRow['BASE_PRICE'],
                            'UNROUND_DISCOUNT_VALUE' => $priceRow['UNROUND_PRICE'],
                            'DISCOUNT_VALUE' => $priceRow['PRICE'],
                            'DISCOUNT_DIFF' => $priceRow['DISCOUNT'],
                            'DISCOUNT_DIFF_PERCENT' => $priceRow['PERCENT']
                        );
                        $oldPriceRow['VATRATE_VALUE'] = $oldPriceRow['VALUE_VAT'] - $oldPriceRow['VALUE_NOVAT'];
                        $oldPriceRow['DISCOUNT_VATRATE_VALUE'] = $oldPriceRow['DISCOUNT_VALUE_VAT'] - $oldPriceRow['DISCOUNT_VALUE_NOVAT'];
                        $oldPriceRow['ROUND_VATRATE_VALUE'] = $oldPriceRow['ROUND_VALUE_VAT'] - $oldPriceRow['ROUND_VALUE_NOVAT'];
                        if ($changeCurrency) {
                            $oldPriceRow['ORIG_CURRENCY'] = $rawPrice['CURRENCY'];
                        }
                        $oldPrices[$priceCode] = $oldPriceRow;
                        unset($oldPriceRow);
                    }
                }
            }
            unset($discounts);
            unset($priceType);
        }
        unset($price);

        $minimalPriceId = null;
        if (is_array($minimalBuyerPrice)) {
            $minimalPrice = $minimalBuyerPrice;
        }
        if (is_array($minimalPrice)) {
            unset($minimalPrice['PRICE_SCALE']);
            $minimalPriceId = $minimalPrice['PRICE_TYPE_ID'];
            $prepareFields = array(
                'BASE_PRICE',
                'PRICE',
                'DISCOUNT'
            );
            if ($this->arParams['PRICE_VAT_SHOW_VALUE']) {
                $prepareFields[] = 'VAT';
            }

            foreach ($prepareFields as $fieldName) {
                $minimalPrice['PRINT_' . $fieldName] = \CCurrencyLang::CurrencyFormat(
                    $minimalPrice[$fieldName],
                    $minimalPrice['CURRENCY'],
                    true
                );
                $minimalPrice['RATIO_' . $fieldName] = $minimalPrice[$fieldName] * $ratio;
                $minimalPrice['PRINT_RATIO_' . $fieldName] = \CCurrencyLang::CurrencyFormat(
                    $minimalPrice['RATIO_' . $fieldName],
                    $minimalPrice['CURRENCY'],
                    true
                );
            }
            unset($fieldName);

            if ($this->arParams['FILL_ITEM_ALL_PRICES']) {
                foreach (array_keys($fullPrices) as $priceType) {
                    foreach ($prepareFields as $fieldName) {
                        $fullPrices[$priceType]['PRINT_' . $fieldName] = \CCurrencyLang::CurrencyFormat(
                            $fullPrices[$priceType][$fieldName],
                            $fullPrices[$priceType]['CURRENCY'],
                            true
                        );
                        $fullPrices[$priceType]['RATIO_' . $fieldName] = $fullPrices[$priceType][$fieldName] * $ratio;
                        $fullPrices[$priceType]['PRINT_RATIO_' . $fieldName] = \CCurrencyLang::CurrencyFormat(
                            $minimalPrice['RATIO_' . $fieldName],
                            $minimalPrice['CURRENCY'],
                            true
                        );
                    }
                    unset($fieldName);
                }
                unset($priceType);
            }

            unset($prepareFields);
        }

        if ($enableCompatible) {
            if ($this->arParams['USE_PRICE_COUNT']) {
                $oldMatrix['CAN_BUY'] = array_values($this->storage['PRICES_CAN_BUY']);
                $this->oldData[$product['ID']]['PRICE_MATRIX'] = $oldMatrix;
            } else {
                $convertFields = array(
                    'VALUE_NOVAT',
                    'VALUE_VAT',
                    'VATRATE_VALUE',
                    'DISCOUNT_VALUE_NOVAT',
                    'DISCOUNT_VALUE_VAT',
                    'DISCOUNT_VATRATE_VALUE'
                );

                $prepareFields = array(
                    'VALUE_NOVAT',
                    'VALUE_VAT',
                    'VATRATE_VALUE',
                    'DISCOUNT_VALUE_NOVAT',
                    'DISCOUNT_VALUE_VAT',
                    'DISCOUNT_VATRATE_VALUE',
                    'VALUE',
                    'DISCOUNT_VALUE',
                    'DISCOUNT_DIFF'
                );

                if (!empty($oldPrices)) {
                    foreach (array_keys($oldPrices) as $index) {
                        foreach ($prepareFields as $fieldName) {
                            $oldPrices[$index]['PRINT_' . $fieldName] = \CCurrencyLang::CurrencyFormat(
                                $oldPrices[$index][$fieldName],
                                $oldPrices[$index]['CURRENCY'],
                                true
                            );
                        }
                        unset($fieldName);
                        if (isset($oldPrices[$index]['ORIG_CURRENCY'])) {
                            foreach ($convertFields as $fieldName) {
                                $oldPrices[$index]['ORIG_' . $fieldName] = \CCurrencyRates::ConvertCurrency(
                                    $oldPrices[$index][$fieldName],
                                    $oldPrices[$index]['CURRENCY'],
                                    $oldPrices[$index]['ORIG_CURRENCY']
                                );
                            }
                            unset($fieldName);
                        }
                        if ($oldPrices[$index]['PRICE_ID'] === $minimalPriceId) {
                            $oldPrices[$index]['MIN_PRICE'] = 'Y';
                            $oldMinPrice = $oldPrices[$index];
                        }
                    }
                    unset($index);
                }
                unset($prepareFields);

                $this->oldData[$product['ID']]['PRICES'] = $oldPrices;
                $this->oldData[$product['ID']]['MIN_PRICE'] = $oldMinPrice;
            }
        }
        unset($oldMatrix, $oldMinPrice, $oldPrices);

        if (!$this->arParams['FILL_ITEM_ALL_PRICES']) {
            return $minimalPrice;
        }

        return [
            'MINIMAL_PRICE' => $minimalPrice,
            'ALL_PRICES' => [
                'QUANTITY_FROM' => $minimalPrice['QUANTITY_FROM'],
                'QUANTITY_TO' => $minimalPrice['QUANTITY_TO'],
                'QUANTITY_HASH' => $minimalPrice['QUANTITY_HASH'],
                'MEASURE_RATIO_ID' => $minimalPrice['MEASURE_RATIO_ID'],
                'PRICES' => $fullPrices
            ]
        ];
    }

    /**
     * Загрузка, расчет и заполнение данных (цены, меры, скидки, устаревшие поля) для предложений
     * @return void
     */
    protected function processOffers()
    {
        if ($this->useCatalog && !empty($this->iblockProducts)) {
            if ($this->iblockProducts[$this->arParams['IBLOCK_ID']]) {
                // инициируем фасету
                $this->facetList = $this->getDiscountPriceFacet($this->iblockProducts[$this->arParams['IBLOCK_ID']]);
            }

            $offers = [];

            $paramStack = [];
            $enableCompatible = $this->isEnableCompatible();
            if ($enableCompatible) {
                $paramStack['USE_PRICE_COUNT'] = $this->arParams['USE_PRICE_COUNT'];
                $paramStack['SHOW_PRICE_COUNT'] = $this->arParams['SHOW_PRICE_COUNT'];
                $this->arParams['USE_PRICE_COUNT'] = false;
                $this->arParams['SHOW_PRICE_COUNT'] = 1;
            }

            foreach (array_keys($this->iblockProducts) as $iblock) {
                if (!empty($this->productWithOffers[$iblock])) {
                    $iblockOffers = $this->getIblockOffers($iblock);
                    if (!empty($iblockOffers)) {
                        $offersId = array_keys($iblockOffers);
                        $this->initItemsMeasure($iblockOffers);
                        $this->loadMeasures($this->getMeasureIds($iblockOffers));

                        $this->loadMeasureRatios($offersId);

                        $this->loadPrices($offersId);
                        $this->calculateItemPrices($iblockOffers);

                        $this->transferItems($iblockOffers);

                        $this->modifyOffers($iblockOffers);
                        $this->chooseOffer($iblockOffers, $iblock);

                        $offers = array_merge($offers, $iblockOffers);
                    }
                    unset($iblockOffers);
                }
            }
            if ($enableCompatible) {
                $this->arParams['USE_PRICE_COUNT'] = $paramStack['USE_PRICE_COUNT'];
                $this->arParams['SHOW_PRICE_COUNT'] = $paramStack['SHOW_PRICE_COUNT'];
            }
            unset($enableCompatible, $paramStack);
        }
    }

    /**
     * Возвращает данные из фасеты для массива товаров
     * @param array $productsList
     * @return array|false
     */
    protected function getDiscountPriceFacet(array $productsList)
    {
        if ($this->arParams['FACET_HL']) {
            Loader::includeModule('highloadblock');
            $facetList = [];

            $hlblockId = $this->arParams['FACET_HL'];
            $hlblock = \Bitrix\Highloadblock\HighloadBlockTable::getById($hlblockId)->fetch();
            $entity = \Bitrix\Highloadblock\HighloadBlockTable::compileEntity($hlblock);
            $entity_data_class = $entity->getDataClass();

            $rsFacet = $entity_data_class::getList([
                "select" => ['*'],
                "order" => ["UF_ELEMENT_ID" => "ASC"],
                "filter" => [
                    "=UF_ELEMENT_ID" => $productsList
                ]
            ]);

            $rsFacet = new \CDBResult($rsFacet, "FacetO2k");
            while ($facet = $rsFacet->Fetch()) {
                $facetList[$facet['UF_OFFER_ID']] = [
                    'PRICE' => $facet['UF_PRICE_OLD'],
                    'DISCOUNT_PRICE' => $facet['UF_PRICE'],
                    'VALUE' => $facet['UF_DISCOUNT'],
                ];
            }

            return $facetList;
        }

        return false;
    }
}
В методе getDiscountPriceFacet() производим выборку данных по скидкам и ценам из HL-блока. Далее инициируем данный метод, и используем его для получения цен со скидкой. Если же кэш по нужным нам товарам не получен, например, кэш не создан или в процессе создания, то получаем цену стандартным способом.
Для получения в данном классе ID HL-блока в компоненте catalog создаём параметр FACET_HL и задаём его в параметрах вызова компонента и вызове catalog.section:

// catalog/index.php
'FACET_HL' => 32,

// local/templates/main/components/bitrix/catalog/main_custom/page_blocks/list_elements_1.php
"FACET_HL" => $arParams["FACET_HL"],
  • Переработка компонента catalog.section
Далее переносим компонент catalog.section в наше пространство имён и все шаблоны к нему в соответствующие директории. В кастомизированном компоненте ничего не меняем, обратная совместимость со всеми шаблонами сохраняется, только наследуем класс компонента от созданного ранее класса ElementListCustom:

class O2kCatalogSectionComponent extends O2k\BitrixCustom\ElementListCustom
  • Создание скрипта и обработчиков для актуализации цен
Ввиду того что пересчёт цен является весьма затратной по ресурсам процедурой, вешать пересчёт цен на обработчик нецелесообразно. Для пересчёта цен создаётся специальное свойство с кодом FACET для товара, со значением по умолчанию 1. На обработчике при изменении цены данное свойство переводится в значение 0:

$eventManager = \Bitrix\Main\EventManager::getInstance();
$eventManager->addEventHandler(
    'sale',
    '\Bitrix\Sale\Internals\Discount::OnAfterAdd',
    ['Discount', 'refreshDiscountSales']
);
$eventManager->addEventHandler(
    'sale',
    '\Bitrix\Sale\Internals\Discount::OnAfterUpdate',
    ['Discount', 'refreshDiscountSales']
);
$eventManager->addEventHandler(
    'sale',
    '\Bitrix\Sale\Internals\Discount::OnAfterDelete',
    ['Discount', 'refreshDiscountSales']
);

class Discount
{
    const PRODUCTS_IBLOCK_ID = 24;

    function refreshDiscountSales(Bitrix\Main\Entity\Event $event)
    {
        $db_res = CSaleDiscount::GetList(
            ['SORT' => 'ASC'],
            [
                //'LID' => SITE_ID
            ],
            false,
            false,
            ['ACTIONS']
        );

        $allIdsDiscount = [];
        while ($ar_res = $db_res->Fetch()) {
            $ids = unserialize($ar_res['ACTIONS'])['CHILDREN'][0]['CHILDREN'][0]['DATA']['value'];
            $type = unserialize($ar_res['ACTIONS'])['CHILDREN'][0]['DATA']['Type'];

            if ($type == 'Discount' && $ids) {
                foreach ($ids as $id) {
                    $allIdsDiscount[] = $id;
                }
            }
        }

        foreach ($allIdsDiscount as $id) {
            CIBlockElement::SetPropertyValuesEx($id, false, ['FACET' => 0]);
        }
    }
}
На Cron заводится скрипт, который работает с пошаговостью по 150 товаров за проход раз в 10 минут (параметры можно регулировать в зависимости от требований и мощности сервера). Данный скрипт пересчитывает все цены торговых предложений тех товаров, у которых свойство FACET стоит в значении 0:

<?

$_SERVER['DOCUMENT_ROOT'] = realpath(dirname(__FILE__) . '/..');

define('NO_KEEP_STATISTIC', true);
define('NOT_CHECK_PERMISSIONS', true);
define('BX_CRONTAB', true);
define('BX_NO_ACCELERATOR_RESET', true);
define('CHK_EVENT', true);

require($_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php');

@set_time_limit(0);
@ignore_user_abort(true);

CModule::IncludeModule('iblock');
CModule::IncludeModule('catalog');
CModule::IncludeModule('sale');
CModule::IncludeModule('highloadblock');
global $USER;

$catalogIblock = 24; // Указываете ID инфоблока вашего каталога
$hlblockId = 32; // указываете ид вашего Highload-блока
$hlblock = \Bitrix\Highloadblock\HighloadBlockTable::getById($hlblockId)->fetch();
$entity = \Bitrix\Highloadblock\HighloadBlockTable::compileEntity($hlblock);
$entityDataClass = $entity->getDataClass();

$arSelect = ['*'];

$prices = CIBlockPriceTools::GetCatalogPrices($catalogIblock, ['BASE']);

$k = 0;
$k1 = 0;
$arFilter = [
    'IBLOCK_ID' => $catalogIblock,
    'ACTIVE' => 'Y',
    '!PROPERTY_FACET' => 1,
];
$res = CIBlockElement::GetList(
    ['SORT' => 'ASC'],
    $arFilter,
    false,
    ['nTopCount' => 150],
    ['ID', 'ACTIVE', 'PROPERTY_ARTICLE', 'PROPERTY_FACET']
);

while ($ar_fields = $res->GetNext()) {
    $arFilterhl = [
        'UF_ELEMENT_ID' => $ar_fields['ID'],
    ];
    $rsData = $entityDataClass::getList([
        'select' => $arSelect,
        'filter' => $arFilterhl
    ]);
    while ($arData = $rsData->Fetch()) {
        $entityDataClass::Delete($arData['ID']);
    }
    if ($ar_fields['ACTIVE'] == 'Y') {
        $art = $ar_fields['PROPERTY_ARTICLE_VALUE'];
        if (CCatalogSku::IsExistOffers($ar_fields['ID'])) {
            // Ищем все тогровые предложения
            $offers = CIBlockPriceTools::GetOffersArray(
                [
                    'IBLOCK_ID' => $catalogIblock,
                    'HIDE_NOT_AVAILABLE' => 'Y',
                    'CHECK_PERMISSIONS' => 'Y',
                    'SHOW_PRICE_COUNT' => 1,
                ],
                [$ar_fields['ID']],
                ['ID' => 'DESC'],
                ['NAME', 'DETAIL_TEXT'],
                ['Sizes', 'ARTICLE'],
                0,
                $prices,
                null,
                []
            );

            $min_price = 0;
            $min_price_old = 0;
            $disc = 0;
            foreach ($offers as $offer) {
                if ($offer['DISPLAY_PROPERTIES']['ARTICLE']['VALUE']) {
                    $art = $offer['DISPLAY_PROPERTIES']['ARTICLE']['VALUE'];
                }

                if (!empty($offer['MIN_PRICE'])) {
                    $data = [
                        'UF_ELEMENT_ID' => $ar_fields['ID'],
                        'UF_OFFER_ID' => $offer['ID'],
                        'UF_PRICE' => $offer['MIN_PRICE']['DISCOUNT_VALUE'],
                        'UF_PRICE_OLD' => $offer['MIN_PRICE']['VALUE'],
                        'UF_DISCOUNT' => $offer['MIN_PRICE']['DISCOUNT_DIFF_PERCENT'],
                        'UF_ARTICUL' => $art,
                        'UF_RAZMER' => $offer['DISPLAY_PROPERTIES']['Sizes']['VALUE'],
                        'UF_RAZMER_ID' => $offer['DISPLAY_PROPERTIES']['Sizes']['VALUE_ENUM_ID'],
                    ];
                    $entityDataClass::add($data);
                    if ($offer['MIN_PRICE']['DISCOUNT_VALUE'] < $min_price || $min_price == 0) {
                        $min_price = $offer['MIN_PRICE']['DISCOUNT_VALUE'];
                        $min_price_old = $offer['MIN_PRICE']['VALUE'];
                        $disc = $offer['MIN_PRICE']['DISCOUNT_DIFF_PERCENT'];
                    }
                } else {
                    $price = CCatalogProduct::GetOptimalPrice($offer['ID'], 1, ['2'], 'N');
                    if (isset($price['PRICE'])) {
                        $data = [
                            'UF_ELEMENT_ID' => $ar_fields['ID'],
                            'UF_OFFER_ID' => $offer['ID'],
                            'UF_PRICE' => $price['RESULT_PRICE']['DISCOUNT_PRICE'],
                            'UF_PRICE_OLD' => $price['RESULT_PRICE']['BASE_PRICE'],
                            'UF_DISCOUNT' => $price['RESULT_PRICE']['PERCENT'],
                            'UF_ARTICUL' => $art,
                            'UF_RAZMER' => $offer['DISPLAY_PROPERTIES']['Sizes']['VALUE'],
                            'UF_RAZMER_ID' => $offer['DISPLAY_PROPERTIES']['Sizes']['VALUE_ENUM_ID'],
                        ];
                        $entityDataClass::add($data);
                        if ($price['RESULT_PRICE']['DISCOUNT_PRICE'] < $min_price || $min_price == 0) {
                            $min_price = $price['RESULT_PRICE']['DISCOUNT_PRICE'];
                            $min_price_old = $price['RESULT_PRICE']['BASE_PRICE'];
                            $disc = $price['RESULT_PRICE']['PERCENT'];
                        }
                    }
                }
            }
            $data = [
                'UF_ELEMENT_ID' => $ar_fields['ID'],
                'UF_OFFER_ID' => 0,
                'UF_PRICE' => $min_price,
                'UF_PRICE_OLD' => $min_price_old,
                'UF_DISCOUNT' => $disc,
                'UF_ARTICUL' => $art,
                'UF_RAZMER' => 0,
                'UF_RAZMER_ID' => 0,
            ];
            $entityDataClass::add($data);
        } else {
            // Простой товар, без торговых предложений (для количества равному 1)
            $price = CCatalogProduct::GetOptimalPrice($ar_fields['ID'], 1, ['2'], 'N');
            if (isset($price['PRICE'])) {
                $data = [
                    'UF_ELEMENT_ID' => $ar_fields['ID'],
                    'UF_OFFER_ID' => 0,
                    'UF_PRICE' => $price['RESULT_PRICE']['DISCOUNT_PRICE'],
                    'UF_PRICE_OLD' => $price['RESULT_PRICE']['BASE_PRICE'],
                    'UF_DISCOUNT' => $price['RESULT_PRICE']['PERCENT'],
                    'UF_ARTICUL' => $art,
                    'UF_RAZMER' => 0,
                    'UF_RAZMER_ID' => 0,
                ];
                $entityDataClass::add($data);
            }
        }
        CIBlockElement::SetPropertyValuesEx(
            $ar_fields['ID'],
            $catalogIblock,
            ['FACET' => 1]
        );
    }
}

require($_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/epilog_after.php'); ?>

Результат

В результате проведённых работ значительно увеличилась производительность страниц каталога
До проведения работ с кэшем:
До проведения работ с кэшем
После проведения работ без кэша:
После проведения работ с кэшем:
После проведения работ с кэшем

Выводы

Ввиду особенностей стандартного компонента «Битрикс» catalog.section при отображении каталога происходит пересчёт цен даже с кэшем. Это создаёт проблемы для сайтов с большим количеством товаров и предложений.
Реализация, которая описана в этом кейсе, решает данную проблему. Способ достаточно универсален, и ввиду этого его можно с небольшими доработками переносить и на другие проекты.
Подписывайтесь на канал для bitrix-разработчиков в Telegram!

Рекомендованные статьи