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