Розроблення системи персональних цін для 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 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);
});
Cache::forget("b2b_prices:{$user->id}");
Для великих каталогів (50k+ SKU) прайс будується в фоновій задачі та зберігається в Redis Hash.
Відображення цін у каталозі
B2B-користувач видит тільки свої ціни, без публічного прайса. Неавторизований користувач видит заглушку «Увійдіть для перегляду цін» чи запит на реєстрацію. Це реалізується middleware:
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:
Artisan::call('prices:import', [
'--file' => '/var/imports/prices_20240115.csv',
'--price-list' => 3,
'--mode' => 'upsert',
]);
Формат 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 на кожному переходе.







