Сниппеты
Назад к сниппетам

Битрикс: батчевый обход пользователей для рассылки купонов

Методы sendCouponsInBatches() и processBatch() для класса BirthdayCouponSender: постраничный обход активных пользователей через lastUserId вместо OFFSET, проверка дней до дня рождения, защита от дублей за год.

php
27 июня 2026 г.
cronperformancephpbitrix1c-bitrix
<?php

use Bitrix\Main\Loader;
use Bitrix\Main\UserTable;

// Константы класса BirthdayCouponSender
public const DEFAULT_BATCH_SIZE = 500;
private const DAYS_BEFORE_BIRTHDAY = 5;
private const COUPON_VALID_DAYS_AFTER_BIRTHDAY = 5;

public static function sendCouponsInBatches(
    int $batchSize = self::DEFAULT_BATCH_SIZE,
    int $maxBatches = 0,
    bool $sendEmail = true
): array
{
    if (!Loader::includeModule('sale') || !Loader::includeModule('catalog')) {
        self::log('skip', ['reason' => 'required modules are not available']);
        return [
            'success' => false,
            'message' => 'required modules are not available',
        ];
    }

    $discount = self::resolveDiscount();
    if ($discount === null) {
        self::log('skip', ['reason' => 'birthday discount was not found']);
        return [
            'success' => false,
            'message' => self::$lastResolveDiscountError !== ''
                ? self::$lastResolveDiscountError
                : 'birthday discount was not found',
        ];
    }

    $batchSize = max(1, $batchSize);
    $maxBatches = max(0, $maxBatches);
    $lastUserId = 0;
    $batchNumber = 0;
    $processed = 0;
    $matched = 0;
    $sent = 0;

    while (true) {
        $batch = self::processBatch($discount, $lastUserId, $batchSize, $sendEmail);
        $batchNumber++;

        $processed += (int)$batch['processed'];
        $matched += (int)$batch['matched'];
        $sent += (int)$batch['sent'];
        $lastUserId = (int)$batch['last_user_id'];

        if (($batch['done'] ?? true) === true) {
            break;
        }

        if ($maxBatches > 0 && $batchNumber >= $maxBatches) {
            break;
        }
    }

    $result = [
        'success' => true,
        'message' => 'birthday coupons batch processing completed',
        'processed_users' => $processed,
        'matched_users' => $matched,
        'sent_coupons' => $sent,
        'email_mode' => $sendEmail ? 'send' : 'skip',
        'batches' => $batchNumber,
        'last_user_id' => $lastUserId,
        'discount_id' => (int)$discount['ID'],
        'discount_name' => (string)$discount['NAME'],
        'batch_size' => $batchSize,
    ];

    self::log('done', $result);

    return $result;
}

private static function loadBirthdayUsersBatch(int $lastUserId, int $limit): array
{
    return UserTable::getList([
        'select' => ['ID', 'NAME', 'LAST_NAME', 'LOGIN', 'EMAIL', 'PERSONAL_BIRTHDAY'],
        'filter' => [
            '=ACTIVE' => 'Y',
            '>ID' => $lastUserId,
            '!=EMAIL' => false,
            '!=PERSONAL_BIRTHDAY' => false,
        ],
        'order' => ['ID' => 'ASC'],
        'limit' => $limit,
    ])->fetchAll();
}

