Розроблення системи скидок та акцій для інтернет-магазину
Система скидок — це не просто «зачеркнута ціна». Це рухавик, який управляє багаторівневими правилами: акційними цінами, скидками по об'єму, часовими розпродажами, комбінованими умовами. Неправильна реалізація ведить до конфліктів правил, некоректних итогів та дир у марж. Розроблення повноцінної системи скидок займає 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,
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 {
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;
}
}
Скидка за об'єм
Ступінчасті ціни за кількість — частий запит у 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();
}
}
Акційні періоди з таймером
Для «гарячих» акцій на фронтенді відображається таймер зворотного отліку. Поточна акційна ціна передається з часом закінчення:
interface PriceData {
regular_price: number;
sale_price: number | null;
sale_ends_at: string | null;
}
Компонент таймера:
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);
$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:
$schedule->command('promotions:activate')->everyMinute();
$schedule->command('promotions:expire')->everyMinute();
При активації акції кеш актуальних цін інвалідується. Для великих каталогів (10k+ SKU) інвалідація виконується через чергу, а не синхронно.
Відчітність по акціях
Admin-панель показує по кожній акції: кількість застосувань, загальну суму скидок, конверсію з акцією vs без, топ товарів в акції по виручці. Це мінімально необхідні дані для оцінки ефективності та прийняття рішень про повторний запуск.







