הטמעת מחסנים אזוריים ומשלוחים למסחר אלקטרוני
תארו לעצמכם חנות מקוונת עם 5 מחסנים ברחבי רוסיה. ללא חלוקת הזמנות נכונה, משלוח לנובוסיבירסק עלול להישלח ממוסקבה גם אם הפריט זמין בקרסנויארסק. הלקוח משלם יותר וממתין זמן רב יותר. התוצאה היא הגדלת החזרות וירידה בשיעורי ההמרה. המערכת שלנו מוצאת אוטומטית את המחסן הקרוב ביותר, שומרת מלאי שם, ומחשבת משלוח מעיר השילוח בפועל. בממוצע, זה מקצר את זמן המשלוח ב-40% ללקוחות אזוריים ומפחית עלויות תפעול ב-25% באמצעות אופטימיזציה של מסלולים. הפתרון בנוי על Laravel 11 ו-React 18, תוך שימוש בתבניות Repository ו-BFF.
בעיות שאנחנו פותרים
- שגיאות שמירת מלאי. המערכת תמיד בוחרת את המחסן הקרוב ביותר ללקוח ושומרת שם מלאי. הלקוח מקבל את ההזמנה מהר יותר וזול יותר.
- חישוב משלוח שגוי. המשלוח מחושב מהמחסן בפועל, לא מהמחסן המרכזי. זה מפחית עלויות ללקוחות אזוריים בממוצע של 15–20%.
- נתוני מלאי מפוזרים. כרטיס המוצר מציג מלאי כולל בכל המחסנים וכן פירוט ברמת העיר—הלקוח רואה בדיוק היכן הפריט זמין.
בניגוד למחוון פשוט של "במלאי/אזל", אנחנו מציגים כמויות מדויקות לכל מחסן ומציעים איסוף מהמיקום הקרוב ביותר. זה מפחית את שיעור ההחזרות ב-15–20%.
איך בוחרים מחסן עבור הלקוח?
האלגוריתם המרכזי מיושם במחלקה WarehouseSelector על Laravel. הוא מקבל את מזהה המוצר, הכמות הנדרשת ועיר היעד, ומבצע שני שלבים:
- מוצא מחסנים שבהם
available >= quantity, ממוינים לפי עדיפות (ניתן להגדיר בפאנל הניהול). - אם קידוד גיאוגרפי של העיר מצליח, ממיין לפי מרחק ללקוח באמצעות נוסחת האוורסין. אם לא, בוחר את הראשון לפי עדיפות.
class WarehouseSelector { public function selectForDelivery( int $productId, int $quantity, string $destinationCity, ): ?WarehouseSelectionResult { // Склады, где есть нужное количество $available = WarehouseStock::where('product_id', $productId) ->where('available', '>=', $quantity) ->with('warehouse') ->orderByDesc('warehouse.priority') ->get(); if ($available->isEmpty()) { // Попробовать split — взять с нескольких складов return $this->splitWarehouseSelection($productId, $quantity); } // Сортировка по близости к покупателю $coords = $this->geocoder->getCoords($destinationCity); if ($coords) { $sorted = $available->sortBy(function ($stock) use ($coords) { return $this->haversineDistance( $coords['lat'], $coords['lng'], $stock->warehouse->lat, $stock->warehouse->lng, ); }); return new WarehouseSelectionResult( warehouse: $sorted->first()->warehouse, isSplit: false, ); } // Геокодирование не удалось — берём по приоритету return new WarehouseSelectionResult( warehouse: $available->first()->warehouse, isSplit: false, ); } private function haversineDistance(float $lat1, float $lng1, float $lat2, float $lng2): float { $R = 6371; // km $dLat = deg2rad($lat2 - $lat1); $dLng = deg2rad($lng2 - $lng1); $a = sin($dLat / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng / 2) ** 2; return $R * 2 * atan2(sqrt($a), sqrt(1 - $a)); } } למה חשוב לשמור מלאי בזמן ההזמנה?
ללא שמירת מלאי, עלול להתרחש מרוץ נתונים: שני לקוחות עלולים להזמין בו-זמנית את היחידה האחרונה. class WarehouseSelector { public function selectForDelivery( int $productId, int $quantity, string $destinationCity, ): ?WarehouseSelectionResult { // Склады, где есть нужное количество $available = WarehouseStock::where('product_id', $productId) ->where('available', '>=', $quantity) ->with('warehouse') ->orderByDesc('warehouse.priority') ->get(); if ($available->isEmpty()) { // Попробовать split — взять с нескольких складов return $this->splitWarehouseSelection($productId, $quantity); } // Сортировка по близости к покупателю $coords = $this->geocoder->getCoords($destinationCity); if ($coords) { $sorted = $available->sortBy(function ($stock) use ($coords) { return $this->haversineDistance( $coords['lat'], $coords['lng'], $stock->warehouse->lat, $stock->warehouse->lng, ); }); return new WarehouseSelectionResult( warehouse: $sorted->first()->warehouse, isSplit: false, ); } // Геокодирование не удалось — берём по приоритету return new WarehouseSelectionResult( warehouse: $available->first()->warehouse, isSplit: false, ); } private function haversineDistance(float $lat1, float $lng1, float $lat2, float $lng2): float { $R = 6371; // km $dLat = deg2rad($lat2 - $lat1); $dLng = deg2rad($lng2 - $lng1); $a = sin($dLat / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng / 2) ** 2; return $R * 2 * atan2(sqrt($a), sqrt(1 - $a)); } } שלנו משתמש בנעילות StockReservationService בתוך עסקה כדי למנוע שמירת מלאי כפולה. שמירת המלאי נמשכת 30 דקות, ולאחר מכן היא מתבטלת אוטומטית.
class StockReservationService { public function reserve(Order $order): ReservationResult { $reservations = []; DB::transaction(function () use ($order, &$reservations) { foreach ($order->items as $item) { // Найти склад с нужным количеством $stock = WarehouseStock::where('product_id', $item->product_id) ->where('available', '>=', $item->quantity) ->where('warehouse_id', $item->preferred_warehouse_id ?? $this->getNearestWarehouse($order->delivery_city, $item->product_id)->id ) ->lockForUpdate() ->first(); if (!$stock) { throw new InsufficientStockException( "Недостаточно товара на складе: SKU {$item->product->sku}" ); } // Увеличить зарезервированное $stock->increment('reserved', $item->quantity); $reservations[] = StockReservation::create([ 'order_id' => $order->id, 'order_item_id' => $item->id, 'warehouse_id' => $stock->warehouse_id, 'product_id' => $item->product_id, 'quantity' => $item->quantity, 'expires_at' => now()->addMinutes(30), ]); } }); return new ReservationResult(reservations: $reservations); } public function cancelExpiredReservations(): int { $expired = StockReservation::where('status', 'reserved') ->where('expires_at', '<', now()) ->get(); foreach ($expired as $reservation) { DB::transaction(function () use ($reservation) { WarehouseStock::where([ 'product_id' => $reservation->product_id, 'warehouse_id' => $reservation->warehouse_id, ])->decrement('reserved', $reservation->quantity); $reservation->update(['status' => 'cancelled']); }); } return $expired->count(); } } איך מחושב משלוח עם מודעות למחסנים?
lockForUpdate מקבץ פריטי עגלה לפי המחסן שנבחר ומבקש עלות משלוח ממחשבון המוביל עבור כל קבוצה. אם פריטים נשלחים ממספר מחסנים, העלויות מסוכמות, והלקוח רואה אפשרות אחת—המחיר המינימלי לכל שיטה.
class RegionalDeliveryCalculator { public function calculate( array $cartItems, string $destination, ): DeliveryResult { // Сгруппировать товары по складу $warehouseGroups = $this->groupByWarehouse($cartItems, $destination); $allOptions = collect(); foreach ($warehouseGroups as $warehouseId => $items) { $warehouse = Warehouse::find($warehouseId); // Считать доставку от конкретного склада $request = new DeliveryRequest( fromCity: $warehouse->city, fromLat: $warehouse->lat, fromLng: $warehouse->lng, destination: $destination, items: $items, ); $options = $this->carrierCalculator->calculate($request); // Если несколько складов — суммировать стоимость if (count($warehouseGroups) > 1) { $allOptions = $allOptions->merge( $options->map(fn($o) => $o->withWarehouseNote($warehouse->city)) ); } else { $allOptions = $allOptions->merge($options); } } // Дедупликация по методу доставки — выбрать лучший вариант return new DeliveryResult( options: $allOptions->groupBy('method') ->map(fn($g) => $g->sortBy('price')->first()) ->values(), ); } private function groupByWarehouse(array $cartItems, string $destination): array { $groups = []; foreach ($cartItems as $item) { $warehouse = $this->selector->selectForDelivery( $item->product_id, $item->quantity, $destination, ); $warehouseId = $warehouse?->warehouse->id ?? $this->defaultWarehouse->id; $groups[$warehouseId][] = $item; } return $groups; } } הצגת מלאי מחסן בכרטיס המוצר
רכיב הפרונטאנד של React שואל מלאי לפי עיר דרך API ומציג רשימה מתקפלת. אם אין מלאי זמין באף מחסן, הוא מציג "אזל מהמלאי".
// Компонент наличия по городам const StockByCity: React.FC<{ productId: number }> = ({ productId }) => { const { data } = useQuery(['stock', productId], () => fetchStockByCity(productId)); if (!data?.length) return <span className="text-red-500">Нет в наличии</span>; return ( <details className="text-sm"> <summary className="cursor-pointer text-green-600 font-medium"> В наличии — {data.length} город(а) </summary> <ul className="mt-1 space-y-1 pl-3"> {data.map(s => ( <li key={s.warehouse} className="text-gray-600"> {s.city} — {s.qty} шт. </li> ))} </ul> </details> ); }; תהליך ההטמעה
ההטמעה מתבצעת ב-5 שלבים:
| שלב | משך זמן |
|---|---|
| ניתוח הארכיטקטורה הנוכחית ואיסוף נתונים | 1-2 ימים |
| עיצוב סכמת נתונים ו-API | יום אחד |
| הטמעת מחלקות בקאנד (WarehouseSelector, RegionalDeliveryCalculator, StockReservationService) | 2-3 ימים |
| שילוב רכיבי UI (כרטיס מוצר, עגלה) | 1-2 ימים |
| בדיקות, פריסה והדרכת מנהלים | 1-2 ימים |
זמן מוערך כולל: 6 עד 7 ימי עבודה, לא כולל העברות בין מחסנים. לוח זמנים מדויק נקבע לאחר ביקורת של הארכיטקטורה הנוכחית שלכם.
מה כלול בתוצאה
- תיעוד של סכמת הנתונים וה-API.
- מחלקות בקאנד עובדות (WarehouseSelector, RegionalDeliveryCalculator, StockReservationService).
- רכיבי UI לכרטיס מוצר ועגלה.
- מדריך מנהל לניהול מחסנים ועדיפויות.
- הדרכת מנהלים על שמירת מלאי והעברות.
- 30 ימי תמיכה לאחר הפריסה.
השוואה למחסן מרכזי
| תכונה | מחסן יחיד | מחסנים אזוריים (הפתרון שלנו) |
|---|---|---|
| מהירות משלוח | גבוהה רק בעיר המחסן | מינימלית לכל אזור |
| עלות משלוח | קבועה מהמיקום המרכזי | אדפטיבית, לרוב נמוכה ב-15-20% |
| הצגת מלאי באתר | רק כולל | לפי מחסן עם פירוט |
| סיכון לשגיאת שמירת מלאי | נמוך | כמעט מבוטל |
הזמינו הטמעה—קבלו הערכה ראשונית תוך יום אחד. לצוות שלנו ניסיון של 8+ שנים בפיתוח מסחר אלקטרוני והוא סיפק מעל 50 פרויקטים עם לוגיסטיקה מרובת מחסנים. מהנדסי Laravel ו-React מוסמכים משתמשים בתבניות מוכחות כמו Repository ו-BFF כדי להבטיח סובלנות לתקלות וקלות תחזוקה. קבלו ייעוץ חינם ממהנדס.







