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,71 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Console\Commands;
use Illuminate\Console\Command;
use Modules\RestaurantDelivery\Enums\DeliveryStatus;
use Modules\RestaurantDelivery\Jobs\AssignRiderJob;
use Modules\RestaurantDelivery\Models\Delivery;
class AssignPendingDeliveries extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'delivery:assign-pending
{--limit=50 : Maximum number of deliveries to process}
{--restaurant= : Filter by restaurant ID}';
/**
* The console command description.
*/
protected $description = 'Assign riders to pending deliveries that need assignment';
/**
* Execute the console command.
*/
public function handle(): int
{
$limit = (int) $this->option('limit');
$restaurantId = $this->option('restaurant');
$this->info('Looking for deliveries that need rider assignment...');
$query = Delivery::query()
->where('status', DeliveryStatus::READY_FOR_PICKUP)
->whereNull('rider_id');
if ($restaurantId) {
$query->where('restaurant_id', $restaurantId);
}
$deliveries = $query->limit($limit)
->orderBy('created_at', 'asc')
->get();
if ($deliveries->isEmpty()) {
$this->info('No deliveries found that need rider assignment.');
return self::SUCCESS;
}
$this->info("Found {$deliveries->count()} deliveries to process.");
$bar = $this->output->createProgressBar($deliveries->count());
$bar->start();
foreach ($deliveries as $delivery) {
dispatch(new AssignRiderJob($delivery));
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info("Dispatched {$deliveries->count()} assignment jobs to queue.");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Console\Commands;
use Illuminate\Console\Command;
use Modules\RestaurantDelivery\Jobs\CleanupStaleLocationsJob;
use Modules\RestaurantDelivery\Models\LocationLog;
use Modules\RestaurantDelivery\Models\Rider;
class CleanupStaleData extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'delivery:cleanup
{--days=7 : Number of days to keep location logs}
{--queue : Dispatch as a background job}';
/**
* The console command description.
*/
protected $description = 'Clean up stale location logs and mark offline riders';
/**
* Execute the console command.
*/
public function handle(): int
{
$days = (int) $this->option('days');
$queue = $this->option('queue');
if ($queue) {
dispatch(new CleanupStaleLocationsJob($days));
$this->info('Cleanup job dispatched to queue.');
return self::SUCCESS;
}
$this->info("Cleaning up data older than {$days} days...");
// Delete old location logs
$deletedLogs = LocationLog::where('recorded_at', '<', now()->subDays($days))
->delete();
$this->info("Deleted {$deletedLogs} old location log entries.");
// Mark stale riders as offline
$offlineThreshold = config('restaurant-delivery.firebase.location.offline_threshold', 120);
$ridersMarkedOffline = Rider::where('is_online', true)
->where('last_location_update', '<', now()->subSeconds($offlineThreshold))
->update(['is_online' => false]);
$this->info("Marked {$ridersMarkedOffline} riders as offline (no update for {$offlineThreshold}s).");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Modules\RestaurantDelivery\Models\RiderEarning;
class GenerateEarningsReport extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'delivery:earnings-report
{--period=week : Report period (today, week, month, year)}
{--restaurant= : Filter by restaurant ID}
{--export= : Export to file (csv, json)}';
/**
* The console command description.
*/
protected $description = 'Generate earnings report for riders';
/**
* Execute the console command.
*/
public function handle(): int
{
$period = $this->option('period');
$restaurantId = $this->option('restaurant');
$export = $this->option('export');
$startDate = match ($period) {
'today' => now()->startOfDay(),
'week' => now()->startOfWeek(),
'month' => now()->startOfMonth(),
'year' => now()->startOfYear(),
default => now()->startOfWeek(),
};
$this->info("Generating earnings report for period: {$period}");
$this->info("From: {$startDate->format('Y-m-d H:i:s')}");
$this->newLine();
$query = RiderEarning::query()
->select([
'rider_id',
DB::raw('COUNT(*) as total_deliveries'),
DB::raw('SUM(gross_amount) as gross_earnings'),
DB::raw('SUM(commission_amount) as total_commission'),
DB::raw('SUM(net_amount) as net_earnings'),
DB::raw('SUM(tip_amount) as total_tips'),
DB::raw('SUM(bonus_amount) as total_bonuses'),
DB::raw('SUM(penalty_amount) as total_penalties'),
])
->where('earned_at', '>=', $startDate)
->groupBy('rider_id');
if ($restaurantId) {
$query->where('restaurant_id', $restaurantId);
}
$report = $query->with('rider:id,first_name,last_name,phone')
->get();
if ($report->isEmpty()) {
$this->warn('No earnings data found for the specified period.');
return self::SUCCESS;
}
$currency = config('restaurant-delivery.pricing.currency', 'BDT');
$data = $report->map(function ($row) use ($currency) {
return [
'Rider ID' => $row->rider_id,
'Name' => $row->rider?->full_name ?? 'Unknown',
'Deliveries' => $row->total_deliveries,
'Gross' => "{$currency} ".number_format((float) $row->gross_earnings, 2),
'Commission' => "{$currency} ".number_format((float) $row->total_commission, 2),
'Tips' => "{$currency} ".number_format((float) $row->total_tips, 2),
'Bonuses' => "{$currency} ".number_format((float) $row->total_bonuses, 2),
'Penalties' => "{$currency} ".number_format((float) $row->total_penalties, 2),
'Net' => "{$currency} ".number_format((float) $row->net_earnings, 2),
];
});
// Display table
$this->table(array_keys($data->first()), $data->toArray());
// Show totals
$this->newLine();
$this->info('Totals:');
$this->line(' Total Deliveries: '.$report->sum('total_deliveries'));
$this->line(" Gross Earnings: {$currency} ".number_format((float) $report->sum('gross_earnings'), 2));
$this->line(" Total Commission: {$currency} ".number_format((float) $report->sum('total_commission'), 2));
$this->line(" Net Earnings: {$currency} ".number_format((float) $report->sum('net_earnings'), 2));
// Export if requested
if ($export) {
$filename = "earnings_report_{$period}_".now()->format('Y-m-d_His').".{$export}";
if ($export === 'csv') {
$this->exportToCsv($data->toArray(), $filename);
} elseif ($export === 'json') {
$this->exportToJson($report->toArray(), $filename);
}
$this->info("Report exported to: {$filename}");
}
return self::SUCCESS;
}
/**
* Export data to CSV.
*/
protected function exportToCsv(array $data, string $filename): void
{
$handle = fopen(storage_path("app/{$filename}"), 'w');
if (! empty($data)) {
fputcsv($handle, array_keys($data[0]));
foreach ($data as $row) {
fputcsv($handle, $row);
}
}
fclose($handle);
}
/**
* Export data to JSON.
*/
protected function exportToJson(array $data, string $filename): void
{
file_put_contents(
storage_path("app/{$filename}"),
json_encode($data, JSON_PRETTY_PRINT)
);
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Console\Commands;
use Illuminate\Console\Command;
use Modules\RestaurantDelivery\Jobs\ProcessPayoutJob;
use Modules\RestaurantDelivery\Models\Rider;
class ProcessRiderPayouts extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'delivery:process-payouts
{--rider= : Process payout for specific rider ID}
{--restaurant= : Filter by restaurant ID}
{--payment-method= : Override payment method}
{--dry-run : Show what would be processed without actually processing}';
/**
* The console command description.
*/
protected $description = 'Process pending payouts for riders';
/**
* Execute the console command.
*/
public function handle(): int
{
$riderId = $this->option('rider');
$restaurantId = $this->option('restaurant');
$paymentMethod = $this->option('payment-method');
$dryRun = $this->option('dry-run');
$this->info('Processing rider payouts...');
$query = Rider::query()
->where('status', '!=', 'suspended')
->whereHas('earnings', function ($q) {
$q->where('status', 'pending')
->whereNull('payout_id');
});
if ($riderId) {
$query->where('id', $riderId);
}
if ($restaurantId) {
$query->where('restaurant_id', $restaurantId);
}
$riders = $query->with(['earnings' => function ($q) {
$q->where('status', 'pending')
->whereNull('payout_id');
}])->get();
if ($riders->isEmpty()) {
$this->info('No riders with pending earnings found.');
return self::SUCCESS;
}
$minimumPayout = config('restaurant-delivery.earnings.payout.minimum_amount', 500);
$currency = config('restaurant-delivery.pricing.currency', 'BDT');
$this->info("Found {$riders->count()} riders with pending earnings.");
$this->info("Minimum payout threshold: {$currency} {$minimumPayout}");
$this->newLine();
$processed = 0;
$skipped = 0;
$this->table(
['Rider ID', 'Name', 'Pending Amount', 'Status'],
$riders->map(function ($rider) use ($minimumPayout, $currency, $dryRun, $paymentMethod, &$processed, &$skipped) {
$pendingAmount = $rider->earnings->sum('net_amount');
if ($pendingAmount < $minimumPayout) {
$skipped++;
return [
$rider->id,
$rider->full_name,
"{$currency} ".number_format($pendingAmount, 2),
'Below minimum',
];
}
if (! $dryRun) {
dispatch(new ProcessPayoutJob($rider, $paymentMethod));
}
$processed++;
return [
$rider->id,
$rider->full_name,
"{$currency} ".number_format($pendingAmount, 2),
$dryRun ? 'Would process' : 'Processing',
];
})->toArray()
);
$this->newLine();
if ($dryRun) {
$this->warn("DRY RUN: Would process {$processed} payouts, skip {$skipped} (below minimum).");
} else {
$this->info("Dispatched {$processed} payout jobs, skipped {$skipped} (below minimum).");
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Console\Commands;
use Illuminate\Console\Command;
use Modules\RestaurantDelivery\Enums\DeliveryStatus;
use Modules\RestaurantDelivery\Models\Delivery;
use Modules\RestaurantDelivery\Models\Rider;
use Modules\RestaurantDelivery\Services\Firebase\FirebaseService;
class SyncFirebaseData extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'delivery:sync-firebase
{--type=all : Type to sync (all, riders, deliveries)}
{--direction=to-firebase : Sync direction (to-firebase, from-firebase)}';
/**
* The console command description.
*/
protected $description = 'Synchronize data between database and Firebase';
/**
* Execute the console command.
*/
public function handle(FirebaseService $firebase): int
{
if (! $firebase->isEnabled()) {
$this->error('Firebase is not enabled. Check your configuration.');
return self::FAILURE;
}
$type = $this->option('type');
$direction = $this->option('direction');
$this->info("Syncing {$type} {$direction}...");
if ($type === 'all' || $type === 'riders') {
$this->syncRiders($firebase, $direction);
}
if ($type === 'all' || $type === 'deliveries') {
$this->syncDeliveries($firebase, $direction);
}
$this->info('Sync completed successfully.');
return self::SUCCESS;
}
/**
* Sync rider data.
*/
protected function syncRiders(FirebaseService $firebase, string $direction): void
{
$this->info('Syncing rider locations...');
$riders = Rider::where('is_online', true)
->whereNotNull('current_latitude')
->whereNotNull('current_longitude')
->get();
$bar = $this->output->createProgressBar($riders->count());
$bar->start();
foreach ($riders as $rider) {
if ($direction === 'to-firebase') {
$firebase->updateRiderLocation(
$rider->id,
(float) $rider->current_latitude,
(float) $rider->current_longitude
);
$firebase->updateRiderStatus($rider->id, $rider->status);
}
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info("Synced {$riders->count()} online riders.");
}
/**
* Sync delivery data.
*/
protected function syncDeliveries(FirebaseService $firebase, string $direction): void
{
$this->info('Syncing active deliveries...');
$deliveries = Delivery::whereIn('status', array_map(
fn ($s) => $s->value,
DeliveryStatus::riderActiveStatuses()
))
->whereNotNull('rider_id')
->with('rider')
->get();
$bar = $this->output->createProgressBar($deliveries->count());
$bar->start();
foreach ($deliveries as $delivery) {
if ($direction === 'to-firebase') {
$firebase->initializeDeliveryTracking($delivery->id, [
'status' => $delivery->status->value,
'rider_id' => $delivery->rider_id,
'pickup_latitude' => $delivery->pickup_latitude,
'pickup_longitude' => $delivery->pickup_longitude,
'drop_latitude' => $delivery->drop_latitude,
'drop_longitude' => $delivery->drop_longitude,
]);
}
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info("Synced {$deliveries->count()} active deliveries.");
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Contracts;
use Modules\RestaurantDelivery\Models\Delivery;
use Modules\RestaurantDelivery\Models\Rider;
interface EarningsCalculatorInterface
{
/**
* Calculate earnings for a delivery.
*/
public function calculateForDelivery(Delivery $delivery): array;
/**
* Calculate base earnings.
*/
public function calculateBaseEarnings(Delivery $delivery): float;
/**
* Calculate distance-based earnings.
*/
public function calculateDistanceEarnings(Delivery $delivery): float;
/**
* Calculate applicable bonuses.
*/
public function calculateBonuses(Delivery $delivery, Rider $rider): array;
/**
* Calculate applicable penalties.
*/
public function calculatePenalties(Delivery $delivery, Rider $rider): array;
/**
* Calculate commission.
*/
public function calculateCommission(float $grossAmount, Rider $rider): array;
/**
* Get rider's earnings summary for a period.
*/
public function getRiderEarningsSummary(Rider $rider, string $period = 'week'): array;
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Contracts;
interface FirebaseServiceInterface
{
/**
* Check if Firebase is enabled.
*/
public function isEnabled(): bool;
/**
* Update rider location in Firebase.
*/
public function updateRiderLocation(
int|string $riderId,
float $latitude,
float $longitude,
?float $speed = null,
?float $bearing = null,
?float $accuracy = null
): bool;
/**
* Get rider location from Firebase.
*/
public function getRiderLocation(int|string $riderId): ?array;
/**
* Check if rider location is stale.
*/
public function isRiderLocationStale(int|string $riderId): bool;
/**
* Update rider status in Firebase.
*/
public function updateRiderStatus(int|string $riderId, string $status): bool;
/**
* Initialize delivery tracking in Firebase.
*/
public function initializeDeliveryTracking(int|string $deliveryId, array $data): bool;
/**
* Update delivery rider location in Firebase.
*/
public function updateDeliveryRiderLocation(
int|string $deliveryId,
float $latitude,
float $longitude,
?float $speed = null,
?float $bearing = null,
?int $eta = null,
?float $remainingDistance = null
): bool;
/**
* Update delivery status in Firebase.
*/
public function updateDeliveryStatus(int|string $deliveryId, string $status, array $metadata = []): bool;
/**
* Update delivery route in Firebase.
*/
public function updateDeliveryRoute(int|string $deliveryId, array $route): bool;
/**
* Get delivery tracking data from Firebase.
*/
public function getDeliveryTracking(int|string $deliveryId): ?array;
/**
* Remove delivery tracking from Firebase.
*/
public function removeDeliveryTracking(int|string $deliveryId): bool;
/**
* Remove rider assignment from Firebase.
*/
public function removeRiderAssignment(int|string $riderId, int|string $deliveryId): bool;
/**
* Send push notification via FCM.
*/
public function sendPushNotification(
string $token,
string $title,
string $body,
array $data = []
): bool;
/**
* Send push notification to multiple devices.
*/
public function sendMulticastNotification(
array $tokens,
string $title,
string $body,
array $data = []
): array;
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Contracts;
interface MapsServiceInterface
{
/**
* Get route between two points.
*/
public function getRoute(
float $originLat,
float $originLng,
float $destLat,
float $destLng,
array $options = []
): ?array;
/**
* Get distance between two points.
*/
public function getDistance(
float $originLat,
float $originLng,
float $destLat,
float $destLng
): ?array;
/**
* Get ETA between two points.
*/
public function getETA(
float $originLat,
float $originLng,
float $destLat,
float $destLng
): ?int;
/**
* Geocode an address to coordinates.
*/
public function geocode(string $address): ?array;
/**
* Reverse geocode coordinates to address.
*/
public function reverseGeocode(float $latitude, float $longitude): ?array;
/**
* Calculate distance using Haversine formula.
*/
public function calculateHaversineDistance(
float $lat1,
float $lng1,
float $lat2,
float $lng2
): float;
/**
* Decode polyline to array of coordinates.
*/
public function decodePolyline(string $encodedPolyline): array;
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Contracts;
use Modules\RestaurantDelivery\Models\Delivery;
use Modules\RestaurantDelivery\Models\DeliveryRating;
use Modules\RestaurantDelivery\Models\Rider;
interface RatingServiceInterface
{
/**
* Create a rating for a delivery.
*/
public function createRating(
Delivery $delivery,
int $overallRating,
array $categoryRatings = [],
?string $review = null,
array $tags = [],
bool $isAnonymous = false,
?int $customerId = null,
bool $isRestaurantRating = false
): ?DeliveryRating;
/**
* Check if delivery can be rated.
*/
public function canRate(Delivery $delivery, bool $isRestaurantRating = false): bool;
/**
* Validate rating value.
*/
public function isValidRating(int $rating): bool;
/**
* Validate review.
*/
public function isValidReview(?string $review): bool;
/**
* Add rider's response to a rating.
*/
public function addRiderResponse(DeliveryRating $rating, string $response): bool;
/**
* Approve a rating.
*/
public function approveRating(DeliveryRating $rating): void;
/**
* Reject a rating.
*/
public function rejectRating(DeliveryRating $rating, string $reason): void;
/**
* Get rider's rating statistics.
*/
public function getRiderStats(Rider $rider): array;
/**
* Get available rating categories.
*/
public function getCategories(): array;
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Contracts;
use Modules\RestaurantDelivery\Models\Delivery;
use Modules\RestaurantDelivery\Models\DeliveryTip;
use Modules\RestaurantDelivery\Models\Rider;
interface TipServiceInterface
{
/**
* Create a pre-delivery tip.
*/
public function createPreDeliveryTip(
Delivery $delivery,
float $amount,
?int $customerId = null,
string $calculationType = 'fixed',
?float $percentageValue = null,
?string $message = null
): ?DeliveryTip;
/**
* Create a post-delivery tip.
*/
public function createPostDeliveryTip(
Delivery $delivery,
float $amount,
?int $customerId = null,
string $calculationType = 'fixed',
?float $percentageValue = null,
?string $message = null
): ?DeliveryTip;
/**
* Mark tip as paid.
*/
public function markTipAsPaid(
DeliveryTip $tip,
string $paymentReference,
string $paymentMethod
): bool;
/**
* Process tip payment.
*/
public function processTipPayment(DeliveryTip $tip, array $paymentData): bool;
/**
* Check if pre-delivery tip is allowed.
*/
public function canTipPreDelivery(Delivery $delivery): bool;
/**
* Check if post-delivery tip is allowed.
*/
public function canTipPostDelivery(Delivery $delivery): bool;
/**
* Validate tip amount.
*/
public function isValidAmount(
float $amount,
string $calculationType,
?float $percentageValue,
float $orderValue
): bool;
/**
* Get tip options for display.
*/
public function getTipOptions(Delivery $delivery): array;
/**
* Get rider's tip statistics.
*/
public function getRiderTipStats(Rider $rider, ?string $period = null): array;
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Contracts;
use Modules\RestaurantDelivery\Models\Delivery;
use Modules\RestaurantDelivery\Models\Rider;
interface TrackingServiceInterface
{
/**
* Update rider's live location.
*/
public function updateRiderLocation(
Rider $rider,
float $latitude,
float $longitude,
?float $speed = null,
?float $bearing = null,
?float $accuracy = null
): array;
/**
* Get last known rider location.
*/
public function getLastRiderLocation(int|string $riderId): ?array;
/**
* Check if rider has valid location.
*/
public function hasValidLocation(Rider $rider): bool;
/**
* Initialize tracking for a new delivery.
*/
public function initializeDeliveryTracking(Delivery $delivery): bool;
/**
* Update delivery tracking with new rider location.
*/
public function updateDeliveryTracking(
Delivery $delivery,
float $latitude,
float $longitude,
?float $speed = null,
?float $bearing = null
): void;
/**
* Update delivery status in tracking.
*/
public function updateDeliveryStatus(Delivery $delivery, ?array $metadata = null): void;
/**
* Get current tracking data for a delivery.
*/
public function getDeliveryTracking(Delivery $delivery): ?array;
/**
* End tracking for completed/cancelled delivery.
*/
public function endDeliveryTracking(Delivery $delivery): void;
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\DTOs;
use Illuminate\Http\Request;
readonly class CreateDeliveryDTO
{
public function __construct(
public int $restaurantId,
public string $restaurantName,
public string $pickupAddress,
public float $pickupLatitude,
public float $pickupLongitude,
public string $customerName,
public string $dropAddress,
public float $dropLatitude,
public float $dropLongitude,
public string $dropContactPhone,
public ?int $zoneId = null,
public ?string $orderableType = null,
public ?int $orderableId = null,
public ?string $pickupContactName = null,
public ?string $pickupContactPhone = null,
public ?string $pickupInstructions = null,
public ?string $dropContactName = null,
public ?string $dropInstructions = null,
public ?string $dropFloor = null,
public ?string $dropApartment = null,
public bool $isScheduled = false,
public ?\DateTimeInterface $scheduledFor = null,
public bool $isPriority = false,
public ?float $orderValue = null,
public ?float $tipAmount = null,
public ?string $tipType = null,
public ?array $meta = null,
) {}
/**
* Create DTO from request.
*
* IMPORTANT: restaurant_id is NEVER passed from request.
* It is always obtained via getUserRestaurantId().
*/
public static function fromRequest(Request $request): self
{
return new self(
restaurantId: (int) getUserRestaurantId(),
restaurantName: $request->string('restaurant_name')->toString(),
pickupAddress: $request->string('pickup_address')->toString(),
pickupLatitude: (float) $request->input('pickup_latitude'),
pickupLongitude: (float) $request->input('pickup_longitude'),
customerName: $request->string('customer_name')->toString(),
dropAddress: $request->string('drop_address')->toString(),
dropLatitude: (float) $request->input('drop_latitude'),
dropLongitude: (float) $request->input('drop_longitude'),
dropContactPhone: $request->string('drop_contact_phone')->toString(),
zoneId: $request->filled('zone_id') ? $request->integer('zone_id') : null,
orderableType: $request->filled('orderable_type') ? $request->string('orderable_type')->toString() : null,
orderableId: $request->filled('orderable_id') ? $request->integer('orderable_id') : null,
pickupContactName: $request->filled('pickup_contact_name') ? $request->string('pickup_contact_name')->toString() : null,
pickupContactPhone: $request->filled('pickup_contact_phone') ? $request->string('pickup_contact_phone')->toString() : null,
pickupInstructions: $request->filled('pickup_instructions') ? $request->string('pickup_instructions')->toString() : null,
dropContactName: $request->filled('drop_contact_name') ? $request->string('drop_contact_name')->toString() : null,
dropInstructions: $request->filled('drop_instructions') ? $request->string('drop_instructions')->toString() : null,
dropFloor: $request->filled('drop_floor') ? $request->string('drop_floor')->toString() : null,
dropApartment: $request->filled('drop_apartment') ? $request->string('drop_apartment')->toString() : null,
isScheduled: $request->boolean('is_scheduled'),
scheduledFor: $request->filled('scheduled_for') ? new \DateTime($request->input('scheduled_for')) : null,
isPriority: $request->boolean('is_priority'),
orderValue: $request->filled('order_value') ? (float) $request->input('order_value') : null,
tipAmount: $request->filled('tip_amount') ? (float) $request->input('tip_amount') : null,
tipType: $request->filled('tip_type') ? $request->string('tip_type')->toString() : null,
meta: $request->input('meta'),
);
}
/**
* Convert to array for model creation.
*/
public function toArray(): array
{
return array_filter([
'restaurant_id' => $this->restaurantId,
'restaurant_name' => $this->restaurantName,
'pickup_address' => $this->pickupAddress,
'pickup_latitude' => $this->pickupLatitude,
'pickup_longitude' => $this->pickupLongitude,
'customer_name' => $this->customerName,
'drop_address' => $this->dropAddress,
'drop_latitude' => $this->dropLatitude,
'drop_longitude' => $this->dropLongitude,
'drop_contact_phone' => $this->dropContactPhone,
'zone_id' => $this->zoneId,
'orderable_type' => $this->orderableType,
'orderable_id' => $this->orderableId,
'pickup_contact_name' => $this->pickupContactName,
'pickup_contact_phone' => $this->pickupContactPhone,
'pickup_instructions' => $this->pickupInstructions,
'drop_contact_name' => $this->dropContactName,
'drop_instructions' => $this->dropInstructions,
'drop_floor' => $this->dropFloor,
'drop_apartment' => $this->dropApartment,
'is_scheduled' => $this->isScheduled,
'scheduled_for' => $this->scheduledFor?->format('Y-m-d H:i:s'),
'is_priority' => $this->isPriority,
'order_value' => $this->orderValue,
'tip_amount' => $this->tipAmount,
'tip_type' => $this->tipType,
'meta' => $this->meta,
], fn ($value) => $value !== null);
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\DTOs;
use Modules\RestaurantDelivery\Enums\DeliveryStatus;
use Modules\RestaurantDelivery\Models\Delivery;
readonly class DeliveryTrackingDTO
{
public function __construct(
public int $deliveryId,
public string $trackingCode,
public DeliveryStatus $status,
public ?RiderLocationDTO $riderLocation = null,
public ?float $pickupLatitude = null,
public ?float $pickupLongitude = null,
public ?float $dropLatitude = null,
public ?float $dropLongitude = null,
public ?string $pickupAddress = null,
public ?string $dropAddress = null,
public ?int $eta = null,
public ?float $remainingDistance = null,
public ?string $routePolyline = null,
public ?array $riderInfo = null,
public ?array $timeline = null,
) {}
/**
* Create DTO from Delivery model and Firebase data.
*/
public static function fromDeliveryAndFirebase(Delivery $delivery, ?array $firebaseData = null): self
{
$riderLocation = null;
if ($firebaseData && isset($firebaseData['rider_location'])) {
$riderLocation = RiderLocationDTO::fromArray($firebaseData['rider_location']);
}
return new self(
deliveryId: $delivery->id,
trackingCode: $delivery->tracking_code,
status: $delivery->status,
riderLocation: $riderLocation,
pickupLatitude: (float) $delivery->pickup_latitude,
pickupLongitude: (float) $delivery->pickup_longitude,
dropLatitude: (float) $delivery->drop_latitude,
dropLongitude: (float) $delivery->drop_longitude,
pickupAddress: $delivery->pickup_address,
dropAddress: $delivery->drop_address,
eta: $firebaseData['eta'] ?? null,
remainingDistance: $firebaseData['remaining_distance'] ?? null,
routePolyline: $firebaseData['route']['polyline'] ?? $delivery->route_polyline,
riderInfo: $delivery->rider ? [
'id' => $delivery->rider->id,
'name' => $delivery->rider->full_name,
'phone' => $delivery->rider->phone,
'photo' => $delivery->rider->photo_url,
'rating' => $delivery->rider->rating,
'vehicle_type' => $delivery->rider->vehicle_type,
] : null,
timeline: $delivery->statusHistory?->map(fn ($h) => [
'status' => $h->to_status,
'timestamp' => $h->changed_at->toIso8601String(),
'label' => DeliveryStatus::from($h->to_status)->label(),
])->toArray(),
);
}
/**
* Convert to array.
*/
public function toArray(): array
{
return [
'delivery_id' => $this->deliveryId,
'tracking_code' => $this->trackingCode,
'status' => $this->status->value,
'status_label' => $this->status->label(),
'status_description' => $this->status->description(),
'status_color' => $this->status->color(),
'rider_location' => $this->riderLocation?->toArray(),
'pickup' => [
'latitude' => $this->pickupLatitude,
'longitude' => $this->pickupLongitude,
'address' => $this->pickupAddress,
],
'drop' => [
'latitude' => $this->dropLatitude,
'longitude' => $this->dropLongitude,
'address' => $this->dropAddress,
],
'eta' => $this->eta,
'eta_formatted' => $this->eta ? $this->formatEta($this->eta) : null,
'remaining_distance' => $this->remainingDistance,
'remaining_distance_formatted' => $this->remainingDistance
? round($this->remainingDistance, 1).' km'
: null,
'route_polyline' => $this->routePolyline,
'rider' => $this->riderInfo,
'timeline' => $this->timeline,
];
}
/**
* Format ETA in human-readable format.
*/
protected function formatEta(int $minutes): string
{
if ($minutes < 60) {
return "{$minutes} min";
}
$hours = floor($minutes / 60);
$mins = $minutes % 60;
if ($mins === 0) {
return "{$hours} hr";
}
return "{$hours} hr {$mins} min";
}
}

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\DTOs;
readonly class EarningsDTO
{
public function __construct(
public float $baseAmount,
public float $distanceAmount,
public float $totalBonus,
public float $totalPenalty,
public float $tipAmount,
public float $grossAmount,
public float $commissionRate,
public float $commissionAmount,
public float $netAmount,
public string $currency,
public array $breakdown = [],
public array $bonuses = [],
public array $penalties = [],
) {}
/**
* Create DTO from calculation result.
*/
public static function fromCalculation(array $data): self
{
return new self(
baseAmount: (float) ($data['base_amount'] ?? 0),
distanceAmount: (float) ($data['distance_amount'] ?? 0),
totalBonus: (float) ($data['total_bonus'] ?? 0),
totalPenalty: (float) ($data['total_penalty'] ?? 0),
tipAmount: (float) ($data['tip_amount'] ?? 0),
grossAmount: (float) ($data['gross_amount'] ?? 0),
commissionRate: (float) ($data['commission_rate'] ?? 0),
commissionAmount: (float) ($data['commission_amount'] ?? 0),
netAmount: (float) ($data['net_amount'] ?? 0),
currency: $data['currency'] ?? config('restaurant-delivery.pricing.currency', 'BDT'),
breakdown: $data['breakdown'] ?? [],
bonuses: $data['bonuses'] ?? [],
penalties: $data['penalties'] ?? [],
);
}
/**
* Convert to array.
*/
public function toArray(): array
{
return [
'base_amount' => $this->baseAmount,
'distance_amount' => $this->distanceAmount,
'total_bonus' => $this->totalBonus,
'total_penalty' => $this->totalPenalty,
'tip_amount' => $this->tipAmount,
'gross_amount' => $this->grossAmount,
'commission_rate' => $this->commissionRate,
'commission_amount' => $this->commissionAmount,
'net_amount' => $this->netAmount,
'currency' => $this->currency,
'breakdown' => $this->breakdown,
'bonuses' => $this->bonuses,
'penalties' => $this->penalties,
];
}
/**
* Get formatted amounts for display.
*/
public function toFormattedArray(): array
{
$symbol = config('restaurant-delivery.pricing.currency_symbol', '৳');
return [
'base_amount' => $symbol.number_format($this->baseAmount, 2),
'distance_amount' => $symbol.number_format($this->distanceAmount, 2),
'total_bonus' => $symbol.number_format($this->totalBonus, 2),
'total_penalty' => $symbol.number_format($this->totalPenalty, 2),
'tip_amount' => $symbol.number_format($this->tipAmount, 2),
'gross_amount' => $symbol.number_format($this->grossAmount, 2),
'commission_amount' => $symbol.number_format($this->commissionAmount, 2),
'net_amount' => $symbol.number_format($this->netAmount, 2),
'commission_rate' => $this->commissionRate.'%',
];
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\DTOs;
use Illuminate\Http\Request;
readonly class RatingDTO
{
public function __construct(
public int $overallRating,
public ?int $speedRating = null,
public ?int $communicationRating = null,
public ?int $foodConditionRating = null,
public ?int $professionalismRating = null,
public ?string $review = null,
public array $tags = [],
public bool $isAnonymous = false,
public bool $isRestaurantRating = false,
public ?int $customerId = null,
) {}
/**
* Create DTO from request.
*/
public static function fromRequest(Request $request): self
{
return new self(
overallRating: $request->integer('overall_rating'),
speedRating: $request->filled('speed_rating') ? $request->integer('speed_rating') : null,
communicationRating: $request->filled('communication_rating') ? $request->integer('communication_rating') : null,
foodConditionRating: $request->filled('food_condition_rating') ? $request->integer('food_condition_rating') : null,
professionalismRating: $request->filled('professionalism_rating') ? $request->integer('professionalism_rating') : null,
review: $request->filled('review') ? $request->string('review')->toString() : null,
tags: $request->input('tags', []),
isAnonymous: $request->boolean('is_anonymous'),
isRestaurantRating: $request->boolean('is_restaurant_rating'),
customerId: $request->filled('customer_id') ? $request->integer('customer_id') : null,
);
}
/**
* Get category ratings as array.
*/
public function categoryRatings(): array
{
return array_filter([
'speed' => $this->speedRating,
'communication' => $this->communicationRating,
'food_condition' => $this->foodConditionRating,
'professionalism' => $this->professionalismRating,
]);
}
/**
* Convert to array.
*/
public function toArray(): array
{
return [
'overall_rating' => $this->overallRating,
'speed_rating' => $this->speedRating,
'communication_rating' => $this->communicationRating,
'food_condition_rating' => $this->foodConditionRating,
'professionalism_rating' => $this->professionalismRating,
'review' => $this->review,
'tags' => $this->tags,
'is_anonymous' => $this->isAnonymous,
'is_restaurant_rating' => $this->isRestaurantRating,
'customer_id' => $this->customerId,
];
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\DTOs;
use Illuminate\Http\Request;
readonly class RiderLocationDTO
{
public function __construct(
public float $latitude,
public float $longitude,
public ?float $speed = null,
public ?float $bearing = null,
public ?float $accuracy = null,
public ?float $altitude = null,
public ?\DateTimeInterface $timestamp = null,
) {}
/**
* Create DTO from request.
*/
public static function fromRequest(Request $request): self
{
return new self(
latitude: (float) $request->input('latitude'),
longitude: (float) $request->input('longitude'),
speed: $request->filled('speed') ? (float) $request->input('speed') : null,
bearing: $request->filled('bearing') ? (float) $request->input('bearing') : null,
accuracy: $request->filled('accuracy') ? (float) $request->input('accuracy') : null,
altitude: $request->filled('altitude') ? (float) $request->input('altitude') : null,
timestamp: $request->filled('timestamp') ? new \DateTime($request->input('timestamp')) : null,
);
}
/**
* Create DTO from array.
*/
public static function fromArray(array $data): self
{
return new self(
latitude: (float) ($data['latitude'] ?? $data['lat'] ?? 0),
longitude: (float) ($data['longitude'] ?? $data['lng'] ?? 0),
speed: isset($data['speed']) ? (float) $data['speed'] : null,
bearing: isset($data['bearing']) ? (float) $data['bearing'] : null,
accuracy: isset($data['accuracy']) ? (float) $data['accuracy'] : null,
altitude: isset($data['altitude']) ? (float) $data['altitude'] : null,
timestamp: isset($data['timestamp']) ? new \DateTime($data['timestamp']) : null,
);
}
/**
* Convert to array.
*/
public function toArray(): array
{
return array_filter([
'latitude' => $this->latitude,
'longitude' => $this->longitude,
'speed' => $this->speed,
'bearing' => $this->bearing,
'accuracy' => $this->accuracy,
'altitude' => $this->altitude,
'timestamp' => $this->timestamp?->format('Y-m-d H:i:s'),
], fn ($value) => $value !== null);
}
/**
* Convert to Firebase format.
*/
public function toFirebaseFormat(): array
{
return [
'lat' => $this->latitude,
'lng' => $this->longitude,
'speed' => $this->speed,
'bearing' => $this->bearing,
'accuracy' => $this->accuracy,
'timestamp' => ($this->timestamp ?? now())->getTimestamp() * 1000, // JavaScript timestamp
];
}
/**
* Check if location is valid.
*/
public function isValid(): bool
{
return $this->latitude >= -90
&& $this->latitude <= 90
&& $this->longitude >= -180
&& $this->longitude <= 180;
}
/**
* Check if accuracy is acceptable.
*/
public function hasAcceptableAccuracy(?float $threshold = null): bool
{
if ($this->accuracy === null) {
return true;
}
$threshold = $threshold ?? config('restaurant-delivery.firebase.location.accuracy_threshold', 50);
return $this->accuracy <= $threshold;
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Enums;
enum CommissionType: string
{
case FIXED = 'fixed';
case PERCENTAGE = 'percentage';
case PER_KM = 'per_km';
case HYBRID = 'hybrid';
public function label(): string
{
return match ($this) {
self::FIXED => 'Fixed per delivery',
self::PERCENTAGE => 'Percentage of delivery fee',
self::PER_KM => 'Per kilometer',
self::HYBRID => 'Base + Per km',
};
}
}

View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Enums;
enum DeliveryStatus: string
{
case PENDING = 'pending';
case CONFIRMED = 'confirmed';
case PREPARING = 'preparing';
case READY_FOR_PICKUP = 'ready_for_pickup';
case RIDER_ASSIGNED = 'rider_assigned';
case RIDER_AT_RESTAURANT = 'rider_at_restaurant';
case PICKED_UP = 'picked_up';
case ON_THE_WAY = 'on_the_way';
case ARRIVED = 'arrived';
case DELIVERED = 'delivered';
case CANCELLED = 'cancelled';
case FAILED = 'failed';
public function label(): string
{
return match ($this) {
self::PENDING => 'Order Placed',
self::CONFIRMED => 'Order Confirmed',
self::PREPARING => 'Preparing',
self::READY_FOR_PICKUP => 'Ready for Pickup',
self::RIDER_ASSIGNED => 'Rider Assigned',
self::RIDER_AT_RESTAURANT => 'Rider at Restaurant',
self::PICKED_UP => 'Picked Up',
self::ON_THE_WAY => 'On The Way',
self::ARRIVED => 'Arrived',
self::DELIVERED => 'Delivered',
self::CANCELLED => 'Cancelled',
self::FAILED => 'Failed',
};
}
public function description(): string
{
return match ($this) {
self::PENDING => 'Waiting for restaurant confirmation',
self::CONFIRMED => 'Restaurant is preparing your food',
self::PREPARING => 'Your food is being prepared',
self::READY_FOR_PICKUP => 'Food is ready, waiting for rider',
self::RIDER_ASSIGNED => 'A rider has been assigned',
self::RIDER_AT_RESTAURANT => 'Rider has arrived at restaurant',
self::PICKED_UP => 'Rider has picked up your order',
self::ON_THE_WAY => 'Rider is on the way to you',
self::ARRIVED => 'Rider has arrived at your location',
self::DELIVERED => 'Order has been delivered',
self::CANCELLED => 'Order has been cancelled',
self::FAILED => 'Delivery failed',
};
}
public function color(): string
{
return match ($this) {
self::PENDING => '#6B7280',
self::CONFIRMED => '#3B82F6',
self::PREPARING => '#F59E0B',
self::READY_FOR_PICKUP => '#8B5CF6',
self::RIDER_ASSIGNED => '#06B6D4',
self::RIDER_AT_RESTAURANT => '#14B8A6',
self::PICKED_UP => '#22C55E',
self::ON_THE_WAY => '#10B981',
self::ARRIVED => '#059669',
self::DELIVERED => '#047857',
self::CANCELLED => '#EF4444',
self::FAILED => '#DC2626',
};
}
public function icon(): string
{
return match ($this) {
self::PENDING => 'clock',
self::CONFIRMED => 'check-circle',
self::PREPARING => 'fire',
self::READY_FOR_PICKUP => 'package',
self::RIDER_ASSIGNED => 'user-check',
self::RIDER_AT_RESTAURANT => 'map-pin',
self::PICKED_UP => 'shopping-bag',
self::ON_THE_WAY => 'truck',
self::ARRIVED => 'home',
self::DELIVERED => 'check-double',
self::CANCELLED => 'x-circle',
self::FAILED => 'alert-triangle',
};
}
public function canTransitionTo(DeliveryStatus $newStatus): bool
{
$allowedTransitions = match ($this) {
self::PENDING => [self::CONFIRMED, self::CANCELLED],
self::CONFIRMED => [self::PREPARING, self::CANCELLED],
self::PREPARING => [self::READY_FOR_PICKUP, self::CANCELLED],
self::READY_FOR_PICKUP => [self::RIDER_ASSIGNED, self::CANCELLED],
self::RIDER_ASSIGNED => [self::RIDER_AT_RESTAURANT, self::CANCELLED, self::READY_FOR_PICKUP], // Can reassign
self::RIDER_AT_RESTAURANT => [self::PICKED_UP, self::CANCELLED],
self::PICKED_UP => [self::ON_THE_WAY, self::FAILED],
self::ON_THE_WAY => [self::ARRIVED, self::FAILED],
self::ARRIVED => [self::DELIVERED, self::FAILED],
self::DELIVERED => [],
self::CANCELLED => [],
self::FAILED => [],
};
return in_array($newStatus, $allowedTransitions);
}
public function isActive(): bool
{
return ! in_array($this, [self::DELIVERED, self::CANCELLED, self::FAILED]);
}
public function isCompleted(): bool
{
return $this === self::DELIVERED;
}
public function isCancelled(): bool
{
return $this === self::CANCELLED;
}
public function isFailed(): bool
{
return $this === self::FAILED;
}
public function isPickedUp(): bool
{
return in_array($this, [self::PICKED_UP, self::ON_THE_WAY, self::ARRIVED, self::DELIVERED]);
}
public function requiresRider(): bool
{
return in_array($this, [
self::RIDER_ASSIGNED,
self::RIDER_AT_RESTAURANT,
self::PICKED_UP,
self::ON_THE_WAY,
self::ARRIVED,
]);
}
public function isTrackable(): bool
{
return in_array($this, [
self::RIDER_ASSIGNED,
self::RIDER_AT_RESTAURANT,
self::PICKED_UP,
self::ON_THE_WAY,
self::ARRIVED,
]);
}
public static function activeStatuses(): array
{
return [
self::PENDING,
self::CONFIRMED,
self::PREPARING,
self::READY_FOR_PICKUP,
self::RIDER_ASSIGNED,
self::RIDER_AT_RESTAURANT,
self::PICKED_UP,
self::ON_THE_WAY,
self::ARRIVED,
];
}
public static function completedStatuses(): array
{
return [self::DELIVERED, self::CANCELLED, self::FAILED];
}
public static function riderActiveStatuses(): array
{
return [
self::RIDER_ASSIGNED,
self::RIDER_AT_RESTAURANT,
self::PICKED_UP,
self::ON_THE_WAY,
self::ARRIVED,
];
}
public function sortOrder(): int
{
return match ($this) {
self::PENDING => 1,
self::CONFIRMED => 2,
self::PREPARING => 3,
self::READY_FOR_PICKUP => 4,
self::RIDER_ASSIGNED => 5,
self::RIDER_AT_RESTAURANT => 6,
self::PICKED_UP => 7,
self::ON_THE_WAY => 8,
self::ARRIVED => 9,
self::DELIVERED => 10,
self::CANCELLED => 11,
self::FAILED => 12,
};
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Enums;
enum RiderStatus: string
{
case PENDING = 'pending';
case ACTIVE = 'active';
case SUSPENDED = 'suspended';
case INACTIVE = 'inactive';
public function label(): string
{
return match ($this) {
self::PENDING => 'Pending Verification',
self::ACTIVE => 'Active',
self::SUSPENDED => 'Suspended',
self::INACTIVE => 'Inactive',
};
}
public function color(): string
{
return match ($this) {
self::PENDING => '#F59E0B',
self::ACTIVE => '#10B981',
self::SUSPENDED => '#EF4444',
self::INACTIVE => '#6B7280',
};
}
public function canAcceptOrders(): bool
{
return $this === self::ACTIVE;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Enums;
enum RiderType: string
{
case INTERNAL = 'internal';
case FREELANCE = 'freelance';
case THIRD_PARTY = 'third_party';
public function label(): string
{
return match ($this) {
self::INTERNAL => 'Internal',
self::FREELANCE => 'Freelance',
self::THIRD_PARTY => 'Third Party',
};
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Enums;
enum VehicleType: string
{
case BICYCLE = 'bicycle';
case MOTORCYCLE = 'motorcycle';
case CAR = 'car';
case VAN = 'van';
public function label(): string
{
return match ($this) {
self::BICYCLE => 'Bicycle',
self::MOTORCYCLE => 'Motorcycle',
self::CAR => 'Car',
self::VAN => 'Van',
};
}
public function icon(): string
{
return match ($this) {
self::BICYCLE => 'bicycle',
self::MOTORCYCLE => 'motorcycle',
self::CAR => 'car',
self::VAN => 'truck',
};
}
public function maxCapacity(): float
{
return match ($this) {
self::BICYCLE => 10, // kg
self::MOTORCYCLE => 25,
self::CAR => 50,
self::VAN => 200,
};
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Modules\RestaurantDelivery\Models\Delivery;
class DeliveryCreated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public readonly Delivery $delivery
) {}
/**
* Get the channels the event should broadcast on.
*/
public function broadcastOn(): array
{
return [
new PrivateChannel("restaurant.{$this->delivery->restaurant_id}.deliveries"),
];
}
/**
* Get the data to broadcast.
*/
public function broadcastWith(): array
{
return [
'delivery_id' => $this->delivery->id,
'tracking_code' => $this->delivery->tracking_code,
'restaurant_id' => $this->delivery->restaurant_id,
'status' => $this->delivery->status,
'pickup_address' => $this->delivery->pickup_address,
'drop_address' => $this->delivery->drop_address,
'created_at' => $this->delivery->created_at->toIso8601String(),
];
}
/**
* The event's broadcast name.
*/
public function broadcastAs(): string
{
return 'delivery.created';
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Modules\RestaurantDelivery\Enums\DeliveryStatus;
use Modules\RestaurantDelivery\Models\Delivery;
class DeliveryStatusChanged implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public readonly Delivery $delivery,
public readonly ?DeliveryStatus $previousStatus = null,
public readonly ?array $metadata = null
) {}
/**
* Get the channels the event should broadcast on.
*/
public function broadcastOn(): array
{
$channels = [
new Channel("delivery.{$this->delivery->tracking_code}"),
new PrivateChannel("restaurant.{$this->delivery->restaurant_id}.deliveries"),
];
if ($this->delivery->rider_id) {
$channels[] = new PrivateChannel("rider.{$this->delivery->rider_id}.deliveries");
}
return $channels;
}
/**
* Get the data to broadcast.
*/
public function broadcastWith(): array
{
return [
'delivery_id' => $this->delivery->id,
'tracking_code' => $this->delivery->tracking_code,
'status' => $this->delivery->status->value,
'status_label' => $this->delivery->status->label(),
'status_description' => $this->delivery->status->description(),
'status_color' => $this->delivery->status->color(),
'previous_status' => $this->previousStatus?->value,
'rider_id' => $this->delivery->rider_id,
'changed_at' => now()->toIso8601String(),
'metadata' => $this->metadata,
];
}
/**
* The event's broadcast name.
*/
public function broadcastAs(): string
{
return 'delivery.status.changed';
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Modules\RestaurantDelivery\Models\Rider;
class RiderLocationUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public readonly Rider $rider,
public readonly float $latitude,
public readonly float $longitude,
public readonly ?float $speed = null,
public readonly ?float $bearing = null
) {}
/**
* Get the channels the event should broadcast on.
*/
public function broadcastOn(): array
{
$channels = [
new PrivateChannel("rider.{$this->rider->id}.location"),
];
// Broadcast to all active delivery channels for this rider
foreach ($this->rider->activeDeliveries as $delivery) {
$channels[] = new Channel("delivery.{$delivery->tracking_code}");
}
return $channels;
}
/**
* Get the data to broadcast.
*/
public function broadcastWith(): array
{
return [
'rider_id' => $this->rider->id,
'location' => [
'latitude' => $this->latitude,
'longitude' => $this->longitude,
'speed' => $this->speed,
'bearing' => $this->bearing,
],
'timestamp' => now()->toIso8601String(),
];
}
/**
* The event's broadcast name.
*/
public function broadcastAs(): string
{
return 'rider.location.updated';
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Modules\RestaurantDelivery\Models\DeliveryRating;
class RiderRated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public readonly DeliveryRating $rating
) {}
/**
* Get the channels the event should broadcast on.
*/
public function broadcastOn(): array
{
return [
new PrivateChannel("rider.{$this->rating->rider_id}.ratings"),
];
}
/**
* Get the data to broadcast.
*/
public function broadcastWith(): array
{
return [
'rating_id' => $this->rating->id,
'delivery_id' => $this->rating->delivery_id,
'rider_id' => $this->rating->rider_id,
'overall_rating' => $this->rating->overall_rating,
'has_review' => ! empty($this->rating->review),
'created_at' => $this->rating->created_at->toIso8601String(),
];
}
/**
* The event's broadcast name.
*/
public function broadcastAs(): string
{
return 'rider.rated';
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Modules\RestaurantDelivery\Models\DeliveryTip;
class TipReceived implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public readonly DeliveryTip $tip
) {}
/**
* Get the channels the event should broadcast on.
*/
public function broadcastOn(): array
{
return [
new PrivateChannel("rider.{$this->tip->rider_id}.tips"),
];
}
/**
* Get the data to broadcast.
*/
public function broadcastWith(): array
{
return [
'tip_id' => $this->tip->id,
'delivery_id' => $this->tip->delivery_id,
'rider_id' => $this->tip->rider_id,
'amount' => $this->tip->amount,
'rider_amount' => $this->tip->rider_amount,
'currency' => $this->tip->currency,
'message' => $this->tip->message,
'created_at' => $this->tip->created_at->toIso8601String(),
];
}
/**
* The event's broadcast name.
*/
public function broadcastAs(): string
{
return 'tip.received';
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Exceptions;
use Exception;
class AssignmentException extends Exception
{
protected string $errorCode;
public function __construct(
string $message,
string $errorCode = 'ASSIGNMENT_ERROR',
int $code = 400,
?\Throwable $previous = null
) {
$this->errorCode = $errorCode;
parent::__construct($message, $code, $previous);
}
/**
* Get error code.
*/
public function getErrorCode(): string
{
return $this->errorCode;
}
/**
* Render the exception as JSON response.
*/
public function render(): \Illuminate\Http\JsonResponse
{
return response()->json([
'success' => false,
'message' => $this->getMessage(),
'error_code' => $this->errorCode,
], $this->getCode());
}
/**
* Create exception for no riders available.
*/
public static function noRidersAvailable(): self
{
return new self(
'No riders are available in the area',
'NO_RIDERS_AVAILABLE',
400
);
}
/**
* Create exception for assignment timeout.
*/
public static function timeout(): self
{
return new self(
'Assignment request timed out',
'ASSIGNMENT_TIMEOUT',
408
);
}
/**
* Create exception for max reassignments reached.
*/
public static function maxReassignmentsReached(): self
{
return new self(
'Maximum reassignment attempts reached',
'MAX_REASSIGNMENTS_REACHED',
400
);
}
/**
* Create exception for rider already assigned.
*/
public static function alreadyAssigned(): self
{
return new self(
'A rider has already been assigned to this delivery',
'RIDER_ALREADY_ASSIGNED',
400
);
}
/**
* Create exception for assignment rejected.
*/
public static function rejected(string $reason = ''): self
{
$message = 'Assignment was rejected';
if ($reason) {
$message .= ": {$reason}";
}
return new self($message, 'ASSIGNMENT_REJECTED', 400);
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Exceptions;
use Exception;
class DeliveryException extends Exception
{
protected string $errorCode;
public function __construct(
string $message,
string $errorCode = 'DELIVERY_ERROR',
int $code = 400,
?\Throwable $previous = null
) {
$this->errorCode = $errorCode;
parent::__construct($message, $code, $previous);
}
/**
* Get error code.
*/
public function getErrorCode(): string
{
return $this->errorCode;
}
/**
* Render the exception as JSON response.
*/
public function render(): \Illuminate\Http\JsonResponse
{
return response()->json([
'success' => false,
'message' => $this->getMessage(),
'error_code' => $this->errorCode,
], $this->getCode());
}
/**
* Create exception for invalid status transition.
*/
public static function invalidStatusTransition(string $from, string $to): self
{
return new self(
"Cannot transition from '{$from}' to '{$to}'",
'INVALID_STATUS_TRANSITION',
422
);
}
/**
* Create exception for delivery not found.
*/
public static function notFound(string $identifier): self
{
return new self(
"Delivery '{$identifier}' not found",
'DELIVERY_NOT_FOUND',
404
);
}
/**
* Create exception for delivery not trackable.
*/
public static function notTrackable(): self
{
return new self(
'Tracking is not available for this delivery',
'DELIVERY_NOT_TRACKABLE',
400
);
}
/**
* Create exception for delivery already completed.
*/
public static function alreadyCompleted(): self
{
return new self(
'This delivery has already been completed',
'DELIVERY_ALREADY_COMPLETED',
400
);
}
/**
* Create exception for delivery already cancelled.
*/
public static function alreadyCancelled(): self
{
return new self(
'This delivery has already been cancelled',
'DELIVERY_ALREADY_CANCELLED',
400
);
}
/**
* Create exception for no rider assigned.
*/
public static function noRiderAssigned(): self
{
return new self(
'No rider has been assigned to this delivery',
'NO_RIDER_ASSIGNED',
400
);
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Exceptions;
use Exception;
class PaymentException extends Exception
{
protected string $errorCode;
public function __construct(
string $message,
string $errorCode = 'PAYMENT_ERROR',
int $code = 400,
?\Throwable $previous = null
) {
$this->errorCode = $errorCode;
parent::__construct($message, $code, $previous);
}
/**
* Get error code.
*/
public function getErrorCode(): string
{
return $this->errorCode;
}
/**
* Render the exception as JSON response.
*/
public function render(): \Illuminate\Http\JsonResponse
{
return response()->json([
'success' => false,
'message' => $this->getMessage(),
'error_code' => $this->errorCode,
], $this->getCode());
}
/**
* Create exception for payment failed.
*/
public static function failed(string $reason = ''): self
{
$message = 'Payment processing failed';
if ($reason) {
$message .= ": {$reason}";
}
return new self($message, 'PAYMENT_FAILED', 400);
}
/**
* Create exception for invalid amount.
*/
public static function invalidAmount(): self
{
return new self(
'Invalid payment amount',
'INVALID_PAYMENT_AMOUNT',
400
);
}
/**
* Create exception for tip already paid.
*/
public static function tipAlreadyPaid(): self
{
return new self(
'This tip has already been paid',
'TIP_ALREADY_PAID',
400
);
}
/**
* Create exception for payout failed.
*/
public static function payoutFailed(string $reason = ''): self
{
$message = 'Payout processing failed';
if ($reason) {
$message .= ": {$reason}";
}
return new self($message, 'PAYOUT_FAILED', 400);
}
/**
* Create exception for insufficient balance.
*/
public static function insufficientBalance(): self
{
return new self(
'Insufficient balance for payout',
'INSUFFICIENT_BALANCE',
400
);
}
}

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Exceptions;
use Exception;
class RiderException extends Exception
{
protected string $errorCode;
public function __construct(
string $message,
string $errorCode = 'RIDER_ERROR',
int $code = 400,
?\Throwable $previous = null
) {
$this->errorCode = $errorCode;
parent::__construct($message, $code, $previous);
}
/**
* Get error code.
*/
public function getErrorCode(): string
{
return $this->errorCode;
}
/**
* Render the exception as JSON response.
*/
public function render(): \Illuminate\Http\JsonResponse
{
return response()->json([
'success' => false,
'message' => $this->getMessage(),
'error_code' => $this->errorCode,
], $this->getCode());
}
/**
* Create exception for rider not found.
*/
public static function notFound(int|string $identifier): self
{
return new self(
"Rider '{$identifier}' not found",
'RIDER_NOT_FOUND',
404
);
}
/**
* Create exception for rider not verified.
*/
public static function notVerified(): self
{
return new self(
'Rider account is not verified',
'RIDER_NOT_VERIFIED',
403
);
}
/**
* Create exception for rider suspended.
*/
public static function suspended(): self
{
return new self(
'Rider account is suspended',
'RIDER_SUSPENDED',
403
);
}
/**
* Create exception for rider not available.
*/
public static function notAvailable(): self
{
return new self(
'Rider is not available to accept orders',
'RIDER_NOT_AVAILABLE',
400
);
}
/**
* Create exception for rider at max capacity.
*/
public static function atMaxCapacity(): self
{
return new self(
'Rider has reached maximum concurrent orders limit',
'RIDER_AT_MAX_CAPACITY',
400
);
}
/**
* Create exception for invalid location.
*/
public static function invalidLocation(): self
{
return new self(
'Invalid rider location data',
'INVALID_RIDER_LOCATION',
400
);
}
/**
* Create exception for rider offline.
*/
public static function offline(): self
{
return new self(
'Rider is currently offline',
'RIDER_OFFLINE',
400
);
}
}

View File

@@ -0,0 +1,308 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Http\Controllers\API;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\RestaurantDelivery\Enums\DeliveryStatus;
use Modules\RestaurantDelivery\Http\Requests\CreateDeliveryRequest;
use Modules\RestaurantDelivery\Http\Requests\UpdateDeliveryStatusRequest;
use Modules\RestaurantDelivery\Http\Resources\DeliveryResource;
use Modules\RestaurantDelivery\Http\Resources\DeliveryTrackingResource;
use Modules\RestaurantDelivery\Models\Delivery;
use Modules\RestaurantDelivery\Models\DeliveryStatusHistory;
use Modules\RestaurantDelivery\Models\Rider;
use Modules\RestaurantDelivery\Services\Tracking\LiveTrackingService;
class DeliveryController extends Controller
{
public function __construct(
protected LiveTrackingService $trackingService
) {}
/**
* List deliveries
*/
public function index(Request $request): JsonResponse
{
$query = Delivery::query()
->with(['rider', 'zone', 'rating']);
// Filter by status
if ($request->has('status')) {
$query->where('status', $request->status);
}
// Filter by restaurant
if ($request->has('restaurant_id')) {
$query->where('restaurant_id', $request->restaurant_id);
}
// Filter by rider
if ($request->has('rider_id')) {
$query->where('rider_id', $request->rider_id);
}
// Filter by date range
if ($request->has('from_date')) {
$query->whereDate('created_at', '>=', $request->from_date);
}
if ($request->has('to_date')) {
$query->whereDate('created_at', '<=', $request->to_date);
}
$deliveries = $query->orderBy('created_at', 'desc')
->paginate($request->get('per_page', 20));
return response()->json([
'success' => true,
'data' => DeliveryResource::collection($deliveries),
'meta' => [
'current_page' => $deliveries->currentPage(),
'last_page' => $deliveries->lastPage(),
'per_page' => $deliveries->perPage(),
'total' => $deliveries->total(),
],
]);
}
/**
* Create a new delivery
*/
public function store(CreateDeliveryRequest $request): JsonResponse
{
$delivery = Delivery::create(array_merge($request->validated(), [
'status' => DeliveryStatus::PENDING->value,
]));
// Initialize tracking
$this->trackingService->initializeDeliveryTracking($delivery);
return response()->json([
'success' => true,
'message' => 'Delivery created successfully',
'data' => new DeliveryResource($delivery->load(['rider', 'zone'])),
], 201);
}
/**
* Get delivery details
*/
public function show(Delivery $delivery): JsonResponse
{
return response()->json([
'success' => true,
'data' => new DeliveryResource(
$delivery->load(['rider', 'zone', 'rating', 'tip', 'statusHistory'])
),
]);
}
/**
* Update delivery status
*/
public function updateStatus(UpdateDeliveryStatusRequest $request, $uuid): JsonResponse
{
$delivery = Delivery::where('uuid', $uuid)->first();
$metadata = [
'changed_by_type' => $request->input('changed_by', 'api'),
'notes' => $request->input('notes'),
'latitude' => $request->input('latitude'),
'longitude' => $request->input('longitude'),
];
$success = $delivery->updateStatus($request->status, $metadata);
if (! $success) {
return response()->json([
'success' => false,
'message' => 'Invalid status transition',
], 422);
}
// Update Firebase tracking
$this->trackingService->updateDeliveryStatus($delivery, $metadata);
return response()->json([
'success' => true,
'message' => 'Status updated successfully',
'data' => new DeliveryResource($delivery->fresh(['rider', 'zone'])),
]);
}
/**
* Assign rider to delivery
*/
public function assignRider(Request $request, $uuid): JsonResponse
{
$request->validate([
'rider_id' => 'required|exists:restaurant_riders,id',
]);
$delivery = Delivery::where('uuid', $uuid)->first();
$rider = Rider::findOrFail($request->rider_id);
if (! $rider->canAcceptOrders()) {
return response()->json([
'success' => false,
'message' => 'Rider is not available',
], 422);
}
$delivery->assignRider($rider);
// Update Firebase tracking
// Check if Firebase tracking module is enabled
if (config('restaurant-delivery.firebase.enabled')) {
// Run Firebase tracking
$this->trackingService->initializeDeliveryTracking($delivery->fresh());
}
return response()->json([
'success' => true,
'message' => 'Rider assigned successfully',
'data' => new DeliveryResource($delivery->fresh(['rider'])),
]);
}
/**
* Cancel delivery
*/
public function cancel(Request $request, Delivery $delivery): JsonResponse
{
$request->validate([
'reason' => 'required|string|max:255',
'cancelled_by' => 'required|string|in:customer,restaurant,rider,admin',
'notes' => 'nullable|string',
]);
$success = $delivery->cancel(
$request->reason,
$request->cancelled_by,
$request->notes
);
if (! $success) {
return response()->json([
'success' => false,
'message' => 'Cannot cancel this delivery',
], 422);
}
// End tracking
$this->trackingService->endDeliveryTracking($delivery);
return response()->json([
'success' => true,
'message' => 'Delivery cancelled successfully',
]);
}
/**
* Mark delivery as delivered
*/
public function markDelivered(Request $request, Delivery $delivery): JsonResponse
{
$request->validate([
'photo' => 'nullable|string',
'signature' => 'nullable|string',
'recipient_name' => 'nullable|string|max:100',
]);
$success = $delivery->markDelivered([
'photo' => $request->photo,
'signature' => $request->signature,
'recipient_name' => $request->recipient_name,
]);
if (! $success) {
return response()->json([
'success' => false,
'message' => 'Cannot mark this delivery as delivered',
], 422);
}
// End tracking
$this->trackingService->endDeliveryTracking($delivery);
return response()->json([
'success' => true,
'message' => 'Delivery completed successfully',
'data' => new DeliveryResource($delivery->fresh()),
]);
}
/**
* Get delivery tracking info
*/
public function tracking(string $uuid): JsonResponse
{
$delivery = Delivery::where('uuid', $uuid)->first();
if (! $delivery->isTrackable()) {
return response()->json([
'success' => false,
'message' => 'Tracking not available for this delivery',
], 404);
}
$trackingData = $this->trackingService->getDeliveryTracking($delivery);
return response()->json([
'success' => true,
'data' => new DeliveryTrackingResource($delivery, $trackingData),
]);
}
/**
* Public tracking by tracking code
*/
public function publicTracking(string $trackingCode): JsonResponse
{
$delivery = Delivery::where('tracking_code', $trackingCode)->first();
if (! $delivery) {
return response()->json([
'success' => false,
'message' => 'Delivery not found',
], 404);
}
$trackingData = null;
if ($delivery->isTrackable()) {
$trackingData = $this->trackingService->getDeliveryTracking($delivery);
}
return response()->json([
'success' => true,
'data' => [
'tracking_code' => $delivery->tracking_code,
'status' => $delivery->status->value,
'status_label' => $delivery->status->label(),
'status_description' => $delivery->status->description(),
'status_color' => $delivery->status->color(),
'estimated_delivery' => $delivery->estimated_delivery_time?->format('H:i'),
'tracking' => $trackingData,
'timeline' => DeliveryStatusHistory::getTimeline($delivery->id),
],
]);
}
/**
* Get delivery status history
*/
public function statusHistory($uuid): JsonResponse
{
$delivery = Delivery::where('uuid', $uuid)->first();
return response()->json([
'success' => true,
'data' => DeliveryStatusHistory::getTimeline($delivery->id),
]);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Modules\RestaurantDelivery\Http\Controllers\API;
use App\Http\Controllers\Controller;
use Exception;
use Illuminate\Http\JsonResponse;
use Modules\RestaurantDelivery\Http\Requests\DeliveryZone\DeliveryZoneStoreRequest;
use Modules\RestaurantDelivery\Http\Requests\DeliveryZone\DeliveryZoneUpdateRequest;
use Modules\RestaurantDelivery\Repositories\DeliveryZoneRepository;
class DeliveryZoneController extends Controller
{
public function __construct(private DeliveryZoneRepository $repo) {}
public function index(): JsonResponse
{
try {
return $this->responseSuccess($this->repo->getAll(request()->all()), 'DeliveryZone has been fetched successfully.');
} catch (Exception $e) {
return $this->responseError([], $e->getMessage());
}
}
public function store(DeliveryZoneStoreRequest $request): JsonResponse
{
try {
return $this->responseSuccess($this->repo->create($request->all()), 'DeliveryZone has been created successfully.');
} catch (\Illuminate\Database\QueryException $exception) {
return $this->responseError([], 'Database error: '.$exception->getMessage());
} catch (Exception $e) {
return $this->responseError([], $e->getMessage());
}
}
public function show($id): JsonResponse
{
try {
return $this->responseSuccess($this->repo->getById($id), 'DeliveryZone has been fetched successfully.');
} catch (Exception $e) {
return $this->responseError([], $e->getMessage());
}
}
public function update(DeliveryZoneUpdateRequest $request, $id): JsonResponse
{
try {
return $this->responseSuccess($this->repo->update($id, $request->all()), 'DeliveryZone has been updated successfully.');
} catch (Exception $e) {
return $this->responseError([], $e->getMessage());
}
}
public function destroy($id): JsonResponse
{
try {
return $this->responseSuccess($this->repo->delete($id), 'DeliveryZone has been deleted successfully.');
} catch (Exception $e) {
return $this->responseError([], $e->getMessage());
}
}
}

View File

@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Http\Controllers\API;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\RestaurantDelivery\Models\Delivery;
use Modules\RestaurantDelivery\Models\DeliveryRating;
use Modules\RestaurantDelivery\Services\Rating\RatingService;
class RatingController extends Controller
{
public function __construct(
protected RatingService $ratingService
) {}
/**
* Create a rating for a delivery
*/
public function store(Request $request, $uuid): JsonResponse
{
$delivery = Delivery::where('uuid', $uuid)->first();
$validated = $request->validate([
'overall_rating' => 'required|integer|min:1|max:5',
'speed_rating' => 'nullable|integer|min:1|max:5',
'communication_rating' => 'nullable|integer|min:1|max:5',
'food_condition_rating' => 'nullable|integer|min:1|max:5',
'professionalism_rating' => 'nullable|integer|min:1|max:5',
'review' => 'nullable|string|max:500',
'tags' => 'nullable|array',
'tags.*' => 'string',
'is_anonymous' => 'nullable|boolean',
]);
$rating = $this->ratingService->createRating(
delivery: $delivery,
overallRating: $validated['overall_rating'],
categoryRatings: [
'speed' => $validated['speed_rating'] ?? null,
'communication' => $validated['communication_rating'] ?? null,
'food_condition' => $validated['food_condition_rating'] ?? null,
'professionalism' => $validated['professionalism_rating'] ?? null,
],
review: $validated['review'] ?? null,
tags: $validated['tags'] ?? [],
isAnonymous: $validated['is_anonymous'] ?? false,
customerId: auth()->id()
);
if (! $rating) {
return response()->json([
'success' => false,
'message' => 'Unable to submit rating. The delivery may have already been rated or the rating window has expired.',
], 422);
}
return response()->json([
'success' => true,
'message' => 'Thank you for your feedback!',
'data' => [
'id' => $rating->uuid,
'overall_rating' => $rating->overall_rating,
'star_display' => $rating->star_display,
],
], 201);
}
/**
* Create a restaurant rating for a delivery
*/
public function storeRestaurantRating(Request $request, $uuid): JsonResponse
{
$delivery = Delivery::where('uuid', $uuid)->first();
$validated = $request->validate([
'overall_rating' => 'required|integer|min:1|max:5',
'review' => 'nullable|string|max:500',
'tags' => 'nullable|array',
]);
$rating = $this->ratingService->createRating(
delivery: $delivery,
overallRating: $validated['overall_rating'],
review: $validated['review'] ?? null,
tags: $validated['tags'] ?? [],
isRestaurantRating: true
);
if (! $rating) {
return response()->json([
'success' => false,
'message' => 'Unable to submit rating.',
], 422);
}
return response()->json([
'success' => true,
'message' => 'Rating submitted successfully',
], 201);
}
/**
* Check if delivery can be rated
*/
public function canRate($uuid): JsonResponse
{
$delivery = Delivery::where('uuid', $uuid)->first();
return response()->json([
'success' => true,
'data' => [
'can_rate' => $this->ratingService->canRate($delivery),
'categories' => $this->ratingService->getCategories(),
'tags' => $this->ratingService->getTags(),
],
]);
}
/**
* Add rider response to a rating
*/
public function addRiderResponse(Request $request, $uuid): JsonResponse
{
$request->validate([
'response' => 'required|string|max:500',
]);
$rating = DeliveryRating::where('uuid', $uuid)->first();
// Verify the rider owns this rating
// if ($rating->rider_id !== auth()->user()->rider?->id) {
// return response()->json([
// 'success' => false,
// 'message' => 'Unauthorized',
// ], 403);
// }
$success = $this->ratingService->addRiderResponse($rating, $request->response);
if (! $success) {
return response()->json([
'success' => false,
'message' => 'You have already responded to this rating',
], 422);
}
return response()->json([
'success' => true,
'message' => 'Response added successfully',
]);
}
/**
* Mark rating as helpful
*/
public function markHelpful($uuid): JsonResponse
{
$rating = DeliveryRating::where('uuid', $uuid)->first();
$rating->markHelpful();
return response()->json([
'success' => true,
'data' => [
'helpful_count' => $rating->helpful_count,
],
]);
}
/**
* Mark rating as not helpful
*/
public function markNotHelpful($uuid): JsonResponse
{
$rating = DeliveryRating::where('uuid', $uuid)->first();
$rating->markNotHelpful();
return response()->json([
'success' => true,
'data' => [
'not_helpful_count' => $rating->not_helpful_count,
],
]);
}
/**
* List ratings (for admin)
*/
public function index(Request $request): JsonResponse
{
$query = DeliveryRating::with(['delivery', 'rider', 'customer']);
if ($request->has('rider_id')) {
$query->where('rider_id', $request->rider_id);
}
if ($request->has('moderation_status')) {
$query->where('moderation_status', $request->moderation_status);
}
if ($request->has('min_rating')) {
$query->where('overall_rating', '>=', $request->min_rating);
}
if ($request->has('max_rating')) {
$query->where('overall_rating', '<=', $request->max_rating);
}
$ratings = $query->orderBy('created_at', 'desc')
->paginate($request->get('per_page', 20));
return response()->json([
'success' => true,
'data' => $ratings,
]);
}
/**
* Approve a rating (admin)
*/
public function approve($uuid): JsonResponse
{
$rating = DeliveryRating::where('uuid', $uuid)->first();
$this->ratingService->approveRating($rating);
return response()->json([
'success' => true,
'message' => 'Rating approved',
]);
}
/**
* Reject a rating (admin)
*/
public function reject(Request $request, $uuid): JsonResponse
{
$rating = DeliveryRating::where('uuid', $uuid)->first();
$request->validate([
'reason' => 'required|string|max:255',
]);
$this->ratingService->rejectRating($rating, $request->reason);
return response()->json([
'success' => true,
'message' => 'Rating rejected',
]);
}
/**
* Feature a rating (admin)
*/
public function feature($uuid): JsonResponse
{
$rating = DeliveryRating::where('uuid', $uuid)->first();
$this->ratingService->featureRating($rating);
return response()->json([
'success' => true,
'message' => 'Rating featured',
]);
}
}

View File

@@ -0,0 +1,413 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Http\Controllers\API;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Modules\Authentication\Models\User;
use Modules\RestaurantDelivery\Enums\RiderStatus;
use Modules\RestaurantDelivery\Http\Resources\DeliveryResource;
use Modules\RestaurantDelivery\Http\Resources\RiderResource;
use Modules\RestaurantDelivery\Models\Rider;
use Modules\RestaurantDelivery\Services\Earnings\EarningsService;
use Modules\RestaurantDelivery\Services\Firebase\FirebaseService;
use Modules\RestaurantDelivery\Services\Rating\RatingService;
use Modules\RestaurantDelivery\Services\Tracking\LiveTrackingService;
use Spatie\Permission\Models\Role;
class RiderController extends Controller
{
public function __construct(
protected LiveTrackingService $trackingService,
protected FirebaseService $firebase,
protected EarningsService $earningsService,
protected RatingService $ratingService
) {}
/**
* List riders
*/
public function index(Request $request): JsonResponse
{
$query = Rider::query();
// Filter by status
if ($request->has('status')) {
$query->where('status', $request->status);
}
// Filter by type
if ($request->has('type')) {
$query->where('type', $request->type);
}
// Filter by online status
if ($request->has('is_online')) {
$query->where('is_online', $request->boolean('is_online'));
}
// Filter by verified
if ($request->has('is_verified')) {
$query->where('is_verified', $request->boolean('is_verified'));
}
// Filter by vehicle type
if ($request->has('vehicle_type')) {
$query->where('vehicle_type', $request->vehicle_type);
}
$riders = $query->with('user')->orderBy('created_at', 'desc')
->paginate($request->get('per_page', 20));
return response()->json([
'success' => true,
'message' => 'Rider has been fetch!',
'data' => RiderResource::collection($riders),
'meta' => [
'current_page' => $riders->currentPage(),
'last_page' => $riders->lastPage(),
'per_page' => $riders->perPage(),
'total' => $riders->total(),
],
], 201);
}
/**
* Get nearby riders
*/
public function nearby(Request $request): JsonResponse
{
$request->validate([
'latitude' => 'required|numeric',
'longitude' => 'required|numeric',
'radius' => 'nullable|numeric|min:0.1|max:50',
]);
$radius = $request->get('radius', config('restaurant-delivery.assignment.assignment_radius'));
$riders = Rider::available()
->nearby($request->latitude, $request->longitude, $radius)
->limit(20)
->get();
return $this->responseSuccess(RiderResource::collection($riders), 'NearBy Rider has been fetched successfully.');
}
/**
* Create a new rider
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'first_name' => 'required|string|max:100',
'last_name' => 'required|string|max:100',
'phone' => 'required|string|unique:restaurant_riders,phone',
'email' => 'nullable|email|unique:restaurant_riders,email',
'type' => 'required|in:internal,freelance,third_party',
'vehicle_type' => 'required|in:bicycle,motorcycle,car,van',
'vehicle_number' => 'nullable|string|max:20',
'commission_type' => 'nullable|in:fixed,percentage,per_km,hybrid',
'commission_rate' => 'nullable|numeric|min:0',
]);
$rider = Rider::create(array_merge($validated, [
'status' => RiderStatus::PENDING->value,
]));
// Delivery Man -> User Create
$restaurantDeliveryManRole = Role::updateOrCreate(['name' => 'Delivery Man', 'restaurant_id' => null, 'guard_name' => 'web']);
$user = $this->createUser($restaurantDeliveryManRole, $rider->first_name, $rider->email, $rider->phone, '123456', getUserRestaurantId());
$rider->update([
'user_id' => $user->id,
]);
return $this->responseSuccess(new RiderResource($rider), 'Rider registered successfully.');
}
/**
* Get rider details
*/
public function show($uuid): JsonResponse
{
$rider = Rider::where('uuid', $uuid)->first();
return $this->responseSuccess(new RiderResource($rider->load(['activeDeliveries'])), 'Rider active deliveries fetch successfully.');
}
/**
* Update rider
*/
public function update(Request $request, $uuid): JsonResponse
{
$rider = Rider::where('uuid', $uuid)->first();
$validated = $request->validate([
'first_name' => 'sometimes|string|max:100',
'last_name' => 'sometimes|string|max:100',
'email' => 'sometimes|nullable|email',
'vehicle_type' => 'sometimes|in:bicycle,motorcycle,car,van',
'vehicle_number' => 'sometimes|nullable|string|max:20',
'vehicle_model' => 'sometimes|nullable|string|max:100',
'vehicle_color' => 'sometimes|nullable|string|max:50',
]);
$rider->update($validated);
return $this->responseSuccess(new RiderResource($rider->fresh()), 'Rider updated successfully.');
}
/**
* Update rider location
*/
public function updateLocation(Request $request, Rider $rider): JsonResponse
{
$validated = $request->validate([
'latitude' => 'required|numeric|between:-90,90',
'longitude' => 'required|numeric|between:-180,180',
'speed' => 'nullable|numeric|min:0',
'bearing' => 'nullable|numeric|between:0,360',
'accuracy' => 'nullable|numeric|min:0',
]);
$result = $this->trackingService->updateRiderLocation(
$rider,
$validated['latitude'],
$validated['longitude'],
$validated['speed'] ?? null,
$validated['bearing'] ?? null,
$validated['accuracy'] ?? null
);
return $this->responseSuccess($result, 'Update location successfully.');
}
/**
* Toggle rider online status
*/
public function toggleOnline(Request $request, Rider $rider): JsonResponse
{
$request->validate([
'is_online' => 'required|boolean',
]);
if ($request->is_online) {
if (! $rider->canAcceptOrders()) {
return response()->json([
'success' => false,
'message' => 'Cannot go online. Please ensure your account is active and verified.',
], 422);
}
$rider->goOnline();
// Update Firebase status
$this->firebase->updateRiderStatus($rider->id, 'online');
} else {
// Check for active deliveries
if ($rider->activeDeliveries()->exists()) {
return response()->json([
'success' => false,
'message' => 'Cannot go offline while having active deliveries.',
], 422);
}
$rider->goOffline();
// Update Firebase status
$this->firebase->updateRiderStatus($rider->id, 'offline');
}
return $this->responseSuccess([
'is_online' => $rider->is_online,
], $rider->is_online ? 'You are now online' : 'You are now offline');
}
/**
* Get rider's active deliveries
*/
public function activeDeliveries($uuid): JsonResponse
{
$rider = Rider::where('uuid', $uuid)->first();
$deliveries = $rider->activeDeliveries()
->with(['zone'])
->orderBy('created_at', 'desc')
->get();
return $this->responseSuccess(DeliveryResource::collection($deliveries), 'Active Deliveries Fetch successfully.');
}
/**
* Get rider's delivery history
*/
public function deliveryHistory(Request $request, Rider $rider): JsonResponse
{
$query = $rider->deliveries()
->completed()
->with(['rating']);
if ($request->has('from_date')) {
$query->whereDate('delivered_at', '>=', $request->from_date);
}
if ($request->has('to_date')) {
$query->whereDate('delivered_at', '<=', $request->to_date);
}
$deliveries = $query->orderBy('delivered_at', 'desc')
->paginate($request->get('per_page', 20));
return $this->responseSuccess([
'data' => DeliveryResource::collection($deliveries),
'meta' => [
'current_page' => $deliveries->currentPage(),
'last_page' => $deliveries->lastPage(),
'per_page' => $deliveries->perPage(),
'total' => $deliveries->total(),
],
], 'Delivery History Fetch successfully.');
}
/**
* Get rider earnings
*/
public function earnings(Request $request, $uuid): JsonResponse
{
$rider = Rider::where('uuid', $uuid)->first();
$period = $request->get('period', 'today');
return $this->responseSuccess($this->earningsService->getRiderEarningsSummary($rider, $period), 'Rider Earning Fetch successfully.');
}
/**
* Get rider earnings history
*/
public function earningsHistory(Request $request, Rider $rider): JsonResponse
{
$type = $request->get('type');
$limit = $request->get('limit', 50);
return $this->responseSuccess($this->earningsService->getEarningsHistory($rider, $limit, $type), 'Rider Earning History Fetch successfully.');
}
/**
* Get rider payout history
*/
public function payouts(Request $request, $uuid): JsonResponse
{
$rider = Rider::where('uuid', $uuid)->first();
return $this->responseSuccess($this->earningsService->getPayoutHistory($rider), 'Rider Payout History Fetch successfully.');
}
/**
* Get rider ratings and stats
*/
public function ratings($uuid): JsonResponse
{
$rider = Rider::where('uuid', $uuid)->first();
return $this->responseSuccess($this->ratingService->getRiderStats($rider), 'Rider Ratings Fetch successfully.');
}
/**
* Update FCM token
*/
public function updateFcmToken(Request $request, $uuid): JsonResponse
{
$rider = Rider::where('uuid', $uuid)->first();
$request->validate([
'fcm_token' => 'required|string',
'device_id' => 'nullable|string',
]);
$rider->update([
'fcm_token' => $request->fcm_token,
'device_id' => $request->device_id,
]);
return $this->responseSuccess($rider, 'FCM token updated.');
}
/**
* Verify rider
*/
public function verify(Request $request, $uuid): JsonResponse
{
$rider = Rider::where('uuid', $uuid)->first();
$request->validate([
'verified_by' => 'required|string',
]);
$rider->update([
'is_verified' => true,
'verified_at' => now(),
'verified_by' => $request->verified_by,
'status' => 'active',
]);
return $this->responseSuccess(new RiderResource($rider->fresh()), 'Rider verified successfully.');
}
/**
* Suspend rider
*/
public function suspend(Request $request, $uuid): JsonResponse
{
$rider = Rider::where('uuid', $uuid)->first();
$request->validate([
'reason' => 'required|string|max:255',
]);
$rider->update(['status' => 'suspended']);
$rider->goOffline();
return $this->responseSuccess(new RiderResource($rider), 'Rider suspended.');
}
/**
* Reactivate rider
*/
public function reactivate($uuid): JsonResponse
{
$rider = Rider::where('uuid', $uuid)->first();
$rider->update(['status' => 'active']);
return $this->responseSuccess(new RiderResource($rider->fresh()), 'Rider reactivated.');
}
public function createUser(Role $role, string $name, string $email, string $phone, string $password, ?int $restaurantId = null)
{
$user = User::factory([
'first_name' => $name,
'email' => $email,
'email_verified_at' => now(),
'password' => Hash::make($password),
'phone' => $phone,
'user_type' => $role->name,
'role_id' => $role->id,
'remember_token' => Str::random(10),
'platform' => 'WEB',
'address' => 'Dhaka, Bangladesh',
'status' => 1,
'created_by' => 1,
'restaurant_id' => $restaurantId,
'created_at' => now(),
'updated_at' => now(),
])->create();
/* Get all the permission and assign admin role */
$user->assignRole($role);
return $user;
}
}

View File

@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Http\Controllers\API;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Modules\RestaurantDelivery\Models\Delivery;
use Modules\RestaurantDelivery\Models\DeliveryTip;
use Modules\RestaurantDelivery\Services\Tip\TipService;
class TipController extends Controller
{
public function __construct(
protected TipService $tipService
) {}
/**
* Get tip options for a delivery
*/
public function options($uuid): JsonResponse
{
$delivery = Delivery::where('uuid', $uuid)->first();
$canTipPre = $this->tipService->canTipPreDelivery($delivery);
$canTipPost = $this->tipService->canTipPostDelivery($delivery);
if (! $canTipPre && ! $canTipPost) {
return response()->json([
'success' => false,
'message' => 'Tipping is not available for this delivery',
], 422);
}
return response()->json([
'success' => true,
'data' => array_merge(
$this->tipService->getTipOptions($delivery),
[
'can_tip_pre_delivery' => $canTipPre,
'can_tip_post_delivery' => $canTipPost,
]
),
]);
}
/**
* Create a pre-delivery tip
*/
public function createPreDeliveryTip(Request $request, Delivery $delivery): JsonResponse
{
$validated = $request->validate([
'amount' => 'required|numeric|min:1',
'calculation_type' => 'required|in:fixed,percentage',
'percentage_value' => 'required_if:calculation_type,percentage|nullable|numeric|min:1|max:50',
'message' => 'nullable|string|max:200',
]);
$tip = $this->tipService->createPreDeliveryTip(
delivery: $delivery,
amount: $validated['amount'],
customerId: auth()->id(),
calculationType: $validated['calculation_type'],
percentageValue: $validated['percentage_value'] ?? null,
message: $validated['message'] ?? null
);
if (! $tip) {
return response()->json([
'success' => false,
'message' => 'Unable to add tip. This delivery may not be eligible for tipping.',
], 422);
}
return response()->json([
'success' => true,
'message' => 'Tip added successfully',
'data' => [
'id' => $tip->uuid,
'amount' => $tip->amount,
'rider_amount' => $tip->rider_amount,
'type' => $tip->type,
],
], 201);
}
/**
* Create a post-delivery tip
*/
public function createPostDeliveryTip(Request $request, Delivery $delivery): JsonResponse
{
$validated = $request->validate([
'amount' => 'required|numeric|min:1',
'calculation_type' => 'required|in:fixed,percentage',
'percentage_value' => 'required_if:calculation_type,percentage|nullable|numeric|min:1|max:50',
'message' => 'nullable|string|max:200',
]);
$tip = $this->tipService->createPostDeliveryTip(
delivery: $delivery,
amount: $validated['amount'],
customerId: auth()->id(),
calculationType: $validated['calculation_type'],
percentageValue: $validated['percentage_value'] ?? null,
message: $validated['message'] ?? null
);
if (! $tip) {
return response()->json([
'success' => false,
'message' => 'Unable to add tip. The tipping window may have expired.',
], 422);
}
return response()->json([
'success' => true,
'message' => 'Thank you for the tip!',
'data' => [
'id' => $tip->uuid,
'amount' => $tip->amount,
'rider_amount' => $tip->rider_amount,
'type' => $tip->type,
],
], 201);
}
/**
* Process tip payment
*/
public function processPayment(Request $request, DeliveryTip $tip): JsonResponse
{
$validated = $request->validate([
'payment_reference' => 'required|string',
'payment_method' => 'required|string',
]);
$success = $this->tipService->markTipAsPaid(
$tip,
$validated['payment_reference'],
$validated['payment_method']
);
if (! $success) {
return response()->json([
'success' => false,
'message' => 'Failed to process tip payment',
], 500);
}
return response()->json([
'success' => true,
'message' => 'Tip payment processed successfully',
]);
}
/**
* Get tip details
*/
public function show($uuid): JsonResponse
{
$tip = DeliveryTip::where('uuid', $uuid)->first();
return response()->json([
'success' => true,
'data' => [
'id' => $tip->uuid,
'amount' => $tip->amount,
'currency' => $tip->currency,
'type' => $tip->type,
'calculation_type' => $tip->calculation_type,
'rider_amount' => $tip->rider_amount,
'payment_status' => $tip->payment_status,
'message' => $tip->message,
'created_at' => $tip->created_at->toIso8601String(),
],
]);
}
/**
* Calculate tip amount from percentage
*/
public function calculate(Request $request): JsonResponse
{
$validated = $request->validate([
'order_value' => 'required|numeric|min:0',
'percentage' => 'required|numeric|min:1|max:50',
]);
$amount = $this->tipService->calculateTipFromPercentage(
$validated['order_value'],
$validated['percentage']
);
return response()->json([
'success' => true,
'data' => [
'amount' => $amount,
'currency' => config('restaurant-delivery.pricing.currency'),
'currency_symbol' => config('restaurant-delivery.pricing.currency_symbol'),
],
]);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Modules\RestaurantDelivery\Http\Controllers\API;
use App\Http\Controllers\Controller;
use Exception;
use Illuminate\Http\JsonResponse;
use Modules\RestaurantDelivery\Http\Requests\ZonePricingRule\ZonePricingRuleStoreRequest;
use Modules\RestaurantDelivery\Http\Requests\ZonePricingRule\ZonePricingRuleUpdateRequest;
use Modules\RestaurantDelivery\Repositories\ZonePricingRuleRepository;
class ZonePricingRuleController extends Controller
{
public function __construct(private ZonePricingRuleRepository $repo) {}
public function index(): JsonResponse
{
try {
return $this->responseSuccess($this->repo->getAll(request()->all()), 'ZonePricingRule has been fetched successfully.');
} catch (Exception $e) {
return $this->responseError([], $e->getMessage());
}
}
public function store(ZonePricingRuleStoreRequest $request): JsonResponse
{
try {
return $this->responseSuccess($this->repo->create($request->all()), 'ZonePricingRule has been created successfully.');
} catch (\Illuminate\Database\QueryException $exception) {
return $this->responseError([], 'Database error: '.$exception->getMessage());
} catch (Exception $e) {
return $this->responseError([], $e->getMessage());
}
}
public function show($id): JsonResponse
{
try {
return $this->responseSuccess($this->repo->getById($id), 'ZonePricingRule has been fetched successfully.');
} catch (Exception $e) {
return $this->responseError([], $e->getMessage());
}
}
public function update(ZonePricingRuleUpdateRequest $request, $id): JsonResponse
{
try {
return $this->responseSuccess($this->repo->update($id, $request->all()), 'ZonePricingRule has been updated successfully.');
} catch (Exception $e) {
return $this->responseError([], $e->getMessage());
}
}
public function destroy($id): JsonResponse
{
try {
return $this->responseSuccess($this->repo->delete($id), 'ZonePricingRule has been deleted successfully.');
} catch (Exception $e) {
return $this->responseError([], $e->getMessage());
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class AssignRiderRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'rider_id' => ['required', 'integer', 'exists:restaurant_riders,id'],
'force_assign' => ['boolean'],
'notes' => ['nullable', 'string', 'max:255'],
];
}
/**
* Get custom attributes for validator errors.
*/
public function attributes(): array
{
return [
'rider_id' => 'rider',
'force_assign' => 'force assignment',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'force_assign' => $this->boolean('force_assign'),
]);
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* CreateDeliveryRequest
*
* IMPORTANT: restaurant_id is NEVER accepted from request input.
* It is always obtained via getUserRestaurantId() in the controller.
*/
class CreateDeliveryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
// User must be authenticated and have a restaurant_id
return auth()->check() && getUserRestaurantId() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* NOTE: restaurant_id is NOT in this list - it comes from getUserRestaurantId()
*/
public function rules(): array
{
return [
// Order Info
'orderable_type' => ['nullable', 'string', 'max:255'],
'orderable_id' => ['nullable', 'integer'],
'zone_id' => ['nullable', 'integer', 'exists:restaurant_delivery_zones,id'],
// Pickup Details
'restaurant_name' => ['required', 'string', 'max:255'],
'pickup_address' => ['required', 'string', 'max:500'],
'pickup_latitude' => ['required', 'numeric', 'between:-90,90'],
'pickup_longitude' => ['required', 'numeric', 'between:-180,180'],
'pickup_contact_name' => ['nullable', 'string', 'max:100'],
'pickup_contact_phone' => ['nullable', 'string', 'max:20'],
'pickup_instructions' => ['nullable', 'string', 'max:500'],
// Drop-off Details
'customer_name' => ['required', 'string', 'max:100'],
'drop_address' => ['required', 'string', 'max:500'],
'drop_latitude' => ['required', 'numeric', 'between:-90,90'],
'drop_longitude' => ['required', 'numeric', 'between:-180,180'],
'drop_contact_name' => ['nullable', 'string', 'max:100'],
'drop_contact_phone' => ['required', 'string', 'max:20'],
'drop_instructions' => ['nullable', 'string', 'max:500'],
'drop_floor' => ['nullable', 'string', 'max:50'],
'drop_apartment' => ['nullable', 'string', 'max:50'],
// Scheduling
'is_scheduled' => ['boolean'],
'scheduled_for' => ['nullable', 'required_if:is_scheduled,true', 'date', 'after:now'],
'is_priority' => ['boolean'],
// Order Value
'order_value' => ['nullable', 'numeric', 'min:0'],
// Pre-delivery Tip
'tip_amount' => ['nullable', 'numeric', 'min:0'],
'tip_type' => ['nullable', Rule::in(['amount', 'percentage'])],
// Additional Metadata
'meta' => ['nullable', 'array'],
];
}
/**
* Get custom attributes for validator errors.
*/
public function attributes(): array
{
return [
'pickup_latitude' => 'pickup location latitude',
'pickup_longitude' => 'pickup location longitude',
'drop_latitude' => 'delivery location latitude',
'drop_longitude' => 'delivery location longitude',
'drop_contact_phone' => 'customer phone',
'scheduled_for' => 'scheduled delivery time',
];
}
/**
* Get the error messages for the defined validation rules.
*/
public function messages(): array
{
return [
'pickup_latitude.between' => 'The pickup latitude must be between -90 and 90.',
'pickup_longitude.between' => 'The pickup longitude must be between -180 and 180.',
'drop_latitude.between' => 'The delivery latitude must be between -90 and 90.',
'drop_longitude.between' => 'The delivery longitude must be between -180 and 180.',
'scheduled_for.after' => 'The scheduled delivery time must be in the future.',
'scheduled_for.required_if' => 'The scheduled delivery time is required when scheduling a delivery.',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'is_scheduled' => $this->boolean('is_scheduled'),
'is_priority' => $this->boolean('is_priority'),
]);
}
/**
* Get validated data with restaurant_id from getUserRestaurantId().
*/
public function validatedWithRestaurant(): array
{
return array_merge($this->validated(), [
'restaurant_id' => getUserRestaurantId(),
]);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CreateRatingRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
$config = config('restaurant-delivery.rating');
$minRating = $config['min_rating'] ?? 1;
$maxRating = $config['max_rating'] ?? 5;
$maxReviewLength = $config['review_max_length'] ?? 500;
return [
'overall_rating' => ['required', 'integer', "min:{$minRating}", "max:{$maxRating}"],
'speed_rating' => ['nullable', 'integer', "min:{$minRating}", "max:{$maxRating}"],
'communication_rating' => ['nullable', 'integer', "min:{$minRating}", "max:{$maxRating}"],
'food_condition_rating' => ['nullable', 'integer', "min:{$minRating}", "max:{$maxRating}"],
'professionalism_rating' => ['nullable', 'integer', "min:{$minRating}", "max:{$maxRating}"],
'review' => ['nullable', 'string', "max:{$maxReviewLength}"],
'tags' => ['nullable', 'array'],
'tags.*' => ['string', 'max:50'],
'is_anonymous' => ['boolean'],
'is_restaurant_rating' => ['boolean'],
];
}
/**
* Get custom attributes for validator errors.
*/
public function attributes(): array
{
return [
'overall_rating' => 'overall rating',
'speed_rating' => 'speed rating',
'communication_rating' => 'communication rating',
'food_condition_rating' => 'food condition rating',
'professionalism_rating' => 'professionalism rating',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'is_anonymous' => $this->boolean('is_anonymous'),
'is_restaurant_rating' => $this->boolean('is_restaurant_rating'),
]);
}
/**
* Get validated category ratings as array.
*/
public function categoryRatings(): array
{
return array_filter([
'speed' => $this->validated('speed_rating'),
'communication' => $this->validated('communication_rating'),
'food_condition' => $this->validated('food_condition_rating'),
'professionalism' => $this->validated('professionalism_rating'),
]);
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CreateTipRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
$config = config('restaurant-delivery.tip');
$minTip = $config['min_tip'] ?? 10;
$maxTip = $config['max_tip'] ?? 1000;
$maxPercentage = $config['max_percentage'] ?? 50;
return [
'amount' => ['required', 'numeric', "min:{$minTip}", "max:{$maxTip}"],
'calculation_type' => ['nullable', Rule::in(['fixed', 'percentage'])],
'percentage_value' => [
'nullable',
'required_if:calculation_type,percentage',
'numeric',
'min:1',
"max:{$maxPercentage}",
],
'message' => ['nullable', 'string', 'max:255'],
'type' => ['nullable', Rule::in(['pre_delivery', 'post_delivery'])],
];
}
/**
* Get custom attributes for validator errors.
*/
public function attributes(): array
{
return [
'amount' => 'tip amount',
'calculation_type' => 'calculation type',
'percentage_value' => 'percentage',
];
}
/**
* Get the error messages for the defined validation rules.
*/
public function messages(): array
{
return [
'percentage_value.required_if' => 'The percentage value is required when using percentage calculation.',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
if (! $this->has('calculation_type')) {
$this->merge(['calculation_type' => 'fixed']);
}
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace Modules\RestaurantDelivery\Http\Requests\DeliveryZone;
use Illuminate\Foundation\Http\FormRequest;
class DeliveryZoneStoreRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:restaurant_delivery_zones,slug',
'description' => 'nullable|string',
'color' => ['nullable', 'regex:/^#[A-Fa-f0-9]{6}$/'],
'coordinates' => 'required|array',
'coordinates.type' => 'required|in:Polygon',
'coordinates.coordinates' => 'required|array|min:1',
'coordinates.coordinates.0' => 'required|array|min:4',
'coordinates.coordinates.0.*' => 'required|array|size:2',
'coordinates.coordinates.0.*.0' => 'required|numeric|between:-180,180',
'coordinates.coordinates.0.*.1' => 'required|numeric|between:-90,90',
'priority' => 'nullable|integer|min:0',
'is_active' => 'nullable|boolean',
'is_default' => 'nullable|boolean',
'max_delivery_distance' => 'nullable|numeric|min:0',
'operating_hours' => 'nullable|array',
'operating_hours.*.open' => 'nullable|date_format:H:i',
'operating_hours.*.close' => 'nullable|date_format:H:i',
'operating_hours.*.enabled' => 'required|boolean',
];
}
public function messages(): array
{
return [
'coordinates.required' => 'Zone polygon is required',
'coordinates.coordinates.0.min' => 'Polygon must contain at least 4 points',
'coordinates.coordinates.0.*.size' => 'Each coordinate must have longitude and latitude',
];
}
public function authorize(): bool
{
return true;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Modules\RestaurantDelivery\Http\Requests\DeliveryZone;
use Illuminate\Foundation\Http\FormRequest;
class DeliveryZoneUpdateRequest extends FormRequest
{
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'color' => ['nullable', 'regex:/^#[A-Fa-f0-9]{6}$/'],
'coordinates' => 'required|array',
'coordinates.type' => 'required|in:Polygon',
'coordinates.coordinates' => 'required|array|min:1',
'coordinates.coordinates.0' => 'required|array|min:4',
'coordinates.coordinates.0.*' => 'required|array|size:2',
'coordinates.coordinates.0.*.0' => 'required|numeric|between:-180,180',
'coordinates.coordinates.0.*.1' => 'required|numeric|between:-90,90',
'priority' => 'nullable|integer|min:0',
'is_active' => 'nullable|boolean',
'is_default' => 'nullable|boolean',
'max_delivery_distance' => 'nullable|numeric|min:0',
'operating_hours' => 'nullable|array',
'operating_hours.*.open' => 'nullable|date_format:H:i',
'operating_hours.*.close' => 'nullable|date_format:H:i',
'operating_hours.*.enabled' => 'required|boolean',
];
}
public function messages(): array
{
return [
'coordinates.required' => 'Zone polygon is required',
'coordinates.coordinates.0.min' => 'Polygon must contain at least 4 points',
'coordinates.coordinates.0.*.size' => 'Each coordinate must have longitude and latitude',
];
}
public function authorize(): bool
{
return true;
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\RestaurantDelivery\Enums\DeliveryStatus;
class UpdateDeliveryStatusRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'status' => [
'required',
'string',
Rule::in(array_column(DeliveryStatus::cases(), 'value')),
],
'changed_by' => [
'nullable',
'string',
Rule::in(['customer', 'restaurant', 'rider', 'admin', 'system', 'api']),
],
'notes' => ['nullable', 'string', 'max:500'],
'latitude' => ['nullable', 'numeric', 'between:-90,90'],
'longitude' => ['nullable', 'numeric', 'between:-180,180'],
// For delivery proof
'photo' => ['nullable', 'string'],
'signature' => ['nullable', 'string'],
'recipient_name' => ['nullable', 'string', 'max:100'],
// For cancellation
'cancellation_reason' => [
'nullable',
'required_if:status,cancelled',
'string',
'max:255',
],
];
}
/**
* Get custom attributes for validator errors.
*/
public function attributes(): array
{
return [
'status' => 'delivery status',
'changed_by' => 'status changed by',
'cancellation_reason' => 'cancellation reason',
];
}
/**
* Get the error messages for the defined validation rules.
*/
public function messages(): array
{
return [
'status.in' => 'The selected status is invalid.',
'cancellation_reason.required_if' => 'A cancellation reason is required when cancelling a delivery.',
];
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateRiderLocationRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'latitude' => ['required', 'numeric', 'between:-90,90'],
'longitude' => ['required', 'numeric', 'between:-180,180'],
'speed' => ['nullable', 'numeric', 'min:0', 'max:500'],
'bearing' => ['nullable', 'numeric', 'min:0', 'max:360'],
'accuracy' => ['nullable', 'numeric', 'min:0'],
'altitude' => ['nullable', 'numeric'],
'timestamp' => ['nullable', 'date'],
];
}
/**
* Get custom attributes for validator errors.
*/
public function attributes(): array
{
return [
'latitude' => 'location latitude',
'longitude' => 'location longitude',
'bearing' => 'direction bearing',
];
}
/**
* Get the error messages for the defined validation rules.
*/
public function messages(): array
{
return [
'latitude.between' => 'The latitude must be between -90 and 90 degrees.',
'longitude.between' => 'The longitude must be between -180 and 180 degrees.',
'bearing.max' => 'The bearing must be between 0 and 360 degrees.',
];
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateRiderProfileRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
$riderId = $this->route('rider')?->id;
return [
'first_name' => ['sometimes', 'string', 'max:100'],
'last_name' => ['sometimes', 'string', 'max:100'],
'email' => [
'sometimes',
'email',
'max:255',
Rule::unique('restaurant_riders', 'email')->ignore($riderId),
],
'phone' => [
'sometimes',
'string',
'max:20',
Rule::unique('restaurant_riders', 'phone')->ignore($riderId),
],
'photo_url' => ['nullable', 'string', 'url', 'max:500'],
'vehicle_type' => ['nullable', Rule::in(['bike', 'motorcycle', 'car', 'bicycle', 'scooter'])],
'vehicle_number' => ['nullable', 'string', 'max:50'],
'license_number' => ['nullable', 'string', 'max:50'],
'license_expiry' => ['nullable', 'date', 'after:today'],
'bank_name' => ['nullable', 'string', 'max:100'],
'bank_account_number' => ['nullable', 'string', 'max:50'],
'bank_branch' => ['nullable', 'string', 'max:100'],
'mobile_banking_provider' => ['nullable', 'string', 'max:50'],
'mobile_banking_number' => ['nullable', 'string', 'max:20'],
'preferred_payment_method' => ['nullable', Rule::in(['bank_transfer', 'bkash', 'nagad', 'rocket'])],
'preferred_zones' => ['nullable', 'array'],
'preferred_zones.*' => ['integer', 'exists:restaurant_delivery_zones,id'],
'emergency_contact_name' => ['nullable', 'string', 'max:100'],
'emergency_contact_phone' => ['nullable', 'string', 'max:20'],
];
}
/**
* Get custom attributes for validator errors.
*/
public function attributes(): array
{
return [
'first_name' => 'first name',
'last_name' => 'last name',
'photo_url' => 'profile photo',
'vehicle_type' => 'vehicle type',
'vehicle_number' => 'vehicle number',
'license_number' => 'license number',
'license_expiry' => 'license expiry date',
'bank_account_number' => 'bank account number',
'mobile_banking_provider' => 'mobile banking provider',
'mobile_banking_number' => 'mobile banking number',
'preferred_payment_method' => 'preferred payment method',
'preferred_zones' => 'preferred delivery zones',
'emergency_contact_name' => 'emergency contact name',
'emergency_contact_phone' => 'emergency contact phone',
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Modules\RestaurantDelivery\Http\Requests\ZonePricingRule;
use Illuminate\Foundation\Http\FormRequest;
class ZonePricingRuleStoreRequest extends FormRequest
{
public function rules(): array
{
return [
// validation rules
];
}
public function authorize(): bool
{
return true;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Modules\RestaurantDelivery\Http\Requests\ZonePricingRule;
use Illuminate\Foundation\Http\FormRequest;
class ZonePricingRuleUpdateRequest extends FormRequest
{
public function rules(): array
{
return [
// validation rules
];
}
public function authorize(): bool
{
return true;
}
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class DeliveryResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->uuid,
'tracking_code' => $this->tracking_code,
// Status
'status' => $this->status,
'status_label' => $this->status->label(),
'status_description' => $this->status->description(),
'status_color' => $this->status->color(),
'is_active' => $this->status->isActive(),
'is_trackable' => $this->status->isTrackable(),
// Restaurant/Pickup
'pickup' => [
'restaurant_name' => $this->restaurant_name,
'address' => $this->pickup_address,
'latitude' => (float) $this->pickup_latitude,
'longitude' => (float) $this->pickup_longitude,
'contact_name' => $this->pickup_contact_name,
'contact_phone' => $this->pickup_contact_phone,
'instructions' => $this->pickup_instructions,
],
// Customer/Drop
'drop' => [
'customer_name' => $this->customer_name,
'address' => $this->drop_address,
'latitude' => (float) $this->drop_latitude,
'longitude' => (float) $this->drop_longitude,
'contact_name' => $this->drop_contact_name,
'contact_phone' => $this->drop_contact_phone,
'instructions' => $this->drop_instructions,
'floor' => $this->drop_floor,
'apartment' => $this->drop_apartment,
],
// Distance and Time
'distance' => [
'value' => (float) $this->distance,
'unit' => $this->distance_unit,
],
'estimated_duration' => $this->estimated_duration,
'estimated_pickup_time' => $this->estimated_pickup_time?->toIso8601String(),
'estimated_delivery_time' => $this->estimated_delivery_time?->toIso8601String(),
// Pricing
'pricing' => [
'base_fare' => (float) $this->base_fare,
'distance_charge' => (float) $this->distance_charge,
'surge_charge' => (float) $this->surge_charge,
'surge_multiplier' => (float) $this->surge_multiplier,
'peak_hour_charge' => (float) $this->peak_hour_charge,
'late_night_charge' => (float) $this->late_night_charge,
'small_order_fee' => (float) $this->small_order_fee,
'total' => (float) $this->total_delivery_charge,
'breakdown' => $this->charge_breakdown,
'currency' => config('restaurant-delivery.pricing.currency'),
'currency_symbol' => config('restaurant-delivery.pricing.currency_symbol'),
],
// Tip
'tip' => [
'amount' => (float) $this->tip_amount,
'type' => $this->tip_type,
'paid_at' => $this->tip_paid_at?->toIso8601String(),
],
// Rider
'rider' => $this->when($this->rider, fn () => [
'id' => $this->rider->uuid,
'name' => $this->rider->full_name,
'phone' => $this->rider->phone,
'photo' => $this->rider->photo_url,
'rating' => (float) $this->rider->rating,
'rating_count' => $this->rider->rating_count,
'vehicle_type' => $this->rider->vehicle_type,
'vehicle_number' => $this->rider->vehicle_number,
]),
// Zone
'zone' => $this->when($this->zone, fn () => [
'id' => $this->zone->uuid,
'name' => $this->zone->name,
]),
// Rating
'rating' => $this->when($this->rating, fn () => [
'overall' => $this->rating->overall_rating,
'star_display' => $this->rating->star_display,
'review' => $this->rating->review,
'created_at' => $this->rating->created_at->toIso8601String(),
]),
'can_rate' => $this->canBeRated(),
'can_tip' => $this->canReceiveTip(),
// Timestamps
'timestamps' => [
'created_at' => $this->created_at->toIso8601String(),
'food_ready_at' => $this->food_ready_at?->toIso8601String(),
'rider_assigned_at' => $this->rider_assigned_at?->toIso8601String(),
'picked_up_at' => $this->picked_up_at?->toIso8601String(),
'delivered_at' => $this->delivered_at?->toIso8601String() ?? null,
'cancelled_at' => $this->cancelled_at?->toIso8601String(),
],
// Cancellation
'cancellation' => $this->when($this->status->isCancelled(), fn () => [
'reason' => $this->cancellation_reason,
'cancelled_by' => $this->cancelled_by,
'notes' => $this->cancellation_notes,
]),
// Failure
'failure' => $this->when($this->status->isFailed(), fn () => [
'reason' => $this->failure_reason,
'notes' => $this->failure_notes,
]),
// Proof of delivery
'proof' => $this->when($this->status->isCompleted(), fn () => [
'photo' => $this->delivery_photo,
'signature' => $this->signature,
'recipient_name' => $this->recipient_name,
]),
// Priority
'is_priority' => $this->is_priority,
'is_scheduled' => $this->is_scheduled,
'scheduled_for' => $this->scheduled_for?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
use Modules\RestaurantDelivery\Models\Delivery;
class DeliveryTrackingResource extends JsonResource
{
protected ?array $trackingData;
public function __construct(Delivery $delivery, ?array $trackingData = null)
{
parent::__construct($delivery);
$this->trackingData = $trackingData;
}
public function toArray($request): array
{
$delivery = $this->resource;
return [
'tracking_code' => $delivery->tracking_code,
// Status
'status' => [
'code' => $delivery->status->value,
'label' => $delivery->status->label(),
'description' => $delivery->status->description(),
'color' => $delivery->status->color(),
'icon' => $delivery->status->icon(),
],
// Restaurant location
'restaurant' => [
'name' => $delivery->restaurant_name,
'address' => $delivery->pickup_address,
'latitude' => (float) $delivery->pickup_latitude,
'longitude' => (float) $delivery->pickup_longitude,
],
// Customer location
'customer' => [
'address' => $delivery->drop_address,
'latitude' => (float) $delivery->drop_latitude,
'longitude' => (float) $delivery->drop_longitude,
],
// Rider info
'rider' => $delivery->rider ? [
'id' => $delivery->rider->uuid,
'name' => $delivery->rider->full_name,
'phone' => $delivery->rider->phone,
'photo' => $delivery->rider->photo_url,
'rating' => (float) $delivery->rider->rating,
'rating_count' => $delivery->rider->rating_count,
'vehicle_type' => $delivery->rider->vehicle_type->value,
'vehicle_number' => $delivery->rider->vehicle_number,
] : null,
// Live tracking data
'tracking' => $this->trackingData ? [
'rider_location' => $this->trackingData['rider_location'] ?? null,
'route' => $this->trackingData['route'] ?? null,
'eta' => $this->trackingData['eta'] ?? null,
'remaining_distance' => $this->trackingData['remaining_distance'] ?? null,
'animation' => $this->trackingData['animation'] ?? null,
] : null,
// Estimated times
'estimated_pickup' => $delivery->estimated_pickup_time?->format('H:i'),
'estimated_delivery' => $delivery->estimated_delivery_time?->format('H:i'),
// Distance
'distance' => [
'total' => (float) $delivery->distance,
'unit' => $delivery->distance_unit,
],
// Map config
'map_config' => [
'default_zoom' => config('restaurant-delivery.tracking.map.default_zoom'),
'tracking_zoom' => config('restaurant-delivery.tracking.map.tracking_zoom'),
'auto_center' => config('restaurant-delivery.tracking.map.auto_center'),
'show_route' => config('restaurant-delivery.tracking.map.show_route_polyline'),
'show_eta' => config('restaurant-delivery.tracking.map.show_eta'),
],
// Markers config
'markers' => config('restaurant-delivery.tracking.markers'),
// Polyline config
'polyline' => config('restaurant-delivery.tracking.polyline'),
// Animation config
'animation' => config('restaurant-delivery.tracking.animation'),
// Firebase config for client
'firebase_config' => [
'path' => "deliveries/{$delivery->id}/tracking",
'enabled' => config('restaurant-delivery.firebase.enabled'),
],
];
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
class RiderResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->uuid,
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'full_name' => $this->full_name,
'phone' => $this->phone,
'email' => $this->email,
'photo' => $this->photo_url,
// Type and Status
'type' => $this->type->value,
'type_label' => $this->type->label(),
'status' => $this->status->value,
'status_label' => $this->status->label(),
'is_verified' => $this->is_verified,
'is_online' => $this->is_online,
'user_info' => [
'restaurant_id' => $this->user?->restaurant_id,
'user_id' => $this->user?->id,
'email' => $this->user?->email,
'phone' => $this->user?->phone,
'user_type' => $this->user?->user_type,
'fcm_token' => $this->user?->fcm_token,
'address' => $this->user?->address,
],
// Vehicle
'vehicle' => [
'type' => $this->vehicle_type->value,
'type_label' => $this->vehicle_type->label(),
'number' => $this->vehicle_number,
'model' => $this->vehicle_model,
'color' => $this->vehicle_color,
],
// Location
'location' => $this->when($this->current_latitude && $this->current_longitude, fn () => [
'latitude' => (float) $this->current_latitude,
'longitude' => (float) $this->current_longitude,
'last_update' => $this->last_location_update?->toIso8601String(),
]),
// Distance (only when queried with nearby scope)
'distance' => $this->when(isset($this->distance), fn () => round($this->distance, 2)),
// Stats
'stats' => [
'rating' => (float) $this->rating,
'rating_count' => $this->rating_count,
'total_deliveries' => $this->total_deliveries,
'successful_deliveries' => $this->successful_deliveries,
'acceptance_rate' => (float) $this->acceptance_rate,
'completion_rate' => (float) $this->completion_rate,
],
// Commission
'commission' => $this->when($request->user()?->isAdmin ?? false, fn () => [
'type' => $this->commission_type->value,
'rate' => (float) $this->commission_rate,
'base' => $this->base_commission ? (float) $this->base_commission : null,
'per_km_rate' => $this->per_km_rate ? (float) $this->per_km_rate : null,
]),
// Active deliveries count
'active_deliveries_count' => $this->whenLoaded('activeDeliveries', fn () => $this->activeDeliveries->count()),
// Online status
'last_online_at' => $this->last_online_at?->toIso8601String(),
// Timestamps
'created_at' => $this->created_at->toIso8601String(),
'verified_at' => $this->verified_at?->toIso8601String(),
'verified_by' => $this->verified_by,
];
}
}

View File

@@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\RestaurantDelivery\Models\Delivery;
use Modules\RestaurantDelivery\Models\Rider;
use Modules\RestaurantDelivery\Services\Firebase\FirebaseService;
class AssignRiderJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*/
public int $tries = 3;
/**
* The maximum number of seconds the job can run before timing out.
*/
public int $timeout = 30;
/**
* Create a new job instance.
*/
public function __construct(
public readonly Delivery $delivery
) {
$this->onQueue(config('restaurant-delivery.queue.queues.assignment', 'restaurant-delivery-assignment'));
}
/**
* Execute the job.
*/
public function handle(FirebaseService $firebase): void
{
// Skip if already assigned
if ($this->delivery->rider_id) {
Log::info('Delivery already has a rider assigned', [
'delivery_id' => $this->delivery->id,
'rider_id' => $this->delivery->rider_id,
]);
return;
}
// Get assignment config
$config = config('restaurant-delivery.assignment');
// Find nearby available riders
$riders = $this->findAvailableRiders();
if ($riders->isEmpty()) {
Log::warning('No available riders found for delivery', [
'delivery_id' => $this->delivery->id,
]);
// Retry after a delay if max attempts not reached
if ($this->attempts() < $this->tries) {
$this->release(60); // Release back to queue after 1 minute
}
return;
}
// Score and rank riders
$scoredRiders = $this->scoreRiders($riders, $config['scoring']);
// Use broadcast or direct assignment
if ($config['broadcast']['enabled']) {
$this->broadcastToRiders($scoredRiders->take($config['broadcast']['max_riders']), $firebase);
} else {
$this->directAssign($scoredRiders->first());
}
}
/**
* Find available riders within the assignment radius.
*/
protected function findAvailableRiders()
{
$radius = config('restaurant-delivery.assignment.assignment_radius', 5);
$maxConcurrent = config('restaurant-delivery.assignment.max_concurrent_orders', 3);
$query = Rider::query()
->where('status', 'available')
->where('is_online', true)
->where('is_verified', true)
->whereNotNull('current_latitude')
->whereNotNull('current_longitude');
// Filter by restaurant for SaaS multi-tenant
if ($this->delivery->restaurant_id) {
$query->where('restaurant_id', $this->delivery->restaurant_id);
}
// Filter riders with less than max concurrent orders
$query->withCount(['deliveries' => function ($q) {
$q->whereIn('status', ['rider_assigned', 'rider_at_restaurant', 'picked_up', 'on_the_way', 'arrived']);
}])->having('deliveries_count', '<', $maxConcurrent);
// Calculate distance and filter by radius
// Using Haversine formula in raw query for efficiency
$lat = $this->delivery->pickup_latitude;
$lng = $this->delivery->pickup_longitude;
$query->selectRaw('
*,
(6371 * acos(
cos(radians(?)) * cos(radians(current_latitude)) * cos(radians(current_longitude) - radians(?))
+ sin(radians(?)) * sin(radians(current_latitude))
)) AS distance
', [$lat, $lng, $lat])
->having('distance', '<=', $radius)
->orderBy('distance');
return $query->get();
}
/**
* Score riders based on various criteria.
*/
protected function scoreRiders($riders, array $weights)
{
$maxDistance = config('restaurant-delivery.assignment.assignment_radius', 5);
return $riders->map(function ($rider) use ($weights, $maxDistance) {
$score = 0;
// Distance score (closer is better)
$distanceScore = (1 - ($rider->distance / $maxDistance)) * $weights['distance_weight'];
$score += $distanceScore;
// Rating score
$ratingScore = ($rider->rating / 5) * $weights['rating_weight'];
$score += $ratingScore;
// Acceptance rate score
$acceptanceScore = ($rider->acceptance_rate / 100) * $weights['acceptance_rate_weight'];
$score += $acceptanceScore;
// Current orders score (fewer is better)
$ordersScore = (1 - ($rider->deliveries_count / config('restaurant-delivery.assignment.max_concurrent_orders', 3)))
* $weights['current_orders_weight'];
$score += $ordersScore;
// Experience score (normalized by max 1000 deliveries)
$experienceScore = min($rider->total_deliveries / 1000, 1) * $weights['experience_weight'];
$score += $experienceScore;
$rider->assignment_score = $score;
return $rider;
})->sortByDesc('assignment_score');
}
/**
* Broadcast assignment request to multiple riders.
*/
protected function broadcastToRiders($riders, FirebaseService $firebase): void
{
$timeout = config('restaurant-delivery.assignment.broadcast.accept_timeout', 30);
foreach ($riders as $rider) {
// Record assignment attempt
$this->delivery->addAssignmentToHistory([
'rider_id' => $rider->id,
'type' => 'broadcast',
'score' => $rider->assignment_score,
'distance' => $rider->distance,
]);
// Send push notification to rider
if ($rider->fcm_token) {
$firebase->sendPushNotification(
$rider->fcm_token,
'New Delivery Request',
"New delivery from {$this->delivery->restaurant_name}. Distance: ".round($rider->distance, 1).' km',
[
'type' => 'delivery_request',
'delivery_id' => (string) $this->delivery->id,
'tracking_code' => $this->delivery->tracking_code,
'restaurant_name' => $this->delivery->restaurant_name,
'pickup_address' => $this->delivery->pickup_address,
'drop_address' => $this->delivery->drop_address,
'distance' => (string) $this->delivery->distance,
'timeout' => (string) $timeout,
]
);
}
}
Log::info('Delivery request broadcasted to riders', [
'delivery_id' => $this->delivery->id,
'rider_count' => $riders->count(),
]);
// Schedule timeout check
dispatch(new CheckAssignmentTimeoutJob($this->delivery))
->delay(now()->addSeconds($timeout + 5));
}
/**
* Directly assign to the best scoring rider.
*/
protected function directAssign(Rider $rider): void
{
DB::transaction(function () use ($rider) {
$this->delivery->assignRider($rider);
// Record assignment
$this->delivery->addAssignmentToHistory([
'rider_id' => $rider->id,
'type' => 'direct',
'score' => $rider->assignment_score ?? 0,
'distance' => $rider->distance ?? 0,
'assigned_at' => now()->toIso8601String(),
]);
});
Log::info('Rider directly assigned to delivery', [
'delivery_id' => $this->delivery->id,
'rider_id' => $rider->id,
]);
}
/**
* Handle a job failure.
*/
public function failed(\Throwable $exception): void
{
Log::error('Failed to assign rider to delivery', [
'delivery_id' => $this->delivery->id,
'error' => $exception->getMessage(),
]);
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\RestaurantDelivery\Models\Delivery;
use Modules\RestaurantDelivery\Models\RiderEarning;
use Modules\RestaurantDelivery\Services\Earnings\EarningsCalculator;
class CalculateEarningsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*/
public int $tries = 3;
/**
* Create a new job instance.
*/
public function __construct(
public readonly Delivery $delivery
) {
$this->onQueue(config('restaurant-delivery.queue.queues.earnings', 'restaurant-delivery-earnings'));
}
/**
* Execute the job.
*/
public function handle(EarningsCalculator $calculator): void
{
// Skip if no rider assigned
if (! $this->delivery->rider_id) {
Log::warning('Cannot calculate earnings: no rider assigned', [
'delivery_id' => $this->delivery->id,
]);
return;
}
// Skip if earnings already exist
if ($this->delivery->riderEarning()->exists()) {
Log::info('Earnings already calculated for delivery', [
'delivery_id' => $this->delivery->id,
]);
return;
}
try {
DB::transaction(function () use ($calculator) {
// Calculate earnings
$earningsData = $calculator->calculateForDelivery($this->delivery);
// Create rider earning record
RiderEarning::create([
'rider_id' => $this->delivery->rider_id,
'delivery_id' => $this->delivery->id,
'restaurant_id' => $this->delivery->restaurant_id,
'type' => 'delivery',
'description' => "Delivery #{$this->delivery->tracking_code}",
'base_amount' => $earningsData['base_amount'],
'distance_amount' => $earningsData['distance_amount'],
'bonus_amount' => $earningsData['total_bonus'],
'penalty_amount' => $earningsData['total_penalty'],
'tip_amount' => $this->delivery->tip_amount ?? 0,
'gross_amount' => $earningsData['gross_amount'],
'commission_rate' => $earningsData['commission_rate'],
'commission_amount' => $earningsData['commission_amount'],
'net_amount' => $earningsData['net_amount'],
'currency' => config('restaurant-delivery.pricing.currency', 'BDT'),
'breakdown' => $earningsData['breakdown'],
'bonuses' => $earningsData['bonuses'],
'penalties' => $earningsData['penalties'],
'status' => 'pending',
'earned_at' => now(),
]);
Log::info('Earnings calculated for delivery', [
'delivery_id' => $this->delivery->id,
'rider_id' => $this->delivery->rider_id,
'net_amount' => $earningsData['net_amount'],
]);
});
} catch (\Exception $e) {
Log::error('Failed to calculate earnings', [
'delivery_id' => $this->delivery->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Handle a job failure.
*/
public function failed(\Throwable $exception): void
{
Log::error('Earnings calculation job failed', [
'delivery_id' => $this->delivery->id,
'error' => $exception->getMessage(),
]);
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Modules\RestaurantDelivery\Enums\DeliveryStatus;
use Modules\RestaurantDelivery\Models\Delivery;
class CheckAssignmentTimeoutJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*/
public int $tries = 1;
/**
* Create a new job instance.
*/
public function __construct(
public readonly Delivery $delivery
) {
$this->onQueue(config('restaurant-delivery.queue.queues.assignment', 'restaurant-delivery-assignment'));
}
/**
* Execute the job.
*/
public function handle(): void
{
// Refresh delivery from database
$delivery = $this->delivery->fresh();
// Skip if already assigned
if ($delivery->rider_id && $delivery->status === DeliveryStatus::RIDER_ASSIGNED) {
Log::info('Delivery already assigned, skipping timeout check', [
'delivery_id' => $delivery->id,
'rider_id' => $delivery->rider_id,
]);
return;
}
// Skip if cancelled or completed
if (in_array($delivery->status, [DeliveryStatus::CANCELLED, DeliveryStatus::DELIVERED, DeliveryStatus::FAILED])) {
Log::info('Delivery is no longer active, skipping assignment', [
'delivery_id' => $delivery->id,
'status' => $delivery->status->value,
]);
return;
}
// Check if max reassignments reached
$maxReassignments = config('restaurant-delivery.assignment.max_reassignments', 3);
if ($delivery->reassignment_count >= $maxReassignments) {
Log::warning('Max reassignments reached for delivery', [
'delivery_id' => $delivery->id,
'reassignment_count' => $delivery->reassignment_count,
]);
return;
}
// Increment reassignment count
$delivery->increment('reassignment_count');
// Retry assignment
Log::info('Assignment timed out, retrying', [
'delivery_id' => $delivery->id,
'attempt' => $delivery->reassignment_count,
]);
dispatch(new AssignRiderJob($delivery));
}
/**
* Handle a job failure.
*/
public function failed(\Throwable $exception): void
{
Log::error('Assignment timeout check failed', [
'delivery_id' => $this->delivery->id,
'error' => $exception->getMessage(),
]);
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Modules\RestaurantDelivery\Models\LocationLog;
use Modules\RestaurantDelivery\Models\Rider;
class CleanupStaleLocationsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*/
public int $tries = 1;
/**
* Create a new job instance.
*/
public function __construct(
public readonly int $daysToKeep = 7
) {
$this->onQueue(config('restaurant-delivery.queue.queues.analytics', 'restaurant-delivery-analytics'));
}
/**
* Execute the job.
*/
public function handle(): void
{
// Delete old location logs
$deletedLogs = LocationLog::where('recorded_at', '<', now()->subDays($this->daysToKeep))
->delete();
Log::info('Cleaned up old location logs', [
'deleted_count' => $deletedLogs,
'days_kept' => $this->daysToKeep,
]);
// Mark riders as offline if no recent location update
$offlineThreshold = config('restaurant-delivery.firebase.location.offline_threshold', 120);
$ridersMarkedOffline = Rider::where('is_online', true)
->where('last_location_update', '<', now()->subSeconds($offlineThreshold))
->update(['is_online' => false]);
Log::info('Marked riders as offline', [
'count' => $ridersMarkedOffline,
'threshold_seconds' => $offlineThreshold,
]);
// Clear stale cache entries
$this->clearStaleCacheEntries();
}
/**
* Clear stale cache entries.
*/
protected function clearStaleCacheEntries(): void
{
// Get all online riders and clear their old cache entries if needed
$riders = Rider::where('is_online', false)->get(['id']);
foreach ($riders as $rider) {
Cache::forget("rider_location_{$rider->id}");
Cache::forget("last_animation_point_{$rider->id}");
}
Log::info('Cleared stale cache entries', [
'rider_count' => $riders->count(),
]);
}
/**
* Handle a job failure.
*/
public function failed(\Throwable $exception): void
{
Log::error('Cleanup stale locations job failed', [
'error' => $exception->getMessage(),
]);
}
}

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\RestaurantDelivery\Models\Rider;
use Modules\RestaurantDelivery\Models\RiderEarning;
use Modules\RestaurantDelivery\Models\RiderPayout;
class ProcessPayoutJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*/
public int $tries = 3;
/**
* Create a new job instance.
*/
public function __construct(
public readonly Rider $rider,
public readonly ?string $paymentMethod = null
) {
$this->onQueue(config('restaurant-delivery.queue.queues.earnings', 'restaurant-delivery-earnings'));
}
/**
* Execute the job.
*/
public function handle(): void
{
// Get pending earnings
$pendingEarnings = $this->rider->earnings()
->where('status', 'pending')
->whereNull('payout_id')
->get();
if ($pendingEarnings->isEmpty()) {
Log::info('No pending earnings for rider', [
'rider_id' => $this->rider->id,
]);
return;
}
// Calculate total
$totalAmount = $pendingEarnings->sum('net_amount');
$minimumPayout = config('restaurant-delivery.earnings.payout.minimum_amount', 500);
if ($totalAmount < $minimumPayout) {
Log::info('Pending amount below minimum payout threshold', [
'rider_id' => $this->rider->id,
'pending_amount' => $totalAmount,
'minimum' => $minimumPayout,
]);
return;
}
try {
DB::transaction(function () use ($pendingEarnings, $totalAmount) {
// Create payout record
$payout = RiderPayout::create([
'rider_id' => $this->rider->id,
'restaurant_id' => $this->rider->restaurant_id,
'amount' => $totalAmount,
'currency' => config('restaurant-delivery.pricing.currency', 'BDT'),
'payment_method' => $this->paymentMethod ?? $this->rider->preferred_payment_method,
'payment_details' => $this->getPaymentDetails(),
'status' => 'pending',
'earnings_count' => $pendingEarnings->count(),
'period_start' => $pendingEarnings->min('earned_at'),
'period_end' => $pendingEarnings->max('earned_at'),
]);
// Link earnings to payout
RiderEarning::whereIn('id', $pendingEarnings->pluck('id'))
->update([
'payout_id' => $payout->id,
'status' => 'processing',
]);
Log::info('Payout created for rider', [
'payout_id' => $payout->id,
'rider_id' => $this->rider->id,
'amount' => $totalAmount,
'earnings_count' => $pendingEarnings->count(),
]);
});
} catch (\Exception $e) {
Log::error('Failed to create payout', [
'rider_id' => $this->rider->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Get payment details based on payment method.
*/
protected function getPaymentDetails(): array
{
$method = $this->paymentMethod ?? $this->rider->preferred_payment_method;
return match ($method) {
'bank_transfer' => [
'bank_name' => $this->rider->bank_name,
'account_number' => $this->rider->bank_account_number,
'branch' => $this->rider->bank_branch,
],
'bkash', 'nagad', 'rocket' => [
'provider' => $method,
'number' => $this->rider->mobile_banking_number,
],
default => [],
};
}
/**
* Handle a job failure.
*/
public function failed(\Throwable $exception): void
{
Log::error('Payout processing job failed', [
'rider_id' => $this->rider->id,
'error' => $exception->getMessage(),
]);
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Modules\RestaurantDelivery\Services\Firebase\FirebaseService;
class SendPushNotificationJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*/
public int $tries = 3;
/**
* The maximum number of seconds to wait before retrying the job.
*/
public int $backoff = 10;
/**
* Create a new job instance.
*/
public function __construct(
public readonly string $token,
public readonly string $title,
public readonly string $body,
public readonly array $data = []
) {
$this->onQueue(config('restaurant-delivery.queue.queues.notifications', 'restaurant-delivery-notifications'));
}
/**
* Execute the job.
*/
public function handle(FirebaseService $firebase): void
{
try {
$firebase->sendPushNotification(
$this->token,
$this->title,
$this->body,
$this->data
);
Log::info('Push notification sent', [
'title' => $this->title,
'data' => $this->data,
]);
} catch (\Exception $e) {
Log::error('Failed to send push notification', [
'title' => $this->title,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Handle a job failure.
*/
public function failed(\Throwable $exception): void
{
Log::error('Push notification job failed permanently', [
'title' => $this->title,
'error' => $exception->getMessage(),
]);
}
}

View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
use Modules\RestaurantDelivery\Enums\DeliveryStatus;
use Modules\RestaurantDelivery\Events\DeliveryStatusChanged;
use Modules\RestaurantDelivery\Jobs\CalculateEarningsJob;
class HandleDeliveryStatusChange implements ShouldQueue
{
use InteractsWithQueue;
/**
* The name of the queue the job should be sent to.
*/
public string $queue = 'restaurant-delivery-notifications';
/**
* Create the event listener.
*/
public function __construct()
{
//
}
/**
* Handle the event.
*/
public function handle(DeliveryStatusChanged $event): void
{
$delivery = $event->delivery;
$status = $delivery->status;
Log::info('Delivery status changed', [
'delivery_id' => $delivery->id,
'from_status' => $event->previousStatus?->value,
'to_status' => $status->value,
]);
// Send notifications based on status
$this->sendCustomerNotification($delivery, $status);
$this->sendRiderNotification($delivery, $status);
$this->sendRestaurantNotification($delivery, $status);
// Handle delivery completion
if ($status === DeliveryStatus::DELIVERED) {
$this->handleDeliveryCompleted($delivery);
}
// Handle cancellation
if ($status === DeliveryStatus::CANCELLED) {
$this->handleDeliveryCancelled($delivery);
}
}
/**
* Send notification to customer.
*/
protected function sendCustomerNotification($delivery, DeliveryStatus $status): void
{
$customerNotifications = config('restaurant-delivery.notifications.customer', []);
$statusKey = match ($status) {
DeliveryStatus::CONFIRMED => 'order_confirmed',
DeliveryStatus::RIDER_ASSIGNED => 'rider_assigned',
DeliveryStatus::RIDER_AT_RESTAURANT => 'rider_at_restaurant',
DeliveryStatus::ON_THE_WAY => 'out_for_delivery',
DeliveryStatus::ARRIVED => 'arrived',
DeliveryStatus::DELIVERED => 'delivered',
default => null,
};
if ($statusKey && ($customerNotifications[$statusKey] ?? false)) {
// Customer notification logic here
Log::info('Customer notification should be sent', [
'delivery_id' => $delivery->id,
'status' => $statusKey,
]);
}
}
/**
* Send notification to rider.
*/
protected function sendRiderNotification($delivery, DeliveryStatus $status): void
{
if (! $delivery->rider_id) {
return;
}
$riderNotifications = config('restaurant-delivery.notifications.rider', []);
if ($status === DeliveryStatus::CANCELLED && ($riderNotifications['order_cancelled'] ?? false)) {
// Rider cancellation notification logic here
Log::info('Rider should be notified of cancellation', [
'delivery_id' => $delivery->id,
'rider_id' => $delivery->rider_id,
]);
}
}
/**
* Send notification to restaurant.
*/
protected function sendRestaurantNotification($delivery, DeliveryStatus $status): void
{
$restaurantNotifications = config('restaurant-delivery.notifications.restaurant', []);
$shouldNotify = match ($status) {
DeliveryStatus::RIDER_AT_RESTAURANT => $restaurantNotifications['rider_arriving'] ?? false,
DeliveryStatus::PICKED_UP => $restaurantNotifications['rider_picked_up'] ?? false,
DeliveryStatus::DELIVERED => $restaurantNotifications['delivery_completed'] ?? false,
default => false,
};
if ($shouldNotify) {
Log::info('Restaurant should be notified', [
'delivery_id' => $delivery->id,
'status' => $status->value,
]);
}
}
/**
* Handle delivery completion.
*/
protected function handleDeliveryCompleted($delivery): void
{
// Calculate and create rider earnings
dispatch(new CalculateEarningsJob($delivery))
->onQueue(config('restaurant-delivery.queue.queues.earnings'));
Log::info('Delivery completed, earnings job dispatched', [
'delivery_id' => $delivery->id,
]);
}
/**
* Handle delivery cancellation.
*/
protected function handleDeliveryCancelled($delivery): void
{
Log::info('Delivery cancelled', [
'delivery_id' => $delivery->id,
'cancelled_by' => $delivery->cancelled_by,
'reason' => $delivery->cancellation_reason,
]);
// Handle rider penalty if cancelled by rider
if ($delivery->cancelled_by === 'rider' && $delivery->rider_id) {
// Penalty logic can be handled here
}
}
/**
* Handle a job failure.
*/
public function failed(DeliveryStatusChanged $event, \Throwable $exception): void
{
Log::error('Failed to handle delivery status change', [
'delivery_id' => $event->delivery->id,
'error' => $exception->getMessage(),
]);
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
use Modules\RestaurantDelivery\Events\RiderRated;
class HandleRiderRated implements ShouldQueue
{
use InteractsWithQueue;
/**
* The name of the queue the job should be sent to.
*/
public string $queue = 'restaurant-delivery-notifications';
/**
* Create the event listener.
*/
public function __construct()
{
//
}
/**
* Handle the event.
*/
public function handle(RiderRated $event): void
{
$rating = $event->rating;
$rider = $rating->rider;
Log::info('Rider rated', [
'rating_id' => $rating->id,
'rider_id' => $rider->id,
'overall_rating' => $rating->overall_rating,
]);
// Notify rider of new rating
if ($rider && $rider->fcm_token) {
// Send push notification to rider
$this->notifyRider($rider, $rating);
}
// Notify restaurant if configured
if (config('restaurant-delivery.notifications.restaurant.rating_received')) {
$this->notifyRestaurant($rating);
}
// Check for bonus eligibility
$this->checkBonusEligibility($rider, $rating);
}
/**
* Notify rider of new rating.
*/
protected function notifyRider($rider, $rating): void
{
Log::info('Notifying rider of new rating', [
'rider_id' => $rider->id,
'rating' => $rating->overall_rating,
]);
// Notification implementation would go here
}
/**
* Notify restaurant of rating.
*/
protected function notifyRestaurant($rating): void
{
$delivery = $rating->delivery;
Log::info('Restaurant should be notified of rating', [
'restaurant_id' => $delivery->restaurant_id,
'delivery_id' => $delivery->id,
]);
}
/**
* Check if rider is eligible for rating bonus.
*/
protected function checkBonusEligibility($rider, $rating): void
{
$bonusConfig = config('restaurant-delivery.earnings.bonuses.rating_bonus');
if (! $bonusConfig['enabled']) {
return;
}
if ($rider->rating >= $bonusConfig['min_rating']) {
Log::info('Rider eligible for rating bonus', [
'rider_id' => $rider->id,
'current_rating' => $rider->rating,
'min_rating' => $bonusConfig['min_rating'],
]);
}
}
/**
* Handle a job failure.
*/
public function failed(RiderRated $event, \Throwable $exception): void
{
Log::error('Failed to handle rider rated event', [
'rating_id' => $event->rating->id,
'error' => $exception->getMessage(),
]);
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
use Modules\RestaurantDelivery\Events\TipReceived;
class HandleTipReceived implements ShouldQueue
{
use InteractsWithQueue;
/**
* The name of the queue the job should be sent to.
*/
public string $queue = 'restaurant-delivery-notifications';
/**
* Create the event listener.
*/
public function __construct()
{
//
}
/**
* Handle the event.
*/
public function handle(TipReceived $event): void
{
$tip = $event->tip;
$rider = $tip->rider;
Log::info('Tip received', [
'tip_id' => $tip->id,
'rider_id' => $rider?->id,
'amount' => $tip->amount,
'rider_amount' => $tip->rider_amount,
]);
// Notify rider of tip
if ($rider && $rider->fcm_token) {
$this->notifyRider($rider, $tip);
}
}
/**
* Notify rider of tip received.
*/
protected function notifyRider($rider, $tip): void
{
Log::info('Notifying rider of tip received', [
'rider_id' => $rider->id,
'amount' => $tip->rider_amount,
'currency' => $tip->currency,
]);
// Push notification implementation would go here
// Example: Send FCM notification
// $this->firebase->sendPushNotification(
// $rider->fcm_token,
// 'Tip Received!',
// "You received a tip of {$tip->currency} {$tip->rider_amount}!",
// ['type' => 'tip_received', 'tip_id' => $tip->id]
// );
}
/**
* Handle a job failure.
*/
public function failed(TipReceived $event, \Throwable $exception): void
{
Log::error('Failed to handle tip received event', [
'tip_id' => $event->tip->id,
'error' => $exception->getMessage(),
]);
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use Modules\RestaurantDelivery\Events\DeliveryCreated;
use Modules\RestaurantDelivery\Jobs\AssignRiderJob;
class SendDeliveryCreatedNotification implements ShouldQueue
{
use InteractsWithQueue;
/**
* The name of the queue the job should be sent to.
*/
public string $queue = 'restaurant-delivery-notifications';
/**
* Create the event listener.
*/
public function __construct()
{
//
}
/**
* Handle the event.
*/
public function handle(DeliveryCreated $event): void
{
$delivery = $event->delivery;
Log::info('Delivery created event received', [
'delivery_id' => $delivery->id,
'tracking_code' => $delivery->tracking_code,
]);
// Notify restaurant if configured
if (config('restaurant-delivery.notifications.restaurant.new_order')) {
// Restaurant notification would be sent here
}
// Auto-assign rider if enabled
if (config('restaurant-delivery.assignment.auto_assign') && ! $delivery->rider_id) {
dispatch(new AssignRiderJob($delivery))
->onQueue(config('restaurant-delivery.queue.queues.assignment'));
}
}
/**
* Handle a job failure.
*/
public function failed(DeliveryCreated $event, \Throwable $exception): void
{
Log::error('Failed to process delivery created notification', [
'delivery_id' => $event->delivery->id,
'error' => $exception->getMessage(),
]);
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Listeners;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Modules\RestaurantDelivery\Events\RiderLocationUpdated;
class UpdateFirebaseLocation implements ShouldQueue
{
use InteractsWithQueue;
/**
* The name of the queue the job should be sent to.
*/
public string $queue = 'restaurant-delivery-tracking';
/**
* The number of times the job may be attempted.
*/
public int $tries = 3;
/**
* Create the event listener.
*/
public function __construct()
{
//
}
/**
* Handle the event.
*/
public function handle(RiderLocationUpdated $event): void
{
$rider = $event->rider;
// Cache the location for quick access
Cache::put(
"rider_location_{$rider->id}",
[
'lat' => $event->latitude,
'lng' => $event->longitude,
'speed' => $event->speed,
'bearing' => $event->bearing,
'updated_at' => now()->toIso8601String(),
],
config('restaurant-delivery.cache.ttl.rider_location', 30)
);
Log::debug('Rider location cached', [
'rider_id' => $rider->id,
'latitude' => $event->latitude,
'longitude' => $event->longitude,
]);
}
/**
* Handle a job failure.
*/
public function failed(RiderLocationUpdated $event, \Throwable $exception): void
{
Log::error('Failed to update Firebase location', [
'rider_id' => $event->rider->id,
'error' => $exception->getMessage(),
]);
}
}

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

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Modules\RestaurantDelivery\Models\Delivery;
class DeliveryStatusNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(
public readonly Delivery $delivery,
public readonly string $recipientType = 'customer'
) {
$this->onQueue(config('restaurant-delivery.queue.queues.notifications', 'restaurant-delivery-notifications'));
}
/**
* Get the notification's delivery channels.
*/
public function via(object $notifiable): array
{
return array_filter(config('restaurant-delivery.notifications.channels', ['database']));
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject("Delivery Update: {$this->delivery->status->label()}")
->greeting('Hello!')
->line("Your delivery #{$this->delivery->tracking_code} status has been updated.")
->line("New Status: {$this->delivery->status->label()}")
->line($this->delivery->status->description())
->action('Track Delivery', $this->getTrackingUrl())
->line('Thank you for using our delivery service!');
}
/**
* Get the array representation of the notification.
*/
public function toArray(object $notifiable): array
{
return [
'type' => 'delivery_status',
'delivery_id' => $this->delivery->id,
'tracking_code' => $this->delivery->tracking_code,
'status' => $this->delivery->status->value,
'status_label' => $this->delivery->status->label(),
'status_description' => $this->delivery->status->description(),
'rider' => $this->delivery->rider ? [
'name' => $this->delivery->rider->full_name,
'phone' => $this->delivery->rider->phone,
] : null,
];
}
/**
* Get FCM data for push notification.
*/
public function toFcm(): array
{
return [
'title' => 'Delivery Update',
'body' => $this->delivery->status->description(),
'data' => [
'type' => 'delivery_status',
'delivery_id' => (string) $this->delivery->id,
'tracking_code' => $this->delivery->tracking_code,
'status' => $this->delivery->status->value,
],
];
}
/**
* Get tracking URL.
*/
protected function getTrackingUrl(): string
{
return url("/track/{$this->delivery->tracking_code}");
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use Modules\RestaurantDelivery\Models\Delivery;
class NewDeliveryRequestNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(
public readonly Delivery $delivery,
public readonly float $distance,
public readonly int $timeout = 30
) {
$this->onQueue(config('restaurant-delivery.queue.queues.notifications', 'restaurant-delivery-notifications'));
}
/**
* Get the notification's delivery channels.
*/
public function via(object $notifiable): array
{
return ['database'];
}
/**
* Get the array representation of the notification.
*/
public function toArray(object $notifiable): array
{
return [
'type' => 'new_delivery_request',
'delivery_id' => $this->delivery->id,
'tracking_code' => $this->delivery->tracking_code,
'restaurant_name' => $this->delivery->restaurant_name,
'pickup_address' => $this->delivery->pickup_address,
'drop_address' => $this->delivery->drop_address,
'distance' => $this->delivery->distance,
'distance_to_pickup' => $this->distance,
'estimated_earnings' => $this->delivery->total_delivery_charge,
'timeout' => $this->timeout,
'is_priority' => $this->delivery->is_priority,
];
}
/**
* Get FCM data for push notification.
*/
public function toFcm(): array
{
$currency = config('restaurant-delivery.pricing.currency_symbol', '৳');
return [
'title' => 'New Delivery Request',
'body' => "{$this->delivery->restaurant_name} - ".round($this->distance, 1).' km away',
'data' => [
'type' => 'delivery_request',
'delivery_id' => (string) $this->delivery->id,
'tracking_code' => $this->delivery->tracking_code,
'restaurant_name' => $this->delivery->restaurant_name,
'pickup_address' => $this->delivery->pickup_address,
'drop_address' => $this->delivery->drop_address,
'distance' => (string) $this->delivery->distance,
'distance_to_pickup' => (string) $this->distance,
'earnings' => "{$currency}".number_format((float) $this->delivery->total_delivery_charge, 2),
'timeout' => (string) $this->timeout,
'is_priority' => $this->delivery->is_priority ? 'true' : 'false',
],
];
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Modules\RestaurantDelivery\Models\Delivery;
class RiderAssignedNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(
public readonly Delivery $delivery
) {
$this->onQueue(config('restaurant-delivery.queue.queues.notifications', 'restaurant-delivery-notifications'));
}
/**
* Get the notification's delivery channels.
*/
public function via(object $notifiable): array
{
return ['database'];
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
$rider = $this->delivery->rider;
return (new MailMessage)
->subject('Rider Assigned to Your Delivery')
->greeting('Good news!')
->line("A rider has been assigned to your delivery #{$this->delivery->tracking_code}.")
->line("Rider: {$rider->full_name}")
->line("Vehicle: {$rider->vehicle_type}")
->action('Track Your Delivery', $this->getTrackingUrl())
->line('You can now track your rider in real-time!');
}
/**
* Get the array representation of the notification.
*/
public function toArray(object $notifiable): array
{
$rider = $this->delivery->rider;
return [
'type' => 'rider_assigned',
'delivery_id' => $this->delivery->id,
'tracking_code' => $this->delivery->tracking_code,
'rider' => [
'id' => $rider->id,
'name' => $rider->full_name,
'phone' => $rider->phone,
'photo' => $rider->photo_url,
'rating' => $rider->rating,
'vehicle_type' => $rider->vehicle_type,
],
];
}
/**
* Get FCM data for push notification.
*/
public function toFcm(): array
{
$rider = $this->delivery->rider;
return [
'title' => 'Rider Assigned',
'body' => "{$rider->full_name} is picking up your order",
'data' => [
'type' => 'rider_assigned',
'delivery_id' => (string) $this->delivery->id,
'tracking_code' => $this->delivery->tracking_code,
'rider_id' => (string) $rider->id,
'rider_name' => $rider->full_name,
],
];
}
/**
* Get tracking URL.
*/
protected function getTrackingUrl(): string
{
return url("/track/{$this->delivery->tracking_code}");
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
use Modules\RestaurantDelivery\Models\Rider;
class RiderWarningNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(
public readonly Rider $rider,
public readonly string $warningType,
public readonly ?array $details = null
) {
$this->onQueue(config('restaurant-delivery.queue.queues.notifications', 'restaurant-delivery-notifications'));
}
/**
* Get the notification's delivery channels.
*/
public function via(object $notifiable): array
{
return ['database', 'mail'];
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
$message = (new MailMessage)
->subject($this->getSubject())
->greeting("Hello {$this->rider->first_name},");
$content = $this->getContent();
foreach ($content['lines'] as $line) {
$message->line($line);
}
if (isset($content['action'])) {
$message->action($content['action']['text'], $content['action']['url']);
}
return $message->line('If you have any questions, please contact support.');
}
/**
* Get the array representation of the notification.
*/
public function toArray(object $notifiable): array
{
return [
'type' => 'rider_warning',
'warning_type' => $this->warningType,
'rider_id' => $this->rider->id,
'details' => $this->details,
'message' => $this->getMessage(),
];
}
/**
* Get FCM data for push notification.
*/
public function toFcm(): array
{
return [
'title' => $this->getSubject(),
'body' => $this->getMessage(),
'data' => [
'type' => 'rider_warning',
'warning_type' => $this->warningType,
],
];
}
/**
* Get subject based on warning type.
*/
protected function getSubject(): string
{
return match ($this->warningType) {
'low_rating' => 'Warning: Your Rating is Getting Low',
'high_cancellation' => 'Warning: High Cancellation Rate',
'late_deliveries' => 'Warning: Multiple Late Deliveries',
'suspension_risk' => 'Warning: Account Suspension Risk',
default => 'Important Notice',
};
}
/**
* Get message based on warning type.
*/
protected function getMessage(): string
{
return match ($this->warningType) {
'low_rating' => "Your current rating is {$this->rider->rating}. Please work on improving your service quality.",
'high_cancellation' => 'Your cancellation rate is higher than acceptable. Please try to complete assigned deliveries.',
'late_deliveries' => 'You have had multiple late deliveries recently. Please plan your routes better.',
'suspension_risk' => 'Your account is at risk of suspension. Please improve your performance immediately.',
default => 'Please review your account status.',
};
}
/**
* Get content for email.
*/
protected function getContent(): array
{
return match ($this->warningType) {
'low_rating' => [
'lines' => [
"We've noticed that your rating has dropped to {$this->rider->rating}.",
'Customer ratings are important for maintaining quality service.',
'Here are some tips to improve your rating:',
'• Be polite and professional with customers',
'• Handle food with care to avoid spills',
'• Communicate any delays promptly',
'• Ensure deliveries are complete and accurate',
],
'action' => [
'text' => 'View My Ratings',
'url' => url('/rider/ratings'),
],
],
'high_cancellation' => [
'lines' => [
'Your cancellation rate has exceeded acceptable limits.',
'Frequent cancellations affect your earnings and account standing.',
'Please only accept orders you can complete.',
'If you face issues with specific deliveries, contact support before cancelling.',
],
],
default => [
'lines' => [
$this->getMessage(),
'Please take this warning seriously.',
'Contact support if you need assistance.',
],
],
};
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;
use Modules\RestaurantDelivery\Models\DeliveryTip;
class TipReceivedNotification extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(
public readonly DeliveryTip $tip
) {
$this->onQueue(config('restaurant-delivery.queue.queues.notifications', 'restaurant-delivery-notifications'));
}
/**
* Get the notification's delivery channels.
*/
public function via(object $notifiable): array
{
return ['database'];
}
/**
* Get the array representation of the notification.
*/
public function toArray(object $notifiable): array
{
return [
'type' => 'tip_received',
'tip_id' => $this->tip->id,
'delivery_id' => $this->tip->delivery_id,
'amount' => $this->tip->amount,
'rider_amount' => $this->tip->rider_amount,
'currency' => $this->tip->currency,
'message' => $this->tip->message,
];
}
/**
* Get FCM data for push notification.
*/
public function toFcm(): array
{
$currency = config('restaurant-delivery.pricing.currency_symbol', '৳');
return [
'title' => 'You Received a Tip!',
'body' => "{$currency}{$this->tip->rider_amount} tip received".
($this->tip->message ? " - \"{$this->tip->message}\"" : ''),
'data' => [
'type' => 'tip_received',
'tip_id' => (string) $this->tip->id,
'delivery_id' => (string) $this->tip->delivery_id,
'amount' => (string) $this->tip->rider_amount,
],
];
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Observers;
use Illuminate\Support\Facades\Log;
use Modules\RestaurantDelivery\Enums\DeliveryStatus;
use Modules\RestaurantDelivery\Models\Delivery;
use Modules\RestaurantDelivery\Services\Tracking\LiveTrackingService;
class DeliveryObserver
{
public function __construct(
protected LiveTrackingService $trackingService
) {}
/**
* Handle the Delivery "created" event.
*/
public function created(Delivery $delivery): void
{
Log::info('Delivery created', [
'delivery_id' => $delivery->id,
'tracking_code' => $delivery->tracking_code,
]);
}
/**
* Handle the Delivery "updated" event.
*/
public function updated(Delivery $delivery): void
{
// Check if status changed
if ($delivery->isDirty('status')) {
$this->handleStatusChange($delivery);
}
// Check if rider changed
if ($delivery->isDirty('rider_id')) {
$this->handleRiderChange($delivery);
}
}
/**
* Handle status change.
*/
protected function handleStatusChange(Delivery $delivery): void
{
$originalStatus = $delivery->getOriginal('status');
Log::info('Delivery status changed', [
'delivery_id' => $delivery->id,
'from' => $originalStatus,
'to' => $delivery->status->value,
]);
// Handle specific status transitions
if ($delivery->status === DeliveryStatus::DELIVERED) {
$this->handleDeliveryCompleted($delivery);
}
if ($delivery->status === DeliveryStatus::CANCELLED) {
$this->handleDeliveryCancelled($delivery);
}
}
/**
* Handle rider change.
*/
protected function handleRiderChange(Delivery $delivery): void
{
$originalRiderId = $delivery->getOriginal('rider_id');
$newRiderId = $delivery->rider_id;
Log::info('Delivery rider changed', [
'delivery_id' => $delivery->id,
'from_rider' => $originalRiderId,
'to_rider' => $newRiderId,
]);
// Reinitialize tracking with new rider
if ($newRiderId) {
$this->trackingService->initializeDeliveryTracking($delivery);
}
}
/**
* Handle delivery completed.
*/
protected function handleDeliveryCompleted(Delivery $delivery): void
{
// End tracking
$this->trackingService->endDeliveryTracking($delivery);
Log::info('Delivery completed', [
'delivery_id' => $delivery->id,
'rider_id' => $delivery->rider_id,
'duration_minutes' => $delivery->getActualDuration(),
]);
}
/**
* Handle delivery cancelled.
*/
protected function handleDeliveryCancelled(Delivery $delivery): void
{
// End tracking
$this->trackingService->endDeliveryTracking($delivery);
Log::info('Delivery cancelled', [
'delivery_id' => $delivery->id,
'cancelled_by' => $delivery->cancelled_by,
'reason' => $delivery->cancellation_reason,
]);
}
/**
* Handle the Delivery "deleted" event.
*/
public function deleted(Delivery $delivery): void
{
// End tracking if still active
if ($delivery->isActive()) {
$this->trackingService->endDeliveryTracking($delivery);
}
Log::info('Delivery deleted', [
'delivery_id' => $delivery->id,
]);
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Observers;
use Illuminate\Support\Facades\Log;
use Modules\RestaurantDelivery\Models\DeliveryRating;
class DeliveryRatingObserver
{
/**
* Handle the DeliveryRating "created" event.
*/
public function created(DeliveryRating $rating): void
{
Log::info('Delivery rating created', [
'rating_id' => $rating->id,
'delivery_id' => $rating->delivery_id,
'rider_id' => $rating->rider_id,
'overall_rating' => $rating->overall_rating,
]);
// Auto-approve high ratings without review
if ($rating->overall_rating >= 4 && ! $rating->review) {
$rating->approve();
}
}
/**
* Handle the DeliveryRating "updated" event.
*/
public function updated(DeliveryRating $rating): void
{
// Check if moderation status changed
if ($rating->isDirty('moderation_status')) {
Log::info('Rating moderation status changed', [
'rating_id' => $rating->id,
'from' => $rating->getOriginal('moderation_status'),
'to' => $rating->moderation_status,
]);
// Recalculate rider's rating if approved or rejected
if (in_array($rating->moderation_status, ['approved', 'rejected'])) {
$rating->rider?->recalculateRating();
}
}
}
/**
* Handle the DeliveryRating "deleted" event.
*/
public function deleted(DeliveryRating $rating): void
{
Log::info('Delivery rating deleted', [
'rating_id' => $rating->id,
'rider_id' => $rating->rider_id,
]);
// Recalculate rider's rating
$rating->rider?->recalculateRating();
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Observers;
use Illuminate\Support\Facades\Log;
use Modules\RestaurantDelivery\Models\Rider;
use Modules\RestaurantDelivery\Notifications\RiderWarningNotification;
use Modules\RestaurantDelivery\Services\Firebase\FirebaseService;
class RiderObserver
{
public function __construct(
protected FirebaseService $firebase
) {}
/**
* Handle the Rider "created" event.
*/
public function created(Rider $rider): void
{
Log::info('Rider created', [
'rider_id' => $rider->id,
'name' => $rider->full_name,
]);
}
/**
* Handle the Rider "updated" event.
*/
public function updated(Rider $rider): void
{
// Check if status changed
if ($rider->isDirty('status')) {
$this->handleStatusChange($rider);
}
// Check if online status changed
if ($rider->isDirty('is_online')) {
$this->handleOnlineStatusChange($rider);
}
// Check if rating changed
if ($rider->isDirty('rating')) {
$this->handleRatingChange($rider);
}
// Check if verification status changed
if ($rider->isDirty('is_verified')) {
$this->handleVerificationChange($rider);
}
}
/**
* Handle status change.
*/
protected function handleStatusChange(Rider $rider): void
{
$originalStatus = $rider->getOriginal('status');
Log::info('Rider status changed', [
'rider_id' => $rider->id,
'from' => $originalStatus,
'to' => $rider->status,
]);
// Update Firebase
if ($this->firebase->isEnabled()) {
$this->firebase->updateRiderStatus($rider->id, $rider->status);
}
// Handle suspension
if ($rider->status === 'suspended') {
Log::warning('Rider suspended', [
'rider_id' => $rider->id,
]);
}
}
/**
* Handle online status change.
*/
protected function handleOnlineStatusChange(Rider $rider): void
{
Log::info('Rider online status changed', [
'rider_id' => $rider->id,
'is_online' => $rider->is_online,
]);
// Update Firebase
if ($this->firebase->isEnabled()) {
$this->firebase->updateRiderStatus(
$rider->id,
$rider->is_online ? 'online' : 'offline'
);
}
}
/**
* Handle rating change.
*/
protected function handleRatingChange(Rider $rider): void
{
$originalRating = $rider->getOriginal('rating');
$newRating = $rider->rating;
Log::info('Rider rating changed', [
'rider_id' => $rider->id,
'from' => $originalRating,
'to' => $newRating,
]);
$thresholds = config('restaurant-delivery.rating.thresholds');
$minRatings = $thresholds['minimum_ratings_for_threshold'] ?? 10;
// Only check thresholds if rider has enough ratings
if ($rider->rating_count >= $minRatings) {
// Check warning threshold
$warningThreshold = $thresholds['warning_threshold'] ?? 3.0;
if ($newRating <= $warningThreshold && $originalRating > $warningThreshold) {
$rider->notify(new RiderWarningNotification($rider, 'low_rating'));
}
}
}
/**
* Handle verification change.
*/
protected function handleVerificationChange(Rider $rider): void
{
Log::info('Rider verification status changed', [
'rider_id' => $rider->id,
'is_verified' => $rider->is_verified,
]);
}
/**
* Handle the Rider "deleted" event.
*/
public function deleted(Rider $rider): void
{
Log::info('Rider deleted', [
'rider_id' => $rider->id,
]);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Modules\RestaurantDelivery\Providers;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
/**
* The event handler mappings for the application.
*
* @var array<string, array<int, string>>
*/
protected $listen = [];
/**
* Indicates if events should be discovered.
*
* @var bool
*/
protected static $shouldDiscoverEvents = true;
/**
* Configure the proper event listeners for email verification.
*/
protected function configureEmailVerification(): void {}
}

View File

@@ -0,0 +1,154 @@
<?php
namespace Modules\RestaurantDelivery\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
use Nwidart\Modules\Traits\PathNamespace;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
class RestaurantDeliveryServiceProvider extends ServiceProvider
{
use PathNamespace;
protected string $name = 'RestaurantDelivery';
protected string $nameLower = 'restaurantdelivery';
/**
* Boot the application events.
*/
public function boot(): void
{
$this->registerCommands();
$this->registerCommandSchedules();
$this->registerTranslations();
$this->registerConfig();
$this->registerViews();
$this->loadMigrationsFrom(module_path($this->name, 'database/migrations'));
}
/**
* Register the service provider.
*/
public function register(): void
{
$this->app->register(EventServiceProvider::class);
$this->app->register(RouteServiceProvider::class);
}
/**
* Register commands in the format of Command::class
*/
protected function registerCommands(): void
{
// $this->commands([]);
}
/**
* Register command Schedules.
*/
protected function registerCommandSchedules(): void
{
// $this->app->booted(function () {
// $schedule = $this->app->make(Schedule::class);
// $schedule->command('inspire')->hourly();
// });
}
/**
* Register translations.
*/
public function registerTranslations(): void
{
$langPath = resource_path('lang/modules/'.$this->nameLower);
if (is_dir($langPath)) {
$this->loadTranslationsFrom($langPath, $this->nameLower);
$this->loadJsonTranslationsFrom($langPath);
} else {
$this->loadTranslationsFrom(module_path($this->name, 'lang'), $this->nameLower);
$this->loadJsonTranslationsFrom(module_path($this->name, 'lang'));
}
}
/**
* Register config.
*/
protected function registerConfig(): void
{
$configPath = module_path($this->name, config('modules.paths.generator.config.path'));
if (is_dir($configPath)) {
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($configPath));
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$config = str_replace($configPath.DIRECTORY_SEPARATOR, '', $file->getPathname());
$config_key = str_replace([DIRECTORY_SEPARATOR, '.php'], ['.', ''], $config);
$segments = explode('.', $this->nameLower.'.'.$config_key);
// Remove duplicated adjacent segments
$normalized = [];
foreach ($segments as $segment) {
if (end($normalized) !== $segment) {
$normalized[] = $segment;
}
}
$key = ($config === 'config.php') ? $this->nameLower : implode('.', $normalized);
$this->publishes([$file->getPathname() => config_path($config)], 'config');
$this->merge_config_from($file->getPathname(), $key);
}
}
}
}
/**
* Merge config from the given path recursively.
*/
protected function merge_config_from(string $path, string $key): void
{
$existing = config($key, []);
$module_config = require $path;
config([$key => array_replace_recursive($existing, $module_config)]);
}
/**
* Register views.
*/
public function registerViews(): void
{
$viewPath = resource_path('views/modules/'.$this->nameLower);
$sourcePath = module_path($this->name, 'resources/views');
$this->publishes([$sourcePath => $viewPath], ['views', $this->nameLower.'-module-views']);
$this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), $this->nameLower);
Blade::componentNamespace(config('modules.namespace').'\\'.$this->name.'\\View\\Components', $this->nameLower);
}
/**
* Get the services provided by the provider.
*/
public function provides(): array
{
return [];
}
private function getPublishableViewPaths(): array
{
$paths = [];
foreach (config('view.paths') as $path) {
if (is_dir($path.'/modules/'.$this->nameLower)) {
$paths[] = $path.'/modules/'.$this->nameLower;
}
}
return $paths;
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace Modules\RestaurantDelivery\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
class RouteServiceProvider extends ServiceProvider
{
protected string $name = 'RestaurantDelivery';
/**
* Called before routes are registered.
*
* Register any model bindings or pattern based filters.
*/
public function boot(): void
{
parent::boot();
}
/**
* Define the routes for the application.
*/
public function map(): void
{
$this->mapApiRoutes();
}
/**
* Define the "api" routes for the application.
*
* These routes are typically stateless.
*/
protected function mapApiRoutes(): void
{
Route::middleware('api')->prefix('api')->name('api.')->group(module_path($this->name, '/routes/api.php'));
}
}

View File

@@ -0,0 +1,931 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Services\Earnings;
use Illuminate\Support\Carbon;
use Modules\RestaurantDelivery\Models\Delivery;
use Modules\RestaurantDelivery\Models\Rider;
use Modules\RestaurantDelivery\Models\RiderEarning;
/**
* Rider Earnings Calculator
*
* Handles all earnings calculations including:
* - Base commission (fixed, percentage, per_km, hybrid)
* - Distance-based earnings
* - Peak hour bonuses
* - Surge pricing multipliers
* - Performance bonuses (rating, consecutive, targets)
* - Weather bonuses
* - Tips
* - Penalties
*/
class EarningsCalculator
{
protected array $config;
public function __construct()
{
$this->config = config('restaurant-delivery.rider_earnings');
}
/*
|--------------------------------------------------------------------------
| MAIN CALCULATION FORMULA
|--------------------------------------------------------------------------
|
| Total Earnings = Base Commission + Distance Bonus + Peak Hour Bonus
| + Surge Bonus + Performance Bonuses + Tips - Penalties
|
| Base Commission Types:
| - Fixed: flat_rate
| - Percentage: delivery_fee × percentage
| - Per KM: base_rate + (distance × per_km_rate)
| - Hybrid: base_amount + (distance × per_km_rate)
|
*/
/**
* Calculate complete earnings for a delivery
*
* @param Delivery $delivery The completed delivery
* @param Rider $rider The rider who completed it
* @return array Detailed breakdown of earnings
*/
public function calculateDeliveryEarnings(Delivery $delivery, Rider $rider): array
{
$breakdown = [
'delivery_id' => $delivery->id,
'rider_id' => $rider->id,
'calculations' => [],
'bonuses' => [],
'penalties' => [],
'subtotals' => [],
];
// 1. Calculate base commission
$baseCommission = $this->calculateBaseCommission($delivery, $rider);
$breakdown['calculations']['base_commission'] = $baseCommission;
// 2. Calculate distance bonus (if applicable)
$distanceBonus = $this->calculateDistanceBonus($delivery);
$breakdown['calculations']['distance_bonus'] = $distanceBonus;
// 3. Calculate peak hour bonus
$peakHourBonus = $this->calculatePeakHourBonus($delivery, $baseCommission['amount']);
$breakdown['bonuses']['peak_hour'] = $peakHourBonus;
// 4. Calculate surge pricing bonus
$surgeBonus = $this->calculateSurgeBonus($delivery, $baseCommission['amount']);
$breakdown['bonuses']['surge'] = $surgeBonus;
// 5. Calculate performance bonuses
$performanceBonuses = $this->calculatePerformanceBonuses($delivery, $rider);
$breakdown['bonuses'] = array_merge($breakdown['bonuses'], $performanceBonuses);
// 6. Calculate weather bonus
$weatherBonus = $this->calculateWeatherBonus($delivery);
$breakdown['bonuses']['weather'] = $weatherBonus;
// 7. Get tip amount
$tipAmount = $this->getTipAmount($delivery);
$breakdown['calculations']['tip'] = [
'amount' => $tipAmount,
'description' => 'Customer tip',
];
// 8. Calculate penalties (if any)
$penalties = $this->calculatePenalties($delivery, $rider);
$breakdown['penalties'] = $penalties;
// Calculate subtotals
$baseEarnings = $baseCommission['amount'] + $distanceBonus['amount'];
$totalBonuses = array_sum(array_column($breakdown['bonuses'], 'amount'));
$totalPenalties = array_sum(array_column($breakdown['penalties'], 'amount'));
$totalEarnings = $baseEarnings + $totalBonuses + $tipAmount - $totalPenalties;
$breakdown['subtotals'] = [
'base_earnings' => round($baseEarnings, 2),
'total_bonuses' => round($totalBonuses, 2),
'tip' => round($tipAmount, 2),
'total_penalties' => round($totalPenalties, 2),
'gross_earnings' => round($baseEarnings + $totalBonuses + $tipAmount, 2),
'net_earnings' => round($totalEarnings, 2),
];
$breakdown['total_earnings'] = round($totalEarnings, 2);
$breakdown['currency'] = config('restaurant-delivery.currency');
return $breakdown;
}
/*
|--------------------------------------------------------------------------
| BASE COMMISSION CALCULATIONS
|--------------------------------------------------------------------------
*/
/**
* Calculate base commission based on commission model
*
* FORMULA BY MODEL:
* - Fixed: commission = fixed_amount
* - Percentage: commission = delivery_fee × (percentage / 100)
* - Per KM: commission = base_rate + (distance_km × per_km_rate)
* - Hybrid: commission = base_amount + (distance_km × per_km_rate)
*/
public function calculateBaseCommission(Delivery $delivery, Rider $rider): array
{
$model = $rider->commission_model ?? $this->config['commission_model'] ?? 'fixed';
$rate = $rider->commission_rate ?? $this->config['default_commission_rate'] ?? 80;
$distance = $delivery->actual_distance ?? $delivery->estimated_distance ?? 0;
$deliveryFee = $delivery->delivery_fee ?? 0;
$amount = 0;
$formula = '';
switch ($model) {
case 'fixed':
// Fixed amount per delivery
$amount = (float) $rate;
$formula = "Fixed rate: {$rate}";
break;
case 'percentage':
// Percentage of delivery fee
$percentage = (float) $rate;
$amount = $deliveryFee * ($percentage / 100);
$formula = "Delivery fee ({$deliveryFee}) × {$percentage}% = {$amount}";
break;
case 'per_km':
// Base + per kilometer rate
$baseRate = $this->config['per_km']['base_rate'] ?? 30;
$perKmRate = (float) $rate;
$amount = $baseRate + ($distance * $perKmRate);
$formula = "Base ({$baseRate}) + Distance ({$distance} km) × Rate ({$perKmRate}/km) = {$amount}";
break;
case 'hybrid':
// Base amount + per km (more flexible)
$baseAmount = $this->config['hybrid']['base_amount'] ?? 40;
$perKmRate = $this->config['hybrid']['per_km_rate'] ?? 10;
$minAmount = $this->config['hybrid']['min_amount'] ?? 50;
$maxAmount = $this->config['hybrid']['max_amount'] ?? 300;
$calculated = $baseAmount + ($distance * $perKmRate);
$amount = max($minAmount, min($maxAmount, $calculated));
$formula = "Base ({$baseAmount}) + Distance ({$distance} km) × Rate ({$perKmRate}/km) = {$calculated}";
if ($calculated !== $amount) {
$formula .= " → Clamped to {$amount} (min: {$minAmount}, max: {$maxAmount})";
}
break;
default:
$amount = (float) $rate;
$formula = "Default fixed rate: {$rate}";
}
return [
'model' => $model,
'amount' => round($amount, 2),
'formula' => $formula,
'inputs' => [
'distance_km' => $distance,
'delivery_fee' => $deliveryFee,
'rate' => $rate,
],
];
}
/**
* Calculate distance-based bonus for long deliveries
*
* FORMULA:
* If distance > threshold:
* bonus = (distance - threshold) × extra_per_km_rate
*/
public function calculateDistanceBonus(Delivery $delivery): array
{
$distanceConfig = $this->config['distance_bonus'] ?? [];
if (empty($distanceConfig['enabled'])) {
return ['amount' => 0, 'formula' => 'Distance bonus disabled'];
}
$distance = $delivery->actual_distance ?? $delivery->estimated_distance ?? 0;
$threshold = $distanceConfig['threshold_km'] ?? 5;
$extraRate = $distanceConfig['extra_per_km'] ?? 5;
if ($distance <= $threshold) {
return [
'amount' => 0,
'formula' => "Distance ({$distance} km) ≤ Threshold ({$threshold} km) → No bonus",
];
}
$extraDistance = $distance - $threshold;
$bonus = $extraDistance * $extraRate;
return [
'amount' => round($bonus, 2),
'formula' => "Extra distance ({$extraDistance} km) × Rate ({$extraRate}/km) = {$bonus}",
'inputs' => [
'distance' => $distance,
'threshold' => $threshold,
'extra_rate' => $extraRate,
],
];
}
/*
|--------------------------------------------------------------------------
| BONUS CALCULATIONS
|--------------------------------------------------------------------------
*/
/**
* Calculate peak hour bonus
*
* FORMULA:
* If within peak hours:
* bonus = base_commission × peak_multiplier
*/
public function calculatePeakHourBonus(Delivery $delivery, float $baseAmount): array
{
$peakConfig = $this->config['bonuses']['peak_hours'] ?? [];
if (empty($peakConfig['enabled'])) {
return ['amount' => 0, 'formula' => 'Peak hour bonus disabled'];
}
$deliveryTime = $delivery->delivered_at ?? $delivery->created_at;
$hour = (int) $deliveryTime->format('H');
$peakPeriods = $peakConfig['periods'] ?? [
['start' => 11, 'end' => 14], // Lunch
['start' => 18, 'end' => 21], // Dinner
];
$isPeakHour = false;
$matchedPeriod = null;
foreach ($peakPeriods as $period) {
if ($hour >= $period['start'] && $hour <= $period['end']) {
$isPeakHour = true;
$matchedPeriod = $period;
break;
}
}
if (! $isPeakHour) {
return [
'amount' => 0,
'formula' => "Hour ({$hour}) not in peak periods → No bonus",
];
}
$multiplier = $peakConfig['multiplier'] ?? 0.2; // 20% bonus
$bonus = $baseAmount * $multiplier;
$maxBonus = $peakConfig['max_bonus'] ?? 50;
$finalBonus = min($bonus, $maxBonus);
return [
'amount' => round($finalBonus, 2),
'formula' => "Base ({$baseAmount}) × Multiplier ({$multiplier}) = {$bonus}".
($bonus > $maxBonus ? " → Capped at {$maxBonus}" : ''),
'peak_period' => "{$matchedPeriod['start']}:00 - {$matchedPeriod['end']}:00",
];
}
/**
* Calculate surge pricing bonus
*
* FORMULA:
* If surge active in zone:
* bonus = base_commission × (surge_multiplier - 1)
*
* Example: 1.5x surge on 100 base = 50 bonus
*/
public function calculateSurgeBonus(Delivery $delivery, float $baseAmount): array
{
$surgeMultiplier = $delivery->surge_multiplier ?? 1.0;
if ($surgeMultiplier <= 1.0) {
return [
'amount' => 0,
'formula' => 'No surge active (multiplier: 1.0)',
];
}
// Surge bonus = base × (multiplier - 1)
// So 1.5x surge gives 50% bonus
$bonusMultiplier = $surgeMultiplier - 1;
$bonus = $baseAmount * $bonusMultiplier;
return [
'amount' => round($bonus, 2),
'formula' => "Base ({$baseAmount}) × Surge bonus ({$bonusMultiplier}) = {$bonus}",
'surge_multiplier' => $surgeMultiplier,
];
}
/**
* Calculate performance-based bonuses
*/
public function calculatePerformanceBonuses(Delivery $delivery, Rider $rider): array
{
$bonuses = [];
// Rating bonus
$bonuses['rating'] = $this->calculateRatingBonus($rider);
// Consecutive delivery bonus
$bonuses['consecutive'] = $this->calculateConsecutiveBonus($rider);
// Early delivery bonus
$bonuses['early_delivery'] = $this->calculateEarlyDeliveryBonus($delivery);
// First delivery of the day bonus
$bonuses['first_delivery'] = $this->calculateFirstDeliveryBonus($rider);
return $bonuses;
}
/**
* Calculate rating-based bonus
*
* FORMULA:
* If rating >= threshold:
* bonus = rating_bonus_amount × rating_tier_multiplier
*/
public function calculateRatingBonus(Rider $rider): array
{
$ratingConfig = $this->config['bonuses']['rating'] ?? [];
if (empty($ratingConfig['enabled'])) {
return ['amount' => 0, 'formula' => 'Rating bonus disabled'];
}
$rating = $rider->rating ?? 0;
$threshold = $ratingConfig['threshold'] ?? 4.8;
$baseBonus = $ratingConfig['bonus_amount'] ?? 10;
if ($rating < $threshold) {
return [
'amount' => 0,
'formula' => "Rating ({$rating}) < Threshold ({$threshold}) → No bonus",
];
}
// Tiered bonus: higher rating = higher multiplier
$tiers = $ratingConfig['tiers'] ?? [
['min' => 4.8, 'max' => 4.89, 'multiplier' => 1.0],
['min' => 4.9, 'max' => 4.94, 'multiplier' => 1.5],
['min' => 4.95, 'max' => 5.0, 'multiplier' => 2.0],
];
$multiplier = 1.0;
foreach ($tiers as $tier) {
if ($rating >= $tier['min'] && $rating <= $tier['max']) {
$multiplier = $tier['multiplier'];
break;
}
}
$bonus = $baseBonus * $multiplier;
return [
'amount' => round($bonus, 2),
'formula' => "Base bonus ({$baseBonus}) × Tier multiplier ({$multiplier}) = {$bonus}",
'rating' => $rating,
'tier_multiplier' => $multiplier,
];
}
/**
* Calculate consecutive delivery bonus
*
* FORMULA:
* If consecutive_count >= threshold:
* bonus = bonus_per_consecutive × min(consecutive_count, max_count)
*/
public function calculateConsecutiveBonus(Rider $rider): array
{
$consecutiveConfig = $this->config['bonuses']['consecutive'] ?? [];
if (empty($consecutiveConfig['enabled'])) {
return ['amount' => 0, 'formula' => 'Consecutive bonus disabled'];
}
// Get today's consecutive deliveries without rejection
$today = Carbon::today();
$consecutiveCount = $this->getConsecutiveDeliveryCount($rider, $today);
$threshold = $consecutiveConfig['threshold'] ?? 3;
$bonusPerDelivery = $consecutiveConfig['bonus_per_delivery'] ?? 5;
$maxCount = $consecutiveConfig['max_count'] ?? 10;
if ($consecutiveCount < $threshold) {
return [
'amount' => 0,
'formula' => "Consecutive ({$consecutiveCount}) < Threshold ({$threshold}) → No bonus",
];
}
$eligibleCount = min($consecutiveCount, $maxCount);
$bonus = $eligibleCount * $bonusPerDelivery;
return [
'amount' => round($bonus, 2),
'formula' => "Consecutive count ({$eligibleCount}) × Bonus ({$bonusPerDelivery}) = {$bonus}",
'consecutive_count' => $consecutiveCount,
];
}
/**
* Calculate early delivery bonus
*
* FORMULA:
* If delivered before ETA:
* bonus = minutes_early × bonus_per_minute (capped)
*/
public function calculateEarlyDeliveryBonus(Delivery $delivery): array
{
$earlyConfig = $this->config['bonuses']['early_delivery'] ?? [];
if (empty($earlyConfig['enabled'])) {
return ['amount' => 0, 'formula' => 'Early delivery bonus disabled'];
}
if (! $delivery->estimated_delivery_at || ! $delivery->delivered_at) {
return ['amount' => 0, 'formula' => 'Missing delivery time data'];
}
$estimatedTime = Carbon::parse($delivery->estimated_delivery_at);
$actualTime = Carbon::parse($delivery->delivered_at);
if ($actualTime >= $estimatedTime) {
return [
'amount' => 0,
'formula' => 'Delivered on time or late → No early bonus',
];
}
$minutesEarly = $estimatedTime->diffInMinutes($actualTime);
$minMinutesRequired = $earlyConfig['min_minutes'] ?? 5;
if ($minutesEarly < $minMinutesRequired) {
return [
'amount' => 0,
'formula' => "Only {$minutesEarly} min early (need {$minMinutesRequired}) → No bonus",
];
}
$bonusPerMinute = $earlyConfig['bonus_per_minute'] ?? 2;
$maxBonus = $earlyConfig['max_bonus'] ?? 30;
$bonus = min($minutesEarly * $bonusPerMinute, $maxBonus);
return [
'amount' => round($bonus, 2),
'formula' => "Minutes early ({$minutesEarly}) × Bonus ({$bonusPerMinute}/min) = ".
($minutesEarly * $bonusPerMinute).
($minutesEarly * $bonusPerMinute > $maxBonus ? " → Capped at {$maxBonus}" : ''),
'minutes_early' => $minutesEarly,
];
}
/**
* Calculate first delivery of the day bonus
*/
public function calculateFirstDeliveryBonus(Rider $rider): array
{
$firstConfig = $this->config['bonuses']['first_delivery'] ?? [];
if (empty($firstConfig['enabled'])) {
return ['amount' => 0, 'formula' => 'First delivery bonus disabled'];
}
$today = Carbon::today();
$todayDeliveries = RiderEarning::where('rider_id', $rider->id)
->whereDate('created_at', $today)
->where('type', 'delivery')
->count();
// This is the first delivery if count is 0 (before we create the earning)
if ($todayDeliveries > 0) {
return [
'amount' => 0,
'formula' => 'Not the first delivery of the day',
];
}
$bonus = $firstConfig['bonus_amount'] ?? 20;
return [
'amount' => round((float) $bonus, 2),
'formula' => "First delivery of the day bonus: {$bonus}",
];
}
/**
* Calculate weather-based bonus
*
* FORMULA:
* If bad weather conditions:
* bonus = base_weather_bonus × condition_multiplier
*/
public function calculateWeatherBonus(Delivery $delivery): array
{
$weatherConfig = $this->config['bonuses']['weather'] ?? [];
if (empty($weatherConfig['enabled'])) {
return ['amount' => 0, 'formula' => 'Weather bonus disabled'];
}
// Weather condition should be stored with the delivery or fetched externally
$weatherCondition = $delivery->weather_condition ?? null;
if (! $weatherCondition) {
return ['amount' => 0, 'formula' => 'No weather data available'];
}
$conditions = $weatherConfig['conditions'] ?? [
'rain' => ['multiplier' => 1.0, 'bonus' => 15],
'heavy_rain' => ['multiplier' => 1.5, 'bonus' => 25],
'storm' => ['multiplier' => 2.0, 'bonus' => 40],
'extreme_heat' => ['multiplier' => 1.2, 'bonus' => 10],
'extreme_cold' => ['multiplier' => 1.2, 'bonus' => 10],
];
if (! isset($conditions[$weatherCondition])) {
return [
'amount' => 0,
'formula' => "Weather condition '{$weatherCondition}' not eligible for bonus",
];
}
$config = $conditions[$weatherCondition];
$bonus = $config['bonus'] * $config['multiplier'];
return [
'amount' => round($bonus, 2),
'formula' => "Weather ({$weatherCondition}): Base ({$config['bonus']}) × Multiplier ({$config['multiplier']}) = {$bonus}",
'condition' => $weatherCondition,
];
}
/*
|--------------------------------------------------------------------------
| TIP & PENALTY CALCULATIONS
|--------------------------------------------------------------------------
*/
/**
* Get total tip amount for delivery
*/
public function getTipAmount(Delivery $delivery): float
{
return (float) ($delivery->tips()->sum('amount') ?? 0);
}
/**
* Calculate penalties for the delivery
*/
public function calculatePenalties(Delivery $delivery, Rider $rider): array
{
$penalties = [];
// Late delivery penalty
$latePenalty = $this->calculateLatePenalty($delivery);
if ($latePenalty['amount'] > 0) {
$penalties['late_delivery'] = $latePenalty;
}
// Customer complaint penalty
$complaintPenalty = $this->calculateComplaintPenalty($delivery);
if ($complaintPenalty['amount'] > 0) {
$penalties['complaint'] = $complaintPenalty;
}
return $penalties;
}
/**
* Calculate late delivery penalty
*
* FORMULA:
* If delivered after ETA + grace_period:
* penalty = min(minutes_late × penalty_per_minute, max_penalty)
*/
public function calculateLatePenalty(Delivery $delivery): array
{
$penaltyConfig = $this->config['penalties']['late_delivery'] ?? [];
if (empty($penaltyConfig['enabled'])) {
return ['amount' => 0, 'formula' => 'Late penalty disabled'];
}
if (! $delivery->estimated_delivery_at || ! $delivery->delivered_at) {
return ['amount' => 0, 'formula' => 'Missing delivery time data'];
}
$estimatedTime = Carbon::parse($delivery->estimated_delivery_at);
$actualTime = Carbon::parse($delivery->delivered_at);
$gracePeriod = $penaltyConfig['grace_period_minutes'] ?? 10;
$estimatedWithGrace = $estimatedTime->addMinutes($gracePeriod);
if ($actualTime <= $estimatedWithGrace) {
return [
'amount' => 0,
'formula' => 'Delivered within acceptable time window',
];
}
$minutesLate = $actualTime->diffInMinutes($estimatedWithGrace);
$penaltyPerMinute = $penaltyConfig['penalty_per_minute'] ?? 2;
$maxPenalty = $penaltyConfig['max_penalty'] ?? 50;
$penalty = min($minutesLate * $penaltyPerMinute, $maxPenalty);
return [
'amount' => round($penalty, 2),
'formula' => "Minutes late ({$minutesLate}) × Penalty ({$penaltyPerMinute}/min) = ".
($minutesLate * $penaltyPerMinute).
($minutesLate * $penaltyPerMinute > $maxPenalty ? " → Capped at {$maxPenalty}" : ''),
'minutes_late' => $minutesLate,
];
}
/**
* Calculate complaint-based penalty
*/
public function calculateComplaintPenalty(Delivery $delivery): array
{
$complaintConfig = $this->config['penalties']['complaint'] ?? [];
if (empty($complaintConfig['enabled'])) {
return ['amount' => 0, 'formula' => 'Complaint penalty disabled'];
}
// Check for verified complaints on this delivery
$hasComplaint = $delivery->ratings()
->where('overall_rating', '<=', 2)
->where('is_verified', true)
->exists();
if (! $hasComplaint) {
return ['amount' => 0, 'formula' => 'No verified complaints'];
}
$penalty = $complaintConfig['penalty_amount'] ?? 25;
return [
'amount' => round((float) $penalty, 2),
'formula' => "Verified complaint penalty: {$penalty}",
];
}
/*
|--------------------------------------------------------------------------
| WEEKLY/MONTHLY TARGET BONUSES
|--------------------------------------------------------------------------
*/
/**
* Calculate weekly target bonus
*
* FORMULA:
* tiers = [
* { min: 50, bonus: 500 },
* { min: 75, bonus: 1000 },
* { min: 100, bonus: 2000 }
* ]
* bonus = highest_matching_tier_bonus
*/
public function calculateWeeklyTargetBonus(Rider $rider): array
{
$targetConfig = $this->config['bonuses']['weekly_target'] ?? [];
if (empty($targetConfig['enabled'])) {
return ['amount' => 0, 'formula' => 'Weekly target bonus disabled', 'eligible' => false];
}
$weekStart = Carbon::now()->startOfWeek();
$weekEnd = Carbon::now()->endOfWeek();
$weeklyDeliveries = RiderEarning::where('rider_id', $rider->id)
->whereBetween('created_at', [$weekStart, $weekEnd])
->where('type', 'delivery')
->count();
$tiers = $targetConfig['tiers'] ?? [
['min' => 50, 'bonus' => 500],
['min' => 75, 'bonus' => 1000],
['min' => 100, 'bonus' => 2000],
];
// Sort tiers descending to find highest matching
usort($tiers, fn ($a, $b) => $b['min'] <=> $a['min']);
$matchedTier = null;
foreach ($tiers as $tier) {
if ($weeklyDeliveries >= $tier['min']) {
$matchedTier = $tier;
break;
}
}
if (! $matchedTier) {
$lowestTier = end($tiers);
return [
'amount' => 0,
'formula' => "Weekly deliveries ({$weeklyDeliveries}) < Minimum target ({$lowestTier['min']})",
'eligible' => false,
'current_count' => $weeklyDeliveries,
'next_target' => $lowestTier['min'],
'next_bonus' => $lowestTier['bonus'],
];
}
return [
'amount' => round((float) $matchedTier['bonus'], 2),
'formula' => "Weekly deliveries ({$weeklyDeliveries}) ≥ Target ({$matchedTier['min']}) → Bonus: {$matchedTier['bonus']}",
'eligible' => true,
'current_count' => $weeklyDeliveries,
'achieved_tier' => $matchedTier['min'],
];
}
/**
* Calculate monthly target bonus
*/
public function calculateMonthlyTargetBonus(Rider $rider): array
{
$targetConfig = $this->config['bonuses']['monthly_target'] ?? [];
if (empty($targetConfig['enabled'])) {
return ['amount' => 0, 'formula' => 'Monthly target bonus disabled', 'eligible' => false];
}
$monthStart = Carbon::now()->startOfMonth();
$monthEnd = Carbon::now()->endOfMonth();
$monthlyDeliveries = RiderEarning::where('rider_id', $rider->id)
->whereBetween('created_at', [$monthStart, $monthEnd])
->where('type', 'delivery')
->count();
$tiers = $targetConfig['tiers'] ?? [
['min' => 200, 'bonus' => 2000],
['min' => 300, 'bonus' => 4000],
['min' => 400, 'bonus' => 7000],
];
usort($tiers, fn ($a, $b) => $b['min'] <=> $a['min']);
$matchedTier = null;
foreach ($tiers as $tier) {
if ($monthlyDeliveries >= $tier['min']) {
$matchedTier = $tier;
break;
}
}
if (! $matchedTier) {
$lowestTier = end($tiers);
return [
'amount' => 0,
'formula' => "Monthly deliveries ({$monthlyDeliveries}) < Minimum target ({$lowestTier['min']})",
'eligible' => false,
'current_count' => $monthlyDeliveries,
'next_target' => $lowestTier['min'],
];
}
return [
'amount' => round((float) $matchedTier['bonus'], 2),
'formula' => "Monthly deliveries ({$monthlyDeliveries}) ≥ Target ({$matchedTier['min']}) → Bonus: {$matchedTier['bonus']}",
'eligible' => true,
'current_count' => $monthlyDeliveries,
'achieved_tier' => $matchedTier['min'],
];
}
/*
|--------------------------------------------------------------------------
| CANCELLATION PENALTY
|--------------------------------------------------------------------------
*/
/**
* Calculate cancellation penalty
*
* FORMULA:
* penalty = base_penalty × cancellation_stage_multiplier
*
* Stages:
* - before_pickup: 0.5x
* - at_restaurant: 1.0x
* - after_pickup: 2.0x
*/
public function calculateCancellationPenalty(Delivery $delivery, string $cancellationStage): array
{
$penaltyConfig = $this->config['penalties']['cancellation'] ?? [];
if (empty($penaltyConfig['enabled'])) {
return ['amount' => 0, 'formula' => 'Cancellation penalty disabled'];
}
$basePenalty = $penaltyConfig['base_penalty'] ?? 50;
$stageMultipliers = $penaltyConfig['stage_multipliers'] ?? [
'before_pickup' => 0.5,
'at_restaurant' => 1.0,
'after_pickup' => 2.0,
];
$multiplier = $stageMultipliers[$cancellationStage] ?? 1.0;
$penalty = $basePenalty * $multiplier;
return [
'amount' => round($penalty, 2),
'formula' => "Base penalty ({$basePenalty}) × Stage multiplier ({$multiplier}) = {$penalty}",
'stage' => $cancellationStage,
];
}
/*
|--------------------------------------------------------------------------
| HELPER METHODS
|--------------------------------------------------------------------------
*/
/**
* Get consecutive delivery count for a rider on a given date
*/
protected function getConsecutiveDeliveryCount(Rider $rider, Carbon $date): int
{
// Get all deliveries for the day ordered by completion time
$deliveries = RiderEarning::where('rider_id', $rider->id)
->whereDate('created_at', $date)
->where('type', 'delivery')
->orderBy('created_at')
->get();
// Count consecutive (no rejections or timeouts between them)
// This is a simplified version - in production, track rejections separately
return $deliveries->count();
}
/**
* Get rider's earning summary for a period
*/
public function getEarningSummary(Rider $rider, Carbon $startDate, Carbon $endDate): array
{
$earnings = RiderEarning::where('rider_id', $rider->id)
->whereBetween('created_at', [$startDate, $endDate])
->get();
$summary = [
'period' => [
'start' => $startDate->toDateString(),
'end' => $endDate->toDateString(),
],
'totals' => [
'deliveries' => $earnings->where('type', 'delivery')->count(),
'base_earnings' => $earnings->where('type', 'delivery')->sum('amount'),
'bonuses' => $earnings->where('type', 'bonus')->sum('amount'),
'tips' => $earnings->where('type', 'tip')->sum('amount'),
'penalties' => $earnings->where('type', 'penalty')->sum('amount'),
],
'breakdown_by_type' => [],
];
// Group by subtype
foreach ($earnings->groupBy('sub_type') as $subType => $group) {
$summary['breakdown_by_type'][$subType] = [
'count' => $group->count(),
'total' => $group->sum('amount'),
];
}
// Calculate net
$summary['totals']['gross'] = $summary['totals']['base_earnings']
+ $summary['totals']['bonuses']
+ $summary['totals']['tips'];
$summary['totals']['net'] = $summary['totals']['gross']
- $summary['totals']['penalties'];
return $summary;
}
}

View File

@@ -0,0 +1,579 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Services\Earnings;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\RestaurantDelivery\Models\Delivery;
use Modules\RestaurantDelivery\Models\Rider;
use Modules\RestaurantDelivery\Models\RiderBonus;
use Modules\RestaurantDelivery\Models\RiderEarning;
use Modules\RestaurantDelivery\Models\RiderPayout;
class EarningsService
{
protected array $config;
public function __construct()
{
$this->config = config('restaurant-delivery.earnings');
}
/*
|--------------------------------------------------------------------------
| Delivery Earnings
|--------------------------------------------------------------------------
*/
/**
* Create earning record for a completed delivery
*/
public function createDeliveryEarning(Delivery $delivery): ?RiderEarning
{
if (! $delivery->rider) {
return null;
}
try {
$earning = DB::transaction(function () use ($delivery) {
// Create base earning
$earning = RiderEarning::createForDelivery($delivery);
if (! $earning) {
return null;
}
// Check and apply bonuses
$this->applyDeliveryBonuses($delivery, $earning);
// Auto-confirm if configured
$earning->confirm();
return $earning;
});
Log::info('Delivery earning created', [
'earning_id' => $earning?->id,
'delivery_id' => $delivery->id,
'amount' => $earning?->net_amount,
]);
return $earning;
} catch (\Exception $e) {
Log::error('Failed to create delivery earning', [
'delivery_id' => $delivery->id,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Apply bonuses to a delivery earning
*/
protected function applyDeliveryBonuses(Delivery $delivery, RiderEarning $earning): void
{
$rider = $delivery->rider;
$bonuses = $this->config['bonuses'];
// Peak hour bonus
if ($bonuses['peak_hour_bonus']['enabled'] && $this->isPeakHour()) {
$this->createBonus(
$rider,
'peak_hour',
$bonuses['peak_hour_bonus']['amount'],
'Peak hour bonus',
$delivery
);
}
// Rating bonus
if ($bonuses['rating_bonus']['enabled'] && $rider->rating >= $bonuses['rating_bonus']['min_rating']) {
$this->createBonus(
$rider,
'rating',
$bonuses['rating_bonus']['amount'],
'High rating bonus',
$delivery
);
}
// Consecutive delivery bonus
if ($bonuses['consecutive_delivery_bonus']['enabled']) {
$consecutiveCount = $this->getConsecutiveDeliveryCount($rider);
if ($consecutiveCount >= $bonuses['consecutive_delivery_bonus']['threshold']) {
$this->createBonus(
$rider,
'consecutive',
$bonuses['consecutive_delivery_bonus']['amount'],
"Consecutive delivery bonus ({$consecutiveCount} deliveries)",
$delivery
);
}
}
}
/*
|--------------------------------------------------------------------------
| Bonus Management
|--------------------------------------------------------------------------
*/
/**
* Create a bonus earning
*/
public function createBonus(
Rider $rider,
string $bonusType,
float $amount,
string $description,
?Delivery $delivery = null
): RiderEarning {
return RiderEarning::createBonus($rider, $bonusType, $amount, $description, $delivery);
}
/**
* Check and apply weekly target bonus
*/
public function checkWeeklyTargetBonus(Rider $rider): ?RiderEarning
{
$bonusConfig = $this->config['bonuses']['weekly_target_bonus'];
if (! $bonusConfig['enabled']) {
return null;
}
$weekStart = now()->startOfWeek();
$weekEnd = now()->endOfWeek();
// Get weekly delivery count
$weeklyDeliveries = $rider->deliveries()
->completed()
->whereBetween('delivered_at', [$weekStart, $weekEnd])
->count();
// Check if already received bonus this week
$existingBonus = RiderEarning::where('rider_id', $rider->id)
->where('type', 'bonus')
->where('sub_type', 'weekly_target')
->whereBetween('earning_date', [$weekStart, $weekEnd])
->exists();
if ($existingBonus) {
return null;
}
// Find applicable bonus tier
$targets = collect($bonusConfig['targets'])->sortByDesc('deliveries');
foreach ($targets as $target) {
if ($weeklyDeliveries >= $target['deliveries']) {
return $this->createBonus(
$rider,
'weekly_target',
$target['bonus'],
"Weekly target bonus ({$weeklyDeliveries} deliveries)"
);
}
}
return null;
}
/**
* Get available bonuses for a rider
*/
public function getAvailableBonuses(Rider $rider): array
{
return RiderBonus::where('is_active', true)
->where(function ($q) {
$q->whereNull('valid_from')
->orWhere('valid_from', '<=', now());
})
->where(function ($q) {
$q->whereNull('valid_until')
->orWhere('valid_until', '>=', now());
})
->where(function ($q) use ($rider) {
$q->whereNull('min_rider_rating')
->orWhere('min_rider_rating', '<=', $rider->rating);
})
->get()
->filter(fn ($bonus) => $this->riderEligibleForBonus($rider, $bonus))
->map(fn ($bonus) => [
'id' => $bonus->id,
'name' => $bonus->name,
'type' => $bonus->type,
'amount' => $bonus->amount,
'description' => $bonus->description,
])
->values()
->toArray();
}
protected function riderEligibleForBonus(Rider $rider, RiderBonus $bonus): bool
{
// Check rider type
if ($bonus->applicable_rider_types) {
if (! in_array($rider->type->value, $bonus->applicable_rider_types)) {
return false;
}
}
// Check zones
if ($bonus->applicable_zones && $rider->assigned_zones) {
$intersection = array_intersect($bonus->applicable_zones, $rider->assigned_zones);
if (empty($intersection)) {
return false;
}
}
// Check usage limits
if ($bonus->max_uses_per_rider) {
$riderUses = RiderEarning::where('rider_id', $rider->id)
->where('sub_type', $bonus->code)
->count();
if ($riderUses >= $bonus->max_uses_per_rider) {
return false;
}
}
return true;
}
/*
|--------------------------------------------------------------------------
| Penalty Management
|--------------------------------------------------------------------------
*/
/**
* Create a penalty
*/
public function createPenalty(
Rider $rider,
string $penaltyType,
float $amount,
string $description,
?Delivery $delivery = null
): RiderEarning {
return RiderEarning::createPenalty($rider, $penaltyType, $amount, $description, $delivery);
}
/**
* Apply cancellation penalty
*/
public function applyCancellationPenalty(Rider $rider, Delivery $delivery): ?RiderEarning
{
$penaltyConfig = $this->config['penalties']['cancellation'];
if (! $penaltyConfig['enabled']) {
return null;
}
// Check free cancellations
$todayCancellations = RiderEarning::where('rider_id', $rider->id)
->where('type', 'penalty')
->where('sub_type', 'cancellation')
->whereDate('earning_date', now()->toDateString())
->count();
if ($todayCancellations < $penaltyConfig['free_cancellations']) {
return null;
}
return $this->createPenalty(
$rider,
'cancellation',
$penaltyConfig['amount'],
"Cancellation penalty for delivery #{$delivery->tracking_code}",
$delivery
);
}
/**
* Apply late delivery penalty
*/
public function applyLateDeliveryPenalty(Rider $rider, Delivery $delivery): ?RiderEarning
{
$penaltyConfig = $this->config['penalties']['late_delivery'];
if (! $penaltyConfig['enabled']) {
return null;
}
$delayMinutes = $delivery->getDelayMinutes();
if (! $delayMinutes || $delayMinutes < $penaltyConfig['threshold']) {
return null;
}
return $this->createPenalty(
$rider,
'late_delivery',
$penaltyConfig['amount'],
"Late delivery penalty ({$delayMinutes} min late)",
$delivery
);
}
/*
|--------------------------------------------------------------------------
| Payout Management
|--------------------------------------------------------------------------
*/
/**
* Generate payouts for all eligible riders
*/
public function generatePayouts(string $periodType = 'weekly'): array
{
$results = [
'generated' => 0,
'skipped' => 0,
'errors' => 0,
];
// Get period dates
[$periodStart, $periodEnd] = $this->getPayoutPeriodDates($periodType);
// Get all active riders
$riders = Rider::active()->get();
foreach ($riders as $rider) {
try {
$payout = RiderPayout::createForRider($rider, $periodStart, $periodEnd, $periodType);
if ($payout) {
$results['generated']++;
} else {
$results['skipped']++;
}
} catch (\Exception $e) {
$results['errors']++;
Log::error('Failed to generate payout', [
'rider_id' => $rider->id,
'error' => $e->getMessage(),
]);
}
}
return $results;
}
/**
* Process a payout
*/
public function processPayout(RiderPayout $payout, array $paymentDetails): bool
{
try {
DB::transaction(function () use ($payout, $paymentDetails) {
$payout->markAsCompleted(
$paymentDetails['reference'],
$paymentDetails
);
});
Log::info('Payout processed', [
'payout_id' => $payout->id,
'amount' => $payout->net_amount,
'reference' => $paymentDetails['reference'],
]);
return true;
} catch (\Exception $e) {
Log::error('Failed to process payout', [
'payout_id' => $payout->id,
'error' => $e->getMessage(),
]);
return false;
}
}
protected function getPayoutPeriodDates(string $periodType): array
{
return match ($periodType) {
'daily' => [
now()->subDay()->startOfDay(),
now()->subDay()->endOfDay(),
],
'weekly' => [
now()->subWeek()->startOfWeek(),
now()->subWeek()->endOfWeek(),
],
'monthly' => [
now()->subMonth()->startOfMonth(),
now()->subMonth()->endOfMonth(),
],
default => [
now()->subWeek()->startOfWeek(),
now()->subWeek()->endOfWeek(),
],
};
}
/*
|--------------------------------------------------------------------------
| Statistics
|--------------------------------------------------------------------------
*/
/**
* Get rider earnings summary
*/
public function getRiderEarningsSummary(Rider $rider, ?string $period = null): array
{
$query = $rider->earnings()->confirmed();
$startDate = match ($period) {
'today' => now()->startOfDay(),
'week' => now()->startOfWeek(),
'month' => now()->startOfMonth(),
'year' => now()->startOfYear(),
default => null,
};
if ($startDate) {
$query->where('earning_date', '>=', $startDate);
}
$earnings = $query->get();
$deliveryEarnings = $earnings->where('type', 'delivery');
$tips = $earnings->where('type', 'tip');
$bonuses = $earnings->where('type', 'bonus');
$penalties = $earnings->where('type', 'penalty');
return [
'total_earnings' => $earnings->sum('net_amount'),
'delivery_earnings' => [
'amount' => $deliveryEarnings->sum('net_amount'),
'count' => $deliveryEarnings->count(),
],
'tips' => [
'amount' => $tips->sum('net_amount'),
'count' => $tips->count(),
],
'bonuses' => [
'amount' => $bonuses->sum('net_amount'),
'count' => $bonuses->count(),
'breakdown' => $bonuses->groupBy('sub_type')->map->sum('net_amount')->toArray(),
],
'penalties' => [
'amount' => abs($penalties->sum('net_amount')),
'count' => $penalties->count(),
],
'pending_payout' => $this->getPendingPayoutAmount($rider),
];
}
/**
* Get pending payout amount
*/
public function getPendingPayoutAmount(Rider $rider): float
{
return $rider->earnings()
->payable()
->sum('net_amount');
}
/**
* Get earnings history
*/
public function getEarningsHistory(Rider $rider, int $limit = 50, ?string $type = null): array
{
$query = $rider->earnings()->confirmed()->orderBy('earning_date', 'desc');
if ($type) {
$query->where('type', $type);
}
return $query->limit($limit)->get()->map(fn ($earning) => [
'id' => $earning->uuid,
'type' => $earning->type,
'sub_type' => $earning->sub_type,
'amount' => $earning->net_amount,
'description' => $earning->description,
'delivery_code' => $earning->delivery?->tracking_code,
'date' => $earning->earning_date->format('Y-m-d'),
'is_paid' => $earning->is_paid,
])->toArray();
}
/**
* Get payout history
*/
public function getPayoutHistory(Rider $rider, int $limit = 20): array
{
return $rider->payouts()
->orderBy('created_at', 'desc')
->limit($limit)
->get()
->map(fn ($payout) => [
'id' => $payout->uuid,
'payout_number' => $payout->payout_number,
'period' => $payout->period_start->format('M d').' - '.$payout->period_end->format('M d, Y'),
'amount' => $payout->net_amount,
'deliveries' => $payout->total_deliveries,
'status' => $payout->status,
'payment_method' => $payout->payment_method,
'paid_at' => $payout->paid_at?->format('Y-m-d H:i'),
])
->toArray();
}
/*
|--------------------------------------------------------------------------
| Helper Methods
|--------------------------------------------------------------------------
*/
protected 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::createFromTimeString($slot['start']);
$end = Carbon::createFromTimeString($slot['end']);
if ($now->between($start, $end)) {
return true;
}
}
return false;
}
protected function getConsecutiveDeliveryCount(Rider $rider): int
{
// Get today's deliveries in chronological order
$deliveries = $rider->deliveries()
->whereDate('delivered_at', now()->toDateString())
->orderBy('delivered_at', 'desc')
->get();
$consecutive = 0;
foreach ($deliveries as $delivery) {
if ($delivery->isCompleted()) {
$consecutive++;
} else {
break;
}
}
return $consecutive;
}
}

View File

@@ -0,0 +1,597 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Services\Firebase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Kreait\Firebase\Auth;
use Kreait\Firebase\Auth\CreateRequest;
use Kreait\Firebase\Exception\Auth\FailedToVerifyToken;
use Kreait\Firebase\Exception\FirebaseException;
use Kreait\Firebase\Factory;
/**
* Firebase Authentication Service
*
* Handles user authentication, custom tokens, and claims for role-based access.
*/
class FirebaseAuthService
{
protected ?Auth $auth = null;
protected Factory $factory;
protected array $config;
public function __construct()
{
$this->config = config('restaurant-delivery.firebase');
$this->initializeFirebase();
}
protected function initializeFirebase(): void
{
if (! $this->config['enabled']) {
return;
}
try {
$this->factory = (new Factory)
->withServiceAccount($this->config['credentials_path']);
} catch (\Exception $e) {
Log::error('Firebase Auth initialization failed', [
'error' => $e->getMessage(),
]);
}
}
public function getAuth(): ?Auth
{
if (! $this->auth && isset($this->factory)) {
$this->auth = $this->factory->createAuth();
}
return $this->auth;
}
/*
|--------------------------------------------------------------------------
| Custom Token Generation
|--------------------------------------------------------------------------
*/
/**
* Create a custom token for a user with custom claims
*
* @param string $uid User ID
* @param array $claims Custom claims (role, permissions, etc.)
* @return string|null JWT token
*/
public function createCustomToken(string $uid, array $claims = []): ?string
{
try {
$auth = $this->getAuth();
if (! $auth) {
return null;
}
$customToken = $auth->createCustomToken($uid, $claims);
return $customToken->toString();
} catch (FirebaseException $e) {
Log::error('Failed to create custom token', [
'uid' => $uid,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Create a custom token for a rider with appropriate claims
*/
public function createRiderToken(string $riderId, array $additionalClaims = []): ?string
{
$claims = array_merge([
'role' => 'rider',
'rider_id' => $riderId,
'can_update_location' => true,
'can_accept_deliveries' => true,
], $additionalClaims);
return $this->createCustomToken($riderId, $claims);
}
/**
* Create a custom token for a restaurant with appropriate claims
*/
public function createRestaurantToken(string $restaurantId, array $additionalClaims = []): ?string
{
$claims = array_merge([
'role' => 'restaurant',
'restaurant_id' => $restaurantId,
'restaurant' => true,
'can_create_deliveries' => true,
'can_assign_riders' => true,
], $additionalClaims);
return $this->createCustomToken($restaurantId, $claims);
}
/**
* Create a custom token for a customer with appropriate claims
*/
public function createCustomerToken(string $customerId, array $additionalClaims = []): ?string
{
$claims = array_merge([
'role' => 'customer',
'customer_id' => $customerId,
'can_track_deliveries' => true,
'can_rate_riders' => true,
], $additionalClaims);
return $this->createCustomToken($customerId, $claims);
}
/**
* Create a custom token for an admin with full permissions
*/
public function createAdminToken(string $adminId, array $additionalClaims = []): ?string
{
$claims = array_merge([
'role' => 'admin',
'admin' => true,
'admin_id' => $adminId,
'full_access' => true,
], $additionalClaims);
return $this->createCustomToken($adminId, $claims);
}
/*
|--------------------------------------------------------------------------
| Token Verification
|--------------------------------------------------------------------------
*/
/**
* Verify an ID token and return the decoded claims
*/
public function verifyIdToken(string $idToken): ?array
{
try {
$auth = $this->getAuth();
if (! $auth) {
return null;
}
$verifiedToken = $auth->verifyIdToken($idToken);
return [
'uid' => $verifiedToken->claims()->get('sub'),
'email' => $verifiedToken->claims()->get('email'),
'email_verified' => $verifiedToken->claims()->get('email_verified'),
'claims' => $verifiedToken->claims()->all(),
];
} catch (FailedToVerifyToken $e) {
Log::warning('Failed to verify ID token', [
'error' => $e->getMessage(),
]);
return null;
} catch (FirebaseException $e) {
Log::error('Firebase error during token verification', [
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Check if token has specific role
*/
public function hasRole(string $idToken, string $role): bool
{
$decoded = $this->verifyIdToken($idToken);
if (! $decoded) {
return false;
}
return ($decoded['claims']['role'] ?? null) === $role;
}
/**
* Check if token has admin access
*/
public function isAdmin(string $idToken): bool
{
$decoded = $this->verifyIdToken($idToken);
if (! $decoded) {
return false;
}
return ($decoded['claims']['admin'] ?? false) === true;
}
/*
|--------------------------------------------------------------------------
| Custom Claims Management
|--------------------------------------------------------------------------
*/
/**
* Set custom claims for a user
*/
public function setCustomClaims(string $uid, array $claims): bool
{
try {
$auth = $this->getAuth();
if (! $auth) {
return false;
}
$auth->setCustomUserClaims($uid, $claims);
// Invalidate cached claims
Cache::forget("firebase_claims_{$uid}");
return true;
} catch (FirebaseException $e) {
Log::error('Failed to set custom claims', [
'uid' => $uid,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Get user's custom claims
*/
public function getCustomClaims(string $uid): array
{
// Try cache first
$cached = Cache::get("firebase_claims_{$uid}");
if ($cached !== null) {
return $cached;
}
try {
$auth = $this->getAuth();
if (! $auth) {
return [];
}
$user = $auth->getUser($uid);
$claims = $user->customClaims ?? [];
// Cache for 5 minutes
Cache::put("firebase_claims_{$uid}", $claims, 300);
return $claims;
} catch (FirebaseException $e) {
Log::error('Failed to get custom claims', [
'uid' => $uid,
'error' => $e->getMessage(),
]);
return [];
}
}
/**
* Add role to user
*/
public function addRole(string $uid, string $role): bool
{
$claims = $this->getCustomClaims($uid);
$claims['role'] = $role;
// Add role-specific flags
switch ($role) {
case 'admin':
$claims['admin'] = true;
break;
case 'restaurant':
$claims['restaurant'] = true;
break;
case 'rider':
$claims['rider'] = true;
break;
case 'customer':
$claims['customer'] = true;
break;
}
return $this->setCustomClaims($uid, $claims);
}
/**
* Remove role from user
*/
public function removeRole(string $uid, string $role): bool
{
$claims = $this->getCustomClaims($uid);
if (isset($claims['role']) && $claims['role'] === $role) {
unset($claims['role']);
}
// Remove role-specific flags
unset($claims[$role]);
return $this->setCustomClaims($uid, $claims);
}
/*
|--------------------------------------------------------------------------
| User Management
|--------------------------------------------------------------------------
*/
/**
* Create a new Firebase user
*/
public function createUser(array $properties): ?array
{
try {
$auth = $this->getAuth();
if (! $auth) {
return null;
}
$request = CreateRequest::new();
if (isset($properties['email'])) {
$request = $request->withEmail($properties['email']);
}
if (isset($properties['password'])) {
$request = $request->withPassword($properties['password']);
}
if (isset($properties['phone'])) {
$request = $request->withPhoneNumber($properties['phone']);
}
if (isset($properties['display_name'])) {
$request = $request->withDisplayName($properties['display_name']);
}
if (isset($properties['photo_url'])) {
$request = $request->withPhotoUrl($properties['photo_url']);
}
if (isset($properties['disabled'])) {
$request = $properties['disabled'] ? $request->markAsDisabled() : $request->markAsEnabled();
}
if (isset($properties['email_verified'])) {
$request = $properties['email_verified'] ? $request->markEmailAsVerified() : $request->markEmailAsUnverified();
}
if (isset($properties['uid'])) {
$request = $request->withUid($properties['uid']);
}
$user = $auth->createUser($request);
return [
'uid' => $user->uid,
'email' => $user->email,
'phone' => $user->phoneNumber,
'display_name' => $user->displayName,
'photo_url' => $user->photoUrl,
'disabled' => $user->disabled,
'email_verified' => $user->emailVerified,
];
} catch (FirebaseException $e) {
Log::error('Failed to create Firebase user', [
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Get user by UID
*/
public function getUser(string $uid): ?array
{
try {
$auth = $this->getAuth();
if (! $auth) {
return null;
}
$user = $auth->getUser($uid);
return [
'uid' => $user->uid,
'email' => $user->email,
'phone' => $user->phoneNumber,
'display_name' => $user->displayName,
'photo_url' => $user->photoUrl,
'disabled' => $user->disabled,
'email_verified' => $user->emailVerified,
'custom_claims' => $user->customClaims,
'created_at' => $user->metadata->createdAt,
'last_login' => $user->metadata->lastLoginAt,
];
} catch (FirebaseException $e) {
Log::error('Failed to get Firebase user', [
'uid' => $uid,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Get user by email
*/
public function getUserByEmail(string $email): ?array
{
try {
$auth = $this->getAuth();
if (! $auth) {
return null;
}
$user = $auth->getUserByEmail($email);
return [
'uid' => $user->uid,
'email' => $user->email,
'phone' => $user->phoneNumber,
'display_name' => $user->displayName,
'disabled' => $user->disabled,
];
} catch (FirebaseException $e) {
return null;
}
}
/**
* Update user properties
*/
public function updateUser(string $uid, array $properties): bool
{
try {
$auth = $this->getAuth();
if (! $auth) {
return false;
}
$auth->updateUser($uid, $properties);
return true;
} catch (FirebaseException $e) {
Log::error('Failed to update Firebase user', [
'uid' => $uid,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Disable a user
*/
public function disableUser(string $uid): bool
{
return $this->updateUser($uid, ['disabled' => true]);
}
/**
* Enable a user
*/
public function enableUser(string $uid): bool
{
return $this->updateUser($uid, ['disabled' => false]);
}
/**
* Delete a user
*/
public function deleteUser(string $uid): bool
{
try {
$auth = $this->getAuth();
if (! $auth) {
return false;
}
$auth->deleteUser($uid);
// Clear cached claims
Cache::forget("firebase_claims_{$uid}");
return true;
} catch (FirebaseException $e) {
Log::error('Failed to delete Firebase user', [
'uid' => $uid,
'error' => $e->getMessage(),
]);
return false;
}
}
/*
|--------------------------------------------------------------------------
| Password Management
|--------------------------------------------------------------------------
*/
/**
* Generate password reset link
*/
public function generatePasswordResetLink(string $email): ?string
{
try {
$auth = $this->getAuth();
if (! $auth) {
return null;
}
return $auth->getPasswordResetLink($email);
} catch (FirebaseException $e) {
Log::error('Failed to generate password reset link', [
'email' => $email,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Generate email verification link
*/
public function generateEmailVerificationLink(string $email): ?string
{
try {
$auth = $this->getAuth();
if (! $auth) {
return null;
}
return $auth->getEmailVerificationLink($email);
} catch (FirebaseException $e) {
Log::error('Failed to generate email verification link', [
'email' => $email,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Revoke all refresh tokens for a user
*/
public function revokeRefreshTokens(string $uid): bool
{
try {
$auth = $this->getAuth();
if (! $auth) {
return false;
}
$auth->revokeRefreshTokens($uid);
return true;
} catch (FirebaseException $e) {
Log::error('Failed to revoke refresh tokens', [
'uid' => $uid,
'error' => $e->getMessage(),
]);
return false;
}
}
}

View File

@@ -0,0 +1,885 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Services\Firebase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Kreait\Firebase\Database;
use Kreait\Firebase\Exception\FirebaseException;
use Kreait\Firebase\Factory;
use Kreait\Firebase\Messaging;
use Kreait\Firebase\Messaging\CloudMessage;
use Kreait\Firebase\Messaging\Notification;
class FirebaseService
{
protected ?Database $database = null;
protected ?Messaging $messaging = null;
protected ?Factory $factory = null; // nullable
protected array $config;
public function __construct()
{
$this->config = config('restaurant-delivery');
$this->initializeFirebase();
}
protected function initializeFirebase(): void
{
if (! $this->config['enabled']) {
return;
}
try {
$this->factory = (new Factory)
->withServiceAccount($this->config['credentials_path'])
->withDatabaseUri($this->config['database_url']);
} catch (\Exception $e) {
Log::error('Firebase initialization failed', [
'error' => $e->getMessage(),
]);
}
}
public function getDatabase(): ?Database
{
if (! $this->database && $this->factory) {
$this->database = $this->factory->createDatabase();
}
return $this->database;
}
public function getMessaging(): ?Messaging
{
if (! $this->messaging && $this->factory) {
$this->messaging = $this->factory->createMessaging();
}
return $this->messaging;
}
/*
|--------------------------------------------------------------------------
| Rider Location Operations
|--------------------------------------------------------------------------
*/
/**
* Update rider's live location in Firebase
*/
public function updateRiderLocation(
int|string $riderId,
float $latitude,
float $longitude,
?float $speed = null,
?float $bearing = null,
?float $accuracy = null
): bool {
try {
$database = $this->getDatabase();
if (! $database) {
return false;
}
$path = str_replace('{rider_id}', (string) $riderId, $this->config['paths']['riders_location']);
$locationData = [
'lat' => $latitude,
'lng' => $longitude,
'speed' => $speed ?? 0,
'bearing' => $bearing ?? 0,
'accuracy' => $accuracy ?? 0,
'timestamp' => time() * 1000, // JavaScript timestamp
'updated_at' => now()->toIso8601String(),
];
$database->getReference($path)->set($locationData);
// Cache locally for quick access
Cache::put(
"rider_location_{$riderId}",
$locationData,
config('restaurant-delivery.cache.ttl.rider_location')
);
return true;
} catch (FirebaseException $e) {
Log::error('Failed to update rider location in Firebase', [
'rider_id' => $riderId,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Get rider's current location from Firebase
*/
public function getRiderLocation(int|string $riderId): ?array
{
// Try cache first
$cached = Cache::get("rider_location_{$riderId}");
if ($cached) {
return $cached;
}
try {
$database = $this->getDatabase();
if (! $database) {
return null;
}
$path = str_replace('{rider_id}', (string) $riderId, $this->config['paths']['riders_location']);
$snapshot = $database->getReference($path)->getSnapshot();
return $snapshot->exists() ? $snapshot->getValue() : null;
} catch (FirebaseException $e) {
Log::error('Failed to get rider location from Firebase', [
'rider_id' => $riderId,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Check if rider location is stale
*/
public function isRiderLocationStale(int|string $riderId): bool
{
$location = $this->getRiderLocation($riderId);
if (! $location || ! isset($location['timestamp'])) {
return true;
}
$lastUpdate = (int) ($location['timestamp'] / 1000); // Convert from JS timestamp
$staleThreshold = $this->config['location']['stale_threshold'];
return (time() - $lastUpdate) > $staleThreshold;
}
/**
* Check if rider is considered offline
*/
public function isRiderOffline(int|string $riderId): bool
{
$location = $this->getRiderLocation($riderId);
if (! $location || ! isset($location['timestamp'])) {
return true;
}
$lastUpdate = (int) ($location['timestamp'] / 1000);
$offlineThreshold = $this->config['location']['offline_threshold'];
return (time() - $lastUpdate) > $offlineThreshold;
}
/*
|--------------------------------------------------------------------------
| Rider Status Operations
|--------------------------------------------------------------------------
*/
/**
* Update rider online/offline status
*/
public function updateRiderStatus(
int|string $riderId,
string $status,
?array $metadata = null
): bool {
try {
$database = $this->getDatabase();
if (! $database) {
return false;
}
$path = str_replace('{rider_id}', (string) $riderId, $this->config['paths']['rider_status']);
$statusData = [
'status' => $status, // online, offline, busy, on_delivery
'updated_at' => now()->toIso8601String(),
'timestamp' => time() * 1000,
];
if ($metadata) {
$statusData = array_merge($statusData, $metadata);
}
$database->getReference($path)->set($statusData);
return true;
} catch (FirebaseException $e) {
Log::error('Failed to update rider status in Firebase', [
'rider_id' => $riderId,
'status' => $status,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Get rider's current status
*/
public function getRiderStatus(int|string $riderId): ?array
{
try {
$database = $this->getDatabase();
if (! $database) {
return null;
}
$path = str_replace('{rider_id}', (string) $riderId, $this->config['paths']['rider_status']);
$snapshot = $database->getReference($path)->getSnapshot();
return $snapshot->exists() ? $snapshot->getValue() : null;
} catch (FirebaseException $e) {
Log::error('Failed to get rider status from Firebase', [
'rider_id' => $riderId,
'error' => $e->getMessage(),
]);
return null;
}
}
/*
|--------------------------------------------------------------------------
| Delivery Tracking Operations
|--------------------------------------------------------------------------
*/
/**
* Initialize delivery tracking in Firebase
*/
public function initializeDeliveryTracking(
int|string $deliveryId,
array $deliveryData
): bool {
try {
$database = $this->getDatabase();
if (! $database) {
return false;
}
$path = str_replace('{delivery_id}', (string) $deliveryId, $this->config['paths']['delivery_tracking']);
$trackingData = [
'delivery_id' => $deliveryId,
'status' => $deliveryData['status'] ?? 'pending',
'rider_id' => $deliveryData['rider_id'] ?? null,
'restaurant' => [
'id' => $deliveryData['restaurant_id'] ?? null,
'name' => $deliveryData['restaurant_name'] ?? null,
'lat' => $deliveryData['pickup_latitude'],
'lng' => $deliveryData['pickup_longitude'],
'address' => $deliveryData['pickup_address'] ?? null,
],
'customer' => [
'lat' => $deliveryData['drop_latitude'],
'lng' => $deliveryData['drop_longitude'],
'address' => $deliveryData['drop_address'] ?? null,
],
'rider_location' => null,
'route' => $deliveryData['route'] ?? null,
'eta' => $deliveryData['eta'] ?? null,
'distance' => $deliveryData['distance'] ?? null,
'created_at' => now()->toIso8601String(),
'updated_at' => now()->toIso8601String(),
];
$database->getReference($path)->set($trackingData);
return true;
} catch (FirebaseException $e) {
Log::error('Failed to initialize delivery tracking in Firebase', [
'delivery_id' => $deliveryId,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Update delivery tracking with rider location
*/
public function updateDeliveryTracking(
int|string $deliveryId,
array $updates
): bool {
try {
$database = $this->getDatabase();
if (! $database) {
return false;
}
$path = str_replace('{delivery_id}', (string) $deliveryId, $this->config['paths']['delivery_tracking']);
$updates['updated_at'] = now()->toIso8601String();
$database->getReference($path)->update($updates);
return true;
} catch (FirebaseException $e) {
Log::error('Failed to update delivery tracking in Firebase', [
'delivery_id' => $deliveryId,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Update delivery status in Firebase
*/
public function updateDeliveryStatus(
int|string $deliveryId,
string $status,
?array $metadata = null
): bool {
try {
$database = $this->getDatabase();
if (! $database) {
return false;
}
$statusPath = str_replace('{delivery_id}', (string) $deliveryId, $this->config['paths']['delivery_status']);
$trackingPath = str_replace('{delivery_id}', (string) $deliveryId, $this->config['paths']['delivery_tracking']);
$statusConfig = config("restaurant-delivery.delivery_flow.statuses.{$status}");
$statusData = [
'status' => $status,
'label' => $statusConfig['label'] ?? $status,
'description' => $statusConfig['description'] ?? null,
'color' => $statusConfig['color'] ?? '#6B7280',
'timestamp' => time() * 1000,
'updated_at' => now()->toIso8601String(),
];
if ($metadata) {
$statusData = array_merge($statusData, $metadata);
}
// Update both status and tracking paths
$database->getReference($statusPath)->set($statusData);
$database->getReference($trackingPath.'/status')->set($status);
$database->getReference($trackingPath.'/status_data')->set($statusData);
return true;
} catch (FirebaseException $e) {
Log::error('Failed to update delivery status in Firebase', [
'delivery_id' => $deliveryId,
'status' => $status,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Get delivery tracking data
*/
public function getDeliveryTracking(int|string $deliveryId): ?array
{
try {
$database = $this->getDatabase();
if (! $database) {
return null;
}
$path = str_replace('{delivery_id}', (string) $deliveryId, $this->config['paths']['delivery_tracking']);
$snapshot = $database->getReference($path)->getSnapshot();
return $snapshot->exists() ? $snapshot->getValue() : null;
} catch (FirebaseException $e) {
Log::error('Failed to get delivery tracking from Firebase', [
'delivery_id' => $deliveryId,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Update rider location for a specific delivery
*/
public function updateDeliveryRiderLocation(
int|string $deliveryId,
float $latitude,
float $longitude,
?float $speed = null,
?float $bearing = null,
?float $eta = null,
?float $remainingDistance = null
): bool {
try {
$database = $this->getDatabase();
if (! $database) {
return false;
}
$path = str_replace('{delivery_id}', (string) $deliveryId, $this->config['paths']['delivery_tracking']);
$locationData = [
'rider_location' => [
'lat' => $latitude,
'lng' => $longitude,
'speed' => $speed ?? 0,
'bearing' => $bearing ?? 0,
'timestamp' => time() * 1000,
],
'eta' => $eta,
'remaining_distance' => $remainingDistance,
'updated_at' => now()->toIso8601String(),
];
$database->getReference($path)->update($locationData);
return true;
} catch (FirebaseException $e) {
Log::error('Failed to update delivery rider location in Firebase', [
'delivery_id' => $deliveryId,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Store route polyline for delivery
*/
public function updateDeliveryRoute(
int|string $deliveryId,
array $route
): bool {
try {
$database = $this->getDatabase();
if (! $database) {
return false;
}
$path = str_replace('{delivery_id}', (string) $deliveryId, $this->config['paths']['delivery_tracking']);
$database->getReference($path.'/route')->set($route);
return true;
} catch (FirebaseException $e) {
Log::error('Failed to update delivery route in Firebase', [
'delivery_id' => $deliveryId,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Remove delivery tracking data (cleanup after delivery)
*/
public function removeDeliveryTracking(int|string $deliveryId): bool
{
try {
$database = $this->getDatabase();
if (! $database) {
return false;
}
$trackingPath = str_replace('{delivery_id}', (string) $deliveryId, $this->config['paths']['delivery_tracking']);
$statusPath = str_replace('{delivery_id}', (string) $deliveryId, $this->config['paths']['delivery_status']);
$database->getReference($trackingPath)->remove();
$database->getReference($statusPath)->remove();
return true;
} catch (FirebaseException $e) {
Log::error('Failed to remove delivery tracking from Firebase', [
'delivery_id' => $deliveryId,
'error' => $e->getMessage(),
]);
return false;
}
}
/*
|--------------------------------------------------------------------------
| Rider Assignment Operations
|--------------------------------------------------------------------------
*/
/**
* Add delivery to rider's assignment list
*/
public function addRiderAssignment(
int|string $riderId,
int|string $deliveryId,
array $deliveryInfo
): bool {
try {
$database = $this->getDatabase();
if (! $database) {
return false;
}
$path = str_replace('{rider_id}', (string) $riderId, $this->config['paths']['rider_assignments']);
$assignmentData = [
'delivery_id' => $deliveryId,
'status' => 'assigned',
'restaurant' => $deliveryInfo['restaurant'] ?? null,
'customer_address' => $deliveryInfo['customer_address'] ?? null,
'pickup_location' => $deliveryInfo['pickup_location'] ?? null,
'drop_location' => $deliveryInfo['drop_location'] ?? null,
'assigned_at' => now()->toIso8601String(),
'timestamp' => time() * 1000,
];
$database->getReference($path.'/'.$deliveryId)->set($assignmentData);
return true;
} catch (FirebaseException $e) {
Log::error('Failed to add rider assignment in Firebase', [
'rider_id' => $riderId,
'delivery_id' => $deliveryId,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Remove delivery from rider's assignment list
*/
public function removeRiderAssignment(
int|string $riderId,
int|string $deliveryId
): bool {
try {
$database = $this->getDatabase();
if (! $database) {
return false;
}
$path = str_replace('{rider_id}', (string) $riderId, $this->config['paths']['rider_assignments']);
$database->getReference($path.'/'.$deliveryId)->remove();
return true;
} catch (FirebaseException $e) {
Log::error('Failed to remove rider assignment from Firebase', [
'rider_id' => $riderId,
'delivery_id' => $deliveryId,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Get all active assignments for a rider
*/
public function getRiderAssignments(int|string $riderId): array
{
try {
$database = $this->getDatabase();
if (! $database) {
return [];
}
$path = str_replace('{rider_id}', (string) $riderId, $this->config['paths']['rider_assignments']);
$snapshot = $database->getReference($path)->getSnapshot();
return $snapshot->exists() ? $snapshot->getValue() : [];
} catch (FirebaseException $e) {
Log::error('Failed to get rider assignments from Firebase', [
'rider_id' => $riderId,
'error' => $e->getMessage(),
]);
return [];
}
}
/*
|--------------------------------------------------------------------------
| Push Notifications
|--------------------------------------------------------------------------
*/
/**
* Send push notification to a device
*/
public function sendPushNotification(
string $token,
string $title,
string $body,
?array $data = null
): bool {
try {
$messaging = $this->getMessaging();
if (! $messaging) {
return false;
}
$message = CloudMessage::withTarget('token', $token)
->withNotification(Notification::create($title, $body));
if ($data) {
$message = $message->withData($data);
}
$messaging->send($message);
return true;
} catch (FirebaseException $e) {
Log::error('Failed to send push notification', [
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Send push notification to multiple devices
*/
public function sendMulticastNotification(
array $tokens,
string $title,
string $body,
?array $data = null
): array {
try {
$messaging = $this->getMessaging();
if (! $messaging) {
return ['success' => 0, 'failure' => count($tokens)];
}
$message = CloudMessage::new()
->withNotification(Notification::create($title, $body));
if ($data) {
$message = $message->withData($data);
}
$report = $messaging->sendMulticast($message, $tokens);
return [
'success' => $report->successes()->count(),
'failure' => $report->failures()->count(),
'invalid_tokens' => $report->invalidTokens(),
];
} catch (FirebaseException $e) {
Log::error('Failed to send multicast notification', [
'error' => $e->getMessage(),
]);
return ['success' => 0, 'failure' => count($tokens)];
}
}
/**
* Send notification to a topic
*/
public function sendTopicNotification(
string $topic,
string $title,
string $body,
?array $data = null
): bool {
try {
$messaging = $this->getMessaging();
if (! $messaging) {
return false;
}
$message = CloudMessage::withTarget('topic', $topic)
->withNotification(Notification::create($title, $body));
if ($data) {
$message = $message->withData($data);
}
$messaging->send($message);
return true;
} catch (FirebaseException $e) {
Log::error('Failed to send topic notification', [
'topic' => $topic,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Subscribe device to a topic
*/
public function subscribeToTopic(string $token, string $topic): bool
{
try {
$messaging = $this->getMessaging();
if (! $messaging) {
return false;
}
$messaging->subscribeToTopic($topic, [$token]);
return true;
} catch (FirebaseException $e) {
Log::error('Failed to subscribe to topic', [
'topic' => $topic,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Unsubscribe device from a topic
*/
public function unsubscribeFromTopic(string $token, string $topic): bool
{
try {
$messaging = $this->getMessaging();
if (! $messaging) {
return false;
}
$messaging->unsubscribeFromTopic($topic, [$token]);
return true;
} catch (FirebaseException $e) {
Log::error('Failed to unsubscribe from topic', [
'topic' => $topic,
'error' => $e->getMessage(),
]);
return false;
}
}
/*
|--------------------------------------------------------------------------
| Utility Methods
|--------------------------------------------------------------------------
*/
/**
* Set a value at a custom path
*/
public function set(string $path, mixed $value): bool
{
try {
$database = $this->getDatabase();
if (! $database) {
return false;
}
$database->getReference($path)->set($value);
return true;
} catch (FirebaseException $e) {
Log::error('Failed to set value in Firebase', [
'path' => $path,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Update values at a custom path
*/
public function update(string $path, array $values): bool
{
try {
$database = $this->getDatabase();
if (! $database) {
return false;
}
$database->getReference($path)->update($values);
return true;
} catch (FirebaseException $e) {
Log::error('Failed to update value in Firebase', [
'path' => $path,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Get value from a custom path
*/
public function get(string $path): mixed
{
try {
$database = $this->getDatabase();
if (! $database) {
return null;
}
$snapshot = $database->getReference($path)->getSnapshot();
return $snapshot->exists() ? $snapshot->getValue() : null;
} catch (FirebaseException $e) {
Log::error('Failed to get value from Firebase', [
'path' => $path,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Delete value at a custom path
*/
public function delete(string $path): bool
{
try {
$database = $this->getDatabase();
if (! $database) {
return false;
}
$database->getReference($path)->remove();
return true;
} catch (FirebaseException $e) {
Log::error('Failed to delete value from Firebase', [
'path' => $path,
'error' => $e->getMessage(),
]);
return false;
}
}
}

View File

@@ -0,0 +1,644 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Services\Firebase;
use Google\Cloud\Firestore\FirestoreClient;
use Illuminate\Support\Facades\Log;
class FirestoreService
{
protected ?FirestoreClient $firestore = null;
protected array $config;
public function __construct()
{
$this->config = config('restaurant-delivery.firebase');
$this->initializeFirestore();
}
protected function initializeFirestore(): void
{
if (! $this->config['firestore']['enabled']) {
return;
}
try {
$this->firestore = new FirestoreClient([
'keyFilePath' => $this->config['credentials_path'],
'projectId' => $this->config['project_id'],
]);
} catch (\Exception $e) {
Log::error('Firestore initialization failed', [
'error' => $e->getMessage(),
]);
}
}
public function getFirestore(): ?FirestoreClient
{
return $this->firestore;
}
protected function getCollectionName(string $type): string
{
return $this->config['firestore']['collections'][$type] ?? $type;
}
/*
|--------------------------------------------------------------------------
| Delivery Operations
|--------------------------------------------------------------------------
*/
/**
* Create delivery document in Firestore
*/
public function createDelivery(int|string $deliveryId, array $data): bool
{
try {
if (! $this->firestore) {
return false;
}
$collection = $this->getCollectionName('deliveries');
$docRef = $this->firestore->collection($collection)->document((string) $deliveryId);
$deliveryData = array_merge($data, [
'created_at' => new \Google\Cloud\Core\Timestamp(new \DateTime),
'updated_at' => new \Google\Cloud\Core\Timestamp(new \DateTime),
]);
$docRef->set($deliveryData);
return true;
} catch (\Exception $e) {
Log::error('Failed to create delivery in Firestore', [
'delivery_id' => $deliveryId,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Update delivery document
*/
public function updateDelivery(int|string $deliveryId, array $data): bool
{
try {
if (! $this->firestore) {
return false;
}
$collection = $this->getCollectionName('deliveries');
$docRef = $this->firestore->collection($collection)->document((string) $deliveryId);
$data['updated_at'] = new \Google\Cloud\Core\Timestamp(new \DateTime);
$docRef->update($this->formatForUpdate($data));
return true;
} catch (\Exception $e) {
Log::error('Failed to update delivery in Firestore', [
'delivery_id' => $deliveryId,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Get delivery document
*/
public function getDelivery(int|string $deliveryId): ?array
{
try {
if (! $this->firestore) {
return null;
}
$collection = $this->getCollectionName('deliveries');
$docRef = $this->firestore->collection($collection)->document((string) $deliveryId);
$snapshot = $docRef->snapshot();
return $snapshot->exists() ? $snapshot->data() : null;
} catch (\Exception $e) {
Log::error('Failed to get delivery from Firestore', [
'delivery_id' => $deliveryId,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Delete delivery document
*/
public function deleteDelivery(int|string $deliveryId): bool
{
try {
if (! $this->firestore) {
return false;
}
$collection = $this->getCollectionName('deliveries');
$this->firestore->collection($collection)->document((string) $deliveryId)->delete();
return true;
} catch (\Exception $e) {
Log::error('Failed to delete delivery from Firestore', [
'delivery_id' => $deliveryId,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Query active deliveries for a rider
*/
public function getRiderActiveDeliveries(int|string $riderId): array
{
try {
if (! $this->firestore) {
return [];
}
$collection = $this->getCollectionName('deliveries');
$query = $this->firestore->collection($collection)
->where('rider_id', '=', $riderId)
->where('status', 'in', ['assigned', 'picked_up', 'on_the_way']);
$deliveries = [];
foreach ($query->documents() as $document) {
if ($document->exists()) {
$deliveries[] = array_merge(['id' => $document->id()], $document->data());
}
}
return $deliveries;
} catch (\Exception $e) {
Log::error('Failed to get rider active deliveries from Firestore', [
'rider_id' => $riderId,
'error' => $e->getMessage(),
]);
return [];
}
}
/**
* Query deliveries for a restaurant
*/
public function getRestaurantDeliveries(
int|string $restaurantId,
?string $status = null,
int $limit = 50
): array {
try {
if (! $this->firestore) {
return [];
}
$collection = $this->getCollectionName('deliveries');
$query = $this->firestore->collection($collection)
->where('restaurant_id', '=', $restaurantId)
->orderBy('created_at', 'desc')
->limit($limit);
if ($status) {
$query = $query->where('status', '=', $status);
}
$deliveries = [];
foreach ($query->documents() as $document) {
if ($document->exists()) {
$deliveries[] = array_merge(['id' => $document->id()], $document->data());
}
}
return $deliveries;
} catch (\Exception $e) {
Log::error('Failed to get restaurant deliveries from Firestore', [
'restaurant_id' => $restaurantId,
'error' => $e->getMessage(),
]);
return [];
}
}
/*
|--------------------------------------------------------------------------
| Rider Operations
|--------------------------------------------------------------------------
*/
/**
* Update rider document
*/
public function updateRider(int|string $riderId, array $data): bool
{
try {
if (! $this->firestore) {
return false;
}
$collection = $this->getCollectionName('riders');
$docRef = $this->firestore->collection($collection)->document((string) $riderId);
$data['updated_at'] = new \Google\Cloud\Core\Timestamp(new \DateTime);
$docRef->set($data, ['merge' => true]);
return true;
} catch (\Exception $e) {
Log::error('Failed to update rider in Firestore', [
'rider_id' => $riderId,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Get rider document
*/
public function getRider(int|string $riderId): ?array
{
try {
if (! $this->firestore) {
return null;
}
$collection = $this->getCollectionName('riders');
$snapshot = $this->firestore->collection($collection)
->document((string) $riderId)
->snapshot();
return $snapshot->exists() ? $snapshot->data() : null;
} catch (\Exception $e) {
Log::error('Failed to get rider from Firestore', [
'rider_id' => $riderId,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Query online riders within a radius
*/
public function getOnlineRidersInArea(
float $latitude,
float $longitude,
float $radiusKm
): array {
try {
if (! $this->firestore) {
return [];
}
// Note: Firestore doesn't support geo queries natively
// You would typically use Geohashing or Firebase GeoFire for this
// This is a simplified version that queries all online riders
// and filters by distance in PHP
$collection = $this->getCollectionName('riders');
$query = $this->firestore->collection($collection)
->where('is_online', '=', true)
->where('status', '=', 'available');
$riders = [];
foreach ($query->documents() as $document) {
if ($document->exists()) {
$data = $document->data();
if (isset($data['location'])) {
$distance = $this->calculateDistance(
$latitude,
$longitude,
$data['location']['lat'],
$data['location']['lng']
);
if ($distance <= $radiusKm) {
$riders[] = array_merge(
['id' => $document->id(), 'distance' => $distance],
$data
);
}
}
}
}
// Sort by distance
usort($riders, fn ($a, $b) => $a['distance'] <=> $b['distance']);
return $riders;
} catch (\Exception $e) {
Log::error('Failed to get online riders from Firestore', [
'error' => $e->getMessage(),
]);
return [];
}
}
/*
|--------------------------------------------------------------------------
| Tracking History Operations
|--------------------------------------------------------------------------
*/
/**
* Add tracking point to history
*/
public function addTrackingPoint(
int|string $deliveryId,
float $latitude,
float $longitude,
array $metadata = []
): bool {
try {
if (! $this->firestore) {
return false;
}
$collection = $this->getCollectionName('tracking_history');
$docRef = $this->firestore->collection($collection)
->document((string) $deliveryId)
->collection('points')
->newDocument();
$pointData = array_merge([
'lat' => $latitude,
'lng' => $longitude,
'timestamp' => new \Google\Cloud\Core\Timestamp(new \DateTime),
], $metadata);
$docRef->set($pointData);
return true;
} catch (\Exception $e) {
Log::error('Failed to add tracking point to Firestore', [
'delivery_id' => $deliveryId,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Get tracking history for a delivery
*/
public function getTrackingHistory(int|string $deliveryId, int $limit = 100): array
{
try {
if (! $this->firestore) {
return [];
}
$collection = $this->getCollectionName('tracking_history');
$query = $this->firestore->collection($collection)
->document((string) $deliveryId)
->collection('points')
->orderBy('timestamp', 'desc')
->limit($limit);
$points = [];
foreach ($query->documents() as $document) {
if ($document->exists()) {
$points[] = $document->data();
}
}
return array_reverse($points); // Return in chronological order
} catch (\Exception $e) {
Log::error('Failed to get tracking history from Firestore', [
'delivery_id' => $deliveryId,
'error' => $e->getMessage(),
]);
return [];
}
}
/*
|--------------------------------------------------------------------------
| Rating Operations
|--------------------------------------------------------------------------
*/
/**
* Create rating document
*/
public function createRating(array $ratingData): ?string
{
try {
if (! $this->firestore) {
return null;
}
$collection = $this->getCollectionName('ratings');
$docRef = $this->firestore->collection($collection)->newDocument();
$ratingData['created_at'] = new \Google\Cloud\Core\Timestamp(new \DateTime);
$docRef->set($ratingData);
return $docRef->id();
} catch (\Exception $e) {
Log::error('Failed to create rating in Firestore', [
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Get ratings for a rider
*/
public function getRiderRatings(int|string $riderId, int $limit = 50): array
{
try {
if (! $this->firestore) {
return [];
}
$collection = $this->getCollectionName('ratings');
$query = $this->firestore->collection($collection)
->where('rider_id', '=', $riderId)
->orderBy('created_at', 'desc')
->limit($limit);
$ratings = [];
foreach ($query->documents() as $document) {
if ($document->exists()) {
$ratings[] = array_merge(['id' => $document->id()], $document->data());
}
}
return $ratings;
} catch (\Exception $e) {
Log::error('Failed to get rider ratings from Firestore', [
'rider_id' => $riderId,
'error' => $e->getMessage(),
]);
return [];
}
}
/**
* Calculate rider average rating
*/
public function calculateRiderAverageRating(int|string $riderId): ?float
{
try {
if (! $this->firestore) {
return null;
}
$collection = $this->getCollectionName('ratings');
$query = $this->firestore->collection($collection)
->where('rider_id', '=', $riderId);
$totalRating = 0;
$count = 0;
foreach ($query->documents() as $document) {
if ($document->exists()) {
$data = $document->data();
if (isset($data['overall_rating'])) {
$totalRating += $data['overall_rating'];
$count++;
}
}
}
return $count > 0 ? round($totalRating / $count, 2) : null;
} catch (\Exception $e) {
Log::error('Failed to calculate rider average rating from Firestore', [
'rider_id' => $riderId,
'error' => $e->getMessage(),
]);
return null;
}
}
/*
|--------------------------------------------------------------------------
| Utility Methods
|--------------------------------------------------------------------------
*/
/**
* Format data for Firestore update operation
*/
protected function formatForUpdate(array $data): array
{
$formatted = [];
foreach ($data as $key => $value) {
$formatted[] = ['path' => $key, 'value' => $value];
}
return $formatted;
}
/**
* Calculate distance between two points using Haversine formula
*/
protected function calculateDistance(
float $lat1,
float $lng1,
float $lat2,
float $lng2
): float {
$earthRadius = 6371; // km
$dLat = deg2rad($lat2 - $lat1);
$dLng = deg2rad($lng2 - $lng1);
$a = sin($dLat / 2) * sin($dLat / 2) +
cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
sin($dLng / 2) * sin($dLng / 2);
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
return $earthRadius * $c;
}
/**
* Run a transaction
*/
public function runTransaction(callable $callback): mixed
{
try {
if (! $this->firestore) {
return null;
}
return $this->firestore->runTransaction($callback);
} catch (\Exception $e) {
Log::error('Firestore transaction failed', [
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Batch write multiple documents
*/
public function batchWrite(array $operations): bool
{
try {
if (! $this->firestore) {
return false;
}
$batch = $this->firestore->batch();
foreach ($operations as $op) {
$docRef = $this->firestore
->collection($op['collection'])
->document($op['document']);
switch ($op['type']) {
case 'set':
$batch->set($docRef, $op['data']);
break;
case 'update':
$batch->update($docRef, $this->formatForUpdate($op['data']));
break;
case 'delete':
$batch->delete($docRef);
break;
}
}
$batch->commit();
return true;
} catch (\Exception $e) {
Log::error('Firestore batch write failed', [
'error' => $e->getMessage(),
]);
return false;
}
}
}

View File

@@ -0,0 +1,676 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Services\Maps;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class MapsService
{
protected array $config;
protected string $provider;
public function __construct()
{
$this->config = config('restaurant-delivery.maps');
$this->provider = $this->config['provider'];
}
/*
|--------------------------------------------------------------------------
| Route Calculation
|--------------------------------------------------------------------------
*/
/**
* Get route between two points
*/
public function getRoute(
float $originLat,
float $originLng,
float $destLat,
float $destLng,
?array $waypoints = null
): ?array {
$cacheKey = "route_{$originLat}_{$originLng}_{$destLat}_{$destLng}_".md5(json_encode($waypoints ?? []));
return Cache::remember(
$cacheKey,
config('restaurant-delivery.cache.ttl.route_calculation'),
function () use ($originLat, $originLng, $destLat, $destLng, $waypoints) {
return match ($this->provider) {
'google' => $this->getGoogleRoute($originLat, $originLng, $destLat, $destLng, $waypoints),
'mapbox' => $this->getMapboxRoute($originLat, $originLng, $destLat, $destLng, $waypoints),
default => $this->getGoogleRoute($originLat, $originLng, $destLat, $destLng, $waypoints),
};
}
);
}
/**
* Get route using Google Maps Directions API
*/
protected function getGoogleRoute(
float $originLat,
float $originLng,
float $destLat,
float $destLng,
?array $waypoints = null
): ?array {
try {
$apiKey = $this->config['google']['api_key'];
if (! $apiKey) {
Log::warning('Google Maps API key not configured');
return $this->getFallbackRoute($originLat, $originLng, $destLat, $destLng);
}
$params = [
'origin' => "{$originLat},{$originLng}",
'destination' => "{$destLat},{$destLng}",
'key' => $apiKey,
'mode' => 'driving',
'departure_time' => 'now',
'traffic_model' => $this->config['route']['traffic_model'],
'alternatives' => $this->config['route']['alternatives'] ? 'true' : 'false',
];
if ($waypoints) {
$params['waypoints'] = implode('|', array_map(
fn ($wp) => "{$wp['lat']},{$wp['lng']}",
$waypoints
));
}
$response = Http::get('https://maps.googleapis.com/maps/api/directions/json', $params);
if (! $response->successful()) {
Log::error('Google Maps API request failed', [
'status' => $response->status(),
]);
return $this->getFallbackRoute($originLat, $originLng, $destLat, $destLng);
}
$data = $response->json();
if ($data['status'] !== 'OK' || empty($data['routes'])) {
Log::error('Google Maps API returned error', [
'status' => $data['status'],
'error_message' => $data['error_message'] ?? null,
]);
return $this->getFallbackRoute($originLat, $originLng, $destLat, $destLng);
}
$route = $data['routes'][0];
$leg = $route['legs'][0];
return [
'polyline' => $route['overview_polyline']['points'],
'points' => $this->decodePolyline($route['overview_polyline']['points']),
'distance' => $leg['distance']['value'] / 1000, // Convert to km
'distance_text' => $leg['distance']['text'],
'duration' => (int) ceil($leg['duration']['value'] / 60), // Convert to minutes
'duration_text' => $leg['duration']['text'],
'duration_in_traffic' => isset($leg['duration_in_traffic'])
? (int) ceil($leg['duration_in_traffic']['value'] / 60)
: null,
'start_address' => $leg['start_address'],
'end_address' => $leg['end_address'],
'steps' => array_map(fn ($step) => [
'instruction' => strip_tags($step['html_instructions']),
'distance' => $step['distance']['value'],
'duration' => $step['duration']['value'],
'start' => $step['start_location'],
'end' => $step['end_location'],
'maneuver' => $step['maneuver'] ?? null,
], $leg['steps']),
'alternatives' => $this->config['route']['alternatives']
? $this->parseAlternativeRoutes($data['routes'])
: [],
];
} catch (\Exception $e) {
Log::error('Google Maps route calculation failed', [
'error' => $e->getMessage(),
]);
return $this->getFallbackRoute($originLat, $originLng, $destLat, $destLng);
}
}
/**
* Get route using Mapbox Directions API
*/
protected function getMapboxRoute(
float $originLat,
float $originLng,
float $destLat,
float $destLng,
?array $waypoints = null
): ?array {
try {
$accessToken = $this->config['mapbox']['access_token'];
if (! $accessToken) {
Log::warning('Mapbox access token not configured');
return $this->getFallbackRoute($originLat, $originLng, $destLat, $destLng);
}
$coordinates = "{$originLng},{$originLat}";
if ($waypoints) {
foreach ($waypoints as $wp) {
$coordinates .= ";{$wp['lng']},{$wp['lat']}";
}
}
$coordinates .= ";{$destLng},{$destLat}";
$params = [
'access_token' => $accessToken,
'geometries' => 'polyline',
'overview' => 'full',
'steps' => 'true',
'alternatives' => $this->config['route']['alternatives'] ? 'true' : 'false',
];
$response = Http::get(
"https://api.mapbox.com/directions/v5/mapbox/driving/{$coordinates}",
$params
);
if (! $response->successful()) {
Log::error('Mapbox API request failed', [
'status' => $response->status(),
]);
return $this->getFallbackRoute($originLat, $originLng, $destLat, $destLng);
}
$data = $response->json();
if (empty($data['routes'])) {
Log::error('Mapbox API returned no routes');
return $this->getFallbackRoute($originLat, $originLng, $destLat, $destLng);
}
$route = $data['routes'][0];
return [
'polyline' => $route['geometry'],
'points' => $this->decodePolyline($route['geometry']),
'distance' => $route['distance'] / 1000, // Convert to km
'distance_text' => $this->formatDistance($route['distance'] / 1000),
'duration' => (int) ceil($route['duration'] / 60), // Convert to minutes
'duration_text' => $this->formatDuration($route['duration']),
'steps' => array_map(fn ($step) => [
'instruction' => $step['maneuver']['instruction'] ?? '',
'distance' => $step['distance'],
'duration' => $step['duration'],
'maneuver' => $step['maneuver']['type'] ?? null,
], $route['legs'][0]['steps'] ?? []),
'alternatives' => [],
];
} catch (\Exception $e) {
Log::error('Mapbox route calculation failed', [
'error' => $e->getMessage(),
]);
return $this->getFallbackRoute($originLat, $originLng, $destLat, $destLng);
}
}
/**
* Fallback route calculation using Haversine (straight line)
*/
protected function getFallbackRoute(
float $originLat,
float $originLng,
float $destLat,
float $destLng
): array {
$distance = $this->calculateHaversineDistance($originLat, $originLng, $destLat, $destLng);
// Estimate duration assuming average speed of 25 km/h
$duration = (int) ceil(($distance / 25) * 60);
// Create a simple polyline (straight line)
$polyline = $this->encodePolyline([
['lat' => $originLat, 'lng' => $originLng],
['lat' => $destLat, 'lng' => $destLng],
]);
return [
'polyline' => $polyline,
'points' => [
['lat' => $originLat, 'lng' => $originLng],
['lat' => $destLat, 'lng' => $destLng],
],
'distance' => $distance,
'distance_text' => $this->formatDistance($distance),
'duration' => $duration,
'duration_text' => $this->formatDuration($duration * 60),
'is_fallback' => true,
'steps' => [],
'alternatives' => [],
];
}
/**
* Parse alternative routes from Google response
*/
protected function parseAlternativeRoutes(array $routes): array
{
$alternatives = [];
for ($i = 1; $i < count($routes); $i++) {
$route = $routes[$i];
$leg = $route['legs'][0];
$alternatives[] = [
'polyline' => $route['overview_polyline']['points'],
'distance' => $leg['distance']['value'] / 1000,
'duration' => (int) ceil($leg['duration']['value'] / 60),
'summary' => $route['summary'] ?? null,
];
}
return $alternatives;
}
/*
|--------------------------------------------------------------------------
| Distance Matrix
|--------------------------------------------------------------------------
*/
/**
* Get distance matrix for multiple origins and destinations
*/
public function getDistanceMatrix(array $origins, array $destinations): ?array
{
if ($this->provider !== 'google' || ! $this->config['google']['distance_matrix_api']) {
return $this->getFallbackDistanceMatrix($origins, $destinations);
}
try {
$apiKey = $this->config['google']['api_key'];
$originStr = implode('|', array_map(
fn ($o) => "{$o['lat']},{$o['lng']}",
$origins
));
$destStr = implode('|', array_map(
fn ($d) => "{$d['lat']},{$d['lng']}",
$destinations
));
$response = Http::get('https://maps.googleapis.com/maps/api/distancematrix/json', [
'origins' => $originStr,
'destinations' => $destStr,
'key' => $apiKey,
'mode' => 'driving',
'departure_time' => 'now',
]);
if (! $response->successful()) {
return $this->getFallbackDistanceMatrix($origins, $destinations);
}
$data = $response->json();
if ($data['status'] !== 'OK') {
return $this->getFallbackDistanceMatrix($origins, $destinations);
}
$matrix = [];
foreach ($data['rows'] as $i => $row) {
$matrix[$i] = [];
foreach ($row['elements'] as $j => $element) {
if ($element['status'] === 'OK') {
$matrix[$i][$j] = [
'distance' => $element['distance']['value'] / 1000,
'duration' => (int) ceil($element['duration']['value'] / 60),
];
} else {
$matrix[$i][$j] = null;
}
}
}
return $matrix;
} catch (\Exception $e) {
Log::error('Distance matrix calculation failed', [
'error' => $e->getMessage(),
]);
return $this->getFallbackDistanceMatrix($origins, $destinations);
}
}
/**
* Fallback distance matrix using Haversine
*/
protected function getFallbackDistanceMatrix(array $origins, array $destinations): array
{
$matrix = [];
foreach ($origins as $i => $origin) {
$matrix[$i] = [];
foreach ($destinations as $j => $dest) {
$distance = $this->calculateHaversineDistance(
$origin['lat'],
$origin['lng'],
$dest['lat'],
$dest['lng']
);
$matrix[$i][$j] = [
'distance' => $distance,
'duration' => (int) ceil(($distance / 25) * 60),
];
}
}
return $matrix;
}
/*
|--------------------------------------------------------------------------
| Geocoding
|--------------------------------------------------------------------------
*/
/**
* Geocode an address to coordinates
*/
public function geocode(string $address): ?array
{
$cacheKey = 'geocode_'.md5($address);
return Cache::remember($cacheKey, 86400, function () use ($address) {
if ($this->provider === 'google' && $this->config['google']['geocoding_api']) {
return $this->googleGeocode($address);
}
return null;
});
}
/**
* Reverse geocode coordinates to address
*/
public function reverseGeocode(float $latitude, float $longitude): ?array
{
$cacheKey = "reverse_geocode_{$latitude}_{$longitude}";
return Cache::remember($cacheKey, 86400, function () use ($latitude, $longitude) {
if ($this->provider === 'google' && $this->config['google']['geocoding_api']) {
return $this->googleReverseGeocode($latitude, $longitude);
}
return null;
});
}
/**
* Google geocoding
*/
protected function googleGeocode(string $address): ?array
{
try {
$response = Http::get('https://maps.googleapis.com/maps/api/geocode/json', [
'address' => $address,
'key' => $this->config['google']['api_key'],
]);
if (! $response->successful()) {
return null;
}
$data = $response->json();
if ($data['status'] !== 'OK' || empty($data['results'])) {
return null;
}
$result = $data['results'][0];
return [
'lat' => $result['geometry']['location']['lat'],
'lng' => $result['geometry']['location']['lng'],
'formatted_address' => $result['formatted_address'],
'place_id' => $result['place_id'],
'components' => $this->parseAddressComponents($result['address_components']),
];
} catch (\Exception $e) {
Log::error('Geocoding failed', ['error' => $e->getMessage()]);
return null;
}
}
/**
* Google reverse geocoding
*/
protected function googleReverseGeocode(float $latitude, float $longitude): ?array
{
try {
$response = Http::get('https://maps.googleapis.com/maps/api/geocode/json', [
'latlng' => "{$latitude},{$longitude}",
'key' => $this->config['google']['api_key'],
]);
if (! $response->successful()) {
return null;
}
$data = $response->json();
if ($data['status'] !== 'OK' || empty($data['results'])) {
return null;
}
$result = $data['results'][0];
return [
'formatted_address' => $result['formatted_address'],
'place_id' => $result['place_id'],
'components' => $this->parseAddressComponents($result['address_components']),
];
} catch (\Exception $e) {
Log::error('Reverse geocoding failed', ['error' => $e->getMessage()]);
return null;
}
}
/**
* Parse address components from Google response
*/
protected function parseAddressComponents(array $components): array
{
$parsed = [];
foreach ($components as $component) {
foreach ($component['types'] as $type) {
$parsed[$type] = $component['long_name'];
}
}
return $parsed;
}
/*
|--------------------------------------------------------------------------
| Utility Methods
|--------------------------------------------------------------------------
*/
/**
* Calculate Haversine distance
*/
public function calculateHaversineDistance(
float $lat1,
float $lng1,
float $lat2,
float $lng2
): float {
$earthRadius = config('restaurant-delivery.distance.earth_radius_km');
$dLat = deg2rad($lat2 - $lat1);
$dLng = deg2rad($lng2 - $lng1);
$a = sin($dLat / 2) * sin($dLat / 2) +
cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
sin($dLng / 2) * sin($dLng / 2);
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
return round($earthRadius * $c, 2);
}
/**
* Decode polyline string to array of points
*/
public function decodePolyline(string $encoded): array
{
$points = [];
$index = 0;
$lat = 0;
$lng = 0;
while ($index < strlen($encoded)) {
$shift = 0;
$result = 0;
do {
$b = ord($encoded[$index++]) - 63;
$result |= ($b & 0x1F) << $shift;
$shift += 5;
} while ($b >= 0x20);
$dlat = (($result & 1) ? ~($result >> 1) : ($result >> 1));
$lat += $dlat;
$shift = 0;
$result = 0;
do {
$b = ord($encoded[$index++]) - 63;
$result |= ($b & 0x1F) << $shift;
$shift += 5;
} while ($b >= 0x20);
$dlng = (($result & 1) ? ~($result >> 1) : ($result >> 1));
$lng += $dlng;
$points[] = [
'lat' => $lat / 1e5,
'lng' => $lng / 1e5,
];
}
return $points;
}
/**
* Encode array of points to polyline string
*/
public function encodePolyline(array $points): string
{
$encoded = '';
$prevLat = 0;
$prevLng = 0;
foreach ($points as $point) {
$lat = (int) round($point['lat'] * 1e5);
$lng = (int) round($point['lng'] * 1e5);
$encoded .= $this->encodeNumber($lat - $prevLat);
$encoded .= $this->encodeNumber($lng - $prevLng);
$prevLat = $lat;
$prevLng = $lng;
}
return $encoded;
}
/**
* Encode a number for polyline
*/
protected function encodeNumber(int $num): string
{
$encoded = '';
$num = $num < 0 ? ~($num << 1) : ($num << 1);
while ($num >= 0x20) {
$encoded .= chr((0x20 | ($num & 0x1F)) + 63);
$num >>= 5;
}
$encoded .= chr($num + 63);
return $encoded;
}
/**
* Format distance for display
*/
protected function formatDistance(float $km): string
{
if ($km < 1) {
return round($km * 1000).' m';
}
return round($km, 1).' km';
}
/**
* Format duration for display
*/
protected function formatDuration(int $seconds): string
{
$minutes = (int) ceil($seconds / 60);
if ($minutes < 60) {
return $minutes.' min';
}
$hours = (int) floor($minutes / 60);
$mins = $minutes % 60;
return $hours.' hr '.$mins.' min';
}
/**
* Calculate bearing between two points
*/
public function calculateBearing(
float $lat1,
float $lng1,
float $lat2,
float $lng2
): float {
$lat1Rad = deg2rad($lat1);
$lat2Rad = deg2rad($lat2);
$dLng = deg2rad($lng2 - $lng1);
$x = sin($dLng) * cos($lat2Rad);
$y = cos($lat1Rad) * sin($lat2Rad) - sin($lat1Rad) * cos($lat2Rad) * cos($dLng);
$bearing = atan2($x, $y);
$bearing = rad2deg($bearing);
return fmod($bearing + 360, 360);
}
}

View File

@@ -0,0 +1,402 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Services\Rating;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Modules\RestaurantDelivery\Events\RiderRated;
use Modules\RestaurantDelivery\Models\Delivery;
use Modules\RestaurantDelivery\Models\DeliveryRating;
use Modules\RestaurantDelivery\Models\Rider;
class RatingService
{
protected array $config;
public function __construct()
{
$this->config = config('restaurant-delivery.rating');
}
/*
|--------------------------------------------------------------------------
| Create Rating
|--------------------------------------------------------------------------
*/
/**
* Create a rating for a delivery
*/
public function createRating(
Delivery $delivery,
int $overallRating,
array $categoryRatings = [],
?string $review = null,
array $tags = [],
bool $isAnonymous = false,
?int $customerId = null,
bool $isRestaurantRating = false
): ?DeliveryRating {
// Validate can rate
if (! $this->canRate($delivery, $isRestaurantRating)) {
Log::warning('Cannot rate delivery', [
'delivery_id' => $delivery->id,
'reason' => 'Rating window expired or already rated',
]);
return null;
}
// Validate rating value
if (! $this->isValidRating($overallRating)) {
return null;
}
// Validate review length
if ($review && ! $this->isValidReview($review)) {
return null;
}
try {
$rating = DB::transaction(function () use (
$delivery,
$overallRating,
$categoryRatings,
$review,
$tags,
$isAnonymous,
$customerId,
$isRestaurantRating
) {
$rating = DeliveryRating::create([
'delivery_id' => $delivery->id,
'rider_id' => $delivery->rider_id,
'customer_id' => $customerId,
'restaurant_id' => $delivery->restaurant_id,
'overall_rating' => $overallRating,
'speed_rating' => $categoryRatings['speed'] ?? null,
'communication_rating' => $categoryRatings['communication'] ?? null,
'food_condition_rating' => $categoryRatings['food_condition'] ?? null,
'professionalism_rating' => $categoryRatings['professionalism'] ?? null,
'review' => $review,
'is_anonymous' => $isAnonymous,
'tags' => array_filter($tags),
'is_restaurant_rating' => $isRestaurantRating,
'is_approved' => $this->shouldAutoApprove($overallRating, $review),
'moderation_status' => $this->shouldAutoApprove($overallRating, $review) ? 'approved' : 'pending',
]);
// Update rider's rating
$delivery->rider->updateRating($overallRating);
// Check if rider needs warning or suspension
$this->checkRatingThresholds($delivery->rider);
return $rating;
});
// Dispatch event
Event::dispatch(new RiderRated($rating));
return $rating;
} catch (\Exception $e) {
Log::error('Failed to create rating', [
'delivery_id' => $delivery->id,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Check if delivery can be rated
*/
public function canRate(Delivery $delivery, bool $isRestaurantRating = false): bool
{
if (! $this->config['enabled']) {
return false;
}
if (! $delivery->isCompleted()) {
return false;
}
if (! $delivery->hasRider()) {
return false;
}
// Check rating window
$window = $this->config['rating_window'];
// If delivered_at is null, rating is not allowed
if (! $delivery->delivered_at) {
return false;
}
if ($delivery->delivered_at->diffInHours(now()) > $window) {
return false;
}
// Check if already rated
$existingRating = DeliveryRating::where('delivery_id', $delivery->id)
->where('is_restaurant_rating', $isRestaurantRating)
->exists();
return ! $existingRating;
}
/*
|--------------------------------------------------------------------------
| Validation
|--------------------------------------------------------------------------
*/
public function isValidRating(int $rating): bool
{
return $rating >= $this->config['min_rating']
&& $rating <= $this->config['max_rating'];
}
public function isValidReview(?string $review): bool
{
if (! $this->config['allow_review']) {
return $review === null;
}
if ($review === null) {
return true;
}
return strlen($review) <= $this->config['review_max_length'];
}
protected function shouldAutoApprove(int $rating, ?string $review): bool
{
// Auto-approve high ratings without review
if ($rating >= 4 && ! $review) {
return true;
}
// Auto-approve high ratings with short reviews
if ($rating >= 4 && $review && strlen($review) < 100) {
return true;
}
// Manual review for low ratings or long reviews
return false;
}
/*
|--------------------------------------------------------------------------
| Rating Thresholds
|--------------------------------------------------------------------------
*/
protected function checkRatingThresholds(Rider $rider): void
{
$thresholds = $this->config['thresholds'];
// Need minimum ratings before thresholds apply
if ($rider->rating_count < $thresholds['minimum_ratings_for_threshold']) {
return;
}
// Check suspension threshold
if ($rider->rating <= $thresholds['suspension_threshold']) {
$this->suspendRider($rider);
return;
}
// Check warning threshold
if ($rider->rating <= $thresholds['warning_threshold']) {
$this->warnRider($rider);
}
}
protected function suspendRider(Rider $rider): void
{
$rider->update(['status' => 'suspended']);
Log::info('Rider suspended due to low rating', [
'rider_id' => $rider->id,
'rating' => $rider->rating,
]);
// TODO: Send notification to rider
}
protected function warnRider(Rider $rider): void
{
Log::info('Rider warned due to low rating', [
'rider_id' => $rider->id,
'rating' => $rider->rating,
]);
// TODO: Send warning notification to rider
}
/*
|--------------------------------------------------------------------------
| Rider Response
|--------------------------------------------------------------------------
*/
/**
* Add rider's response to a rating
*/
public function addRiderResponse(DeliveryRating $rating, string $response): bool
{
if ($rating->rider_response) {
return false; // Already responded
}
$rating->addRiderResponse($response);
return true;
}
/*
|--------------------------------------------------------------------------
| Moderation
|--------------------------------------------------------------------------
*/
/**
* Approve a rating
*/
public function approveRating(DeliveryRating $rating): void
{
$rating->approve();
}
/**
* Reject a rating
*/
public function rejectRating(DeliveryRating $rating, string $reason): void
{
$rating->reject($reason);
// Recalculate rider's rating without this one
$rating->rider->recalculateRating();
}
/**
* Feature a rating
*/
public function featureRating(DeliveryRating $rating): void
{
$rating->feature();
}
/*
|--------------------------------------------------------------------------
| Statistics
|--------------------------------------------------------------------------
*/
/**
* Get rider's rating statistics
*/
public function getRiderStats(Rider $rider): array
{
$ratings = $rider->ratings()->approved()->get();
if ($ratings->isEmpty()) {
return [
'average_rating' => null,
'total_ratings' => 0,
'rating_distribution' => [1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0],
'category_averages' => null,
'recent_reviews' => [],
'positive_tags' => [],
'negative_tags' => [],
];
}
// Rating distribution
$distribution = [1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0];
foreach ($ratings as $rating) {
$distribution[$rating->overall_rating]++;
}
// Category averages
$categoryAverages = [
'speed' => $ratings->whereNotNull('speed_rating')->avg('speed_rating'),
'communication' => $ratings->whereNotNull('communication_rating')->avg('communication_rating'),
'food_condition' => $ratings->whereNotNull('food_condition_rating')->avg('food_condition_rating'),
'professionalism' => $ratings->whereNotNull('professionalism_rating')->avg('professionalism_rating'),
];
// Tag counts
$allTags = $ratings->pluck('tags')->flatten()->filter()->countBy();
$positiveTags = $allTags->only(DeliveryRating::getPositiveTags())->toArray();
$negativeTags = $allTags->only(DeliveryRating::getNegativeTags())->toArray();
// Recent reviews
$recentReviews = $rider->ratings()
->approved()
->withReview()
->recent($this->config['display']['show_recent_reviews'])
->get()
->map(fn ($r) => [
'rating' => $r->overall_rating,
'review' => $r->review,
'created_at' => $r->created_at->diffForHumans(),
'is_anonymous' => $r->is_anonymous,
'rider_response' => $r->rider_response,
]);
return [
'average_rating' => round($rider->rating, 2),
'total_ratings' => $ratings->count(),
'rating_distribution' => $distribution,
'category_averages' => array_map(fn ($v) => $v ? round($v, 2) : null, $categoryAverages),
'recent_reviews' => $recentReviews->toArray(),
'positive_tags' => $positiveTags,
'negative_tags' => $negativeTags,
];
}
/**
* Get ratings for display on tracking page
*/
public function getRiderDisplayInfo(Rider $rider): array
{
if (! $this->config['display']['show_on_tracking']) {
return [];
}
$data = [
'rating' => round($rider->rating, 1),
'star_display' => str_repeat('★', (int) round($rider->rating)).str_repeat('☆', 5 - (int) round($rider->rating)),
];
if ($this->config['display']['show_review_count']) {
$data['review_count'] = $rider->rating_count;
}
return $data;
}
/**
* Get available rating categories
*/
public function getCategories(): array
{
return $this->config['categories'];
}
/**
* Get positive and negative tags
*/
public function getTags(): array
{
return [
'positive' => DeliveryRating::getPositiveTags(),
'negative' => DeliveryRating::getNegativeTags(),
];
}
}

View File

@@ -0,0 +1,406 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Services\Tip;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Modules\RestaurantDelivery\Events\TipReceived;
use Modules\RestaurantDelivery\Models\Delivery;
use Modules\RestaurantDelivery\Models\DeliveryTip;
use Modules\RestaurantDelivery\Models\Rider;
use Modules\RestaurantDelivery\Models\RiderEarning;
class TipService
{
protected array $config;
public function __construct()
{
$this->config = config('restaurant-delivery.tip');
}
/*
|--------------------------------------------------------------------------
| Create Tip
|--------------------------------------------------------------------------
*/
/**
* Create a pre-delivery tip (at checkout)
*/
public function createPreDeliveryTip(
Delivery $delivery,
float $amount,
?int $customerId = null,
string $calculationType = 'fixed',
?float $percentageValue = null,
?string $message = null
): ?DeliveryTip {
if (! $this->canTipPreDelivery($delivery)) {
return null;
}
return $this->createTip(
$delivery,
$amount,
'pre_delivery',
$customerId,
$calculationType,
$percentageValue,
$message
);
}
/**
* Create a post-delivery tip
*/
public function createPostDeliveryTip(
Delivery $delivery,
float $amount,
?int $customerId = null,
string $calculationType = 'fixed',
?float $percentageValue = null,
?string $message = null
): ?DeliveryTip {
if (! $this->canTipPostDelivery($delivery)) {
return null;
}
return $this->createTip(
$delivery,
$amount,
'post_delivery',
$customerId,
$calculationType,
$percentageValue,
$message
);
}
/**
* Create a tip
*/
protected function createTip(
Delivery $delivery,
float $amount,
string $type,
?int $customerId,
string $calculationType,
?float $percentageValue,
?string $message
): ?DeliveryTip {
// Validate amount
if (! $this->isValidAmount($amount, $calculationType, $percentageValue, (float) $delivery->order_value)) {
Log::warning('Invalid tip amount', [
'delivery_id' => $delivery->id,
'amount' => $amount,
'type' => $calculationType,
]);
return null;
}
// Calculate final amount if percentage
$finalAmount = $calculationType === 'percentage'
? DeliveryTip::calculateFromPercentage($delivery->order_value, $percentageValue ?? $amount)
: $amount;
try {
$tip = DB::transaction(function () use (
$delivery,
$finalAmount,
$type,
$customerId,
$calculationType,
$percentageValue,
$message
) {
$tip = DeliveryTip::create([
'delivery_id' => $delivery->id,
'rider_id' => $delivery->rider_id,
'customer_id' => $customerId,
'restaurant_id' => $delivery->restaurant_id,
'amount' => $finalAmount,
'currency' => config('restaurant-delivery.pricing.currency', 'BDT'),
'type' => $type,
'calculation_type' => $calculationType,
'percentage_value' => $calculationType === 'percentage' ? ($percentageValue ?? $finalAmount) : null,
'order_value' => $delivery->order_value,
'payment_status' => 'pending',
'message' => $message,
]);
// Update delivery tip amount
$delivery->update([
'tip_amount' => $delivery->tip_amount + $finalAmount,
'tip_type' => $type,
]);
return $tip;
});
Log::info('Tip created', [
'tip_id' => $tip->id,
'delivery_id' => $delivery->id,
'amount' => $finalAmount,
'type' => $type,
]);
return $tip;
} catch (\Exception $e) {
Log::error('Failed to create tip', [
'delivery_id' => $delivery->id,
'error' => $e->getMessage(),
]);
return null;
}
}
/*
|--------------------------------------------------------------------------
| Process Tip Payment
|--------------------------------------------------------------------------
*/
/**
* Mark tip as paid
*/
public function markTipAsPaid(
DeliveryTip $tip,
string $paymentReference,
string $paymentMethod
): bool {
try {
DB::transaction(function () use ($tip, $paymentReference, $paymentMethod) {
$tip->markAsPaid($paymentReference, $paymentMethod);
// Create rider earning for this tip
RiderEarning::createFromTip($tip);
// Update delivery
$tip->delivery->update([
'tip_paid_at' => now(),
]);
});
// Dispatch event
Event::dispatch(new TipReceived($tip));
Log::info('Tip marked as paid', [
'tip_id' => $tip->id,
'payment_reference' => $paymentReference,
]);
return true;
} catch (\Exception $e) {
Log::error('Failed to mark tip as paid', [
'tip_id' => $tip->id,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* Process tip payment (integration point for payment gateway)
*/
public function processTipPayment(DeliveryTip $tip, array $paymentData): bool
{
// This would integrate with your payment gateway
// For now, we'll just mark it as paid
return $this->markTipAsPaid(
$tip,
$paymentData['reference'] ?? 'MANUAL-'.time(),
$paymentData['method'] ?? 'unknown'
);
}
/*
|--------------------------------------------------------------------------
| Validation
|--------------------------------------------------------------------------
*/
/**
* Check if pre-delivery tip is allowed
*/
public function canTipPreDelivery(Delivery $delivery): bool
{
if (! $this->config['enabled']) {
return false;
}
if (! $this->config['allow_pre_delivery']) {
return false;
}
if (! $delivery->isActive()) {
return false;
}
if (! $delivery->hasRider()) {
return false;
}
// Check if already has a tip
return ! $delivery->tip()->exists();
}
/**
* Check if post-delivery tip is allowed
*/
public function canTipPostDelivery(Delivery $delivery): bool
{
if (! $this->config['enabled']) {
return false;
}
if (! $this->config['allow_post_delivery']) {
return false;
}
if (! $delivery->isCompleted()) {
return false;
}
if (! $delivery->hasRider()) {
return false;
}
// Check tip window
$window = $this->config['post_delivery_window'];
if ($delivery->delivered_at->diffInHours(now()) > $window) {
return false;
}
// Check if already has a tip
return ! $delivery->tip()->exists();
}
/**
* Validate tip amount
*/
public function isValidAmount(
float $amount,
string $calculationType,
?float $percentageValue,
float $orderValue
): bool {
if ($calculationType === 'percentage') {
$percentage = $percentageValue ?? $amount;
if (! DeliveryTip::isValidPercentage($percentage)) {
return false;
}
$amount = DeliveryTip::calculateFromPercentage($orderValue, $percentage);
}
return DeliveryTip::isValidAmount($amount);
}
/*
|--------------------------------------------------------------------------
| Tip Options
|--------------------------------------------------------------------------
*/
/**
* Get tip options for display
*/
public function getTipOptions(Delivery $delivery): array
{
if (! $this->config['enabled']) {
return [];
}
$options = [
'enabled' => true,
'show_suggested' => $this->config['show_suggested'],
'suggested_message' => $this->config['suggested_message'],
'default_type' => $this->config['default_type'],
'min_tip' => $this->config['min_tip'],
'max_tip' => $this->config['max_tip'],
];
// Preset amounts
if ($this->config['default_type'] === 'amount') {
$options['presets'] = array_map(fn ($amount) => [
'value' => $amount,
'label' => config('restaurant-delivery.pricing.currency_symbol').$amount,
'type' => 'amount',
], $this->config['preset_amounts']);
} else {
$options['presets'] = array_map(fn ($percentage) => [
'value' => $percentage,
'label' => $percentage.'%',
'calculated_amount' => DeliveryTip::calculateFromPercentage($delivery->order_value, $percentage),
'type' => 'percentage',
], $this->config['preset_percentages']);
}
// Add custom option
$options['allow_custom'] = true;
return $options;
}
/**
* Calculate tip amount from percentage
*/
public function calculateTipFromPercentage(float $orderValue, float $percentage): float
{
return DeliveryTip::calculateFromPercentage($orderValue, $percentage);
}
/*
|--------------------------------------------------------------------------
| Statistics
|--------------------------------------------------------------------------
*/
/**
* Get rider's tip statistics
*/
public function getRiderTipStats(Rider $rider, ?string $period = null): array
{
$query = $rider->tips()->paid();
if ($period) {
$startDate = match ($period) {
'today' => now()->startOfDay(),
'week' => now()->startOfWeek(),
'month' => now()->startOfMonth(),
'year' => now()->startOfYear(),
default => null,
};
if ($startDate) {
$query->where('paid_at', '>=', $startDate);
}
}
$tips = $query->get();
return [
'total_tips' => $tips->sum('rider_amount'),
'tip_count' => $tips->count(),
'average_tip' => $tips->count() > 0 ? round($tips->avg('rider_amount'), 2) : 0,
'highest_tip' => $tips->max('rider_amount') ?? 0,
'tips_with_message' => $tips->whereNotNull('message')->count(),
];
}
/**
* Get tip transfer status
*/
public function getPendingTransferAmount(Rider $rider): float
{
return $rider->tips()
->notTransferred()
->sum('rider_amount');
}
}

View File

@@ -0,0 +1,603 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Services\Tracking;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Modules\RestaurantDelivery\Enums\DeliveryStatus;
use Modules\RestaurantDelivery\Events\DeliveryStatusChanged;
use Modules\RestaurantDelivery\Events\RiderLocationUpdated;
use Modules\RestaurantDelivery\Models\Delivery;
use Modules\RestaurantDelivery\Models\LocationLog;
use Modules\RestaurantDelivery\Models\Rider;
use Modules\RestaurantDelivery\Services\Firebase\FirebaseService;
use Modules\RestaurantDelivery\Services\Maps\MapsService;
class LiveTrackingService
{
protected FirebaseService $firebase;
protected MapsService $mapsService;
protected array $config;
public function __construct(
FirebaseService $firebase,
MapsService $mapsService
) {
$this->firebase = $firebase;
$this->mapsService = $mapsService;
$this->config = config('restaurant-delivery.tracking');
}
/*
|--------------------------------------------------------------------------
| Rider Location Updates
|--------------------------------------------------------------------------
*/
/**
* Update rider's live location
*/
public function updateRiderLocation(
Rider $rider,
float $latitude,
float $longitude,
?float $speed = null,
?float $bearing = null,
?float $accuracy = null
): array {
// Validate accuracy
$accuracyThreshold = config('restaurant-delivery.firebase.location.accuracy_threshold');
if ($accuracy && $accuracy > $accuracyThreshold) {
Log::warning('Rider location accuracy too low', [
'rider_id' => $rider->id,
'accuracy' => $accuracy,
'threshold' => $accuracyThreshold,
]);
}
// Check minimum distance change
$lastLocation = $this->getLastRiderLocation($rider->id);
$minDistance = config('restaurant-delivery.firebase.location.min_distance_change');
if ($lastLocation) {
$distance = $this->calculateDistance(
$lastLocation['lat'],
$lastLocation['lng'],
$latitude,
$longitude
);
// Skip if distance is less than minimum threshold (reduces noise)
if ($distance < ($minDistance / 1000)) { // Convert meters to km
return [
'updated' => false,
'reason' => 'Distance change below threshold',
'distance' => $distance * 1000,
];
}
}
// Update Firebase
$this->firebase->updateRiderLocation(
$rider->id,
$latitude,
$longitude,
$speed,
$bearing,
$accuracy
);
// Update local database
$rider->update([
'current_latitude' => $latitude,
'current_longitude' => $longitude,
'last_location_update' => now(),
]);
// Log location (if enabled)
if (config('restaurant-delivery.logging.log_location_updates')) {
LocationLog::create([
'rider_id' => $rider->id,
'latitude' => $latitude,
'longitude' => $longitude,
'speed' => $speed,
'bearing' => $bearing,
'accuracy' => $accuracy,
]);
}
// Update tracking for all active deliveries
$activeDeliveries = $rider->activeDeliveries;
foreach ($activeDeliveries as $delivery) {
$this->updateDeliveryTracking($delivery, $latitude, $longitude, $speed, $bearing);
}
// Dispatch event
Event::dispatch(new RiderLocationUpdated($rider, $latitude, $longitude, $speed, $bearing));
return [
'updated' => true,
'location' => [
'lat' => $latitude,
'lng' => $longitude,
'speed' => $speed,
'bearing' => $bearing,
],
'active_deliveries' => $activeDeliveries->count(),
];
}
/**
* Get last known rider location
*/
public function getLastRiderLocation(int|string $riderId): ?array
{
// Try cache first
$cached = Cache::get("rider_location_{$riderId}");
if ($cached) {
return $cached;
}
// Try Firebase
return $this->firebase->getRiderLocation($riderId);
}
/**
* Check if rider has valid location
*/
public function hasValidLocation(Rider $rider): bool
{
if (! $this->firebase->isRiderLocationStale($rider->id)) {
return true;
}
// Fallback to database
if ($rider->last_location_update) {
$threshold = config('restaurant-delivery.firebase.location.stale_threshold');
return $rider->last_location_update->diffInSeconds(now()) < $threshold;
}
return false;
}
/*
|--------------------------------------------------------------------------
| Delivery Tracking
|--------------------------------------------------------------------------
*/
/**
* Initialize tracking for a new delivery
*/
public function initializeDeliveryTracking(Delivery $delivery): bool
{
if (! $this->config['restaurant-delivery.maps']['enabled']) {
return false;
}
// Get route from maps service
$route = null;
if ($delivery->rider) {
$route = $this->mapsService->getRoute(
$delivery->pickup_latitude,
$delivery->pickup_longitude,
$delivery->drop_latitude,
$delivery->drop_longitude
);
}
$deliveryData = [
'status' => DeliveryStatus::PENDING,
'rider_id' => $delivery->rider_id,
'restaurant_id' => $delivery->restaurant_id,
'restaurant_name' => $delivery->restaurant?->name,
'pickup_latitude' => $delivery->pickup_latitude,
'pickup_longitude' => $delivery->pickup_longitude,
'pickup_address' => $delivery->pickup_address,
'drop_latitude' => $delivery->drop_latitude,
'drop_longitude' => $delivery->drop_longitude,
'drop_address' => $delivery->drop_address,
'route' => $route ? [
'polyline' => $route['polyline'],
'distance' => $route['distance'],
'duration' => $route['duration'],
] : null,
'eta' => $route ? $route['duration'] : null,
'distance' => $route ? $route['distance'] : null,
];
return $this->firebase->initializeDeliveryTracking($delivery->id, $deliveryData);
}
/**
* Update delivery tracking with new rider location
*/
public function updateDeliveryTracking(
Delivery $delivery,
float $latitude,
float $longitude,
?float $speed = null,
?float $bearing = null
): void {
// Determine destination based on delivery status
$destLat = $delivery->status->isPickedUp()
? $delivery->drop_latitude
: $delivery->pickup_latitude;
$destLng = $delivery->status->isPickedUp()
? $delivery->drop_longitude
: $delivery->pickup_longitude;
// Calculate remaining distance
$remainingDistance = $this->calculateDistance($latitude, $longitude, $destLat, $destLng);
// Calculate ETA based on speed or average
$eta = $this->calculateETA($remainingDistance, $speed);
// Update Firebase
$this->firebase->updateDeliveryRiderLocation(
$delivery->id,
$latitude,
$longitude,
$speed,
$bearing,
$eta,
$remainingDistance
);
// Check for geofence triggers (auto status updates)
$this->checkGeofenceTriggers($delivery, $latitude, $longitude);
// Update route if significantly off track
if ($this->isOffRoute($delivery, $latitude, $longitude)) {
$this->recalculateRoute($delivery, $latitude, $longitude);
}
}
/**
* Update delivery status in tracking
*/
public function updateDeliveryStatus(Delivery $delivery, ?array $metadata = null): void
{
$statusConfig = config("restaurant-delivery.delivery_flow.statuses.{$delivery->status->value}");
$this->firebase->updateDeliveryStatus(
$delivery->id,
$delivery->status->value,
array_merge($metadata ?? [], [
'eta' => $delivery->estimated_delivery_time?->diffForHumans(),
'rider' => $delivery->rider ? [
'id' => $delivery->rider->id,
'name' => $delivery->rider->full_name,
'phone' => $delivery->rider->phone,
'photo' => $delivery->rider->photo_url,
'rating' => $delivery->rider->rating,
'vehicle' => $delivery->rider->vehicle_type,
] : null,
])
);
Event::dispatch(new DeliveryStatusChanged($delivery));
}
/**
* Get current tracking data for a delivery
*/
public function getDeliveryTracking(Delivery $delivery): ?array
{
$trackingData = $this->firebase->getDeliveryTracking($delivery->id);
if (! $trackingData) {
return null;
}
// Enhance with interpolated positions for smooth animation
if ($this->config['animation']['enabled'] && isset($trackingData['rider_location'])) {
$trackingData['animation'] = $this->generateAnimationData($trackingData['rider_location']);
}
return $trackingData;
}
/**
* End tracking for completed/cancelled delivery
*/
public function endDeliveryTracking(Delivery $delivery): void
{
// Store final tracking data in history
$this->storeTrackingHistory($delivery);
// Remove from Firebase after a delay (allow final status update to be seen)
dispatch(function () use ($delivery) {
$this->firebase->removeDeliveryTracking($delivery->id);
})->delay(now()->addMinutes(5));
// Remove rider assignment
if ($delivery->rider_id) {
$this->firebase->removeRiderAssignment($delivery->rider_id, $delivery->id);
}
}
/*
|--------------------------------------------------------------------------
| Geofencing
|--------------------------------------------------------------------------
*/
/**
* Check if rider has entered geofence zones
*/
protected function checkGeofenceTriggers(
Delivery $delivery,
float $latitude,
float $longitude
): void {
$geofenceRadius = config('restaurant-delivery.delivery_flow.auto_status_updates.geofence_radius');
// Check proximity to restaurant (for pickup)
if ($delivery->status->value === 'rider_assigned') {
$distanceToRestaurant = $this->calculateDistance(
$latitude,
$longitude,
$delivery->pickup_latitude,
$delivery->pickup_longitude
);
if ($distanceToRestaurant * 1000 <= $geofenceRadius) {
$this->triggerGeofenceEvent($delivery, 'restaurant_arrival');
}
}
// Check proximity to customer (for delivery)
if ($delivery->status->value === 'on_the_way') {
$distanceToCustomer = $this->calculateDistance(
$latitude,
$longitude,
$delivery->drop_latitude,
$delivery->drop_longitude
);
if ($distanceToCustomer * 1000 <= $geofenceRadius) {
$this->triggerGeofenceEvent($delivery, 'customer_arrival');
}
}
}
/**
* Handle geofence trigger event
*/
protected function triggerGeofenceEvent(Delivery $delivery, string $event): void
{
$requireConfirmation = config('restaurant-delivery.delivery_flow.auto_status_updates.arrival_confirmation');
if (! $requireConfirmation) {
switch ($event) {
case 'restaurant_arrival':
$delivery->updateStatus('rider_at_restaurant');
break;
case 'customer_arrival':
$delivery->updateStatus('arrived');
break;
}
} else {
// Send notification to rider to confirm arrival
$this->notifyRiderForConfirmation($delivery, $event);
}
}
/**
* Notify rider to confirm arrival
*/
protected function notifyRiderForConfirmation(Delivery $delivery, string $event): void
{
$message = $event === 'restaurant_arrival'
? 'You have arrived at the restaurant. Please confirm pickup.'
: 'You have arrived at the customer location. Please confirm delivery.';
$this->firebase->sendPushNotification(
$delivery->rider->fcm_token,
'Arrival Detected',
$message,
[
'type' => 'arrival_confirmation',
'delivery_id' => (string) $delivery->id,
'event' => $event,
]
);
}
/*
|--------------------------------------------------------------------------
| Route Management
|--------------------------------------------------------------------------
*/
/**
* Check if rider is off the expected route
*/
protected function isOffRoute(Delivery $delivery, float $latitude, float $longitude): bool
{
if (! config('restaurant-delivery.edge_cases.route_deviation.enabled')) {
return false;
}
$threshold = config('restaurant-delivery.edge_cases.route_deviation.threshold');
$cachedRoute = Cache::get("delivery_route_{$delivery->id}");
if (! $cachedRoute || empty($cachedRoute['points'])) {
return false;
}
// Find minimum distance to any point on the route
$minDistance = PHP_FLOAT_MAX;
foreach ($cachedRoute['points'] as $point) {
$distance = $this->calculateDistance(
$latitude,
$longitude,
$point['lat'],
$point['lng']
) * 1000; // Convert to meters
$minDistance = min($minDistance, $distance);
}
return $minDistance > $threshold;
}
/**
* Recalculate route from current position
*/
protected function recalculateRoute(
Delivery $delivery,
float $currentLat,
float $currentLng
): void {
$destLat = $delivery->status->isPickedUp()
? $delivery->drop_latitude
: $delivery->pickup_latitude;
$destLng = $delivery->status->isPickedUp()
? $delivery->drop_longitude
: $delivery->pickup_longitude;
$route = $this->mapsService->getRoute($currentLat, $currentLng, $destLat, $destLng);
if ($route) {
Cache::put("delivery_route_{$delivery->id}", [
'polyline' => $route['polyline'],
'points' => $route['points'],
'distance' => $route['distance'],
'duration' => $route['duration'],
], config('restaurant-delivery.cache.ttl.route_calculation'));
$this->firebase->updateDeliveryRoute($delivery->id, $route);
}
}
/*
|--------------------------------------------------------------------------
| Animation Support
|--------------------------------------------------------------------------
*/
/**
* Generate interpolation data for smooth animation
*/
protected function generateAnimationData(array $currentLocation): array
{
$lastLocation = Cache::get("last_animation_point_{$currentLocation['delivery_id']}");
if (! $lastLocation) {
Cache::put(
"last_animation_point_{$currentLocation['delivery_id']}",
$currentLocation,
60
);
return ['interpolated' => false];
}
$points = $this->config['animation']['interpolation_points'];
$duration = $this->config['animation']['animation_duration'];
$interpolatedPoints = [];
for ($i = 1; $i <= $points; $i++) {
$ratio = $i / $points;
$interpolatedPoints[] = [
'lat' => $lastLocation['lat'] + ($currentLocation['lat'] - $lastLocation['lat']) * $ratio,
'lng' => $lastLocation['lng'] + ($currentLocation['lng'] - $lastLocation['lng']) * $ratio,
'bearing' => $this->interpolateBearing(
$lastLocation['bearing'] ?? 0,
$currentLocation['bearing'] ?? 0,
$ratio
),
'timestamp' => $lastLocation['timestamp'] + ($duration * $ratio),
];
}
Cache::put(
"last_animation_point_{$currentLocation['delivery_id']}",
$currentLocation,
60
);
return [
'interpolated' => true,
'points' => $interpolatedPoints,
'duration' => $duration,
'easing' => $this->config['animation']['easing'],
];
}
/**
* Interpolate bearing between two angles
*/
protected function interpolateBearing(float $from, float $to, float $ratio): float
{
$diff = $to - $from;
// Handle wrap-around at 360 degrees
if ($diff > 180) {
$diff -= 360;
} elseif ($diff < -180) {
$diff += 360;
}
return fmod($from + $diff * $ratio + 360, 360);
}
/*
|--------------------------------------------------------------------------
| Utility Methods
|--------------------------------------------------------------------------
*/
/**
* Calculate distance between two points using Haversine formula
*/
protected function calculateDistance(
float $lat1,
float $lng1,
float $lat2,
float $lng2
): float {
$earthRadius = config('restaurant-delivery.distance.earth_radius_km');
$dLat = deg2rad($lat2 - $lat1);
$dLng = deg2rad($lng2 - $lng1);
$a = sin($dLat / 2) * sin($dLat / 2) +
cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
sin($dLng / 2) * sin($dLng / 2);
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
return $earthRadius * $c;
}
/**
* Calculate ETA based on distance and speed
*/
protected function calculateETA(float $distanceKm, ?float $speedKmh): int
{
// Use provided speed or assume average
$speed = $speedKmh && $speedKmh > 0 ? $speedKmh : 25; // Default 25 km/h
$hours = $distanceKm / $speed;
return (int) ceil($hours * 60); // Return minutes
}
/**
* Store tracking history for analytics
*/
protected function storeTrackingHistory(Delivery $delivery): void
{
// This would store the complete tracking history for analytics
// Implementation depends on your data storage strategy
}
}

View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Traits;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Modules\Authentication\Models\Restaurant;
/**
* HasRestaurant Trait
*
* Automatically scopes queries by restaurant_id for multi-restaurant setup.
* Ensures that all records are linked to a restaurant and prevents cross-restaurant access.
*/
trait HasRestaurant
{
/**
* Boot the trait and add global scope
*/
public static function bootHasRestaurant(): void
{
// Global scope: filter by current restaurant automatically
static::addGlobalScope('restaurant', function (Builder $builder) {
if (! config('restaurant-delivery.multi_restaurant.enabled', true)) {
return;
}
$restaurantId = static::getCurrentRestaurantId();
if ($restaurantId) {
$builder->where($builder->getModel()->getTable().'.restaurant_id', $restaurantId);
}
});
// Auto-set restaurant_id on creating
static::creating(function ($model) {
if (! config('restaurant-delivery.multi_restaurant.enabled', true)) {
return;
}
if (empty($model->restaurant_id)) {
$model->restaurant_id = static::getCurrentRestaurantId();
}
});
}
/**
* Get current restaurant ID from helper, auth, session, or header
*/
public static function getCurrentRestaurantId(): ?int
{
// 1⃣ Helper function
if (function_exists('getUserRestaurantId')) {
$restaurantId = getUserRestaurantId();
if ($restaurantId) {
return (int) $restaurantId;
}
}
// 2⃣ Authenticated user
if (auth()->check()) {
$user = auth()->user();
if (! empty($user->restaurant_id)) {
return (int) $user->restaurant_id;
}
if (method_exists($user, 'getRestaurantId')) {
return (int) $user->getRestaurantId();
}
if (method_exists($user, 'restaurant') && $user->restaurant) {
return (int) $user->restaurant->id;
}
}
// 3⃣ Session
if (session()->has('restaurant_id')) {
return (int) session('restaurant_id');
}
// 4⃣ Request header (API)
if (request()->hasHeader('X-Restaurant-ID')) {
return (int) request()->header('X-Restaurant-ID');
}
return null;
}
/**
* Scope to remove restaurant filter (admin or global queries)
*/
public function scopeWithoutRestaurant(Builder $query): Builder
{
return $query->withoutGlobalScope('restaurant');
}
/**
* Scope to filter for a specific restaurant
*/
public function scopeForRestaurant(Builder $query, int $restaurantId): Builder
{
return $query->withoutGlobalScope('restaurant')
->where($this->getTable().'.restaurant_id', $restaurantId);
}
/**
* Relationship to the Restaurant model
*/
public function restaurant(): BelongsTo
{
$restaurantModel = config(
'restaurant-delivery.multi_restaurant.restaurant_model',
Restaurant::class
);
return $this->belongsTo($restaurantModel, 'restaurant_id');
}
/**
* Check if this record belongs to the current restaurant
*/
public function belongsToCurrentRestaurant(): bool
{
$currentId = static::getCurrentRestaurantId();
return $currentId && $this->restaurant_id === $currentId;
}
/**
* Enforce that this record belongs to the current restaurant
*
* @throws AuthorizationException
*/
public function ensureRestaurantOwnership(): void
{
if (! $this->belongsToCurrentRestaurant()) {
throw new AuthorizationException('Access denied: record belongs to another restaurant.');
}
}
/**
* Helper to optionally auto-scope queries for a specific restaurant or all
*/
public static function queryForRestaurant(?int $restaurantId = null): Builder
{
$builder = static::query();
if ($restaurantId) {
return $builder->forRestaurant($restaurantId);
}
return $builder;
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Traits;
use Illuminate\Database\Eloquent\Builder;
use Modules\Authentication\Models\Restaurant;
trait HasTenant
{
/**
* Boot the trait and add global scope for restaurant_id
*/
public static function bootHasRestaurant(): void
{
// Global scope: filter by restaurant_id automatically
static::addGlobalScope('restaurant', function (Builder $builder) {
if (! config('restaurant-delivery.multi_restaurant.enabled')) {
return;
}
$restaurantId = static::getCurrentRestaurantId();
if ($restaurantId) {
$builder->where('restaurant_id', $restaurantId);
}
});
// Auto-fill restaurant_id on creating
static::creating(function ($model) {
if (! config('restaurant-delivery.multi_restaurant.enabled')) {
return;
}
$column = config('restaurant-delivery.multi_restaurant.restaurant_column', 'restaurant_id');
if (empty($model->{$column})) {
$model->{$column} = static::getCurrentRestaurantId();
}
});
}
/**
* Get the current restaurant ID from request, auth, or session
*/
public static function getCurrentRestaurantId(): ?int
{
// Option 2: From authenticated user
if (auth()->check() && method_exists(auth()->user(), 'getRestaurantId')) {
return auth()->user()->getRestaurantId();
}
// Option 3: From session
if (session()->has('restaurant_id')) {
return session('restaurant_id');
}
return null;
}
/**
* Remove the global restaurant scope
*/
public function scopeWithoutRestaurant(Builder $query): Builder
{
return $query->withoutGlobalScope('restaurant');
}
/**
* Query explicitly for a specific restaurant
*/
public function scopeForRestaurant(Builder $query, int $restaurantId): Builder
{
return $query->withoutGlobalScope('restaurant')
->where(config('restaurant-delivery.multi_restaurant.restaurant_column', 'restaurant_id'), $restaurantId);
}
/**
* Relationship to Restaurant model
*/
public function restaurant()
{
$restaurantModel = config('restaurant-delivery.multi_restaurant.restaurant_model', Restaurant::class);
return $this->belongsTo($restaurantModel, 'restaurant_id');
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Traits;
use Illuminate\Support\Str;
trait HasUuid
{
public static function bootHasUuid(): void
{
static::creating(function ($model) {
if (empty($model->uuid)) {
$model->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public static function findByUuid(string $uuid): ?static
{
return static::where('uuid', $uuid)->first();
}
public static function findByUuidOrFail(string $uuid): static
{
return static::where('uuid', $uuid)->firstOrFail();
}
}