Блог
Назад к статьям

Автоматические купоны ко дню рождения в 1С-Битрикс: batching, cron и защита от дублей

Как реализовать автоматическую выдачу купонов ко дню рождения в коробочном 1С-Битрикс: DiscountCouponTable, батчевый cron, CLI-скрипт ручного выпуска, защита от дублей через маркер в DESCRIPTION, парсинг PERSONAL_BIRTHDAY.

27 июня 2026 г.
412
BackendBitrixbitrixbackendphpormautomationd71c-bitrixcron
Автоматические купоны ко дню рождения в 1С-Битрикс: batching, cron и защита от дублей

Зачем нужны автоматические купоны ко дню рождения в e-commerce

Поздравление покупателя с днём рождения — один из самых конверсионных инструментов в email-маркетинге интернет-магазина. По данным исследований Experian, письма с купонами ко дню рождения открывают в 2,5 раза чаще, чем обычные промо-рассылки, а их конверсия в покупку выше в несколько раз. Причина проста: человек уже лоялен к магазину, он ждёт внимания, и персонализированное предложение воспринимается не как спам, а как подарок.

Проблема возникает сразу, как только база покупателей вырастает до нескольких тысяч человек. Ручная выдача купонов через панель администратора — это десятки минут ежедневной рутины. Маркетолог должен зайти в Битрикс, найти всех именинников, создать купон, скопировать код и отправить письмо. На практике это либо не делается вовсе, либо делается нерегулярно — и маркетинговый инструмент теряет эффективность.

В этой статье я покажу законченное продакшн-решение, которое автоматизирует весь цикл: ежедневный cron обходит всех активных пользователей, находит именинников через 5 дней, создаёт одноразовый купон через DiscountCouponTable и отправляет письмо через почтовое событие Битрикс. Предусмотрена защита от повторной выдачи купона в одном году, корректная обработка 29 февраля и ручной режим для единичного выпуска по ID или email пользователя.


Как работает система: общая схема автоматической выдачи купонов

Весь процесс выглядит следующим образом:

cron (birthday_coupon_cron.php)
         │
         ▼
sendCouponsInBatches()
         │
         ├─── resolveDiscount()  ←── DiscountTable::getList() по ID 125
         │         │                  или fallback по NAME / DISCOUNT_VALUE
         │         ▼
         │    [discount array]
         │
         ├─── processBatch()  ←── loadBirthdayUsersBatch() (lastUserId пагинация)
         │         │
         │         ├─ parseBirthday()           → месяц + день из PERSONAL_BIRTHDAY
         │         ├─ resolveNextBirthdayDate()  → ближайшая дата ДР
         │         ├─ DAYS_BEFORE_BIRTHDAY === 5 → пропуск всех остальных
         │         ├─ hasCouponForYear()         → дубль за год? пропуск
         │         ├─ createCoupon()             → DiscountCouponTable::add()
         │         └─ sendCouponEmail()          → CEvent::Send() + fallback mail()
         │
         └─── log('done', [...]) → /local/logs/birthday_coupon_sender.log

Система даёт три ключевые гарантии:

  1. Не дублировать. Перед созданием купона метод hasCouponForYear() проверяет, не был ли уже выдан купон этому пользователю в текущем году. Проверка идёт по строке-маркеру в поле DESCRIPTION таблицы купонов — никаких дополнительных таблиц.
  1. Не пропускать 29 февраля. Метод buildBirthdayDate() обрабатывает ситуацию, когда в невисокосный год нельзя создать дату 29 февраля: в таком случае купон выдаётся 28 февраля, и пользователь не остаётся без поздравления.
  1. Не падать при отсутствии правила скидки. Метод resolveDiscount() имеет три уровня поиска: по фиксированному ID, затем по точному совпадению процента, затем по ключевым словам в имени. Если ничего не найдено — процесс завершается с информативным JSONL-сообщением в лог, но не бросает исключение.

Архитектура класса BirthdayCouponSender в 1С-Битрикс

