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







