16.06.2026 13 мин чтения

Кастомный блок «Вы смотрели»: мини-туториал на CatalogViewedProductTable

Кирилл Новожилов

Кирилл Новожилов

Автор

Кастомный блок «Вы смотрели»: мини-туториал на CatalogViewedProductTable
Введение

Стандартный компонент catalog.products.viewed покрывает большинство кейсов, но кастомный дизайн, SPA-витрина или фильтрация по разделу требуют прямой работы с Bitrix\Catalog\CatalogViewedProductTable. Ядро уже решает SKU, лимиты и отсечение ботов — задача разработчика расширить функционал, не потеряв эту логику.

Ниже — разбор API и самодостаточные примеры: их можно скопировать в свой файл (local/php_interface/, result_modifier.php, тестовый скрипт) и поэкспериментировать. Готовый модуль vendor.viewed с компонентом, контроллером и DI — в материалах для подписчиков BXMax.

Цель

  • Понять, как ядро хранит просмотры и чем PRODUCT_ID отличается от ELEMENT_ID.
  • Записать просмотр через refresh() и получить список для своего шаблона.
  • Не писать в b_catalog_viewed_product напрямую и не дублировать логику SKU.

Предварительные условия

Настройки catalog

        use Bitrix\Main\Config\Option;

Option::get('catalog', 'enable_viewed_products'); // должно быть 'Y'
Option::get('catalog', 'viewed_count');           // лимит записей на fuser+site, по умолчанию 10
Option::get('catalog', 'viewed_time');            // срок хранения в днях, чистит clearAgent()

    

FUSER_ID, не USER_ID

Просмотры привязаны к покупателю корзины (Bitrix\Sale\Fuser::getId()), а не к USER_ID. Анонимный посетитель тоже накапливает историю.

Таблица b_catalog_viewed_product

Поле Смысл
ID Первичный ключ
FUSER_ID Покупатель корзины (b_sale_fuser)
SITE_ID Сайт (2 символа, например s1)
PRODUCT_ID Конкретный элемент: SKU или простой товар
ELEMENT_ID Родительский товар (для SKU) или тот же ID
DATE_VISIT Время последнего просмотра
VIEW_COUNT Счётчик просмотров одной и той же позиции
RECOMMENDATION ID рекомендации Big Data (до 40 символов, из cookie аналитики)

При просмотре торгового предложения ядро удаляет запись с PRODUCT_ID = parentId и обновляет строку среди «соседних» SKU той же карточки. Это предотвращает дубли «товар + оффер» в блоке.

После каждого refresh() срабатывает truncateUserViewedProducts() — лишние строки сверх viewed_count удаляются. Старые записи чистит агент CatalogViewedProductTable::clearAgent() по viewed_time.

Пример 1. Запись просмотра на деталке

Скопируйте в result_modifier.php деталки или вызовите из своего кода после загрузки товара.

        <?php
declare(strict_types=1);

use Bitrix\Catalog\CatalogViewedProductTable;
use Bitrix\Main\Config\Option;
use Bitrix\Main\Loader;
use Bitrix\Sale\Fuser;

function recordViewedProduct(int $productId, int $parentId = 0, ?string $siteId = null): int
{
    if (
        !Loader::includeModule('catalog')
        || !Loader::includeModule('sale')
        || Option::get('catalog', 'enable_viewed_products') !== 'Y'
    ) {
        return -1;
    }

    $fuserId = (int)Fuser::getId();
    $productId = (int)$productId;
    $parentId = (int)$parentId;
    $siteId = $siteId ?? (defined('SITE_ID') ? SITE_ID : '');

    if ($productId <= 0 || $fuserId <= 0 || $siteId === '') {
        return -1;
    }

    // $productId — ID оффера или простого товара
    // $parentId — ID родительского товара (0, если SKU нет)
    return (int)CatalogViewedProductTable::refresh(
        $productId,
        $fuserId,
        $siteId,
        $parentId,
    );
}

// --- вызов на деталке ---

// Простой товар без SKU:
// recordViewedProduct((int)$arResult['ID']);

// Торговое предложение (как в catalog.element):
// recordViewedProduct((int)$arResult['OFFER_ID'], (int)$arResult['ID']);

    

refresh() вернёт -1, если опция выключена, fuserId = 0 или запрос от краулера (Catalog\Product\Basket::isNotCrawler()).

Если на сайте уже стоит catalog.element с SET_VIEWED_IN_COMPONENT = Y, дублировать вызов не нужно.

Как проверить в БД:

        SELECT PRODUCT_ID, ELEMENT_ID, DATE_VISIT, VIEW_COUNT, RECOMMENDATION
