Розробка системи групових покупок на 1С-Бітрикс
Групова покупка — механізм, при якому ціна на товар знижується по мірі зростання числа учасників. Коробочний Бітрикс цього не вміє: модуль sale оперує індивідуальними замовленнями, а механізм скидок b_catalog_discount не прив'язаний до лічильника учасників в режимі реального часу. Вся логіка пишеться поверх стандартної архітектури.
Модель даних
Система будується навколо сутності «акція групової покупки». Зручніше всього зберігати її в окремому HL-блоці або кастомній таблиці.
Таблиця b_group_deal:
CREATE TABLE b_group_deal (
ID INT AUTO_INCREMENT PRIMARY KEY,
PRODUCT_ID INT NOT NULL, -- ID елемента інфоблока / торговельної пропозиції
DATE_START DATETIME NOT NULL,
DATE_END DATETIME NOT NULL,
MIN_PARTICIPANTS INT NOT NULL, -- мінімум для активації скидки
MAX_PARTICIPANTS INT, -- обмеження учасників (NULL = без ліміту)
CURRENT_COUNT INT DEFAULT 0,
STATUS ENUM('active','success','failed','closed') DEFAULT 'active',
INDEX idx_product (PRODUCT_ID),
INDEX idx_status_date (STATUS, DATE_END)
);
Таблиця b_group_deal_tier — шкала скидок:
CREATE TABLE b_group_deal_tier (
ID INT AUTO_INCREMENT PRIMARY KEY,
DEAL_ID INT NOT NULL,
PARTICIPANTS_FROM INT NOT NULL, -- від N учасників
DISCOUNT_PERCENT DECIMAL(5,2), -- скидка в %
PRICE_FIXED DECIMAL(10,2), -- або фіксована ціна
FOREIGN KEY (DEAL_ID) REFERENCES b_group_deal(ID)
);
Таблиця b_group_deal_participant — учасники:
CREATE TABLE b_group_deal_participant (
ID INT AUTO_INCREMENT PRIMARY KEY,
DEAL_ID INT NOT NULL,
USER_ID INT NOT NULL,
ORDER_ID INT, -- NULL поки замовлення не підтверджено
DATE_ADD DATETIME NOT NULL,
STATUS ENUM('waiting','paid','cancelled','refunded')
);
Лічильник CURRENT_COUNT оновлюється тільки за статусом paid — попередні участі без оплати не рахуються.
Логіка участі та синхронізація лічильника
Головна інженерна проблема — race condition при одночасному приєднанні учасників. Якщо два клієнти одночасно читають CURRENT_COUNT = 9 при MIN_PARTICIPANTS = 10, обидва можуть стати «активуючим» учасником.
Захист — атомарне оновлення:
use Bitrix\Main\Application;
$connection = Application::getConnection();
$connection->startTransaction();
try {
// Блокуємо рядок акції
$row = $connection->query(
"SELECT * FROM b_group_deal WHERE ID = {$dealId} AND STATUS = 'active' FOR UPDATE"
)->fetch();
if (!$row || strtotime($row['DATE_END']) < time()) {
$connection->rollbackTransaction();
return ['error' => 'Deal not available'];
}
// Вставляємо учасника
$connection->query(
"INSERT INTO b_group_deal_participant (DEAL_ID, USER_ID, DATE_ADD, STATUS)
VALUES ({$dealId}, {$userId}, NOW(), 'waiting')"
);
// Інкрементуємо лічильник
$connection->query(
"UPDATE b_group_deal SET CURRENT_COUNT = CURRENT_COUNT + 1 WHERE ID = {$dealId}"
);
$connection->commitTransaction();
} catch (\Exception $e) {
$connection->rollbackTransaction();
throw $e;
}
Після приєднання учасник отримує статус waiting — він бачить поточний прогрес, але замовлення ще не оформлено. Оплата відбувається за двома сценаріями:
Сценарій A — Передоплата: учасник одразу оформляє замовлення та платить. Якщо акція не набирає MIN_PARTICIPANTS до DATE_END, гроші повертаються автоматично через обработчик агента.
Сценарій B — Відкладений замовлення: учасник «бронює» місце без оплати. При досягненні мінімуму всім учасникам йде сповіщення з пропозицією оформити замовлення за зниженою ціною. Дедлайн — 24–48 годин.
Ціноутворення та скидки
Поточна скидка розраховується динамічно за таблицею b_group_deal_tier. Не можна використовувати стандартну систему скидок b_catalog_discount — вона не вміє працювати з динамічним лічильником учасників.
Розрахунок активного тиру:
function getActiveTier(int $dealId, int $currentCount): ?array
{
$connection = Application::getConnection();
return $connection->query(
"SELECT * FROM b_group_deal_tier
WHERE DEAL_ID = {$dealId} AND PARTICIPANTS_FROM <= {$currentCount}
ORDER BY PARTICIPANTS_FROM DESC
LIMIT 1"
)->fetch() ?: null;
}
При додаванні в кошик ціна підставляється через обработчик OnSaleBasketItemRefreshData — перехоплюємо перерахунок кошика та підставляємо ціну з активного тиру замість каталожної.
Візуальна прогрес-смуга
Компонент прогресу — AJAX-віджет, оновлюваний кожні 30 секунд. Дані видає контролер:
// /local/ajax/group-deal-status.php
$deal = $connection->query(
"SELECT gd.*, gt.DISCOUNT_PERCENT, gt.PARTICIPANTS_FROM as NEXT_TIER
FROM b_group_deal gd
LEFT JOIN b_group_deal_tier gt ON gt.DEAL_ID = gd.ID
AND gt.PARTICIPANTS_FROM > gd.CURRENT_COUNT
WHERE gd.ID = {$dealId}
ORDER BY gt.PARTICIPANTS_FROM ASC
LIMIT 1"
)->fetch();
header('Content-Type: application/json');
echo json_encode([
'current' => (int)$deal['CURRENT_COUNT'],
'next_tier' => (int)$deal['NEXT_TIER'],
'discount' => (float)$deal['DISCOUNT_PERCENT'],
'time_left' => strtotime($deal['DATE_END']) - time(),
]);
Прогрес-смуга рахує відсоток current / next_tier * 100 та показує, скільки учасників потрібно до наступної ступені скидки.
Завершення акції та повернення
Агент CAgent запускається кожні 5 хвилин та перевіряє акції з сплилим DATE_END:
- Якщо
CURRENT_COUNT >= MIN_PARTICIPANTS→ статусsuccess. Всім учасникам зі статусомwaitingйде завдання оформити замовлення (або автоматично створюється замовлення за мінімальною ціною тиру). - Якщо
CURRENT_COUNT < MIN_PARTICIPANTS→ статусfailed. Учасникам зі статусомpaidвиконується повернення через\Bitrix\Sale\PaySystem\Manager::refund()або REST-запит до платіжної системи.
Сповіщення — через \Bitrix\Main\Mail\Event::send() з кастомними поштовими шаблонами типу GROUP_DEAL_SUCCESS, GROUP_DEAL_FAILED.
Строки реалізації
| Масштаб | Функціонал | Строк |
|---|---|---|
| MVP (одна акція, один ступінь скидки, ручне управління) | HL-блок + обработчики + AJAX-лічильник | 1–1.5 тижня |
| Повна система (тиру, агенти, повернення, ЛК учасника) | Кастомні таблиці + транзакції + модуль + поштові шаблони | 2–3 тижні |
| Маркетплейс акцій (кілька постачальників, вітрина) | Повноцінний модуль з адміністративним інтерфейсом + API | 4–5 тижнів |
Критичні моменти — атомарність при оновленні лічильника та коректність повернень при провалі акції. Без транзакцій та FOR UPDATE система неминуче дає неправильні дані при конкурентних запитах.







