Разработка системы скидок и акций для интернет-магазина
Система скидок — это не просто «зачёркнутая цена». Это движок, который управляет многоуровневыми правилами: акционными ценами, скидками по объёму, временными распродажами, комбинированными условиями. Неправильная реализация ведёт к конфликтам правил, некорректным итогам и дырам в марже. Разработка полноценной системы скидок занимает 5–9 рабочих дней.
Типы скидок и их приоритет
В любом магазине сосуществуют несколько видов скидок. Порядок применения критически важен:
-
Акционная цена товара (
sale_priceна уровне SKU) — базовая, применяется всегда - Скидка по категории — процент на все товары категории в период акции
- Скидка за объём — при покупке N единиц цена снижается
- Скидка по сегменту пользователя — VIP-клиенты, оптовики
- Промокод — поверх уже применённых скидок (или вместо них — зависит от правила)
Правило стекирования задаётся на уровне каждой акции: exclusive (не совмещается с другими), stackable (суммируется), override (отменяет остальные).
Модель данных акций
CREATE TABLE promotions (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
type VARCHAR(30) NOT NULL,
-- 'product_discount', 'category_discount', 'volume_discount',
-- 'bundle', 'bogo', 'tiered'
priority INT DEFAULT 0,
stackable BOOLEAN DEFAULT FALSE,
conditions JSONB DEFAULT '{}',
actions JSONB DEFAULT '{}',
starts_at TIMESTAMP,
ends_at TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE
);
conditions и actions в JSONB позволяют задавать произвольные правила без изменения схемы:
{
"conditions": {
"min_qty": 3,
"product_ids": [101, 102, 103],
"user_segments": ["wholesale"]
},
"actions": {
"type": "percent_discount",
"value": 15,
"applies_to": "matching_items"
}
}
Движок применения скидок
Класс DiscountEngine последовательно применяет правила к корзине:
class DiscountEngine
{
/** @var PromotionRule[] */
private array $rules = [];
public function __construct(Collection $activePromotions)
{
foreach ($activePromotions->sortByDesc('priority') as $promo) {
$this->rules[] = PromotionRuleFactory::make($promo);
}
}
public function apply(Cart $cart): DiscountResult
{
$result = new DiscountResult($cart);
foreach ($this->rules as $rule) {
if (!$rule->matches($cart)) continue;
if (!$rule->isStackable() && $result->hasDiscount()) continue;
$result->addDiscount($rule->calculate($cart));
if ($rule->isExclusive()) break;
}
return $result;
}
}
Скидка за объём (volume pricing)
Ступенчатые цены за количество — частый запрос в B2C и особенно в B2B:
class VolumePricingRule implements PromotionRule
{
public function calculate(Cart $cart): array
{
$discounts = [];
foreach ($cart->items as $item) {
$tier = $this->getTier($item->product_id, $item->quantity);
if ($tier) {
$discounts[] = [
'item_id' => $item->id,
'amount' => ($item->price - $tier->price) * $item->quantity,
'label' => "Скидка за объём (×{$item->quantity})",
];
}
}
return $discounts;
}
private function getTier(int $productId, int $qty): ?VolumeTier
{
return VolumeTier::where('product_id', $productId)
->where('min_qty', '<=', $qty)
->orderByDesc('min_qty')
->first();
}
}
Акционные периоды с таймером
Для «горящих» акций на фронтенде отображается таймер обратного отсчёта. Текущая акционная цена передаётся из API вместе с временем окончания:
interface PriceData {
regular_price: number;
sale_price: number | null;
sale_ends_at: string | null; // ISO datetime
}
Компонент таймера:
const SaleTimer = ({ endsAt }: { endsAt: string }) => {
const [remaining, setRemaining] = useState(differenceInSeconds(parseISO(endsAt), new Date()));
useEffect(() => {
const interval = setInterval(() => {
setRemaining(prev => Math.max(0, prev - 1));
}, 1000);
return () => clearInterval(interval);
}, []);
const hours = Math.floor(remaining / 3600);
const minutes = Math.floor((remaining % 3600) / 60);
const seconds = remaining % 60;
return (
<div className="flex gap-1 font-mono text-red-600">
<span>{String(hours).padStart(2, '0')}</span>:
<span>{String(minutes).padStart(2, '0')}</span>:
<span>{String(seconds).padStart(2, '0')}</span>
</div>
);
};
BOGO (Buy One Get One)
Акция «купи 2, получи 1 в подарок» реализуется отдельным правилом:
class BogoRule implements PromotionRule
{
public function calculate(Cart $cart): array
{
$qualifying = $cart->items->filter(fn($i) => in_array($i->product_id, $this->productIds));
$totalQty = $qualifying->sum('quantity');
$freeQty = intdiv($totalQty, $this->buyQty); // 1 бесплатный за каждые N купленных
// Берём самые дешёвые позиции как бесплатные
$cheapestPrice = $qualifying->min('price');
return [['amount' => $cheapestPrice * $freeQty, 'label' => 'Акция 2+1']];
}
}
Отображение скидок на странице товара и в каталоге
- Зачёркнутая оригинальная цена рядом с акционной
- Бейдж «−20%» или «−500 ₽» — в зависимости от типа скидки
- Для volume pricing — таблица ступенчатых цен на карточке товара
- Фильтр «Только акционные» в каталоге — SQL-запрос с
WHERE sale_price IS NOT NULL AND sale_ends_at > NOW()
Планировщик акций
Акции стартуют и завершаются автоматически через Laravel Scheduler:
// App\Console\Kernel
$schedule->command('promotions:activate')->everyMinute();
$schedule->command('promotions:expire')->everyMinute();
При активации акции кеш актуальных цен инвалидируется. Для крупных каталогов (10k+ SKU) инвалидация выполняется через очередь, а не синхронно — иначе spike нагрузки на Redis.
Отчётность по акциям
Admin-панель показывает по каждой акции: количество применений, общую сумму скидок, средний чек с акцией vs без, топ товаров в акции по выручке. Это минимально необходимые данные для оценки эффективности и принятия решений о повторном запуске.







