浏览代码

push ao criar sob medida, enviado para prestadores que podem aceitar a oportunidade, com base na availability

Gustavo Zanatta 1 周之前
父节点
当前提交
b12cc9477d

+ 108 - 0
app/Jobs/NotifyProvidersOfNewOpportunityJob.php

@@ -0,0 +1,108 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Enums\PushNotificationTargetEnum;
+use App\Models\Provider;
+use App\Models\Schedule;
+use App\Models\User;
+use App\Services\CustomScheduleService;
+use Carbon\Carbon;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Log;
+
+class NotifyProvidersOfNewOpportunityJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public int $tries = 3;
+
+    public int $timeout = 60;
+
+    public array $backoff = [10, 60, 300];
+
+    /**
+     * @param  int[]  $scheduleIds
+     */
+    public function __construct(public readonly array $scheduleIds) {}
+
+    public function handle(CustomScheduleService $customScheduleService): void
+    {
+        $scheduleId = $this->scheduleIds[0] ?? null;
+        if (! $scheduleId) {
+            return;
+        }
+        $schedule = Schedule::with(['customSchedule', 'address'])->find($scheduleId);
+
+        if (! $this->shouldNotify($schedule)) {
+            return;
+        }
+
+        $providerIds = $customScheduleService->getAvailableProvidersForOpportunity($schedule);
+
+        if ($providerIds->isEmpty()) {
+            Log::info('Nenhum prestador disponivel para a nova oportunidade', [
+                'schedule_id' => $schedule->id,
+            ]);
+
+            return;
+        }
+
+        $userIds = User::query()
+            ->whereIn('id', Provider::whereIn('id', $providerIds)->pluck('user_id'))
+            ->where('push_notifications_enabled', true)
+            ->whereHas('deviceTokens', function ($query) {
+                $query->where('app_type', PushNotificationTargetEnum::PRESTADOR->value)
+                    ->where('active', true);
+            })
+            ->pluck('id');
+
+        if ($userIds->isEmpty()) {
+            return;
+        }
+
+        $district  = $schedule->address?->district;
+        $dateLabel = Carbon::parse($schedule->date)->format('d/m');
+        $quantity  = count($this->scheduleIds);
+        $userIds->chunk(50)->each(function ($chunk) use ($schedule, $district, $dateLabel, $quantity) {
+            foreach ($chunk as $userId) {
+                SendOpportunityPushJob::dispatch(
+                    $userId,
+                    $schedule->id,
+                    $district,
+                    $dateLabel,
+                    $quantity,
+                );
+            }
+        });
+    }
+
+    public function failed(\Throwable $exception): void
+    {
+        Log::error('Falha ao notificar prestadores de nova oportunidade', [
+            'schedule_ids' => $this->scheduleIds,
+            'error'        => $exception->getMessage(),
+        ]);
+    }
+
+    private function shouldNotify(?Schedule $schedule): bool
+    {
+        if (! $schedule || ! $schedule->customSchedule) {
+            return false;
+        }
+
+        if ($schedule->schedule_type !== 'custom' || $schedule->status !== 'pending') {
+            return false;
+        }
+
+        if ($schedule->provider_id !== null) {
+            return false;
+        }
+
+        return ! Carbon::parse($schedule->date)->lt(today());
+    }
+}

+ 54 - 0
app/Jobs/SendOpportunityPushJob.php

@@ -0,0 +1,54 @@
+<?php
+
+namespace App\Jobs;
+
+use App\Models\User;
+use App\Notifications\Push\Prestador\Agendamento\NovaOportunidadePush;
+use App\Services\PushNotificationService;
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Log;
+
+class SendOpportunityPushJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public int $tries = 2;
+
+    public int $timeout = 30;
+
+    public array $backoff = [15, 60];
+
+    public function __construct(
+        public readonly int $userId,
+        public readonly int $scheduleId,
+        public readonly ?string $district = null,
+        public readonly ?string $dateLabel = null,
+        public readonly int $quantity = 1,
+    ) {}
+
+    public function handle(PushNotificationService $pushNotificationService): void
+    {
+        $user = User::find($this->userId);
+      Log::info('----------entrou disparo-------------');
+        if (! $user) {
+            return;
+        }
+        Log::info('tem user');
+        try {
+            $pushNotificationService->sendToUser(
+                $user,
+                new NovaOportunidadePush($this->district, $this->dateLabel, $this->quantity)
+            );
+        } catch (\Throwable $exception) {
+            Log::error('Falha ao enviar push de nova oportunidade', [
+                'user_id'     => $this->userId,
+                'schedule_id' => $this->scheduleId,
+                'error'       => $exception->getMessage(),
+            ]);
+        }
+    }
+}

