Разработка бота для автоматического обновления остатков на маркетплейсах
Актуальный сток на маркетплейсах — это защита от двух болей: продажи отсутствующего товара (ведёт к отменам, штрафам, снижению рейтинга) и заниженного стока, из-за которого маркетплейс занижает позиции карточки. Бот синхронизирует остатки между вашей системой учёта и маркетплейсами без ручного вмешательства.
Источники данных об остатках
Источников может быть несколько, их нужно агрегировать:
- Складская система (1С, МойСклад, Odoo) — основной источник
- Поставщики — синхронизируются через импорт
- Маркетплейсы — нужно читать остаток, зарезервированный платформой
- Собственный сайт — виртуальный резерв от открытых корзин
1С / МойСклад → (вебхук или polling) → Stock Aggregator → Marketplace API
Схема данных
CREATE TABLE stock_levels (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT REFERENCES products(id),
warehouse_id INT REFERENCES warehouses(id),
quantity INT NOT NULL DEFAULT 0,
reserved INT NOT NULL DEFAULT 0, -- зарезервировано платформами
available INT GENERATED ALWAYS AS (quantity - reserved) STORED,
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE marketplace_stocks (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT REFERENCES products(id),
marketplace VARCHAR(50) NOT NULL,
warehouse_code VARCHAR(100), -- код склада на маркетплейсе
synced_quantity INT,
last_synced_at TIMESTAMP,
sync_status VARCHAR(20) DEFAULT 'ok', -- 'ok', 'error', 'pending'
error_message TEXT,
UNIQUE(product_id, marketplace, warehouse_code)
);
-- Лог изменений для аудита
CREATE TABLE stock_sync_log (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT,
marketplace VARCHAR(50),
old_qty INT,
new_qty INT,
source VARCHAR(50), -- '1c', 'webhook', 'manual'
synced_at TIMESTAMP DEFAULT NOW()
);
Интеграция с Ozon API
class OzonStockSyncer
{
public function syncStocks(array $items): SyncResult
{
// Ozon принимает до 100 позиций за запрос
$result = new SyncResult();
$batches = array_chunk($items, 100);
foreach ($batches as $batch) {
$payload = array_map(fn($item) => [
'offer_id' => $item['sku'],
'stock' => $item['qty'],
'warehouse_id' => $item['warehouse_id'],
], $batch);
$response = Http::withHeaders([
'Client-Id' => $this->clientId,
'Api-Key' => $this->apiKey,
])->post('https://api-seller.ozon.ru/v2/products/stocks', [
'stocks' => $payload,
]);
if (!$response->successful()) {
$result->errors[] = $response->json('message', 'Unknown error');
continue;
}
foreach ($response->json('result', []) as $item) {
if ($item['updated']) {
$result->updated++;
} else {
$result->errors[] = "SKU {$item['offer_id']}: " . ($item['errors'][0]['message'] ?? 'error');
}
}
}
return $result;
}
}
Интеграция с Wildberries
class WildberriesStockSyncer
{
public function syncStocks(array $items, int $warehouseId): SyncResult
{
// WB API v3 — обновление остатков
$payload = array_map(fn($item) => [
'sku' => $item['wb_barcode'], // штрихкод WB, не артикул
'amount' => max(0, $item['qty']),
], $items);
$response = Http::withToken($this->apiKey)
->put("https://marketplace-api.wildberries.ru/api/v3/warehouses/{$warehouseId}/stocks", [
'stocks' => $payload,
]);
if (!$response->successful()) {
throw new WildberriesApiException($response->json('title', 'API Error'));
}
return new SyncResult(updated: count($items));
}
}
Получение остатков из 1С
Через HTTP-сервис 1С (REST):
class OneCStockClient
{
public function getStocks(?Carbon $changedAfter = null): array
{
$params = ['format' => 'json'];
if ($changedAfter) {
$params['changedAfter'] = $changedAfter->toISOString();
}
$response = Http::withBasicAuth($this->user, $this->password)
->timeout(60)
->get("{$this->baseUrl}/hs/stocks/list", $params);
return $response->json('stocks', []);
}
}
Через вебхук (1С пушит изменения):
// routes/api.php
Route::post('/webhooks/1c/stock', [StockWebhookController::class, 'handle'])
->middleware('auth.webhook:1c');
class StockWebhookController extends Controller
{
public function handle(Request $request): JsonResponse
{
$data = $request->validate([
'stocks' => 'required|array',
'stocks.*.sku' => 'required|string',
'stocks.*.quantity' => 'required|integer|min:0',
]);
foreach ($data['stocks'] as $item) {
UpdateStockJob::dispatch($item['sku'], $item['quantity'], 'webhook_1c');
}
return response()->json(['accepted' => count($data['stocks'])]);
}
}
Job синхронизации остатков
class SyncMarketplaceStocksJob implements ShouldQueue
{
public int $timeout = 300;
public function handle(
OzonStockSyncer $ozon,
WildberriesStockSyncer $wb,
): void {
// Получить товары, у которых сток изменился с последней синхронизации
$changed = Product::whereHas('stockChanges', fn($q) =>
$q->where('changed_at', '>', now()->subHour())
)->with('marketplaceSkus')->get();
if ($changed->isEmpty()) return;
// Группировка по маркетплейсу
$ozonItems = $changed->filter(fn($p) => $p->hasMarketplace('ozon'))
->map(fn($p) => [
'sku' => $p->ozon_sku,
'qty' => $p->available_stock,
'warehouse_id' => config('ozon.warehouse_id'),
])->values()->toArray();
if ($ozonItems) {
$result = $ozon->syncStocks($ozonItems);
Log::info("Ozon stock sync: {$result->updated} updated, " . count($result->errors) . " errors");
}
// Аналогично для WB...
}
}
Буферный сток
Часто нужно держать «буфер» — не выгружать весь остаток на маркетплейс, чтобы резервировать для других каналов или страховаться от ошибок:
class StockCalculator
{
public function calculateMarketplaceQty(Product $product, string $marketplace): int
{
$available = $product->available_stock;
// Абсолютный буфер
$buffer = $product->stock_buffer ?? config("marketplaces.{$marketplace}.default_buffer", 2);
// Процентный буфер (например, 10% для WB)
$pctBuffer = (int) ceil($available * config("marketplaces.{$marketplace}.buffer_pct", 0) / 100);
$reserved = max($buffer, $pctBuffer);
$qty = max(0, $available - $reserved);
// Верхний лимит (не выгружать больше N единиц на площадку)
$maxQty = $product->max_marketplace_stock ?? PHP_INT_MAX;
return min($qty, $maxQty);
}
}
Алерты при критических ситуациях
class StockAlertService
{
public function checkCritical(Product $product): void
{
// Остаток на сайте > 0, но на маркетплейсе 0 уже 2+ часа
$marketplaceZero = MarketplaceStock::where('product_id', $product->id)
->where('synced_quantity', 0)
->where('last_synced_at', '<', now()->subHours(2))
->exists();
if ($marketplaceZero && $product->available_stock > 0) {
Notification::send($this->ops, new StockDesyncAlert($product));
}
}
}
Расписание
// Синхронизация каждые 15 минут
$schedule->job(new SyncMarketplaceStocksJob)->everyFifteenMinutes();
// Полная принудительная синхронизация раз в ночь
$schedule->job(new FullStockSyncJob)->dailyAt('02:00');
Сроки реализации
- Ozon API + схема данных + базовый SyncJob: 1–2 дня
- Wildberries API: +1 день
- Интеграция с 1С (polling или webhook): 1–2 дня
- Буферный сток + алерты: 0.5 дня
- Лог синхронизации + дашборд: 0.5 дня
Итого: 4–5 рабочих дней.







