Files
kulakpos_web/public/restaurant/Modules/RestaurantDelivery/app/Models/Delivery.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]);
}
}