migrate to gtea from bistbucket
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user