Marketplace Stock Auto-Update Bot Development
Actual stock on marketplaces protects from two pain points: selling non-existent products (leads to cancellations, fines, rating drop) and understated stock that causes the marketplace to lower card rankings. A bot synchronizes inventory between your system and marketplaces without manual intervention.
Stock Data Sources
Multiple sources need aggregation:
- Warehouse system (1C, МойСклад, Odoo)—primary source
- Suppliers—synchronized via import
- Marketplaces—read stock reserved by platform
- Own website—virtual reserve from open carts
1C / МойСклад → (webhook or polling) → Stock Aggregator → Marketplace API
Data Schema
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, -- reserved by platforms
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), -- warehouse code on marketplace
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)
);
-- Audit log
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 Integration
class OzonStockSyncer
{
public function syncStocks(array $items): SyncResult
{
// Ozon accepts up to 100 items per request
$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 Integration
class WildberriesStockSyncer
{
public function syncStocks(array $items, int $warehouseId): SyncResult
{
// WB API v3—stock update
$payload = array_map(fn($item) => [
'sku' => $item['wb_barcode'], // WB barcode, not SKU
'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));
}
}
Getting Stock from 1C
Via 1C HTTP service (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', []);
}
}
Via webhook (1C pushes changes):
// 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'])]);
}
}
Stock Sync Job
class SyncMarketplaceStocksJob implements ShouldQueue
{
public int $timeout = 300;
public function handle(
OzonStockSyncer $ozon,
WildberriesStockSyncer $wb,
): void {
// Get products with stock changes since last sync
$changed = Product::whereHas('stockChanges', fn($q) =>
$q->where('changed_at', '>', now()->subHour())
)->with('marketplaceSkus')->get();
if ($changed->isEmpty()) return;
// Group by marketplace
$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");
}
// Similarly for WB...
}
}
Buffer Stock
Often need to keep a "buffer"—don't upload all inventory to marketplace, reserving for other channels or as insurance:
class StockCalculator
{
public function calculateMarketplaceQty(Product $product, string $marketplace): int
{
$available = $product->available_stock;
// Absolute buffer
$buffer = $product->stock_buffer ?? config("marketplaces.{$marketplace}.default_buffer", 2);
// Percentage buffer (e.g., 10% for WB)
$pctBuffer = (int) ceil($available * config("marketplaces.{$marketplace}.buffer_pct", 0) / 100);
$reserved = max($buffer, $pctBuffer);
$qty = max(0, $available - $reserved);
// Upper limit (don't upload more than N units to marketplace)
$maxQty = $product->max_marketplace_stock ?? PHP_INT_MAX;
return min($qty, $maxQty);
}
}
Critical Situation Alerts
class StockAlertService
{
public function checkCritical(Product $product): void
{
// Stock on site > 0, but on marketplace 0 for 2+ hours
$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));
}
}
}
Schedule
// Sync every 15 minutes
$schedule->job(new SyncMarketplaceStocksJob)->everyFifteenMinutes();
// Full forced sync nightly
$schedule->job(new FullStockSyncJob)->dailyAt('02:00');
Timeline
- Ozon API + data schema + basic SyncJob: 1–2 days
- Wildberries API: +1 day
- 1C integration (polling or webhook): 1–2 days
- Buffer stock + alerts: 0.5 days
- Sync log + dashboard: 0.5 days
Total: 4–5 working days.







