Битрикс: поиск правила скидки с fallback по имени и значению
Метод resolveDiscount() для класса BirthdayCouponSender: поиск активного правила скидки сначала по фиксированному ID, затем по точному совпадению процента и USE_COUPONS, затем по ключевым словам в NAME/XML_ID.
<?php
use Bitrix\Sale\Internals\DiscountTable;
// Константы класса BirthdayCouponSender
private const DISCOUNT_ID = 125;
private const DISCOUNT_PERCENT = 5.0;
private const DISCOUNT_NAME_HINTS = [
'день рождения',
'деньрождения',
'birthday',
'birth day',
'др',
];
private static string $lastResolveDiscountError = '';
private static function resolveDiscount(): ?array
{
self::$lastResolveDiscountError = '';
$fixedDiscount = DiscountTable::getList([
'select' => ['ID', 'NAME', 'XML_ID', 'LID', 'DISCOUNT_VALUE', 'DISCOUNT_TYPE', 'SORT', 'ACTIVE', 'USE_COUPONS'],
'filter' => [
'=ID' => self::DISCOUNT_ID,
'=LID' => SITE_ID,
],
'limit' => 1,
])->fetch();
if (is_array($fixedDiscount)) {
if ((string)($fixedDiscount['ACTIVE'] ?? 'N') !== 'Y') {
self::$lastResolveDiscountError = 'Правило скидки ID=125 найдено, но оно неактивно.';
self::log('skip', [
'reason' => 'birthday discount exists but is inactive',
'discount_id' => self::DISCOUNT_ID,
]);
return null;
}
if ((string)($fixedDiscount['USE_COUPONS'] ?? 'N') !== 'Y') {
self::$lastResolveDiscountError = 'Правило скидки ID=125 найдено, но в нем отключены купоны.';
self::log('skip', [
'reason' => 'birthday discount exists but coupons are disabled',
'discount_id' => self::DISCOUNT_ID,
]);
return null;
}
return $fixedDiscount;
}
$exactCandidates = self::loadDiscountCandidates([
'=ACTIVE' => 'Y',
'=USE_COUPONS' => 'Y',
'=LID' => SITE_ID,
'=DISCOUNT_TYPE' => 'P',
'=DISCOUNT_VALUE' => self::DISCOUNT_PERCENT,
]);
$discount = self::pickBestDiscount($exactCandidates);
if ($discount !== null) {
return $discount;
}
$fallbackCandidates = self::loadDiscountCandidates([
'=ACTIVE' => 'Y',
'=USE_COUPONS' => 'Y',
'=LID' => SITE_ID,
]);
$discount = self::pickBestDiscount($fallbackCandidates);
if ($discount !== null) {
return $discount;
}
self::$lastResolveDiscountError = 'Правило скидки ID=125 не найдено для сайта или недоступно в текущей конфигурации.';
return null;
}
private static function loadDiscountCandidates(array $filter): array
{
return DiscountTable::getList([
'select' => ['ID', 'NAME', 'XML_ID', 'LID', 'DISCOUNT_VALUE', 'DISCOUNT_TYPE', 'SORT'],
'filter' => $filter,
'order' => ['SORT' => 'ASC', 'ID' => 'ASC'],
])->fetchAll();
}
private static function pickBestDiscount(array $discounts): ?array
{
$bestDiscount = null;
$bestScore = 0;
foreach ($discounts as $discount) {
$haystack = mb_strtolower(
trim((string)($discount['NAME'] ?? '')) . ' ' . trim((string)($discount['XML_ID'] ?? ''))
);
$score = 0;
foreach (self::DISCOUNT_NAME_HINTS as $hint) {
if ($hint !== '' && mb_stripos($haystack, $hint) !== false) {
$score += 100;
}
}
if ((string)($discount['DISCOUNT_TYPE'] ?? '') === 'P'
&& (float)($discount['DISCOUNT_VALUE'] ?? 0) === self::DISCOUNT_PERCENT) {
$score += 20;
}
if ($score > $bestScore) {
$bestScore = $score;
$bestDiscount = $discount;
}
}
return $bestDiscount;
}
Как работает трёхуровневый fallback при поиске скидки
На первом уровне resolveDiscount() выполняет прямой запрос к DiscountTable по константе DISCOUNT_ID = 125. Это самый быстрый путь — один запрос с фильтром по ID и SITE_ID. Если правило найдено, дополнительно проверяется его активность (ACTIVE = 'Y') и наличие купонов (USE_COUPONS = 'Y'). Если хотя бы одно условие нарушено, метод возвращает null с информативным сообщением в $lastResolveDiscountError — это позволяет вызывающему коду показать точную причину отказа.
На втором уровне, если правило с фиксированным ID не найдено на текущем сайте (например, после переноса базы или пересоздания правила), поиск расширяется до всех активных правил с купонами, у которых тип скидки — процентный (DISCOUNT_TYPE = 'P') и значение равно DISCOUNT_PERCENT. Среди результатов метод pickBestDiscount() выбирает кандидата с наибольшим числом совпадений по ключевым словам в полях NAME и XML_ID. Каждое вхождение хинта из DISCOUNT_NAME_HINTS добавляет 100 очков, совпадение процентного значения — ещё 20.
На третьем уровне фильтр снимается до всех активных правил с купонами без ограничения по значению скидки. Этот уровень работает исключительно через ключевые слова. Если ни один уровень не дал результата, $lastResolveDiscountError содержит итоговую диагностическую строку, которая записывается в лог.
Где применять этот подход
Трёхуровневый поиск особенно полезен в модулях для нескольких клиентов, где ID правил скидок различаются на каждом проекте. Метод pickBestDiscount() можно переиспользовать для поиска других типов правил: акционных скидок, накопительных программ, скидок для групп пользователей. Достаточно передать другой набор хинтов и другое значение DISCOUNT_PERCENT.