Разработка бота для автоматического управления ценами на маркетплейсах (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 дня
- Ozon API интеграция (цены конкурентов + публикация): 1–2 дня
- Wildberries API: +1 день
- PriceWarDetector + cooldown + алерты: 1 день
- Интерфейс управления правилами + лог изменений: 1–2 дня
Итого: 6–8 рабочих дней.







