Синхронизация остатков и цен между сайтом и маркетплейсами
Когда магазин продаёт одновременно через сайт и несколько маркетплейсов, управление остатками становится критичной задачей: продали на сайте — нужно снизить остаток на Ozon и Wildberries; пришёл новый товар — поднять везде. Ценовая синхронизация обеспечивает ценовой паритет или наценку на маркетплейсе.
Архитектура системы
Источник правды (Основной склад / Сайт)
↓
Stock Manager Service
├── Резервирование при заказе на сайте
├── Освобождение при отмене
└── Пополнение при приёмке товара
↓
Sync Queue (Redis)
↓
Marketplace Workers
├── Ozon Worker → Ozon API
├── WB Worker → WB API
└── YM Worker → Яндекс.Маркет API
Расчёт доступного остатка
class StockCalculator
{
public function getAvailableForMarketplace(int $productId, string $marketplace): int
{
$product = Product::with(['reservations', 'warehouseItems'])->findOrFail($productId);
$totalStock = $product->warehouseItems->sum('quantity');
$reservedSite = $product->reservations()->where('source', 'site')->sum('quantity');
$reservedOther = $product->reservations()->where('source', '!=', $marketplace)->sum('quantity');
// Для маркетплейса доступно не более 80% свободного остатка
$available = $totalStock - $reservedSite - $reservedOther;
return max(0, (int)($available * 0.8));
}
}
Коэффициент 0.8 — защита от ситуации, когда несколько маркетплейсов видят полный остаток и одновременно принимают заказы.
Очередь синхронизации
class StockSyncQueue
{
public function enqueue(int $productId): void
{
// Дедупликация: если уже в очереди — обновляем таймер
Redis::setex("sync:pending:{$productId}", 30, 1);
}
public function processQueue(): void
{
// Batching: собираем все изменения за 30 секунд и отправляем батчем
$keys = Redis::keys('sync:pending:*');
$productIds = array_map(fn($k) => (int)explode(':', $k)[2], $keys);
if (empty($productIds)) return;
Redis::del($keys);
$this->syncToMarketplaces($productIds);
}
}
Ценовая синхронизация
class PriceSyncService
{
private array $marketplacePriceRules = [
'ozon' => ['type' => 'markup', 'value' => 5.0], // +5%
'wb' => ['type' => 'markup', 'value' => 7.0], // +7%
'ym' => ['type' => 'fixed', 'value' => 0], // без наценки
];
public function calculateMarketplacePrice(float $basePrice, string $marketplace): float
{
$rule = $this->marketplacePriceRules[$marketplace];
return match($rule['type']) {
'markup' => round($basePrice * (1 + $rule['value'] / 100), 0),
'fixed' => $basePrice + $rule['value'],
default => $basePrice,
};
}
}
Обработка пересекающихся заказов
class OrderProcessor
{
public function process(Order $order): void
{
DB::transaction(function () use ($order) {
foreach ($order->items as $item) {
$reserved = ProductReservation::create([
'product_id' => $item->product_id,
'quantity' => $item->quantity,
'source' => $order->source, // 'site', 'ozon', 'wb'
'order_id' => $order->id,
]);
// Проверяем не превышен ли физический остаток
$totalReserved = ProductReservation::where('product_id', $item->product_id)->sum('quantity');
$actualStock = WarehouseItem::where('product_id', $item->product_id)->sum('quantity');
if ($totalReserved > $actualStock) {
throw new InsufficientStockException($item->product_id);
}
}
// Ставим задачу на снижение остатков на всех площадках
StockSyncJob::dispatch($order->items->pluck('product_id')->unique()->all());
});
}
}
Мониторинг расхождений
Периодически сверяем фактические остатки на маркетплейсах с нашими данными:
class StockDiscrepancyChecker
{
public function check(): array
{
$discrepancies = [];
$ozonStocks = $this->ozon->getAllStocks();
foreach ($ozonStocks as $ozonItem) {
$ourStock = $this->calculator->getAvailableForMarketplace($ozonItem['offer_id'], 'ozon');
if (abs($ourStock - $ozonItem['stock']) > 1) {
$discrepancies[] = [
'sku' => $ozonItem['offer_id'],
'our' => $ourStock,
'ozon' => $ozonItem['stock'],
'delta' => $ourStock - $ozonItem['stock'],
];
}
}
return $discrepancies;
}
}
Сроки
Система синхронизации остатков и цен для 2–3 маркетплейсов: 14–20 рабочих дней.







