migrate to gtea from bistbucket
This commit is contained in:
@@ -0,0 +1,540 @@
|
||||
<?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\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\RestaurantDelivery\Enums\DeliveryStatus;
|
||||
use Modules\RestaurantDelivery\Events\DeliveryCreated;
|
||||
use Modules\RestaurantDelivery\Events\DeliveryStatusChanged;
|
||||
use Modules\RestaurantDelivery\Traits\HasRestaurant;
|
||||
use Modules\RestaurantDelivery\Traits\HasUuid;
|
||||
|
||||
class Delivery extends Model
|
||||
{
|
||||
use HasFactory, HasRestaurant, HasUuid, SoftDeletes;
|
||||
|
||||
public const TABLE_NAME = 'restaurant_deliveries';
|
||||
|
||||
protected $table = self::TABLE_NAME;
|
||||
|
||||
protected $fillable = [
|
||||
'tracking_code',
|
||||
'restaurant_id',
|
||||
'rider_id',
|
||||
'zone_id',
|
||||
'orderable_type',
|
||||
'orderable_id',
|
||||
'status',
|
||||
'previous_status',
|
||||
'status_changed_at',
|
||||
'restaurant_name',
|
||||
'pickup_address',
|
||||
'pickup_latitude',
|
||||
'pickup_longitude',
|
||||
'pickup_contact_name',
|
||||
'pickup_contact_phone',
|
||||
'pickup_instructions',
|
||||
'customer_name',
|
||||
'drop_address',
|
||||
'drop_latitude',
|
||||
'drop_longitude',
|
||||
'drop_contact_name',
|
||||
'drop_contact_phone',
|
||||
'drop_instructions',
|
||||
'drop_floor',
|
||||
'drop_apartment',
|
||||
'distance',
|
||||
'distance_unit',
|
||||
'estimated_duration',
|
||||
'estimated_pickup_time',
|
||||
'estimated_delivery_time',
|
||||
'food_ready_at',
|
||||
'rider_assigned_at',
|
||||
'rider_accepted_at',
|
||||
'rider_at_restaurant_at',
|
||||
'picked_up_at',
|
||||
'on_the_way_at',
|
||||
'arrived_at',
|
||||
'delivered_at',
|
||||
'cancelled_at',
|
||||
'failed_at',
|
||||
'base_fare',
|
||||
'distance_charge',
|
||||
'surge_charge',
|
||||
'surge_multiplier',
|
||||
'peak_hour_charge',
|
||||
'late_night_charge',
|
||||
'small_order_fee',
|
||||
'total_delivery_charge',
|
||||
'charge_breakdown',
|
||||
'tip_amount',
|
||||
'tip_type',
|
||||
'tip_paid_at',
|
||||
'order_value',
|
||||
'cancellation_reason',
|
||||
'cancelled_by',
|
||||
'cancellation_notes',
|
||||
'failure_reason',
|
||||
'failure_notes',
|
||||
'route_polyline',
|
||||
'route_data',
|
||||
'assignment_attempts',
|
||||
'reassignment_count',
|
||||
'assignment_history',
|
||||
'delivery_photo',
|
||||
'signature',
|
||||
'recipient_name',
|
||||
'customer_notified',
|
||||
'notification_log',
|
||||
'is_priority',
|
||||
'is_scheduled',
|
||||
'scheduled_for',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'status' => DeliveryStatus::class, // <-- cast status to enum
|
||||
'pickup_latitude' => 'decimal:7',
|
||||
'pickup_longitude' => 'decimal:7',
|
||||
'drop_latitude' => 'decimal:7',
|
||||
'drop_longitude' => 'decimal:7',
|
||||
'distance' => 'decimal:2',
|
||||
'base_fare' => 'decimal:2',
|
||||
'distance_charge' => 'decimal:2',
|
||||
'surge_charge' => 'decimal:2',
|
||||
'surge_multiplier' => 'decimal:2',
|
||||
'peak_hour_charge' => 'decimal:2',
|
||||
'late_night_charge' => 'decimal:2',
|
||||
'small_order_fee' => 'decimal:2',
|
||||
'total_delivery_charge' => 'decimal:2',
|
||||
'tip_amount' => 'decimal:2',
|
||||
'order_value' => 'decimal:2',
|
||||
'status_changed_at' => 'datetime',
|
||||
'estimated_pickup_time' => 'datetime',
|
||||
'estimated_delivery_time' => 'datetime',
|
||||
'food_ready_at' => 'datetime',
|
||||
'rider_assigned_at' => 'datetime',
|
||||
'rider_accepted_at' => 'datetime',
|
||||
'rider_at_restaurant_at' => 'datetime',
|
||||
'picked_up_at' => 'datetime',
|
||||
'on_the_way_at' => 'datetime',
|
||||
'arrived_at' => 'datetime',
|
||||
'delivered_at' => 'datetime',
|
||||
'cancelled_at' => 'datetime',
|
||||
'failed_at' => 'datetime',
|
||||
'tip_paid_at' => 'datetime',
|
||||
'scheduled_for' => 'datetime',
|
||||
'is_priority' => 'boolean',
|
||||
'is_scheduled' => 'boolean',
|
||||
'customer_notified' => 'boolean',
|
||||
'charge_breakdown' => 'array',
|
||||
'route_data' => 'array',
|
||||
'assignment_history' => 'array',
|
||||
'notification_log' => 'array',
|
||||
'meta' => 'array',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($delivery) {
|
||||
if (empty($delivery->tracking_code)) {
|
||||
$delivery->tracking_code = static::generateTrackingCode();
|
||||
}
|
||||
});
|
||||
|
||||
static::created(function ($delivery) {
|
||||
event(new DeliveryCreated($delivery));
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Relationships
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function orderable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function rider(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Rider::class, 'rider_id');
|
||||
}
|
||||
|
||||
public function zone(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DeliveryZone::class, 'zone_id');
|
||||
}
|
||||
|
||||
public function rating(): HasOne
|
||||
{
|
||||
return $this->hasOne(DeliveryRating::class, 'delivery_id')
|
||||
->where('is_restaurant_rating', false);
|
||||
}
|
||||
|
||||
public function restaurantRating(): HasOne
|
||||
{
|
||||
return $this->hasOne(DeliveryRating::class, 'delivery_id')
|
||||
->where('is_restaurant_rating', true);
|
||||
}
|
||||
|
||||
public function ratings(): HasMany
|
||||
{
|
||||
return $this->hasMany(DeliveryRating::class, 'delivery_id');
|
||||
}
|
||||
|
||||
public function tip(): HasOne
|
||||
{
|
||||
return $this->hasOne(DeliveryTip::class, 'delivery_id');
|
||||
}
|
||||
|
||||
public function assignments(): HasMany
|
||||
{
|
||||
return $this->hasMany(DeliveryAssignment::class, 'delivery_id');
|
||||
}
|
||||
|
||||
public function statusHistory(): HasMany
|
||||
{
|
||||
return $this->hasMany(DeliveryStatusHistory::class, 'delivery_id')
|
||||
->orderBy('changed_at', 'desc');
|
||||
}
|
||||
|
||||
public function locationLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(LocationLog::class, 'delivery_id')
|
||||
->orderBy('recorded_at', 'desc');
|
||||
}
|
||||
|
||||
public function riderEarning(): HasOne
|
||||
{
|
||||
return $this->hasOne(RiderEarning::class, 'delivery_id')
|
||||
->where('type', 'delivery');
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Scopes
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->whereIn('status', array_map(
|
||||
fn ($s) => $s->value,
|
||||
DeliveryStatus::activeStatuses()
|
||||
));
|
||||
}
|
||||
|
||||
public function scopeCompleted($query)
|
||||
{
|
||||
return $query->where('status', DeliveryStatus::DELIVERED);
|
||||
}
|
||||
|
||||
public function scopeCancelled($query)
|
||||
{
|
||||
return $query->where('status', DeliveryStatus::CANCELLED);
|
||||
}
|
||||
|
||||
public function scopeFailed($query)
|
||||
{
|
||||
return $query->where('status', DeliveryStatus::FAILED);
|
||||
}
|
||||
|
||||
public function scopeForRider($query, int $riderId)
|
||||
{
|
||||
return $query->where('rider_id', $riderId);
|
||||
}
|
||||
|
||||
public function scopeForRestaurant($query, int $restaurantId)
|
||||
{
|
||||
return $query->where('restaurant_id', $restaurantId);
|
||||
}
|
||||
|
||||
public function scopeNeedsRider($query)
|
||||
{
|
||||
return $query->where('status', DeliveryStatus::READY_FOR_PICKUP)
|
||||
->whereNull('rider_id');
|
||||
}
|
||||
|
||||
public function scopeScheduledFor($query, $date)
|
||||
{
|
||||
return $query->where('is_scheduled', true)
|
||||
->whereDate('scheduled_for', $date);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Status Methods
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function updateStatus(string|DeliveryStatus $newStatus, ?array $metadata = null): bool
|
||||
{
|
||||
if (is_string($newStatus)) {
|
||||
$newStatus = DeliveryStatus::from($newStatus);
|
||||
}
|
||||
|
||||
if (! $this->status->canTransitionTo($newStatus)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$previousStatus = $this->status;
|
||||
|
||||
$this->previous_status = $previousStatus->value;
|
||||
$this->status = $newStatus;
|
||||
$this->status_changed_at = now();
|
||||
|
||||
// Set specific timestamps
|
||||
$this->setStatusTimestamp($newStatus);
|
||||
|
||||
$this->save();
|
||||
|
||||
// Record status history
|
||||
DeliveryStatusHistory::create([
|
||||
'delivery_id' => $this->id,
|
||||
'rider_id' => $this->rider_id,
|
||||
'from_status' => $previousStatus->value,
|
||||
'to_status' => $newStatus->value,
|
||||
'changed_by_type' => $metadata['changed_by_type'] ?? 'system',
|
||||
'changed_by_id' => $metadata['changed_by_id'] ?? null,
|
||||
'latitude' => $metadata['latitude'] ?? null,
|
||||
'longitude' => $metadata['longitude'] ?? null,
|
||||
'notes' => $metadata['notes'] ?? null,
|
||||
'meta' => $metadata['meta'] ?? null,
|
||||
'changed_at' => now(),
|
||||
]);
|
||||
|
||||
event(new DeliveryStatusChanged($this, $previousStatus, $metadata));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function setStatusTimestamp(DeliveryStatus $status): void
|
||||
{
|
||||
$timestampMap = [
|
||||
DeliveryStatus::READY_FOR_PICKUP->value => 'food_ready_at',
|
||||
DeliveryStatus::RIDER_ASSIGNED->value => 'rider_assigned_at',
|
||||
DeliveryStatus::RIDER_AT_RESTAURANT->value => 'rider_at_restaurant_at',
|
||||
DeliveryStatus::PICKED_UP->value => 'picked_up_at',
|
||||
DeliveryStatus::ON_THE_WAY->value => 'on_the_way_at',
|
||||
DeliveryStatus::ARRIVED->value => 'arrived_at',
|
||||
DeliveryStatus::DELIVERED->value => 'delivered_at',
|
||||
DeliveryStatus::CANCELLED->value => 'cancelled_at',
|
||||
DeliveryStatus::FAILED->value => 'failed_at',
|
||||
];
|
||||
|
||||
if (isset($timestampMap[$status->value])) {
|
||||
$this->{$timestampMap[$status->value]} = now();
|
||||
}
|
||||
}
|
||||
|
||||
public function confirm(): bool
|
||||
{
|
||||
return $this->updateStatus(DeliveryStatus::CONFIRMED);
|
||||
}
|
||||
|
||||
public function markPreparing(): bool
|
||||
{
|
||||
return $this->updateStatus(DeliveryStatus::PREPARING);
|
||||
}
|
||||
|
||||
public function markReadyForPickup(): bool
|
||||
{
|
||||
return $this->updateStatus(DeliveryStatus::READY_FOR_PICKUP);
|
||||
}
|
||||
|
||||
public function assignRider(Rider $rider): bool
|
||||
{
|
||||
$this->rider_id = $rider->id;
|
||||
$this->rider_assigned_at = now();
|
||||
$this->save();
|
||||
|
||||
return $this->updateStatus(DeliveryStatus::RIDER_ASSIGNED);
|
||||
}
|
||||
|
||||
public function markRiderAtRestaurant(): bool
|
||||
{
|
||||
return $this->updateStatus(DeliveryStatus::RIDER_AT_RESTAURANT);
|
||||
}
|
||||
|
||||
public function markPickedUp(): bool
|
||||
{
|
||||
return $this->updateStatus(DeliveryStatus::PICKED_UP);
|
||||
}
|
||||
|
||||
public function markOnTheWay(): bool
|
||||
{
|
||||
return $this->updateStatus(DeliveryStatus::ON_THE_WAY);
|
||||
}
|
||||
|
||||
public function markArrived(): bool
|
||||
{
|
||||
return $this->updateStatus(DeliveryStatus::ARRIVED);
|
||||
}
|
||||
|
||||
public function markDelivered(array $proofData = []): bool
|
||||
{
|
||||
if (! empty($proofData)) {
|
||||
$this->fill([
|
||||
'delivery_photo' => $proofData['photo'] ?? null,
|
||||
'signature' => $proofData['signature'] ?? null,
|
||||
'recipient_name' => $proofData['recipient_name'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->updateStatus(DeliveryStatus::DELIVERED);
|
||||
}
|
||||
|
||||
public function cancel(string $reason, string $cancelledBy, ?string $notes = null): bool
|
||||
{
|
||||
$this->cancellation_reason = $reason;
|
||||
$this->cancelled_by = $cancelledBy;
|
||||
$this->cancellation_notes = $notes;
|
||||
|
||||
return $this->updateStatus(DeliveryStatus::CANCELLED, [
|
||||
'changed_by_type' => $cancelledBy,
|
||||
'notes' => $notes,
|
||||
]);
|
||||
}
|
||||
|
||||
public function markFailed(string $reason, ?string $notes = null): bool
|
||||
{
|
||||
$this->failure_reason = $reason;
|
||||
$this->failure_notes = $notes;
|
||||
|
||||
return $this->updateStatus(DeliveryStatus::FAILED, [
|
||||
'notes' => $notes,
|
||||
]);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Helper Methods
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public static function generateTrackingCode(): string
|
||||
{
|
||||
do {
|
||||
$code = 'RD'.strtoupper(Str::random(8));
|
||||
} while (static::where('tracking_code', $code)->exists());
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status->isActive();
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function isTrackable(): bool
|
||||
{
|
||||
return $this->status->isTrackable() && $this->rider_id !== null;
|
||||
}
|
||||
|
||||
public function hasRider(): bool
|
||||
{
|
||||
return $this->rider_id !== null;
|
||||
}
|
||||
|
||||
public function canBeRated(): bool
|
||||
{
|
||||
if (! $this->isCompleted() || ! $this->delivered_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$window = config('restaurant-delivery.rating.rating_window', 72);
|
||||
|
||||
return $this->delivered_at->diffInHours(now()) <= $window ?? null;
|
||||
}
|
||||
|
||||
public function canReceiveTip(): bool
|
||||
{
|
||||
if (! $this->hasRider()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if post-delivery tip is allowed
|
||||
if ($this->isCompleted() && $this->delivered_at) {
|
||||
$window = config('restaurant-delivery.tip.post_delivery_window', 24);
|
||||
|
||||
return $this->delivered_at->diffInHours(now()) <= $window;
|
||||
}
|
||||
|
||||
// Pre-delivery tip is allowed for active deliveries
|
||||
return config('restaurant-delivery.tip.allow_pre_delivery', true) && $this->isActive();
|
||||
}
|
||||
|
||||
public function getTotalChargeWithTip(): float
|
||||
{
|
||||
return $this->total_delivery_charge + $this->tip_amount;
|
||||
}
|
||||
|
||||
public function getActualDuration(): ?int
|
||||
{
|
||||
if (! $this->delivered_at) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->created_at->diffInMinutes($this->delivered_at);
|
||||
}
|
||||
|
||||
public function wasDelayed(): bool
|
||||
{
|
||||
if (! $this->estimated_delivery_time || ! $this->delivered_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->delivered_at->gt($this->estimated_delivery_time);
|
||||
}
|
||||
|
||||
public function getDelayMinutes(): ?int
|
||||
{
|
||||
if (! $this->wasDelayed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->estimated_delivery_time->diffInMinutes($this->delivered_at);
|
||||
}
|
||||
|
||||
public function addAssignmentToHistory(array $assignmentData): void
|
||||
{
|
||||
$history = $this->assignment_history ?? [];
|
||||
$history[] = array_merge($assignmentData, ['timestamp' => now()->toIso8601String()]);
|
||||
|
||||
$this->update([
|
||||
'assignment_history' => $history,
|
||||
'assignment_attempts' => count($history),
|
||||
]);
|
||||
}
|
||||
|
||||
public function logNotification(string $type, array $data = []): void
|
||||
{
|
||||
$log = $this->notification_log ?? [];
|
||||
$log[] = [
|
||||
'type' => $type,
|
||||
'data' => $data,
|
||||
'sent_at' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
$this->update(['notification_log' => $log]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<?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 Modules\RestaurantDelivery\Traits\HasRestaurant;
|
||||
use Modules\RestaurantDelivery\Traits\HasUuid;
|
||||
|
||||
class DeliveryAssignment extends Model
|
||||
{
|
||||
use HasFactory, HasRestaurant, HasUuid;
|
||||
|
||||
public const TABLE_NAME = 'restaurant_delivery_assignments';
|
||||
|
||||
protected $table = self::TABLE_NAME;
|
||||
|
||||
protected $fillable = [
|
||||
'delivery_id',
|
||||
'rider_id',
|
||||
'restaurant_id',
|
||||
'status',
|
||||
'attempt_number',
|
||||
'assignment_type',
|
||||
'assigned_by',
|
||||
'score',
|
||||
'score_breakdown',
|
||||
'distance_to_restaurant',
|
||||
'estimated_arrival_time',
|
||||
'notified_at',
|
||||
'responded_at',
|
||||
'expires_at',
|
||||
'rejection_reason',
|
||||
'rejection_notes',
|
||||
'rider_latitude',
|
||||
'rider_longitude',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'score' => 'decimal:2',
|
||||
'score_breakdown' => 'array',
|
||||
'distance_to_restaurant' => 'decimal:2',
|
||||
'notified_at' => 'datetime',
|
||||
'responded_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'rider_latitude' => 'decimal:7',
|
||||
'rider_longitude' => 'decimal:7',
|
||||
];
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Relationships
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function delivery(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Delivery::class, 'delivery_id');
|
||||
}
|
||||
|
||||
public function rider(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Rider::class, 'rider_id');
|
||||
}
|
||||
|
||||
public function assignedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(config('auth.providers.users.model'), 'assigned_by');
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Scopes
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('status', 'pending');
|
||||
}
|
||||
|
||||
public function scopeAccepted($query)
|
||||
{
|
||||
return $query->where('status', 'accepted');
|
||||
}
|
||||
|
||||
public function scopeRejected($query)
|
||||
{
|
||||
return $query->where('status', 'rejected');
|
||||
}
|
||||
|
||||
public function scopeExpired($query)
|
||||
{
|
||||
return $query->where('status', 'expired');
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->whereIn('status', ['pending', 'accepted']);
|
||||
}
|
||||
|
||||
public function scopeNotExpired($query)
|
||||
{
|
||||
return $query->where(function ($q) {
|
||||
$q->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Methods
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function accept(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'accepted',
|
||||
'responded_at' => now(),
|
||||
]);
|
||||
|
||||
// Reject all other pending assignments for this delivery
|
||||
DeliveryAssignment::where('delivery_id', $this->delivery_id)
|
||||
->where('id', '!=', $this->id)
|
||||
->where('status', 'pending')
|
||||
->update([
|
||||
'status' => 'cancelled',
|
||||
'responded_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function reject(string $reason, ?string $notes = null): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'rejected',
|
||||
'responded_at' => now(),
|
||||
'rejection_reason' => $reason,
|
||||
'rejection_notes' => $notes,
|
||||
]);
|
||||
}
|
||||
|
||||
public function expire(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'expired',
|
||||
'responded_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function cancel(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'cancelled',
|
||||
'responded_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === 'pending';
|
||||
}
|
||||
|
||||
public function isAccepted(): bool
|
||||
{
|
||||
return $this->status === 'accepted';
|
||||
}
|
||||
|
||||
public function isRejected(): bool
|
||||
{
|
||||
return $this->status === 'rejected';
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
if ($this->status === 'expired') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->expires_at && $this->expires_at->isPast()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function hasExpired(): bool
|
||||
{
|
||||
return $this->expires_at && $this->expires_at->isPast();
|
||||
}
|
||||
|
||||
public function getResponseTime(): ?int
|
||||
{
|
||||
if (! $this->responded_at || ! $this->notified_at) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->notified_at->diffInSeconds($this->responded_at);
|
||||
}
|
||||
|
||||
public static function getRejectionReasons(): array
|
||||
{
|
||||
return [
|
||||
'too_far' => 'Restaurant is too far',
|
||||
'busy' => 'Currently busy',
|
||||
'ending_shift' => 'Ending my shift',
|
||||
'vehicle_issue' => 'Vehicle issue',
|
||||
'low_battery' => 'Phone/GPS low battery',
|
||||
'personal' => 'Personal reason',
|
||||
'other' => 'Other',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
<?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\SoftDeletes;
|
||||
use Modules\RestaurantDelivery\Traits\HasRestaurant;
|
||||
use Modules\RestaurantDelivery\Traits\HasUuid;
|
||||
|
||||
class DeliveryRating extends Model
|
||||
{
|
||||
use HasFactory, HasRestaurant, HasUuid, SoftDeletes;
|
||||
|
||||
public const TABLE_NAME = 'restaurant_delivery_ratings';
|
||||
|
||||
protected $table = self::TABLE_NAME;
|
||||
|
||||
protected $fillable = [
|
||||
'delivery_id',
|
||||
'rider_id',
|
||||
'customer_id',
|
||||
'restaurant_id',
|
||||
'overall_rating',
|
||||
'speed_rating',
|
||||
'communication_rating',
|
||||
'food_condition_rating',
|
||||
'professionalism_rating',
|
||||
'review',
|
||||
'is_anonymous',
|
||||
'tags',
|
||||
'is_restaurant_rating',
|
||||
'is_approved',
|
||||
'is_featured',
|
||||
'moderation_status',
|
||||
'moderation_notes',
|
||||
'rider_response',
|
||||
'rider_responded_at',
|
||||
'helpful_count',
|
||||
'not_helpful_count',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'overall_rating' => 'integer',
|
||||
'speed_rating' => 'integer',
|
||||
'communication_rating' => 'integer',
|
||||
'food_condition_rating' => 'integer',
|
||||
'professionalism_rating' => 'integer',
|
||||
'is_anonymous' => 'boolean',
|
||||
'is_restaurant_rating' => 'boolean',
|
||||
'is_approved' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
'tags' => 'array',
|
||||
'rider_responded_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
'average_category_rating',
|
||||
'star_display',
|
||||
];
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Relationships
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function delivery(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Delivery::class, 'delivery_id');
|
||||
}
|
||||
|
||||
public function rider(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Rider::class, 'rider_id');
|
||||
}
|
||||
|
||||
public function customer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(config('auth.providers.users.model'), 'customer_id');
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Accessors
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function getAverageCategoryRatingAttribute(): ?float
|
||||
{
|
||||
$ratings = array_filter([
|
||||
$this->speed_rating,
|
||||
$this->communication_rating,
|
||||
$this->food_condition_rating,
|
||||
$this->professionalism_rating,
|
||||
]);
|
||||
|
||||
if (empty($ratings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return round(array_sum($ratings) / count($ratings), 2);
|
||||
}
|
||||
|
||||
public function getStarDisplayAttribute(): string
|
||||
{
|
||||
$filled = str_repeat('★', $this->overall_rating);
|
||||
$empty = str_repeat('☆', 5 - $this->overall_rating);
|
||||
|
||||
return $filled.$empty;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Scopes
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function scopeApproved($query)
|
||||
{
|
||||
return $query->where('is_approved', true);
|
||||
}
|
||||
|
||||
public function scopeFeatured($query)
|
||||
{
|
||||
return $query->where('is_featured', true);
|
||||
}
|
||||
|
||||
public function scopeFromCustomers($query)
|
||||
{
|
||||
return $query->where('is_restaurant_rating', false);
|
||||
}
|
||||
|
||||
public function scopeFromRestaurants($query)
|
||||
{
|
||||
return $query->where('is_restaurant_rating', true);
|
||||
}
|
||||
|
||||
public function scopeWithReview($query)
|
||||
{
|
||||
return $query->whereNotNull('review');
|
||||
}
|
||||
|
||||
public function scopeHighRated($query, int $minRating = 4)
|
||||
{
|
||||
return $query->where('overall_rating', '>=', $minRating);
|
||||
}
|
||||
|
||||
public function scopeLowRated($query, int $maxRating = 2)
|
||||
{
|
||||
return $query->where('overall_rating', '<=', $maxRating);
|
||||
}
|
||||
|
||||
public function scopeRecent($query, int $days = 30)
|
||||
{
|
||||
return $query->where('created_at', '>=', now()->subDays($days));
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Methods
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function approve(): void
|
||||
{
|
||||
$this->update([
|
||||
'is_approved' => true,
|
||||
'moderation_status' => 'approved',
|
||||
]);
|
||||
}
|
||||
|
||||
public function reject(string $reason): void
|
||||
{
|
||||
$this->update([
|
||||
'is_approved' => false,
|
||||
'moderation_status' => 'rejected',
|
||||
'moderation_notes' => $reason,
|
||||
]);
|
||||
}
|
||||
|
||||
public function feature(): void
|
||||
{
|
||||
$this->update(['is_featured' => true]);
|
||||
}
|
||||
|
||||
public function unfeature(): void
|
||||
{
|
||||
$this->update(['is_featured' => false]);
|
||||
}
|
||||
|
||||
public function addRiderResponse(string $response): void
|
||||
{
|
||||
$this->update([
|
||||
'rider_response' => $response,
|
||||
'rider_responded_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markHelpful(): void
|
||||
{
|
||||
$this->increment('helpful_count');
|
||||
}
|
||||
|
||||
public function markNotHelpful(): void
|
||||
{
|
||||
$this->increment('not_helpful_count');
|
||||
}
|
||||
|
||||
public function getHelpfulnessScore(): float
|
||||
{
|
||||
$total = $this->helpful_count + $this->not_helpful_count;
|
||||
|
||||
if ($total === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->helpful_count / $total) * 100, 2);
|
||||
}
|
||||
|
||||
public function hasTag(string $tag): bool
|
||||
{
|
||||
return in_array($tag, $this->tags ?? []);
|
||||
}
|
||||
|
||||
public function getCategoryRatings(): array
|
||||
{
|
||||
return array_filter([
|
||||
'speed' => $this->speed_rating,
|
||||
'communication' => $this->communication_rating,
|
||||
'food_condition' => $this->food_condition_rating,
|
||||
'professionalism' => $this->professionalism_rating,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPositiveTags(): array
|
||||
{
|
||||
return ['friendly', 'fast', 'careful', 'professional', 'polite', 'on_time'];
|
||||
}
|
||||
|
||||
public static function getNegativeTags(): array
|
||||
{
|
||||
return ['late', 'rude', 'careless', 'unprofessional', 'no_communication'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<?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 Modules\RestaurantDelivery\Enums\DeliveryStatus;
|
||||
|
||||
class DeliveryStatusHistory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TABLE_NAME = 'restaurant_delivery_status_history';
|
||||
|
||||
protected $table = self::TABLE_NAME;
|
||||
|
||||
protected $fillable = [
|
||||
'delivery_id',
|
||||
'rider_id',
|
||||
'from_status',
|
||||
'to_status',
|
||||
'changed_by_type',
|
||||
'changed_by_id',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'notes',
|
||||
'meta',
|
||||
'duration_from_previous',
|
||||
'duration_from_start',
|
||||
'changed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'latitude' => 'decimal:7',
|
||||
'longitude' => 'decimal:7',
|
||||
'meta' => 'array',
|
||||
'changed_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($history) {
|
||||
// Calculate durations
|
||||
$delivery = Delivery::find($history->delivery_id);
|
||||
|
||||
if ($delivery) {
|
||||
// Duration from delivery creation
|
||||
$history->duration_from_start = $delivery->created_at->diffInSeconds(now());
|
||||
|
||||
// Duration from previous status
|
||||
$previousHistory = static::where('delivery_id', $history->delivery_id)
|
||||
->orderBy('changed_at', 'desc')
|
||||
->first();
|
||||
|
||||
if ($previousHistory) {
|
||||
$history->duration_from_previous = $previousHistory->changed_at->diffInSeconds(now());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Relationships
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function delivery(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Delivery::class, 'delivery_id');
|
||||
}
|
||||
|
||||
public function rider(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Rider::class, 'rider_id');
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Scopes
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function scopeForDelivery($query, int $deliveryId)
|
||||
{
|
||||
return $query->where('delivery_id', $deliveryId);
|
||||
}
|
||||
|
||||
public function scopeByStatus($query, string $status)
|
||||
{
|
||||
return $query->where('to_status', $status);
|
||||
}
|
||||
|
||||
public function scopeRecent($query)
|
||||
{
|
||||
return $query->orderBy('changed_at', 'desc');
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Accessors
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function getFromStatusEnumAttribute(): ?DeliveryStatus
|
||||
{
|
||||
return $this->from_status ? DeliveryStatus::tryFrom($this->from_status) : null;
|
||||
}
|
||||
|
||||
public function getToStatusEnumAttribute(): DeliveryStatus
|
||||
{
|
||||
return DeliveryStatus::from($this->to_status);
|
||||
}
|
||||
|
||||
public function getFromStatusLabelAttribute(): ?string
|
||||
{
|
||||
return $this->from_status_enum?->label();
|
||||
}
|
||||
|
||||
public function getToStatusLabelAttribute(): string
|
||||
{
|
||||
return $this->to_status_enum->label();
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Methods
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function getDurationFormatted(): string
|
||||
{
|
||||
if (! $this->duration_from_previous) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
$seconds = $this->duration_from_previous;
|
||||
|
||||
if ($seconds < 60) {
|
||||
return "{$seconds}s";
|
||||
}
|
||||
|
||||
$minutes = floor($seconds / 60);
|
||||
$remainingSeconds = $seconds % 60;
|
||||
|
||||
if ($minutes < 60) {
|
||||
return "{$minutes}m {$remainingSeconds}s";
|
||||
}
|
||||
|
||||
$hours = floor($minutes / 60);
|
||||
$remainingMinutes = $minutes % 60;
|
||||
|
||||
return "{$hours}h {$remainingMinutes}m";
|
||||
}
|
||||
|
||||
public function hasLocation(): bool
|
||||
{
|
||||
return $this->latitude !== null && $this->longitude !== null;
|
||||
}
|
||||
|
||||
public function getCoordinates(): ?array
|
||||
{
|
||||
if (! $this->hasLocation()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'lat' => $this->latitude,
|
||||
'lng' => $this->longitude,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getTimeline(int $deliveryId): array
|
||||
{
|
||||
return static::forDelivery($deliveryId)
|
||||
->orderBy('changed_at')
|
||||
->get()
|
||||
->map(fn ($history) => [
|
||||
'status' => $history->to_status,
|
||||
'label' => $history->to_status_label,
|
||||
'color' => $history->to_status_enum->color(),
|
||||
'icon' => $history->to_status_enum->icon(),
|
||||
'changed_at' => $history->changed_at->toIso8601String(),
|
||||
'changed_at_human' => $history->changed_at->diffForHumans(),
|
||||
'duration' => $history->getDurationFormatted(),
|
||||
'notes' => $history->notes,
|
||||
'location' => $history->getCoordinates(),
|
||||
'changed_by' => $history->changed_by_type,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
<?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\SoftDeletes;
|
||||
use Modules\RestaurantDelivery\Traits\HasRestaurant;
|
||||
use Modules\RestaurantDelivery\Traits\HasUuid;
|
||||
|
||||
class DeliveryTip extends Model
|
||||
{
|
||||
use HasFactory, HasRestaurant, HasUuid, SoftDeletes;
|
||||
|
||||
public const TABLE_NAME = 'restaurant_delivery_tips';
|
||||
|
||||
protected $table = self::TABLE_NAME;
|
||||
|
||||
protected $fillable = [
|
||||
'delivery_id',
|
||||
'rider_id',
|
||||
'customer_id',
|
||||
'restaurant_id',
|
||||
'amount',
|
||||
'currency',
|
||||
'type',
|
||||
'calculation_type',
|
||||
'percentage_value',
|
||||
'order_value',
|
||||
'payment_status',
|
||||
'payment_method',
|
||||
'payment_reference',
|
||||
'paid_at',
|
||||
'rider_amount',
|
||||
'platform_amount',
|
||||
'rider_share_percentage',
|
||||
'is_transferred',
|
||||
'transferred_at',
|
||||
'payout_id',
|
||||
'message',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount' => 'decimal:2',
|
||||
'percentage_value' => 'decimal:2',
|
||||
'order_value' => 'decimal:2',
|
||||
'rider_amount' => 'decimal:2',
|
||||
'platform_amount' => 'decimal:2',
|
||||
'rider_share_percentage' => 'decimal:2',
|
||||
'is_transferred' => 'boolean',
|
||||
'paid_at' => 'datetime',
|
||||
'transferred_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($tip) {
|
||||
// Calculate rider and platform amounts
|
||||
$riderShare = config('restaurant-delivery.tip.rider_share', 100);
|
||||
$tip->rider_share_percentage = $riderShare;
|
||||
$tip->rider_amount = ($tip->amount * $riderShare) / 100;
|
||||
$tip->platform_amount = $tip->amount - $tip->rider_amount;
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Relationships
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function delivery(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Delivery::class, 'delivery_id');
|
||||
}
|
||||
|
||||
public function rider(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Rider::class, 'rider_id');
|
||||
}
|
||||
|
||||
public function customer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(config('auth.providers.users.model'), 'customer_id');
|
||||
}
|
||||
|
||||
public function payout(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(RiderPayout::class, 'payout_id');
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Scopes
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('payment_status', 'pending');
|
||||
}
|
||||
|
||||
public function scopePaid($query)
|
||||
{
|
||||
return $query->where('payment_status', 'captured');
|
||||
}
|
||||
|
||||
public function scopeTransferred($query)
|
||||
{
|
||||
return $query->where('is_transferred', true);
|
||||
}
|
||||
|
||||
public function scopeNotTransferred($query)
|
||||
{
|
||||
return $query->where('is_transferred', false)
|
||||
->where('payment_status', 'captured');
|
||||
}
|
||||
|
||||
public function scopePreDelivery($query)
|
||||
{
|
||||
return $query->where('type', 'pre_delivery');
|
||||
}
|
||||
|
||||
public function scopePostDelivery($query)
|
||||
{
|
||||
return $query->where('type', 'post_delivery');
|
||||
}
|
||||
|
||||
public function scopeForPeriod($query, $startDate, $endDate)
|
||||
{
|
||||
return $query->whereBetween('created_at', [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Methods
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function markAsPaid(string $paymentReference, string $paymentMethod): void
|
||||
{
|
||||
$this->update([
|
||||
'payment_status' => 'captured',
|
||||
'payment_reference' => $paymentReference,
|
||||
'payment_method' => $paymentMethod,
|
||||
'paid_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsTransferred(int $payoutId): void
|
||||
{
|
||||
$this->update([
|
||||
'is_transferred' => true,
|
||||
'transferred_at' => now(),
|
||||
'payout_id' => $payoutId,
|
||||
'payment_status' => 'transferred',
|
||||
]);
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->payment_status === 'pending';
|
||||
}
|
||||
|
||||
public function isPaid(): bool
|
||||
{
|
||||
return $this->payment_status === 'captured';
|
||||
}
|
||||
|
||||
public function isTransferred(): bool
|
||||
{
|
||||
return $this->is_transferred;
|
||||
}
|
||||
|
||||
public function isPreDelivery(): bool
|
||||
{
|
||||
return $this->type === 'pre_delivery';
|
||||
}
|
||||
|
||||
public function isPostDelivery(): bool
|
||||
{
|
||||
return $this->type === 'post_delivery';
|
||||
}
|
||||
|
||||
public static function calculateFromPercentage(float $orderValue, float $percentage): float
|
||||
{
|
||||
return round(($orderValue * $percentage) / 100, 2);
|
||||
}
|
||||
|
||||
public static function getPresetAmounts(): array
|
||||
{
|
||||
return config('restaurant-delivery.tip.preset_amounts', [20, 50, 100, 200]);
|
||||
}
|
||||
|
||||
public static function getPresetPercentages(): array
|
||||
{
|
||||
return config('restaurant-delivery.tip.preset_percentages', [5, 10, 15, 20]);
|
||||
}
|
||||
|
||||
public static function isValidAmount(float $amount): bool
|
||||
{
|
||||
$minTip = config('restaurant-delivery.tip.min_tip', 10);
|
||||
$maxTip = config('restaurant-delivery.tip.max_tip', 1000);
|
||||
|
||||
return $amount >= $minTip && $amount <= $maxTip;
|
||||
}
|
||||
|
||||
public static function isValidPercentage(float $percentage): bool
|
||||
{
|
||||
$maxPercentage = config('restaurant-delivery.tip.max_percentage', 50);
|
||||
|
||||
return $percentage > 0 && $percentage <= $maxPercentage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?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\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Modules\RestaurantDelivery\Traits\HasRestaurant;
|
||||
use Modules\RestaurantDelivery\Traits\HasUuid;
|
||||
|
||||
class DeliveryZone extends Model
|
||||
{
|
||||
use HasFactory, HasRestaurant, HasUuid, SoftDeletes;
|
||||
|
||||
public const TABLE_NAME = 'restaurant_delivery_zones';
|
||||
|
||||
protected $table = self::TABLE_NAME;
|
||||
|
||||
protected $fillable = [
|
||||
'id',
|
||||
'uuid',
|
||||
'restaurant_id',
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'color',
|
||||
'coordinates',
|
||||
'min_lat',
|
||||
'max_lat',
|
||||
'min_lng',
|
||||
'max_lng',
|
||||
'priority',
|
||||
'is_active',
|
||||
'is_default',
|
||||
'max_delivery_distance',
|
||||
'operating_hours',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'coordinates' => 'array',
|
||||
'operating_hours' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'is_default' => 'boolean',
|
||||
'min_lat' => 'decimal:7',
|
||||
'max_lat' => 'decimal:7',
|
||||
'min_lng' => 'decimal:7',
|
||||
'max_lng' => 'decimal:7',
|
||||
'max_delivery_distance' => 'decimal:2',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::saving(function ($zone) {
|
||||
if (! empty($zone->coordinates)) {
|
||||
$zone->calculateBoundingBox();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Relationships
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function pricingRules(): HasMany
|
||||
{
|
||||
return $this->hasMany(ZonePricingRule::class, 'zone_id')
|
||||
->orderBy('priority');
|
||||
}
|
||||
|
||||
public function activePricingRule(): HasMany
|
||||
{
|
||||
return $this->hasMany(ZonePricingRule::class, 'zone_id')
|
||||
->where('is_active', true)
|
||||
->orderBy('priority')
|
||||
->limit(1);
|
||||
}
|
||||
|
||||
public function deliveries(): HasMany
|
||||
{
|
||||
return $this->hasMany(Delivery::class, 'zone_id');
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Scopes
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeDefault($query)
|
||||
{
|
||||
return $query->where('is_default', true);
|
||||
}
|
||||
|
||||
public function scopeByPriority($query)
|
||||
{
|
||||
return $query->orderByDesc('priority');
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Methods
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function calculateBoundingBox(): void
|
||||
{
|
||||
if (empty($this->coordinates)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lats = array_column($this->coordinates, 0);
|
||||
$lngs = array_column($this->coordinates, 1);
|
||||
|
||||
$this->min_lat = min($lats);
|
||||
$this->max_lat = max($lats);
|
||||
$this->min_lng = min($lngs);
|
||||
$this->max_lng = max($lngs);
|
||||
}
|
||||
|
||||
public function containsPoint(float $latitude, float $longitude): bool
|
||||
{
|
||||
// Quick bounding box check first
|
||||
if (
|
||||
$latitude < $this->min_lat ||
|
||||
$latitude > $this->max_lat ||
|
||||
$longitude < $this->min_lng ||
|
||||
$longitude > $this->max_lng
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ray casting algorithm for polygon containment
|
||||
return $this->pointInPolygon($latitude, $longitude, $this->coordinates);
|
||||
}
|
||||
|
||||
protected function pointInPolygon(float $latitude, float $longitude, array $polygon): bool
|
||||
{
|
||||
$n = count($polygon);
|
||||
$inside = false;
|
||||
|
||||
$x = $longitude;
|
||||
$y = $latitude;
|
||||
|
||||
$p1x = $polygon[0][1];
|
||||
$p1y = $polygon[0][0];
|
||||
|
||||
for ($i = 1; $i <= $n; $i++) {
|
||||
$p2x = $polygon[$i % $n][1];
|
||||
$p2y = $polygon[$i % $n][0];
|
||||
|
||||
if ($y > min($p1y, $p2y)) {
|
||||
if ($y <= max($p1y, $p2y)) {
|
||||
if ($x <= max($p1x, $p2x)) {
|
||||
if ($p1y != $p2y) {
|
||||
$xinters = ($y - $p1y) * ($p2x - $p1x) / ($p2y - $p1y) + $p1x;
|
||||
}
|
||||
if ($p1x == $p2x || $x <= $xinters) {
|
||||
$inside = ! $inside;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$p1x = $p2x;
|
||||
$p1y = $p2y;
|
||||
}
|
||||
|
||||
return $inside;
|
||||
}
|
||||
|
||||
public function isOperatingNow(): bool
|
||||
{
|
||||
if (empty($this->operating_hours)) {
|
||||
return true; // No restrictions
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$dayName = strtolower($now->format('l'));
|
||||
|
||||
if (! isset($this->operating_hours[$dayName])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hours = $this->operating_hours[$dayName];
|
||||
|
||||
if (isset($hours['closed']) && $hours['closed']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$openTime = \Carbon\Carbon::createFromTimeString($hours['open']);
|
||||
$closeTime = \Carbon\Carbon::createFromTimeString($hours['close']);
|
||||
|
||||
// Handle overnight hours
|
||||
if ($closeTime->lt($openTime)) {
|
||||
return $now->gte($openTime) || $now->lte($closeTime);
|
||||
}
|
||||
|
||||
return $now->between($openTime, $closeTime);
|
||||
}
|
||||
|
||||
public function getActivePricingRule(): ?ZonePricingRule
|
||||
{
|
||||
return $this->pricingRules()
|
||||
->where('is_active', true)
|
||||
->orderBy('priority')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function canDeliverTo(float $latitude, float $longitude): bool
|
||||
{
|
||||
if (! $this->is_active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->containsPoint($latitude, $longitude)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->isOperatingNow()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function findForPoint(float $latitude, float $longitude): ?self
|
||||
{
|
||||
return static::active()
|
||||
->byPriority()
|
||||
->get()
|
||||
->first(fn ($zone) => $zone->containsPoint($latitude, $longitude));
|
||||
}
|
||||
|
||||
public static function getDefault(): ?self
|
||||
{
|
||||
return static::active()->default()->first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
<?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;
|
||||
|
||||
class LocationLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TABLE_NAME = 'restaurant_rider_location_logs';
|
||||
|
||||
protected $table = self::TABLE_NAME;
|
||||
|
||||
protected $fillable = [
|
||||
'rider_id',
|
||||
'delivery_id',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'speed',
|
||||
'bearing',
|
||||
'accuracy',
|
||||
'altitude',
|
||||
'battery_level',
|
||||
'is_charging',
|
||||
'network_type',
|
||||
'source',
|
||||
'recorded_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'latitude' => 'decimal:7',
|
||||
'longitude' => 'decimal:7',
|
||||
'speed' => 'float',
|
||||
'bearing' => 'float',
|
||||
'accuracy' => 'float',
|
||||
'altitude' => 'float',
|
||||
'battery_level' => 'integer',
|
||||
'is_charging' => 'boolean',
|
||||
'recorded_at' => 'datetime',
|
||||
];
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Relationships
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function rider(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Rider::class, 'rider_id');
|
||||
}
|
||||
|
||||
public function delivery(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Delivery::class, 'delivery_id');
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Scopes
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function scopeForRider($query, int $riderId)
|
||||
{
|
||||
return $query->where('rider_id', $riderId);
|
||||
}
|
||||
|
||||
public function scopeForDelivery($query, int $deliveryId)
|
||||
{
|
||||
return $query->where('delivery_id', $deliveryId);
|
||||
}
|
||||
|
||||
public function scopeRecent($query, int $minutes = 60)
|
||||
{
|
||||
return $query->where('recorded_at', '>=', now()->subMinutes($minutes));
|
||||
}
|
||||
|
||||
public function scopeInDateRange($query, $startDate, $endDate)
|
||||
{
|
||||
return $query->whereBetween('recorded_at', [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
public function scopeHighAccuracy($query, float $maxAccuracy = 50)
|
||||
{
|
||||
return $query->where('accuracy', '<=', $maxAccuracy);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Methods
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function getCoordinates(): array
|
||||
{
|
||||
return [
|
||||
'lat' => $this->latitude,
|
||||
'lng' => $this->longitude,
|
||||
];
|
||||
}
|
||||
|
||||
public function hasLowBattery(): bool
|
||||
{
|
||||
return $this->battery_level !== null && $this->battery_level < 20;
|
||||
}
|
||||
|
||||
public function hasPoorAccuracy(): bool
|
||||
{
|
||||
return $this->accuracy !== null && $this->accuracy > 50;
|
||||
}
|
||||
|
||||
public function distanceTo(float $latitude, float $longitude): float
|
||||
{
|
||||
$earthRadius = 6371; // km
|
||||
|
||||
$dLat = deg2rad($latitude - $this->latitude);
|
||||
$dLng = deg2rad($longitude - $this->longitude);
|
||||
|
||||
$a = sin($dLat / 2) * sin($dLat / 2) +
|
||||
cos(deg2rad($this->latitude)) * cos(deg2rad($latitude)) *
|
||||
sin($dLng / 2) * sin($dLng / 2);
|
||||
|
||||
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
|
||||
|
||||
return round($earthRadius * $c, 3);
|
||||
}
|
||||
|
||||
public static function getTrackingPath(int $deliveryId): array
|
||||
{
|
||||
return static::forDelivery($deliveryId)
|
||||
->orderBy('recorded_at')
|
||||
->get()
|
||||
->map(fn ($log) => [
|
||||
'lat' => $log->latitude,
|
||||
'lng' => $log->longitude,
|
||||
'timestamp' => $log->recorded_at->toIso8601String(),
|
||||
'speed' => $log->speed,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public static function calculateTotalDistance(int $deliveryId): float
|
||||
{
|
||||
$logs = static::forDelivery($deliveryId)
|
||||
->orderBy('recorded_at')
|
||||
->get();
|
||||
|
||||
if ($logs->count() < 2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$totalDistance = 0;
|
||||
$previousLog = null;
|
||||
|
||||
foreach ($logs as $log) {
|
||||
if ($previousLog) {
|
||||
$totalDistance += $log->distanceTo(
|
||||
$previousLog->latitude,
|
||||
$previousLog->longitude
|
||||
);
|
||||
}
|
||||
$previousLog = $log;
|
||||
}
|
||||
|
||||
return round($totalDistance, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
<?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\SoftDeletes;
|
||||
use Modules\RestaurantDelivery\Traits\HasRestaurant;
|
||||
use Modules\RestaurantDelivery\Traits\HasUuid;
|
||||
|
||||
class RiderBonus extends Model
|
||||
{
|
||||
use HasFactory, HasRestaurant, HasUuid, SoftDeletes;
|
||||
|
||||
public const TABLE_NAME = 'restaurant_rider_bonuses';
|
||||
|
||||
protected $table = self::TABLE_NAME;
|
||||
|
||||
protected $fillable = [
|
||||
'rider_id',
|
||||
'restaurant_id',
|
||||
'type',
|
||||
'name',
|
||||
'description',
|
||||
'amount',
|
||||
'currency',
|
||||
'criteria',
|
||||
'criteria_value',
|
||||
'achieved_value',
|
||||
'period_start',
|
||||
'period_end',
|
||||
'status',
|
||||
'awarded_at',
|
||||
'expires_at',
|
||||
'earning_id',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount' => 'decimal:2',
|
||||
'criteria_value' => 'decimal:2',
|
||||
'achieved_value' => 'decimal:2',
|
||||
'period_start' => 'datetime',
|
||||
'period_end' => 'datetime',
|
||||
'awarded_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'meta' => 'array',
|
||||
];
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Relationships
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function rider(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Rider::class, 'rider_id');
|
||||
}
|
||||
|
||||
public function earning(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(RiderEarning::class, 'earning_id');
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Scopes
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('status', 'pending');
|
||||
}
|
||||
|
||||
public function scopeAwarded($query)
|
||||
{
|
||||
return $query->where('status', 'awarded');
|
||||
}
|
||||
|
||||
public function scopeExpired($query)
|
||||
{
|
||||
return $query->where('status', 'expired');
|
||||
}
|
||||
|
||||
public function scopeForPeriod($query, $start, $end)
|
||||
{
|
||||
return $query->whereBetween('period_start', [$start, $end]);
|
||||
}
|
||||
|
||||
public function scopeOfType($query, string $type)
|
||||
{
|
||||
return $query->where('type', $type);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', 'awarded')
|
||||
->where(function ($q) {
|
||||
$q->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Bonus Types
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public static function bonusTypes(): array
|
||||
{
|
||||
return [
|
||||
'peak_hour' => 'Peak Hour Bonus',
|
||||
'rain' => 'Rain/Weather Bonus',
|
||||
'consecutive_delivery' => 'Consecutive Delivery Bonus',
|
||||
'rating' => 'High Rating Bonus',
|
||||
'weekly_target' => 'Weekly Target Bonus',
|
||||
'referral' => 'Referral Bonus',
|
||||
'signup' => 'Signup Bonus',
|
||||
'special' => 'Special Bonus',
|
||||
];
|
||||
}
|
||||
|
||||
public function getTypeLabel(): string
|
||||
{
|
||||
return self::bonusTypes()[$this->type] ?? ucfirst($this->type);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Status Methods
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === 'pending';
|
||||
}
|
||||
|
||||
public function isAwarded(): bool
|
||||
{
|
||||
return $this->status === 'awarded';
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->status === 'expired';
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->isAwarded() && (! $this->expires_at || $this->expires_at > now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Award the bonus to rider.
|
||||
*/
|
||||
public function award(): bool
|
||||
{
|
||||
if (! $this->isPending()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->update([
|
||||
'status' => 'awarded',
|
||||
'awarded_at' => now(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark bonus as expired.
|
||||
*/
|
||||
public function markExpired(): bool
|
||||
{
|
||||
if (! $this->isPending()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->update([
|
||||
'status' => 'expired',
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Factory Methods
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create a peak hour bonus.
|
||||
*/
|
||||
public static function createPeakHourBonus(Rider $rider, float $amount): self
|
||||
{
|
||||
return static::create([
|
||||
'rider_id' => $rider->id,
|
||||
'restaurant_id' => getUserRestaurantId(),
|
||||
'type' => 'peak_hour',
|
||||
'name' => 'Peak Hour Bonus',
|
||||
'description' => 'Bonus for delivering during peak hours',
|
||||
'amount' => $amount,
|
||||
'currency' => config('restaurant-delivery.pricing.currency', 'BDT'),
|
||||
'status' => 'pending',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a weather bonus.
|
||||
*/
|
||||
public static function createWeatherBonus(Rider $rider, float $amount, string $condition = 'rain'): self
|
||||
{
|
||||
return static::create([
|
||||
'rider_id' => $rider->id,
|
||||
'restaurant_id' => getUserRestaurantId(),
|
||||
'type' => 'rain',
|
||||
'name' => ucfirst($condition).' Bonus',
|
||||
'description' => "Bonus for delivering during {$condition}",
|
||||
'amount' => $amount,
|
||||
'currency' => config('restaurant-delivery.pricing.currency', 'BDT'),
|
||||
'status' => 'pending',
|
||||
'meta' => ['weather_condition' => $condition],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a consecutive delivery bonus.
|
||||
*/
|
||||
public static function createConsecutiveBonus(Rider $rider, float $amount, int $deliveryCount): self
|
||||
{
|
||||
return static::create([
|
||||
'rider_id' => $rider->id,
|
||||
'restaurant_id' => getUserRestaurantId(),
|
||||
'type' => 'consecutive_delivery',
|
||||
'name' => 'Consecutive Delivery Bonus',
|
||||
'description' => "Bonus for {$deliveryCount} consecutive deliveries",
|
||||
'amount' => $amount,
|
||||
'currency' => config('restaurant-delivery.pricing.currency', 'BDT'),
|
||||
'criteria' => 'consecutive_deliveries',
|
||||
'criteria_value' => $deliveryCount,
|
||||
'achieved_value' => $deliveryCount,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a weekly target bonus.
|
||||
*/
|
||||
public static function createWeeklyTargetBonus(
|
||||
Rider $rider,
|
||||
float $amount,
|
||||
int $targetDeliveries,
|
||||
int $achievedDeliveries
|
||||
): self {
|
||||
return static::create([
|
||||
'rider_id' => $rider->id,
|
||||
'restaurant_id' => getUserRestaurantId(),
|
||||
'type' => 'weekly_target',
|
||||
'name' => 'Weekly Target Bonus',
|
||||
'description' => "Bonus for completing {$targetDeliveries} deliveries in a week",
|
||||
'amount' => $amount,
|
||||
'currency' => config('restaurant-delivery.pricing.currency', 'BDT'),
|
||||
'criteria' => 'weekly_deliveries',
|
||||
'criteria_value' => $targetDeliveries,
|
||||
'achieved_value' => $achievedDeliveries,
|
||||
'period_start' => now()->startOfWeek(),
|
||||
'period_end' => now()->endOfWeek(),
|
||||
'status' => 'pending',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a rating bonus.
|
||||
*/
|
||||
public static function createRatingBonus(Rider $rider, float $amount, float $rating): self
|
||||
{
|
||||
return static::create([
|
||||
'rider_id' => $rider->id,
|
||||
'restaurant_id' => getUserRestaurantId(),
|
||||
'type' => 'rating',
|
||||
'name' => 'High Rating Bonus',
|
||||
'description' => "Bonus for maintaining {$rating}+ rating",
|
||||
'amount' => $amount,
|
||||
'currency' => config('restaurant-delivery.pricing.currency', 'BDT'),
|
||||
'criteria' => 'min_rating',
|
||||
'criteria_value' => $rating,
|
||||
'achieved_value' => $rider->rating,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
<?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\SoftDeletes;
|
||||
use Modules\RestaurantDelivery\Traits\HasRestaurant;
|
||||
use Modules\RestaurantDelivery\Traits\HasUuid;
|
||||
|
||||
class RiderEarning extends Model
|
||||
{
|
||||
use HasFactory, HasRestaurant, HasUuid, SoftDeletes;
|
||||
|
||||
public const TABLE_NAME = 'restaurant_rider_earnings';
|
||||
|
||||
protected $table = self::TABLE_NAME;
|
||||
|
||||
protected $fillable = [
|
||||
'rider_id',
|
||||
'delivery_id',
|
||||
'restaurant_id',
|
||||
'type',
|
||||
'sub_type',
|
||||
'gross_amount',
|
||||
'platform_fee',
|
||||
'tax',
|
||||
'deductions',
|
||||
'net_amount',
|
||||
'currency',
|
||||
'calculation_breakdown',
|
||||
'description',
|
||||
'status',
|
||||
'confirmed_at',
|
||||
'payout_id',
|
||||
'is_paid',
|
||||
'paid_at',
|
||||
'earning_date',
|
||||
'earning_week',
|
||||
'earning_month',
|
||||
'earning_year',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'gross_amount' => 'decimal:2',
|
||||
'platform_fee' => 'decimal:2',
|
||||
'tax' => 'decimal:2',
|
||||
'deductions' => 'decimal:2',
|
||||
'net_amount' => 'decimal:2',
|
||||
'calculation_breakdown' => 'array',
|
||||
'is_paid' => 'boolean',
|
||||
'confirmed_at' => 'datetime',
|
||||
'paid_at' => 'datetime',
|
||||
'earning_date' => 'date',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($earning) {
|
||||
// Set earning date metadata
|
||||
$date = $earning->earning_date ?? now();
|
||||
$earning->earning_date = $date;
|
||||
$earning->earning_week = $date->weekOfYear;
|
||||
$earning->earning_month = $date->month;
|
||||
$earning->earning_year = $date->year;
|
||||
|
||||
// Calculate net amount
|
||||
$earning->net_amount = $earning->gross_amount
|
||||
- $earning->platform_fee
|
||||
- $earning->tax
|
||||
- $earning->deductions;
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Relationships
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function rider(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Rider::class, 'rider_id');
|
||||
}
|
||||
|
||||
public function delivery(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Delivery::class, 'delivery_id');
|
||||
}
|
||||
|
||||
public function payout(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(RiderPayout::class, 'payout_id');
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Scopes
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('status', 'pending');
|
||||
}
|
||||
|
||||
public function scopeConfirmed($query)
|
||||
{
|
||||
return $query->where('status', 'confirmed');
|
||||
}
|
||||
|
||||
public function scopePaid($query)
|
||||
{
|
||||
return $query->where('is_paid', true);
|
||||
}
|
||||
|
||||
public function scopeUnpaid($query)
|
||||
{
|
||||
return $query->where('is_paid', false);
|
||||
}
|
||||
|
||||
public function scopeDeliveryEarnings($query)
|
||||
{
|
||||
return $query->where('type', 'delivery');
|
||||
}
|
||||
|
||||
public function scopeTips($query)
|
||||
{
|
||||
return $query->where('type', 'tip');
|
||||
}
|
||||
|
||||
public function scopeBonuses($query)
|
||||
{
|
||||
return $query->where('type', 'bonus');
|
||||
}
|
||||
|
||||
public function scopePenalties($query)
|
||||
{
|
||||
return $query->where('type', 'penalty');
|
||||
}
|
||||
|
||||
public function scopeForDate($query, $date)
|
||||
{
|
||||
return $query->whereDate('earning_date', $date);
|
||||
}
|
||||
|
||||
public function scopeForWeek($query, int $week, int $year)
|
||||
{
|
||||
return $query->where('earning_week', $week)
|
||||
->where('earning_year', $year);
|
||||
}
|
||||
|
||||
public function scopeForMonth($query, int $month, int $year)
|
||||
{
|
||||
return $query->where('earning_month', $month)
|
||||
->where('earning_year', $year);
|
||||
}
|
||||
|
||||
public function scopeForPeriod($query, $startDate, $endDate)
|
||||
{
|
||||
return $query->whereBetween('earning_date', [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
public function scopePayable($query)
|
||||
{
|
||||
return $query->where('status', 'confirmed')
|
||||
->where('is_paid', false);
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Methods
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function confirm(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'confirmed',
|
||||
'confirmed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsPaid(int $payoutId): void
|
||||
{
|
||||
$this->update([
|
||||
'is_paid' => true,
|
||||
'paid_at' => now(),
|
||||
'payout_id' => $payoutId,
|
||||
'status' => 'paid',
|
||||
]);
|
||||
}
|
||||
|
||||
public function cancel(): void
|
||||
{
|
||||
$this->update(['status' => 'cancelled']);
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === 'pending';
|
||||
}
|
||||
|
||||
public function isConfirmed(): bool
|
||||
{
|
||||
return $this->status === 'confirmed';
|
||||
}
|
||||
|
||||
public function isPaid(): bool
|
||||
{
|
||||
return $this->is_paid;
|
||||
}
|
||||
|
||||
public function isDebit(): bool
|
||||
{
|
||||
return in_array($this->type, ['penalty', 'adjustment']) && $this->net_amount < 0;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Static Factory Methods
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public static function createForDelivery(Delivery $delivery): ?self
|
||||
{
|
||||
if (! $delivery->rider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rider = $delivery->rider;
|
||||
$grossAmount = $rider->calculateEarningForDelivery($delivery);
|
||||
|
||||
$breakdown = [
|
||||
'delivery_charge' => $delivery->total_delivery_charge,
|
||||
'commission_type' => $rider->commission_type->value,
|
||||
'commission_rate' => $rider->commission_rate,
|
||||
'distance' => $delivery->distance,
|
||||
'base_earning' => $grossAmount,
|
||||
];
|
||||
|
||||
// Add any bonuses
|
||||
$bonusAmount = static::calculateDeliveryBonuses($delivery, $rider);
|
||||
$grossAmount += $bonusAmount;
|
||||
$breakdown['bonuses'] = $bonusAmount;
|
||||
|
||||
return static::create([
|
||||
'rider_id' => $rider->id,
|
||||
'delivery_id' => $delivery->id,
|
||||
'restaurant_id' => getUserRestaurantId(),
|
||||
'type' => 'delivery',
|
||||
'gross_amount' => $grossAmount,
|
||||
'platform_fee' => 0,
|
||||
'tax' => 0,
|
||||
'deductions' => 0,
|
||||
'net_amount' => $grossAmount,
|
||||
'currency' => config('restaurant-delivery.pricing.currency', 'BDT'),
|
||||
'calculation_breakdown' => $breakdown,
|
||||
'description' => "Delivery #{$delivery->tracking_code}",
|
||||
'status' => 'pending',
|
||||
'earning_date' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function createFromTip(DeliveryTip $tip): ?self
|
||||
{
|
||||
return static::create([
|
||||
'rider_id' => $tip->rider_id,
|
||||
'delivery_id' => $tip->delivery_id,
|
||||
'restaurant_id' => getUserRestaurantId(),
|
||||
'type' => 'tip',
|
||||
'gross_amount' => $tip->rider_amount,
|
||||
'platform_fee' => 0,
|
||||
'tax' => 0,
|
||||
'deductions' => 0,
|
||||
'net_amount' => $tip->rider_amount,
|
||||
'currency' => $tip->currency,
|
||||
'calculation_breakdown' => [
|
||||
'total_tip' => $tip->amount,
|
||||
'rider_share' => $tip->rider_share_percentage,
|
||||
'rider_amount' => $tip->rider_amount,
|
||||
],
|
||||
'description' => "Tip for delivery #{$tip->delivery->tracking_code}",
|
||||
'status' => 'confirmed',
|
||||
'confirmed_at' => now(),
|
||||
'earning_date' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function createBonus(
|
||||
Rider $rider,
|
||||
string $subType,
|
||||
float $amount,
|
||||
string $description,
|
||||
?Delivery $delivery = null
|
||||
): self {
|
||||
return static::create([
|
||||
'rider_id' => $rider->id,
|
||||
'delivery_id' => $delivery?->id,
|
||||
'restaurant_id' => getUserRestaurantId(),
|
||||
'type' => 'bonus',
|
||||
'sub_type' => $subType,
|
||||
'gross_amount' => $amount,
|
||||
'platform_fee' => 0,
|
||||
'tax' => 0,
|
||||
'deductions' => 0,
|
||||
'net_amount' => $amount,
|
||||
'currency' => config('restaurant-delivery.pricing.currency', 'BDT'),
|
||||
'description' => $description,
|
||||
'status' => 'confirmed',
|
||||
'confirmed_at' => now(),
|
||||
'earning_date' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function createPenalty(
|
||||
Rider $rider,
|
||||
string $subType,
|
||||
float $amount,
|
||||
string $description,
|
||||
?Delivery $delivery = null
|
||||
): self {
|
||||
return static::create([
|
||||
'rider_id' => $rider->id,
|
||||
'delivery_id' => $delivery?->id,
|
||||
'restaurant_id' => getUserRestaurantId(),
|
||||
'type' => 'penalty',
|
||||
'sub_type' => $subType,
|
||||
'gross_amount' => -$amount,
|
||||
'platform_fee' => 0,
|
||||
'tax' => 0,
|
||||
'deductions' => 0,
|
||||
'net_amount' => -$amount,
|
||||
'currency' => config('restaurant-delivery.pricing.currency', 'BDT'),
|
||||
'description' => $description,
|
||||
'status' => 'confirmed',
|
||||
'confirmed_at' => now(),
|
||||
'earning_date' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
protected static function calculateDeliveryBonuses(Delivery $delivery, Rider $rider): float
|
||||
{
|
||||
$bonus = 0;
|
||||
$config = config('restaurant-delivery.earnings.bonuses');
|
||||
|
||||
// Peak hour bonus
|
||||
if ($config['peak_hour_bonus']['enabled'] && static::isPeakHour()) {
|
||||
$bonus += $config['peak_hour_bonus']['amount'];
|
||||
}
|
||||
|
||||
// Rating bonus
|
||||
if ($config['rating_bonus']['enabled'] && $rider->rating >= $config['rating_bonus']['min_rating']) {
|
||||
$bonus += $config['rating_bonus']['amount'];
|
||||
}
|
||||
|
||||
return $bonus;
|
||||
}
|
||||
|
||||
protected static function isPeakHour(): bool
|
||||
{
|
||||
$peakHours = config('restaurant-delivery.pricing.peak_hours.slots', []);
|
||||
$now = now();
|
||||
$currentDay = $now->dayOfWeekIso;
|
||||
|
||||
foreach ($peakHours as $slot) {
|
||||
if (! in_array($currentDay, $slot['days'] ?? [])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$start = \Carbon\Carbon::createFromTimeString($slot['start']);
|
||||
$end = \Carbon\Carbon::createFromTimeString($slot['end']);
|
||||
|
||||
if ($now->between($start, $end)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
<?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 Illuminate\Support\Str;
|
||||
use Modules\RestaurantDelivery\Traits\HasRestaurant;
|
||||
use Modules\RestaurantDelivery\Traits\HasUuid;
|
||||
|
||||
class RiderPayout extends Model
|
||||
{
|
||||
use HasFactory, HasRestaurant, HasUuid, SoftDeletes;
|
||||
|
||||
public const TABLE_NAME = 'restaurant_rider_payouts';
|
||||
|
||||
protected $table = self::TABLE_NAME;
|
||||
|
||||
protected $fillable = [
|
||||
'payout_number',
|
||||
'rider_id',
|
||||
'restaurant_id',
|
||||
'period_start',
|
||||
'period_end',
|
||||
'period_type',
|
||||
'total_deliveries_amount',
|
||||
'total_tips_amount',
|
||||
'total_bonuses_amount',
|
||||
'total_penalties_amount',
|
||||
'total_adjustments_amount',
|
||||
'gross_amount',
|
||||
'platform_fees',
|
||||
'tax_deductions',
|
||||
'other_deductions',
|
||||
'net_amount',
|
||||
'currency',
|
||||
'total_deliveries',
|
||||
'total_tips_count',
|
||||
'total_bonuses_count',
|
||||
'total_penalties_count',
|
||||
'status',
|
||||
'payment_method',
|
||||
'payment_reference',
|
||||
'payment_details',
|
||||
'processed_at',
|
||||
'paid_at',
|
||||
'failed_at',
|
||||
'failure_reason',
|
||||
'retry_count',
|
||||
'approved_by',
|
||||
'approved_at',
|
||||
'processed_by',
|
||||
'notes',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'period_start' => 'date',
|
||||
'period_end' => 'date',
|
||||
'total_deliveries_amount' => 'decimal:2',
|
||||
'total_tips_amount' => 'decimal:2',
|
||||
'total_bonuses_amount' => 'decimal:2',
|
||||
'total_penalties_amount' => 'decimal:2',
|
||||
'total_adjustments_amount' => 'decimal:2',
|
||||
'gross_amount' => 'decimal:2',
|
||||
'platform_fees' => 'decimal:2',
|
||||
'tax_deductions' => 'decimal:2',
|
||||
'other_deductions' => 'decimal:2',
|
||||
'net_amount' => 'decimal:2',
|
||||
'payment_details' => 'array',
|
||||
'processed_at' => 'datetime',
|
||||
'paid_at' => 'datetime',
|
||||
'failed_at' => 'datetime',
|
||||
'approved_at' => 'datetime',
|
||||
'meta' => 'array',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($payout) {
|
||||
if (empty($payout->payout_number)) {
|
||||
$payout->payout_number = static::generatePayoutNumber();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Relationships
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function rider(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Rider::class, 'rider_id');
|
||||
}
|
||||
|
||||
public function earnings(): HasMany
|
||||
{
|
||||
return $this->hasMany(RiderEarning::class, 'payout_id');
|
||||
}
|
||||
|
||||
public function tips(): HasMany
|
||||
{
|
||||
return $this->hasMany(DeliveryTip::class, 'payout_id');
|
||||
}
|
||||
|
||||
public function approvedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(config('auth.providers.users.model'), 'approved_by');
|
||||
}
|
||||
|
||||
public function processedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(config('auth.providers.users.model'), 'processed_by');
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Scopes
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('status', 'pending');
|
||||
}
|
||||
|
||||
public function scopeProcessing($query)
|
||||
{
|
||||
return $query->where('status', 'processing');
|
||||
}
|
||||
|
||||
public function scopeCompleted($query)
|
||||
{
|
||||
return $query->where('status', 'completed');
|
||||
}
|
||||
|
||||
public function scopeFailed($query)
|
||||
{
|
||||
return $query->where('status', 'failed');
|
||||
}
|
||||
|
||||
public function scopeForPeriod($query, $startDate, $endDate)
|
||||
{
|
||||
return $query->whereBetween('period_start', [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
public function scopeWeekly($query)
|
||||
{
|
||||
return $query->where('period_type', 'weekly');
|
||||
}
|
||||
|
||||
public function scopeMonthly($query)
|
||||
{
|
||||
return $query->where('period_type', 'monthly');
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Methods
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public static function generatePayoutNumber(): string
|
||||
{
|
||||
do {
|
||||
$number = 'PAY-'.date('Ymd').'-'.strtoupper(Str::random(6));
|
||||
} while (static::where('payout_number', $number)->exists());
|
||||
|
||||
return $number;
|
||||
}
|
||||
|
||||
public function approve(int $approvedBy): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'processing',
|
||||
'approved_by' => $approvedBy,
|
||||
'approved_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function process(int $processedBy): void
|
||||
{
|
||||
$this->update([
|
||||
'processed_by' => $processedBy,
|
||||
'processed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsCompleted(string $paymentReference, array $paymentDetails = []): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'completed',
|
||||
'payment_reference' => $paymentReference,
|
||||
'payment_details' => $paymentDetails,
|
||||
'paid_at' => now(),
|
||||
]);
|
||||
|
||||
// Mark all associated earnings as paid
|
||||
$this->earnings()->update([
|
||||
'is_paid' => true,
|
||||
'paid_at' => now(),
|
||||
'status' => 'paid',
|
||||
]);
|
||||
|
||||
// Mark all associated tips as transferred
|
||||
$this->tips()->update([
|
||||
'is_transferred' => true,
|
||||
'transferred_at' => now(),
|
||||
'payment_status' => 'transferred',
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsFailed(string $reason): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'failed',
|
||||
'failed_at' => now(),
|
||||
'failure_reason' => $reason,
|
||||
]);
|
||||
}
|
||||
|
||||
public function retry(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'processing',
|
||||
'retry_count' => $this->retry_count + 1,
|
||||
'failed_at' => null,
|
||||
'failure_reason' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function cancel(): void
|
||||
{
|
||||
$this->update(['status' => 'cancelled']);
|
||||
|
||||
// Unlink earnings from this payout
|
||||
$this->earnings()->update(['payout_id' => null]);
|
||||
$this->tips()->update(['payout_id' => null]);
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === 'pending';
|
||||
}
|
||||
|
||||
public function isProcessing(): bool
|
||||
{
|
||||
return $this->status === 'processing';
|
||||
}
|
||||
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
return $this->status === 'completed';
|
||||
}
|
||||
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return $this->status === 'failed';
|
||||
}
|
||||
|
||||
public function canRetry(): bool
|
||||
{
|
||||
return $this->isFailed() && $this->retry_count < 3;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Static Factory Methods
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public static function createForRider(
|
||||
Rider $rider,
|
||||
\DateTime $periodStart,
|
||||
\DateTime $periodEnd,
|
||||
string $periodType = 'weekly'
|
||||
): ?self {
|
||||
// Get all unpaid earnings for the period
|
||||
$earnings = RiderEarning::where('rider_id', $rider->id)
|
||||
->payable()
|
||||
->forPeriod($periodStart, $periodEnd)
|
||||
->get();
|
||||
|
||||
// Get all untransferred tips
|
||||
$tips = DeliveryTip::where('rider_id', $rider->id)
|
||||
->notTransferred()
|
||||
->forPeriod($periodStart, $periodEnd)
|
||||
->get();
|
||||
|
||||
if ($earnings->isEmpty() && $tips->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate amounts by type
|
||||
$deliveriesAmount = $earnings->where('type', 'delivery')->sum('net_amount');
|
||||
$bonusesAmount = $earnings->where('type', 'bonus')->sum('net_amount');
|
||||
$penaltiesAmount = abs($earnings->where('type', 'penalty')->sum('net_amount'));
|
||||
$adjustmentsAmount = $earnings->where('type', 'adjustment')->sum('net_amount');
|
||||
$tipsAmount = $tips->sum('rider_amount');
|
||||
|
||||
$grossAmount = $deliveriesAmount + $bonusesAmount + $tipsAmount + $adjustmentsAmount;
|
||||
$netAmount = $grossAmount - $penaltiesAmount;
|
||||
|
||||
// Check minimum payout amount
|
||||
$minimumAmount = config('restaurant-delivery.earnings.payout.minimum_amount', 500);
|
||||
if ($netAmount < $minimumAmount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payout = static::create([
|
||||
'rider_id' => $rider->id,
|
||||
'restaurant_id' => getUserRestaurantId(),
|
||||
'period_start' => $periodStart,
|
||||
'period_end' => $periodEnd,
|
||||
'period_type' => $periodType,
|
||||
'total_deliveries_amount' => $deliveriesAmount,
|
||||
'total_tips_amount' => $tipsAmount,
|
||||
'total_bonuses_amount' => $bonusesAmount,
|
||||
'total_penalties_amount' => $penaltiesAmount,
|
||||
'total_adjustments_amount' => $adjustmentsAmount,
|
||||
'gross_amount' => $grossAmount,
|
||||
'platform_fees' => 0,
|
||||
'tax_deductions' => 0,
|
||||
'other_deductions' => $penaltiesAmount,
|
||||
'net_amount' => $netAmount,
|
||||
'currency' => config('restaurant-delivery.pricing.currency', 'BDT'),
|
||||
'total_deliveries' => $earnings->where('type', 'delivery')->count(),
|
||||
'total_tips_count' => $tips->count(),
|
||||
'total_bonuses_count' => $earnings->where('type', 'bonus')->count(),
|
||||
'total_penalties_count' => $earnings->where('type', 'penalty')->count(),
|
||||
'status' => 'pending',
|
||||
'payment_method' => $rider->mobile_wallet_provider ?? 'bank_transfer',
|
||||
]);
|
||||
|
||||
// Link earnings and tips to this payout
|
||||
$earnings->each(fn ($e) => $e->update(['payout_id' => $payout->id]));
|
||||
$tips->each(fn ($t) => $t->update(['payout_id' => $payout->id]));
|
||||
|
||||
return $payout;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
<?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\SoftDeletes;
|
||||
use Modules\RestaurantDelivery\Traits\HasUuid;
|
||||
|
||||
class ZonePricingRule extends Model
|
||||
{
|
||||
use HasFactory, HasUuid, SoftDeletes;
|
||||
|
||||
public const TABLE_NAME = 'restaurant_zone_pricing_rules';
|
||||
|
||||
protected $table = self::TABLE_NAME;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'restaurant_id',
|
||||
'zone_id',
|
||||
'name',
|
||||
'priority',
|
||||
'is_active',
|
||||
'base_fare',
|
||||
'minimum_fare',
|
||||
'per_km_charge',
|
||||
'free_distance',
|
||||
'max_distance',
|
||||
'surge_enabled',
|
||||
'surge_multiplier',
|
||||
'conditions',
|
||||
'valid_from',
|
||||
'valid_until',
|
||||
'valid_days',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'surge_enabled' => 'boolean',
|
||||
'base_fare' => 'decimal:2',
|
||||
'minimum_fare' => 'decimal:2',
|
||||
'per_km_charge' => 'decimal:2',
|
||||
'free_distance' => 'decimal:2',
|
||||
'max_distance' => 'decimal:2',
|
||||
'surge_multiplier' => 'decimal:2',
|
||||
'conditions' => 'array',
|
||||
'valid_from' => 'datetime:H:i',
|
||||
'valid_until' => 'datetime:H:i',
|
||||
];
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Relationships
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function zone(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DeliveryZone::class, 'zone_id');
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Scopes
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeByPriority($query)
|
||||
{
|
||||
return $query->orderBy('priority');
|
||||
}
|
||||
|
||||
public function scopeValidNow($query)
|
||||
{
|
||||
$now = now();
|
||||
$currentTime = $now->format('H:i:s');
|
||||
$currentDay = pow(2, $now->dayOfWeek); // Bitmask for current day
|
||||
|
||||
return $query->where(function ($q) use ($currentTime) {
|
||||
$q->whereNull('valid_from')
|
||||
->orWhere(function ($q2) use ($currentTime) {
|
||||
$q2->where('valid_from', '<=', $currentTime)
|
||||
->where('valid_until', '>=', $currentTime);
|
||||
});
|
||||
})->where(function ($q) use ($currentDay) {
|
||||
$q->whereNull('valid_days')
|
||||
->orWhereRaw('(valid_days & ?) > 0', [$currentDay]);
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Methods
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
public function isValidNow(): bool
|
||||
{
|
||||
$now = now();
|
||||
|
||||
// Check time validity
|
||||
if ($this->valid_from && $this->valid_until) {
|
||||
$currentTime = $now->format('H:i:s');
|
||||
if ($currentTime < $this->valid_from || $currentTime > $this->valid_until) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check day validity (bitmask)
|
||||
if ($this->valid_days !== null) {
|
||||
$currentDayBit = pow(2, $now->dayOfWeek);
|
||||
if (($this->valid_days & $currentDayBit) === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function calculateCharge(float $distance): float
|
||||
{
|
||||
// Check max distance
|
||||
if ($this->max_distance && $distance > $this->max_distance) {
|
||||
return -1; // Indicates delivery not possible
|
||||
}
|
||||
|
||||
// Calculate distance charge
|
||||
$chargeableDistance = max(0, $distance - $this->free_distance);
|
||||
$distanceCharge = $chargeableDistance * $this->per_km_charge;
|
||||
|
||||
// Calculate total
|
||||
$total = $this->base_fare + $distanceCharge;
|
||||
|
||||
// Apply surge if enabled
|
||||
if ($this->surge_enabled && $this->surge_multiplier > 1) {
|
||||
$total *= $this->surge_multiplier;
|
||||
}
|
||||
|
||||
// Apply minimum fare
|
||||
$total = max($total, $this->minimum_fare);
|
||||
|
||||
return round($total, 2);
|
||||
}
|
||||
|
||||
public function getChargeBreakdown(float $distance): array
|
||||
{
|
||||
$chargeableDistance = max(0, $distance - $this->free_distance);
|
||||
$distanceCharge = $chargeableDistance * $this->per_km_charge;
|
||||
$baseTotal = $this->base_fare + $distanceCharge;
|
||||
|
||||
$surgeAmount = 0;
|
||||
if ($this->surge_enabled && $this->surge_multiplier > 1) {
|
||||
$surgeAmount = $baseTotal * ($this->surge_multiplier - 1);
|
||||
}
|
||||
|
||||
$total = $baseTotal + $surgeAmount;
|
||||
$minimumApplied = false;
|
||||
|
||||
if ($total < $this->minimum_fare) {
|
||||
$total = $this->minimum_fare;
|
||||
$minimumApplied = true;
|
||||
}
|
||||
|
||||
return [
|
||||
'base_fare' => $this->base_fare,
|
||||
'distance' => $distance,
|
||||
'free_distance' => $this->free_distance,
|
||||
'chargeable_distance' => $chargeableDistance,
|
||||
'per_km_charge' => $this->per_km_charge,
|
||||
'distance_charge' => $distanceCharge,
|
||||
'surge_enabled' => $this->surge_enabled,
|
||||
'surge_multiplier' => $this->surge_multiplier,
|
||||
'surge_amount' => $surgeAmount,
|
||||
'minimum_fare' => $this->minimum_fare,
|
||||
'minimum_applied' => $minimumApplied,
|
||||
'total' => round($total, 2),
|
||||
];
|
||||
}
|
||||
|
||||
public function meetsConditions(array $deliveryData): bool
|
||||
{
|
||||
if (empty($this->conditions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($this->conditions as $condition) {
|
||||
if (! $this->evaluateCondition($condition, $deliveryData)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function evaluateCondition(array $condition, array $data): bool
|
||||
{
|
||||
$field = $condition['field'] ?? null;
|
||||
$operator = $condition['operator'] ?? null;
|
||||
$value = $condition['value'] ?? null;
|
||||
|
||||
if (! $field || ! $operator) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$fieldValue = $data[$field] ?? null;
|
||||
|
||||
return match ($operator) {
|
||||
'equals' => $fieldValue == $value,
|
||||
'not_equals' => $fieldValue != $value,
|
||||
'greater_than' => $fieldValue > $value,
|
||||
'less_than' => $fieldValue < $value,
|
||||
'greater_or_equal' => $fieldValue >= $value,
|
||||
'less_or_equal' => $fieldValue <= $value,
|
||||
'in' => in_array($fieldValue, (array) $value),
|
||||
'not_in' => ! in_array($fieldValue, (array) $value),
|
||||
'is_true' => (bool) $fieldValue === true,
|
||||
'is_false' => (bool) $fieldValue === false,
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user