Класс объявлен как final — это намеренное решение. Логика выдачи купонов — специализированная и сплочённая; наследование здесь создало бы только риски нарушить инварианты (например, переопределить hasCouponForYear() и сломать защиту от дублей). final явно сигнализирует коллегам и IDE: этот класс не предназначен для расширения.

Все методы статические, потому что класс не хранит изменяемого состояния между вызовами. Единственный исключение — статическое свойство $lastResolveDiscountError, которое накапливает диагностическое сообщение о причине неудачного поиска скидки и передаётся наружу через публичные методы. Это позволяет избежать исключений для штатной ситуации «скидка не настроена».

Публичный API класса минимален и осознан:

МетодЧто делаетДоступ
sendCouponsInBatches(batchSize, maxBatches, sendEmail)Батчевый обход всех активных пользователейпубличный
issueCouponForUser(userId, sendEmail)Ручной выпуск купона на одного пользователяпубличный
ensureAgentRegistered()Регистрация CAgent (альтернатива cron)публичный
runAgent()Обёртка для вызова из CAgentпубличный
sendCoupons()Алиас для sendCouponsInBatches() с параметрами по умолчаниюпубличный
resolveDiscount()Поиск правила скидки (три уровня)приватный
processBatch()Обработка одной пачки пользователейприватный
loadBirthdayUsersBatch()Запрос пользователей через UserTableприватный
hasCouponForYear()Проверка дубля через DESCRIPTIONприватный
createCoupon()Создание записи в DiscountCouponTableприватный
sendCouponEmail()Отправка через CEvent::Send + fallbackприватный
parseBirthday()Парсинг PERSONAL_BIRTHDAY в 7 форматахприватный
resolveNextBirthdayDate()Вычисление ближайшей даты ДРприватный
buildBirthdayDate()Создание DateTimeImmutable с учётом 29 февприватный
buildCouponMarker()Генерация строки-маркера для DESCRIPTIONприватный
log()JSONL-запись в файлприватный

Шаг 1: подключение класса в init.php

Класс BirthdayCouponSender размещается в /local/php_interface/lib/Local/User/BirthdayCouponSender.php и подключается через /local/php_interface/init.php:

use Local\User\BirthdayCouponSender;

$birthdayCouponSenderPath = __DIR__ . '/lib/Local/User/BirthdayCouponSender.php';
if (file_exists($birthdayCouponSenderPath)) {
    require_once $birthdayCouponSenderPath;
}

Обратите внимание на file_exists перед require_once. В init.php ошибка подключения файла приводит к фатальной ошибке PHP и падению всего сайта — поэтому проверка существования файла обязательна. require_once вместо require гарантирует, что класс не будет подключён дважды при возможном повторном включении init.php.

Если вы используете автозагрузку через Composer или собственный PSR-4 загрузчик в Битрикс, эту секцию init.php можно пропустить — класс будет найден автоматически по пространству имён LocalUser.


Шаг 2: батчевый cron для автоматической рассылки купонов ко дню рождения

Основной сценарий запуска — ежедневный cron. Скрипт размещается в /local/cli/birthday_coupon_cron.php:

<?php

if (PHP_SAPI !== 'cli') {
    http_response_code(403);
    exit('Forbidden');
}

$_SERVER['DOCUMENT_ROOT'] = realpath(__DIR__ . '/../..');
$DOCUMENT_ROOT = $_SERVER['DOCUMENT_ROOT'];

define('NO_KEEP_STATISTIC', true);
define('NOT_CHECK_PERMISSIONS', true);
define('BX_NO_ACCELERATOR_RESET', true);

require $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';

use Local\User\BirthdayCouponSender;

$batchSize = BirthdayCouponSender::DEFAULT_BATCH_SIZE;
$maxBatches = 0;
$sendEmail = true;

foreach (array_slice($argv, 1) as $argument) {
    if (preg_match('/^--batch=(\d+)$/', (string)$argument, $matches)) {
        $batchSize = max(1, (int)$matches[1]);
        continue;
    }

    if (preg_match('/^--max-batches=(\d+)$/', (string)$argument, $matches)) {
        $maxBatches = max(0, (int)$matches[1]);
        continue;
    }

    if ((string)$argument === '--no-email') {
        $sendEmail = false;
    }
}

