Кастомный блок «Вы смотрели»: мини-туториал на 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 }, ...]
});
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 с компонентом, контроллером и установщиком.
Теги:
Комментарии (0)
Пожалуйста, войдите в аккаунт, чтобы оставить комментарий
Оставить комментарийПока нет ни одного комментария. Будьте первым!
Похожие статьи
Тихие хуки Битрикса: вспомогательные файлы, которые ядро подключает за вас (и как ими пользоваться)