403 lines
12 KiB
PHP
403 lines
12 KiB
PHP
|
|
<?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(),
|
||
|
|
];
|
||
|
|
}
|
||
|
|
}
|