$result = BirthdayCouponSender::sendCouponsInBatches($batchSize, $maxBatches, $sendEmail);

echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL;

exit(($result['success'] ?? false) ? 0 : 1);

Почему PHP_SAPI !== 'cli'. Скрипт использует $argv, ручное задание DOCUMENT_ROOT и prolog_before.php с неполной инициализацией сессий. Запуск через браузер привёл бы к ошибкам и потенциально к несанкционированной выдаче купонов. Проверка PHP_SAPI на первой строке — стандартная практика для CLI-скриптов Битрикс.

Константы NO_KEEP_STATISTIC и BX_NO_ACCELERATOR_RESET отключают запись статистики посещений и сброс кэша акселератора. Без них cron-скрипт создаёт «мусорные» записи в статистике и может непреднамеренно инвалидировать кэши.

Флаги командной строки:

  • --batch=N — размер пачки пользователей (по умолчанию 500). Уменьшайте при ограничении памяти.
  • --max-batches=N — максимальное число итераций. По умолчанию 0 — обходить всех пользователей. Задайте 10–20, если cron ограничен по времени выполнения.
  • --no-email — создать купоны без отправки писем (полезно для тестирования).

Запись в crontab:

0 8 * * * /usr/bin/php /var/www/bitrix/local/cli/birthday_coupon_cron.php >> /var/log/birthday_coupon_cron.log 2>&1

Запуск в 8:00 каждый день. Путь к PHP и к документ-руту замените на актуальные для вашего сервера. Вывод JSON-результата сохраняется в отдельный системный лог — это удобно для мониторинга через tail -f.


Шаг 3: ручной выпуск купона для конкретного пользователя

Для поддержки и маркетинговых акций предусмотрен CLI-скрипт birthday_coupon_issue.php, который выдаёт купон на одного пользователя:

<?php

if (PHP_SAPI !== 'cli') {
    http_response_code(403);
    exit('Forbidden');
}

$_SERVER['DOCUMENT_ROOT'] = realpath(__DIR__ . '/../..');
$DOCUMENT_ROOT = $_SERVER['DOCUMENT_ROOT'];

define('NO_KEEP_STATISTIC', true);
define('NOT_CHECK_PERMISSIONS', true);
define('BX_NO_ACCELERATOR_RESET', true);

require $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';

use Bitrix\Main\UserTable;
use Local\User\BirthdayCouponSender;

$userId = 0;
$email = '';
$sendEmail = true;

foreach (array_slice($argv, 1) as $argument) {
    if (preg_match('/^--user=(\d+)$/', (string)$argument, $matches)) {
        $userId = (int)$matches[1];
        continue;
    }

    if (preg_match('/^--email=(.+)$/', (string)$argument, $matches)) {
        $email = trim((string)$matches[1]);
        continue;
    }

    if ((string)$argument === '--no-email') {
        $sendEmail = false;
    }
}

if ($userId <= 0 && $email !== '') {
    $user = UserTable::getList([
        'select' => ['ID'],
        'filter' => ['=EMAIL' => $email],
        'limit' => 1,
    ])->fetch();

    $userId = (int)($user['ID'] ?? 0);
}

