460 lines
13 KiB
PHP
460 lines
13 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace Modules\RestaurantDelivery\Models;
|
||
|
|
|
||
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||
|
|
use Illuminate\Database\Eloquent\Model;
|
||
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||
|
|
use Modules\Authentication\Models\User;
|
||
|
|
use Modules\RestaurantDelivery\Enums\CommissionType;
|
||
|
|
use Modules\RestaurantDelivery\Enums\RiderStatus;
|
||
|
|
use Modules\RestaurantDelivery\Enums\RiderType;
|
||
|
|
use Modules\RestaurantDelivery\Enums\VehicleType;
|
||
|
|
use Modules\RestaurantDelivery\Traits\HasRestaurant;
|
||
|
|
use Modules\RestaurantDelivery\Traits\HasUuid;
|
||
|
|
|
||
|
|
class Rider extends Model
|
||
|
|
{
|
||
|
|
use HasFactory, HasRestaurant, HasUuid, SoftDeletes;
|
||
|
|
|
||
|
|
public const TABLE_NAME = 'restaurant_riders';
|
||
|
|
|
||
|
|
protected $table = self::TABLE_NAME;
|
||
|
|
|
||
|
|
protected $fillable = [
|
||
|
|
'restaurant_id',
|
||
|
|
'user_id',
|
||
|
|
'first_name',
|
||
|
|
'last_name',
|
||
|
|
'phone',
|
||
|
|
'email',
|
||
|
|
'photo',
|
||
|
|
'date_of_birth',
|
||
|
|
'national_id',
|
||
|
|
'emergency_contact',
|
||
|
|
'type',
|
||
|
|
'status',
|
||
|
|
'vehicle_type',
|
||
|
|
'vehicle_number',
|
||
|
|
'vehicle_model',
|
||
|
|
'vehicle_color',
|
||
|
|
'license_number',
|
||
|
|
'license_expiry',
|
||
|
|
'is_verified',
|
||
|
|
'verified_at',
|
||
|
|
'verified_by',
|
||
|
|
'verification_documents',
|
||
|
|
'commission_type',
|
||
|
|
'commission_rate',
|
||
|
|
'base_commission',
|
||
|
|
'per_km_rate',
|
||
|
|
'current_latitude',
|
||
|
|
'current_longitude',
|
||
|
|
'last_location_update',
|
||
|
|
'rating',
|
||
|
|
'rating_count',
|
||
|
|
'total_deliveries',
|
||
|
|
'successful_deliveries',
|
||
|
|
'cancelled_deliveries',
|
||
|
|
'failed_deliveries',
|
||
|
|
'acceptance_rate',
|
||
|
|
'completion_rate',
|
||
|
|
'is_online',
|
||
|
|
'last_online_at',
|
||
|
|
'went_offline_at',
|
||
|
|
'fcm_token',
|
||
|
|
'device_id',
|
||
|
|
'bank_name',
|
||
|
|
'bank_account_number',
|
||
|
|
'bank_account_name',
|
||
|
|
'mobile_wallet_number',
|
||
|
|
'mobile_wallet_provider',
|
||
|
|
'assigned_zones',
|
||
|
|
'meta',
|
||
|
|
];
|
||
|
|
|
||
|
|
protected $casts = [
|
||
|
|
'date_of_birth' => 'date',
|
||
|
|
'license_expiry' => 'date',
|
||
|
|
'is_verified' => 'boolean',
|
||
|
|
'verified_at' => 'datetime',
|
||
|
|
'is_online' => 'boolean',
|
||
|
|
'last_online_at' => 'datetime',
|
||
|
|
'went_offline_at' => 'datetime',
|
||
|
|
'last_location_update' => 'datetime',
|
||
|
|
'current_latitude' => 'decimal:7',
|
||
|
|
'current_longitude' => 'decimal:7',
|
||
|
|
'rating' => 'decimal:2',
|
||
|
|
'acceptance_rate' => 'decimal:2',
|
||
|
|
'completion_rate' => 'decimal:2',
|
||
|
|
'commission_rate' => 'decimal:2',
|
||
|
|
'base_commission' => 'decimal:2',
|
||
|
|
'per_km_rate' => 'decimal:2',
|
||
|
|
'verification_documents' => 'array',
|
||
|
|
'assigned_zones' => 'array',
|
||
|
|
'meta' => 'array',
|
||
|
|
];
|
||
|
|
|
||
|
|
protected $appends = [
|
||
|
|
'full_name',
|
||
|
|
'photo_url',
|
||
|
|
];
|
||
|
|
|
||
|
|
/*
|
||
|
|
|--------------------------------------------------------------------------
|
||
|
|
| Accessors
|
||
|
|
|--------------------------------------------------------------------------
|
||
|
|
*/
|
||
|
|
|
||
|
|
public function getFullNameAttribute(): string
|
||
|
|
{
|
||
|
|
return trim("{$this->first_name} {$this->last_name}");
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getPhotoUrlAttribute(): ?string
|
||
|
|
{
|
||
|
|
if (! $this->photo) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (filter_var($this->photo, FILTER_VALIDATE_URL)) {
|
||
|
|
return $this->photo;
|
||
|
|
}
|
||
|
|
|
||
|
|
return asset('storage/'.$this->photo);
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
|--------------------------------------------------------------------------
|
||
|
|
| Relationships
|
||
|
|
|--------------------------------------------------------------------------
|
||
|
|
*/
|
||
|
|
public function user(): BelongsTo
|
||
|
|
{
|
||
|
|
return $this->belongsTo(User::class, 'user_id');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function deliveries(): HasMany
|
||
|
|
{
|
||
|
|
return $this->hasMany(Delivery::class, 'rider_id');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function activeDeliveries(): HasMany
|
||
|
|
{
|
||
|
|
return $this->hasMany(Delivery::class, 'rider_id')
|
||
|
|
->whereIn('status', array_map(
|
||
|
|
fn ($s) => $s->value,
|
||
|
|
\Modules\RestaurantDelivery\Enums\DeliveryStatus::riderActiveStatuses()
|
||
|
|
));
|
||
|
|
}
|
||
|
|
|
||
|
|
public function completedDeliveries(): HasMany
|
||
|
|
{
|
||
|
|
return $this->hasMany(Delivery::class, 'rider_id')
|
||
|
|
->where('status', 'delivered');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function ratings(): HasMany
|
||
|
|
{
|
||
|
|
return $this->hasMany(DeliveryRating::class, 'rider_id');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function earnings(): HasMany
|
||
|
|
{
|
||
|
|
return $this->hasMany(RiderEarning::class, 'rider_id');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function tips(): HasMany
|
||
|
|
{
|
||
|
|
return $this->hasMany(DeliveryTip::class, 'rider_id');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function payouts(): HasMany
|
||
|
|
{
|
||
|
|
return $this->hasMany(RiderPayout::class, 'rider_id');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function assignments(): HasMany
|
||
|
|
{
|
||
|
|
return $this->hasMany(DeliveryAssignment::class, 'rider_id');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function locationLogs(): HasMany
|
||
|
|
{
|
||
|
|
return $this->hasMany(LocationLog::class, 'rider_id');
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
|--------------------------------------------------------------------------
|
||
|
|
| Scopes
|
||
|
|
|--------------------------------------------------------------------------
|
||
|
|
*/
|
||
|
|
|
||
|
|
// -------------------------------
|
||
|
|
// Enum Accessors / Mutators
|
||
|
|
// -------------------------------
|
||
|
|
|
||
|
|
public function getTypeAttribute($value): ?RiderType
|
||
|
|
{
|
||
|
|
return $value ? RiderType::tryFrom($value) : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// After (works with DB strings)
|
||
|
|
public function setTypeAttribute($type): void
|
||
|
|
{
|
||
|
|
if ($type instanceof RiderType) {
|
||
|
|
$this->attributes['type'] = $type->value;
|
||
|
|
} elseif (is_string($type)) {
|
||
|
|
$this->attributes['type'] = $type; // assume DB value is valid
|
||
|
|
} else {
|
||
|
|
$this->attributes['type'] = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getStatusAttribute($value): ?RiderStatus
|
||
|
|
{
|
||
|
|
return $value ? RiderStatus::tryFrom($value) : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function setStatusAttribute($status): void
|
||
|
|
{
|
||
|
|
if ($status instanceof RiderStatus) {
|
||
|
|
$this->attributes['status'] = $status->value;
|
||
|
|
} elseif (is_string($status)) {
|
||
|
|
$this->attributes['status'] = $status;
|
||
|
|
} else {
|
||
|
|
$this->attributes['status'] = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getVehicleTypeAttribute($value): ?VehicleType
|
||
|
|
{
|
||
|
|
return $value ? VehicleType::tryFrom($value) : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function setVehicleTypeAttribute($vehicleType): void
|
||
|
|
{
|
||
|
|
if ($vehicleType instanceof VehicleType) {
|
||
|
|
$this->attributes['vehicle_type'] = $vehicleType->value;
|
||
|
|
} elseif (is_string($vehicleType)) {
|
||
|
|
$this->attributes['vehicle_type'] = $vehicleType;
|
||
|
|
} else {
|
||
|
|
$this->attributes['vehicle_type'] = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getCommissionTypeAttribute($value): ?CommissionType
|
||
|
|
{
|
||
|
|
return $value ? CommissionType::tryFrom($value) : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function setCommissionTypeAttribute($type): void
|
||
|
|
{
|
||
|
|
if ($type instanceof CommissionType) {
|
||
|
|
$this->attributes['commission_type'] = $type->value;
|
||
|
|
} elseif (is_string($type)) {
|
||
|
|
$this->attributes['commission_type'] = $type;
|
||
|
|
} else {
|
||
|
|
$this->attributes['commission_type'] = null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public function scopeActive($query)
|
||
|
|
{
|
||
|
|
return $query->where('status', RiderStatus::ACTIVE);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function scopeOnline($query)
|
||
|
|
{
|
||
|
|
return $query->where('is_online', true);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function scopeVerified($query)
|
||
|
|
{
|
||
|
|
return $query->where('is_verified', true);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function scopeAvailable($query)
|
||
|
|
{
|
||
|
|
return $query->active()
|
||
|
|
->online()
|
||
|
|
->verified()
|
||
|
|
->whereDoesntHave('activeDeliveries', function ($q) {
|
||
|
|
$maxConcurrent = config('restaurant-delivery.assignment.max_concurrent_orders', 3);
|
||
|
|
$q->havingRaw('COUNT(*) >= ?', [$maxConcurrent]);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
public function scopeNearby($query, float $latitude, float $longitude, float $radiusKm = 5)
|
||
|
|
{
|
||
|
|
$earthRadius = config('restaurant-delivery.distance.earth_radius_km', 6371);
|
||
|
|
|
||
|
|
return $query->selectRaw("
|
||
|
|
*,
|
||
|
|
({$earthRadius} * acos(
|
||
|
|
cos(radians(?)) * cos(radians(current_latitude)) *
|
||
|
|
cos(radians(current_longitude) - radians(?)) +
|
||
|
|
sin(radians(?)) * sin(radians(current_latitude))
|
||
|
|
)) AS distance
|
||
|
|
", [$latitude, $longitude, $latitude])
|
||
|
|
->having('distance', '<=', $radiusKm)
|
||
|
|
->orderBy('distance');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function scopeWithMinRating($query, float $minRating)
|
||
|
|
{
|
||
|
|
return $query->where('rating', '>=', $minRating);
|
||
|
|
}
|
||
|
|
|
||
|
|
/*
|
||
|
|
|--------------------------------------------------------------------------
|
||
|
|
| Methods
|
||
|
|
|--------------------------------------------------------------------------
|
||
|
|
*/
|
||
|
|
|
||
|
|
public function canAcceptOrders(): bool
|
||
|
|
{
|
||
|
|
return $this->status->canAcceptOrders()
|
||
|
|
&& $this->is_verified
|
||
|
|
&& $this->is_online;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getCurrentOrderCount(): int
|
||
|
|
{
|
||
|
|
return $this->activeDeliveries()->count();
|
||
|
|
}
|
||
|
|
|
||
|
|
public function canTakeMoreOrders(): bool
|
||
|
|
{
|
||
|
|
$maxConcurrent = config('restaurant-delivery.assignment.max_concurrent_orders', 3);
|
||
|
|
|
||
|
|
return $this->getCurrentOrderCount() < $maxConcurrent;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function goOnline(): void
|
||
|
|
{
|
||
|
|
$this->update([
|
||
|
|
'is_online' => true,
|
||
|
|
'last_online_at' => now(),
|
||
|
|
'went_offline_at' => null,
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function goOffline(): void
|
||
|
|
{
|
||
|
|
$this->update([
|
||
|
|
'is_online' => false,
|
||
|
|
'went_offline_at' => now(),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function updateLocation(float $latitude, float $longitude): void
|
||
|
|
{
|
||
|
|
$this->update([
|
||
|
|
'current_latitude' => $latitude,
|
||
|
|
'current_longitude' => $longitude,
|
||
|
|
'last_location_update' => now(),
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function updateRating(float $newRating): void
|
||
|
|
{
|
||
|
|
$totalRating = ($this->rating * $this->rating_count) + $newRating;
|
||
|
|
$newCount = $this->rating_count + 1;
|
||
|
|
|
||
|
|
$this->update([
|
||
|
|
'rating' => round($totalRating / $newCount, 2),
|
||
|
|
'rating_count' => $newCount,
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function recalculateRating(): void
|
||
|
|
{
|
||
|
|
$stats = $this->ratings()
|
||
|
|
->selectRaw('AVG(overall_rating) as avg_rating, COUNT(*) as count')
|
||
|
|
->first();
|
||
|
|
|
||
|
|
$avgRating = (float) ($stats->avg_rating ?? 5.00);
|
||
|
|
$count = (int) ($stats->count ?? 0);
|
||
|
|
|
||
|
|
$this->update([
|
||
|
|
'rating' => round($avgRating, 2),
|
||
|
|
'rating_count' => $count,
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function incrementDeliveryStats(bool $successful = true): void
|
||
|
|
{
|
||
|
|
$this->increment('total_deliveries');
|
||
|
|
|
||
|
|
if ($successful) {
|
||
|
|
$this->increment('successful_deliveries');
|
||
|
|
} else {
|
||
|
|
$this->increment('failed_deliveries');
|
||
|
|
}
|
||
|
|
|
||
|
|
$this->updateCompletionRate();
|
||
|
|
}
|
||
|
|
|
||
|
|
public function incrementCancelledDeliveries(): void
|
||
|
|
{
|
||
|
|
$this->increment('cancelled_deliveries');
|
||
|
|
$this->updateAcceptanceRate();
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function updateCompletionRate(): void
|
||
|
|
{
|
||
|
|
if ($this->total_deliveries > 0) {
|
||
|
|
$rate = ($this->successful_deliveries / $this->total_deliveries) * 100;
|
||
|
|
$this->update(['completion_rate' => round($rate, 2)]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function updateAcceptanceRate(): void
|
||
|
|
{
|
||
|
|
$totalAssignments = $this->assignments()->count();
|
||
|
|
$acceptedAssignments = $this->assignments()->where('status', 'accepted')->count();
|
||
|
|
|
||
|
|
if ($totalAssignments > 0) {
|
||
|
|
$rate = ($acceptedAssignments / $totalAssignments) * 100;
|
||
|
|
$this->update(['acceptance_rate' => round($rate, 2)]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public function calculateEarningForDelivery(Delivery $delivery): float
|
||
|
|
{
|
||
|
|
$deliveryCharge = $delivery->total_delivery_charge;
|
||
|
|
|
||
|
|
return match ($this->commission_type) {
|
||
|
|
CommissionType::FIXED => $this->commission_rate,
|
||
|
|
CommissionType::PERCENTAGE => ($deliveryCharge * $this->commission_rate) / 100,
|
||
|
|
CommissionType::PER_KM => $delivery->distance * $this->commission_rate,
|
||
|
|
CommissionType::HYBRID => $this->base_commission + ($delivery->distance * ($this->per_km_rate ?? 0)),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getDistanceTo(float $latitude, float $longitude): float
|
||
|
|
{
|
||
|
|
if (! $this->current_latitude || ! $this->current_longitude) {
|
||
|
|
return PHP_FLOAT_MAX;
|
||
|
|
}
|
||
|
|
|
||
|
|
$earthRadius = config('restaurant-delivery.distance.earth_radius_km', 6371);
|
||
|
|
|
||
|
|
$dLat = deg2rad($latitude - $this->current_latitude);
|
||
|
|
$dLng = deg2rad($longitude - $this->current_longitude);
|
||
|
|
|
||
|
|
$a = sin($dLat / 2) * sin($dLat / 2) +
|
||
|
|
cos(deg2rad($this->current_latitude)) * cos(deg2rad($latitude)) *
|
||
|
|
sin($dLng / 2) * sin($dLng / 2);
|
||
|
|
|
||
|
|
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
|
||
|
|
|
||
|
|
return round($earthRadius * $c, 2);
|
||
|
|
}
|
||
|
|
}
|