604 lines
19 KiB
PHP
604 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Modules\RestaurantDelivery\Services\Tracking;
|
|
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Modules\RestaurantDelivery\Enums\DeliveryStatus;
|
|
use Modules\RestaurantDelivery\Events\DeliveryStatusChanged;
|
|
use Modules\RestaurantDelivery\Events\RiderLocationUpdated;
|
|
use Modules\RestaurantDelivery\Models\Delivery;
|
|
use Modules\RestaurantDelivery\Models\LocationLog;
|
|
use Modules\RestaurantDelivery\Models\Rider;
|
|
use Modules\RestaurantDelivery\Services\Firebase\FirebaseService;
|
|
use Modules\RestaurantDelivery\Services\Maps\MapsService;
|
|
|
|
class LiveTrackingService
|
|
{
|
|
protected FirebaseService $firebase;
|
|
|
|
protected MapsService $mapsService;
|
|
|
|
protected array $config;
|
|
|
|
public function __construct(
|
|
FirebaseService $firebase,
|
|
MapsService $mapsService
|
|
) {
|
|
$this->firebase = $firebase;
|
|
$this->mapsService = $mapsService;
|
|
$this->config = config('restaurant-delivery.tracking');
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Rider Location Updates
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
/**
|
|
* Update rider's live location
|
|
*/
|
|
public function updateRiderLocation(
|
|
Rider $rider,
|
|
float $latitude,
|
|
float $longitude,
|
|
?float $speed = null,
|
|
?float $bearing = null,
|
|
?float $accuracy = null
|
|
): array {
|
|
// Validate accuracy
|
|
$accuracyThreshold = config('restaurant-delivery.firebase.location.accuracy_threshold');
|
|
if ($accuracy && $accuracy > $accuracyThreshold) {
|
|
Log::warning('Rider location accuracy too low', [
|
|
'rider_id' => $rider->id,
|
|
'accuracy' => $accuracy,
|
|
'threshold' => $accuracyThreshold,
|
|
]);
|
|
}
|
|
|
|
// Check minimum distance change
|
|
$lastLocation = $this->getLastRiderLocation($rider->id);
|
|
$minDistance = config('restaurant-delivery.firebase.location.min_distance_change');
|
|
|
|
if ($lastLocation) {
|
|
$distance = $this->calculateDistance(
|
|
$lastLocation['lat'],
|
|
$lastLocation['lng'],
|
|
$latitude,
|
|
$longitude
|
|
);
|
|
|
|
// Skip if distance is less than minimum threshold (reduces noise)
|
|
if ($distance < ($minDistance / 1000)) { // Convert meters to km
|
|
return [
|
|
'updated' => false,
|
|
'reason' => 'Distance change below threshold',
|
|
'distance' => $distance * 1000,
|
|
];
|
|
}
|
|
}
|
|
|
|
// Update Firebase
|
|
$this->firebase->updateRiderLocation(
|
|
$rider->id,
|
|
$latitude,
|
|
$longitude,
|
|
$speed,
|
|
$bearing,
|
|
$accuracy
|
|
);
|
|
|
|
// Update local database
|
|
$rider->update([
|
|
'current_latitude' => $latitude,
|
|
'current_longitude' => $longitude,
|
|
'last_location_update' => now(),
|
|
]);
|
|
|
|
// Log location (if enabled)
|
|
if (config('restaurant-delivery.logging.log_location_updates')) {
|
|
LocationLog::create([
|
|
'rider_id' => $rider->id,
|
|
'latitude' => $latitude,
|
|
'longitude' => $longitude,
|
|
'speed' => $speed,
|
|
'bearing' => $bearing,
|
|
'accuracy' => $accuracy,
|
|
]);
|
|
}
|
|
|
|
// Update tracking for all active deliveries
|
|
$activeDeliveries = $rider->activeDeliveries;
|
|
foreach ($activeDeliveries as $delivery) {
|
|
$this->updateDeliveryTracking($delivery, $latitude, $longitude, $speed, $bearing);
|
|
}
|
|
|
|
// Dispatch event
|
|
Event::dispatch(new RiderLocationUpdated($rider, $latitude, $longitude, $speed, $bearing));
|
|
|
|
return [
|
|
'updated' => true,
|
|
'location' => [
|
|
'lat' => $latitude,
|
|
'lng' => $longitude,
|
|
'speed' => $speed,
|
|
'bearing' => $bearing,
|
|
],
|
|
'active_deliveries' => $activeDeliveries->count(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get last known rider location
|
|
*/
|
|
public function getLastRiderLocation(int|string $riderId): ?array
|
|
{
|
|
// Try cache first
|
|
$cached = Cache::get("rider_location_{$riderId}");
|
|
if ($cached) {
|
|
return $cached;
|
|
}
|
|
|
|
// Try Firebase
|
|
return $this->firebase->getRiderLocation($riderId);
|
|
}
|
|
|
|
/**
|
|
* Check if rider has valid location
|
|
*/
|
|
public function hasValidLocation(Rider $rider): bool
|
|
{
|
|
if (! $this->firebase->isRiderLocationStale($rider->id)) {
|
|
return true;
|
|
}
|
|
|
|
// Fallback to database
|
|
if ($rider->last_location_update) {
|
|
$threshold = config('restaurant-delivery.firebase.location.stale_threshold');
|
|
|
|
return $rider->last_location_update->diffInSeconds(now()) < $threshold;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Delivery Tracking
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
/**
|
|
* Initialize tracking for a new delivery
|
|
*/
|
|
public function initializeDeliveryTracking(Delivery $delivery): bool
|
|
{
|
|
if (! $this->config['restaurant-delivery.maps']['enabled']) {
|
|
return false;
|
|
}
|
|
|
|
// Get route from maps service
|
|
$route = null;
|
|
if ($delivery->rider) {
|
|
$route = $this->mapsService->getRoute(
|
|
$delivery->pickup_latitude,
|
|
$delivery->pickup_longitude,
|
|
$delivery->drop_latitude,
|
|
$delivery->drop_longitude
|
|
);
|
|
}
|
|
|
|
$deliveryData = [
|
|
'status' => DeliveryStatus::PENDING,
|
|
'rider_id' => $delivery->rider_id,
|
|
'restaurant_id' => $delivery->restaurant_id,
|
|
'restaurant_name' => $delivery->restaurant?->name,
|
|
'pickup_latitude' => $delivery->pickup_latitude,
|
|
'pickup_longitude' => $delivery->pickup_longitude,
|
|
'pickup_address' => $delivery->pickup_address,
|
|
'drop_latitude' => $delivery->drop_latitude,
|
|
'drop_longitude' => $delivery->drop_longitude,
|
|
'drop_address' => $delivery->drop_address,
|
|
'route' => $route ? [
|
|
'polyline' => $route['polyline'],
|
|
'distance' => $route['distance'],
|
|
'duration' => $route['duration'],
|
|
] : null,
|
|
'eta' => $route ? $route['duration'] : null,
|
|
'distance' => $route ? $route['distance'] : null,
|
|
];
|
|
|
|
return $this->firebase->initializeDeliveryTracking($delivery->id, $deliveryData);
|
|
}
|
|
|
|
/**
|
|
* Update delivery tracking with new rider location
|
|
*/
|
|
public function updateDeliveryTracking(
|
|
Delivery $delivery,
|
|
float $latitude,
|
|
float $longitude,
|
|
?float $speed = null,
|
|
?float $bearing = null
|
|
): void {
|
|
// Determine destination based on delivery status
|
|
$destLat = $delivery->status->isPickedUp()
|
|
? $delivery->drop_latitude
|
|
: $delivery->pickup_latitude;
|
|
$destLng = $delivery->status->isPickedUp()
|
|
? $delivery->drop_longitude
|
|
: $delivery->pickup_longitude;
|
|
|
|
// Calculate remaining distance
|
|
$remainingDistance = $this->calculateDistance($latitude, $longitude, $destLat, $destLng);
|
|
|
|
// Calculate ETA based on speed or average
|
|
$eta = $this->calculateETA($remainingDistance, $speed);
|
|
|
|
// Update Firebase
|
|
$this->firebase->updateDeliveryRiderLocation(
|
|
$delivery->id,
|
|
$latitude,
|
|
$longitude,
|
|
$speed,
|
|
$bearing,
|
|
$eta,
|
|
$remainingDistance
|
|
);
|
|
|
|
// Check for geofence triggers (auto status updates)
|
|
$this->checkGeofenceTriggers($delivery, $latitude, $longitude);
|
|
|
|
// Update route if significantly off track
|
|
if ($this->isOffRoute($delivery, $latitude, $longitude)) {
|
|
$this->recalculateRoute($delivery, $latitude, $longitude);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update delivery status in tracking
|
|
*/
|
|
public function updateDeliveryStatus(Delivery $delivery, ?array $metadata = null): void
|
|
{
|
|
$statusConfig = config("restaurant-delivery.delivery_flow.statuses.{$delivery->status->value}");
|
|
|
|
$this->firebase->updateDeliveryStatus(
|
|
$delivery->id,
|
|
$delivery->status->value,
|
|
array_merge($metadata ?? [], [
|
|
'eta' => $delivery->estimated_delivery_time?->diffForHumans(),
|
|
'rider' => $delivery->rider ? [
|
|
'id' => $delivery->rider->id,
|
|
'name' => $delivery->rider->full_name,
|
|
'phone' => $delivery->rider->phone,
|
|
'photo' => $delivery->rider->photo_url,
|
|
'rating' => $delivery->rider->rating,
|
|
'vehicle' => $delivery->rider->vehicle_type,
|
|
] : null,
|
|
])
|
|
);
|
|
|
|
Event::dispatch(new DeliveryStatusChanged($delivery));
|
|
}
|
|
|
|
/**
|
|
* Get current tracking data for a delivery
|
|
*/
|
|
public function getDeliveryTracking(Delivery $delivery): ?array
|
|
{
|
|
$trackingData = $this->firebase->getDeliveryTracking($delivery->id);
|
|
|
|
if (! $trackingData) {
|
|
return null;
|
|
}
|
|
|
|
// Enhance with interpolated positions for smooth animation
|
|
if ($this->config['animation']['enabled'] && isset($trackingData['rider_location'])) {
|
|
$trackingData['animation'] = $this->generateAnimationData($trackingData['rider_location']);
|
|
}
|
|
|
|
return $trackingData;
|
|
}
|
|
|
|
/**
|
|
* End tracking for completed/cancelled delivery
|
|
*/
|
|
public function endDeliveryTracking(Delivery $delivery): void
|
|
{
|
|
// Store final tracking data in history
|
|
$this->storeTrackingHistory($delivery);
|
|
|
|
// Remove from Firebase after a delay (allow final status update to be seen)
|
|
dispatch(function () use ($delivery) {
|
|
$this->firebase->removeDeliveryTracking($delivery->id);
|
|
})->delay(now()->addMinutes(5));
|
|
|
|
// Remove rider assignment
|
|
if ($delivery->rider_id) {
|
|
$this->firebase->removeRiderAssignment($delivery->rider_id, $delivery->id);
|
|
}
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Geofencing
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
/**
|
|
* Check if rider has entered geofence zones
|
|
*/
|
|
protected function checkGeofenceTriggers(
|
|
Delivery $delivery,
|
|
float $latitude,
|
|
float $longitude
|
|
): void {
|
|
$geofenceRadius = config('restaurant-delivery.delivery_flow.auto_status_updates.geofence_radius');
|
|
|
|
// Check proximity to restaurant (for pickup)
|
|
if ($delivery->status->value === 'rider_assigned') {
|
|
$distanceToRestaurant = $this->calculateDistance(
|
|
$latitude,
|
|
$longitude,
|
|
$delivery->pickup_latitude,
|
|
$delivery->pickup_longitude
|
|
);
|
|
|
|
if ($distanceToRestaurant * 1000 <= $geofenceRadius) {
|
|
$this->triggerGeofenceEvent($delivery, 'restaurant_arrival');
|
|
}
|
|
}
|
|
|
|
// Check proximity to customer (for delivery)
|
|
if ($delivery->status->value === 'on_the_way') {
|
|
$distanceToCustomer = $this->calculateDistance(
|
|
$latitude,
|
|
$longitude,
|
|
$delivery->drop_latitude,
|
|
$delivery->drop_longitude
|
|
);
|
|
|
|
if ($distanceToCustomer * 1000 <= $geofenceRadius) {
|
|
$this->triggerGeofenceEvent($delivery, 'customer_arrival');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle geofence trigger event
|
|
*/
|
|
protected function triggerGeofenceEvent(Delivery $delivery, string $event): void
|
|
{
|
|
$requireConfirmation = config('restaurant-delivery.delivery_flow.auto_status_updates.arrival_confirmation');
|
|
|
|
if (! $requireConfirmation) {
|
|
switch ($event) {
|
|
case 'restaurant_arrival':
|
|
$delivery->updateStatus('rider_at_restaurant');
|
|
break;
|
|
case 'customer_arrival':
|
|
$delivery->updateStatus('arrived');
|
|
break;
|
|
}
|
|
} else {
|
|
// Send notification to rider to confirm arrival
|
|
$this->notifyRiderForConfirmation($delivery, $event);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Notify rider to confirm arrival
|
|
*/
|
|
protected function notifyRiderForConfirmation(Delivery $delivery, string $event): void
|
|
{
|
|
$message = $event === 'restaurant_arrival'
|
|
? 'You have arrived at the restaurant. Please confirm pickup.'
|
|
: 'You have arrived at the customer location. Please confirm delivery.';
|
|
|
|
$this->firebase->sendPushNotification(
|
|
$delivery->rider->fcm_token,
|
|
'Arrival Detected',
|
|
$message,
|
|
[
|
|
'type' => 'arrival_confirmation',
|
|
'delivery_id' => (string) $delivery->id,
|
|
'event' => $event,
|
|
]
|
|
);
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Route Management
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
/**
|
|
* Check if rider is off the expected route
|
|
*/
|
|
protected function isOffRoute(Delivery $delivery, float $latitude, float $longitude): bool
|
|
{
|
|
if (! config('restaurant-delivery.edge_cases.route_deviation.enabled')) {
|
|
return false;
|
|
}
|
|
|
|
$threshold = config('restaurant-delivery.edge_cases.route_deviation.threshold');
|
|
$cachedRoute = Cache::get("delivery_route_{$delivery->id}");
|
|
|
|
if (! $cachedRoute || empty($cachedRoute['points'])) {
|
|
return false;
|
|
}
|
|
|
|
// Find minimum distance to any point on the route
|
|
$minDistance = PHP_FLOAT_MAX;
|
|
foreach ($cachedRoute['points'] as $point) {
|
|
$distance = $this->calculateDistance(
|
|
$latitude,
|
|
$longitude,
|
|
$point['lat'],
|
|
$point['lng']
|
|
) * 1000; // Convert to meters
|
|
|
|
$minDistance = min($minDistance, $distance);
|
|
}
|
|
|
|
return $minDistance > $threshold;
|
|
}
|
|
|
|
/**
|
|
* Recalculate route from current position
|
|
*/
|
|
protected function recalculateRoute(
|
|
Delivery $delivery,
|
|
float $currentLat,
|
|
float $currentLng
|
|
): void {
|
|
$destLat = $delivery->status->isPickedUp()
|
|
? $delivery->drop_latitude
|
|
: $delivery->pickup_latitude;
|
|
$destLng = $delivery->status->isPickedUp()
|
|
? $delivery->drop_longitude
|
|
: $delivery->pickup_longitude;
|
|
|
|
$route = $this->mapsService->getRoute($currentLat, $currentLng, $destLat, $destLng);
|
|
|
|
if ($route) {
|
|
Cache::put("delivery_route_{$delivery->id}", [
|
|
'polyline' => $route['polyline'],
|
|
'points' => $route['points'],
|
|
'distance' => $route['distance'],
|
|
'duration' => $route['duration'],
|
|
], config('restaurant-delivery.cache.ttl.route_calculation'));
|
|
|
|
$this->firebase->updateDeliveryRoute($delivery->id, $route);
|
|
}
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Animation Support
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
/**
|
|
* Generate interpolation data for smooth animation
|
|
*/
|
|
protected function generateAnimationData(array $currentLocation): array
|
|
{
|
|
$lastLocation = Cache::get("last_animation_point_{$currentLocation['delivery_id']}");
|
|
|
|
if (! $lastLocation) {
|
|
Cache::put(
|
|
"last_animation_point_{$currentLocation['delivery_id']}",
|
|
$currentLocation,
|
|
60
|
|
);
|
|
|
|
return ['interpolated' => false];
|
|
}
|
|
|
|
$points = $this->config['animation']['interpolation_points'];
|
|
$duration = $this->config['animation']['animation_duration'];
|
|
|
|
$interpolatedPoints = [];
|
|
for ($i = 1; $i <= $points; $i++) {
|
|
$ratio = $i / $points;
|
|
$interpolatedPoints[] = [
|
|
'lat' => $lastLocation['lat'] + ($currentLocation['lat'] - $lastLocation['lat']) * $ratio,
|
|
'lng' => $lastLocation['lng'] + ($currentLocation['lng'] - $lastLocation['lng']) * $ratio,
|
|
'bearing' => $this->interpolateBearing(
|
|
$lastLocation['bearing'] ?? 0,
|
|
$currentLocation['bearing'] ?? 0,
|
|
$ratio
|
|
),
|
|
'timestamp' => $lastLocation['timestamp'] + ($duration * $ratio),
|
|
];
|
|
}
|
|
|
|
Cache::put(
|
|
"last_animation_point_{$currentLocation['delivery_id']}",
|
|
$currentLocation,
|
|
60
|
|
);
|
|
|
|
return [
|
|
'interpolated' => true,
|
|
'points' => $interpolatedPoints,
|
|
'duration' => $duration,
|
|
'easing' => $this->config['animation']['easing'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Interpolate bearing between two angles
|
|
*/
|
|
protected function interpolateBearing(float $from, float $to, float $ratio): float
|
|
{
|
|
$diff = $to - $from;
|
|
|
|
// Handle wrap-around at 360 degrees
|
|
if ($diff > 180) {
|
|
$diff -= 360;
|
|
} elseif ($diff < -180) {
|
|
$diff += 360;
|
|
}
|
|
|
|
return fmod($from + $diff * $ratio + 360, 360);
|
|
}
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| Utility Methods
|
|
|--------------------------------------------------------------------------
|
|
*/
|
|
|
|
/**
|
|
* Calculate distance between two points using Haversine formula
|
|
*/
|
|
protected function calculateDistance(
|
|
float $lat1,
|
|
float $lng1,
|
|
float $lat2,
|
|
float $lng2
|
|
): float {
|
|
$earthRadius = config('restaurant-delivery.distance.earth_radius_km');
|
|
|
|
$dLat = deg2rad($lat2 - $lat1);
|
|
$dLng = deg2rad($lng2 - $lng1);
|
|
|
|
$a = sin($dLat / 2) * sin($dLat / 2) +
|
|
cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
|
|
sin($dLng / 2) * sin($dLng / 2);
|
|
|
|
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
|
|
|
|
return $earthRadius * $c;
|
|
}
|
|
|
|
/**
|
|
* Calculate ETA based on distance and speed
|
|
*/
|
|
protected function calculateETA(float $distanceKm, ?float $speedKmh): int
|
|
{
|
|
// Use provided speed or assume average
|
|
$speed = $speedKmh && $speedKmh > 0 ? $speedKmh : 25; // Default 25 km/h
|
|
|
|
$hours = $distanceKm / $speed;
|
|
|
|
return (int) ceil($hours * 60); // Return minutes
|
|
}
|
|
|
|
/**
|
|
* Store tracking history for analytics
|
|
*/
|
|
protected function storeTrackingHistory(Delivery $delivery): void
|
|
{
|
|
// This would store the complete tracking history for analytics
|
|
// Implementation depends on your data storage strategy
|
|
}
|
|
}
|