Files

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
}
}