Разработка системы персональных цен для B2B интернет-магазина
B2B-магазин принципиально отличается от B2C: каждый контрагент работает по индивидуальным условиям — своей ценовой группе, персональным скидкам на SKU, договорным ценам, отсрочкам платежа. Публичного прайса может не существовать вовсе. Разработка полноценной B2B-ценовой системы — одна из наиболее трудоёмких задач в e-commerce: от 10 до 20 рабочих дней в зависимости от глубины интеграции с ERP.
Архитектура ценообразования
Система строится вокруг нескольких концепций:
- Price list (прайс-лист) — набор цен для группы клиентов
- Customer group — сегмент: розница, оптовик, дилер, VIP
- Contract price — индивидуальная цена для конкретного контрагента на конкретный SKU
- Volume tier — ступенчатая скидка за объём
- Customer discount — персональная скидка в процентах поверх прайс-листа
Приоритет применения цен (от высшего к низшему):
- Contract price (персональная договорная цена на SKU)
- Customer group price list
- Volume tier из прайс-листа
- Base retail price
Схема базы данных
-- Прайс-листы
CREATE TABLE price_lists (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255),
currency VARCHAR(3) DEFAULT 'RUB',
is_default BOOLEAN DEFAULT FALSE
);
-- Цены в прайс-листе
CREATE TABLE price_list_items (
id BIGSERIAL PRIMARY KEY,
price_list_id BIGINT REFERENCES price_lists(id) ON DELETE CASCADE,
product_id BIGINT NOT NULL,
variant_id BIGINT,
price NUMERIC(14,4) NOT NULL,
min_qty INT DEFAULT 1
);
CREATE UNIQUE INDEX idx_pli_product_qty
ON price_list_items(price_list_id, product_id, COALESCE(variant_id, 0), min_qty);
-- Привязка групп к прайс-листу
CREATE TABLE customer_groups (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255),
price_list_id BIGINT REFERENCES price_lists(id),
discount_percent NUMERIC(5,2) DEFAULT 0
);
-- Персональные цены контрагентов
CREATE TABLE customer_prices (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
product_id BIGINT NOT NULL,
variant_id BIGINT,
price NUMERIC(14,4) NOT NULL,
min_qty INT DEFAULT 1,
valid_from DATE,
valid_to DATE
);
Резолвер цены
Ключевой класс, вычисляющий итоговую цену для конкретного пользователя:
class B2BPriceResolver
{
public function resolve(int $productId, int $qty, User $customer): PriceResult
{
// 1. Персональная договорная цена
$contractPrice = CustomerPrice::where('user_id', $customer->id)
->where('product_id', $productId)
->where('min_qty', '<=', $qty)
->where(fn($q) => $q->whereNull('valid_from')->orWhere('valid_from', '<=', today()))
->where(fn($q) => $q->whereNull('valid_to')->orWhere('valid_to', '>=', today()))
->orderByDesc('min_qty')
->first();
if ($contractPrice) {
return new PriceResult($contractPrice->price, 'contract');
}
// 2. Прайс-лист группы с volume tier
$group = $customer->customerGroup;
if ($group?->priceList) {
$tier = PriceListItem::where('price_list_id', $group->price_list_id)
->where('product_id', $productId)
->where('min_qty', '<=', $qty)
->orderByDesc('min_qty')
->first();
if ($tier) {
$price = $tier->price;
if ($group->discount_percent > 0) {
$price *= (1 - $group->discount_percent / 100);
}
return new PriceResult(round($price, 4), 'price_list');
}
}
// 3. Базовая цена
$product = Product::find($productId);
return new PriceResult($product->price, 'retail');
}
}
Кеширование цен
Резолвер вызывается при каждом отображении каталога — для 100 товаров на странице это 100 вызовов. Без кеша это катастрофа производительности.
Стратегия: кешировать персональный прайс-лист пользователя целиком при логине, инвалидировать при изменении его ценовых условий:
// Кеш персонального прайса
$cacheKey = "b2b_prices:{$user->id}";
$prices = Cache::remember($cacheKey, 3600, function () use ($user) {
return $this->buildUserPriceMap($user); // все цены для всех SKU
});
// Инвалидация при обновлении условий
Cache::forget("b2b_prices:{$user->id}");
Для крупных каталогов (50k+ SKU) прайс строится в фоновой задаче и сохраняется в Redis Hash.
Отображение цен в каталоге
B2B-пользователь видит только свои цены, без публичного прайса. Неавторизованный пользователь видит заглушку «Войдите для просмотра цен» или запрос на регистрацию. Это реализуется middleware:
// React: условный рендер цены
const PriceDisplay = ({ product }: { product: Product }) => {
const { user } = useAuth();
if (!user) return <RequestAccessButton />;
if (!product.b2b_price) return <PriceOnRequest />;
return (
<div>
<span className="text-lg font-bold">{formatPrice(product.b2b_price)}</span>
{product.b2b_source === 'contract' && (
<span className="text-xs text-green-600 ml-2">Договорная цена</span>
)}
</div>
);
};
Управление ценами в admin-панели
Admin-панель обеспечивает:
- Создание и редактирование прайс-листов с массовым импортом через CSV/XLSX
- Назначение клиентов на группы
- Установку персональных цен на SKU с датами действия
- Предпросмотр цены: «Какую цену видит клиент X на товар Y?»
- Журнал изменений цен с указанием автора и времени
Импорт из ERP
Для синхронизации с 1С, SAP или другой ERP реализуется API-приёмник или file-watcher:
// Команда импорта прайса из CSV
Artisan::call('prices:import', [
'--file' => '/var/imports/prices_20240115.csv',
'--price-list' => 3,
'--mode' => 'upsert', // или 'replace'
]);
Формат CSV: sku,price,min_qty,valid_from,valid_to. Импорт через очередь, результат — отчёт с количеством добавленных/обновлённых/пропущенных позиций.
Мультивалютность
Если B2B работает с несколькими валютами, прайс-листы хранятся в валюте контракта. Конвертация в валюту отображения по курсу ЦБ (кешируется на 1 час):
$displayPrice = $price * ExchangeRateService::getRate($priceList->currency, 'RUB');
Курс фиксируется в момент создания заказа — изменение курса между просмотром каталога и оплатой должно быть явно обозначено на checkout.
Согласование цен (price negotiation)
Для enterprise-сегмента реализуем flow запроса цены: клиент запрашивает персональное предложение, менеджер устанавливает договорную цену, клиент видит её в своём каталоге. Статусы: requested → under_review → approved → active. Уведомления по email на каждом переходе.







