Разработка системы групповых покупок на 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 система неизбежно даёт неверные данные при конкурентных запросах.