FROM b_catalog_viewed_product
WHERE SITE_ID = 's1'
ORDER BY DATE_VISIT DESC;

    

Пример 2. Выборка ID просмотренных товаров

Функция возвращает ID родительских элементов в порядке просмотра — готово для getList() или своего шаблона.

        <?php
declare(strict_types=1);

use Bitrix\Catalog\CatalogViewedProductTable;
use Bitrix\Main\Config\Option;
use Bitrix\Main\Loader;
use Bitrix\Sale\Fuser;

/**
 * @return int[] ID элементов каталога, от новых к старым
 */
function getViewedElementIds(
    int $iblockId,
    int $limit = 10,
    int $excludeElementId = 0,
    ?string $siteId = null,
): array {
    if (
        !Loader::includeModule('catalog')
        || !Loader::includeModule('sale')
        || Option::get('catalog', 'enable_viewed_products') !== 'Y'
    ) {
        return [];
    }

    $fuserId = (int)Fuser::getId();
    $siteId = $siteId ?? (defined('SITE_ID') ? SITE_ID : '');

    if ($iblockId <= 0 || $fuserId <= 0 || $siteId === '') {
        return [];
    }

    $filter = [
        '=FUSER_ID' => $fuserId,
        '=SITE_ID' => $siteId,
    ];
    if ($excludeElementId > 0) {
        $filter['!=ELEMENT_ID'] = $excludeElementId;
    }

    $map = [];
    $iterator = CatalogViewedProductTable::getList([
        'select' => ['PRODUCT_ID', 'ELEMENT_ID'],
        'filter' => $filter,
        'order' => ['DATE_VISIT' => 'DESC'],
        'limit' => $limit,
    ]);

    $emptyParents = [];
    while ($row = $iterator->fetch()) {
        $productId = (int)$row['PRODUCT_ID'];
        $elementId = (int)$row['ELEMENT_ID'];
        $map[$productId] = $elementId;
        if ($elementId <= 0) {
            $emptyParents[] = $productId;
        }
    }

    // Старые записи могли остаться без ELEMENT_ID — дозаполняем
    if ($emptyParents !== []) {
        $resolved = CatalogViewedProductTable::getProductsMap($emptyParents);
        foreach ($resolved as $productId => $parentId) {
            if ($excludeElementId > 0 && $parentId === $excludeElementId) {
                unset($map[$productId]);
            } else {
                $map[$productId] = $parentId;
            }
        }
    }

    $ids = [];
    foreach ($map as $productId => $elementId) {
        $ids[] = $elementId > 0 ? $elementId : $productId;
    }

    return array_values(array_unique($ids));
}

    
⚠️ Важно
Так же поступает компонент catalog.viewed.products в ядре — мы не изобретаем велосипед, а повторяем проверенный путь.

Пример 3. Фильтр по разделу

Если блок «Вы смотрели» должен показывать только товары из текущего раздела (и подразделов), используйте getProductSkuMap() — ядро само строит ORM-запрос с join на секции.

        <?php
declare(strict_types=1);

use Bitrix\Catalog\CatalogViewedProductTable;
use Bitrix\Main\Loader;
use Bitrix\Sale\Fuser;

/**
 * @return array<int, int> [SKU_ID => PARENT_ELEMENT_ID]
 */
function getViewedInSection(
    int $iblockId,
    int $sectionId,
    int $limit = 10,
    int $sectionDepth = 0,
    ?string $siteId = null,
): array {
    if (!Loader::includeModule('catalog') || !Loader::includeModule('sale')) {
        return [];
    }

    $fuserId = (int)Fuser::getId();
    $siteId = $siteId ?? (defined('SITE_ID') ? SITE_ID : '');

    if ($iblockId <= 0 || $fuserId <= 0) {
        return [];
    }

    return CatalogViewedProductTable::getProductSkuMap(
        $iblockId,
        $sectionId,
        $fuserId,
        0,              // excludeElementId
        $limit,
        $sectionDepth,  // 0 — только этот раздел; >0 — подняться вверх по дереву
        $siteId,
    );
}

// На странице раздела:
// $map = getViewedInSection(iblockId: 2, sectionId: (int)$arResult['SECTION']['ID'], limit: 8, sectionDepth: 1);
// $elementIds = array_values(array_unique($map));

    

Пример 4. Мини-блок для вставки в шаблон

Связка «выборка + элементы инфоблока + простой HTML». Подставьте свой $iblockId и $excludeId (текущий товар на деталке).

        <?php
declare(strict_types=1);

use Bitrix\Iblock\ElementTable;
use Bitrix\Main\Loader;

