Розробка мультисклада для E-Commerce
Один склад — проста задача. Два та більше — принципово інша архітектура. Мультисклад потрібен, коли товар зберігається на кількох фізичних точках: центральний склад + регіональні, власне зберігання + дропшиппінг-поставщики, магазин + офлайн-точки. Завдання системи — не просто рахувати остатки по кожному складу, але й маршрутизувати замовлення до оптимального джерела відправлення.
Коли це стає необхідним
- Магазин працює з кількома поставщиками, кожен зберігає свій товар
- Є кілька регіональних складів, і доставка йде з найближчого до покупця
- Частина товару — дропшиппінг, частина — власний склад
- Офлайн-точки беруть участь у обробці онлайн-замовлень (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 тижня.