+ 64 - 0
app/Notifications/Push/Prestador/Agendamento/NovaOportunidadePush.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Notifications\Push\Prestador\Agendamento;
+
+use App\Enums\PushNotificationCategoryEnum;
+use App\Enums\PushNotificationTargetEnum;
+use App\Notifications\Push\BasePushNotification;
+use Illuminate\Database\Eloquent\Collection;
+
+class NovaOportunidadePush extends BasePushNotification
+{
+    public function __construct(
+        private readonly ?string $district = null,
+        private readonly ?string $dateLabel = null,
+        private readonly int $quantity = 1,
+    ) {}
+
+    public function label(): string
+    {
+        return 'provider_new_custom_opportunity';
+    }
+
+    public function title(): string
+    {
+        return 'Nova oportunidade disponível';
+    }
+
+    public function body(): string
+    {
+        $where = $this->district ? " em {$this->district}" : '';
+        $when  = $this->dateLabel ? " para {$this->dateLabel}" : '';
+
+        if ($where === '' && $when === '') {
+            return 'Um cliente publicou um pedido sob medida perto de você. Envie sua proposta.';
+        }
+
+        return "Novo pedido sob medida{$where}{$when}. Envie sua proposta.";
+    }
+
+    public function target(): PushNotificationTargetEnum
+    {
+        return PushNotificationTargetEnum::PRESTADOR;
+    }
+
+    public function category(): PushNotificationCategoryEnum
+    {
+        return PushNotificationCategoryEnum::AGENDA;
+    }
+
+    public function eligibleUsers(): Collection
+    {
+        return new Collection();
+    }
+
+    public function notificationCooldownDays(): int
+    {
+        return 0;
+    }
+
+    public function categoryCooldownDays(): int
+    {
+        return 0;
+    }
+}

+ 9 - 11
app/Rules/ScheduleBusinessRules.php

@@ -2,6 +2,7 @@
 
 namespace App\Rules;
 
+use App\Enums\BlockedPeriodEnum;
 use App\Models\ClientProviderBlock;
 use App\Models\Provider;
 use App\Models\ProviderBlockedDay;
@@ -105,19 +106,16 @@ class ScheduleBusinessRules
     public static function validateBlockedDay($provider_id, $date_ymd, $start_time, $end_time)
     {
         $blockedDay = ProviderBlockedDay::where('provider_id', $provider_id)
-            ->where('date', $date_ymd)
+            ->whereDate('date', $date_ymd)
             ->where(function ($query) use ($start_time, $end_time) {
-                $query->where('period', 'full')
+                $query->where('period', BlockedPeriodEnum::ALL->value)
                     ->orWhere(function ($q) use ($start_time, $end_time) {
-                        $q->where('period', 'partial')
-                            ->where(function ($q2) use ($start_time, $end_time) {
-                                $q2->whereBetween('init_hour', [$start_time, $end_time])
-                                    ->orWhereBetween('end_hour', [$start_time, $end_time])
-                                    ->orWhere(function ($q3) use ($start_time, $end_time) {
-                                        $q3->where('init_hour', '<=', $start_time)
-                                            ->where('end_hour', '>=', $end_time);
-                                    });
-                            });
+                        $q->whereIn('period', [
+                            BlockedPeriodEnum::MORNING->value,
+                            BlockedPeriodEnum::AFTERNOON->value,
+                        ])
+                            ->where('init_hour', '<', $end_time)
+                            ->where('end_hour', '>', $start_time);
                     });
             })
             ->first();

+ 153 - 0
app/Services/CustomScheduleService.php

@@ -5,7 +5,9 @@ namespace App\Services;
 use App\Broadcasting\RealtimeEvent;
 use App\Broadcasting\RealtimeRoom;
 use App\Broadcasting\RealtimeService;
+use App\Enums\ApprovalStatusEnum;
 use App\Enums\NotificationTypeEnum;
+use App\Jobs\NotifyProvidersOfNewOpportunityJob;
 use App\Models\Address;
 use App\Models\CustomSchedule;
 use App\Models\CustomScheduleSpeciality;
@@ -23,6 +25,7 @@ use App\Rules\ScheduleBusinessRules;
 use App\Services\NotificationService;
 use App\Services\DistanceService;
 use Carbon\Carbon;
+use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Storage;
@@ -123,6 +126,8 @@ class CustomScheduleService
 
             DB::commit();
 
+            $this->dispatchOpportunityNotification($createdCustomSchedules);
+
             return $createdCustomSchedules;
         } catch (\Exception $e) {
             DB::rollBack();
@@ -371,6 +376,28 @@ class CustomScheduleService
         return $availableOpportunities->values();
     }
 
