DEV Community

Андрей Викулов (VProger)
Андрей Викулов (VProger)

Posted on • Originally published at viku-lov.ru on

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

Автоматические купоны ко дню рождения в 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
Enter fullscreen mode Exit fullscreen mode

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

  1. Не дублировать. Перед созданием купона метод hasCouponForYear() проверяет, не был ли уже выдан купон этому пользователю в текущем году. Проверка идёт по строке-маркеру в поле DESCRIPTION таблицы купонов — никаких дополнительных таблиц.

  2. Не пропускать 29 февраля. Метод buildBirthdayDate() обрабатывает ситуацию, когда в невисокосный год нельзя создать дату 29 февраля: в таком случае купон выдаётся 28 февраля, и пользователь не остаётся без поздравления.

  3. Не падать при отсутствии правила скидки. Метод 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;
}
Enter fullscreen mode Exit fullscreen mode

Обратите внимание на 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);
Enter fullscreen mode Exit fullscreen mode

Почему 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
Enter fullscreen mode Exit fullscreen mode

Запуск в 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);
Enter fullscreen mode Exit fullscreen mode

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

# По 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
Enter fullscreen mode Exit fullscreen mode

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


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

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

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

birthday_coupon:user=42;year=2026
Enter fullscreen mode Exit fullscreen mode

Метод 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;
}
Enter fullscreen mode Exit fullscreen mode

Почему поле 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',
'др',
];
Enter fullscreen mode Exit fullscreen mode

Почему важен 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 — американский формат
];
Enter fullscreen mode Exit fullscreen mode

Если ни один формат не подошёл, делается попытка через 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;
}
Enter fullscreen mode Exit fullscreen mode

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


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

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

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

Поле Значение Описание
EMAIL_TO email пользователя Адрес получателя
USER_NAME Имя Фамилия или login Имя для обращения
COUPON Код купона Строка вида BDAY-A1B2C3
DISCOUNT_PERCENT 5 Процент скидки
BIRTHDAY_DATE dd.mm.YYYY Дата дня рождения
COUPON_ACTIVE_TO dd.mm.YYYY Последний день действия купона
DAYS_BEFORE_BIRTHDAY 5 За сколько дней выдаётся купон

В шаблоне письма обращайтесь к этим полям через синтаксис #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}}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

Причина: при создании купона не записывается маркер в поле 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.

Read more on Viku-Lov Studio

Top comments (0)