Розроблення бота для автоматичного управління цінами на маркетплейсах (Repricing)
Repricing — автоматичне зміна цін на маркетплейсах у відповідь на дії конкурентів або ринкову ситуацію. Мета: утримати позицію в топі пошуку та Buy Box без ручного моніторингу. Неправильно настроєний repricing — це ціна ва до нуля або продажи нижче собівартості. Правильно настроєний — стабільний ріст конверсії при збереженні маржинальності.
Моделі репрайсингу
| Стратегія | Опис | Коли застосовувати |
|---|---|---|
| Min Price | Тримати ціну на рівні найкращого конкурента | Конкурентний ринок без унікальної пропозиції |
| Buy Box | Оптимізувати для перемоги в Buy Box на Ozon/WB | Мультиселлерні позиції |
| Margin Floor | Не опускатися нижче заданої маржи | Завжди, як обмежувач |
| Rule-Based | Набір умов (якщо конкурент < нашої ціни — знизити на X%) | Гнучкі сценарії |
| Demand-Based | Піднести ціну при високому попиту / залишках | Товари з непостійним попитом |
Схема даних
CREATE TABLE repricing_rules (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
marketplace VARCHAR(50) NOT NULL, -- 'ozon', 'wildberries', 'yandex_market'
scope_type VARCHAR(20) NOT NULL, -- 'global', 'category', 'product'
scope_id BIGINT,
strategy VARCHAR(30) NOT NULL, -- 'min_price', 'buy_box', 'rule_based'
min_price_mode VARCHAR(20) DEFAULT 'margin_floor', -- 'fixed' | 'margin_floor'
min_price_value NUMERIC(12,2), -- фіксована мінімальна ціна
min_margin_pct NUMERIC(5,2) DEFAULT 10, -- мінімальна маржа в %
max_price NUMERIC(12,2), -- стеля ціни
step_pct NUMERIC(5,2) DEFAULT 1.0, -- крок зміни в %
step_abs NUMERIC(10,2), -- або крок у гривнях
cooldown_minutes INT DEFAULT 60, -- мінімальний інтервал між змінами
is_active BOOLEAN DEFAULT TRUE
);
CREATE TABLE repricing_log (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT REFERENCES products(id),
marketplace VARCHAR(50),
old_price NUMERIC(12,2),
new_price NUMERIC(12,2),
reason TEXT,
rule_id BIGINT REFERENCES repricing_rules(id),
triggered_at TIMESTAMP DEFAULT NOW()
);
Отримання конкурентних цін
Ozon API — цени конкурентів в Buy Box:
class OzonCompetitorPriceClient
{
public function getCompetitorPrices(string $offerId): array
{
$response = Http::withHeaders([
'Client-Id' => $this->clientId,
'Api-Key' => $this->apiKey,
])->post('https://api-seller.ozon.ru/v1/product/info/competitor-price', [
'offer_id' => $offerId,
]);
return $response->json('result', []);
}
}
Wildberries — аналіз через картку товару:
class WildberriesPriceClient
{
public function getSellerPrices(int $nmId): array
{
// WB API v2 для отримання прайслиста конкурентів на товар
$response = Http::get("https://card.wb.ru/cards/detail", [
'nm' => $nmId,
'spp' => 27,
'curr' => 'rub',
]);
$products = $response->json('data.products', []);
$product = collect($products)->firstWhere('id', $nmId);
return $product ? $product['sizes'] ?? [] : [];
}
}
Движок репрайсингу
class RepricingEngine
{
public function calculateNewPrice(
Product $product,
string $marketplace,
RepricingRule $rule,
): ?PriceDecision {
$competitorData = $this->getCompetitorData($product, $marketplace);
$costPrice = $product->cost_price ?? 0;
$currentPrice = $this->getCurrentMarketplacePrice($product, $marketplace);
$decision = match ($rule->strategy) {
'min_price' => $this->strategyMinPrice($currentPrice, $competitorData, $rule, $costPrice),
'buy_box' => $this->strategyBuyBox($currentPrice, $competitorData, $rule, $costPrice),
'rule_based' => $this->strategyRuleBased($currentPrice, $competitorData, $rule, $costPrice),
default => null,
};
if (!$decision) return null;
// Перевірка cooldown — не менять ціну занадто часто
$lastChange = RepricingLog::where('product_id', $product->id)
->where('marketplace', $marketplace)
->where('triggered_at', '>=', now()->subMinutes($rule->cooldown_minutes))
->exists();
if ($lastChange) return null;
return $decision;
}
private function strategyMinPrice(
float $current, array $competitors, RepricingRule $rule, float $costPrice
): ?PriceDecision {
$competitorMin = collect($competitors)->min('price');
if (!$competitorMin) return null;
$floor = $this->calculateFloor($rule, $costPrice);
// Конкурент дешевше — знизити до його ціни (не нижче floor)
if ($competitorMin < $current) {
$newPrice = max($competitorMin, $floor);
if ($newPrice >= $current) return null; // немає сенсу
return new PriceDecision(
newPrice: $newPrice,
reason: "Конкурент знизив ціну до {$competitorMin}",
);
}
// Конкурент дороже — можна піднести (не вище max_price)
if ($competitorMin > $current && $rule->max_price && $current < $rule->max_price) {
$newPrice = min($competitorMin - 1, $rule->max_price);
return new PriceDecision(
newPrice: $newPrice,
reason: "Конкурент піднув ціну до {$competitorMin}",
);
}
return null;
}
private function calculateFloor(RepricingRule $rule, float $costPrice): float
{
if ($rule->min_price_mode === 'fixed' && $rule->min_price_value) {
return $rule->min_price_value;
}
if ($rule->min_margin_pct && $costPrice > 0) {
return $costPrice * (1 + $rule->min_margin_pct / 100);
}
return 0;
}
}
Публікація ціни через API
class OzonPricePublisher
{
public function setPrice(string $offerId, float $newPrice): bool
{
$response = Http::withHeaders([
'Client-Id' => $this->clientId,
'Api-Key' => $this->apiKey,
])->post('https://api-seller.ozon.ru/v1/product/import/prices', [
'prices' => [[
'offer_id' => $offerId,
'price' => (string) $newPrice,
'old_price' => '0',
'premium_price' => '0',
'price_strategy_enabled' => false,
]],
]);
return $response->successful()
&& collect($response->json('result', []))->first()['updated'] === true;
}
}
Захист від ціневих воєн
Ціна вйна — ситуація, коли два конкуренти нескінченно знижують ціни один одному. Механізми захисту:
class PriceWarDetector
{
public function isWarring(int $productId, string $marketplace): bool
{
// Якщо ціна змінювалася більше 5 разів за 24 години — ознака води
$changes = RepricingLog::where('product_id', $productId)
->where('marketplace', $marketplace)
->where('triggered_at', '>=', now()->subDay())
->count();
if ($changes >= 5) {
// Зупинити repricing на 6 годин і сповістити
Cache::put("repricing.paused.{$productId}.{$marketplace}", true, now()->addHours(6));
Notification::send($this->admins, new PriceWarAlert($productId, $marketplace));
return true;
}
return false;
}
}
Моніторинг та звіти
-- Статистика змін цін за день
SELECT
p.name,
rl.marketplace,
COUNT(*) AS changes_count,
MIN(rl.new_price) AS min_price_today,
MAX(rl.new_price) AS max_price_today,
ROUND(AVG(rl.new_price), 2) AS avg_price_today
FROM repricing_log rl
JOIN products p ON p.id = rl.product_id
WHERE rl.triggered_at >= NOW() - INTERVAL '24 hours'
GROUP BY p.name, rl.marketplace
ORDER BY changes_count DESC;
Розписання
// Запуск репрайсингу кожні 30 хвилин
$schedule->job(new RunRepricingJob)->everyThirtyMinutes();
// Вночі — скидання лічильників і пересчёт стратегій
$schedule->job(new ResetRepricingCountersJob)->dailyAt('03:00');
Графік реалізації
- Схема даних + движок базових стратегій: 2 дні
- Integracia з Ozon API (ціни конкурентів + публікація): 1–2 дні
- Wildberries API: +1 день
- PriceWarDetector + cooldown + alert: 1 день
- Інтерфейс управління правилами + лог змін: 1–2 дні
Разом: 6–8 робочих днів.