+    /**
+     * @return Collection<int, int>
+     */
+    public function getAvailableProvidersForOpportunity(Schedule $schedule): Collection
+    {
+        $schedule->loadMissing(['customSchedule', 'address']);
+
+        $candidateIds = $this->getCandidateProviderIdsForOpportunity($schedule);
+
+        if ($candidateIds->isEmpty()) {
+            return $candidateIds;
+        }
+
+        return $candidateIds->filter(function ($providerId) use ($schedule) {
+            try {
+                return $this->checkProviderAvailability($providerId, $schedule);
+            } catch (\Exception $e) {
+                return false;
+            }
+        })->values();
+    }
+
     public function getOpportunityProposals($scheduleId)
     {
         return ScheduleProposal::with(['provider.user'])
@@ -875,6 +902,132 @@ class CustomScheduleService
 
     //
 
+    /**
+     * @return Collection<int, int>
+     */
+    private function getCandidateProviderIdsForOpportunity(Schedule $schedule): Collection
+    {
+        $address = Address::find($schedule->address_id);
+
+        $cityId = $address?->city_id;
+        $lat    = $address?->latitude !== null ? (float) $address->latitude : null;
+        $lng    = $address?->longitude !== null ? (float) $address->longitude : null;
+
+        if ($cityId === null && ($lat === null || $lng === null)) {
+            Log::warning('Oportunidade sem geolocalizacao; nenhum prestador elegivel', [
+                'schedule_id' => $schedule->id,
+                'address_id'  => $schedule->address_id,
+            ]);
+
+            return collect();
+        }
+
+        $periodType = (string) $schedule->period_type;
+
+        $factor = match ($periodType) {
+            '2'     => 0.30,
+            '4'     => 0.55,
+            '6'     => 0.85,
+            '8'     => 1.00,
+            default => null,
+        };
+
+        if ($factor === null) {
+            return collect();
+        }
+
+        $priceColumn = "providers.daily_price_{$periodType}h";
+
+        $date      = Carbon::parse($schedule->date);
+        $dayOfWeek = $date->dayOfWeek;
+
+        $period = $schedule->start_time < '13:00:00' ? 'morning' : 'afternoon';
+
+        $minProportional = (float) $schedule->customSchedule->min_price * $factor;
+        $maxProportional = (float) $schedule->customSchedule->max_price * $factor;
+
+        $providerAddressSubquery = DB::raw("
+            (
+                SELECT DISTINCT ON (source_id)
+                    *
+                FROM addresses
+                WHERE
+                    source = 'provider'
+                    AND deleted_at IS NULL
+                ORDER BY
+                    source_id,
+                    (latitude IS NOT NULL AND longitude IS NOT NULL) DESC,
+                    is_primary DESC,
+                    id DESC
+            ) AS provider_address
+        ");
+
+        return Provider::query()
+            ->join($providerAddressSubquery, 'provider_address.source_id', '=', 'providers.id')
+            ->where('providers.approval_status', ApprovalStatusEnum::ACCEPTED->value)
+
+            ->where(function ($query) use ($cityId, $lat, $lng) {
+                if ($cityId !== null) {
+                    $query->orWhere('provider_address.city_id', $cityId);
+                }
+
+                if ($lat !== null && $lng !== null) {
+                    $query->orWhereRaw(
+                        DistanceService::withinRadiusSqlCondition(
+                            $lat,
+                            $lng,
+                            self::NEARBY_RADIUS_KM,
+                            'provider_address.latitude',
+                            'provider_address.longitude',
+                        )
+                    );
+                }
+            })
+
+            ->whereExists(function ($query) use ($dayOfWeek, $period) {
+                $query->select(DB::raw(1))
+                    ->from('provider_working_days')
+                    ->whereColumn('provider_working_days.provider_id', 'providers.id')
+                    ->where('provider_working_days.day', $dayOfWeek)
+                    ->where('provider_working_days.period', $period)
+                    ->whereNull('provider_working_days.deleted_at');
+            })
+
+            ->whereNotNull($priceColumn)
+            ->whereBetween($priceColumn, [$minProportional, $maxProportional])
+
+            ->whereNotIn(
+                'providers.id',
+                ScheduleBusinessRules::getBlockedProviderIdsForClient($schedule->client_id)
+            )
+
+            ->pluck('providers.id');
+    }
+
+    private function dispatchOpportunityNotification(array $customSchedules): void
+    {
+        $scheduleIds = [];
+
+        try {
+            $scheduleIds = collect($customSchedules)
+                ->pluck('schedule_id')
+                ->filter()
+                ->values()
+                ->all();
+
+            if (empty($scheduleIds)) {
+                return;
+            }
+
+            NotifyProvidersOfNewOpportunityJob::dispatch($scheduleIds);
+        } catch (\Throwable $exception) {
+            Log::error('Falha ao enfileirar notificacao de nova oportunidade', [
+                'schedule_ids' => $scheduleIds,
+                'error'        => $exception->getMessage(),
+            ]);
+        }
+    }
+
     private function checkProviderAvailability($providerId, $schedule)
     {
         $client_id   = $schedule->client_id;