migrate to gtea from bistbucket

This commit is contained in:
2026-03-15 17:08:23 +07:00
commit 129ca2260c
3716 changed files with 566316 additions and 0 deletions

View File

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

View File

@@ -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',
];
}
}

View File

@@ -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'];
}
}

View File

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

View File

@@ -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;
}
}

View File

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

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

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

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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,
};
}
}