Розроблення бота для автоматичного оновлення залишків на маркетплейсах
Актуальний сток на маркетплейсах — це захист від двох болів: продажи відсутнього товару (веде до відмен, штрафів, зниження рейтингу) і заниженого стоку, через який маркетплейс занижує позиції картки. Бот синхронізує залишки між вашою системою обліку та маркетплейсами без ручного втручання.
Джерела даних про залишки
Джерел може бути кілька, їх потрібно агрегувати:
- Складська система (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 робочих днів.







