Автоматические купоны ко дню рождения в 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
Система даёт три ключевые гарантии:
- Не дублировать. Перед созданием купона метод `hasCouponForYear()` проверяет, не был ли уже выдан купон этому пользователю в текущем году. Проверка идёт по строке-маркеру в поле `DESCRIPTION` таблицы купонов — никаких дополнительных таблиц.
- Не пропускать 29 февраля. Метод `buildBirthdayDate()` обрабатывает ситуацию, когда в невисокосный год нельзя создать дату 29 февраля: в таком случае купон выдаётся 28 февраля, и пользователь не остаётся без поздравления.
- Не падать при отсутствии правила скидки. Метод `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/phpinterface/lib/Local/User/BirthdayCouponSender.php` и подключается через `/local/phpinterface/init.php`:
use Local\User\BirthdayCouponSender;$birthdayCouponSenderPath = __DIR__ . '/lib/Local/User/BirthdayCouponSender.php';if (file_exists($birthdayCouponSenderPath)) {require_once $birthdayCouponSenderPath;}
Обратите внимание на `fileexists` перед `requireonce`. В `init.php` ошибка подключения файла приводит к фатальной ошибке PHP и падению всего сайта — поэтому проверка существования файла обязательна. `require_once` вместо `require` гарантирует, что класс не будет подключён дважды при возможном повторном включении `init.php`.
Если вы используете автозагрузку через Composer или собственный PSR-4 загрузчик в Битрикс, эту секцию `init.php` можно пропустить — класс будет найден автоматически по пространству имён `Local\User`.
Шаг 2: батчевый cron для автоматической рассылки купонов ко дню рождения
Основной сценарий запуска — ежедневный cron. Скрипт размещается в `/local/cli/birthdaycouponcron.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);
Почему `PHPSAPI !== 'cli'`. Скрипт использует `$argv`, ручное задание `DOCUMENTROOT` и `prologbefore.php` с неполной инициализацией сессий. Запуск через браузер привёл бы к ошибкам и потенциально к несанкционированной выдаче купонов. Проверка `PHPSAPI` на первой строке — стандартная практика для CLI-скриптов Битрикс.
Константы `NOKEEPSTATISTIC` и `BXNOACCELERATOR_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-скрипт `birthdaycouponissue.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`, `birthdaydate`, `activeto`, `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` (константа `DISCOUNTID`). Это самый быстрый путь: один запрос к БД. Если правило найдено, но неактивно или в нём отключены купоны (`USECOUPONS !== 'Y'`) — метод возвращает `null` с диагностическим сообщением.
Уровень 2 — точное совпадение параметров. Если правило с ID 125 не найдено на текущем сайте, происходит поиск всех активных правил с купонами, где `DISCOUNTTYPE = 'P'` (процентная скидка) и `DISCOUNTVALUE = 5.0`. Среди найденных выбирается лучший кандидат через `pickBestDiscount()`.
Уровень 3 — поиск по ключевым словам. Если второй уровень не дал результата, поиск расширяется до всех активных правил с купонами. `pickBestDiscount()` присваивает каждому правилу очки: +100 за каждое ключевое слово из `DISCOUNTNAMEHINTS` (день рождения, birthday, ДР и другие) в поле `NAME` или `XML_ID`, +20 за совпадение процента. Правило с максимальным счётом становится победителем.
private const DISCOUNT_NAME_HINTS = ['день рождения','деньрождения','birthday','birth day','др',];
Почему важен fallback? На боевых проектах правила скидок нередко пересоздаются после импорта или миграции между стендами — ID меняется. Трёхуровневый поиск позволяет системе найти нужное правило даже после таких операций, без ручного вмешательства в код.
Парсинг PERSONAL_BIRTHDAY и проблема 29 февраля в Битрикс
Поле `PERSONALBIRTHDAY` хранится в таблице `buser` как строка и не имеет жёстко заданного формата. На реальных проектах встречаются все возможные варианты в зависимости от истории импортов и форм регистрации. Метод `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`, и купон выйдет в правильный день.
Почтовое событие USERBIRTHDAYCOUPON: настройка в Битрикс
Для отправки письма используется почтовое событие Битрикс. Его нужно создать в разделе «Настройки → Почта → Типы почтовых событий» с именем `USERBIRTHDAYCOUPON` и добавить шаблон письма.
Метод `sendCouponEmail()` передаёт в событие следующие поля:
| Поле | Значение | Описание |
|---|---|---|
| `EMAIL_TO` | email пользователя | Адрес получателя |
| `USER_NAME` | Имя Фамилия или login | Имя для обращения |
| `COUPON` | Код купона | Строка вида `BDAY-A1B2C3` |
| `DISCOUNT_PERCENT` | `5` | Процент скидки |
| `BIRTHDAY_DATE` | `dd.mm.YYYY` | Дата дня рождения |
| `COUPONACTIVETO` | `dd.mm.YYYY` | Последний день действия купона |
| `DAYSBEFOREBIRTHDAY` | `5` | За сколько дней выдаётся купон |
В шаблоне письма обращайтесь к этим полям через синтаксис `#COUPON#`, `#USERNAME#`, `#COUPONACTIVE_TO#`.
Fallback через `mail()`. Если `CEvent::Send()` вернула `false` (событие не настроено или почтовый транспорт недоступен), метод `sendFallbackMail()` отправляет упрощённое текстовое письмо напрямую через `mail()`. Это обеспечивает минимальную отказоустойчивость. В production рекомендуется всегда настраивать событие и шаблон — fallback предназначен только для экстренных ситуаций.
Логирование в JSONL-формате
Каждое действие системы записывается в файл `/local/logs/birthdaycouponsender.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` — купон создан и письмо отправлено
- `couponcreatedwithout_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()` должен использовать оператор точного равенства (префикс `=`).
Скрипт не находит правило скидки
Причина: правило скидки создано не для текущего `SITEID`, или поле `USECOUPONS` установлено в `N`, или правило неактивно.
Решение: проверьте лог — там будет конкретная причина отказа. Войдите в администраторскую панель, откройте правило с ID 125, убедитесь, что оно активно, привязано к нужному сайту и в нём включены купоны. Если правило было пересоздано с другим ID — обновите константу `DISCOUNT_ID` в классе.
Письмо не отправляется
Причина: почтовое событие `USERBIRTHDAYCOUPON` не создано или для него нет активного шаблона.
Решение: создайте тип почтового события в разделе «Настройки → Почта → Типы почтовых событий» и добавьте шаблон с перечисленными выше полями. Проверьте раздел «Настройки → Почта → Очередь почтовых сообщений» — письмо может быть в очереди, а не отправлено. Также убедитесь, что настроен почтовый транспорт (SMTP или sendmail).
Cron падает с ошибкой «prolog_before.php: no such file»
Причина: переменная `DOCUMENT_ROOT` задаётся через `realpath(DIR . '/../..')`, но скрипт запускается из другой рабочей директории, и относительный путь не разрешается корректно.
Решение: используйте абсолютный путь к скрипту в crontab: `php /var/www/html/local/cli/birthdaycouponcron.php`. Проверьте, что `realpath(DIR . '/../..')` на вашем сервере возвращает корень сайта, а не `false`. В случае проблем замените на явный абсолютный путь: `$SERVER['DOCUMENTROOT'] = '/var/www/html';`.
Чеклист проверки после внедрения купонов ко дню рождения
Перед переводом в production пройдитесь по этим шести пунктам:
- [ ] Правило скидки с ID 125 активно, привязано к нужному сайту, купоны включены (`USE_COUPONS = Y`)
- [ ] Почтовое событие `USERBIRTHDAYCOUPON` создано, шаблон активен, все поля в шаблоне написаны верно
- [ ] Тестовый запуск `--no-email` завершился без ошибок: `grep '"level":"error"' /local/logs/birthdaycouponsender.log` — пусто
- [ ] Ручной выпуск `birthdaycouponissue.php --user=YOURTESTID` создал купон и прислал письмо
- [ ] В таблице купонов (Битрикс → Маркетинг → Купоны) появился купон с правильным `DESCRIPTION`, датами и привязкой к нужному правилу
- [ ] Cron-задача зарегистрирована, проверьте `crontab -l` и убедитесь, что путь к PHP и к скрипту верны
Заключение: масштабируемость и дальнейшее развитие
Решение работает стабильно на базах с десятками тысяч пользователей: пагинация через `lastUserId` вместо `OFFSET` исключает деградацию производительности при росте таблицы. Размер пачки и лимит батчей позволяют точно подстроить потребление ресурсов под ограничения вашего хостинга. JSONL-лог совместим с Loki, Elastic и любыми другими агрегаторами логов без дополнительной обработки.
Возможные направления развития: расширить процент скидки в зависимости от сегмента покупателя, добавить SMS-канал через CEvent или сторонний провайдер, ввести настройку `DAYSBEFOREBIRTHDAY` через публичный конструктор вместо константы. Практическое руководство по скидкам и купонам (в т.ч. `DiscountCouponTable::add()`) — в официальной документации Битрикс: Комплекты, наборы и скидки. Полная справка по `CEvent::Send` — в разделе справочника по модулю `main`.