541 lines
15 KiB
PHP
541 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Modules\RestaurantDelivery\Models;
|
|
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
use Illuminate\Database\Eloquent\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]);
|
|
}
|
|
}
|