Разработка мультисклада для интернет-магазина
Один склад — простая задача. Два и больше — принципиально другая архитектура. Мультисклад нужен, когда товар хранится на нескольких физических точках: центральный склад + региональные, собственное хранение + дропшиппинг-поставщики, магазин + офлайн-точки. Задача системы — не просто считать остатки по каждому складу, но и маршрутизировать заказы к оптимальному источнику отгрузки.
Когда это становится нужным
- Магазин работает с несколькими поставщиками, каждый хранит свой товар
- Есть несколько региональных складов, и доставка идёт с ближайшего к покупателю
- Часть товара — дропшиппинг, часть — собственный склад
- Офлайн-точки участвуют в обработке онлайн-заказов (BOPIS — Buy Online, Pick Up In Store)
Схема данных
Расширение односкладовой схемы: добавляется измерение warehouse_id:
CREATE TABLE warehouses (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
code VARCHAR(50) NOT NULL UNIQUE,
address TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
priority INTEGER NOT NULL DEFAULT 0, -- приоритет при маршрутизации
type VARCHAR(50) NOT NULL DEFAULT 'internal' -- internal | dropship | store
);
CREATE TABLE warehouse_stock (
id BIGSERIAL PRIMARY KEY,
warehouse_id BIGINT NOT NULL REFERENCES warehouses(id),
variant_id BIGINT NOT NULL REFERENCES product_variants(id),
stock_qty INTEGER NOT NULL DEFAULT 0,
reserved_qty INTEGER NOT NULL DEFAULT 0,
UNIQUE (warehouse_id, variant_id),
CHECK (stock_qty >= 0),
CHECK (reserved_qty >= 0)
);
-- Агрегированный остаток по всем складам (materialized view или generated)
CREATE MATERIALIZED VIEW product_total_stock AS
SELECT
variant_id,
SUM(stock_qty) AS total_stock,
SUM(reserved_qty) AS total_reserved,
SUM(stock_qty - reserved_qty) AS available_qty
FROM warehouse_stock
GROUP BY variant_id;
Materialized view обновляется после каждого изменения через триггер или REFRESH MATERIALIZED VIEW CONCURRENTLY по расписанию (каждую минуту).
Маршрутизация заказа к складу
Это ключевая бизнес-логика мультисклада. Алгоритм выбора склада:
class WarehouseRouter
{
public function resolve(OrderItem $item, Address $destination): Warehouse
{
$candidates = WarehouseStock::query()
->where('variant_id', $item->variant_id)
->whereRaw('stock_qty - reserved_qty >= ?', [$item->qty])
->with('warehouse')
->get()
->filter(fn($ws) => $ws->warehouse->is_active)
->sortBy([
fn($a, $b) => $this->byProximity($a->warehouse, $destination) <=> $this->byProximity($b->warehouse, $destination),
fn($a, $b) => $a->warehouse->priority <=> $b->warehouse->priority,
]);
return $candidates->first()?->warehouse
?? throw new NoWarehouseAvailableException($item->variant_id);
}
}
Стратегии маршрутизации — конфигурируемые:
| Стратегия | Описание | Применение |
|---|---|---|
| Proximity | Ближайший к адресу доставки | Региональные сети |
| Priority | По приоритету склада | Дропшиппинг как запасной |
| Cost | Минимальная стоимость отгрузки | Интеграция с транспортными API |
| Consolidation | Минимум источников в заказе | Снижение стоимости упаковки |
| FIFO по SKU | Первый пришедший первым уходит | Управление сроками годности |
Для большинства проектов достаточно proximity + priority fallback.
Расщепление заказа по складам
Если товары из одного заказа есть на разных складах — заказ расщепляется на несколько отгрузок (shipments):
class OrderSplitter
{
public function split(Order $order): Collection
{
$assignments = collect();
foreach ($order->items as $item) {
$warehouse = $this->router->resolve($item, $order->delivery_address);
$assignments->push([
'warehouse' => $warehouse,
'item' => $item,
]);
}
return $assignments
->groupBy(fn($a) => $a['warehouse']->id)
->map(fn($group) => new Shipment($group));
}
}
Покупатель видит один заказ, но внутри — несколько отгрузок с разными трек-номерами. Статус заказа агрегируется из статусов отгрузок: «выполнен» только когда все shipments доставлены.
Синхронизация остатков с несколькими источниками
Каждый склад может иметь свой канал обновления остатков:
- Внутренний склад: WMS через REST API или файловый обмен (XLSX, CSV)
- Дропшиппер: их API с rate-limit, часто нестандартный формат
- Офлайн-точка: кассовая система (1С:Розница, iiko, r_keeper)
Для каждого источника — отдельный адаптер:
interface WarehouseStockProvider
{
public function fetchStock(Carbon $since): Collection; // варианты + количества
public function confirmReservation(string $reservationId): bool;
public function releaseReservation(string $reservationId): bool;
}
class MoyskladWarehouseProvider implements WarehouseStockProvider { ... }
class DropshipperApiProvider implements WarehouseStockProvider { ... }
class OneCWarehouseProvider implements WarehouseStockProvider { ... }
Синхронизация запускается независимо для каждого провайдера по своему расписанию:
* * * * * php artisan stock:sync --warehouse=central
*/5 * * * * php artisan stock:sync --warehouse=dropshipper-a
*/15 * * * * php artisan stock:sync --warehouse=store-minsk
Резервирование в мультисклад-контексте
При резервировании важно фиксировать конкретный склад, а не только вариант:
CREATE TABLE stock_reservations (
id BIGSERIAL PRIMARY KEY,
warehouse_id BIGINT NOT NULL REFERENCES warehouses(id),
variant_id BIGINT NOT NULL REFERENCES product_variants(id),
order_id BIGINT NOT NULL REFERENCES orders(id),
qty INTEGER NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'active',
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
Атомарная операция резервирования — тот же UPDATE ... WHERE available >= qty на warehouse_stock, плюс запись в stock_reservations.
Виджет выбора склада для самовывоза
Если включён BOPIS, покупатель выбирает точку самовывоза. Для этого нужна карта точек с реальными остатками:
GET /api/warehouses/availability?variant_ids[]=123&variant_ids[]=456
Ответ содержит список складов с available_qty по каждому товару. Фронтенд строит список точек и карту (Leaflet, Яндекс.Карты), фильтруя только те, где есть весь заказ.
Отчётность по складам
Аналитика, которая реально нужна операционному менеджеру:
- Остатки по каждому складу в разрезе категорий
- Оборачиваемость (sold_qty / avg_stock за период)
- Перемещение между складами (transfer orders)
- Прогноз дефицита по дням продаж
Базовый запрос оборачиваемости:
SELECT
w.name AS warehouse,
pv.sku,
ws.stock_qty,
COALESCE(sales.sold_30d, 0) AS sold_30d,
CASE
WHEN COALESCE(sales.sold_30d, 0) = 0 THEN NULL
ELSE ROUND(ws.stock_qty / (sales.sold_30d / 30.0))
END AS days_of_stock
FROM warehouse_stock ws
JOIN warehouses w ON ws.warehouse_id = w.id
JOIN product_variants pv ON ws.variant_id = pv.id
LEFT JOIN (
SELECT variant_id, SUM(qty) AS sold_30d
FROM order_items oi
JOIN orders o ON oi.order_id = o.id
WHERE o.completed_at >= NOW() - INTERVAL '30 days'
GROUP BY variant_id
) sales ON sales.variant_id = pv.id;
Сроки реализации
- Схема мультисклада + маршрутизация по приоритету: 5–7 дней
- Расщепление заказов + управление отгрузками: 3–5 дней
- Интеграция с одним внешним источником (1С, МойСклад): 3–5 дней
- BOPIS с картой самовывоза: +3–4 дня
- Аналитические отчёты по складам: +2–3 дня
Полноценная мультисклад-система для магазина с 2–5 точками хранения: 2–4 недели.







