Добавить в корзинуПозвонить
Найти в Дзене

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

Поздравление покупателя с днём рождения — один из самых конверсионных инструментов в email-маркетинге интернет-магазина. По данным исследований Experian, письма с купонами ко дню рождения открывают в 2,5 раза чаще, чем обычные промо-рассылки, а их конверсия в покупку выше в несколько раз. Причина проста: человек уже лоялен к магазину, он ждёт внимания, и персонализированное предложение воспринимается не как спам, а как подарок. Проблема возникает сразу, как только база покупателей вырастает до нескольких тысяч человек. Ручная выдача купонов через панель администратора — это десятки минут ежедневной рутины. Маркетолог должен зайти в Битрикс, найти всех именинников, создать купон, скопировать код и отправить письмо. На практике это либо не делается вовсе, либо делается нерегулярно — и маркетинговый инструмент теряет эффективность. В этой статье я покажу законченное продакшн-решение, которое автоматизирует весь цикл: ежедневный 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

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

  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/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`.