private static function processBatch(array $discount, int $lastUserId, int $batchSize, bool $sendEmail): array
{
    $today = new \DateTimeImmutable('today');
    $rows = self::loadBirthdayUsersBatch($lastUserId, $batchSize);

    if (empty($rows)) {
        return [
            'processed' => 0,
            'matched' => 0,
            'sent' => 0,
            'last_user_id' => $lastUserId,
            'done' => true,
        ];
    }

    $processed = 0;
    $matched = 0;
    $sent = 0;

    foreach ($rows as $user) {
        $processed++;

        $birthdayInfo = self::parseBirthday((string)($user['PERSONAL_BIRTHDAY'] ?? ''));
        if ($birthdayInfo === null) {
            continue;
        }

        $nextBirthday = self::resolveNextBirthdayDate(
            (int)$birthdayInfo['month'],
            (int)$birthdayInfo['day'],
            $today
        );
        if ($nextBirthday === null) {
            continue;
        }

        $daysLeft = (int)$today->diff($nextBirthday)->format('%a');
        if ($daysLeft !== self::DAYS_BEFORE_BIRTHDAY) {
            continue;
        }

        $matched++;

        $userId = (int)($user['ID'] ?? 0);
        if ($userId <= 0 || self::hasCouponForYear($userId, (int)$nextBirthday->format('Y'))) {
            continue;
        }

        $coupon = self::createCoupon($userId, $discount, $nextBirthday);
        if ($coupon === null) {
            continue;
        }

        if ($sendEmail) {
            self::sendCouponEmail($user, $coupon, $nextBirthday);
        } else {
            self::log('coupon_created_without_email', [
                'user_id' => $userId,
                'email' => (string)($user['EMAIL'] ?? ''),
                'coupon' => $coupon,
                'birthday_date' => $nextBirthday->format('d.m.Y'),
                'active_to' => $nextBirthday
                    ->modify('+' . self::COUPON_VALID_DAYS_AFTER_BIRTHDAY . ' days')
                    ->format('d.m.Y'),
            ]);
        }

        $sent++;
    }

    $nextLastUserId = (int)($rows[count($rows) - 1]['ID'] ?? $lastUserId);

    return [
        'processed' => $processed,
        'matched' => $matched,
        'sent' => $sent,
        'last_user_id' => $nextLastUserId,
        'done' => count($rows) < $batchSize,
    ];
}

Почему lastUserId вместо OFFSET для обхода пользователей Битрикс

Классическая пагинация через OFFSET (в D7 — параметр offset в getList) имеет фундаментальный недостаток на больших таблицах: чем глубже страница, тем дольше выполняется запрос. При OFFSET 50000 MySQL вынужден прочитать и отбросить 50 тысяч строк, прежде чем вернуть нужные 500. На таблице b_user с миллионом записей это превращается в запросы с временем выполнения в несколько секунд каждый, а общее время cron-скрипта растёт квадратично.

Метод loadBirthdayUsersBatch() использует фильтр '>ID' => $lastUserId и сортировку по ID ASC. После обработки каждой пачки lastUserId обновляется до ID последнего пользователя в batch. Следующий запрос начинает ровно с того места, где закончился предыдущий — без сканирования всего предшествующего диапазона. Время каждого запроса постоянно вне зависимости от положения «страницы», а индекс по первичному ключу используется максимально эффективно. Признак завершения — количество возвращённых строк меньше $batchSize, то есть пользователи закончились.

Как читать лог батча

После завершения батчевого прохода в JSONL-лог записывается итоговая строка уровня done:

{
  "ts": "2026-06-27T08:00:05+03:00",
  "level": "done",
  "context": {
    "success": true,
    "processed_users": 12450,
    "matched_users": 7,
    "sent_coupons": 5,
    "email_mode": "send",
    "batches": 25,
    "last_user_id": 98712,
    "discount_id": 125,
    "discount_name": "Скидка ко дню рождения 5%",
    "batch_size": 500
  }
}

Поле matched_users показывает, сколько пользователей имеют день рождения ровно через DAYS_BEFORE_BIRTHDAY дней. Разница между matched_users и sent_coupons — это пользователи, которым купон уже был выдан в этом году (дубли). Если sent_coupons равно нулю при ненулевом matched_users — значит, все именинники уже получили купоны ранее, это нормальная ситуация при повторном запуске в тот же день.

Батчевый обход пользователей в 1С-Битрикс через lastUserId | Viku-Lov Studio