Files

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(),
]);
}
}