function renderViewedProductsBlock(int $iblockId, int $limit = 8, int $excludeId = 0): string
{
    if (!Loader::includeModule('iblock')) {
        return '';
    }

    $elementIds = getViewedElementIds($iblockId, $limit, $excludeId);
    if ($elementIds === []) {
        return '';
    }

    $byId = [];
    $rows = ElementTable::getList([
        'select' => ['ID', 'NAME', 'PREVIEW_PICTURE'],
        'filter' => [
            '@ID' => $elementIds,
            '=IBLOCK_ID' => $iblockId,
            '=ACTIVE' => 'Y',
        ],
    ])->fetchAll();

    foreach ($rows as $row) {
        $byId[(int)$row['ID']] = $row;
    }

    // Сохраняем порядок просмотра, а не порядок из SQL
    $items = [];
    foreach ($elementIds as $id) {
        if (isset($byId[$id])) {
            $items[] = $byId[$id];
        }
    }

    if ($items === []) {
        return '';
    }

    $html = '<section class="viewed-products">'
        . '<h2>Вы смотрели</h2>'
        . '<ul>';

    foreach ($items as $item) {
        $html .= '<li>' . htmlspecialcharsbx($item['NAME']) . '</li>';
    }

    return $html . '</ul></section>';
}

// В шаблоне деталки:
// echo renderViewedProductsBlock(iblockId: 2, limit: 8, excludeId: (int)$arResult['ID']);

    

Порядок элементов здесь важен: ElementTable::getList без явной сортировки по ID не гарантирует «от недавних к старым». Для свойств инфоблока вместо ElementTable используйте ORM-класс, скомпилированный по API_CODE (IblockTable::compileEntity).

Пример 5. Запрос с фронта (SPA)

Если деталка на JS и catalog.element не используется — запись через runAction удобнее, чем свой endpoint. В нашем демо-модуле это vendor:viewed.viewed.record; для эксперимента можно быстро повесить action на Controllerable-компонент.

Минимальный вызов со стороны клиента (при наличии зарегистрированного action):

        // Запись просмотра: productId — оффер или товар, parentId — родитель (0 для простого товара)
BX.ajax.runAction('vendor:viewed.viewed.record', {
    data: { productId: 456, parentId: 123 },
});

// Список карточек для кастомного рендера
BX.ajax.runAction('vendor:viewed.viewed.getProducts', {
    data: { iblockId: 2, limit: 8, excludeElementId: 123 },
}).then(({ data }) => {
    console.log(data.products); // [{ id, name, url, previewPicture }, ...]
});

    
⚠️ Важно
На read-only action снимайте CSRF и закрывайте сессию (CloseSession) — иначе будут лишние блокировки сессии на каждый запрос списка.

Куда двигаться дальше

Примеры выше — для прототипа и понимания API. В продакшене ту же логику обычно выносят в модуль: репозиторий → сервис → тонкий компонент и контроллер с DI. Так код переиспользуется между шаблоном, AJAX и cron.

Сценарий Решение
Стандартная витрина, цены и SKU bitrix:catalog.products.viewed
Только запись просмотра SET_VIEWED_IN_COMPONENT = Y в catalog.element
Кастомный UI, API, несколько точек входа Свой модуль (vendor.viewed)

Антипаттерны

  • INSERT в b_catalog_viewed_product — обходит isNotCrawler(), лимиты и SKU-логику.
  • USER_ID вместо FUSER_ID — анонимные посетители без истории.
  • Чтение только PRODUCT_ID — в блоке появятся и родитель, и оффер.
  • refresh() на каждом AJAX-хите — растёт VIEW_COUNT без пользы.
  • Вся логика в одном шаблоне — для поддержки быстро упирается в копипасту; модуль решает это, но для эксперимента примеры с функциями выше — нормальный старт.
Заключение

Кастомный блок «Вы смотрели» держится на двух методах ядра: refresh() для записи и getList() / getProductSkuMap() для выборки. Остальное — обвязка вокруг FUSER_ID, порядка элементов и исключения текущего товара на деталке.

Если после экспериментов захотите поставить решение на проект без ручной сборки — у подписчиков BXMax есть готовый модуль vendor.viewed с компонентом, контроллером и установщиком.

Исходники
Доступно по подписке: Кофе && Код
Открыть уровни поддержки
Опубликовано 12 часов назад

Теги:

Комментарии (0)

Пожалуйста, войдите в аккаунт, чтобы оставить комментарий

Оставить комментарий

Пока нет ни одного комментария. Будьте первым!

Похожие статьи

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

AI Домовой История

0 / 25

Привет! Я помогу с вопросами по 1С-Битрикс.

Спрашивай про D7, ORM, компоненты или события.

Требуется авторизация

Войдите или зарегистрируйтесь, чтобы задавать вопросы AI-ассистенту.

Войти