Реалізація трекінгу кур'єра/замовлення в реальному часі на веб-сайті
Користувач оформив замовлення та чекає кур'єра. Сторінка «Мої замовлення» з полем «статус: у дорозі» — це вже застарілий метод. Сучасний стандарт — карта з живим маркером кур'єра та лічильником «прибуде через N хвилин». Технічно це поєднання трьох компонентів: мобільне пристрій/пристрій кур'єра, backend-приложення, клієнтський браузер.
Архітектура потоку даних
[Пристрій кур'єра]
GPS → POST /api/courier/location кожні 3–5 сек
↓
[Backend]
Зберегти в Redis (TTL 30s)
Опублікувати в Redis Pub/Sub канал order:{id}
↓
[WebSocket сервер (Laravel Reverb / Pusher)]
Broadcast event LocationUpdated
↓
[Браузер клієнта]
Оновити маркер на карті
Геопозиції не зберігаються в PostgreSQL при кожному оновленні — це 720 записів на годину на одного кур'єра. Пишемо в базу лише при зміні статусу замовлення та кінцеву позицію при завершенні. Поточна позиція — у Redis з TTL.
Таблиця замовлень
CREATE TABLE delivery_orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
courier_id BIGINT REFERENCES couriers(id),
status VARCHAR(50) NOT NULL DEFAULT 'pending',
-- pending | assigned | picked_up | in_transit | delivered | failed
address_lat DECIMAL(10, 8),
address_lng DECIMAL(11, 8),
address_text VARCHAR(500),
estimated_at TIMESTAMP,
delivered_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE delivery_status_log (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES delivery_orders(id),
status VARCHAR(50) NOT NULL,
lat DECIMAL(10, 8),
lng DECIMAL(11, 8),
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
API кур'єра: оновлення позиції
Ендпоінт викликається з пристрою кур'єра кожні 3–5 секунд:
class CourierLocationController extends Controller
{
public function update(Request $request, DeliveryOrder $order): JsonResponse
{
$data = $request->validate([
'lat' => 'required|numeric|between:-90,90',
'lng' => 'required|numeric|between:-180,180',
]);
// Поточна позиція — тільки в Redis, з TTL 60 секунд
$key = "courier_location:{$order->courier_id}";
Redis::setex($key, 60, json_encode([
'lat' => $data['lat'],
'lng' => $data['lng'],
'order_id' => $order->id,
'ts' => now()->timestamp,
]));
// Broadcast клієнту замовлення
broadcast(new CourierLocationUpdated(
orderId: $order->id,
lat: $data['lat'],
lng: $data['lng'],
eta: $this->calculateEta($order, $data['lat'], $data['lng']),
));
return response()->json(['ok' => true]);
}
private function calculateEta(DeliveryOrder $order, float $lat, float $lng): ?int
{
// Приблизний розрахунок по прямій — 30 км/год середня швидкість в місті
$distanceKm = $this->haversineKm($lat, $lng, $order->address_lat, $order->address_lng);
return (int) round($distanceKm / 30 * 60); // хвилини
}
}
Laravel Event
class CourierLocationUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public readonly int $orderId,
public readonly float $lat,
public readonly float $lng,
public readonly ?int $eta,
) {}
public function broadcastOn(): Channel
{
return new PrivateChannel("order.{$this->orderId}");
}
public function broadcastWith(): array
{
return [
'lat' => $this->lat,
'lng' => $this->lng,
'eta' => $this->eta,
];
}
}
PrivateChannel — клієнт повинен бути авторизованим для підписки. Це виключає ситуацію, коли сторонній користувач може підписатися на канал чужого замовлення.
Авторизація каналу
// routes/channels.php
Broadcast::channel('order.{orderId}', function (User $user, int $orderId) {
return $user->id === DeliveryOrder::find($orderId)?->user_id;
});
Клієнтська частина: карта
import mapboxgl from 'mapbox-gl';
mapboxgl.accessToken = 'pk.eyJ...';
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
center: [orderLng, orderLat],
zoom: 14,
});
// Маркер адреси доставки
new mapboxgl.Marker({ color: '#EF4444' })
.setLngLat([orderLng, orderLat])
.addTo(map);
// Маркер кур'єра
const courierMarker = new mapboxgl.Marker({ color: '#3B82F6' })
.setLngLat([initialLng, initialLat])
.addTo(map);
// WebSocket підписка
Echo.private(`order.${orderId}`)
.listen('CourierLocationUpdated', ({ lat, lng, eta }) => {
courierMarker.setLngLat([lng, lat]);
if (eta !== null) {
document.getElementById('eta').textContent =
eta < 2 ? 'Кур\'єр уже рядом' : `Прибуде через ~${eta} хв`;
}
});
Альтернатива Mapbox — Yandex Maps API або Google Maps Platform. Для CIS країн Яндекс переважний за якістю геокодування та покриттям.
Плавне переміщення маркера
Різкі стрибки маркера кожні 3–5 секунд виглядають грубо. Рішення — анімація через requestAnimationFrame:
function animateMarker(marker, from, to, duration = 500) {
const start = performance.now();
function step(now) {
const t = Math.min((now - start) / duration, 1);
const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; // easeInOut
const lng = from[0] + (to[0] - from[0]) * ease;
const lat = from[1] + (to[1] - from[1]) * ease;
marker.setLngLat([lng, lat]);
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
Push-сповіщення при зміні статусу
Крім живого трекінгу, клієнти потребують сповіщень про переходи: «кур'єр забрав замовлення», «кур'єр за 5 хвилин», «замовлення доставлено». Реалізується через Web Push API або SMS:
// У обробнику події OrderStatusChanged
if ($order->status === 'in_transit') {
$order->user->notify(new CourierPickedUpNotification($order));
}
Офлайн-режим кур'єра
Якщо пристрій кур'єра втрачає з'єднання — на стороні мобільного приложення накопичується буфер координат, який відправляється пачкою при відновленні з'єднання. Backend приймає масив точок з часовими мітками та відтворює анімацію шляху, а не перепригує на кінцеву позицію.
Терміни
- Базовий трекінг (Redis + broadcast + карта): 4–5 днів
- Авторизація Private Channel + логіка доступу: 1 день
- ETA-розрахунок по прямій: 0.5 дня
- ETA через Routing API (OSRM / Google Directions): +1–2 дні
- Анімація маркера + згладжування шляху: 1 день
- Push-сповіщення при зміні статусу: 1–2 дні
- Адміністративна панель диспетчера (усі кур'єри на карті): 3–4 дні