if ($userId <= 0) {
    echo json_encode([
        'success' => false,
        'message' => 'Передайте --user=ID или --email=user@example.com',
    ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL;
    exit(1);
}

$result = BirthdayCouponSender::issueCouponForUser($userId, $sendEmail);

echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL;

exit(($result['success'] ?? false) ? 0 : 1);

Примеры запуска:

# По ID пользователя, с отправкой письма
php /var/www/bitrix/local/cli/birthday_coupon_issue.php --user=42

# По email, без отправки письма (только создать купон)
php /var/www/bitrix/local/cli/birthday_coupon_issue.php --email=user@example.com --no-email

# По ID без письма
php /var/www/bitrix/local/cli/birthday_coupon_issue.php --user=42 --no-email

Скрипт возвращает JSON с полями success, coupon, birthday_date, active_to, email_sent. При успехе код завершения — 0, при ошибке — 1, что позволяет использовать скрипт в Makefile или CI-пайплайнах.


Защита от дублей: маркер в поле DESCRIPTION купона

Главная проблема ежедневного cron — повторная выдача купона пользователю, которому он уже был выдан в этом году. Наивное решение — проверять наличие купона по USER_ID и диапазону дат — ненадёжно: если даты хранятся в разных форматах или купон был продлён вручную, фильтр может дать ложноотрицательный результат.

В этом решении используется строка-маркер, которая записывается в поле DESCRIPTION каждого созданного купона:

birthday_coupon:user=42;year=2026

Метод hasCouponForYear() выполняет точный поиск по этой строке:

private static function hasCouponForYear(int $userId, int $year): bool
{
    $existingCoupon = DiscountCouponTable::getList([
        'select' => ['ID'],
        'filter' => [
            '=USER_ID' => $userId,
            '=DESCRIPTION' => self::buildCouponMarker($userId, $year),
        ],
        'limit' => 1,
    ])->fetch();

    return is_array($existingCoupon);
}

private static function buildCouponMarker(int $userId, int $year): string
{
    return 'birthday_coupon:user=' . $userId . ';year=' . $year;
}

Почему поле DESCRIPTION, а не отдельная таблица? Таблица купонов в Битрикс уже хранит все необходимые данные. Добавлять отдельную таблицу для маркера — это усложнение без выгоды: нужно создавать таблицу, следить за её миграциями, удалять устаревшие записи. Поле DESCRIPTION доступно из коробки, индексируется через фильтр D7 ORM и не требует никакой дополнительной инфраструктуры. Строка birthday_coupon:user=N;year=YYYY достаточно уникальна, чтобы исключить ложные совпадения с другими купонами, где поле DESCRIPTION тоже может быть заполнено.


Поиск правила скидки: три уровня fallback в resolveDiscount

Метод resolveDiscount() реализует устойчивый поиск правила скидки с тремя уровнями:

Уровень 1 — фиксированный ID. Сначала ищется правило с ID = 125 (константа DISCOUNT_ID). Это самый быстрый путь: один запрос к БД. Если правило найдено, но неактивно или в нём отключены купоны (USE_COUPONS !== 'Y') — метод возвращает null с диагностическим сообщением.

Уровень 2 — точное совпадение параметров. Если правило с ID 125 не найдено на текущем сайте, происходит поиск всех активных правил с купонами, где DISCOUNT_TYPE = 'P' (процентная скидка) и DISCOUNT_VALUE = 5.0. Среди найденных выбирается лучший кандидат через pickBestDiscount().

Уровень 3 — поиск по ключевым словам. Если второй уровень не дал результата, поиск расширяется до всех активных правил с купонами. pickBestDiscount() присваивает каждому правилу очки: +100 за каждое ключевое слово из DISCOUNT_NAME_HINTS (день рождения, birthday, ДР и другие) в поле NAME или XML_ID, +20 за совпадение процента. Правило с максимальным счётом становится победителем.

private const DISCOUNT_NAME_HINTS = [
    'день рождения',
    'деньрождения',
    'birthday',
    'birth day',
    'др',
];

Почему важен fallback? На боевых проектах правила скидок нередко пересоздаются после импорта или миграции между стендами — ID меняется. Трёхуровневый поиск позволяет системе найти нужное правило даже после таких операций, без ручного вмешательства в код.


Парсинг PERSONAL_BIRTHDAY и проблема 29 февраля в Битрикс

Поле PERSONAL_BIRTHDAY хранится в таблице b_user как строка и не имеет жёстко заданного формата. На реальных проектах встречаются все возможные варианты в зависимости от истории импортов и форм регистрации. Метод parseBirthday() последовательно перебирает семь форматов через DateTimeImmutable::createFromFormat:

$formats = [
    'd.m.Y',       // 15.03.1990  — основной формат Битрикс
    'd.m.Y H:i:s', // 15.03.1990 00:00:00  — с временем
    'd.m.y',       // 15.03.90   — двузначный год
    'Y-m-d',       // 1990-03-15  — ISO 8601
    'Y-m-d H:i:s', // 1990-03-15 00:00:00
    'd/m/Y',       // 15/03/1990
    'm/d/Y',       // 03/15/1990  — американский формат
];

Если ни один формат не подошёл, делается попытка через strtotime() — это последний шанс для нестандартных строк вида «15 марта 1990».

Метод возвращает только месяц и день — год рождения намеренно игнорируется, чтобы не зависеть от возможных ошибок ввода (пользователи нередко указывают год неверно).

Проблема 29 февраля. Если пользователь родился 29 февраля, то в невисокосный год эту дату создать невозможно. Метод buildBirthdayDate() обрабатывает этот случай явно:

private static function buildBirthdayDate(int $year, int $month, int $day): ?\DateTimeImmutable
{
    if (checkdate($month, $day, $year)) {
        return new \DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day));
    }

    if ($month === 2 && $day === 29) {
        return new \DateTimeImmutable(sprintf('%04d-02-28', $year));
    }

    return null;
}

