247 lines
8.4 KiB
PHP
247 lines
8.4 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace Modules\RestaurantDelivery\Jobs;
|
||
|
|
|
||
|
|
use Illuminate\Bus\Queueable;
|
||
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||
|
|
use Illuminate\Queue\InteractsWithQueue;
|
||
|
|
use Illuminate\Queue\SerializesModels;
|
||
|
|
use Illuminate\Support\Facades\DB;
|
||
|
|
use Illuminate\Support\Facades\Log;
|
||
|
|
use Modules\RestaurantDelivery\Models\Delivery;
|
||
|
|
use Modules\RestaurantDelivery\Models\Rider;
|
||
|
|
use Modules\RestaurantDelivery\Services\Firebase\FirebaseService;
|
||
|
|
|
||
|
|
class AssignRiderJob implements ShouldQueue
|
||
|
|
{
|
||
|
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The number of times the job may be attempted.
|
||
|
|
*/
|
||
|
|
public int $tries = 3;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The maximum number of seconds the job can run before timing out.
|
||
|
|
*/
|
||
|
|
public int $timeout = 30;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create a new job instance.
|
||
|
|
*/
|
||
|
|
public function __construct(
|
||
|
|
public readonly Delivery $delivery
|
||
|
|
) {
|
||
|
|
$this->onQueue(config('restaurant-delivery.queue.queues.assignment', 'restaurant-delivery-assignment'));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Execute the job.
|
||
|
|
*/
|
||
|
|
public function handle(FirebaseService $firebase): void
|
||
|
|
{
|
||
|
|
// Skip if already assigned
|
||
|
|
if ($this->delivery->rider_id) {
|
||
|
|
Log::info('Delivery already has a rider assigned', [
|
||
|
|
'delivery_id' => $this->delivery->id,
|
||
|
|
'rider_id' => $this->delivery->rider_id,
|
||
|
|
]);
|
||
|
|
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get assignment config
|
||
|
|
$config = config('restaurant-delivery.assignment');
|
||
|
|
|
||
|
|
// Find nearby available riders
|
||
|
|
$riders = $this->findAvailableRiders();
|
||
|
|
|
||
|
|
if ($riders->isEmpty()) {
|
||
|
|
Log::warning('No available riders found for delivery', [
|
||
|
|
'delivery_id' => $this->delivery->id,
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Retry after a delay if max attempts not reached
|
||
|
|
if ($this->attempts() < $this->tries) {
|
||
|
|
$this->release(60); // Release back to queue after 1 minute
|
||
|
|
}
|
||
|
|
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Score and rank riders
|
||
|
|
$scoredRiders = $this->scoreRiders($riders, $config['scoring']);
|
||
|
|
|
||
|
|
// Use broadcast or direct assignment
|
||
|
|
if ($config['broadcast']['enabled']) {
|
||
|
|
$this->broadcastToRiders($scoredRiders->take($config['broadcast']['max_riders']), $firebase);
|
||
|
|
} else {
|
||
|
|
$this->directAssign($scoredRiders->first());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Find available riders within the assignment radius.
|
||
|
|
*/
|
||
|
|
protected function findAvailableRiders()
|
||
|
|
{
|
||
|
|
$radius = config('restaurant-delivery.assignment.assignment_radius', 5);
|
||
|
|
$maxConcurrent = config('restaurant-delivery.assignment.max_concurrent_orders', 3);
|
||
|
|
|
||
|
|
$query = Rider::query()
|
||
|
|
->where('status', 'available')
|
||
|
|
->where('is_online', true)
|
||
|
|
->where('is_verified', true)
|
||
|
|
->whereNotNull('current_latitude')
|
||
|
|
->whereNotNull('current_longitude');
|
||
|
|
|
||
|
|
// Filter by restaurant for SaaS multi-tenant
|
||
|
|
if ($this->delivery->restaurant_id) {
|
||
|
|
$query->where('restaurant_id', $this->delivery->restaurant_id);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Filter riders with less than max concurrent orders
|
||
|
|
$query->withCount(['deliveries' => function ($q) {
|
||
|
|
$q->whereIn('status', ['rider_assigned', 'rider_at_restaurant', 'picked_up', 'on_the_way', 'arrived']);
|
||
|
|
}])->having('deliveries_count', '<', $maxConcurrent);
|
||
|
|
|
||
|
|
// Calculate distance and filter by radius
|
||
|
|
// Using Haversine formula in raw query for efficiency
|
||
|
|
$lat = $this->delivery->pickup_latitude;
|
||
|
|
$lng = $this->delivery->pickup_longitude;
|
||
|
|
|
||
|
|
$query->selectRaw('
|
||
|
|
*,
|
||
|
|
(6371 * acos(
|
||
|
|
cos(radians(?)) * cos(radians(current_latitude)) * cos(radians(current_longitude) - radians(?))
|
||
|
|
+ sin(radians(?)) * sin(radians(current_latitude))
|
||
|
|
)) AS distance
|
||
|
|
', [$lat, $lng, $lat])
|
||
|
|
->having('distance', '<=', $radius)
|
||
|
|
->orderBy('distance');
|
||
|
|
|
||
|
|
return $query->get();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Score riders based on various criteria.
|
||
|
|
*/
|
||
|
|
protected function scoreRiders($riders, array $weights)
|
||
|
|
{
|
||
|
|
$maxDistance = config('restaurant-delivery.assignment.assignment_radius', 5);
|
||
|
|
|
||
|
|
return $riders->map(function ($rider) use ($weights, $maxDistance) {
|
||
|
|
$score = 0;
|
||
|
|
|
||
|
|
// Distance score (closer is better)
|
||
|
|
$distanceScore = (1 - ($rider->distance / $maxDistance)) * $weights['distance_weight'];
|
||
|
|
$score += $distanceScore;
|
||
|
|
|
||
|
|
// Rating score
|
||
|
|
$ratingScore = ($rider->rating / 5) * $weights['rating_weight'];
|
||
|
|
$score += $ratingScore;
|
||
|
|
|
||
|
|
// Acceptance rate score
|
||
|
|
$acceptanceScore = ($rider->acceptance_rate / 100) * $weights['acceptance_rate_weight'];
|
||
|
|
$score += $acceptanceScore;
|
||
|
|
|
||
|
|
// Current orders score (fewer is better)
|
||
|
|
$ordersScore = (1 - ($rider->deliveries_count / config('restaurant-delivery.assignment.max_concurrent_orders', 3)))
|
||
|
|
* $weights['current_orders_weight'];
|
||
|
|
$score += $ordersScore;
|
||
|
|
|
||
|
|
// Experience score (normalized by max 1000 deliveries)
|
||
|
|
$experienceScore = min($rider->total_deliveries / 1000, 1) * $weights['experience_weight'];
|
||
|
|
$score += $experienceScore;
|
||
|
|
|
||
|
|
$rider->assignment_score = $score;
|
||
|
|
|
||
|
|
return $rider;
|
||
|
|
})->sortByDesc('assignment_score');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Broadcast assignment request to multiple riders.
|
||
|
|
*/
|
||
|
|
protected function broadcastToRiders($riders, FirebaseService $firebase): void
|
||
|
|
{
|
||
|
|
$timeout = config('restaurant-delivery.assignment.broadcast.accept_timeout', 30);
|
||
|
|
|
||
|
|
foreach ($riders as $rider) {
|
||
|
|
// Record assignment attempt
|
||
|
|
$this->delivery->addAssignmentToHistory([
|
||
|
|
'rider_id' => $rider->id,
|
||
|
|
'type' => 'broadcast',
|
||
|
|
'score' => $rider->assignment_score,
|
||
|
|
'distance' => $rider->distance,
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Send push notification to rider
|
||
|
|
if ($rider->fcm_token) {
|
||
|
|
$firebase->sendPushNotification(
|
||
|
|
$rider->fcm_token,
|
||
|
|
'New Delivery Request',
|
||
|
|
"New delivery from {$this->delivery->restaurant_name}. Distance: ".round($rider->distance, 1).' km',
|
||
|
|
[
|
||
|
|
'type' => 'delivery_request',
|
||
|
|
'delivery_id' => (string) $this->delivery->id,
|
||
|
|
'tracking_code' => $this->delivery->tracking_code,
|
||
|
|
'restaurant_name' => $this->delivery->restaurant_name,
|
||
|
|
'pickup_address' => $this->delivery->pickup_address,
|
||
|
|
'drop_address' => $this->delivery->drop_address,
|
||
|
|
'distance' => (string) $this->delivery->distance,
|
||
|
|
'timeout' => (string) $timeout,
|
||
|
|
]
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Log::info('Delivery request broadcasted to riders', [
|
||
|
|
'delivery_id' => $this->delivery->id,
|
||
|
|
'rider_count' => $riders->count(),
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Schedule timeout check
|
||
|
|
dispatch(new CheckAssignmentTimeoutJob($this->delivery))
|
||
|
|
->delay(now()->addSeconds($timeout + 5));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Directly assign to the best scoring rider.
|
||
|
|
*/
|
||
|
|
protected function directAssign(Rider $rider): void
|
||
|
|
{
|
||
|
|
DB::transaction(function () use ($rider) {
|
||
|
|
$this->delivery->assignRider($rider);
|
||
|
|
|
||
|
|
// Record assignment
|
||
|
|
$this->delivery->addAssignmentToHistory([
|
||
|
|
'rider_id' => $rider->id,
|
||
|
|
'type' => 'direct',
|
||
|
|
'score' => $rider->assignment_score ?? 0,
|
||
|
|
'distance' => $rider->distance ?? 0,
|
||
|
|
'assigned_at' => now()->toIso8601String(),
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
Log::info('Rider directly assigned to delivery', [
|
||
|
|
'delivery_id' => $this->delivery->id,
|
||
|
|
'rider_id' => $rider->id,
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle a job failure.
|
||
|
|
*/
|
||
|
|
public function failed(\Throwable $exception): void
|
||
|
|
{
|
||
|
|
Log::error('Failed to assign rider to delivery', [
|
||
|
|
'delivery_id' => $this->delivery->id,
|
||
|
|
'error' => $exception->getMessage(),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
}
|