"Best Sellers" Block Development for 1C-Bitrix
"Best sellers" is a block featuring the most frequently purchased products. It works on the homepage as a popularity showcase, in the catalog as a buyer orientation tool, and on a product page as a trust signal ("others choose this"). Bitrix has no built-in automatic mechanism for calculating best sellers — manually flagging the HIT property doesn't scale. You need a data-driven system.
Data Sources for Calculation
Best sellers are determined by actual sales — from b_sale_basket + b_sale_order. You can additionally weight product page views (if tracking is in place) with a lower weight.
-- Top-selling products over 30 days
SELECT
b.product_id,
SUM(b.quantity) AS total_qty,
COUNT(DISTINCT b.order_id) AS total_orders,
SUM(b.price * b.quantity) AS total_revenue
FROM b_sale_basket b
JOIN b_sale_order o ON b.order_id = o.id
WHERE
o.canceled = 'N'
AND o.date_insert >= DATE_SUB(NOW(), INTERVAL 30 DAY)
AND b.product_id IS NOT NULL
GROUP BY b.product_id
ORDER BY total_orders DESC, total_qty DESC
LIMIT 100;
Rank by total_orders (number of orders), not by total_qty — otherwise one order for 100 units would rank higher than 50 separate orders for 1 unit. The number of unique orders more objectively reflects popularity.
Multi-Factor Best Sellers: Weighted Formula
For more accurate ranking — a formula with multiple factors:
function calculateHitScore(array $stats, int $windowDays = 30): float {
$ordersWeight = 0.5;
$revenueWeight = 0.3;
$viewsWeight = 0.2;
// Normalization: divide by maximum value in the dataset
$normOrders = $stats['total_orders'] / ($stats['max_orders'] ?: 1);
$normRevenue = $stats['total_revenue'] / ($stats['max_revenue'] ?: 1);
$normViews = $stats['total_views'] / ($stats['max_views'] ?: 1);
// Recency boost: recent sales are worth more
$recencyBoost = 1.0;
if ($stats['last_sale_days_ago'] <= 7) {
$recencyBoost = 1.2;
} elseif ($stats['last_sale_days_ago'] <= 14) {
$recencyBoost = 1.1;
}
return ($normOrders * $ordersWeight + $normRevenue * $revenueWeight + $normViews * $viewsWeight)
* $recencyBoost;
}
Hits Table and Recalculation Agent
CREATE TABLE custom_hits (
product_id INT NOT NULL PRIMARY KEY,
score FLOAT NOT NULL,
total_orders INT DEFAULT 0,
total_qty INT DEFAULT 0,
total_revenue DECIMAL(12,2) DEFAULT 0,
category_rank INT, -- rank within category
is_hit TINYINT DEFAULT 1,
calculated_at DATETIME DEFAULT NOW(),
INDEX idx_score (score DESC),
INDEX idx_category_rank (category_rank)
);
The agent recalculates the table once per day:
function RecalcHitsAgent(): string {
$connection = \Bitrix\Main\Application::getConnection();
// Truncate and repopulate the table
$connection->truncateTable('custom_hits');
$data = calcSalesStats(30); // over 30 days
$max = getMaxValues($data);
foreach ($data as $productId => $stats) {
$stats = array_merge($stats, $max);
$score = calculateHitScore($stats);
$connection->add('custom_hits', [
'product_id' => $productId,
'score' => $score,
'total_orders' => $stats['total_orders'],
'total_qty' => $stats['total_qty'],
'total_revenue' => $stats['total_revenue'],
'is_hit' => $score > 0.1 ? 1 : 0,
'calculated_at' => new \Bitrix\Main\Type\DateTime(),
]);
}
// Calculate rank within each category
updateCategoryRanks();
return 'RecalcHitsAgent();';
}
Category Best Sellers
In addition to the overall ranking — best sellers by catalog section. On a product page the block shows "Best sellers in this category":
function getCategoryHits(int $sectionId, int $limit = 8, int $excludeId = 0): array {
// Get section products sorted by rank within the category
$hitIds = \Bitrix\Main\Application::getConnection()->query("
SELECT ch.product_id
FROM custom_hits ch
JOIN b_iblock_element ie ON ch.product_id = ie.id
WHERE ie.iblock_section_id = {$sectionId}
AND ie.active = 'Y'
AND ch.is_hit = 1
AND ch.product_id != {$excludeId}
ORDER BY ch.score DESC
LIMIT {$limit}
")->fetchAll();
return getProductsByIds(array_column($hitIds, 'product_id'));
}
Component and Caching
// company:catalog.hits — component.php
$cacheKey = "hits_{$arParams['SECTION_ID']}_{$arParams['LIMIT']}";
$cache = \Bitrix\Main\Data\Cache::createInstance();
if ($cache->initCache(3600 * 6, $cacheKey, '/catalog/hits')) {
$arResult = $cache->getVars();
} elseif ($cache->startDataCache()) {
$arResult = getCategoryHits(
(int)$arParams['SECTION_ID'],
(int)$arParams['LIMIT'],
(int)$arParams['EXCLUDE_ID']
);
$cache->endDataCache($arResult);
}
Cache for 6 hours — best seller data changes once a day, frequent cache updates are not needed.
Manual Management: Editorial Hit Flagging
In addition to automatic hits — the ability to manually flag products. A manager opens a product card and sets the "Editorial hit" flag. Such products are always shown in the block, regardless of statistics. Used for promotional and new products.
// Iblock property EDITORIAL_HIT (type: list, values: Y/N)
// When building the block, manual hits come first
$manualHits = getEditorialHits($sectionId, $limit);
$autoHits = getCategoryHits($sectionId, $limit - count($manualHits), $excludeIds);
$result = array_merge($manualHits, $autoHits);
Displaying the "Best Seller" Badge on Cards
In the product card template and in the listing, add a label:
// In the listing template.php
if (!empty($arItem['PROPERTIES']['HIT']['VALUE'])) {
echo '<span class="product-badge product-badge--hit">Best seller</span>';
}
// Or via the hits table — doesn't require an iblock property
$isHit = isProductHit($arItem['ID']); // reads from custom_hits
Timeline
| Stage | Timeline |
|---|---|
| Calculation SQL + hits table | 1–2 days |
| Recalculation agent + weighted formula | 2–3 days |
| Component (global + category) | 2–3 days |
| Manual flagging in admin panel | 1–2 days |
| Badges on cards and in listings | 1 day |
| Testing | 1–2 days |
Total: 1–1.5 weeks.