В невисокосный год именинники 29 февраля получат купон 28 февраля — это наиболее справедливое поведение. В следующий високосный год checkdate(2, 29, year) вернёт true, и купон выйдет в правильный день.


Почтовое событие USER_BIRTHDAY_COUPON: настройка в Битрикс

Для отправки письма используется почтовое событие Битрикс. Его нужно создать в разделе «Настройки → Почта → Типы почтовых событий» с именем USER_BIRTHDAY_COUPON и добавить шаблон письма.

Метод sendCouponEmail() передаёт в событие следующие поля:

ПолеЗначениеОписание
EMAIL_TOemail пользователяАдрес получателя
USER_NAMEИмя Фамилия или loginИмя для обращения
COUPONКод купонаСтрока вида BDAY-A1B2C3
DISCOUNT_PERCENT5Процент скидки
BIRTHDAY_DATEdd.mm.YYYYДата дня рождения
COUPON_ACTIVE_TOdd.mm.YYYYПоследний день действия купона
DAYS_BEFORE_BIRTHDAY5За сколько дней выдаётся купон

В шаблоне письма обращайтесь к этим полям через синтаксис #COUPON#, #USER_NAME#, #COUPON_ACTIVE_TO#.

Fallback через mail(). Если CEvent::Send() вернула false (событие не настроено или почтовый транспорт недоступен), метод sendFallbackMail() отправляет упрощённое текстовое письмо напрямую через mail(). Это обеспечивает минимальную отказоустойчивость. В production рекомендуется всегда настраивать событие и шаблон — fallback предназначен только для экстренных ситуаций.


Логирование в JSONL-формате

Каждое действие системы записывается в файл /local/logs/birthday_coupon_sender.log в формате JSONL (JSON Lines — одна запись в строку):

{"ts":"2026-06-27T08:00:01+03:00","level":"sent","context":{"user_id":42,"email":"user@example.com","coupon":"BDAY-X1Y2Z3","birthday_date":"01.07.2026","active_to":"06.07.2026"}}
{"ts":"2026-06-27T08:00:01+03:00","level":"skip","context":{"reason":"birthday_coupon already exists for this year","user_id":15}}
{"ts":"2026-06-27T08:00:05+03:00","level":"done","context":{"success":true,"processed_users":1250,"matched_users":3,"sent_coupons":2,"batches":3,"discount_id":125}}

Уровни записей:

  • sent — купон создан и письмо отправлено
  • coupon_created_without_email — купон создан, письмо не отправлялось (флаг --no-email)
  • skip — пользователь пропущен (нет ДР, дубль за год, неактивен, не найдена скидка)
  • error — ошибка при создании купона или другая исключительная ситуация
  • done — итоговая запись после завершения всей батчевой обработки

Для просмотра последних ошибок:

grep '"level":"error"' /var/www/bitrix/local/logs/birthday_coupon_sender.log | tail -20

Для подсчёта купонов за день:

