Розробка управління складськими остатками для E-Commerce
Управління складськими остатками — це не просто лічильник поряд з товаром. Це система, яка пов'язує вітрину магазину з реальними фізичними запасами, запобігає продажам неіснуючого товару та дає дані для закупочних рішень. Без грамотно реалізованого інвентарю магазин працює всліпу.
Що входить у завдання
Мінімально жизненна система обліку остатків включає:
- зберігання кількості одиниць на складі з розбивкою по варіантам (розмір, колір, SKU)
- резервування остатка в момент оформлення замовлення до факту оплати
- звільнення резерву при скасуванні або вичерпанні таймаута
- списання при переході замовлення у статус "відправлено"
- пороги сповіщення: "закінчується" та "немає в наявності"
- відображення статусу на вітрині без повного перезапроса сторінки
Без резервування два покупці одночасно можуть купити останній екземпляр — класична race condition. Без звільнення резерву покинуті кошики назавжди блокують остатки.
Схема даних
Базова структура для PostgreSQL:
CREATE TABLE product_variants (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES products(id),
sku VARCHAR(100) NOT NULL UNIQUE,
attributes JSONB NOT NULL DEFAULT '{}',
stock_qty INTEGER NOT NULL DEFAULT 0,
reserved_qty INTEGER NOT NULL DEFAULT 0,
low_stock_threshold INTEGER NOT NULL DEFAULT 5,
CHECK (stock_qty >= 0),
CHECK (reserved_qty >= 0),
CHECK (stock_qty >= reserved_qty)
);
CREATE TABLE stock_movements (
id BIGSERIAL PRIMARY KEY,
variant_id BIGINT NOT NULL REFERENCES product_variants(id),
delta INTEGER NOT NULL,
type VARCHAR(50) NOT NULL, -- 'reserve', 'release', 'deduct', 'restock'
reference VARCHAR(255), -- order_id, shipment_id и т.д.
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
Поле available_qty = stock_qty - reserved_qty вичисляється на лету або через generated column. Таблиця stock_movements — незмінний журнал усіх операцій, необхідний для аудиту та відновлення при збоях.
Резервування без перегонів
Атомарність забезпечується через SELECT ... FOR UPDATE або UPDATE ... RETURNING:
-- Атомарне резервування
UPDATE product_variants
SET reserved_qty = reserved_qty + :qty
WHERE id = :variant_id
AND (stock_qty - reserved_qty) >= :qty
RETURNING id, stock_qty, reserved_qty;
Якщо рядок не повернувся — товара недостатньо. Ніяких SELECT перед UPDATE, ніяких перевірок на рівні програми, які брешуть при конкурентному доступі.
Для Laravel це завертається в транзакцію з песимістичною блокуванням:
DB::transaction(function () use ($variantId, $qty, $orderId) {
$variant = ProductVariant::lockForUpdate()->findOrFail($variantId);
if ($variant->available_qty < $qty) {
throw new InsufficientStockException($variantId, $qty);
}
$variant->increment('reserved_qty', $qty);
StockMovement::create([
'variant_id' => $variantId,
'delta' => -$qty,
'type' => 'reserve',
'reference' => "order:{$orderId}",
]);
});
Звільнення зависших резервів
Покупатель кинув кошик — резерв повинен повернутися. Два підходи:
1. TTL через чергу. При створенні резерву диспатчується джоб з затримкою:
ReleaseStockReservation::dispatch($reservationId)
->delay(now()->addMinutes(30));
Якщо замовлення оплачено до вичерпання — джоб перевіряє статус та виходить без дії.
2. Запланований cleanup. Cron кожні 5 хвилин шукає давно минулі резервації:
// app/Console/Commands/ReleaseExpiredReservations.php
StockReservation::where('expires_at', '<', now())
->where('status', 'pending')
->each(fn($r) => $r->release());
Перший підхід точніший, другий простіший в інфраструктурі. Для більшості магазинів з навантаженням до 1000 замовлень/день — достатньо запланованого cleanup.
Імпорт та синхронізація з 1С / складською системою
Якщо у клієнта є облікова система (1С:Торговля, МойСклад, Мегаплан), остатки приходять звідси:
-
Webhook-режим: склад викликає endpoint
/api/stock/updateпри зміні - Pull-режим: магазин раз на N хвилин запитує дифф остатків
Для pull-режиму зберігається last_sync_at та запитується тільки змінене:
GET /api/moysklad/stock?changedSince=2025-03-01T00:00:00Z
При імпорті важливо не перезаписувати reserved_qty — тільки stock_qty. Інакше при синхронізації слетять активні резерви.
Відображення статусу на вітрині
Три варіанти подачі:
| Статус | Умова | Відображення |
|---|---|---|
| В наявності | available_qty > threshold | "В наявності", додати в кошик активно |
| Закінчується | 0 < available_qty ≤ threshold | "Залишилось 3 шт." |
| Немає в наявності | available_qty = 0 | "Немає в наявності", кнопка недоступна |
| Під замовлення | out_of_stock_allowed = true | "Під замовлення, 5–7 днів" |
Для високонатовованих сторінок статус кешується в Redis з TTL 60 секунд. Точність ±1 хвилина — прийнятний компроміс для більшості магазинів.
Сповіщення про низький остаток
Менеджеру закупок потрібно знати заздалегідь. Сповіщення триггерується в observer:
class ProductVariantObserver
{
public function updated(ProductVariant $variant): void
{
if ($variant->wasChanged('stock_qty') && $variant->isLowStock()) {
LowStockAlert::dispatch($variant);
}
}
}
Алерт йде на email, Telegram або Slack — залежить від інфраструктури клієнта.
Сроки реалізації
- Базове резервування + списання + журналювання: 3–5 днів
- Інтеграція з 1С або МойСклад через API: +3–5 днів
- Кеширование статусів через Redis + відображення на вітрині: +2 дня
- Панель остатків в админці з фільтрами та експортом: +2–3 дня
Типовий проект: 1–2 тижня залежно від наявності зовнішної облікової системи.







