Development of the "Bestsellers" block in 1C-Bitrix

Our company is engaged in the development, support and maintenance of Bitrix and Bitrix24 solutions of any complexity. From simple one-page sites to complex online stores, CRM systems with 1C and telephony integration. The experience of developers is confirmed by certificates from the vendor.
Our competencies:
Development stages

"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.