grep '"level":"sent"' /var/www/bitrix/local/logs/birthday_coupon_sender.log | grep "2026-06-27" | wc -l

Типичные ошибки при внедрении автоматических купонов в Битрикс

Купоны создаются повторно каждый год

Причина: при создании купона не записывается маркер в поле DESCRIPTION, или hasCouponForYear() ищет не по точному совпадению строки, а по LIKE.

Решение: убедитесь, что createCoupon() передаёт buildCouponMarker($userId, $year) в ключ DESCRIPTION при вызове DiscountCouponTable::add(). Фильтр '=DESCRIPTION' в hasCouponForYear() должен использовать оператор точного равенства (префикс =).

Скрипт не находит правило скидки

Причина: правило скидки создано не для текущего SITE_ID, или поле USE_COUPONS установлено в N, или правило неактивно.

Решение: проверьте лог — там будет конкретная причина отказа. Войдите в администраторскую панель, откройте правило с ID 125, убедитесь, что оно активно, привязано к нужному сайту и в нём включены купоны. Если правило было пересоздано с другим ID — обновите константу DISCOUNT_ID в классе.

Письмо не отправляется

Причина: почтовое событие USER_BIRTHDAY_COUPON не создано или для него нет активного шаблона.

Решение: создайте тип почтового события в разделе «Настройки → Почта → Типы почтовых событий» и добавьте шаблон с перечисленными выше полями. Проверьте раздел «Настройки → Почта → Очередь почтовых сообщений» — письмо может быть в очереди, а не отправлено. Также убедитесь, что настроен почтовый транспорт (SMTP или sendmail).

Cron падает с ошибкой «prolog_before.php: no such file»

Причина: переменная DOCUMENT_ROOT задаётся через realpath(__DIR__ . '/../..'), но скрипт запускается из другой рабочей директории, и относительный путь не разрешается корректно.

Решение: используйте абсолютный путь к скрипту в crontab: php /var/www/html/local/cli/birthday_coupon_cron.php. Проверьте, что realpath(__DIR__ . '/../..') на вашем сервере возвращает корень сайта, а не false. В случае проблем замените на явный абсолютный путь: $_SERVER['DOCUMENT_ROOT'] = '/var/www/html';.


Чеклист проверки после внедрения купонов ко дню рождения

Перед переводом в production пройдитесь по этим шести пунктам:

  • Правило скидки с ID 125 активно, привязано к нужному сайту, купоны включены (USE_COUPONS = Y)
  • Почтовое событие USER_BIRTHDAY_COUPON создано, шаблон активен, все поля в шаблоне написаны верно
  • Тестовый запуск --no-email завершился без ошибок: grep '"level":"error"' /local/logs/birthday_coupon_sender.log — пусто
  • Ручной выпуск birthday_coupon_issue.php --user=YOUR_TEST_ID создал купон и прислал письмо
  • В таблице купонов (Битрикс → Маркетинг → Купоны) появился купон с правильным DESCRIPTION, датами и привязкой к нужному правилу
  • Cron-задача зарегистрирована, проверьте crontab -l и убедитесь, что путь к PHP и к скрипту верны

Заключение: масштабируемость и дальнейшее развитие

Решение работает стабильно на базах с десятками тысяч пользователей: пагинация через lastUserId вместо OFFSET исключает деградацию производительности при росте таблицы. Размер пачки и лимит батчей позволяют точно подстроить потребление ресурсов под ограничения вашего хостинга. JSONL-лог совместим с Loki, Elastic и любыми другими агрегаторами логов без дополнительной обработки.

Возможные направления развития: расширить процент скидки в зависимости от сегмента покупателя, добавить SMS-канал через CEvent или сторонний провайдер, ввести настройку DAYS_BEFORE_BIRTHDAY через публичный конструктор вместо константы. Практическое руководство по скидкам и купонам (в т.ч. DiscountCouponTable::add()) — в официальной документации Битрикс: Комплекты, наборы и скидки. Полная справка по CEvent::Send — в разделе справочника по модулю main.

Купоны ко дню рождения в 1С-Битрикс: автоматическая рассылка через cron