Разработка управления складскими остатками для интернет-магазина
Управление складскими остатками — это не просто счётчик рядом с товаром. Это система, которая связывает витрину магазина с реальными физическими запасами, предотвращает продажи несуществующего товара и даёт данные для закупочных решений. Без грамотно реализованного инвентаря магазин работает вслепую.
Что входит в задачу
Минимально жизнеспособная система учёта остатков включает:
- хранение количества единиц на складе с разбивкой по вариантам (размер, цвет, 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, никаких application-level проверок, которые врут при конкурентном доступе.
Для 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 через очередь. При создании резерва диспатчится джоб с delay:
ReleaseStockReservation::dispatch($reservationId)
->delay(now()->addMinutes(30));
Если заказ оплачен до истечения — джоб проверяет статус и выходит без действия.
2. Scheduled cleanup. Cron каждые 5 минут ищет просроченные резервации:
// app/Console/Commands/ReleaseExpiredReservations.php
StockReservation::where('expires_at', '<', now())
->where('status', 'pending')
->each(fn($r) => $r->release());
Первый подход точнее, второй проще в инфраструктуре. Для большинства магазинов с нагрузкой до 1000 заказов/день — достаточно scheduled 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 недели в зависимости от наличия внешней учётной системы.







